文章目录
-
- 前言
- 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




