数据库并发控制:从丢失更新到锁、MVCC与隔离级别

数据库并发控制:从丢失更新到锁、MVCC与隔离级别

本文选自 Horizon 2026年9月4日技术简报。

本文根据 Horizon Daily 2026 年 9 月 4 日收录的数据库并发控制话题扩展而成。

在数据库故障中,最令人头疼的往往不是程序直接报错,而是所有请求看起来都成功了,最终数据却悄悄出了问题。一个经典例子是账户余额为 100 元,两个请求几乎同时读取余额,各自扣除 10 元,然后都把 90 元写回数据库。两次扣款均返回成功,余额却只减少了 10 元。单独检查每个请求,读取、计算、写入都没有明显错误;问题出在两个事务的执行过程发生了重叠。

这就是“丢失更新”。它揭示了数据库并发控制的核心矛盾:系统需要让大量事务同时运行以提高吞吐量,又必须保证它们交错执行后的结果与某种正确的串行顺序一致。悲观锁、乐观锁、MVCC 和隔离级别并不是彼此孤立的概念,而是数据库围绕这一矛盾建立的不同控制层。

一、并发错误为什么难以发现

如果两个扣款请求按顺序执行,结果很简单。事务 T1 读取 100,写入 90 并提交;随后 T2 读取 90,写入 80。并发执行时,时间线可能变成:T1 读取 100,T2 读取 100,T1 写入 90,T2 也写入 90。第二次写入覆盖了第一次写入产生的结果。

这类问题在开发和测试环境中经常消失。单元测试通常一次只调用一个接口;本地数据库响应很快,事务重叠窗口较小;测试数据量和线上连接数也不同。生产环境中的网络抖动、垃圾回收、磁盘写入和锁等待,会把几毫秒的竞争窗口放大。错误可能每十万次请求才发生一次,却足以破坏库存、账户、优惠券、工单状态等关键数据。

并发错误还有三个特点。第一,日志可能显示两个事务都成功提交。第二,异常往往与机器负载和时序有关,难以用相同输入稳定复现。第三,最终结果经常仍在字段允许范围内,例如余额 90 并不违反非负约束,因此数据库不会自动报警。

需要说明的是,事务的“原子性”并不能独自解决此问题。两个事务各自都完整执行,没有出现半次扣款;真正受损的是隔离性。ACID 中的原子性回答“一个事务内部是否全部成功”,隔离性回答“多个事务相互交错时能看到什么、会产生什么结果”。

二、先把读改写写成一个原子语句

很多丢失更新来自应用层的“读取—计算—写回”模式:

1
2
3
SELECT balance FROM accounts WHERE id = 1;
-- 应用程序计算 balance - 10
UPDATE accounts SET balance = 90 WHERE id = 1;

对简单增减操作,更安全的写法是让数据库直接基于当前值更新:

1
2
3
UPDATE accounts
SET balance = balance - 10
WHERE id = 1 AND balance >= 10;

数据库执行 UPDATE 时会对目标行采取必要的并发控制。两个语句同时到达时,后执行者会基于前一事务提交后的版本继续计算,因此结果可以正确变成 80。条件 balance >= 10 还把余额校验与扣减放进同一个原子操作。应用程序通过受影响行数判断扣款是否成功。

这条原则很实用:能够用数据库内的集合运算、条件更新、唯一约束或 UPSERT 表达的业务规则,尽量不要拆成应用端的多步读写。数据库约束比“先查询再判断”更接近数据的最终写入点,也更不容易被另一个事务插入执行。

然而,现实业务常需要读取多个字段、调用规则或修改多张表,无法压缩为一条 SQL。此时才需要显式选择并发控制方案。

三、悲观锁:先获得修改资格

悲观锁假设冲突较可能发生,因此事务在读取数据时就锁住目标行:

1
2
3
4
5
6
7
8
9
10
11
12
BEGIN;

SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE;

UPDATE accounts
SET balance = balance - 10
WHERE id = 1;

COMMIT;

T1 获得行锁后,T2 的 SELECT ... FOR UPDATE 会等待。T1 提交后,T2 才读取新余额并继续处理。其优势是模型直观,适合冲突频繁、业务步骤复杂、失败重试成本较高的场景,例如库存扣减、任务认领和账户状态迁移。

代价也很明确。锁持有时间越长,其他事务等待越久。若事务获得锁后进行远程接口调用、复杂计算或等待人工输入,会严重降低吞吐量。工程上应在进入事务前完成不依赖数据库一致性的准备,事务内只保留必要读写,并尽快提交。

锁还可能形成死锁。假设 T1 先锁账户 A,再请求账户 B;T2 先锁 B,再请求 A,两者将互相等待。数据库通常会检测等待图中的环并中止其中一个事务,但应用必须能够处理死锁错误并重试。最有效的预防方式,是让所有业务按一致顺序获得资源,例如转账时始终先锁 ID 较小的账户。

“悲观”并不等于低性能。在热点资源确实高度冲突时,排队可能比大量事务反复执行后失败更经济。关键指标包括锁等待时间、死锁率、事务持续时间和热点行分布,而不是只看每秒 SQL 数量。

数据库并发控制:从丢失更新到锁、MVCC与隔离级别

四、乐观锁:写入时检查版本

乐观锁允许多个请求并行读取,在提交更新时检查数据是否已被改变。表中通常增加 version 字段:

1
2
3
4
5
6
7
8
SELECT balance, version
FROM accounts
WHERE id = 1;

UPDATE accounts
SET balance = 90,
version = version + 1
WHERE id = 1 AND version = 7;

如果 T1 和 T2 都读到版本 7,T1 更新后版本变成 8;T2 再用 version = 7 更新时,受影响行数为 0。应用知道自己的读结果已经过期,需要重新读取、重新计算,或把冲突交给用户处理。

乐观锁适合读取多、冲突少、事务不便长时间持锁的系统,也常用于 HTTP API。版本可以是递增整数,也可以使用更新时间或不可变修订 ID。递增整数最清晰;时间戳可能受精度、时钟和同一时间内多次更新影响。

重试策略不能简单写成无限循环。高冲突时,大量请求同时失败并立即重试,会形成重试风暴。通常需要限制次数、采用指数退避并加入随机抖动。涉及支付、下单等外部副作用时,还要使用幂等键,防止数据库事务虽然重试成功,外部接口却被调用两次。

乐观锁也不自动解决多行不变量。两名医生各自看到“至少还有一人值班”,随后都把自己设为休假;两行版本均未冲突,最终却无人值班。这种写偏差需要更强隔离、显式锁住共同约束对象,或重新设计数据模型。

五、MVCC:让读取不必挡住写入

MVCC 即多版本并发控制。它的基本思路是:更新一行时不立即覆盖所有读者可见的旧状态,而是生成新版本,并通过事务 ID、可见性规则和快照决定每个事务能看到哪个版本。

假设一行先后产生 v1、v2、v3。早先开始的长查询可能仍读取 v1,新的事务读取已经提交的 v3。写入者不必等待所有读者结束,读者也通常不需要锁住正在读取的行。这对高并发数据库十分重要,因为报表扫描与在线更新可以在较大程度上同时进行。

MVCC 不是“不使用锁”。更新同一行时仍可能出现写写冲突;表结构变更、唯一性检查和显式锁也需要锁机制。MVCC 主要优化的是读写并发,并为不同隔离级别提供快照基础。

多版本还需要清理。旧版本不能永久保留,否则表会不断膨胀。数据库必须在确认没有活动事务需要某个版本后回收空间。长期不提交的事务会持有很老的快照,阻碍垃圾回收,引发膨胀、索引负担和性能下降。因此,“idle in transaction” 不只是连接管理问题,也可能拖累整个版本清理系统。

从应用角度看,MVCC 最容易造成的误解是:“普通 SELECT 不加锁,所以读到的数据在我使用时仍然有效。”查询返回之后,其他事务可能已经修改该行。若业务要基于读取结果执行写入,就必须依靠原子 UPDATE、版本条件、显式锁或更高隔离级别保护。

六、隔离级别控制事务能看到什么

SQL 标准通常讨论四个隔离级别:读未提交、读已提交、可重复读和可串行化。不同数据库对名称的实现存在差异,不能只凭字面判断。

在读已提交下,每条语句通常看到该语句开始前已提交的数据。一个事务内执行两次相同查询,中间若有其他事务提交,结果可能不同。它具有较好的吞吐量,是许多系统的默认选择,但应用需要主动处理读改写竞争。

可重复读通常让一个事务持续使用同一快照,避免同一行前后读取结果变化。在 PostgreSQL 中,可重复读基于快照隔离,能够阻止一部分丢失更新,却仍可能出现跨多行的写偏差。数据库之间差异较大,例如锁定范围、幻读表现和冲突处理方式都可能不同。

可串行化追求的结果是:并发事务的最终效果等价于某种串行执行顺序。PostgreSQL 使用 Serializable Snapshot Isolation 监测危险依赖关系;发现无法形成安全串行顺序时,会中止一个事务并返回序列化失败。应用必须捕获 SQLSTATE 40001,从事务起点完整重试,而不是只重试最后一条语句。

更高隔离级别并不意味着所有请求自动排队执行。现代数据库会尽可能保留并发,只在检测到危险结构时阻止或中止事务。因此,可串行化的主要工程成本往往是重试率和额外跟踪开销。若事务短、热点可控、应用具备可靠重试,它未必像想象中昂贵。

七、四种方案应该怎样选择

选择并发控制机制可以从冲突概率、重试成本和业务不变量三个维度判断。

对于单字段计数、余额增减,优先使用带条件的原子 UPDATE。对于高冲突的库存、任务认领和状态机,悲观行锁通常简单可靠。对于冲突较少、前后端交互较长的编辑操作,版本字段更合适。对于复杂的跨行约束和财务规则,可以考虑可串行化事务,并设计完整重试。

MVCC 则不是与这些方案并列的单一业务选项,而是许多数据库提供快照和并发读写的底层机制。应用仍需选择隔离级别,并在关键操作上使用锁或版本检查。

真实系统也常组合使用:数据库运行在读已提交或可重复读;普通查询依赖 MVCC;热点扣减使用条件 UPDATE;少数流程使用 FOR UPDATE;HTTP 更新携带版本号;跨表结算运行在可串行化级别。不要追求一种机制覆盖全部场景。

八、工程落地:从重试、幂等到可观测性

并发控制最终要落到应用代码。首先,事务重试必须包住完整业务逻辑,并设置最大次数、退避时间和可重试错误清单。唯一约束冲突、死锁和序列化失败可能适合重试,语法错误和权限错误则不应重试。

其次,事务边界不能跨越不可回滚的外部副作用。若必须发送消息、调用支付或驱动设备,可以使用 Outbox 模式:业务数据和待发送事件在同一数据库事务中提交,再由独立投递器可靠发送。接收方使用幂等键去重。

再次,为业务不变量建立压力测试。测试不应只验证接口返回值,而要在数百个并发工作线程下反复执行,然后检查余额守恒、库存非负、优惠券不超发和状态转换合法性。可使用屏障让多个事务在读取后同时继续,提高冲突复现概率。

最后,需要监控锁等待、死锁次数、序列化失败率、版本冲突率、事务 P95/P99 时长、长事务数量以及重试后的最终成功率。若只监控数据库 CPU 和 QPS,数据一致性风险往往要等到对账时才被发现。

结语

数据库并发控制没有一条适合所有业务的“最高配置”。锁提供明确的排他顺序,乐观版本检查用失败换取无等待,MVCC 让不同时间的读者看到合适版本,隔离级别则定义允许何种并发现象。它们共同构成数据正确性的防线。

丢失更新之所以值得作为起点,是因为它看起来极其简单:两个请求都读到 100,各减 10,最终却得到 90。理解这条时间线之后,许多复杂概念便有了清晰坐标。设计系统时,真正需要回答的不是“我们用了事务没有”,而是:业务不变量是什么,哪些事务可能交错,冲突由谁检测,失败怎样重试,外部副作用如何去重,以及最终正确性如何被持续验证。


参考资料

  1. PostgreSQL Documentation,Transaction Isolation
  2. PostgreSQL Documentation,Explicit Locking
  3. PostgreSQL Documentation,Serialization Failure Handling
  4. PostgreSQL Documentation,Transactions
  5. Berenson 等,A Critique of ANSI SQL Isolation Levels
  6. Cahill、Röhm、Fekete,Serializable Isolation for Snapshot Databases
分享到