29 答疑:await、reload 与事务可见性

对应项目:createCustomerWithAddress()、replaceActorFilms()。

1. 忘记 await 不是“晚一点执行而已”

const actor = Actor.findByPk(actor_id);
if (actor === null) {
  throw new Error("演员不存在");
}

actor 是 Promise<Actor | null>,不是查询结果。Promise 对象本身不等于 null,这个判断无法判断演员是否存在。

如果查询用于获取锁,漏 await 还意味着可能在锁真正获取前就继续业务步骤。

正确:

const actor = await Actor.findByPk(actor_id, {
  transaction,
  lock: transaction.LOCK.UPDATE,
});
if (actor === null) throw new Error("演员不存在");

不要仅靠类型检查发现所有异步错误。某些未使用的 Promise 表达式完全可以通过编译;异步流程测试仍很重要。

2. reload 实际做了什么?

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

它按实例的身份重新查询数据库,再更新当前实例中的数据和关联。不是只把内存里的字段“整理一下”。

当前客户写入后 reload,是为了拿到 address/city/country 等 DTO 所需关联,不是每次 create 都必须加 reload。只返回已知创建字段时可能不需要额外查询。

重新加载会更新当前实例状态;不要把 reload 当“只读副本操作”,也不要依赖它保留尚未保存的本地修改。

3. 漏 await:DTO 先跑,关联还没加载

customer.reload({ include: customer_address_include, transaction });
return toCustomerListItemDto(customer);

时间线可能是:

发起 reload SQL
  → mapper 立即访问 customer.address
  → address 尚未加载,抛错
  → reload 后续才完成或失败

事务 callback 若提前结束,还可能在未完成查询后提交/回滚,引出“已结束事务仍被使用”或未处理 Promise 错误。

这不是 optional 类型太严格,而是执行顺序真的不对。修复是 await,不是给 address 加非空断言。

4. 漏 transaction:可能换了连接

连接池的含义是 Sequelize 可以使用多个数据库连接,并非所有 SQL 都固定在同一连接。

事务连接 A:START → INSERT address → INSERT customer(未提交)
普通连接 B:SELECT customer(没有参加事务 A)

B 通常看不到 A 尚未提交的新客户。因此:

await customer.reload({ include: customer_address_include });

可能查不到它,并报:Instance could not be reloaded because it does not exist anymore。

正确的写法同时具有两个要求:

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

await 保证顺序;transaction 保证参加同一个事务、使用对应连接与可见性。两者解决不同问题。

如果池很小,外部查询还可能等待可用连接,而事务又在等待这条查询,最终超时。扩大连接池不是修复遗漏 transaction 的办法。

5. “does not exist anymore”一定是被删除了吗?

不一定。它表示 reload 查询没有获得可用实例;可能原因包括:

当前项目没有因此就需要加读写分离配置。排查方法是看 reload 实际 SQL、参数、事务标识和连接角色,不根据一句错误文案猜数据丢失。

6. “模型实例创建时传过 transaction”不等于之后自动继承

const customer = await Customer.create(data, { transaction });
await customer.update(patch, { transaction });
await customer.reload({ transaction });

每个相关数据库操作都显式传递。项目没有依赖 CLS 自动事务上下文;别靠猜实例会永久记住创建事务。

事务对象也不能存成全局变量复用。callback 结束后它已提交或回滚,后续请求需要自己的事务。

7. DTO 放事务内还是外?

放外面:

const customer = await db.transaction(async (transaction) => {
  // 创建、reload
  return instance;
}); // 到这里已经提交

return toCustomerListItemDto(customer); // 若这里失败,写入不能再回滚

会出现“数据库写入成功,但接口报错”。客户端重试可能再次创建。

当前选择:

return db.transaction(async (transaction) => {
  // 创建、await reload,并全部传 transaction
  return toCustomerListItemDto(instance);
});

快速 DTO 映射在提交前验证响应数据契约,失败则回滚。不要把慢速渲染、外部 API 或邮件误认为也是“DTO 转换”然后长期持锁。

这也不是绝对规则:只读查询或映射已充分保证时,可以在事务外转换,减少事务时长。关键是知道失败后的数据库状态。

8. HTTP 报错不等于数据库回滚

事务提交成功 → 网络断开 → 客户端没收到响应

数据库不会自动撤销已提交操作。反过来,Express 返回 500,也不能据此证明没有写入。

重要写接口应考虑请求幂等、业务唯一标识和可查询结果。AbortSignal / 客户端取消也不自动把已提交的事务回滚,参见 请求取消章。

不要在事务 callback 中提前 res.json():响应可能已经发成功,随后 COMMIT 却失败。Controller 等待 Service 的外层事务 Promise 完成后再响应。

9. catch 应放在哪里?

想把数据库约束异常转换为项目业务错误时:

return db.transaction(async (transaction) => {
  // 数据库步骤,任何失败都让 Promise reject
  return dto;
}).catch((error: unknown) => {
  // 此处失败事务已走过回滚处理。
  // 识别已知约束并抛 AppError;未知异常继续 throw。
  throw error;
});

在 callback 内 catch 也可以,但必须理解是否应该重新 throw。记录日志后返回 undefined,托管事务会认为 callback 成功,可能提交部分结果。

未知连接错误、SQL 字段错误不应全部改成“客户不存在”。它们属于内部异常,要保留诊断信息给日志。

10. 重试应该重做什么?

死锁后不能只重试最后一个 INSERT,前面读取的状态与锁已失效。通常需要重新开始整个可重试的业务事务。

也不能无条件重试任何错误:输入非法、FK 不存在、唯一约束冲突等往往需要业务处理。含邮件、第三方调用等副作用的 callback 不能直接重放。

11. 最小测试清单

  1. reload 真正完成后才调用 mapper。
  2. Address.create、Customer.create、Raw 检查与 reload 的 transaction 是同一个对象。
  3. 故意让 Customer 创建失败,真实 MySQL 中不留下新 Address。
  4. 让 DTO 映射失败,检查事务是否回滚。
  5. 让 COMMIT 失败/模拟服务失败,Controller 不应提前响应成功。

只看 Mock 被调用不足以证明未提交记录的可见性,至少保留一个真实 MySQL 集成测试。

下一章:查询调试与验证答疑。