21/8/5 读书笔记 数据库并发控制 语言在解决问题上的演化
21/8/5 读书笔记
数据库系统概论 并发控制
数据库系统中的并发控制也是事务处理技术中重要的一部分,用于保证多个事务之间能正确地并发执行。我们认为数据库并发控制需要保持事务的隔离性和一致性。而事实上我们也需要注意到,有时候数据库应用并不严格要求绝对的隔离性和一致性,某些应用对脏数据具有鲁棒性(比如大数据统计),因此我们需要在效率和安全之间进行trade off。
并发下可能存在的问题
理论上,事务的并发可能带来三种一致性问题:
- 丢失修改:事务\(T_1\)与\(T_2\)同时对A进行修改,由于涉及并发导致读写顺序问题,可能导致两个事务提交的结果丢失了另一方的修改。比如账户中有100元,此时两个银行同时取100元,均先读得余额为100,随后再都写入0,导致最后余额为0但是取出了200元。
- 不可重复读:事务\(T_1\)读取到A后再次读取A,两次读取结果不一致,因为事务\(T_2\)在两次读取期间对A进行了修改。这违反了事务的隔离性,即事务\(T_2\)不应该影响到事务\(T_1\)的执行。
- 读“脏”数据。事务\(T_1\)对A进行了修改后,事务\(T_2\)读取了A,但是随后事务\(T_1\)进行了事务撤销,导致事务\(T_2\)读取的是本应被撤销的A的结果。如果将事务的撤销也算作事务的本质内容,那么实际上这个问题和不可重复读一致,即事务\(T_1\)的撤销导致了事务\(T_2\)的隔离性被破坏。
可串行性
事务之间的执行顺序可以影响执行结果。我们认为完全串行化(serialized)地按顺序执行事务所得到的结果是正确的,因此认为某种并发执行策略如果能得到与完全串行化一致的结果,则该并发执行策略是可串行化调度(serializable)。
封锁 locking
众多的数据库系统都使用加锁的方式来实现并发控制,这种技术称为封锁技术。数据库系统中的锁与我们在OS和OO课上使用的锁相比,抽象程度更高,所需要实现的机制也更为复杂。
基本的封锁类型包括:
- 排他锁(写锁,X锁):如果事务T对A加上X锁,那么只允许T对A进行修改和读取。其他事务不能在A上加任何锁,也不能对A进行任何操作。
- 共享锁(读锁,S锁):如果事务T对A加上S锁,那么T可以读取A但是不能修改A。同时其他事务只能对A加上S锁而不能加X锁,之后对A只能进行读取,直到T释放A上的S锁。
事实上,我们发现一个事务上只能允许来自不同事务的S锁相容。
封锁协议
然而,我们认为事务对数据库对象的操纵操作和上锁操作是独立的,因此需要利用一定的规则来规定操纵操作与上锁操作的对应关系,这种对应关系就是封锁协议(locking protocol),包括何时申请和释放锁等。不同的封锁协议对应了不同的一致性要求,通常来说,一致性要求越高,效率越差。书中介绍了三级封锁协议:
- 一级封锁协议:仅考虑X锁,认为事务在修改A前需要对A加上X锁,直到事务结束再释放。由于不对读取操作和S锁进行考虑,使得不加S锁就能读,一级封锁协议无法解决可重复读和“脏”数据的问题。
- 二级封锁协议:在一级协议的基础上要求事务在读取A之前需要加S锁。由于事务读取完成后可以立即释放S锁,所以在事务\(T_1\)中如果有多次读取,且事务\(T_2\)对象的修改恰处第一次读取结束和第二次读取开始之间时,依旧不能保证可重复读。
- 三级封锁协议:在二级协议的基础上,要求S锁在事务结束时才释放。解决了可重复读的问题。
注:事务结束表示事务的撤销也已经完成
两段锁协议
两段锁协议是为了使得并发事务实现可串行化,其将事务分为两个阶段:
- 扩展阶段:事务可以申请获取任何类型的锁
- 收缩阶段:事务只能释放封锁,而不能再申请锁
二段锁协议实际上限制了事务不能在释放封锁后再次申请封锁。考虑我们在OS中学习的同时申请和释放所有锁来避免死锁的方法,二段锁协议与之对比,并没有要求必须“同时”,但是申请阶段与释放阶段必须完全分离。
注意,两段锁协议 是 可串行化 的 充分条件。
封锁的粒度问题
封锁的粒度(granularity),实际上对应我们对数据库对象加锁的规模问题。我们可以对一个元组设置一个锁,也可以对一个表设置一个锁。对于数据库系统来说,封锁的粒度越小,锁越多,事务间并发程度越高,但是平时维护锁的开销越大。
为了效率,通常采用多粒度封锁,即对数据库对象进行加锁时支持不同的封锁粒度。采用多粒度树来描述,即每个子结点在数据库系统中属于其父结点,每个父结点的封锁粒度大于每个子结点,对一个父结点施加的封锁将同样加在其子结点上。我们称直接施加的封锁是显式封锁,而从父结点继承来的封锁是隐式封锁。
当对某个数据库对象进行加锁,需要对其所有父结点进行判断,是否有继承自父结点的隐式封锁与当前封锁冲突;同时对所有子结点进行判断,是否有施加在子结点上的显式封锁与当前封锁冲突。
我们引入意向锁概念,当对一个结点施加意向锁,表示该结点的下层结点被施加了某种锁。意向锁分为:
- IS锁:表示该结点的下层结点拟施加S锁,即事务拟读取该对象的某个子对象。
- IX锁:表示该结点的下层结点拟施加X锁,即事务拟修改该对象的某个子对象。
- SIX锁:表示该事务要读取当前对象,并拟对当前对象中的部分子对象进行修改。
在多粒度封锁中,意向锁能够避免对下层结点进行遍历,从而优化效率。我们认为锁的强度满足X > SIX > S = IX > IS,事务申请封锁时能够以强锁替换弱锁。
函数式编程思想 语言的演化
我们探讨语言的演化,不是探讨语言是如何引入各种新的概念,而是探讨语言是如何解决特定的问题。如果我们将语言的演化类比生物的进化,那么问题就好比是环境,而语言中的各种概念就好比生物的性状,生物为了适应环境而演化出新的性状,而语言为了解决问题而演化出新的概念。问题是语言演化的根本,而解决问题的思想才是语言演化的本质,语言引入的各种新的概念只是表象。
本章探讨了函数式编程是如何看待与解决问题的
函数式编程看待问题的思路:100个函数搭配一种数据结构
首先我们先思考一下传统的面向对象编程(OOP也成传统了……)是如何看待问题的。在OOP中,问题的建模中心在于类以及类之间的通信机制,因此会出现设计模式,每种模式描述了一类问题的解决思路,包括如何设计类与相关的通信机制。在面对一个新的问题时,开发者被鼓励去套这些现成的设计模式,让新的问题去契合那些业已总结出经验的经典模式。而这些模式的一大特征(也可以说是OOP的一大特征),就是引入新的数据结构(也就是类)并在新的数据结构上建立新的操作(也就是方法)。因此在OOP中在面对问题时的重用,更多地考虑的是类之间通信结构的重用(也就是设计模式的重用),而不是类本身的重用。
举个例子,我们考虑学生成绩管理系统时,设计了学生和老师两个类以及相关通信机制,而考虑员工绩效管理系统时,我们又设计了员工和老板两个类以及相关机制。这两个系统实际遵循的是同一种设计模式,而我们却设计了两组不同的类实现。这也就是说我们虽然OOP中设计模式可以跨问题重用,但是针对一个问题场景所设计的类与方法不行。
函数式编程则认为应该用更少的数据结构搭配更多的方法,它采用很少的一组关键数据结构,比如list、set、map,搭配filter、map等函数,实现一套运转机构,同时使得这个机构对我们针对问题定制的函数保持开放,比如我们在不同场景下设计的比较函数与映射函数。由此,函数式编程将重用的粒度降低到这一套运转机构的级别,使得这一套运转机构在不同的问题场景下均能使用,改变的只是我们使用这套机构的方式以及传入的高阶函数。
因此在函数式编程的背景下,我们会发现同一个函数会在多个问题场景下出现。也正是因为如此,对这些高度可重用的函数进行优化的收益远远大于对OOP场景下某个类的优化。
函数式编程中的语言:让语言迎合问题
对于常年浸淫在低可塑性的语言中的开发者来说,解决一个问题就是将这个问题根据语言的规则而用选用的编程语言去进行描述,无论这个描述多么地冗杂。而对于一些高可塑性的语言,开发者可以让语言去适应问题,使得语言能够更加方便简洁地描述一个问题。在这些高可塑性语言的背景下,开发者与语言的设计者之间并没有明显的界限。
为了更加深刻地了解这个概念,我们可以考虑通用编程语言(General Purpose Language, GPL)和领域专用语言(Domain-Specific Language, DSL)的差异。GPL的典型有python、java、c等,它们具有一套图灵完备的逻辑,因此可以对任何问题进行描述。而DSL的典型有正则表达式Regex、HTMl、CSS,它们不能解决通用的问题,只专注于解决某个领域的问题,并且能够取得更高的效率。你可以想象一下用C语言来描述一个网页与其上元素的样式,那一定非常复杂与难以理解!而采用HTML与CSS就能简洁高效地完成这个任务。内部DSL(Embedded DSL)是DSL的一种,其寄生于GPL之上,而通过宿主语言的可塑性提供出一套新的风格,来更加高效地解决某一领域的问题。
让语言迎合问题,就是让语言能够在开发者手中改变自身的风格来变得越来越倾向于DSL,从而在特定的问题上变得简洁高效。这当然不是函数式编程的专属特点,实际上,任何支持运算符重载的语言都可以说具有一定可塑性,只是这种让语言迎合问题的思想更容易诞生具有函数式风格的代码。
这是因为大部分情况下函数与运算符的界限并不清晰,而当我们将函数作为一等公民看待时,我们更倾向于将它们融入语言的风格中,包括自行创建新的关键字与运算符。比如Scala中就允许我们在函数调用时将.和()省略,这样使得我们的代码呈现出这种样式:
e1.add(e2) // 函数调用
e1 add e2 // 像是添加了新的运算符
函数式编程的数据结构:维护引用透明性
在大多数语言中,函数会抛出异常。但是“抛出异常”这一行为本身就是函数的副作用,因为它会作用于该函数所处的上下文环境。我们应当避免一个函数对其作用域之外的上下文造成影响。
引用的透明性,指调用者并不在乎其访问的对象是一个真实的值或是一个具有返回值的函数。在调用者看来,一个具有返回值的函数等同于其返回值。当一个函数会抛出异常时,意味着其不一定能返回值,也就是说调用者并不能将其与一个真实的值等同看待,失去了引用的透明性。引用的透明性是函数式编程非常看重的特点。
因此,函数式的错误处理的基本思想就是将异常装进返回值,这样返回值就能表示一个异常的发生。但是对于调用者来说,其通常并不能从一个返回值中得出该返回值是异常还是正常返回值,也不应该这样做,因为为每个异常单独设定一个返回值还需要考虑数据类型,导致不同的异常对应的返回值的数据类型可能都会不一致,这会使得管理起来非常麻烦。因此函数式编程语言会设计新的通用的数据结构作为可能出现异常的函数的返回值,这个数据结构中既包含了异常又包含了正常返回值,并且以通用的方式向调用者进行展示。
Either类
Either类是一种不相交联合体(disjoint union,可以联想C语言中的union),其具有左值和右值,并且只会同时持有二者中的一个。通常来说,左值中存储异常,而右值中存储返回值。使用时如果发生异常就将异常放入左值,否则将返回值放入右值。调用者只需要查看返回的Either类对象就能知道被调用函数是否正确返回,并且获取异常或返回值。
Either类将异常放入左值暂时存储起来,而避免了以往结构化编程中直接抛出异常(使得异常对象立即被生成并抛出),因此可以继续进行包装,实现对异常的缓求值。在语言的加持下,Either类能够表达函数异常返回的语义,并只在调用者试图获取异常时才会对异常对象进行求值。
Option类
Option类是一种相对于Either更简化的数据结构,在异常发生时其值none,正常返回时其值为some并存储了具体的返回值。调用者只需要先查看返回的Option类对象的值是否为none,如果不是则取出返回值即可。Option类并不能指明具体的异常类型,只能表示函数异常返回。
相较于书中,笔者在这篇笔记中没有提及分发机制的再思考和运算符重载两节。这是因为:
- 文中提到的分发机制本质上是语言中“动态地选择行为”的机制,但是笔者并没有感觉相较于OOP下的工厂模式具有明显优势(除了看起来简洁不少),反而在文中基于Clojure的分发机制进行介绍时有点难于理解,这也可能是因为笔者并没有Clojure的语言基础。
- 运算符重载本身可能带来一些复杂性,滥用运算符重载在某些情况下反而不能实现函数式编程所追求的简洁,同时鉴于运算符重载既是一个普遍知晓的概念,又在语言迎合问题一节中提及了函数与运算符的关系,故不额外着墨。

浙公网安备 33010602011771号