09|Sequelize 事务基础:让一组操作共同成功或失败
事务不是“给查询加一层保险”,而是用来维护明确的业务不变量。本章先掌握 Sequelize 6 的 Transaction API,再判断事务边界。
1. 从业务不变量开始
假设登录成功时必须同时:
- 清零
failed_login_count; - 清除
locked_until; - 更新
last_login_at; - 写入一条
admin_login_audit成功记录。
如果业务规定这四项必须共同成功或共同失败,这就是一个事务边界。
不变量:只要存在“登录成功”的审计记录,管理员状态也必须已更新成功。
先写出这句话,再决定使用事务;不要因为看到两条 SQL 就机械地加事务。
2. ACID 在当前案例中的含义
- 原子性(Atomicity):状态更新和审计插入不会只成功一半。
- 一致性(Consistency):事务前后都满足数据库约束和业务不变量。
- 隔离性(Isolation):并发事务不会任意看到彼此未提交的中间状态。
- 持久性(Durability):提交成功后,数据由 MySQL/InnoDB 持久保存。
事务不能替代约束。唯一索引、外键、NOT NULL 仍然是数据库数据底线。
3. 托管事务:当前项目首选
Sequelize 的托管事务接收一个异步 callback:
import db from "../db";
await db.transaction(async (transaction) => {
await AdminUser.update(
{
failed_login_count: 0,
locked_until: null,
last_login_at: logged_in_at,
},
{
where: { id: admin.id },
transaction,
},
);
await AdminLoginAudit.create(
{
admin_user_id: admin.id,
attempted_username: admin.username,
login_result: 1,
},
{ transaction },
);
});
执行规则很重要:
callback 正常完成 → Sequelize COMMIT
callback 抛出/返回 rejected Promise → Sequelize ROLLBACK,再向外抛错
托管事务中不要手工调用 transaction.commit() 或 rollback(),否则会同时存在两套生命周期控制。
3.1 返回值
callback 的返回值会成为 db.transaction() 的结果:
const audit = await db.transaction(async (transaction) => {
return AdminLoginAudit.create(data, { transaction });
});
当外层拿到 audit 时,事务已经提交。如果提交失败,外层会收到异常,不会拿到成功结果。
4. 最容易漏掉的规则:每次操作都显式传入 transaction
下面代码看起来在事务中,实际 create() 没有加入该事务:
await db.transaction(async (transaction) => {
await AdminUser.update(data, { where, transaction });
// 错误:这条 SQL 使用连接池中的普通连接,可能立即提交。
await AdminLoginAudit.create(audit_data);
});
如果后面抛错,管理员更新会回滚,审计记录却可能保留下来。
正确写法:
await AdminLoginAudit.create(audit_data, { transaction });
在本项目中优先显式传递,而不是依赖隐式上下文。显式参数能让代码评审者一眼看出哪些 SQL 属于事务。
5. 捕获异常时不要把它吞掉
错误示例:
await db.transaction(async (transaction) => {
try {
await AdminLoginAudit.create(data, { transaction });
} catch (error: unknown) {
console.error(error);
// callback 正常结束,Sequelize 会提交事务。
}
});
如果失败意味着整个事务必须失败,应继续抛出:
await db.transaction(async (transaction) => {
try {
await AdminLoginAudit.create(data, { transaction });
} catch (error: unknown) {
throw new Error("记录管理员登录审计失败", { cause: error });
}
});
只有当某个异常本来就是可接受的分支时才可以处理后继续,而且要能说明为什么仍满足业务不变量。
6. 非托管事务
非托管模式由代码手工提交和回滚:
const transaction = await db.transaction();
try {
await AdminUser.update(data, { where, transaction });
await AdminLoginAudit.create(audit_data, { transaction });
await transaction.commit();
} catch (error: unknown) {
await transaction.rollback();
throw error;
}
它适合事务生命周期无法自然放进一个 callback 的特殊流程,但更容易犯错:
- 成功路径忘记
commit(); - 某个异常路径忘记
rollback(); - 提交后又尝试回滚;
- 提交/回滚本身失败时丢失原始错误。
当前项目绝大多数 Service 都可以使用托管事务,应优先选它。
7. 事务内读取与实例更新
当前 recordFailedLogin() 做了正确示范:事务内重新查,并把同一个事务传给更新:
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(
{ failed_login_count: current_admin.failed_login_count + 1 },
{ transaction },
);
});
事务外传入的 admin 是旧快照。并发敏感计算应使用事务内读取的 current_admin。
8. 事务应该尽量短
事务期间连接一直被占用,行锁可能阻塞其他请求。不要把无关耗时工作放进去。
8.1 Argon2 密码验证放在事务外
读取密码摘要 → Argon2 验证(CPU 密集)→ 只把必要状态更新放进短事务
如果验证时就开启事务,昂贵的密码计算会延长连接和锁持有时间。
8.2 外部 HTTP、邮件和文件上传通常放在事务外
外部调用可能几秒甚至超时,而 MySQL 事务无法撤回已经发送的邮件或外部 API 请求。常见做法是:
- 先提交数据库事务;
- 再执行可重试的外部动作;或
- 在事务内写 outbox 记录,由后台任务发送。
8.3 JWT 不受数据库事务控制
jwt.sign() 只在内存里生成字符串。数据库 ROLLBACK 不会“撤销”这个字符串。当前登录代码先签发、再更新数据库,但只有整个函数成功才把 Token 返回客户端,因此 DB 更新失败时 Token 不会被交付。
9. 哪些操作不必使用事务
通常单条 SQL 本身就是原子的:
await AdminUser.update(
{ token_version: db.literal("token_version + 1") },
{ where: { id } },
);
如果业务只有这一条更新,不需要为了“更专业”再包事务。
常见不需要事务的场景:
- 单条
INSERT; - 单条
UPDATE; - 单次只读查询(不要求跨多次读取保持同一快照);
- 密码 Hash/Verify;
- JWT 签发。
需要事务的判断题不是“SQL 有几条”,而是“中间失败或并发交错会不会破坏不变量”。
10. 隔离级别入门
MySQL InnoDB 常见默认隔离级别是 REPEATABLE READ,但应以实际环境配置为准:
SELECT @@transaction_isolation;
Sequelize 可以为事务指定隔离级别:
import { Transaction } from "sequelize";
await db.transaction(
{ isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED },
async (transaction) => {
// 查询与更新
},
);
不要因为不理解并发现象就盲目调到 SERIALIZABLE。更强隔离通常带来更多阻塞;很多场景应通过唯一约束、条件更新或精确行锁解决。
11. 错误处理与事务的边界
事务 callback 抛出的 Sequelize 错误最终仍会沿 Service → Controller → Express 5 错误中间件传播。
建议:
- 已知唯一约束冲突可在 Service 外层映射为安全
AppError; - 死锁、连接失败等未知错误保留为内部错误并记录;
- 不要在事务中把所有错误都包装成可公开的
AppError; - 包装普通
Error时可使用cause保留底层原因。
12. 可执行实验:观察回滚
在本地测试库中运行类似脚本:
await db.transaction(async (transaction) => {
await AdminUser.update(
{ display_name: "事务中的临时名称" },
{ where: { id: "1" }, transaction },
);
throw new Error("故意触发回滚");
});
捕获脚本错误后查询:
SELECT id, display_name
FROM admin_user
WHERE id = 1;
名称应保持原值。随后故意去掉 transaction 参数再实验,就能直观看到事务外更新不会随 callback 回滚。
危险提示:只在专用测试数据上做故障实验,不要修改真实管理员。脚本顶层要在
finally中关闭自己的 Sequelize 连接池。
13. 初学者常犯的错误
- 开了事务,但部分 Model 调用漏传
transaction。 - catch 后只打印不抛出,导致托管事务提交。
- 托管事务里手动
commit()/rollback()。 - 把 Argon2、HTTP、邮件等耗时工作放进事务。
- 使用事务外旧实例计算事务内的新值。
- 认为事务能撤销 JWT、消息或外部系统操作。
- 为单条普通更新无条件开事务,浪费连接又增加复杂度。
- 用更高隔离级别掩盖没有定义清楚的并发规则。
14. 练习
- 修改“成功登录 + 审计记录”示例,让审计插入故意违反
CHECK,验证状态更新也回滚。 - 故意遗漏审计
create()的transaction,观察部分写入,再解释原因。 - 把非托管事务改成托管事务,并列出减少了哪些失败路径。
- 写一句“不需要事务”的单条管理员显示名更新,说明为什么。
- 为“修改密码 +
token_version + 1”写业务不变量,再决定是否用事务。
15. 复盘清单
- [ ] 是否先写出了业务不变量?
- [ ] 所有应参与的 SQL 是否都显式传了同一个
transaction? - [ ] 托管 callback 出错后是否继续抛出?
- [ ] 事务内是否重新读取并发敏感数据?
- [ ] 是否把 CPU、网络、文件等耗时操作移到事务外?
- [ ] 事务是否尽可能短?
- [ ] 是否误以为事务能回滚数据库之外的副作用?
- [ ] 单条原子 SQL 是否被不必要地复杂化?
下一章专门讨论并发:为什么“读取、JavaScript 中加一、再写回”会丢失更新,以及 FOR UPDATE 如何解决当前登录计数问题。
Actor / Customer 的场景补充见 26 什么时候需要事务、27 悲观与乐观控制、29 reload 的事务可见性。