11|事务最佳实践案例集:先判断边界,再选择工具
这一章不再从 API 出发,而是用日常业务训练判断:是否需要事务、事务应该覆盖哪些 SQL、哪些事情不能放进去。
1. 一张判断表
准备写 db.transaction() 前,先回答:
| 问题 | 如果答案是“是” |
|---|---|
| 多条数据库操作必须共同成功或失败吗? | 考虑事务 |
| 需要“读取当前值后决定如何更新”吗? | 考虑事务 + 行锁或条件更新 |
| 两个请求可能同时修改同一行吗? | 明确并发策略 |
| 只有一条原子 SQL 吗? | 通常不需要额外事务 |
| 包含 HTTP、邮件、文件、JWT 等数据库外副作用吗? | 不能指望数据库事务回滚它 |
| 操作失败后可能自动重试吗? | 先保证幂等 |
事务解决的是数据库一致性,不是一个“防止所有错误”的保护罩。
2. 案例一:更新管理员显示名
需求只有一条更新:
const [affected_count] = await AdminUser.update(
{ display_name },
{ where: { id: admin_id } },
);
通常不需要额外事务。这条 UPDATE 自身具有原子性。
应关注的反而是:
display_name是否已 trim 和校验长度;affected_count === 0是管理员不存在还是值未变化;- 是否需要权限检查;
- Model 和数据库列长度是否一致。
**常见错误:**看到“更新”就包事务,增加代码和连接占用,却没有新增任何一致性保证。
3. 案例二:创建管理员
当前创建首管理员只写 admin_user 一行,INSERT 本身原子,单纯为它开启事务没有明显价值。
但“必须保证表为空才能创建”涉及并发:
进程 A 查询 count = 0
进程 B 查询 count = 0
进程 A 创建 admin_a
进程 B 创建 admin_b
事务本身也不会自动解决这件事。对空表执行 SELECT ... FOR UPDATE 没有一条现存管理员记录可锁,锁行为还会受索引和隔离级别影响。
更可靠的设计选项包括:
- 只允许一次性部署任务运行,并由运维避免并发;
- 增加能由数据库唯一约束表达的初始化标记;
- 使用专门的锁表/命名锁方案,并清楚评估影响;
- 把创建首管理员设计为部署流程,而非公开 HTTP 接口。
这说明:“使用事务”不等于“并发安全”,必须知道锁住了什么。
4. 案例三:登录失败计数与临时锁定
业务不变量:每次密码失败只累计一次,第 5 次失败同时写入锁定截止时间,并发失败不能丢计数。
当前项目适合:托管事务 + 主键行锁 + 同一条更新。
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 lock_expired =
current_admin.locked_until !== null &&
current_admin.locked_until.getTime() <= failed_at.getTime();
const previous_count = lock_expired
? 0
: current_admin.failed_login_count;
const next_count = previous_count + 1;
await current_admin.update(
{
failed_login_count: next_count,
locked_until:
next_count >= MAX_FAILED_LOGIN_ATTEMPTS
? new Date(failed_at.getTime() + LOGIN_LOCK_DURATION_MS)
: null,
},
{ transaction },
);
});
为什么密码验证不在事务内:Argon2 是 CPU 密集操作,事务期间占连接、持锁只会增加阻塞。
为什么事务内重新查询:登录开始时得到的 Model 可能已被并发请求修改。
为什么一次 update() 同时写两列:计数和锁定时间属于同一个状态决策。
5. 案例四:成功登录与审计记录
假设新需求规定:管理员状态更新与成功审计必须共同完成。
await db.transaction(async (transaction) => {
const [affected_count] = await AdminUser.update(
{
failed_login_count: 0,
locked_until: null,
last_login_at: logged_in_at,
},
{
where: { id: admin.id },
transaction,
},
);
if (affected_count !== 1) {
throw new Error("登录成功状态更新失败");
}
await AdminLoginAudit.create(
{
admin_user_id: admin.id,
attempted_username: admin.username,
login_result: 1,
ip_address,
},
{ transaction },
);
});
这里需要先明确产品选择:如果审计写入暂时失败,登录是否也必须失败?
- 强一致:必须有审计才允许登录,用同一事务。
- 可用性优先:登录状态提交,审计通过可靠队列/outbox 异步补写。
没有脱离业务要求的“唯一最佳答案”。但不能直接 catch 审计异常后忽略,同时又宣称审计完整。
Token 放在哪里
jwt.sign() 不需要数据库事务。比较清楚的顺序是:
- 验证密码;
- 验证 JWT 配置并签发内存中的 Token;
- 执行成功状态事务;
- 事务成功后才把 Token 返回 Controller。
如果第 3 步失败,函数抛错,Token 没有交付。事务不能撤销已经交付的 Token,因此绝不能在 DB 结果确定前发送响应。
6. 案例五:失败登录审计是否与计数同事务
如果规则是“每增加一次失败计数,就必须有一条对应审计”,应放同一事务:
await db.transaction(async (transaction) => {
const current_admin = await AdminUser.findByPk(admin.id, {
transaction,
lock: transaction.LOCK.UPDATE,
});
if (current_admin === null) {
throw invalidCredentialsError();
}
await current_admin.update(next_state, { transaction });
await AdminLoginAudit.create(
{
admin_user_id: current_admin.id,
attempted_username: current_admin.username,
login_result: 2,
ip_address,
},
{ transaction },
);
});
账号不存在时 admin_user_id 是 NULL,但仍可保存输入用户名快照:
await AdminLoginAudit.create({
admin_user_id: null,
attempted_username: input.username,
login_result: 2,
ip_address,
});
不存在管理员行可供锁定,也没有失败计数需要更新,所以这只是单条插入,不必额外开事务。
这里的 login_result=2 表示凭据错误。实际代码应使用 TypeScript enum 代替魔法数字;公开响应仍应统一为“用户名或密码错误”,审计记录也绝不能保存密码。
7. 案例六:修改密码并撤销旧 Token
业务不变量:新密码摘要与新 token_version 必须同时生效。
Argon2 Hash 应在事务外计算:
const new_password_hash = await hashPassword(new_password);
await db.transaction(async (transaction) => {
const current_admin = await AdminUser.findByPk(admin_id, {
transaction,
lock: transaction.LOCK.UPDATE,
});
if (current_admin === null) {
throw new Error("管理员不存在");
}
await current_admin.update(
{
password_hash: new_password_hash,
password_changed_at: changed_at,
token_version: current_admin.token_version + 1,
},
{ transaction },
);
});
为什么三列同一条更新:不能出现密码已改但旧 Token 仍有效,或 Token 已撤销但密码没改的半完成状态。
并发修改密码是否允许“后提交覆盖先提交”,需要额外业务决策。行锁避免计算时丢失 token_version 增量,但不会替你决定哪个密码修改请求应该胜出。
8. 案例七:订单与明细
虽然当前工程没有订单,这仍是最常见的事务模式:
const order = await db.transaction(async (transaction) => {
const created_order = await Order.create(order_data, { transaction });
await OrderItem.bulkCreate(
items.map((item) => ({
order_id: created_order.id,
product_id: item.product_id,
quantity: item.quantity,
unit_price: item.unit_price,
})),
{ transaction },
);
return created_order;
});
不变量:订单头和所有订单明细共同存在。价格应保存下单时快照,不能只依赖当前商品价格。
如果还要扣库存,必须设计条件更新或行锁;事务不会自动防止库存变负。
9. 案例八:转账
转账需要同时扣款、入账并记录流水:
锁定账户(固定 ID 顺序)
→ 检查余额
→ 扣付款方
→ 加收款方
→ 写转账流水
→ 提交
固定锁顺序可减少死锁:
const ordered_ids = [from_account_id, to_account_id].sort(compareBigIntIds);
资金字段应使用 DECIMAL 或明确的最小货币单位整数,不能用浮点数。转账请求还需要业务幂等键,防止客户端重试造成重复扣款;事务本身不识别“这是同一个 HTTP 请求”。
10. 事务不能解决的五类问题
10.1 重复请求
网络超时后客户端可能重试。解决方案是唯一业务键或幂等表,例如 request_id 唯一索引,而不是仅仅包事务。
10.2 已发送的邮件或消息
MySQL 回滚不了 SMTP、HTTP 或消息队列中已经发生的动作。需要 outbox、幂等消费者或补偿流程。
10.3 已返回的 HTTP 响应或 JWT
响应一旦发送无法撤回。应先完成数据库提交,再向客户端宣布成功。
10.4 多个独立数据库
一个 Sequelize Transaction 通常只绑定一个数据库连接。跨库一致性需要分布式事务、Saga/outbox 等更复杂设计。
10.5 不正确的业务逻辑
事务能原子地提交错误计算。范围约束、唯一索引、测试和代码评审仍然必要。
11. 事务中的错误处理模板
优先让托管事务自动回滚:
export async function changeSomething(input: Input): Promise<Result> {
try {
return await db.transaction(async (transaction) => {
// 所有数据库操作显式传 transaction
// 不满足规则时直接 throw
return result;
});
} catch (error: unknown) {
if (isExpectedUniqueConstraint(error)) {
throw new AppError(ErrorCodes.CONFLICT, "业务编号已存在");
}
throw new Error("修改业务数据失败", { cause: error });
}
}
原则:
- callback 内抛错触发回滚;
- 外层只映射明确、可公开的已知错误;
- 未知错误保留
cause,交给统一日志记录; - 不记录密码、JWT、数据库 Secret;
- 不通过中文
message猜数据库错误类型。
12. 事务重试前检查幂等性
可以考虑重试:
- 确认是死锁或短暂锁冲突;
- 整个事务可以从头重新执行;
- callback 内只有数据库操作,或外部操作具备幂等性;
- 有严格最大次数和退避;
- 最终失败仍向上抛出并记录。
不应盲目重试:
- 唯一约束冲突;
- 外键错误;
- 校验错误;
- SQL/程序缺陷;
- 已经发送不可撤回外部动作的流程。
13. 一套实践检查顺序
每次设计事务可按下面顺序写在代码注释或评审描述中:
- 不变量:什么必须共同成立?
- 资源:涉及哪些表、行和数据库?
- 并发:另一个请求可能怎样交错?
- 原子方式:单条 SQL、条件更新、行锁还是乐观锁?
- 边界:哪些计算放事务外,哪些 SQL 放事务内?
- 失败:异常是否会继续抛出并触发回滚?
- 副作用:是否有数据库无法回滚的动作?
- 重试:是否幂等、哪些错误允许有限重试?
- 验证:怎样用并发测试和故障注入证明设计?
14. 初学者常犯的错误
- 认为只要使用
transaction()就不会有并发错误。 - 没写业务不变量,事务范围忽大忽小。
- 事务内做 Argon2、HTTP、邮件和文件操作。
- 忘记把
transaction传给一条 Model 调用。 - catch 后吞错,托管事务反而提交。
- 在响应已发送后才等待事务提交。
- 对所有数据库异常无限重试。
- 把
token_version混用成通用乐观锁版本。 - 认为空表检查放进事务就自然杜绝第二个初始化管理员。
15. 练习
练习一:故障注入
在测试库实现“成功登录状态 + 审计记录”,在两条操作之间故意 throw,确认两张表都没有部分结果。
练习二:遗漏事务参数
故意让审计 create() 不传 transaction,再抛错。观察哪部分被回滚,并解释使用了不同连接/提交边界的原因。
练习三:第五次失败
把管理员计数设为 4,同时发出两个错误密码请求。验证:
- 最终计数没有丢失;
locked_until已设置;- 两条失败审计都存在(如果业务采用强一致审计)。
练习四:锁定过期
设置过去的 locked_until 和旧失败计数,再发起一次失败。验证新一轮计数从 1 开始。
练习五:做决策
分别判断以下需求是否需要事务,并写一句不变量:
- 修改显示名;
- 修改密码并递增
token_version; - 登录时只写一条审计;
- 创建订单及三条明细;
- 上传头像到对象存储并更新 URL。
16. 复盘清单
- [ ] 事务是否对应一条明确业务不变量?
- [ ] 单条 SQL 能否更简单地完成?
- [ ] 是否处理并发交错,而不只是正常串行路径?
- [ ] 行锁查询是否使用精确索引,且事务内重新读取?
- [ ] 所有相关 Model 调用是否传入同一个
transaction? - [ ] callback 异常是否会向外传播?
- [ ] 是否在提交前避免发送 HTTP 响应或交付 Token?
- [ ] 数据库外副作用是否采用 outbox、幂等或补偿方案?
- [ ] 重试是否有限,并且整个操作可安全重放?
- [ ] 是否有故障注入和真正的并发测试?
掌握事务的标志不是会写 db.transaction(),而是能解释:锁了哪一行、另一个请求会在哪里等待、失败时哪些结果会消失、哪些外部动作不会消失。