2.5 PostgreSQL中的锁
即使在数据库之外,锁也是非常重要的概念。例如在多线程并发的情况下,需要使用系统锁来保证临界区的原子性,不同的线程在对临界区进行操作时,都要先获得一个互斥锁,然后才能对临界区做读写操作;而其他线程要访问临界区,就只能等待已经获得锁的线程释放锁,然后一哄而上,开始竞争锁资源。
数据库中的锁和普通应用程序中的锁在本质上也没有区别,数据库内核通过自己控制、调度锁,来解放应用程序的生产力。DBA们在写SQL脚本时,就可以放心地写各种SQL脚本,而不用考虑读写数据时的并发问题。
在PostgreSQL数据库中,锁可以分为以下3个层次。
• 自旋锁(Spin Lock):是一种和硬件结合的互斥锁。它借用了硬件提供的原子操作的原语来对一些共享变量进行封锁,通常适用于临界区比较小的情况。
• 轻量锁(Lightweight Lock):在PostgreSQL数据库中,大量使用共享内存来保存数据结构。不同的进程需要对这些数据结构频繁地进行读写操作,轻量锁负责保护这些共享内存中的数据结构。轻量锁是一种读写锁,有共享和排他两种模式。
• 常规锁(Regular Lock):对数据库对象加锁,PostgreSQL的两阶段锁就是借助常规锁实现的。根据封锁对象的不同,它又分成了多种不同的粒度,例如可以对表、页面、元组、事务ID等分别加锁。以最常用的表锁为例,当不同的事务操作一个表时,会尝试通过表的Oid来构造Lock Tag。这样每个数据库对象都会有一个唯一标识,然后根据这个唯一标识到锁表中申请锁。PostgreSQL数据库将常规锁分成了8个等级,不同的操作需要使用不同等级的常规锁。
在早期的PostgreSQL数据库版本中,轻量锁是借助自旋锁实现的,自旋锁负责保护轻量锁中的共享变量、引用计数、等待队列等。随着PostgreSQL数据库不断进行性能优化,PostgreSQL社区开发者发现用自旋锁这种互斥锁直接保护轻量锁的方法会导致性能劣化,因此对轻量锁的实现进行了优化。目前轻量锁也借助原子操作的原语(封装之后的)进行“无锁”保护,这样会降低“锁”的粒度,提高并发的性能。
如图2-11所示,常规锁是借助了轻量锁、自旋锁来实现的,它与轻量锁、自旋锁除了层次不同,还有一个重要的区别,就是常规锁有死锁检测机制,而轻量锁、自旋锁主要保护的是数据库内核中的数据对象,由数据库内核的开发人员进行锁的申请和释放。因此,需要在使用它们时注意这些锁的使用“顺序”,确保不会发生死锁。
图2-11 PostgreSQL中的锁
在数据库对象中,最重要的对象就是表、页面和元组。因此从加锁对象的角度来看,锁还可以分为3种类型,如图2-12所示。
图2-12 表锁、页面锁、行锁示意图
• 表锁:对表进行加锁,会依照操作的不同加不同等级的锁。
• 页面锁:PostgreSQL数据库的存储是基于页面的,在对页面进行操作时也需要对这些页面加锁。
• 行锁:又称为元组锁,PostgreSQL采用常规锁和xmax相结合的方式来对行进行加锁。
通常而言,用户不能直接使用这些锁,但是PostgreSQL提供了SQL命令或者函数来帮助用户实现对数据库对象加锁。例如可以通过LOCK命令直接对表进行加锁,再如可以利用咨询锁(Advisory Lock)对数据库对象进行加锁,咨询锁的持锁时间不受事务的影响,它是可以跨事务的。
2.5.1 自旋锁
顾名思义,自旋锁就是不停旋转的锁,如果一个进程要访问临界区,就必须先获得锁资源。如果不能获得锁资源,就一直在原地忙等,直到获取锁资源。在生活中,很多人常常采用自旋锁模式来获得一些资源。
显然,这种自旋会对CPU资源造成浪费,但如果要保护的临界区非常“小”,持有锁的进程很快就会释放锁资源,那就无须我们等太久。也就是说,自旋等待虽然浪费CPU资源,但是它比释放CPU资源带来的切换上下文的消耗要小,这时候自旋就是值得的。总之,自旋锁有以下两个特点。
• 不想释放CPU资源。
• 保护的临界区“小”。
PostgreSQL通常用自旋锁保护一些比较小的临界区,还用来保护轻量锁中的变量。我们可以用下面的伪代码来描述一个自旋锁。
上面的代码是无法作为锁来实现的,因为上面的操作缺乏原子性。比如对“lock对应的值是否为0(*lock != 0)”的判断并不是一个原子操作,它通过movq -0x8(%rbp), %rax和cmpl $0x0, (%rax)两个指令来实现。
如果有两个线程同时通过spinlock_acquire获得锁,那么可能会出现图2-13中的情况,两个线程都获得了锁。
图2-13 多线程下非原子操作带来的问题
C语言的一条语句可能对应几条汇编指令,在执行期间无法保证它的原子性,要想通过高级语言来实现临界区的同步功能,则需要使用一些比较精妙的算法(如Peterson算法),但这不是本章的重点。 PostgreSQL借助TAS(TEST-AND-SET)来实现自旋锁,它的流程是向内存变量写入1,然后返回内存变量的原值。
实际上,还有另一种模型——COMPARE-AND-SWAP,简称CAS,它的流程是比较锁中的值和期望值。如果锁中的值和期望值相同,则设置为新值,返回true;否则不设置新值,返回false。
在X86架构的CPU中,分别提供了XCHG指令和CMPXCHG指令来实现TAS和CAS操作,PostgreSQL采用的是基于XCHG的TAS来实现的自旋锁。
在不同的CPU型号下,使用的方法也略有不同。例如针对一些型号的CPU使用的就是TTAS(TEST AND TEST-AND-SET),在进行XCHG指令之前,先对lock进行“粗略”的判断,这个判断不会锁住总线,只是先验证一下是否有其他进程正在持有锁(lock中的值是1代表锁已经被持有)。只有在其他进程没有持有锁的情况下,才有必要使用XCHG做交换。
从上面的汇编代码可以看出,CMP指令和下面的XCHG不能组成一个原子操作,比如一个进程B已经持有了锁,进程A做了CMP指令,也发现有其他进程已经占了锁资源,于是它就会通过JNE指令跳过XCHG指令,但是很可能在CMP指令之后,进程B就立即释放了锁资源,如图2-14所示。
图2-14 TTAS在实现中存在的误判
XCHG指令会尝试交换两个操作数,如果想要获得一个锁,即我们打算在“锁对应的值是0的情况下把锁的值设置为1”,同时我们可以用0和1来代表TEST-AND-SET是否成功,那么将上面的汇编代码翻译成高级语言,如下所示。
在XCHG之前做一个CMP检测,是因为我们认为XCHG为了实现原子操作,在指令执行期间会锁住总线,这会降低CPU的使用效率,以及带来缓存失效问题。虽然这看似很有道理,但是在有些CPU型号下也可能有问题,比如在老版本的PostgreSQL的注释中对这种情况做了解释。
目前大部分情况都是在TAS函数中只做XCHG,在汇编代码之外通过TAS_SPIN来做CMP这种初步判断。
借助TAS_SPIN函数(或者说宏),就可以实现一个自旋锁了。
另外,PostgreSQL在原地自旋模式下还做了一些优化。
首先,适当的偷懒。这种自旋的目的是不放弃CPU,但也没必要不停地旋。我们可以用空指令来适当地让CPU歇一会儿,而不必过度旋转,旋转太频繁了费电。
其次,在哪里旋转,就在哪里“休息一下”。也就是说,如果尝试了很多次TAS之后仍然无法获得锁资源,那么就进入sleep,也就是交出CPU资源,sleep的时间是随机的,但不能超出上下界。
再次,如果旋转了很长时间,仍然没有办法获得锁资源,就进入自杀模式。因为我们假设自旋锁的临界区都很短,如果很长时间还获取不到锁资源,那么就可能出问题了。
需要注意的是,虽然目前单核的CPU已经很少了,但还是需要注意自旋锁在单核CPU上是不好的,因为它会一直占着CPU不放手。在单核的CPU下,其他进程也没有办法释放锁资源。
我们把目光过多地关注到锁的获取上,但是锁的释放也并非一帆风顺,也许我们会认为当操作结束时,进程用一条普通的move指令将lock的值重新设置为0。但是目前很多CPU实现了乱序执行,这时候如果把释放锁写成只是简单设置lock的值,由于乱序执行的作用,有些临界区中的指令可能会在lock释放后才执行,这就相当于两个进程共同进入了临界区,会导致不一致的问题,我们可以采用内存屏障的方式来保证锁释放的有序性。
2.5.2 轻量锁
自旋锁是一种互斥锁,对于临界区比较小的情况,它能够保证多个进程对临界区的访问是互斥的。但是在PostgreSQL数据库中,多个进程会大量读写共享内存,尤其存在大量的读操作。这些读操作不冲突,因为它们不会修改数据,因此PostgreSQL实现了轻量锁来解决这个问题。
轻量锁有共享和排他两种模式,如果要读共享内存中的内容,可以给要读取的部分加上共享锁,这样就可以和其他读操作并发执行,同时保证不会有人修改这部分共享内存。当要修改共享内存时,需要加上排他锁。排他锁和共享锁是互斥的,需要对象上所有的共享锁都被释放之后,才能尝试给对象加排他锁。轻量锁的相容性矩阵如表2-1所示。
表2-1 轻量锁的相容性矩阵
对于数据库的开发者而言,轻量锁有两种使用方法,一种是统一保存的Individual LWLocks,另一种是Builtin Tranches。实际上它们只是形式上的不同,本质上没有区别。
目前PostgreSQL保存了44种Individual LWLocks,它们都被保存在src/backend/storage/lmgr/lwlocknames.txt文件中,在编译PostgreSQL的过程中会通过这个文件生成一个新的头文件:src/backend/storage/lmgr/lwlocknames.h,里面罗列了所有内置的Individual LWLocks。
从定义可以看出,Individual LWLocks被保存在MainLWLockArray数组中,每种Individual LWLocks都有自己固定要保护的对象。Individual LWLocks的使用方式如下。
在Individual LWLocks中,每个LWLock都对应一个Tranche ID,它具有全局唯一性。也就是说,在MainLWLockArray数组中的前(NUM_INDIVIDUAL_LWLOCKS - 1)个都是Individual LWLocks。
与Individual LWLocks不同,每个Builtin Tranche可能对应多个LWLock,它代表的是一组LWLocks,这组LWLocks虽然各自封锁各自的内容,但是它们的功能相同。Builtin Tranches包含如下类型。
这些Builtin Tranches对应的锁一部分被保存在MainLWLockArray中,另一部分被保存在使用它们的结构体中,如下所示。
无论Individual LWLocks还是Builtin Tranches,它们都被保存在共享内存中,只是保存的位置和方式略有不同。
为了方便用户在Extension模块中使用轻量锁,轻量锁模块还提供了两种扩展方法,如图2-15所示。
方法一:通过RequestNamedLWLockTranche函数和GetNamedLWLockTranche函数来实现。其中,RequestNamedLWLockTranche函数负责注册Tranche的名字及自己所需的轻量锁的数量,GetNamedLWLockTranche函数负责根据Tranche Name来获得对应的轻量锁。每个Tranche都有自己唯一的ID,也在全局范围内有一个唯一的名字,也就是说Tranche ID和Tranche Name是一一对应的关系。
方法二:通过LWLockNewTrancheId函数获得新的Tranche ID,然后将Tranche ID和Tranche Name通过LWLockRegisterTranche函数建立联系,然后由LWLockInitialize函数来初始化轻量锁。
图2-15 轻量锁模块的两种扩展方法
在介绍了轻量锁的一些概念后,我们可以开始看一下轻量锁的“庐山真面目”。首先看一下轻量锁的结构体定义。
在早期的PostgreSQL中,轻量锁是借助自旋锁实现的,使用自旋锁来保护共享内存中的轻量锁结构体LWLock。随着数据库性能提升的需求越来越强烈,轻量锁的实现开始借助原子操作来实现,PostgreSQL自己实现了一套原子操作的接口。
这些原子操作可以用来操作LWLock结构体中的state变量,这个变量的作用是记录当前锁的状态,共有32位。其中,低24位用来作为共享锁的计数区,因为共享锁之间是相容的,因此可以有多个申请者同时持有共享锁,也就是说一个轻量锁最多可以有2^24个持锁者(共享锁)。另外保留了1位用来做排他锁的标记,因为排他锁模式和其他模式不相容,同一时间只能有一个持锁者,所以只需要1位就够了。轻量锁的状态变量如图2-16所示。
图2-16 轻量锁的状态变量
如图2-17所示,当申请一个新的共享锁时,如果当前LWLock->state中表示有两个共享锁的持有者,那么新的共享锁的申请者不会进入等待队列,而是直接获得共享锁。在比较极端的情况下,如果不停地有新的会话申请共享锁,则排他锁就没有机会被唤醒,会造成排他锁的饥饿。
如果要申请的是一个排他锁,而当前锁的持有者是共享锁,这时候申请者就会被安排到等待队列中。
轻量锁通过LWLockRelease函数来释放封锁。释放封锁的时候,如果发现锁已经没有持有者,则要考虑唤醒等待队列中的等待者,此时会分成两种情况,如图2-18所示。
• 如果等待队列中的第一个申请者申请的是排他锁,则只有这一个申请者被唤醒,其他申请者需要继续等待。
• 如果等待队列中的第一个申请者申请的是共享锁,那么等待队列中的所有共享锁都可以被唤醒,只剩排他锁继续等待。
图2-17 轻量锁的等待队列
图2-18 轻量锁的竞争模式
轻量锁的主要作用是保护共享内存中的变量,由于PostgreSQL是多进程结构,因此轻量锁在PostgreSQL中被频繁使用,所以轻量锁在PostgreSQL内核的实现中有非常重要的地位。
2.5.3 常规锁
常规锁是数据库实现中的锁,它和自旋锁、轻量锁的不同在于:自旋锁和轻量锁属于系统锁,它们主要用来保护数据库系统的一些变量,服务于数据库内核的实现;而常规锁则属于事务锁,主要用来封锁各种数据库对象,如表、页面、元组等。
常规锁也是一种读写锁,但是在PostgreSQL中,它的相容性矩阵更为复杂,PostgreSQL将常规锁的锁模式分成了8个等级,如表2-2所示。
表2-2 常规锁的锁模式
常规锁锁模式的相容性矩阵如表2-3所示。
表2-3 常规锁锁模式的相容性矩阵
以AccessExclusiveLock和AccessShareLock为例,ALTER TABLE操作会对表加AccessExclusiveLock模式的常规锁,SELECT操作会对表加AccessShareLock模式的常规锁,这两个锁模式是冲突的。
常规锁最常用于给表加锁,PostgreSQL提供了pg_locks系统表来帮助DBA查询当前的活跃事务对哪些对象加了何种类型的锁。例如当一个SELECT语句执行时,系统会对表加AccessShareLock模式的常规锁,如果在SELECT查询中使用FOR UPDATE子句,则在表上加的就是RowShareLock模式的常规锁;如果要对一个表做INSERT、DELETE、UPDATE操作,则会在表上加RowExclusiveLock模式的锁。
从示例可以看出,会话S1中开启新的事务分别对t1表做了3种不同的DML操作,对应地,在S1中的事务提交之前,会话S2可以在pg_locks系统表中查询到不同的DML操作对t1表加了不同模式的常规锁。当S1中的事务提交之后,这些锁被释放,这时无法从pg_locks系统表中找到t1表对应的锁。
PostgreSQL支持LOCK命令,用户可以使用这个命令显式地给表加常规锁。LOCK命令只在事务中有效,如果在隐式事务中单独执行一个LOCK命令,则这个锁随着命令执行结束也会被回收。因此,通常在显式事务中使用LOCK命令。