10|并发、行锁与丢失更新:读到的值可能已经过期

单个请求测试正确,不代表两个请求同时到达时仍正确。当前 recordFailedLogin() 正是学习并发控制的好案例。

1. 丢失更新是怎样发生的

假设数据库中 failed_login_count = 3,两个错误密码请求同时执行:

请求 A:读取 3
请求 B:读取 3
请求 A:在 JavaScript 中计算 4,写入 4
请求 B:在 JavaScript 中计算 4,写入 4
最终:4
正确结果本应是:5

两个请求都成功执行了 SQL,没有报错,却丢失了一次更新。这就是并发数据错误。

下面的写法有风险:

admin.failed_login_count += 1;
await admin.save();

admin 可能是事务外较早查询得到的快照。在它被使用之前,其他请求已经修改了同一行。

2. 当前项目的行锁方案

当前 recordFailedLogin() 使用托管事务和 FOR UPDATE:

await db.transaction(async (transaction) => {
  const current_admin = await AdminUser.findByPk(admin.id, {
    transaction,
    lock: transaction.LOCK.UPDATE,
  });

  if (current_admin === null) {
    throw invalidCredentialsError();
  }

  const next_failed_count = current_admin.failed_login_count + 1;

  await current_admin.update(
    { failed_login_count: next_failed_count },
    { transaction },
  );
});

MySQL 大致执行:

START TRANSACTION;

SELECT ...
FROM admin_user
WHERE id = ?
FOR UPDATE;

UPDATE admin_user
SET failed_login_count = ?
WHERE id = ?;

COMMIT;

FOR UPDATE 会对命中的管理员记录加排他行锁,直到事务提交或回滚。

3. 两个请求现在如何执行

请求 A 开启事务并锁住 admin_user(id=1),读到 3
请求 B 请求同一行锁,等待
请求 A 写入 4并提交,释放锁
请求 B 获得锁,此时重新读到 4
请求 B 写入 5并提交
最终:5

重点不是“锁住 JavaScript 对象”,而是数据库对索引记录协调并发事务。

4. 锁为什么必须放在事务中

如果没有显式事务:

await AdminUser.findByPk(id, {
  lock: true,
});

查询结束时自动提交边界也随之结束,锁可能立即释放,后面的更新不能受到保护。

正确结构始终是:

开启事务 → 锁定并读取 → 计算 → 更新 → 提交释放锁

锁只应覆盖维护不变量所需的最短区域。

5. 为什么事务内必须重新查询

登录开始时查出的 admin 用于密码验证,这是合理的。但记录失败次数时,必须重新查询:

const current_admin = await AdminUser.findByPk(admin.id, {
  transaction,
  lock: transaction.LOCK.UPDATE,
});

原因:

传入的旧实例可以提供稳定主键,但不能作为最新状态来源。

6. 第五次失败锁定的原子决策

业务规则:第 5 次连续失败后锁定 15 分钟。事务中应基于锁定后读到的最新值计算:

const previous_failed_count =
  current_admin.locked_until !== null &&
  current_admin.locked_until.getTime() <= failed_at.getTime()
    ? 0
    : current_admin.failed_login_count;

const next_failed_count = previous_failed_count + 1;
const should_lock = next_failed_count >= MAX_FAILED_LOGIN_ATTEMPTS;

await current_admin.update(
  {
    failed_login_count: next_failed_count,
    locked_until: should_lock
      ? new Date(failed_at.getTime() + LOGIN_LOCK_DURATION_MS)
      : null,
  },
  { transaction },
);

计数和 locked_until 在同一条 UPDATE 中保存,避免出现“计数已经 5,但锁定时间没有写入”的中间状态。

判断必须使用 next_failed_count。如果用旧值判断,就会到第 6 次失败才锁定。

7. 行锁依赖合适的索引

当前查询使用主键 id:

WHERE id = ? FOR UPDATE

MySQL 能通过主键精确定位记录,锁范围清楚。如果使用无索引条件扫描大量行,锁的范围和阻塞影响可能显著扩大。

因此锁查询应做到:

8. 另一种方案:原子 increment()

简单计数可让数据库直接自增:

await AdminUser.increment(
  { failed_login_count: 1 },
  { where: { id: admin.id } },
);

大致 SQL 是:

UPDATE admin_user
SET failed_login_count = failed_login_count + 1
WHERE id = ?;

它避免“读 3、写 4”的丢失更新。但当前业务还需要:

如果先 increment() 再查询决定锁定,仍需处理这两步之间的并发。可以把逻辑写成更复杂的条件 SQL,但对当前学习项目,事务 + 行锁更易读、更容易证明正确。

9. 条件 UPDATE:用影响行数表达竞争结果

某些状态流转可以不用先锁再读:

const [affected_count] = await AdminUser.update(
  { account_status: AdminAccountStatus.Disabled },
  {
    where: {
      id,
      account_status: AdminAccountStatus.Active,
    },
  },
);

if (affected_count === 0) {
  // 行不存在,或已经不是 Active。
}

这相当于数据库级 compare-and-set:只有状态仍符合预期才更新。优点是少一次往返、锁时间短;缺点是复杂计算和失败原因区分会更难。

10. 乐观锁与悲观锁

悲观锁

SELECT ... FOR UPDATE 假设竞争可能发生,先锁住再处理。

适合:

代价是等待、死锁风险和连接占用。

乐观锁

通过版本号判断读取后是否被别人修改:

UPDATE admin_user
SET failed_login_count = ?, row_version = row_version + 1
WHERE id = ? AND row_version = ?;

影响 0 行表示发生冲突,调用方重新读取并重试。Sequelize Model 也支持 version 配置,但需要专门的版本列和冲突处理。

适合竞争较少、重试成本低的编辑场景。当前 token_version 有 JWT 撤销语义,不应顺便拿来充当通用 ORM 乐观锁版本列。

11. 死锁并不一定说明代码“完全错误”

如果两个事务以相反顺序锁两行:

事务 A:锁管理员 1 → 等管理员 2
事务 B:锁管理员 2 → 等管理员 1

双方循环等待,InnoDB 会检测死锁并回滚其中一个事务。

最佳实践:

12. 有限重试的基本形态

示意代码:

async function runWithDeadlockRetry<T>(
  operation: () => Promise<T>,
  max_attempts = 3,
): Promise<T> {
  for (let attempt = 1; attempt <= max_attempts; attempt += 1) {
    try {
      return await operation();
    } catch (error: unknown) {
      const retryable = isMysqlDeadlockError(error);
      if (!retryable || attempt === max_attempts) {
        throw error;
      }
    }
  }

  throw new Error("unreachable");
}

生产代码还应加入很短的随机退避,并通过 mysql2/Sequelize 错误结构可靠识别死锁,而不是只匹配自然语言 message。

为什么强调重试整个事务:被回滚后,事务里的所有读取结论都可能过期,不能只重跑最后一条 UPDATE。

为什么强调幂等:如果事务 callback 中还发送了邮件,重试会重复发送。这也是外部副作用不应放进事务 callback 的原因。

13. 锁等待超时与死锁不同

二者都应记录:操作、表、关键 ID、尝试次数和 request ID,但不要记录密码、Token。不能对所有数据库错误无限重试。

14. 并发测试怎么做

串行测试无法验证行锁。一个简单测试思路:

await Promise.all([
  recordFailedLogin(admin, fixed_time),
  recordFailedLogin(admin, fixed_time),
]);

const reloaded = await AdminUser.findByPk(admin.id);

初始计数为 3 时,最终应为 5,并且 locked_until 应被设置。

需要注意:

更深入的锁观察可以在本地数据库查看:

SHOW ENGINE INNODB STATUS;

只在受控测试环境制造阻塞,不要让调试事务长时间不提交。

15. 初学者常犯的错误

  1. 以为 Node.js 单线程就没有数据库并发;多个请求的异步 I/O 仍会交错,多进程更明显。
  2. 用事务外 Model 实例做最新值计算。
  3. FOR UPDATE 没有放在事务中。
  4. 用无索引条件加锁,意外扩大阻塞范围。
  5. 分两条 UPDATE 保存必须一致的状态。
  6. 无限重试所有数据库错误,造成雪崩。
  7. 只重试失败 SQL,而不重跑已回滚的整个事务。
  8. 多行锁没有固定顺序,增加死锁概率。

16. 练习

  1. 用两个并发 Promise 验证计数从 3 正确变成 5。
  2. 去掉 FOR UPDATE 重复运行测试,尝试观察丢失更新。
  3. 用条件 UPDATE 实现“只允许 Active → Disabled”,检查影响行数。
  4. 设计两个会以相反顺序锁管理员的本地实验,再把锁顺序统一修复。
  5. 比较行锁方案与原子 increment():分别写出它们适合的业务规则。

17. 复盘清单

下一章将把这些规则放进多个日常案例,训练“何时需要事务、事务应该包到哪里”的判断能力。