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,
});
原因:
- Argon2 验证期间另一个请求可能已经更新计数;
- 旧实例不属于当前事务的一致读取;
FOR 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 能通过主键精确定位记录,锁范围清楚。如果使用无索引条件扫描大量行,锁的范围和阻塞影响可能显著扩大。
因此锁查询应做到:
- 使用主键或合适索引;
- 条件尽量精确;
- 查询和更新采用一致的定位条件;
- 用
EXPLAIN检查访问路径。
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”的丢失更新。但当前业务还需要:
- 判断是否正好达到阈值;
- 设置
locked_until; - 锁定过期后把新周期从 1 开始。
如果先 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 会检测死锁并回滚其中一个事务。
最佳实践:
- 多行操作始终按同一顺序加锁,例如按
id ASC; - 事务尽可能短;
- 不在持锁期间等待网络或用户输入;
- 精确索引,减少不必要锁;
- 捕获并记录死锁信息;
- 对确认安全、幂等的整个事务做有限重试。
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 应被设置。
需要注意:
- 使用真实 MySQL/InnoDB,而不是假 Model;
- 测试连接池至少允许两个并发连接;
- 每次测试准备独立管理员;
- 测试结束清理数据;
- 固定
failed_at,避免时间边界造成随机失败。
更深入的锁观察可以在本地数据库查看:
SHOW ENGINE INNODB STATUS;
只在受控测试环境制造阻塞,不要让调试事务长时间不提交。
15. 初学者常犯的错误
- 以为 Node.js 单线程就没有数据库并发;多个请求的异步 I/O 仍会交错,多进程更明显。
- 用事务外 Model 实例做最新值计算。
FOR UPDATE没有放在事务中。- 用无索引条件加锁,意外扩大阻塞范围。
- 分两条 UPDATE 保存必须一致的状态。
- 无限重试所有数据库错误,造成雪崩。
- 只重试失败 SQL,而不重跑已回滚的整个事务。
- 多行锁没有固定顺序,增加死锁概率。
16. 练习
- 用两个并发 Promise 验证计数从 3 正确变成 5。
- 去掉
FOR UPDATE重复运行测试,尝试观察丢失更新。 - 用条件 UPDATE 实现“只允许 Active → Disabled”,检查影响行数。
- 设计两个会以相反顺序锁管理员的本地实验,再把锁顺序统一修复。
- 比较行锁方案与原子
increment():分别写出它们适合的业务规则。
17. 复盘清单
- [ ] 读取到的数据在写入前是否可能已过期?
- [ ] 能否用单条原子或条件 UPDATE 解决?
- [ ] 若要读后计算,是否在事务内重新查询并加精确行锁?
- [ ] 行锁查询是否使用索引?
- [ ] 多行锁是否采用一致顺序?
- [ ] 事务是否足够短?
- [ ] 重试是否有限、只针对明确可重试错误,并重跑整个事务?
- [ ] 并发测试是否使用真实 MySQL 和至少两个连接?
下一章将把这些规则放进多个日常案例,训练“何时需要事务、事务应该包到哪里”的判断能力。