
本文选自 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 | SELECT balance FROM accounts WHERE id = 1; |
对简单增减操作,更安全的写法是让数据库直接基于当前值更新:
1 | UPDATE accounts |
数据库执行 UPDATE 时会对目标行采取必要的并发控制。两个语句同时到达时,后执行者会基于前一事务提交后的版本继续计算,因此结果可以正确变成 80。条件 balance >= 10 还把余额校验与扣减放进同一个原子操作。应用程序通过受影响行数判断扣款是否成功。
这条原则很实用:能够用数据库内的集合运算、条件更新、唯一约束或 UPSERT 表达的业务规则,尽量不要拆成应用端的多步读写。数据库约束比“先查询再判断”更接近数据的最终写入点,也更不容易被另一个事务插入执行。
然而,现实业务常需要读取多个字段、调用规则或修改多张表,无法压缩为一条 SQL。此时才需要显式选择并发控制方案。
三、悲观锁:先获得修改资格
悲观锁假设冲突较可能发生,因此事务在读取数据时就锁住目标行:
1 | BEGIN; |
T1 获得行锁后,T2 的 SELECT ... FOR UPDATE 会等待。T1 提交后,T2 才读取新余额并继续处理。其优势是模型直观,适合冲突频繁、业务步骤复杂、失败重试成本较高的场景,例如库存扣减、任务认领和账户状态迁移。
代价也很明确。锁持有时间越长,其他事务等待越久。若事务获得锁后进行远程接口调用、复杂计算或等待人工输入,会严重降低吞吐量。工程上应在进入事务前完成不依赖数据库一致性的准备,事务内只保留必要读写,并尽快提交。
锁还可能形成死锁。假设 T1 先锁账户 A,再请求账户 B;T2 先锁 B,再请求 A,两者将互相等待。数据库通常会检测等待图中的环并中止其中一个事务,但应用必须能够处理死锁错误并重试。最有效的预防方式,是让所有业务按一致顺序获得资源,例如转账时始终先锁 ID 较小的账户。
“悲观”并不等于低性能。在热点资源确实高度冲突时,排队可能比大量事务反复执行后失败更经济。关键指标包括锁等待时间、死锁率、事务持续时间和热点行分布,而不是只看每秒 SQL 数量。
四、乐观锁:写入时检查版本
乐观锁允许多个请求并行读取,在提交更新时检查数据是否已被改变。表中通常增加 version 字段:
1 | SELECT balance, version |
如果 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。理解这条时间线之后,许多复杂概念便有了清晰坐标。设计系统时,真正需要回答的不是“我们用了事务没有”,而是:业务不变量是什么,哪些事务可能交错,冲突由谁检测,失败怎样重试,外部副作用如何去重,以及最终正确性如何被持续验证。
参考资料
- PostgreSQL Documentation,Transaction Isolation。
- PostgreSQL Documentation,Explicit Locking。
- PostgreSQL Documentation,Serialization Failure Handling。
- PostgreSQL Documentation,Transactions。
- Berenson 等,A Critique of ANSI SQL Isolation Levels。
- Cahill、Röhm、Fekete,Serializable Isolation for Snapshot Databases。