2.7 常规锁的加锁
那么,这些常规锁是何时被加上的呢?当浏览PostgreSQL的代码时就可以看到,如果要对一个表进行操作,通常会通过heap_open来“打开”这个表。在打开表的时候,需要指定打开这个表所需要的锁的模式。在heap_open之后,会有一系列函数将锁模式传递下去,最终会通过LockRelationOid函数将表的Oid和lockmode联系在一起。
实际上,常规锁不仅可以对表加锁,还可以对很多对象加锁。PostgreSQL会为不同类型的锁设定不同的LOCKTAG,LOCKTAG结构体中的成员变量没有特定的含义,它们的含义完全取决于使用者。锁的LOCKTAG类型和说明如表2-6所示。
表2-6 锁的LOCKTAG类型和说明
为不同的对象加锁时使用的标识符不同,PostgreSQL定义了通用的LOCKTAG结构体来作为不同对象的唯一标识。
如果加锁对象是一个表,那么就可以通过SET_LOCKTAG_RELATION来生成对应的LOCKTAG,这样locktag_field1就被认为是Database ID,而locktag_field2就被设定为表的Oid,LOCKTAG本身的类型就是LOCKTAG_RELATION。
对表进行封锁的最重要的函数是LockAcquire(LockAcquireExtended)函数,对于像封锁这样的高频操作,性能是非常重要的,因此在LockAcquire函数中,对封锁的性能做了大量的优化。
首先,本地锁表会发挥作用。每个会话都自己保存了一个本地锁表,这是基于这样一个假设,在同一个事务中,可能会多次对同一个对象请求同一类型的锁,如果事务在第一次请求这个锁时能够获得这个锁,即这个锁已经被当前事务持有了,那么之后这个事务再申请同样的锁时,就可以直接获得,而无须去共享内存中的锁表进行查询。
本地锁表的名字是LockMethodLocalHash,这是一个hash表,这个hash表的查询(Search)标签是“LockTag + 请求的锁模式”(指针对同一对象的同一类型的锁)。
如果没有从这个锁表中检查到对应的锁,说明这是本事务第一次申请该锁。HASH_ENTER标记会帮助我们在内存中分配一个locallock的空间,我们需要向locallock填充正确的内容,例如在locallock->nLocks中填充的值是0,证明还没有持有这个锁。
如果能够在本地锁表中找到对应的锁(此时这个锁的locallock->nLocks一定大于0),就证明这个锁对象和锁模式已经授予给本事务,只需要给本地锁表的这个锁增加引用计数就可以,这个工作由GrantLockLocal函数来完成。它首先增加locallock->nLocks计数,另外需要增加ResourceOwner中锁的计数,然后这次申请锁的任务就完成了,等于直接获得了锁。
如果本地锁表中没有找到这个锁对象和锁模式,则开始尝试进入Fast Path的检查。Fast Path的检查分两部分,分别是弱锁部分和强锁部分,PostgreSQL分别使用两个宏来判断强锁和弱锁。
一个弱锁能够直接获得而不经过主锁表,需要判断两个条件。
• 共享内存中是否设置了强锁标记。
• 当前进程是否还有空间可以保存弱锁。
如果共享内存中没有设置强锁标记,则证明当前没有其他事务获得过这个锁对象的强锁模式。也就是说,当前的所有事务要么没有封锁这个对象,要么只申请了这个对象的弱锁模式,这些事务之间在这个锁对象上没有冲突存在,此时可以考虑去获得这个弱锁。
如果弱锁保存在本事务的Fast Path中,则实际上它是保存在PGPROC中的,共可以保留FP_LOCK_SLOTS_PER_BACKEND =16个弱锁。
PGPROC->fpRelId是一个长度为16的数组,它里面保存的是表的Oid,这就限制了每个事务可以保存的弱锁的数量。
PGPROC->fpLockBits保存的则是一个位图,它目前是一个64位的无符号整型,其中只有48位是有用的。每3位组成一个槽,每个槽都和PGPROC->fpRelId数组中的表对应。
所以当一个事务要对弱锁使用Fast Path时,就尝试在PGPROC->fpLockBits中记录当前的锁模式,这时候事务就能够获得锁了。
当然,如果一个事务需要记录的Fast Path数量超过了16个,则这个弱锁也需要去主锁表中检查。
反之,如果要申请的锁模式是一个强锁模式呢?那么它需要做的事情如下。
首先,设置强锁标记,也就是FastPathStrongRelationLocks->count的引用计数加1,但这里可能存在冲突的情况,因为FastPathStrongRelationLocks->count也是一个数组,数组长度是1024,不同的锁对象可能碰撞到相同的数组下标,但关系不大,这只会导致弱锁在检查强锁标记时出现不准确的情况,但不会出现错误。
其次,把目前其他事务保存的对应弱锁转移到主锁表中,因为有了强锁之后,弱锁和强锁之间就有了等待关系(冲突),因此需要把保存在PGPROC中的弱锁转移进主锁表,方便进行死锁检查。
实际上,本地锁表和Fast Path的主要目的都是提升性能,不过它已经可以处理大多数情况,其他情况需要对主锁表进行查询,根据锁的状态决定是获得锁还是进入等待状态。
SetupLockInTable函数的主要作用就是在主锁表和进程锁表中查找对应的锁,如果该锁还没有存在,则在主锁表和进程锁表中申请内存来保存锁并且初始化锁的信息。
在主锁表和进程锁表中保存锁之后,就可以进行锁的冲突检测。如果检测到当前的申请锁模式与其他事务冲突,则必须进入等待状态。
LockCheckConflicts函数主要是检查当前申请的模式与其他事务已经持有的模式是否存在冲突。
如果通过检测发现可以获得锁,则可以通过GrantLock函数和GrantLocalLock函数来增加锁的引用计数;而如果发现不能获得锁,则进入等待状态。等待状态的判断通过WaitOnLock函数来实现,调用关系是WaitOnLock函数->ProcSleep函数。ProcSleep函数一方面要将当前事务加入等待队列,另一方面还要做死锁检测。
将当前事务(或进程)加入等待队列也是有技巧的。通常而言,等待队列应该按照锁的申请顺序排列,因此当前事务应该加入等待队列的队尾。但是如果本事务A除了当前申请的锁模式,已经持有了这个对象的其他锁模式,而且等待队列中某个事务B所等待的锁模式和当前事务A持有的锁模式冲突,这时候如果把事务A插入这个等待者B的后面,就隐含着死锁的可能,所以可以考虑把事务A插入这个等待者的前面。
插入等待队列后,锁就开始进入等待状态,它会在以下两种情况下被唤醒。
• 死锁检测触发超时机制,要进行新一轮的死锁检测。
• PGPROC->waitStatus不再是STATUS_WAITING状态,已经有其他事务释放了锁,当前事务被唤醒。