NestJS 后端开发 / 第 14 课
NestJS 数据层、异步读写与事务
从 Entity 和 Repository 追踪数据如何进入数据库,理解异步操作、事务与迁移的职责。
上一课的单例服务可以跨请求保存内存对象,但进程重启会丢失数据,多个进程也不会自动共享它;OJ 的提交记录要能在稍后被 Worker 读取并产生评测结果。数据库负责持久保存数据。Entity描述程序对象如何映射到表和列,Repository提供按条件查询、保存和更新的接口。NestJS 注入 Repository,让 Service 执行业务步骤,而不是把 SQL 和 HTTP 处理全塞进控制器。
例如 Submission Entity 有 userId、problemId、language、status 等列,也通过关系关联题目和用户。项目用 TypeORM 连 MySQL。submissionRepo.save({...}) 是异步写入:数据库需要网络往返,函数要 await 等待结果;findOne({ where: { id } }) 是异步读取,查不到时得到空值,Service 再决定返回 404。save 完成才说明持久化请求完成,不代表后续评测任务也已完成。
看懂 SQL 实际做了什么
假设提交 ID 是 72,查询某个用户的提交可以写成:
SELECT id, problemId, status
FROM submission
WHERE userId = 18
ORDER BY id DESC
LIMIT 20;
这段教学 SQL 表达从提交表读取用户 18 最近 20 条记录的意图。实际表名、列名和必须写入的字段需要对照 Entity 与迁移,下面的事务示意也不能直接用于生产数据。WHERE 过滤行,ORDER BY 排序,LIMIT 限制数量。Repository 的 find 或查询构造器最终也要表达类似的 SQL;传入条件并不意味着数据已保存在本地变量中。数据库索引可加速常用过滤,但会占存储且让写入维护索引更费事,应由真实查询需要来决定。
多次写入要考虑原子性
重判一条提交时,系统可能需要先保存旧成绩、清空当前结果,再将状态置为待评测。如果中途失败,就不能留下只有一半更新的数据。事务把相关语句作为一个单元提交或回滚:
START TRANSACTION;
INSERT INTO rejudge_log (submissionId, status) VALUES (72, 1);
UPDATE submission SET status = 9 WHERE id = 72;
COMMIT;
START TRANSACTION 开始事务,COMMIT 确认更改;遇到错误时应用应执行 ROLLBACK,放弃该事务的改动。TypeORM 的 manager.transaction(async manager => ...) 会给回调提供事务管理器;事务中的读写都要用这个 manager,不能一部分走事务、一部分偷偷使用普通 Repository。事务通常只覆盖数据库内相关操作,队列消息、发邮件等外部副作用不能因 SQL 回滚自动撤销。
数据库结构也会变化。迁移用有序脚本把表从旧结构升级到新结构,例如新增 judgeAttempt 列;上线时按迁移版本应用变更,必要时设计回退方案。不要把实体同步当作生产变更计划:框架自动改表会让环境状态难以审阅。项目数据库模块根据运行环境配置自动迁移与 synchronize,这反映的是该版本当前实现;学习示例不应连接或启动数据库。
纯内存 mock 可以测试 Service 如何调用 save 或如何处理空结果,但它不是 MySQL:不会验证真实 SQL、事务隔离、索引、约束或迁移是否正确。需要数据库语义的验证应使用隔离测试库;此课只读示例,不运行数据库。
小练习
一次事务中 INSERT 成功、UPDATE 抛错且事务回滚后,插入还应留下吗?用一个内存 mock 通过测试,是否能证明 MySQL 的 ROLLBACK 正确?
**答案:**在同一事务中,回滚应撤销插入和更新。内存 mock 只能证明测试替身的逻辑,不能证明真实数据库行为;需要对隔离的数据库做集成验证。
固定版本源码
互动练习:让转账保持原子性
账户 A 向 B 转 20;扣款成功后若入账失败,不能留下只扣不加的状态。按事务边界排列。
- 两步成功后提交;异常则回滚
- 给 B 增加 20
- 扣除 A 的 20
- 开启数据库事务
用 ↑ ↓ 调整顺序,再检查答案。也可以用 Tab 和回车操作。
Entity 映射数据结构,Repository 执行持久化读写,事务保证一组相关操作共同成功或回滚,迁移管理表结构变化。