27 悲观锁、乐观锁与 MySQL 锁:解决的不是同一个问题
对应项目:Actor 关系操作、客户停用。SQL 写入实验只在独立练习库运行,本轮教程编写不执行建表或写入。
1. 有事务,为什么还会丢失更新?
初始 token_version=5。两个请求都先查到 5,再各自写回 6:
| 时间 | 请求 A | 请求 B |
|---|---|---|
| 1 | 读到 5 | |
| 2 | 读到 5 | |
| 3 | 写回 6 | |
| 4 | 写回 6 |
期望加两次得到 7,实际为 6。即使 UPDATE 阶段数据库自动加了写锁,B 仍可能把过时的值写回。写操作串行,不等于“读取 + 决策 + 写入”整个业务流程串行。
只需加 1 时,SET token_version = token_version + 1 比读改写更简单。需要读取关系集合再修改时,才进一步考虑悲观或乐观控制。
2. 悲观锁:先排队,再读最新状态并修改
await db.transaction(async (transaction) => {
const actor = await Actor.findByPk(actor_id, {
attributes: ["actor_id"],
transaction,
lock: transaction.LOCK.UPDATE,
});
if (actor === null) throw new Error("演员不存在");
// 必须所有关系写入也传同一 transaction。
await FilmActor.destroy({ where: { actor_id }, transaction });
if (new_film_ids.length > 0) {
await FilmActor.bulkCreate(
new_film_ids.map((film_id) => ({ actor_id, film_id })),
{ transaction },
);
}
});
以上展示持锁结构,完整业务还需电影存在性检查、去重、数量限制和错误映射,见当前 Service。
等价 SQL:
START TRANSACTION;
SELECT actor_id FROM actor WHERE actor_id = :actor_id FOR UPDATE;
DELETE FROM film_actor WHERE actor_id = :actor_id;
INSERT INTO film_actor(actor_id, film_id) VALUES (...);
COMMIT;
第二个请求在获取相同 Actor 行锁时等待。前一个事务结束后再继续处理。悲观的意思是:假设竞争可能发生,先通过锁控制访问,而不是先写再检查冲突。
锁生命周期依赖事务;在 autocommit 的单条 SELECT 后锁就结束,无法保护下一条独立 UPDATE。
3. 为什么锁 Actor,而不是只锁 FilmActor?
关系集合可能为空,未必有现成 FilmActor 行可锁。不同修改可能涉及不同 film_id,单锁已有关系难以给“演员的整个关系集合”一个统一排队点。
Actor 行是稳定存在的父记录。attach、detach、replace 都先锁同一个 Actor,形成项目内的约定:
修改某演员的电影关系 → 先锁这个 Actor → 检查电影 → 修改 FilmActor
这不是 Actor 锁会神奇保护所有代码。任何绕过该协议的写入口仍可能破坏业务约定。FK 校验自身也可能获取锁,但不能拿偶然的 FK 等待行为当完整业务协议。
当前实现还用共享锁读取目标 Film,避免检查后电影在写入关系前被删除。尽量固定锁获取顺序;按 film_id 排序有助于降低竞争,但实际扫描/加锁顺序也受执行计划影响,不能宣称完全消灭死锁。
4. 客户停用:另一端也必须遵守协议
停用逻辑:锁 Customer → 检查未归还 Rental → 更新 active。
未来创建租赁逻辑也应:锁同一个 Customer → 检查 active → 创建 Rental。
停用先拿锁:创建租赁等待,醒来后看到已停用并拒绝
创建先拿锁:停用等待,醒来后看到新租赁并拒绝
这样检查与写入被排到一起。不要求对所有租赁 JOIN 一次性 FOR UPDATE;先只锁客户主体更容易控制锁范围。
如果新租赁入口不参与这个协议,单方面修改停用服务并不能证明系统并发安全。
5. 锁的几种叫法属于不同维度
| 维度 | 常见概念 | 要回答的问题 |
|---|---|---|
| 并发策略 | 悲观、乐观 | 冲突前排队,还是冲突后发现? |
| 锁模式 | 共享 S、排他 X | 哪些操作彼此兼容? |
| 锁范围 | 记录、间隙、临键、表等 | 保护哪部分数据/范围? |
| 读取机制 | MVCC 一致性读、锁定当前读 | 看历史版本,还是读取并锁定当前状态? |
不能把“乐观锁、共享锁、行锁”放在同一列表当成互斥三选一。
共享与排他
FOR UPDATE通常获取排他记录/范围锁:其他事务修改或获取不兼容锁时等待。- MySQL 8 SQL 的
FOR SHARE获取共享锁;多个共享读取可以共存,排他修改会等待。 - 当前 Sequelize MySQL 方言的
LOCK.SHARE通常生成LOCK IN SHARE MODE,不是必须逐字生成 FOR SHARE。
普通 SELECT 通常是 MVCC 一致性读,不会仅因为同一行被 FOR UPDATE 锁住就等待;它可能读到已提交旧版本。不要说“加行锁后别人都不能读取”。
记录、间隙与临键
InnoDB 锁与索引有关:
- 记录锁保护索引记录。
- 间隙锁保护索引记录之间的间隙,主要影响插入。
- 临键锁(next-key)结合记录与相邻间隙,常用于范围扫描保护。
完整唯一键等值查找命中存在记录时,通常锁范围较窄。范围条件、缺失记录、非唯一索引,以及不同隔离级别可能改变锁范围。不能把所有 FOR UPDATE 都描述成“只锁这一行”。
缺少合适索引可能扫描并锁住更多记录,造成大范围等待。MySQL 还有意向锁、元数据锁等;DDL 被长事务挡住,不一定是你看到的那条记录锁。
隔离级别与 MVCC
InnoDB 常见默认是 REPEATABLE READ,但以实际服务配置为准。READ COMMITTED 下间隙锁行为通常较少,外键与唯一性检查等仍有例外。
普通一致性读取与 FOR UPDATE/共享锁定读取不同,后者通常是当前读。不要先在事务外查状态,再把这个旧实例拿进事务写回。
6. 乐观锁:带着“我看过的版本”去更新
假想记录 version=3,客户端编辑完成后提交:
UPDATE document
SET content = :content, version = version + 1
WHERE document_id = :id AND version = 3;
若别人已更新为 version=4,本次条件不匹配,返回冲突。业务可让用户重新加载,或按规则重试;不能悄悄覆盖。
Sequelize 支持 Model 的 version: true 或指定版本字段,实例保存时检测版本冲突并可能抛 OptimisticLockError。
启用前必须真实创建版本列、定义 Model/TS 类型并规划迁移。当前 Actor/Customer 没有该版本列,本章不把 version: true 写入运行代码。静态批量 update 也不能想当然具有实例 save 的全部自动乐观保护。
手工条件更新更直观,但要检查影响行数,定义“缺失还是版本冲突”,并保证所有相关写入口都推进版本。
7. 原子更新、悲观锁、乐观锁怎么选?
- 纯计数/扣减:优先单条原子或条件 UPDATE。
- 短时间多步骤写入、冲突较频繁、需要读最新关系:事务 + 悲观锁。
- 用户打开页面编辑几分钟后保存、冲突相对少:版本号乐观锁,避免把数据库锁持有几分钟。
- 涉及多表共同提交:即使采用乐观控制,仍可能需要事务。
乐观锁不等于不用数据库锁;实际 UPDATE 仍会获取写锁,它描述的是业务如何发现冲突。
8. 双会话实验:亲眼看等待、旧版本与条件扣减
只在独立练习库建一张新表,不操作真实业务表;表已存在时先检查来源,不要自动覆盖:
CREATE TABLE sakila_lock_demo (
demo_id INT UNSIGNED NOT NULL PRIMARY KEY,
quantity INT NOT NULL,
revision INT UNSIGNED NOT NULL DEFAULT 0,
CONSTRAINT chk_sakila_lock_demo_quantity CHECK (quantity >= 0)
) ENGINE=InnoDB;
INSERT INTO sakila_lock_demo(demo_id, quantity) VALUES (1, 1);
先在两个窗口确认使用同一个练习库、不同连接:
SELECT DATABASE(), CONNECTION_ID(), @@transaction_isolation;
会话 A:
START TRANSACTION;
SELECT * FROM sakila_lock_demo WHERE demo_id = 1 FOR UPDATE;
-- 暂停操作,不提交。这里暂停的是人的实验操作,不是业务代码等待用户。
会话 B 的普通查询:
SELECT * FROM sakila_lock_demo WHERE demo_id = 1;
通常立即返回已提交 quantity=1。然后 B:
START TRANSACTION;
SELECT * FROM sakila_lock_demo WHERE demo_id = 1 FOR UPDATE;
-- 在这里等待 A 释放锁。
A 执行:
UPDATE sakila_lock_demo SET quantity = quantity - 1 WHERE demo_id = 1;
COMMIT;
B 的锁定读取继续,看到当前 quantity=0。B 再尝试:
UPDATE sakila_lock_demo
SET quantity = quantity - 1
WHERE demo_id = 1 AND quantity >= 1;
SELECT ROW_COUNT(); -- 0,没有成功扣减
ROLLBACK;
若改为 A 回滚,B 会看到 quantity=1。这分别演示锁等待、读取版本与事务回滚。
9. 等待、死锁与重试
锁等待:某事务等待另一个释放锁。死锁:A 等 B、B 又等 A,形成环;MySQL 通常选择一个事务回滚并报错。锁等待超时与死锁不是同一个错误。
排查起点:
SHOW ENGINE INNODB STATUS;
-- 有相应权限时,还可查看 performance_schema.data_locks / data_lock_waits。
实践建议:
- 所有入口固定“先父记录、再子记录”的顺序。
- 缩短事务,不在锁内请求第三方、计算密码摘要或等待用户。
- 给锁定条件建立合适索引,限制范围。
- 对确认可重试的死锁/竞争做有界重试,重新执行整个业务事务。
- 有外部副作用时先解决幂等,不直接重放整个 callback。
一个 UPDATE 超时后的数据库回滚范围还受配置影响,不应在 callback 中吞错然后继续提交;让托管事务失败并完整回滚更清楚。
10. 练习
解释 Actor 父行锁为什么适合作为关系集合的排队点;给未来的 Rental 创建入口写一段伪代码,说明它如何与停用入口使用同一个 Customer 锁;最后比较“长时间持锁编辑”和“提交时带版本号”的用户体验。
下一章:Model 类型与 DTO 答疑。参考:v6 Optimistic Locking、MySQL 8 InnoDB Locking。