Python后端中间件专题04:工单改了缓存还显示旧值——写后失效与一致性窗口
把 cache-aside 接进 GET 并不等于完成缓存设计。如果 PATCH 只更新 PostgreSQL,热门工单的旧副本会一直存活到 TTL 结束。更危险的修复是“先删缓存,再提交数据库”:两步之间的 GET 会把旧数据重新填回。
先接受这个不舒服的事实:DB commit → DEL cache是正确基线,却不能消灭每一个旧值。你会用版本和底层读取次数证明更新后最终读到新值,并定位一个更早启动的 GET 在删除后回填旧版本的窗口。
这里要用到第 03 篇的 miss→loader→set 路径,以及“数据库事务和 Redis 命令不是同一原子事务”的边界。实验继续在project/的 fake cache 上运行;真实 Redis 延迟、二次删除和断网恢复仍属于远程 integration。
图中的蓝线是PATCH commit v2 → delete;红色虚线则是更早开始的GET-A read v1 → late SET v1。它发生在删除之后,所以橙色框明确标为“窗口仍存在”:第 04 课的普通删除没有关闭这个竞态。
上一课练习答案
先用 v1/v2 复盘:版本能说明什么。
答案 EX-03-01 [code]
defchoose_newer(cached:dict,database:dict)->dict:"""Version is the ordering signal; timestamp is diagnostic context."""returndatabaseifdatabase["version"]>cached["version"]elsecached cached={"id":"ticket-1","title":"VPN down","version":1,"updated_at":"2026-09-21T08:00:00+00:00",}database={"id":"ticket-1","title":"VPN restored","version":2,"updated_at":"2026-09-21T08:03:00+00:00",}chosen=choose_newer(cached,database)assertchosen["version"]==2print(chosen["title"],chosen["updated_at"])# 版本和时间戳能暴露陈旧,但不会自动阻止旧值被读出。# 强一致还需要读写共享的事务/锁/条件写边界,本课只做最终收敛。运行命令:python version_probe.py
预期输出:
VPN restored 2026-09-21T08:03:00+00:00不用客户端时间戳来裁决新旧,因为时钟可偏移。由事实库在更新时递增的version更适合做顺序信号,updated_at主要服务于用户展示和事故排查。
答案 EX-03-02 [prose]
必要基线是DB commit → DEL cache:删除发生在事实提交后,下一次 miss 才可能读到新值。残余竞态是:GET-A 在 PATCH 提交前已从 DB 读到 v1;PATCH 提交 v2 并删 key;GET-A 随后才把 v1 写回 Redis。因此“写后删除”会大幅缩小陈旧时间,却不是线性一致保证。可以用短 TTL、二次延迟删除、版本检查或消息化失效继续收窄窗口,但不应把 Redis 升格为事实源。
PATCH 提交后,删除到底负责什么
PostgreSQL 事务与 RedisDEL之间没有廉价可靠的跨系统原子提交。本项目将更新与ticket.updatedOutbox 同事务提交,进程内先尝试失效;删除失败不重做 PATCH。Worker 消费这条持久事件时再次幂等失效同一 key;这类正确性 repair 使用有上限退避但不进入普通三次重试后的 DLQ,Redis 长时间故障后仍保持待执行。修复只针对可丢弃副本,不会再次增加 ticket version 或重复生成用户更新 Outbox。
本课阶段的模块只做普通DEL,没有把更新后的 version 传给 cache,也没有接入 version fence。因此 GET-A 若在 PATCH 提交前读到 v1,仍可能在 delete 之后晚到SET v1;图中的红色路径正是这条残余窗口。后续阶段可以讨论 version fence、条件回填或二次延迟删除来继续收窄它,但不能把那些能力倒写成本课已经完成的保证。
但“不回滚”不是“静默吞掉”。invalidate_ticket返回布尔值,生产组合层应将False记成低基数错误指标与带 ticket/version 的结构化日志。恢复动作是再次删除对应 key 或等待有界 TTL,不是重做数据库更新。
与上一阶段快照相比,关键变化只有两处:TicketCache协议增加失效方法;update只在底层更新真正返回 Ticket 时删缓存。跨租户或不存在的 PATCH 返回None,不生产没意义的删除。注释解释的是事务边界,而不是重复代码字面。
顺序读回 v2,只能证明什么
检查点先读 v1 并填入 fake cache,然后 PATCH 为VPN restored,再读时断言 version=2、title 为新值、失效列表只有对应 key,底层读计数从 1 增到 2。
运行命令:python -m pytest tests/unit/test_cached_ticket_service.py::test_successful_update_invalidates_then_next_read_gets_new_version -q
本地 Python 3.11 实际输出:
. [100%] 1 passed本地测试只证明当前进程的顺序失效控制流,既没有启动 GET-A,也没有断言其 late SET 被拒绝;它不能证明并发窗口已关闭。可信远端 selector 会缓存 v1、正常更新 v2,再缓存 v2、让 Redis 故障持续超过普通 delivery 的三次重试预算后更新 v3;恢复后必须观测这一条精确 update event 被 Worker 成功处理、未进入 DLQ,并核对 PostgreSQL version=3、更新 Outbox 恰两条。等待 TTL 不算 repair;未运行仍为 PENDING。
因此复盘时先排除四种误修:先DEL再提交会扩大旧值窗口;DEL失败后重做 PATCH 会造成 version/Outbox 重复;静默忽略删除失败会失去修复证据;把普通写后删除叫作强一致则掩盖图中仍存在的 late refill。
本课练习
设计一条不重放业务写入的修复线。
练习 EX-04-01 [code]
设计一个invalidate_after_update包装器:数据库已更新时,缓存删除失败不抛出令业务回滚的异常,但必须把(tenant_id, ticket_id, version)加入待修复列表并将失败计数加 1。用 fake cache 写出成功与失败两条断言。
练习 EX-04-02 [prose]
说明为什么缓存删除失败的自动修复不应“重新执行 PATCH”,以及大量 key 同时过期时,光有失败列表为什么保护不了数据库。
第 05 篇会消费 EX-04-01/02:单 key 的修复线不足以保护同时到期的流量。下一次实验将区分穿透、热点击穿、批量雪崩,并用 barrier 证明 6 个并发 miss 只触发一次 loader。
完整核心模块:写后失效的缓存服务
from__future__importannotationsfromdatetimeimportdatetimefromtypingimportProtocolfrom.ticketsimportTicket,TicketServiceclassTicketCache(Protocol):defget_or_load_ticket(self,tenant_id:str,ticket_id:str,loader):...definvalidate_ticket(self,tenant_id:str,ticket_id:str)->bool:...def_serialize(ticket:Ticket)->dict[str,object]:"""Create strict JSON data; the cache never receives Python-only values."""return{"id":ticket.id,"tenant_id":ticket.tenant_id,"actor_id":ticket.actor_id,"title":ticket.title,"body":ticket.body,"priority":ticket.priority,"status":ticket.status,"version":ticket.version,"created_at":ticket.created_at.isoformat(),"updated_at":ticket.updated_at.isoformat(),}def_deserialize(payload:dict[str,object])->Ticket:returnTicket(id=str(payload["id"]),tenant_id=str(payload["tenant_id"]),actor_id=str(payload["actor_id"]),title=str(payload["title"]),body=str(payload["body"]),priority=str(payload["priority"]),status=str(payload["status"]),version=int(payload["version"]),created_at=datetime.fromisoformat(str(payload["created_at"])),updated_at=datetime.fromisoformat(str(payload["updated_at"])),)classCachedTicketService:"""Decorate detail reads with cache-aside while writes keep DB authority."""def__init__(self,service:TicketService,cache:TicketCache)->None:self._service=service self._cache=cachedefcreate(self,*args):# type: ignore[no-untyped-def]returnself._service.create(*args)defget(self,tenant_id:str,ticket_id:str)->Ticket|None:defload()->dict[str,object]|None:ticket=self._service.get(tenant_id,ticket_id)return_serialize(ticket)ifticketisnotNoneelseNonepayload=self._cache.get_or_load_ticket(tenant_id,ticket_id,load)return_deserialize(payload)ifpayloadisnotNoneelseNonedefupdate(self,tenant_id:str,ticket_id:str,title:str|None=None,body:str|None=None,priority:str|None=None,)->Ticket|None:ticket=self._service.update(tenant_id,ticket_id,title=title,body=body,priority=priority)ifticketisnotNone:# Invalidation is deliberately after the committed DB write. A failed# cache delete is observable but cannot roll back business truth.self._cache.invalidate_ticket(tenant_id,ticket_id)returnticketache delete is observable but cannot roll back business truth.
self._cache.invalidate_ticket(tenant_id, ticket_id)
return ticket