11|事务最佳实践案例集:先判断边界,再选择工具

这一章不再从 API 出发,而是用日常业务训练判断:是否需要事务、事务应该覆盖哪些 SQL、哪些事情不能放进去。

1. 一张判断表

准备写 db.transaction() 前,先回答:

问题 如果答案是“是”
多条数据库操作必须共同成功或失败吗? 考虑事务
需要“读取当前值后决定如何更新”吗? 考虑事务 + 行锁或条件更新
两个请求可能同时修改同一行吗? 明确并发策略
只有一条原子 SQL 吗? 通常不需要额外事务
包含 HTTP、邮件、文件、JWT 等数据库外副作用吗? 不能指望数据库事务回滚它
操作失败后可能自动重试吗? 先保证幂等

事务解决的是数据库一致性,不是一个“防止所有错误”的保护罩。

2. 案例一:更新管理员显示名

需求只有一条更新:

const [affected_count] = await AdminUser.update(
  { display_name },
  { where: { id: admin_id } },
);

通常不需要额外事务。这条 UPDATE 自身具有原子性。

应关注的反而是:

**常见错误:**看到“更新”就包事务,增加代码和连接占用,却没有新增任何一致性保证。

3. 案例二:创建管理员

当前创建首管理员只写 admin_user 一行,INSERT 本身原子,单纯为它开启事务没有明显价值。

但“必须保证表为空才能创建”涉及并发:

进程 A 查询 count = 0
进程 B 查询 count = 0
进程 A 创建 admin_a
进程 B 创建 admin_b

事务本身也不会自动解决这件事。对空表执行 SELECT ... FOR UPDATE 没有一条现存管理员记录可锁,锁行为还会受索引和隔离级别影响。

更可靠的设计选项包括:

这说明:“使用事务”不等于“并发安全”,必须知道锁住了什么。

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 },
  );
});

这里需要先明确产品选择:如果审计写入暂时失败,登录是否也必须失败?

没有脱离业务要求的“唯一最佳答案”。但不能直接 catch 审计异常后忽略,同时又宣称审计完整。

Token 放在哪里

jwt.sign() 不需要数据库事务。比较清楚的顺序是:

  1. 验证密码;
  2. 验证 JWT 配置并签发内存中的 Token;
  3. 执行成功状态事务;
  4. 事务成功后才把 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 });
  }
}

原则:

12. 事务重试前检查幂等性

可以考虑重试:

不应盲目重试:

13. 一套实践检查顺序

每次设计事务可按下面顺序写在代码注释或评审描述中:

  1. 不变量:什么必须共同成立?
  2. 资源:涉及哪些表、行和数据库?
  3. 并发:另一个请求可能怎样交错?
  4. 原子方式:单条 SQL、条件更新、行锁还是乐观锁?
  5. 边界:哪些计算放事务外,哪些 SQL 放事务内?
  6. 失败:异常是否会继续抛出并触发回滚?
  7. 副作用:是否有数据库无法回滚的动作?
  8. 重试:是否幂等、哪些错误允许有限重试?
  9. 验证:怎样用并发测试和故障注入证明设计?

14. 初学者常犯的错误

  1. 认为只要使用 transaction() 就不会有并发错误。
  2. 没写业务不变量,事务范围忽大忽小。
  3. 事务内做 Argon2、HTTP、邮件和文件操作。
  4. 忘记把 transaction 传给一条 Model 调用。
  5. catch 后吞错,托管事务反而提交。
  6. 在响应已发送后才等待事务提交。
  7. 对所有数据库异常无限重试。
  8. 把 token_version 混用成通用乐观锁版本。
  9. 认为空表检查放进事务就自然杜绝第二个初始化管理员。

15. 练习

练习一:故障注入

在测试库实现“成功登录状态 + 审计记录”,在两条操作之间故意 throw,确认两张表都没有部分结果。

练习二:遗漏事务参数

故意让审计 create() 不传 transaction,再抛错。观察哪部分被回滚,并解释使用了不同连接/提交边界的原因。

练习三:第五次失败

把管理员计数设为 4,同时发出两个错误密码请求。验证:

练习四:锁定过期

设置过去的 locked_until 和旧失败计数,再发起一次失败。验证新一轮计数从 1 开始。

练习五:做决策

分别判断以下需求是否需要事务,并写一句不变量:

16. 复盘清单

掌握事务的标志不是会写 db.transaction(),而是能解释:锁了哪一行、另一个请求会在哪里等待、失败时哪些结果会消失、哪些外部动作不会消失。