第27篇RDB 写一半数据不一致用事务边界和补偿日志兜住摘要RDB 写一半数据不一致常见于主表已经插入、明细表或索引表失败页面却提示保存成功。关系型数据库适合复杂关系数据但多表写入必须有清晰事务边界如果业务还涉及文件、网络或跨模块状态也要留下补偿日志方便启动后恢复或提醒用户重试。我见过一个课程计划保存问题计划主表写入成功课时明细写到第三条时报错。页面重启后能看到一个空计划点进去没有课时删除又删不干净。根因不是 RDB 本身不可靠而是业务把一次保存拆成了多处写入中间失败没有事务也没有恢复记录。这篇文章解决四个实际问题如何识别哪些写入必须放在同一个事务里。如何把 Service、Repository、事务适配器分清楚。为什么事务之外还要保留补偿日志。如何做启动扫描和发布前一致性验证。先画出一次保存会写哪些表多表不一致通常不是 SQL 写错而是保存链路没有账本。数据作用是否必须和主表一起提交plan课程计划主记录是plan_lesson计划下的课时明细是plan_index首页检索摘要可重建失败后可补偿本地图片文件计划封面不适合直接放进 RDB 事务表结构之间有强一致关系时要进入同一个事务能从事实数据重建的摘要可以失败后重建但要有标记。用事务适配器隔离底层 API文章示例不绑定具体 RDB API 调用把事务能力抽成适配器。真实项目里再用关系型数据库的事务对象去实现。exportinterfaceRdbTransaction{insert(table:string,value:Recordstring,string|number):PromisevoiddeleteWhere(table:string,field:string,value:string):Promisevoid}exportinterfaceRdbTransactionAdapter{runInTransaction(task:(tx:RdbTransaction)Promisevoid):Promisevoid}这样 Service 只表达“这组写入必须原子完成”底层是 RDB 事务、测试替身还是内存实现都不影响业务边界。Repository 只做表级写入Repository 不决定一个业务事务包含哪些表它只提供表级操作。exportinterfaceCoursePlanDraft{id:stringtitle:stringupdatedAt:numberlessons:LessonDraft[]}exportinterfaceLessonDraft{id:stringtitle:stringsortNo:number}exportclassCoursePlanRepository{asyncinsertPlan(tx:RdbTransaction,draft:CoursePlanDraft):Promisevoid{awaittx.insert(plan,{id:draft.id,title:draft.title,updatedAt:draft.updatedAt})}asyncinsertLesson(tx:RdbTransaction,planId:string,lesson:LessonDraft):Promisevoid{awaittx.insert(plan_lesson,{id:lesson.id,planId,title:lesson.title,sortNo:lesson.sortNo})}}这里不弹 Toast不写页面状态也不处理重试。Repository 的边界越窄事务编排越容易读懂。Service 把主表和明细放进同一个事务Service 负责定义业务原子性。课程计划保存时主表和明细要么一起成功要么都不落库。exportclassCoursePlanService{constructor(privatereadonlytxAdapter:RdbTransactionAdapter,privatereadonlyrepository:CoursePlanRepository,privatereadonlyrepairLog:PlanRepairLog){}asyncsavePlan(draft:CoursePlanDraft):Promisevoid{awaitthis.repairLog.markSaving(draft.id)awaitthis.txAdapter.runInTransaction(async(tx:RdbTransaction){awaitthis.repository.insertPlan(tx,draft)for(constlessonofdraft.lessons){awaitthis.repository.insertLesson(tx,draft.id,lesson)}})awaitthis.repairLog.markDone(draft.id)}}repairLog不替代事务它处理的是事务之外的可恢复线索。比如应用崩溃、文件写入失败、摘要重建失败都需要知道哪次保存未完成。补偿日志只记录意图不存完整业务副本补偿日志太重会变成第二套数据库。只记录操作 id、阶段和必要摘要即可。exportinterfaceRepairEntry{opId:stringbusinessId:stringstage:saving|indexing|doneupdatedAt:number}exportinterfacePlanRepairLog{markSaving(planId:string):PromisevoidmarkDone(planId:string):PromisevoidlistUnfinished():PromiseRepairEntry[]}启动时看到saving未完成可以触发一致性检查看到indexing未完成可以重建摘要。不要把整份课程计划复制进 repair log否则后面又要处理两份事实数据。启动后做一次轻量修复对本地优先应用来说启动扫描能兜住上一次异常退出。exportclassPlanStartupRepair{constructor(privatereadonlyrepairLog:PlanRepairLog,privatereadonlyverifier:CoursePlanVerifier){}asyncrepairUnfinishedPlans():Promisevoid{constentriesawaitthis.repairLog.listUnfinished()for(constentryofentries){awaitthis.verifier.checkAndRepair(entry.businessId)}}}修复逻辑要轻量不能拖慢首屏。必要时只标记“需要用户确认修复”不要在启动阶段做大批量重写。验证清单模拟明细第三条写入失败确认主表不会留下半成品。模拟事务成功但摘要重建失败确认 repair log 能记录未完成阶段。杀进程后重启确认未完成操作能被扫描出来。删除计划时确认主表、明细、摘要都被处理。发布前跑一次主表和明细数量一致性检查。事务外资源要单独设计恢复策略实际项目里RDB 事务只能保护数据库内的多表写入。图片文件、远端同步、系统通知、首页摘要重建这些动作不一定能放进同一个事务。它们要么放在事务成功之后要么通过补偿日志恢复。动作能否放进 RDB 事务建议主表和明细表写入能同一事务提交首页摘要索引通常可重建事务后重建失败记录阶段图片文件移动不适合先写临时文件事务成功后确认远端同步不适合本地成功后进入待同步队列通知提醒不适合数据成功后再创建失败可重试这个边界很关键。不要为了追求“一起完成”把文件和网络动作硬塞进数据库事务的思维里。RDB 负责数据库一致性补偿日志负责跨资源恢复。发布前做一次一致性巡检多表数据在发布前要能被脚本或调试入口检查。下面是一个面向业务的校验接口形状。exportinterfaceIntegrityProblem{businessId:stringtype:missing_detail|orphan_detail|index_stalemessage:string}exportinterfaceCoursePlanVerifier{scanProblems():PromiseIntegrityProblem[]repair(problem:IntegrityProblem):Promisevoid}发布前至少扫描三类问题主表没有明细、明细没有主表、摘要索引和事实数据不一致。小数据量应用可以直接全量扫数据量大时按更新时间分批扫。重点是让数据问题被看见而不是等用户点进空计划才发现。我会怎样复现半写入复现 RDB 半写入时可以在明细表第三条写入前主动抛错。预期结果是事务整体回滚主表不应该留下空壳记录如果主表留下了说明主表和明细没有处在同一个事务边界。再模拟摘要重建失败预期结果不是回滚事实数据而是在补偿日志里记录indexing未完成方便下一次启动补建。复现时重点看三项证据主表是否出现没有明细的孤儿数据。repair log 是否记录了未完成阶段。再次启动后能不能通过巡检发现并修复。小结RDB 一致性靠边界不靠保存按钮RDB 写一半的问题要从业务原子性处理。Repository 只做表级操作Service 定义事务边界补偿日志记录事务之外的恢复线索启动修复和发布前校验负责收尾。这样数据不一致时能定位也能恢复。看三项证据主表是否出现没有明细的孤儿数据。repair log 是否记录了未完成阶段。再次启动后能不能通过巡检发现并修复。小结RDB 一致性靠边界不靠保存按钮RDB 写一半的问题要从业务原子性处理。Repository 只做表级操作Service 定义事务边界补偿日志记录事务之外的恢复线索启动修复和发布前校验负责收尾。这样数据不一致时能定位也能恢复。