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 一致性读、锁定当前读 看历史版本,还是读取并锁定当前状态?

不能把“乐观锁、共享锁、行锁”放在同一列表当成互斥三选一。

共享与排他

普通 SELECT 通常是 MVCC 一致性读,不会仅因为同一行被 FOR UPDATE 锁住就等待;它可能读到已提交旧版本。不要说“加行锁后别人都不能读取”。

记录、间隙与临键

InnoDB 锁与索引有关:

完整唯一键等值查找命中存在记录时,通常锁范围较窄。范围条件、缺失记录、非唯一索引,以及不同隔离级别可能改变锁范围。不能把所有 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 仍会获取写锁,它描述的是业务如何发现冲突。

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。

实践建议:

  1. 所有入口固定“先父记录、再子记录”的顺序。
  2. 缩短事务,不在锁内请求第三方、计算密码摘要或等待用户。
  3. 给锁定条件建立合适索引,限制范围。
  4. 对确认可重试的死锁/竞争做有界重试,重新执行整个业务事务。
  5. 有外部副作用时先解决幂等,不直接重放整个 callback。

一个 UPDATE 超时后的数据库回滚范围还受配置影响,不应在 callback 中吞错然后继续提交;让托管事务失败并完整回滚更清楚。

10. 练习

解释 Actor 父行锁为什么适合作为关系集合的排队点;给未来的 Rental 创建入口写一段伪代码,说明它如何与停用入口使用同一个 Customer 锁;最后比较“长时间持锁编辑”和“提交时带版本号”的用户体验。

下一章:Model 类型与 DTO 答疑。参考:v6 Optimistic Locking、MySQL 8 InnoDB Locking。