把业务不变量钉进数据库:应用层检查不是防线¶
"同一个房间,至多一张在住单"——这条规则听起来用一行判断就能实现。这篇文章讲我们为这一行判断付出的两代学费,以及最后把规则钉进数据库时学到的东西:应用层检查是过闸检查,不是不变量本身。
现象¶
两个同根的症状。其一:仅有应用层判重时,同房双在住仍然出现——旁路写入、并发请求、后台任务,任何一条不经判重入口的路径都能绕过这行判断;脏数据进了库,房态图和账务跟着错乱。其二:放开"一房多卡"限制时改了代码、没动库上的旧唯一索引,前台补卡/加发全被数据库拒绝——功能明明开发完了,一用就报错,而自测全过。
现场经过(据"补刀"迁移 0009_drop_card_unique 的自留注释):放开一房多卡的迁移上线后,补卡/加发/延卡陆续被 422 拦下——新代码翻不出问题,因为执法的是库上还在的旧唯一索引:历史卡记录按"置为被替代"保留而非删除,同单同房的新卡每一次都撞约束。注释里那句"存量库 0006 已应用,需要新迁移补刀",就是这次打脸的完整记录。
我一开始的错误判断¶
补卡报错出现时,第一反应是当新代码 bug 查:新逻辑翻遍没有问题,新造的数据怎么测都通——新造的卡不会撞历史行,只有真实库里"被替代仍保留"的旧记录才会撞。最后才意识到执法的是库上一个"看不见的"旧索引:代码放开了,库没有。而更早以前对一房一在住的态度,是典型的"应用层判一下就行",没有意识到判重与写入之间存在时间窗,并发下两个请求可以同时通过检查,后台任务更是根本不经过这个入口。
排查与关键证据¶
两段迁移注释是本文的一级证据(都在 internal/mod/frontdesk/module.go 的迁移清单里),注释即决策记录:
- 上约束之前,先清历史脏数据。同房多笔在住的历史行去重:每房保留最新一笔、其余按规则对齐为已退房;随后才建部分唯一索引(
idx_checkins_room_inhouse,只约束在住且未删除的行)。注释写明了为什么要部分索引:普通唯一索引会挡住同房的历史退房记录——"同房至多一在住"是带条件的唯一,只有部分索引能表达; - 放开约束也要补刀。放开一房多卡的那条迁移只建了普通索引、漏删旧的唯一索引;同单同房的新卡撞上还在执法的唯一约束,补卡/加发/延卡全部被拒。注释里明确自留了打脸记录:历史卡记录按"置为被替代"保留而不删除,让每次补卡都撞索引——最后用一条新迁移删除旧索引(
frontdesk/0009_drop_card_unique)才真正放开。这印证了《迁移按 ID 幂等的盲区》的定律:放开约束同样是 schema 变更,同样受"已发布迁移不再执行"的规律约束。
应用层的判重检查最终保留——但地位变了:从"防线"降级为"第二道"。它的真实价值是给出可读的业务错误文案,而并发正确性由库级索引兜底。双层结构里两层各司其职:应用层负责体验,库层负责不变量。
根因¶
三层因果:
- 应用层检查守的是"入口",不是"事实"。并发、旁路写入、后台任务都造不成入口,却都能造成事实;
- 不变量必须落在存储层才有执法权——存储层是所有写入路径的必经之地,也是唯一无法绕过的地方;
- 对约束做任何动作(加、删、改条件)都是 schema 变更,都要走新迁移;且动作前必须先回答"现存数据满不满足新约束"——历史数据的形状是新约束最常见的死法。
约束与取舍¶
- 放弃了"只靠应用层判重"。代码可读、错误友好,但守不住并发与旁路;
- 放弃了"直接加索引"的省事。不清历史数据直接建唯一索引,迁移会失败在半路,库停在中间态——"先清后建"多花一步,换来动作原子可回退;
- 代价一:脏数据清理迁移里写进了裁决规则(同房多笔保留哪一笔、其余如何对齐)——业务决策以 SQL 形态固化,错了难回滚;
- 代价二:部分索引的过滤条件成为隐性契约——今后任何改状态枚举、改删除语义的人,都必须知道这个索引的存在与其条件的含义,注释与测试是唯一的传送带;
- 代价三:每多一层约束,就多一类需要文档化的"为什么会有这个索引"。
可复用检查清单¶
- 每条业务不变量问一遍:它能从哪条旁路绕过(并发/其他入口/后台任务)?答不出"无法绕过"就上库级约束;
- 加唯一类约束前,先跑一次"现存数据是否满足该约束"的检查查询;不满足,先写清理迁移再建约束;
- 放开约束 = 找到当初创建它的迁移,确认删除动作用了新的迁移 ID;
- 每个非常规索引(部分索引、条件唯一)在迁移注释里写清它执法的不变量与条件的含义——给一年后的自己。
双路径对照实验(SQLite 实测):同一张表里放进同房的历史退房行与两笔在住脏行——
- 直接建普通唯一索引:失败。历史退房行与在住行同房,必撞约束——这正是普通唯一索引表达不了"同房至多一在住"的原因;
- 不清理脏数据直接建部分唯一索引:同样失败。建索引时校验存量数据,两笔在住违反约束,迁移死在半路;
- 先清脏数据(每房保留最新一笔、其余对齐为已退房)再建部分唯一索引:成功。历史退房行原样保留,再插一笔同房在住被库当场拒绝。
清理先行不是洁癖,是建索引能走完的前提;而 0002 迁移里"保留哪一笔、其余如何对齐"的裁决 SQL,就是把业务裁决写进了迁移。
小结¶
应用层检查和库级约束的关系,不是二选一,是分工:前者产出体验(友好报错),后者产出正确性(无法绕过)。判断一个不变量守没守住,标准只有一条——存不存在一条能绕过它的写入路径。