本文有过半内容参考《PostgreSQL修炼之道-唐成》。Postgres 相关的资料不多,这本是比较适合入门的!

MVCC行锁事务隔离级别是处理事务并发问题的三大核心工具,需要深刻理解!

MVCC

Postgres 的 MVCC 核心原理和 Mysql 一样,都是通过各种类型的事务 ID 和版本链来判断数据可见性。两者的主要区别在于:

  • 存储方式不同:Postgres 的版本链和数据一起存在数据页中;Mysql 只有最新数据存在数据页中,历史数据的版本链存在 undolog 中。这导致 Postgres 对更新频繁的表需要更积极的 VACUUM 维护。

表锁

表级锁一般都是由 SQL 语句自动加的,业务开发的绝大多数情况都不需要显式请求表级锁,只在一些特殊情况下可能会涉及到,这里先做一个大概的了解。

Postgres 中共有 8 种表级锁,他们之间的共享/互斥关系如下:

image

看着挺复杂,梳理一下就简单多了:

  • 最基础的是SHAREEXCLUSIVE两种锁,即读锁和写锁。读锁排斥写锁,不排斥读锁;写锁和读锁/写锁都排斥。
  • 后来有了 MVCC 功能,MVCC 实现了同一行数据的读写分离(并发),于是就新增了两种锁:ACCESS SHARE不排斥普通的写锁EXCLUSIVE(为了并发读写),但仍排斥新增的加强版写锁ACCESS EXCLUSIVEEXCLUSIVE多了一个不相互排斥的ACCESS SHARE锁,所以就需要新增一个更强的写锁ACCESS EXCLUSIVE来排斥其他所有锁。
  • 后来有了行锁,但行锁和表锁之间不能直接建立共享/互斥关系,于是就需要在获得行锁的同时再额外获得一种表级锁来和其他表级锁建立共享/互斥关系。Mysql 中这种锁称为意向锁 IS/IX,Postgres 中则称为ROW SHAREROW EXCLUSIVE,分别表示有事务在读或写该表的某些行。这种锁表示的是对部分行的读写,所以锁名称使用了前缀ROW,但它实际上属于表级锁,下文也继续将其称为“意向锁”。由意向锁的概念出发,可以分析出意向锁的特性:
    • 意向锁之间不互斥。因为意向锁只表示对部分行的读写,而不同行之间显然是可以并发读写的。
    • 意向锁和其他类型的表级锁之间按照常规的读/写锁互斥规则判断。这也很好理解,比如ROW SHARE表示我在读表中某些行,而EXCLUSIVE表示我要修改这张表,两者显然应该互斥。
  • 还存在一些操作,它们不影响表的行级操作,但它们内部之间不能同时执行(比如VACCUM、创建索引等),这就要求一种写锁,这种写锁对其他类型的表锁而言相当于ROW EXCLUSIVE,但额外需要其和自身互斥,于是就有了SHARE UPDATE EXCLUSIVE(意思是“可共享更新的写锁”)。
  • 还有一种锁SHARE ROW EXCLUSIVE,相当于同时加了SHAREROW EXCLUSIVE(名副其实)。这种锁 Postgres 内部没用上,可能某些业务开发场景能用上吧,遇到再研究。

一些 SQL 语句会隐式请求表级锁。除此之外也可以显式请求:

-- lockmode 就是上文介绍的 8 种锁类型
-- ONLY 表示不锁继承子表
LOCK [ TABLE ] [ ONLY ] <tablename> [, ...] IN <lockmode> MODE [ NOWAIT ]

行锁

先说明一点,Postgres 的行锁并不是建在索引上的,而是直接上在 tuple(即表中的记录)上的。这点和 Mysql 不一样,Mysql 通过将行锁建在索引上实现了锁查询范围的效果。如果缺少了这种能力,可能会导致写倾斜问题。为了解决该问题,Postgres 在 Serializable 隔离模式下引入了 SSI(Serializable Snapshot Isolation)和谓词锁技术,详见下文事务隔离级别和谓词锁相关章节。

Postgres 中包含如下几种行锁:

行锁模式 锁类型 强度 典型触发场景
FOR KEY SHARE 共享锁 最弱 外键引用方检查被引用行时自动加;也可显式指定
FOR SHARE 共享锁 较弱 显式 SELECT ... FOR SHARE
FOR NO KEY UPDATE 排他锁 较强 不修改"键列"的 UPDATE 自动加;也可显式指定
FOR UPDATE 排他锁 最强 DELETE 自动加;修改"键列"的 UPDATE 自动加;显式 SELECT ... FOR UPDATE

梳理一下:

  • FOR SHAREFOR UPDATE即行级的共享锁和排他锁,有时也称为读锁和写锁,它们之间的共享/互斥规则也和普通的读锁/写锁相同。
  • FOR KEY SHAREFOR SHARE弱,语义是只要求主键不被修改FOR NO KEY UPDATEFOR UPDATE弱,语义是只修改非主键列。它们在一些特定业务场景下也许用得上。

它们之间的共享/互斥关系如下:

请求 ↓ \ 已持有 → FOR KEY SHARE FOR SHARE FOR NO KEY UPDATE FOR UPDATE
FOR KEY SHARE X
FOR SHARE X X
FOR NO KEY UPDATE X X X
FOR UPDATE X X X X

行锁可通过 DML 语句隐式请求,也可以显式请求:

-- lockmode 就是上文介绍的行锁的种类
SELECT ... FOR <lockmode> [ OF <tablename> [, ...] ] [NOWAIT]

如果SELECT ...中包含JOIN、子查询、视图,可以通过OF子句指定锁哪张表/事务/子查询。但是注意:

  • 对于视图和子查询,OF后面只能跟视图名或者子查询的别名,从而锁住整个视图或子查询中涉及的所有表。如果想只锁单个表,只能在视图或子查询中单独上锁,或者将查询修改为等效的JOIN
  • SELECT FOR UPDATE/SHARE无法给WITH子句(即 CTE)上锁,需要在WITH子句内部单独上锁。

SELECT FOR SHARE/UPDATE和 DML 语句一样都是当前读,即对 MVCC 中最新版本的数据加锁,查到的也是最新版本的数据。

目前,Django 只提供了model.select_for_update()接口,没有提供FOR SHARE对应的通用接口,只能通过原生 SQL 实现。这是因为各个数据库产品对FOR SHARE的兼容性不好。

锁的查看

要查看系统中当前持有和等待的锁,以及从中分析出事务之间的等待关系,需要查询系统视图pg_locks。下面该视图的所有字段:

类型 含义
locktype text 可锁定对象的类型,取值范围:relationextendfrozenidpagetupletransactionidvirtualxidspectokenobjectuserlockadvisoryapplytransaction
database oid 锁目标所在库的 OID;共享对象为 0;目标是事务 ID 时为 NULL
relation oid 锁目标关系的 OID(指向 pg_class.oid),非关系对象则为 NULL
page int4 关系内页号,非页锁/行锁时为 NULL
tuple int2 页内元组号,非行锁时为 NULL
virtualxid text 虚拟事务 ID,非虚拟事务锁时为 NULL
transactionid xid 实事务 ID,非事务锁时为 NULL
classid oid 包含锁目标的系统表 OID,非通用数据库对象时为 NULL
objid oid 锁目标在系统表中的 OID,非通用对象时为 NULL
objsubid int2 列号(针对表列),其他对象为 0,非通用对象为 NULL
virtualtransaction text 持有或等待该锁的事务的虚拟 ID
pid int4 持有/等待锁的服务器进程 PID;若是 prepared transaction 则为 NULL
mode text 该进程持有或期望获取的锁模式名(如 AccessShareLockRowExclusiveLock 等)
granted bool true 表示已持有该锁,false 表示正在等待该锁
fastpath bool true 表示锁通过 fast-path 机制获取(轻量级、未进入主锁表),false 表示走主锁表
waitstart timestamptz 开始等待该锁的时间;已持有的锁为 NULLPostgreSQL 9.6 之后才有此列

这里涉及一个概念:虚拟事务。MVCC 是基于事务 ID 实现的,但对于一些只读事务或者空事务来说,如果事务一开始就分配一个事务 ID,会造成资源的浪费。于是就提出了虚拟事务的概念,对于不写数据的事务,可以只分配一个虚拟事务 ID,而不实际分配真实的事务 ID,可以在 commit log 中少占用 2bit 空间。相比于事务 ID,反而是虚拟事务 ID 才能够唯一标识一个事务

首先强调一点:pg_locks记录的是事务和锁之间的持有和等待关系。基于此对pg_locks中的字段做如下梳理:

  • virtualtransaction之前的字段描述的是锁的信息;virtualtransaction及其之后的字段描述的是持有或等待锁的事务(或者说 session)的信息。
  • 锁信息中,locktype表示锁的资源类型,因为锁能够加在行、表、事务等多种资源上。剩下的锁信息字段则用于唯一标识锁对应的资源,比如locktypevirtualxid时字段virtualxid会显示虚拟事务 ID,locktypetransactionid时字段transactionid会显示事务 ID。
  • 事务信息中,virtualtransaction表示持有或等待锁的事务的 ID,pid表示事务所属会话的 ID(以我的经验看两者一般是一一对应的)。mode表示锁的模式,比如ACCESS SHAREROW EXCLUSIVE等。granted表示事务对锁是持有还是等待,True表示持有,False表示等待。
  • fastpath表示锁属于主锁表还是通过 fast-path 机制获取。fast-path 机制是一种对锁资源管理的优化机制,一般频繁使用的弱锁会走这个机制。waitstart为开始等待锁的时间。这两个字段不是太重要。

下面通过案例来说明如何通过pg_locks分析事务间的锁等待情况。

假设数据库中有一张testtab01表,执行如下 SQL 语句:

begin;
select * from testtab01 where id=1 for update;  -- 持有锁但不提交事务

然后在另一个事务中查询pg_locks

image

对此分析如下:

  • (第 3 行)事务创建时会自动被分配一个虚拟事务 ID,在pg_locks中体现为持有类型为virtualxid的锁。
  • (第 1,2 行)事务在请求行锁之前,首先需要请求对应主键表(testtab01_pkey)的ACCESS SHARE/UPDATE锁和当前表(testtab01)的意向锁ROW SHARE/UPDATE
  • 图中并不能找到id=1的行锁。这是因为行锁太多了,所以行锁的数据并不是记录在内存中的,而是记录在每个 tuple(行)中。平时未阻塞的行锁并不显示,只有阻塞的行锁才会显示在pg_locks中。
  • (第 4 行)虽然pg_locks中不会显示未阻塞的行锁,但事务成功持有任意锁后会被分配一个事务 ID,用于写入 tuple 的行锁数据段中。这在pg_locks中体现为持有类型为transactionid的锁(一般称为事务锁)。注意,如果事务只是阻塞在某个锁上,尚未取得任何锁,那么就不会被分配事务 ID,pg_locks中也看不到事务锁;如果事务只是通过普通SELECT的 MVCC 可见性查询数据,同样也不会被分配事务 ID 和持有事务锁。

那么如何分析事务之间的行锁阻塞关系呢?我们在另一个事务中执行如下 SQL 语句:

begin;
select * from testtab01 where id=1 for update;  -- 被上一个事务阻塞

再次查询pg_locks

image

这里先说明一下行锁的竞争机制。当一个事务请求行锁时,如果检查 tuple 中的行锁数据段后发现行锁尚未被持有,那么就如同上文所述,Postgres 为事务分配一个事务 ID 并将其写入 tuple 的行锁数据段中,表示该事务持有该行的行锁;如果行锁已被别的事务持有,那么当前事务会依次请求以下两个锁:

  • 先尝试请求该 tuple 上的一个锁(一般称为 tuple 锁)。tuple 锁的作用是排队,它的内部按请求锁的顺序维护着一个事务等待队列。tuple 锁的类型由请求行锁时的类型决定:FOR UPDATE则请求排他的 tuple 锁;FOR SHARE则请求共享的 tuple 锁。随着旧事务释放 tuple 锁,当前事务成功获得 tuple 锁,则表示当前事务已经进入了排队的第一梯队。如果本轮事务请求的是共享 tuple 锁,就会有多个事务同时获得该共享 tuple 锁。
  • 持有 tuple 锁以后,事务会继续以共享锁模式请求持有行锁的事务的事务锁并阻塞在上面(由上文可知,如果本轮请求的是共享 tuple 锁,此时会有多个事务同时请求该共享事务锁)。该操作的目的是等待持有行锁的事务提交。一旦持有行锁的事务提交了,对应的排他事务锁就会被释放,那么所有请求共享事务锁的事务就可以持有该锁了(此时该事务锁就已经没用了)。然后它(FOR UPDATE)或它们(FOR SHARE)就会把自己的事务 ID 写入该行的行锁数据段中表示持有,并释放手里持有的 tuple 锁。随着手里 tuple 锁的释放,tuple 锁上等待的下一轮事务就会获得 tuple 锁并依次请求(如果上一轮请求的是共享行锁的话)刚刚持有行锁的那些事务的共享事务锁

总结一下就是,要取得行锁,首先要请求 tuple 锁用于排队,然后再请求事务锁用于等待持有行锁的事务。当两个锁都持有后,再释放掉 tuple 锁和事务锁,然后把自身事务 ID 写入行锁数据段中。

以上流程其实是通过 tuple 锁和事务锁设计了一个巧妙的排队功能。在此基础上对上文的命令回显分析如下:

  • (第 1~6 行)见上文,这里重点分析最后 3 行。
  • (第 9 行)第一个事务持有行锁后同时持有的排他事务锁。
  • (第 7 行)由于请求的是FOR UPDATE锁,因此第二个事务首先请求排他 tuple 锁。由于尚没有其他事务和第二个事务竞争,所以直接获得了该 tuple 锁,进入排队的第一梯队。
  • (第 8 行)于是第二个事务接着请求持有行锁事务(也就是第一个事务)的共享事务锁。由于此时第一个事务尚未提交,仍然持有着自己的排他事务锁,所以第二个事务开始阻塞等待第一个事务提交。

如果此时再有第三个事务请求该行的行锁(无论是读锁还是写锁),该事务就会在第一步被第二个事务持有的排他 tuple 锁阻塞。如果第二个事务当时请求的是FOR SHARE锁,且现在第三个事务请求的也是FOR SHARE锁,那么第三个事务就可以持有 tuple 锁,并在第二步中和第二个事务一起阻塞在第一个事务持有的排他事务锁上。也就是说,申请FOR SHARE锁的事务可以同时等待同一个被持有的FOR UPDATE锁。

可见,处于阻塞等待的事务要么阻塞在 tuple 锁上,要么持有某个 tuple 锁并阻塞在某个事务锁上

如果对上文的流程仍有疑问,可以自己去做做实验,或者阅读《PostgreSQL修炼之道-唐成》的【6.11 锁的查看】章节。查询当前进程 pid 的命令如下:

select pg_backend_pid();

如果想查询 tuple 锁对应的记录,可以查询pg_lockspagetuple字段,它们合在一起就是 ctid:

select page || ',' || tuple as ctid from pg_locks where pid = ...;  -- 假设返回 0,1

select * from testtab01 where ctid = '(0,1)';  -- 查询 tuple 锁对应的记录

事务

-- 启动事务
BEGIN;

-- 设置事务隔离级别
SET TRANSACTION ISOLATION LEVEL READ COMMITTED/REPEATABLE READ/SERIALIZABLE;  -- 在事务内、任何查询之前设置。主要用于 Django 等框架在开启事务后通过原生 SQL 设置隔离级别。
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED/REPEATABLE READ/SERIALIZABLE;  -- 或者在启动事务的同时直接指定隔离级别

-- 查看当前事务的隔离级别
SHOW TRANSACTION_ISOLATION;

-- 提交/回滚
COMMIT/ROLLBACK;

Postgres 也支持 SAVEPOINT:

-- 设置保存点
SAVEPOINT <savepoint_name>;

-- 回退到保存点
ROLLBACK TO SAVEPOINT <savepoint_name>;

事务隔离级别

Postgres 真正的事务隔离级别只有 3 个,因为 RU 级别被升级成 RC 级别了。

  • 读已提交(READ COMMITTED):处处都是当前读。这是 Postgres 的默认隔离级别,80% 对一致性要求不高的简单业务场景够用了。
  • 可重复读(REPEATABLE READ):读和写都必须基于事务启动时创建的快照。但实际上 DML 又要求必须是当前读,对此 Postgres 的处理是:DML 仍然是当前读,但在修改之前做个检查,一旦发现快照被修改了就直接报无法串行访问的错误,需要业务层面捕获该报错并重试。这点和 Mysql 不一样,Mysql 是直接修改当前读的数据,不报错,需要业务层面想办法保证数据一致性。RR 一般用在对数据一致性要求更高的、复杂的多 SQL 查询和更新中。
  • 串行化(Serializable):加强版的 RR,通过 SSI 检查机制来保证并发事务的执行效果一定能等效于它们的某种串行化,一旦发现任何无法串行化的操作就直接报错。虽然不是真正的串行,但理论上可看作等效的串行。和 RR 一样需要业务层捕获无法串行访问的异常并重试。

RR 级别的快照是在事务中第一个非事务管理语句执行时创建的,并非BEGIN执行时创建。但下文为了简化描述,有时会省略创建快照的SELECT语句。

RR 和 Serializable 级别都要求应用层必须处理序列化失败异常并重试事务,无法序列化的异常也只有可能出现在这两个隔离级别。

Serializable 中 SSI 检查机制的原理比较复杂,涉及一些数学原理,我们只需要大致了解即可。SSI 不会引入比 RR 级别更多的阻塞,但是存在更多的监控开销,且会报更多的可序列化异常,从而导致更多的事务回滚,导致吞吐率下降 10% ~ 30%。但这是一种能够绝对确保并发业务一致性的隔离级别,省去了人工加锁的复杂性和阻塞风险,在银行转账等绝对无法对数据一致性妥协的场景下可以使用,在中等冲突且业务逻辑复杂的场景下也是一种更优的 trade-off(权衡)。

单从业务一致性的角度来看,Serializable 已经可以保证绝对的等效串行化,就不需要再显式使用FOR SHARE/UPDATE加行锁了。对此官方的态度是:大多数情况下 SSI 的并发性能要好于显式加行锁,但在个别场景下也需要在 Serializable 中显式加行锁以获得更好的并发性能。类似的,其实 Postgres 的 RR 相比 Mysql 的 RR 要更强一些,Mysql 的 RR 下需要加的一些行锁在 Postgres 的 RR 下已经可以省略了,但是有时与其等事务执行到一半再报无法串行化的错误,显式使用行锁反而是性能更好的选择。

注意,Serializable 隔离级别下如果执行成功,就能够绝对保证可串行化,但这并不意味着不可串行化时报的错误都是不可串行化异常,Postgres 可能会把一些异常留给其他检查模块来报,如果遇到类似情况不要觉得奇怪。

由于不同数据库的事务隔离级别之间兼容性并不好,所以 Django 并没有提供设置事务隔离级别的统一接口,需要通过原生 SQL 来设置。

事务隔离级别的本质是提供不同的 MVCC 可见性以及限制 DML 语句可操作的数据版本,实战中该选择哪种隔离级别完全取决于谁能够在满足业务一致性的前提下提供尽可能高的并发。更高并发的事务隔离级别往往会在数据一致性方面有所欠缺,但是这种欠缺对于某些简单业务往往是无关紧要的。

可见性判断(重中之重)

参考链接:

以下分析皆基于对 Postgres 13 的实验验证,结论应该大致正确,但由于细节较多,实验并没有穷举所有可能的组合情况,学习和实战运用时心里要有数。

SQL 语句在事务中的可见性判断分为两个阶段:

  • 索引阶段:即UPDATE / DELETE / SELECT / SELECT FOR SHARE/UPDATE的 WHERE 子句能看到哪些记录。这部分完全由各事务隔离级别的 MVCC 可见性来决定:RC 级别下,WHERE 子句可以筛选任何已提交的记录;RR 级别下,WHERE 子句只能筛选事务创建时的快照。索引阶段的 MVCC 筛选是修改阶段当前读的前提,如果连 WHERE 子句都筛不到,那根本就没有后面修改阶段加锁、当前读的那些事。
  • 更新阶段:即更新或声明更新数据时可以读到的数据,包括UPDATE / INSERT / DELETE以及SELECT FOR SHARE/UPDATE的加锁操作。这部分都需要申请锁且完全由当前读决定,如果锁已被占用,会先阻塞,等待占用锁的事务提交或回滚后再根据事务隔离级别判断应该继续更新还是报错。RC 级别下会继续更新;若是 RR 级别,因为 RR 要求只有在快照的基础上才能更新数据,因此还需要再判断最新数据是否是在快照的基础上被修改过,如果未修改或修改事务被回滚了则正常更新,否则只能报无法串行访问的错误。再强调一下,能走到更新阶段更新数据的前提是索引阶段的 MVCC 要能看到这些数据。

注意:

  • SELECT FOR SHARE/UPDATEDELETE虽然只有 WHERE 子句包含入参,但实际上分为两个步骤,分别是【WHERE 子句筛选】的索引阶段和【加锁/删除】的更新阶段。
  • SELECT只包含索引阶段,而INSERT只包含更新阶段。
  • UPDATE显然同时包含两个阶段。

下面举一些 RR 级别下的可见性分析案例:

-- 开启事务 A 
begin transaction isolation level repeatable read;

-- 事务 B 插入一条记录
insert into table(id, name) values(1, 'tom');

-- 事务 A 尝试更新
update table set name = 'jerry' where id = 1;  -- 由于事务 A 的快照里根本没有 id=1 的记录,因此这条 SQL 语句不会给任何记录上锁,既不会阻塞也不会报错。
-- 开启事务 A
begin transaction isolation level repeatable read;

-- 事务 A 查到 ID=1 的记录
select id from table;

-- 事务 B 修改 ID
update table set name = 'jerry' where id = 1;

-- 事务 A 尝试获得 ID=1 的记录的读锁(获得写锁的效果也相同)
select id, name from table where id = 1 for share;  -- 由于事务 A 请求 ID=1 记录的读锁时记录已经被事务 B 更新了,因此违反了 RR 更新必须在快照基础上的要求,所以这里只能报无法串行访问的错误。

SELECT FOR SHARE/UPDATEUPDATE / DELETE一样是当前读,RR 下如果更新阶段加锁时发现数据已经被其他事务修改,会直接报无法串行访问的错误。

INSERT语句比较特殊,它是唯一没有 WHERE 子句的 DML 语句,所以 RR 下INSERT能否插入完全取决于当前读能否找到违反约束的记录,与当前快照是否已被其他事务修改无关。这会导致一个现象:根据 MVCC 看到的快照数据判断插入会失败,但是实际上会成功;或者根据快照判断会成功,但是实际上会失败。

-- 开启事务 A
begin transaction isolation level repeatable read;

-- 事务 B 插入一条记录
insert into table(id, name) values(1, 'tom');

-- 事务 A 尝试插入相同 ID 的记录
insert into table(id, name) values(1, 'tom');  -- 报错,ID 冲突。但是事务 A 的快照里面其实根本没有 ID=1 的记录。

或者:

-- 开启事务 A
begin transaction isolation level repeatable read;

-- 事务 A 查到 ID=1 的记录
select id from table;

-- 事务 B 修改 ID
update table set id = 2 where id = 1;

-- 事务 A 插入 ID=1 的记录
insert into table(id, name) values(1, 'tom');  -- 成功。但是事务 A 的快照中其实已经有了 ID=1 的记录了。

上面这个问题在 SERIALIZABLE 隔离级别下会被解决,事务 A 插入记录时都会直接报错。

由上文可知,RC 和 RR 之间的差异主要集中在以下方面:

  • 索引阶段的 MVCC 可见性:RC 能看到其他所有事务(无论是当前事务创建前还是创建后的)已提交的数据;而 RR 只能看到事务开启瞬间的快照。
  • 更新阶段的数据更新限制:RC 允许在其他事务的更新的基础上继续更新;而 RR 则强制要求只能在快照的基础上更新,若更新时发现快照基础上已存在其他事务的更新,则会直接报无法串行访问的错误。

有趣案例

下面通过一些有趣案例巩固一下对事务隔离级别的理解。

-- 事务 A:RC
-- 事务 B:RC
-- website 表:表中有 hits=9,10 两条记录

-- 开启事务 A 并更新 website 表
BEGIN;
UPDATE website SET hits = hits + 1;

-- 事务 B 开启一个单语句事务并删除 hits=10 的记录
DELETE FROM website WHERE hits = 10;  -- 阻塞

-- 事务 A 提交
COMMIT;  -- 此时发现事务 B 并没有删除任何记录

-- 事务 B 删除时由于事务 A 并未提交,所以事务 B 只能看到修改前的 hits=9,10。事务 B 过滤掉了 hits=9,只申请 hits=10 的锁,然后被事务 A 阻塞。
-- 事务 A 提交后,事务 B 查看原来 hits=10 的记录发现 hits 已经变成 11 了,所以也不会删它。

谓词锁

Postgres 的行锁不在索引上上锁,因此不能锁查询范围,这会导致写倾斜问题(Write Skew)。

所谓写倾斜,是由于事务在修改数据时引用了过期的快照,导致数据一致性遭到破坏。例如:

-- 根据业务规则,VIP 人数最多 5 人

-- 事务 A/B
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- 读已提交
SELECT count(*) FROM users WHERE is_vip FOR SHARE;  -- 事务 A、B 同时查出有 4 个 VIP

-- 事务 A 和 B 基于自己的快照都认为可以新增 VIP,于是新增然后提交,都没有报错
-- 事务 A
UPDATE users SET is_vip = TRUE WHERE username = 'tom';
COMMIT;
-- 事务 B
UPDATE users SET is_vip = TRUE WHERE username = 'jerry';
COMMIT;

-- 结果:总共新增了 2 名 VIP,人数超了,违反了业务约束

这是因为单单只锁行的做法并不足以精确控制业务逻辑,还需要能够锁住is_vip=TRUE这样的“谓词”。Mysql 的锁建立在索引上,于是可以通过临键锁解决大部分写倾斜问题。而 Postgres 的解决方法是在 Serializable 隔离级别下引入谓词锁。

所谓谓词锁,实际上并不是锁,它只是在查询范围上贴一个标签,表示“这个范围被我读过了”。如果有其他事务往这个范围里写数据,Postgres 一看标签就知道这个范围被人读过,可能会被用作写数据的依据,那就有可能造成冲突,于是就会回滚其中一个事务,让你重试。从实际效果上看,相当于给查询范围加了个锁。

关于谓词锁,有以下几点需要注意:

  • 谓词锁的核心功能是锁查询范围,相当于 Mysql 的临键锁。Serializable 下所有带检索功能的 SQL 语句都会自动使用谓词锁,且也只有在 Serializable 下才可以使用谓词锁。
  • 只有同为 Serializable 隔离级别的事务才能够被谓词锁检测,因此谓词锁属于“君子协定”,在业务开发时就需要把需要使用谓词锁的业务都设置为 Serializable 隔离级别。
  • 谓词锁不是锁,因此它不会造成阻塞和死锁,但是会造成不可序列化的错误以及事务的回滚。
  • 谓词锁会粗化。Postgres 为了节约内存,会把多个细粒度的谓词锁合并为一个粗粒度的,比如行级锁升级为页级锁,页级锁升级为表级锁,而表级锁的并发性能简直就是灾难,应设法避免。
  • 谓词锁的物理实现其实也是基于各种可加锁的资源对象的,比如行、页、表等,其中最常见的就是索引。也就是说,Postgres 的索引级谓词锁也是类似 Mysql 的行锁一样加在索引上的。因此,平时开发时使用谓词锁,要尽可能让谓词锁落在索引上,不然谓词锁就会从索引级粗化成表级。
  • 如果只是读数据,尽可能使用READ ONLY模式。这样 PostgreSQL 知道你不会写,就能节约很多不必要的性能开销,还能享受快照隔离的一致性。如果再加上DEFERRABLE,甚至可以等到没有冲突的快照时才启动,彻底避免回滚。

实际上,谓词锁是和 SSI 机制一起配合工作的。在使用 Serializable 隔离级别时,其实只需要知道,即使是没有命中索引的查询条件,也能够通过 SSI 和谓词锁保证等效的串行化(当然还是尽量让查询条件命中索引比较好)。

不要过度依赖谓词锁,也可以考虑 RC 隔离级别 + 乐观锁的解决方案。

只读事务

只读事务是一种严格限制事务内部只能执行不修改数据库状态的操作的事务访问模式,如果违反会立刻报错。

设置方式如下:

-- 事务开启时直接设置
BEGIN READ ONLY;

-- 事务开启后设置
BEGIN;
SET TRANSACTION READ ONLY;

只读事务的好处在于:

  • Serializable 之外的隔离级别下的只读事务,会跳过 XID 分配。
  • Serializable 下可以减少谓词锁(但不能消除)。
  • 减少一些 Postgres 内部的性能开销。

只读模式可与任意隔离级别组合,但存在细微差异:

隔离级别 只读事务行为
READ COMMITTED 每条语句获取最新快照,不分配 XID,适合短查询
REPEATABLE READ 整个事务使用同一快照,不分配 XID,适合一致性报表
SERIALIZABLE 必须分配 XID(因为需要检测序列化异常),但仍禁止写操作

Django 没有提供只读事务的应用层接口,只能通过原生 SQL 来实现。

无法序列化的错误

参考链接:

这里着重强调一点:即使是只读事务,也需要执行 SSI 检查,因此也就需要谓词锁,也有可能报无法序列化的错误。看下面这个例子:

W1: BEGIN ISOLATION LEVEL SERIALIZABLE;
W1: UPDATE t SET count=count+1 WHERE id=1;   -- 改 id=1
W1: SELECT data FROM t WHERE id=2;           -- 读 id=2

W2: BEGIN ISOLATION LEVEL SERIALIZABLE;
W2: UPDATE t SET count=count+1 WHERE id=2;   -- 改 id=2
W2: COMMIT;

R:  BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY;
R:  SELECT data FROM t WHERE id IN (1,2);    -- 看到 W2 改后的 id=2,但看不到 W1 改后的 id=1
                                             -- 因为 W1 还没提交

W1: COMMIT;   -- R 此刻报序列化错误!

我们来分析下 W1 提交时如果不执行 SSI 检查,那么后面 R 提交后如何对这 3 个事务做等效的串行化。首先由于 R 是在 W2 提交后才开启的,能看到 W2 修改的id=2,因此 R 就只能排在 W2 后面。剩下的就是 W1 插在它们之间的什么位置了。

  • 情况一(W1 - W2 - R):这样 R 应该能看到 W1 修改的id=1才对,但实际上 R 开启时 W1 还未提交,R 是看不到id=1的,因此不成立。
  • 情况二(W2 - W1 - R):不成立,原因同上。
  • 情况三(W2 - R - W1):这样 W1 应该能看到 W2 修改的id=2才对,但实际上 W1 开启时 W2 尚未开启,W1 是看不到id=2的,因此不成立。

综上,即使 R 只是只读事务,但是这 3 个并发事务仍然不存在一个等效的串行化序列。

这个问题的背后其实是有着一篇论文和一套成熟的理论体系在支撑的。理论可以进一步证明:只读事务能否卷入异常,只取决于它取快照的时刻,而不取决于它的提交时刻。Postgres 基于此对只读事务又做了优化:如果显式声明事务为SERIALIZABLE READ ONLY DEFERRABLE,那么这个事务在开启前会阻塞等待,直到它能确保拿到一个不会导致自己无法串行化的快照为止(DEFERRABLE只能和READ ONLY关键字一起使用)。但是注意,这好像是比较新的特性,资料很少,使用前须心里有数。

咨询锁

咨询锁(Advisory Lock)是一种由应用层自定义语义的锁机制。数据库只负责实现锁的功能和管理锁的生命周期,至于这个锁代表什么资源、谁该遵守它,完全由应用层约定。

咨询锁按照生命周期可分为会话级和事务级两种:

  • 事务级:事务结束自动释放;事务内多次获取视为一次(可重入)。
  • 会话级:会话结束时自动释放,或手动释放;同一会话可多次获取,必须调用等量次的unlock才可真正释放(不可重入)。用于跨事务的长期互斥。

咨询锁标识符有两种形式,且两个键空间不重叠

  • 单个 bigint 值
  • 两个 integer 值(便于做 namespace + object_id 的层次化设计)

Postgres 提供了一套正交的函数矩阵,涵盖“会话/事务 × 排他/共享 × 阻塞/尝试”三个维度:

-- 会话级 · 排他 · 阻塞式
SELECT pg_advisory_lock(12345);
SELECT pg_advisory_lock(1, 23456);

-- 会话级 · 排他 · 非阻塞(立即返回 bool)
SELECT pg_try_advisory_lock(12345);  -- true 抢到,false 没抢到

-- 会话级 · 共享 · 阻塞/非阻塞
SELECT pg_advisory_lock_shared(12345);
SELECT pg_try_advisory_lock_shared(12345);

-- 会话级 · 释放
SELECT pg_advisory_unlock(12345);
SELECT pg_advisory_unlock_shared(12345);
SELECT pg_advisory_unlock_all();  -- 释放当前会话持有的所有会话级咨询锁

-- 事务级 · 排他 · 阻塞/非阻塞(事务结束自动释放,无法显式释放)
SELECT pg_advisory_xact_lock(12345);
SELECT pg_try_advisory_xact_lock(12345);

-- 事务级 · 共享 · 阻塞/非阻塞
SELECT pg_advisory_xact_lock_shared(12345);
SELECT pg_try_advisory_xact_lock_shared(12345);

两阶段提交协议

Postgres 支持基于 SQL 语句控制的两阶段提交协议,这在分布式数据库场景下很有用,以后有机会可以深入研究。

DDL事务

和其他数据库相比,Postgres 有个很好的特点就是大多数 DDL 语句也可以放在一个事务中。这使得 Postgres 非常适合作为分布式数据库系统的底层数据库。再配合二阶段提交协议,分布式数据库系统中就不会出现类似部分数据库建表成功部分失败的情况了。