26 什么时候需要 transaction?从业务不变量判断

对应项目:Customer Service、Actor 关系写入。基础 API 见 09 章。

1. 先别数 SQL 条数,先问失败后允许留下什么

业务不变量是“系统无论成功失败都应保持的规则”。例如:

前两项需要共同提交/回滚;第三项还涉及并发条件。transaction 可以保证一组数据库操作的原子性,但不自动保证所有业务规则在并发下成立。

2. 常见场景与推荐

场景 推荐起点 原因
只查一页客户 普通查询 没有必须共同提交的写入
创建一条使用现有地址的客户 单条 INSERT + FK 数据库可保证这一条写入与引用完整性
创建地址再创建客户 同一事务 第二步失败必须撤销第一步
删除旧关系再插入新关系 同一事务;并发时再加锁协议 不允许部分替换
token_version 加 1 单条原子更新 不需要先读再写
停用前检查租赁 事务 + 统一客户锁协议 检查与写入之间可能有新租赁
多查询组成严格时点报表 明确快照与隔离级别 普通多次查询可能看到不同状态

单条 DML 在 InnoDB 中本来就参加事务,通常由 autocommit 自动提交。“不显式传 transaction”不等于数据库没有事务。

3. 地址 + 客户:最典型的多写事务

当前实现的结构:

return db.transaction(async (transaction) => {
  // 在当前 Service 中实际还会检查 store、city 并构造字段白名单。
  const address = await Address.create(address_data, { transaction });
  const customer = await Customer.create({
    ...customer_data,
    address_id: address.address_id,
  }, { transaction });

  await customer.reload({
    attributes: customer_list_attributes,
    include: customer_address_include,
    transaction,
  });

  return toCustomerListItemDto(customer);
});

这里变量表示该 Service 已准备好的完整创建数据、投影及 include;不是可直接粘贴运行的独立函数。

SQL 时间线:

START TRANSACTION;
-- 查询并确认相关父记录
INSERT INTO address (...) VALUES (...);
INSERT INTO customer (..., address_id) VALUES (..., :new_address_id);
SELECT ... FROM customer JOIN ... WHERE customer_id = :new_customer_id;
COMMIT;

任一步失败:

ROLLBACK;

地址和客户一起撤销。注意自增 ID 可能留下空洞,回滚不保证编号连续,这不是数据损坏。

4. 托管事务的 Promise 决定提交还是回滚

db.transaction(callback)
  → 分配事务连接、开始事务
  → await callback 的结果
     ├─ fulfilled:提交,提交成功后外层 Promise 才成功
     └─ rejected:回滚,然后向外传播错误

关键规则:

不要这样:

await db.transaction(async (transaction) => {
  try {
    await Customer.create(data, { transaction });
  } catch (error: unknown) {
    console.error(error);
    // 没有重新 throw,callback 正常结束,可能提交前面已经成功的操作。
  }
});

当前 DTO 在 callback 中转换:若 include 或映射出了程序错误,两个创建操作仍能回滚。并非“任何 DTO 都必须占着事务做”,而是这里转换快速,并且创建成功响应依赖它;外部 HTTP、文件上传、邮件不适合这样塞进事务。

5. 替换关系:事务解决半成品,锁解决交错

事务至少保证:

START TRANSACTION;
DELETE FROM film_actor WHERE actor_id = :actor_id;
INSERT INTO film_actor(actor_id, film_id) VALUES (...), (...);
COMMIT;

新电影不存在或 INSERT 失败,DELETE 回滚,旧关系保留。

但两个请求同时替换同一演员关系,可能交错或竞争,甚至发生死锁。当前实现先对 Actor 行 FOR UPDATE,并要求 attach/detach/replace 采用相同协议,让同一演员的关系修改串行化。

电影是否存在应在删除旧关系之前验证;事务不能代替输入校验。重复关系仍由复合主键约束兜底。

6. 只读也可能需要事务,但不是所有详情都需要

客户详情包含基础资料、最近租赁、支付总额、未归还数量;它们由多个 SELECT 获取。

普通查询下:基础资料时点 A,统计时点 B,可能有小幅变化。当前管理详情如果接受这种一致性,不必为了“专业”一律加事务。

严格报告需要定义一致性目标。例如使用 InnoDB REPEATABLE READ 下同一事务的普通一致性读取,让数据查询共享快照:

await db.transaction({
  isolationLevel: Transaction.ISOLATION_LEVELS.REPEATABLE_READ,
}, async (transaction) => {
  const customer = await Customer.findByPk(customer_id, { transaction });
  const statistics = await db.query<StatisticsRow>(statistics_sql, {
    type: QueryTypes.SELECT,
    replacements: { customer_id },
    transaction,
  });
  return { customer, statistics };
});

Transaction 从 Sequelize 导入,StatisticsRow/statistics_sql 由具体报表定义。快照通常在首次一致性读取时建立;锁定读取是另一套“当前读”语义,不应随意混为同一历史快照。

长事务会占连接并延长旧版本保留。只读报表也要限制时间与数据量。

7. 不写显式多语句事务,有哪些替代方案?

A. 原子更新,去掉读改写

await AdminUser.increment("token_version", {
  by: 1,
  where: { id: admin_id },
});
UPDATE admin_user SET token_version = token_version + 1 WHERE id = :id;

不再先查询旧值,因此两个请求不会都写回同一个“旧值 + 1”。需要基于新值做复杂决策时,还要进一步设计。

B. 单条条件更新,检查影响行数

库存扣减的思想示例:

UPDATE stock
SET quantity = quantity - 1
WHERE stock_id = :id AND quantity >= 1;

stock 是教学假想表,不是现有 Sakila inventory;后者的业务结构不同。

检查扣减是否成功,避免先 SELECT 数量再写回。但如果还要创建订单,两步仍需要共同提交。涉及“另一张表不存在某记录”的复杂条件,也不能未经分析就认定一条条件 SQL 解决所有跨表竞态。

MySQL/驱动对 matched rows 和 changed rows 的返回配置可能不同。一般更新返回 0,不总能直接判断“记录不存在”;需要结合条件、是否确实发生变化及当前驱动行为。库存成功扣减必然改变值,相对容易判定。

C. 数据库约束

唯一索引解决重复用户名,FK 解决无效引用,CHECK 解决部分行内范围规则。先查再写只是友好提示,数据库约束是并发下的兜底。

约束不能表达所有跨记录业务规则,例如“停用客户前必须没有未归还租赁”。

D. 幂等与业务流程调整

把“创建可复用地址”和“创建客户”明确拆成两个独立操作,可能允许地址单独存在;这是修改业务边界,不是保持原有原子保证的魔法。

幂等键可避免重复提交,但它不自动保证地址和客户共同回滚。通常仍要靠唯一约束、事务或专门状态机保证幂等记录与业务写入一致。

E. 补偿与最终一致性

“第二步失败就再删除地址”是补偿,不等于数据库回滚:补偿也可能失败,或地址已被别人引用。跨服务流程可以使用状态机、重试、Outbox/可靠消息与补偿,但保证不同、复杂度也更高;本项目同库写入优先用事务。

8. 数据库事务不能回滚外部副作用

发出邮件 → 数据库 INSERT 失败 → ROLLBACK

邮件不会自动收回。HTTP 请求、文件、第三方扣款同理。

简单项目可在提交成功后发通知,同时接受通知失败与重试问题。重要通知可在同一事务内写 Outbox 记录,提交后由工作进程投递,配合幂等消费;这部分是进阶设计,不要求现在引入框架。

9. 一分钟判断清单

  1. 哪些写入必须全成功或全失败?
  2. 只用一条原子 SQL 是否足够?
  3. 先查再写的条件会不会被并发改变?
  4. 需要统一锁协议、条件更新还是版本号?
  5. 读结果是否必须来自同一个快照?
  6. 是否包含数据库无法回滚的外部操作?

练习:解释 createCustomer 与 createCustomerWithAddress 的事务需求差别;再解释为什么 updateCustomerStatus 光有 transaction 还不完整。

下一章:悲观锁、乐观锁与 MySQL 锁。参考:v6 Transactions。