09|Sequelize 事务基础:让一组操作共同成功或失败

事务不是“给查询加一层保险”,而是用来维护明确的业务不变量。本章先掌握 Sequelize 6 的 Transaction API,再判断事务边界。

1. 从业务不变量开始

假设登录成功时必须同时:

  1. 清零 failed_login_count;
  2. 清除 locked_until;
  3. 更新 last_login_at;
  4. 写入一条 admin_login_audit 成功记录。

如果业务规定这四项必须共同成功或共同失败,这就是一个事务边界。

不变量:只要存在“登录成功”的审计记录,管理员状态也必须已更新成功。

先写出这句话,再决定使用事务;不要因为看到两条 SQL 就机械地加事务。

2. ACID 在当前案例中的含义

事务不能替代约束。唯一索引、外键、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 的特殊流程,但更容易犯错:

当前项目绝大多数 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 请求。常见做法是:

8.3 JWT 不受数据库事务控制

jwt.sign() 只在内存里生成字符串。数据库 ROLLBACK 不会“撤销”这个字符串。当前登录代码先签发、再更新数据库,但只有整个函数成功才把 Token 返回客户端,因此 DB 更新失败时 Token 不会被交付。

9. 哪些操作不必使用事务

通常单条 SQL 本身就是原子的:

await AdminUser.update(
  { token_version: db.literal("token_version + 1") },
  { where: { id } },
);

如果业务只有这一条更新,不需要为了“更专业”再包事务。

常见不需要事务的场景:

需要事务的判断题不是“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 错误中间件传播。

建议:

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. 初学者常犯的错误

  1. 开了事务,但部分 Model 调用漏传 transaction。
  2. catch 后只打印不抛出,导致托管事务提交。
  3. 托管事务里手动 commit() / rollback()。
  4. 把 Argon2、HTTP、邮件等耗时工作放进事务。
  5. 使用事务外旧实例计算事务内的新值。
  6. 认为事务能撤销 JWT、消息或外部系统操作。
  7. 为单条普通更新无条件开事务,浪费连接又增加复杂度。
  8. 用更高隔离级别掩盖没有定义清楚的并发规则。

14. 练习

  1. 修改“成功登录 + 审计记录”示例,让审计插入故意违反 CHECK,验证状态更新也回滚。
  2. 故意遗漏审计 create() 的 transaction,观察部分写入,再解释原因。
  3. 把非托管事务改成托管事务,并列出减少了哪些失败路径。
  4. 写一句“不需要事务”的单条管理员显示名更新,说明为什么。
  5. 为“修改密码 + token_version + 1”写业务不变量,再决定是否用事务。

15. 复盘清单

下一章专门讨论并发:为什么“读取、JavaScript 中加一、再写回”会丢失更新,以及 FOR UPDATE 如何解决当前登录计数问题。

Actor / Customer 的场景补充见 26 什么时候需要事务、27 悲观与乐观控制、29 reload 的事务可见性。