跳到主要内容

并发控制介绍

并发控制(concurrency control)解决的是一个核心问题:多个事务同时访问同一批数据时,如何让最终结果既正确又尽可能高效。

例如,账户余额为 100 元。两个请求同时读取余额,并分别扣除 80 元。如果数据库只按“读到 100、计算、写回 20”的顺序处理,最终可能只扣除一次,或者出现余额为负数。并发控制就是用事务、锁、MVCC(多版本并发控制)和约束等机制,避免这类结果。

本文使用通用 SQL 建立概念模型。事务语法、锁提示、隔离级别的精确定义和默认值会因数据库产品与版本而不同,示例执行前应核对目标数据库文档。

先理解事务​

事务(transaction)是一组要作为一个整体执行的数据库操作。它通常具有 ACID 四个特性:

特性含义例子
原子性(Atomicity)全部成功,或全部回滚转账扣款和入账不能只完成一半
一致性(Consistency)提交后仍满足约束和业务规则余额不能违反 CHECK (balance >= 0)
隔离性(Isolation)一个事务的中间状态不被不当观察或破坏未提交的扣款不应被另一个事务当成已生效
持久性(Durability)提交成功后,数据不会因普通故障丢失提交后的订单应能在重启后恢复

一个典型事务可以写成:

BEGIN;

UPDATE accounts
SET balance = balance - 80
WHERE account_id = 1;

UPDATE accounts
SET balance = balance + 80
WHERE account_id = 2;

COMMIT;

发生业务错误或数据库错误时,应回滚:

ROLLBACK;

BEGIN、COMMIT 和 ROLLBACK 在不同数据库中可能有别名或额外选项。应用程序还需要确认驱动是否默认开启自动提交(autocommit);自动提交开启时,每条语句可能都是一个独立事务。

没有并发控制会出现什么问题​

下面的异常名称是理解隔离级别的基础。为了便于说明,假设事务 A 和事务 B 同时运行。

脏读:读到未提交的数据​

  1. 事务 A 把余额从 100 改成 20,但还没有提交。
  2. 事务 B 读到 20。
  3. 事务 A 回滚,余额恢复为 100。
  4. 事务 B 使用了一个从未正式存在过的 20。

这就是脏读(dirty read)。它会让报表、风控或后续计算使用临时结果。

不可重复读:同一事务两次读取结果不同​

  1. 事务 A 读取订单状态,得到 pending。
  2. 事务 B 把状态改为 paid 并提交。
  3. 事务 A 在同一个事务中再次读取,得到 paid。

这就是不可重复读(non-repeatable read)。两次读取的是同一行,但结果不同。

幻读:同一范围内出现或消失了行​

  1. 事务 A 查询“金额大于 100 的订单”,得到 5 行。
  2. 事务 B 插入一笔符合条件的新订单并提交。
  3. 事务 A 再次执行同样的范围查询,得到 6 行。

新增的第 6 行像凭空出现的“幻象”,因此称为幻读(phantom read)。它关注的是符合条件的行集合变化,而不只是某一行的列值变化。

丢失更新:后写入覆盖先写入​

  1. 事务 A 和事务 B 都读到库存为 10。
  2. A 计算出 9,B 也计算出 9。
  3. A 写入 9,B 随后也写入 9。
  4. 实际卖出两件,但库存只减少了 1 件。

这叫丢失更新(lost update)。它常由“先 SELECT,再在应用层计算,最后 UPDATE”的流程造成。

写偏差:每个事务都合法,组合结果却违规​

假设至少一名值班医生在岗。A 看到 B 在岗,于是把自己设为休息;B 同时看到 A 在岗,也把自己设为休息。两个事务分别都满足“不能让最后一名医生离岗”的检查,但一起提交后无人值班。

这类写偏差(write skew)说明:仅锁住各自要更新的行,未必能保护跨行的业务规则。需要更高的隔离级别、显式锁定约束范围,或重新设计数据模型。

隔离级别:在正确性和并发量之间取舍​

隔离级别规定事务可以看到什么,以及数据库需要阻止哪些并发异常。SQL 标准常见四个级别如下:

隔离级别通常要避免的异常直观理解
Read Uncommitted几乎不保证可能读到别人的未提交修改,通常只适合特殊场景
Read Committed脏读每次语句只读已提交数据,但同一事务的两次语句可能看到不同结果
Repeatable Read脏读、不可重复读同一事务重复读取已读数据时结果更稳定;幻读和写偏差需看实现
Serializable目标是避免可串行化异常并发结果等价于事务按某个顺序一个个执行,代价通常更高

可以在事务开始时设置级别(具体语法可能不同):

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;

-- 事务操作

COMMIT;

要注意三点:

  1. 级别名称不代表所有数据库实现完全相同。 例如,某些数据库在 REPEATABLE READ 下使用快照,某些数据库还会通过锁阻止特定范围写入。
  2. 默认级别因数据库而异。 不能把开发环境的默认值当作所有生产环境都一样。
  3. 更高隔离级别可能导致等待、死锁或序列化失败。 应在真实负载下测试,而不是只追求最高级别。

语句快照与事务快照​

基于 MVCC 的数据库会为读取选择某个数据快照。常见的两种行为是:

  • 语句级快照:每条 SELECT 开始时获得一个快照,同一事务的不同语句可能看到不同提交结果;
  • 事务级快照:事务第一次读取或事务开始时固定快照,同一事务的后续读取更稳定。

这只是帮助理解的模型。快照建立时机、写入冲突处理和是否读取自己的修改都依赖具体数据库。

数据库如何实现并发控制​

悲观锁:先锁住,再操作​

悲观并发控制假设冲突很可能发生,因此在读取或修改前先取得锁。常见锁类型包括:

  • 共享锁(S):多个事务可以同时读取,但通常不能让其他事务修改同一对象;
  • 排他锁(X):持有者可以修改,其他事务通常不能再读取或修改该对象;
  • 行锁与表锁:锁的粒度不同。行锁并发度更高,表锁管理成本较低但更容易阻塞无关操作;
  • 意向锁:表级标记,用于说明事务准备在其中的行上加锁,帮助数据库协调不同粒度的锁。

部分数据库支持在查询时请求锁:

BEGIN;

SELECT account_id, balance
FROM accounts
WHERE account_id = 1
FOR UPDATE;

-- 根据已锁定的余额执行扣款
UPDATE accounts
SET balance = balance - 80
WHERE account_id = 1
AND balance >= 80;

COMMIT;

FOR UPDATE 是常见但非通用的语法。它通常表示锁定查询到的行,直到事务提交或回滚;不同数据库对范围、索引缺失、锁等待和快照读的处理可能不同。

乐观并发控制:提交时检测冲突​

乐观并发控制假设冲突较少,读取时不长期持锁,而是在更新时确认数据仍是自己读到的版本。最简单的做法是增加版本号:

-- 应用先读到 version = 7
UPDATE products
SET stock = stock - 1,
version = version + 1
WHERE product_id = :product_id
AND version = 7
AND stock > 0;

应用检查受影响行数:

  • 受影响行数为 1:说明版本仍匹配,更新成功;
  • 受影响行数为 0:说明库存不足、记录不存在或版本已变化,需要重新读取、提示冲突或放弃操作。

这种“比较并更新”必须是一条原子 UPDATE,不能拆成独立的查询和更新,否则中间仍可能被其他事务插入竞态。

时间戳也可以作为版本字段,但整数版本号通常更明确。版本字段应由数据库或受控的更新语句维护,不能让客户端任意覆盖。

MVCC:读旧版本,写新版本​

MVCC 会为数据保留多个版本,使读操作通常可以读取符合快照的旧版本,而不必和普通写入长期互相阻塞。它常带来以下特征:

  • 读写之间的阻塞更少,读并发通常更好;
  • 旧版本需要清理,长事务可能阻碍回收并造成存储膨胀;
  • MVCC 不会自动解决所有写写冲突、丢失更新或跨行约束问题;
  • “读到某个快照”不等于“提交时一定成功”,写入仍可能因冲突而失败或需要重试。

MVCC 的版本保存方式、可见性规则和垃圾回收机制是数据库实现细节。使用 MVCC 也仍然需要正确选择隔离级别和事务边界。

通过 SQL 设计避免竞态​

用原子更新代替先读后写​

不安全的思路是先把库存读到应用中,再计算新值。更稳妥的写法把条件和计算放进同一条更新:

UPDATE products
SET stock = stock - :quantity
WHERE product_id = :product_id
AND stock >= :quantity;

然后检查受影响行数。为 0 时,可能是库存不足或商品不存在,应用应明确区分这两种业务结果。数据库会保证单条语句的原子执行,但“更新成功”不代表后续发送消息、扣款等外部操作也已经成功。

用约束保护不变量​

能交给数据库约束的规则,不要只放在应用层:

CREATE TABLE seat_reservations (
reservation_id BIGINT PRIMARY KEY,
event_id BIGINT NOT NULL,
seat_no VARCHAR(20) NOT NULL,
user_id BIGINT NOT NULL,
UNIQUE (event_id, seat_no)
);

两个事务同时抢同一座位时,唯一约束会让其中一个插入失败。应用需要捕获唯一性冲突,并把它转换成“座位已被占用”等可理解的业务结果。约束名称和错误码因数据库及驱动而异。

缩短事务并保持访问顺序​

事务中只做必要的数据库工作:

  • 在事务外完成网络请求、文件读取和复杂计算;
  • 尽早提交或回滚,不要让事务跨越用户思考时间;
  • 多个事务访问同一组资源时,尽量按一致顺序加锁;
  • 不要在事务中等待不受控的外部服务。

事务越长,持有锁或快照的时间通常越久,发生阻塞、死锁和版本膨胀的机会也越大。

死锁、锁等待与超时​

死锁是怎样形成的​

两个事务互相等待对方持有的锁,就形成死锁:

  1. 事务 A 锁住行 1,等待行 2;
  2. 事务 B 锁住行 2,等待行 1;
  3. 两者都无法继续。

数据库通常会检测死锁并主动回滚其中一个事务;没有检测到死锁时,也可能先触发锁等待超时。应用必须把这类失败视为可预期的并发结果,而不是罕见到可以忽略的异常。

处理策略​

  1. 统一加锁顺序:例如始终按 account_id 从小到大锁定账户。
  2. 缩小事务范围:减少持锁时间和锁的数量。
  3. 使用合适的索引:让更新尽快定位目标行,避免无意中扫描并锁住更多行。
  4. 有限重试:对明确可重试的死锁、序列化失败或瞬时锁超时,采用有限次数、带随机退避的重试。
  5. 保证幂等:重试前确认事务已经回滚,外部副作用不会被重复执行。

重试不是万能方案。若业务逻辑本身不满足幂等,或者锁冲突持续存在,应先修正事务设计和访问顺序。

事务边界与应用代码​

数据库只能控制自己的事务,应用还要正确管理连接和异常:

开始事务
-> 执行必要的读写
-> 任何一步失败:回滚并返回可处理的错误
-> 全部成功:提交
结束事务并归还连接

常见注意事项:

  • 从连接池借出的连接可能保留上一次事务的隔离级别或未完成状态,归还前必须回滚或由框架确认清理;
  • COMMIT 返回成功后,才能把事务内结果视为已提交;
  • 提交后再发送消息可能出现“数据库成功、消息失败”,提交前发送则可能出现“消息成功、数据库回滚”;需要时使用事务消息、Outbox 等经过设计的方案;
  • 不要把数据库事务误认为能自动覆盖 HTTP 请求、消息队列和其他服务,这些属于分布式一致性问题。

如何选择控制方式​

可以按冲突模式做初步选择:

场景常见做法需要确认的事项
高频读取、写入冲突少MVCC 或乐观版本号冲突后的重试和用户提示
库存、余额等不能超卖的单行更新带条件的原子 UPDATE,必要时配合行锁受影响行数、索引和余额约束
抢座位、唯一名额唯一约束 + 插入冲突处理重复请求和错误映射
多行必须共同满足规则更高隔离级别或显式锁定规则涉及的范围锁范围、死锁和吞吐量
长时间编辑同一记录版本号或更新时间的乐观锁冲突合并策略

不要只根据“查询很快”或“锁越少越好”来决定方案。正确性不变量、冲突概率、事务时长、失败后的补偿和可观测性都要一起考虑。

调试和验证并发问题​

并发 bug 往往无法通过单次手工测试稳定复现。建议建立可重复的验证方法:

  1. 明确不变量,例如“库存永远不能小于 0”“一个座位最多一条有效预订”。
  2. 写出两个或多个事务的交错顺序,先用小数据复盘竞态。
  3. 使用数据库的锁等待、死锁、活动事务和执行计划工具观察实际行为。
  4. 用并发测试同时发起大量相同或相互冲突的事务,检查最终数据和失败比例。
  5. 覆盖提交、回滚、超时、死锁重试、连接断开和进程崩溃等情况。
  6. 记录事务耗时、锁等待时间、死锁次数、重试次数和序列化失败次数。

测试时要使用与生产接近的数据库产品、版本、索引和隔离级别。只在单线程测试或 SQLite 默认配置下验证通过,不能证明多实例生产环境没有并发问题。

一套实用检查清单​

  • 这个业务操作是否必须放在一个事务中?事务边界是否足够小?
  • 可能出现脏读、丢失更新、幻读、写偏差中的哪一种?
  • 当前数据库的默认隔离级别是什么,是否与代码假设一致?
  • 能否用原子更新、唯一约束或检查约束直接保护规则?
  • 如果采用乐观锁,如何判断冲突,冲突后如何重试或提示?
  • 如果采用悲观锁,锁住了哪些行或范围,是否有合适索引?
  • 多个事务的加锁顺序是否一致?死锁和锁超时如何处理?
  • 事务失败后,外部消息、支付或其他副作用如何保持一致?
  • 是否记录了锁等待、死锁、重试和长事务,并用并发测试验证过?

记住:并发控制不是简单地“把隔离级别调高”或“到处加锁”。先明确业务不变量,再选择原子语句、约束、锁、MVCC 和重试策略的组合,才能在数据正确性与系统吞吐量之间取得平衡。