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:回滚,然后向外传播错误
关键规则:
- 每条有关查询,包括检查、Raw SQL、reload,都显式传同一个 transaction。
- 每个操作都 await,或返回它的 Promise 链。
- callback 中不手工 commit/rollback。
- 失败时抛错,不把失败吞成一个正常返回值。
- 外层 Promise 成功后再发送 HTTP 成功响应。
不要这样:
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. 一分钟判断清单
- 哪些写入必须全成功或全失败?
- 只用一条原子 SQL 是否足够?
- 先查再写的条件会不会被并发改变?
- 需要统一锁协议、条件更新还是版本号?
- 读结果是否必须来自同一个快照?
- 是否包含数据库无法回滚的外部操作?
练习:解释 createCustomer 与 createCustomerWithAddress 的事务需求差别;再解释为什么 updateCustomerStatus 光有 transaction 还不完整。
下一章:悲观锁、乐观锁与 MySQL 锁。参考:v6 Transactions。