一、锁机制和多版本并发控制(MVCC)

锁是数据库并发控制中必不可少的功能。为了确保复杂事务能够安全地同时运行,PostgreSQL提供了各种级别的锁来控制对数据对象的并发访问,使对数据库关键部分的更改序列化。当多个事务同时在数据库中运行时,并发控制是一种用于维持一致性和隔离性的技术。在PostgreSQL中,使用快照隔离(Snapshot Isolation, SI)来实现多版本并发控制(MVCC),同时辅以两阶段锁定(2PL)机制。DDL(数据定义语言)操作使用2PL,而DML(数据操作语言)操作使用SI。

并发控制机制

  • 快照隔离(Snapshot Isolation, SI):用于处理DML操作(如SELECT、UPDATE、INSERT、DELETE),确保读操作和写操作互不干扰。它通过维护数据的多个版本,实现了“读不阻塞写,写不阻塞读”。

  • 两阶段锁定(Two-Phase Locking, 2PL):2PL用于处理DDL操作(如CREATE TABLE等),是关系数据库系统中保证数据完整性的经典并发控制机制之一。

两阶段锁定(2PL)详解

两阶段锁定将事务分为两个阶段:

  1. 扩展阶段

    • 在扩展阶段,事务可以获取锁,但不能释放锁。
    • 事务在操作数据项时会先加锁,以确保其他事务不能同时修改相同的数据。
  2. 收缩阶段

    • 在收缩阶段,事务开始释放锁,但不能再获取新的锁。
    • 当事务释放所有锁之后,其他事务才能操作这些数据项。

这种机制确保了事务在第一阶段(扩展阶段)内,对所有涉及的数据项加锁,防止其他事务进行相同数据项的读写操作。在第二阶段(收缩阶段),事务释放所有锁后,其他事务才能继续操作相同的数据项。

实际应用

  1. DML操作(使用SI)

    • 读取操作(SELECT)不会阻塞写入操作(UPDATE、INSERT、DELETE),反之亦然。
    • 通过维护数据的多个版本,实现高效的并发控制。
  2. DDL操作(使用2PL)

    • 例如,创建表(CREATE TABLE)时,必须确保操作的完整性和一致性,因此需要使用2PL。
    • 在扩展阶段加锁,防止其他事务修改相同的数据结构;在收缩阶段释放锁,允许其他事务继续操作。

对于常规应用,可以有:表级锁、行级锁、页级锁、咨询锁等等;

二、表级锁的八种类型

在常规表级锁中,有八种表级锁,每种锁有不同的排他级别。简单来说,不同的锁之间可能会相互冲突,但一个事务内连续获取冲突的锁是没问题的。

img

1. AccessShare Lock
  • 描述:当执行SELECT查询,只读取数据时会用到这种锁。
  • 适用场景:普通的SELECT查询。
  • 冲突:只与AccessExclusive锁冲突。
2. RowShare Lock
  • 描述:当执行SELECT FOR UPDATE或SELECT FOR SHARE查询时,会用到这种锁。
  • 适用场景:需要读取并锁定行以防止其他事务修改。
  • 冲突:与RowExclusive、ShareUpdateExclusive、Share、ShareRowExclusive、Exclusive和AccessExclusive锁冲突。
3. RowExclusive Lock
  • 描述:当执行UPDATE、DELETE和INSERT操作时,会用到这种锁。
  • 适用场景:修改表中数据。
  • 冲突:与Share、ShareRowExclusive、Exclusive和AccessExclusive锁冲突。
4. ShareUpdateExclusive Lock
  • 描述:一些在线维护操作(如VACUUM、ANALYZE、CREATE INDEX CONCURRENTLY等)会用到这种锁。
  • 适用场景:在线维护操作。
  • 冲突:与RowExclusive、Share、ShareRowExclusive、Exclusive和AccessExclusive锁冲突。
5. Share Lock
  • 描述:当执行不带CONCURRENTLY的CREATE INDEX命令时,会用到这种锁。
  • 适用场景:创建索引。
  • 冲突:与RowExclusive、ShareUpdateExclusive、ShareRowExclusive、Exclusive和AccessExclusive锁冲突。
6. ShareRowExclusive Lock
  • 描述:当创建排序规则、触发器和执行某些表修改操作时,会用到这种锁。
  • 适用场景:创建排序规则、触发器和某些表修改操作。
  • 冲突:与Share、ShareUpdateExclusive、RowExclusive、Exclusive和AccessExclusive锁冲突。
7. Exclusive Lock
  • 描述:这种锁模式只允许并发的AccessShare锁,持有这种锁时只允许表的只读操作。
  • 适用场景:需要限制表的修改,但允许读取。
  • 冲突:与RowShare、RowExclusive、Share、ShareUpdateExclusive、ShareRowExclusive和AccessExclusive锁冲突。
8. AccessExclusive Lock
  • 描述:这是最高级别的锁,与所有其他锁冲突,保证持有者是访问该表的唯一事务。
  • 适用场景:执行DROP TABLE、TRUNCATE、REINDEX、CLUSTER、VACUUM FULL等操作时会用到这种锁。
  • 冲突:与所有其他锁冲突。

三、行级锁:

行级锁:同一个事务可能会在相同的行上保持冲突的锁,甚至是在不同的子事务中。但是除此之外,两个事务永远不可能在相同的行上持有冲突的锁。行级锁不影响数据查询,它们只阻塞对同一行的写入者和加锁者。行级锁在事务结束时或保存点回滚的时候释放,就像表级锁一样。

行级锁主要包括以下几种:

  • 共享锁(Share Lock, S-lock):当一个事务正在读取一行数据时,它会在该行上设置共享锁。
    其他事务可以读取这行数据,但不能修改它,直到共享锁被释放。
  • 排它锁(Exclusive Lock, X-lock):当一个事务想要修改一行数据时,它会在该行上设置排它锁。
    这行数据不能被其他事务读取或修改,直到排它锁被释放。
  • 其他类型:行共享锁、行排他锁等。

四、spin自旋锁:

自旋锁(spin lock)和互斥锁(mutex)类似,都是用来保证同一时间只有一个线程能够访问某个资源的锁机制。当一个线程尝试获取自旋锁时,如果锁已经被其他线程占用,那么这个线程会进入一个循环,不断检查锁是否可以获取,直到成功获取锁为止。在这个过程中,线程一直处于活跃状态,不会发生用户态到内核态的切换,也就是说,线程不会进入阻塞状态。这样可以减少不必要的上下文切换,提高执行速度。自旋锁不会导致线程进入阻塞状态,因此减少了上下文切换的开销,线程一直在用户态运行,执行速度较快。然而,自旋锁在等待期间不会释放CPU,如果持有自旋锁的线程长时间不释放锁,那么等待的线程会一直占用CPU资源,导致CPU时间的浪费。因此,自旋锁适用于锁持有时间较短的情况。在这种情况下,线程不希望在重新调度上花费过多时间,使用自旋锁可以提高性能。
总体来说,自旋锁和互斥锁都是保证资源独占访问的锁机制,但它们的工作方式不同。自旋锁通过不断尝试获取锁来保持线程活跃,减少了上下文切换的开销,因此执行速度较快,但也会消耗CPU资源。因此,自旋锁适用于锁持有时间短且频繁获取的场景,而互斥锁适用于锁持有时间较长的场景。
例如postgresql中的实现,主要是用TAS来进行实现的:

/*
 * s_lock(lock) - platform-independent portion of waiting for a spinlock.
 */
int
s_lock(volatile slock_t *lock, const char *file, int line, const char *func)
{
    SpinDelayStatus delayStatus;

    init_spin_delay(&delayStatus, file, line, func);

    while (TAS_SPIN(lock))
    {
        perform_spin_delay(&delayStatus);
    }

    finish_spin_delay(&delayStatus);

    return delayStatus.delays;
}

s_lock函数用于尝试获取一个自旋锁。如果锁已经被其他线程持有,当前线程将不断重试,直到成功获取锁。在此过程中,线程不会进入阻塞状态,而是保持活跃,通过反复检查锁的状态来实现锁的获取。
使用 init_spin_delay函数初始化 delayStatus结构体,该结构体记录了自旋锁的延迟状态,包括延迟的次数、文件名、行号和函数名等信息。这些信息主要用于调试和性能监控。

  • 尝试获取锁:使用 TAS_SPIN(lock)函数尝试获取锁。如果锁已经被其他线程持有, TAS_SPIN(lock)将返回非零值,表示获取锁失败。如果获取锁失败,调用 perform_spin_delay(&delayStatus)函数来执行自旋延迟。自旋延迟的作用是通过短暂的忙等待或让出CPU来减少系统资源的浪费,并避免长时间的忙等待占用CPU。
  • 成功获取锁:一旦成功获取锁,即 TAS_SPIN(lock)返回零值,退出循环。
  • 完成自旋延迟状态:使用 finish_spin_delay(&delayStatus)函数完成自旋延迟状态的记录和处理。这一步通常用于记录自旋锁的获取情况,便于后续分析和调试。
  • 返回延迟次数:函数最终返回 delayStatus.delays,表示在成功获取锁之前,自旋延迟的次数。这有助于评估锁竞争的激烈程度和系统性能。
Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐