并发控制介绍
并发控制(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 同时运行。
脏读:读到未提交的数据
- 事务 A 把余额从 100 改成 20,但还没有提交。
- 事务 B 读到 20。
- 事务 A 回滚,余额恢复为 100。
- 事务 B 使用了一个从未正式存在过的 20。
这就是脏读(dirty read)。它会让报表、风控或后续计算使用临时结果。
不可重复读:同一事务两次读取结果不同
- 事务 A 读取订单状态,得到
pending。 - 事务 B 把状态改为
paid并提交。 - 事务 A 在同一个事务中再次读取,得到
paid。
这就是不可重复读(non-repeatable read)。两次读取的是同一行,但结果不同。
幻读:同一范围内出现或消失了行
- 事务 A 查询“金额大于 100 的订单”,得到 5 行。
- 事务 B 插入一笔符合条件的新订单并提交。
- 事务 A 再次执行同样的范围查询,得到 6 行。
新增的第 6 行像凭空出现的“幻象”,因此称为幻读(phantom read)。它关注的是符合条件的行集合变化,而不只是某一行的列值变化。
丢失更新:后写入覆盖先写入
- 事务 A 和事务 B 都读到库存为 10。
- A 计算出 9,B 也计算出 9。
- A 写入 9,B 随后也写入 9。
- 实际卖出两件,但库存只减少了 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;
要注意三点:
- 级别名称不代表所有数据库实现完全相同。 例如,某些数据库在
REPEATABLE READ下使用快照,某些数据库还会通过锁阻止特定范围写入。 - 默认级别因数据库而异。 不能把开发环境的默认值当作所有生产环境都一样。
- 更高隔离级别可能导致等待、死锁或序列化失败。 应在真实负载下测试,而不是只追求最高级别。
语句快照与事务快照
基于 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)
);
两个事务同时抢同一座位时,唯一约束会让其中一个插入失败。应用需要捕获唯一性冲突,并把它转换成“座位已被占用”等可理解的业务结果。约束名称和错误码因数据库及驱动而异。
缩短事务并保持访问顺序
事务中只做必要的数据库工作:
- 在事务外完成网络请求、文件读取和复杂计算;
- 尽早提交或回滚,不要让事务跨越用户思考时间;
- 多个事务访问同一组资源时,尽量按一致顺序加锁;
- 不要在事务中等待不受控的外部服务。
事务越长,持有锁或快照的时间通常越久,发生阻塞、死锁和版本膨胀的机会也越大。
死锁、锁等待与超时
死锁是怎样形成的
两个事务互相等待对方持有的锁,就形成死锁:
- 事务 A 锁住行 1,等待行 2;
- 事务 B 锁住行 2,等待行 1;
- 两者都无法继续。
数据库通常会检测死锁并主动回滚其中一个事务;没有检测到死锁时,也可能先触发锁等待超时。应用必须把这类失败视为可预期的并发结果,而不是罕见到可以忽略的异常。
处理策略
- 统一加锁顺序:例如始终按
account_id从小到大锁定账户。 - 缩小事务范围:减少持锁时间和锁的数量。
- 使用合适的索引:让更新尽快定位目标行,避免无意中扫描并锁住更多行。
- 有限重试:对明确可重试的死锁、序列化失败或瞬时锁超时,采用有限次数、带随机退避的重试。
- 保证幂等:重试前确认事务已经回滚,外部副作用不会被重复执行。
重试不是万能方案。若业务逻辑本身不满足幂等,或者锁冲突持续存在,应先修正事务设计和访问顺序。
事务边界与应用代码
数据库只能控制自己的事务,应用还要正确管理连接和异常:
开始事务
-> 执行必要的读写
-> 任何一步失败:回滚并返回可处理的错误
-> 全部成功:提交
结束事务并归还连接
常见注意事项:
- 从连接池借出的连接可能保留上一次事务的隔离级别或未完成状态,归还前必须回滚或由框架确认清理;
COMMIT返回成功后,才能把事务内结果视为已提交;- 提交后再发送消息可能出现“数据库成功、消息失败”,提交前发送则可能出现“消息成功、数据库回滚”;需要时使用事务消息、Outbox 等经过设计的方案;
- 不要把数据库事务误认为能自动覆盖 HTTP 请求、消息队列和其他服务,这些属于分布式一致性问题。
如何选择控制方式
可以按冲突模式做初步选择:
| 场景 | 常见做法 | 需要确认的事项 |
|---|---|---|
| 高频读取、写入冲突少 | MVCC 或乐观版本号 | 冲突后的重试和用户提示 |
| 库存、余额等不能超卖的单行更新 | 带条件的原子 UPDATE,必要时配合行锁 | 受影响行数、索引和余额约束 |
| 抢座位、唯一名额 | 唯一约束 + 插入冲突处理 | 重复请求和错误映射 |
| 多行必须共同满足规则 | 更高隔离级别或显式锁定规则涉及的范围 | 锁范围、死锁和吞吐量 |
| 长时间编辑同一记录 | 版本号或更新时间的乐观锁 | 冲突合并策略 |
不要只根据“查询很快”或“锁越少越好”来决定方案。正确性不变量、冲突概率、事务时长、失败后的补偿和可观测性都要一起考虑。
调试和验证并发问题
并发 bug 往往无法通过单次手工测试稳定复现。建议建立可重复的验证方法:
- 明确不变量,例如“库存永远不能小于 0”“一个座位最多一条有效预订”。
- 写出两个或多个事务的交错顺序,先用小数据复盘竞态。
- 使用数据库的锁等待、死锁、活动事务和执行计划工具观察实际行为。
- 用并发测试同时发起大量相同或相互冲突的事务,检查最终数据和失败比例。
- 覆盖提交、回滚、超时、死锁重试、连接断开和进程崩溃等情况。
- 记录事务耗时、锁等待时间、死锁次数、重试次数和序列化失败次数。
测试时要使用与生产接近的数据库产品、版本、索引和隔离级别。只在单线程测试或 SQLite 默认配置下验证通过,不能证明多实例生产环境没有并发问题。
一套实用检查清单
- 这个业务操作是否必须放在一个事务中?事务边界是否足够小?
- 可能出现脏读、丢失更新、幻读、写偏差中的哪一种?
- 当前数据库的默认隔离级别是什么,是否与代码假设一致?
- 能否用原子更新、唯一约束或检查约束直接保护规则?
- 如果采用乐观锁,如何判断冲突,冲突后如何重试或提示?
- 如果采用悲观锁,锁住了哪些行或范围,是否有合适索引?
- 多个事务的加锁顺序是否一致?死锁和锁超时如何处理?
- 事务失败后,外部消息、支付或其他副作用如何保持一致?
- 是否记录了锁等待、死锁、重试和长事务,并用并发测试验证过?
记住:并发控制不是简单地“把隔离级别调高”或“到处加锁”。先明确业务不变量,再选择原子语句、约束、锁、MVCC 和重试策略的组合,才能在数据正确性与系统吞吐量之间取得平衡。