欢迎光临
我们一直在努力

别让模型直接改数据库字段:预约改期的业务一致性陷阱

文章目录

    • 前言
    • 1 先把规矩摆桌上
      • 1.1 三条规矩,一条都不能破
      • 1.2 时间是怎么数的
    • 2 一次错误更新是怎么发生的
      • 2.1 这句话干了什么,没干什么
      • 2.2 坏状态为什么不是"中间态"
    • 3 换个思路:把"改字段"换成"改期"
      • 3.1 两个工具摆在一起
      • 3.2 谁出参数,谁出结果
    • 4 上代码:一个文件跑给你看
      • 4.1 完整代码
      • 4.2 跑起来
    • 5 光看输出不够,得看状态
      • 5.1 三条分支对照着看
      • 5.2 回滚不是嘴上说说
    • 6 事务管打包,领域代码管配餐
    • 7 边界划在哪:谁的孩子谁抱走
    • 8 最后落一张表:接口评审照着勾

P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看,传送门https://blog.csdn.net/qq_74013365

前言

先说个真事儿——当然,是我编的,但这种事你们指定见过。

有个用户跑来跟系统说:“我九点那场预约,帮我挪到十点。”

模型一听,这活儿简单,抬手就调了个"修改结束时间"的接口。接口秒回:成功。

然后事情就离谱了。原来九点到十点的预约,变成了九点到十二点。而资源占位那条记录,还是老老实实地占着九点到十点。

你去查数据库吧,每个字段都是合法整数,结束时间也确实晚于开始时间,数据库心里特别清白:我没坏,是你业务坏了。

1 先把规矩摆桌上

别笑数据库。它有它的委屈,业务有业务的规矩。这一节先把这个例子里三条不能商量的规矩写死,后面全靠它们断案。

1.1 三条规矩,一条都不能破

第一,这场预约固定六十分钟,不多不少。你不能因为今天心情好,就把一小时会开成三小时,数据库可不会劝你"差不多得了"。

第二,预约记录和资源占位记录,必须长得一模一样。一个说九点到十点,另一个也得说九点到十点,不能一个提前量、一个拖后腿。

第三,同一个资源,时间不能重叠。这就像公交车座位,两个人买同一排同一座,司机都得劝一个下去。

1.2 时间是怎么数的

例子里所有时间都是"从当天零点开始过了多少分钟"。九点就是540,十点就是600,区间左闭右开。

预约1原来是540到600,预约2是660到720。所以预约1挪到600到660是合法的——结束点刚好是预约2的开始点,挨着不算抢座。但你要是敢挪到660到720,那就是明着插队了。

这条细节决定了 SQL 里判断冲突的条件:已有的开始小于新的结束,并且已有的结束大于新的开始。端点挨在一起不算冲突,别写成"撞上就算",那会把正常的接力也误杀。

2 一次错误更新是怎么发生的

现在回头看模型那一刀。它执行的是这么一句:

UPDATE appointment SET end_min=720 WHERE id=1

2.1 这句话干了什么,没干什么

它只改了结束时间,开始时间没碰,占位表更是连看都没看一眼。

效果就是:预约时长从60分钟变成180分钟,占位时长还是60分钟。两份记录各说各话,像两口子各自都遵纪守法,就是过不到一块儿去。

用户要的十点到十一点?压根没出现。界面要是把"更新成功"四个字直接翻译成"改期成功",那就是把后厨出餐的小票,当成了顾客桌上的菜。

2.2 坏状态为什么不是"中间态"

这里必须较真一下。有人会说:模型下一次调用不就把开始时间也改了吗?急什么。

急。因为这条 SQL 在默认连接设置下是立刻提交的,不是攒在事务里等你慢慢写完。也就是说,在模型准备调第二次接口的那几秒钟里,坏状态已经落库了。这几秒钟里,可能已经有另一个人按着旧占位排活了。

这就像装修师傅砸完墙下班了,说"明天再来补"。今晚这屋子漏不漏水,可跟他明天补不补没关系——现在就是漏的。

脚本跑完,断言看到的就是 ((540,720,0),(540,600)),不是我纸上谈兵猜出来的中间态。

3 换个思路:把"改字段"换成"改期"

那怎么办?难道把模型摁在墙上补习数据库吗?不用。思路换一下就行:别让模型学业务,让懂业务的服务把活儿整个接走。

3.1 两个工具摆在一起

弱接口长这样:

update_appointment_field(id, field, value)

这玩意儿像什么?像把一把菜刀递给新来的实习生,说"菜在那儿,你看着切"。他切得好不好,全靠他悟性。

换个样子:

reschedule_appointment(id, expected_version, new_start, new_end)

注意,这不是给旧接口换了个好听的名字。区别在于:现在是服务端自己去读当前预约、自己判断能不能改、自己校验完整时间窗口、自己检查占位,然后把这次动作需要的所有写入打包处理。模型只需要说一句"把预约1挪到十点到十一点",它根本不需要知道你后台有几张表、先更新哪张。

3.2 谁出参数,谁出结果

输入三样东西就够了:预约是谁、你是基于哪个版本提的、新窗口完整是多少。

那个 expected_version 很有意思,它像进门暗号。你说"我基于0号状态来改",服务端一查,欸,现在都1号了,那就直接拒收——stale_version。因为你眼里的世界已经不是现在的世界了,按旧地图导航必撞墙。

成功了返回什么?返回预约id、新时间、新版本号,调用者自己拿去跟用户原话对一遍。被拒绝了呢?给个稳定原因,slot_conflict 就是资源被占了,invalid_time_window 就是你这窗口不合规矩。

关键在于:这些原因出来以后,正确动作是"刷新信息、重新选时间",而不是"换个更底层的接口再偷偷写一遍字段"。规则摆在台面上,就不许钻桌子底下。

还有一句大实话要交代:这个例子没做认证、没做JSON Schema、没做用户确认,它只是演示边界,不是一个能直接挂出去收请求的生产API。别拿内存里跑的函数去扛线上流量,那是让穿裤衩的兄弟去迎亲。

4 上代码:一个文件跑给你看

光说不练假把式。下面这个单文件例子,预约和占位都放在一个 SQLite 内存库里,由同一个排程服务持有,用显式事务管边界。没有HTTP,没有模型,没有短信通知,就是为了把三种结果跑清楚。

4.1 完整代码

存成 reschedule_invariants.py,注意别用 python -O 跑,不然 assert 自检全被优化没了,等于体检不抽血。

"""Fictional single-owner calendar; in-memory SQL, no authentication or network."""
import json
import sqlite3

def state(db):
appointment = db.execute(
"SELECT start_min,end_min,version FROM appointment WHERE id=1"
).fetchone()
reservation = db.execute(
"SELECT start_min,end_min FROM reservation WHERE appointment_id=1"
).fetchone()
return appointment, reservation

def consistent(db):
appointment, reservation = state(db)
return (appointment[1] – appointment[0] == 60
and appointment[:2] == reservation)

def reschedule(db, appointment_id, expected_version, new_start, new_end):
db.execute("BEGIN IMMEDIATE")
try:
row = db.execute(
"SELECT status,version FROM appointment WHERE id=?",
(appointment_id,),
).fetchone()
if row is None or row[0] != "scheduled":
raise ValueError("not_reschedulable")
if row[1] != expected_version:
raise ValueError("stale_version")
if (type(new_start) is not int or type(new_end) is not int
or not 0 <= new_start < new_end <= 1440
or new_end – new_start != 60):
raise ValueError("invalid_time_window")

db.execute(
"UPDATE appointment SET start_min=?,end_min=?,version=version+1 "
"WHERE id=?", (new_start, new_end, appointment_id),
)
# Deliberately check after the first write so rejection exercises rollback.
occupied = db.execute(
"SELECT 1 FROM reservation WHERE appointment_id<>? "
"AND start_min<? AND end_min>?",
(appointment_id, new_end, new_start),
).fetchone()
if occupied:
raise ValueError("slot_conflict")

changed = db.execute(
"UPDATE reservation SET start_min=?,end_min=? "
"WHERE appointment_id=?", (new_start, new_end, appointment_id),
).rowcount
if changed != 1:
raise ValueError("reservation_missing")

db.commit()
return {"status": "succeeded", "appointment_id": appointment_id,
"start_min": new_start, "end_min": new_end,
"version": expected_version + 1}
except ValueError as error:
db.rollback()
return {"status": "rejected", "reason": str(error)}
except Exception:
db.rollback()
raise

def main():
db = sqlite3.connect(":memory:", isolation_level=None)
try:
db.executescript("""
CREATE TABLE appointment (
id INTEGER PRIMARY KEY, start_min INTEGER, end_min INTEGER,
status TEXT, version INTEGER,
CHECK (end_min > start_min)
);
CREATE TABLE reservation (
appointment_id INTEGER PRIMARY KEY,
start_min INTEGER, end_min INTEGER,
CHECK (end_min > start_min)
);
INSERT INTO appointment VALUES (1,540,600,'scheduled',0);
INSERT INTO appointment VALUES (2,660,720,'scheduled',0);
INSERT INTO reservation VALUES (1,540,600);
INSERT INTO reservation VALUES (2,660,720);
"""
)

assert consistent(db)

# Deliberately weak legacy CRUD commits just one model-selected field.
db.execute("UPDATE appointment SET end_min=720 WHERE id=1")
assert not consistent(db)
assert state(db) == ((540, 720, 0), (540, 600))
print("crud_half_update=committed; domain_consistent=false")

db.execute("UPDATE appointment SET end_min=600 WHERE id=1")
assert consistent(db)

result = reschedule(db, 1, 0, 600, 660)
assert result["status"] == "succeeded"
assert state(db) == ((600, 660, 1), (600, 660))
assert consistent(db) and not db.in_transaction
print(json.dumps(result, sort_keys=True))

before = state(db)
result = reschedule(db, 1, 1, 660, 720)
assert result == {"status": "rejected", "reason": "slot_conflict"}
assert state(db) == before and consistent(db)
assert not db.in_transaction
print("slot_conflict=rejected; first_write_rolled_back=true; version=1")

result = reschedule(db, 1, 0, 720, 780)
assert result["reason"] == "stale_version" and state(db) == before

result = reschedule(db, 1, 1, 720, 800)
assert result["reason"] == "invalid_time_window" and state(db) == before

result = reschedule(db, 99, 0, 720, 780)
assert result["reason"] == "not_reschedulable" and state(db) == before
print("stale_version,invalid_window,missing_id=rejected; state_unchanged=true")
print("fixture_only=true; production_auth=false; external_notifications=false")
finally:
db.close()

if __name__ == "__main__":
main()

4.2 跑起来

python3 examples/reschedule_invariants.py

本地 Python 3.13.5、SQLite 3.53.0,退出码0。标准输出长这样:

crud_half_update=committed; domain_consistent=false
{"appointment_id": 1, "end_min": 660, "start_min": 600, "status": "succeeded", "version": 1}
slot_conflict=rejected; first_write_rolled_back=true; version=1
stale_version,invalid_window,missing_id=rejected; state_unchanged=true
fixture_only=true; production_auth=false; external_notifications=false

5 光看输出不够,得看状态

很多人验证代码,看到函数返回 succeeded 就鼓掌了。这不行。函数说成功,跟菜真上齐了,是两码事。

5.1 三条分支对照着看

分支干了什么预约变成占位变成结论
弱字段写入 只把end改成720 540–720,版本0 540–600 已提交,但业务不一致
完整改期 版本0,挪到600–660 600–660,版本1 600–660 两份记录一致
资源冲突 版本1,想挪到660–720 600–660,版本1 600–660 拒绝,而且第一条写入也撤了

5.2 回滚不是嘴上说说

注意冲突那一支。代码是先把预约写成660–720,写完才去查占位,发现撞了预约2,于是 raise slot_conflict,走 rollback。

为什么故意这么写?就是为了验证一件事:已经落进事务的第一条 UPDATE,有没有真的被撤回来。跑完查询一看,预约1和占位都还是600–660,版本还是1。这说明回滚撤的是真金白银的写入,不是写个日志安慰自己。

这就像后厨切到一半发现食材不新鲜,正确做法是整盘倒掉、重新切,而不是把不新鲜那半盘先端上桌、再跟顾客说"稍后给您换"。

当然,工程上更合理的做法是事务里尽早做可行性检查,少做无谓写入。但有一点省不掉:校验到提交之间的保护,以及失败时完整恢复。这两样没了,回滚就是个吉祥物。

6 事务管打包,领域代码管配餐

有人问:我把那条坏 SQL 也包进事务里,是不是就好了?

不好。原子性只保证"这一摞操作要么全生效、要么全撤销",它不负责懂业务。你把一句错话包进事务里提交,提交得再整齐,内容也是错的。事务像打包盒,负责把菜一次端上桌;但菜配得对不对,是厨师的事。

反过来,只在模型那边检查一遍时长,后面还是拆开提交,也不行。因为检查和写入之间隔着网络超时、断网、模型重新规划,跟没查差不多。

还有个边界要拎清:本例的冲突发生在提交之前,rollback 撤的是本事务内的写入。如果你先提交了预约,再去远端会议室系统通知,远端挂了,然后你补一条反向操作——那叫补偿,不叫本地事务回滚。两个流程对调用者可见的中间状态完全不同,别混为一谈。短信没发出去,本地数据库再一致,用户手机上也不会凭空收到通知。

7 边界划在哪:谁的孩子谁抱走

这个例子里,预约和占位被看成一个"一致性单元",因为它们都归同一个排程服务管,所以一个本地事务就能打包解决。

但注意,这不是说"一张表算一个聚合"或者"一个工具拆一个微服务"。真正的判断标准是:哪些业务事实必须一起成立,那就由同一个权威边界来裁决。

如果预约服务只管预约,资源另有一个服务管着,那你就不能假装用本地事务实现了跨服务原子改期。这时候该谈的是协议:中间状态怎么暴露、失败怎么释放、结果怎么确认。让模型连续调两个裸 CRUD 接口,跟让两个盲人互相带路差不多——谁也没比谁多看见什么。

反过来,内部加索引、占位拆表、存储迁移,这些事不应该自动变成模型可以写的新字段。能力归能力,存储归存储,两码事。

8 最后落一张表:接口评审照着勾

把这一个场景收一收,得到一张可以直接拿去开接口评审会的映射:

必须保护的事实谁有裁决权对应能力边界
新窗口符合时长和当前状态 排程领域服务 在 reschedule 里整体校验,不开放任意时间字段写
预约时间与本地占位一致 持有两者的同一权威边界 一次完整提交,或一次完整拒绝
资源不重叠占用 占位权威方,本例与排程同处一处 检查到提交之间全程保护;跨所有者时显式协调
调用依据的版本仍有效 当前预约状态所有者 旧版本直接拒,不许静默覆盖
返回只承诺本地完成 能力定义者与状态所有者 输出写清完成范围,不替外部通知打包票

逐字段的工具太细,等于把维持这些关系的责任踢回给模型;搞一个大而全的 manage_calendar,改期、删除、退款、管理员修复全塞进去,又太粗,权限和后果都说不清。中间那个刚刚好的粒度,就是一个用户听得懂的业务意图,加上它必须一起保护的那几条规矩。

这不代表 CRUD 要全删。内部持久化该怎么用还怎么用,真有哪个低风险字段允许单独改,那就单独改,最简单。要收回来的,是那些会绕开规矩的任意写入口——别搞形式主义,把所有接口都包一层皮。

对老系统动手时,先挑一个已经出过半更新风险的动作,顺着它读的状态、必须一起变的记录,画出最小边界。还有最容易被漏掉的一条:如果排程服务还允许绕过 reschedule 直接写时间字段,那新入口只是条"可选路径",保护根本没合上。运维修复确实需要低层写入?那就给它一条受控的、专门的通道,别当成普通改期工具开放。

测试位置也顺带着定了:模型那边测它会不会选对工具、时间表达准不准;而不变量的测试,必须放在每个调用者都要经过的服务端。哪天把 Agent 换成网页、换成批量脚本、换成人工后台,同一条规矩照样成立——这才说明保护绑的是业务能力,不是某一段提示词。

最后一句。这个内存例子只证明了上面列的状态约束和事务行为,认证、Schema、幂等、外部通知这些生产课,一节都还没上。拿现有工具目录做评审时,最后留在纸上的就该是这张表:哪组事实必须一起成立、由谁裁决、工具承诺到哪一步。答案写清楚了,再决定暴露什么参数——犯不上让模型先把你后台有几张表背下来,才肯帮用户把九点挪到十点。

P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看,传送门https://blog.csdn.net/qq_74013365

赞(0)
未经允许不得转载:171主机测评 » 别让模型直接改数据库字段:预约改期的业务一致性陷阱
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址