news 2026/10/23 3:32:10

Python后端中间件专题04:工单改了缓存还显示旧值——写后失效与一致性窗口

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python后端中间件专题04:工单改了缓存还显示旧值——写后失效与一致性窗口

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)returnticket

ache delete is observable but cannot roll back business truth.
self._cache.invalidate_ticket(tenant_id, ticket_id)
return ticket

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 21:33:53

Filmigo 移动端视频剪辑工具|安卓短视频剪辑体验分享

简介现在短视频创作需求越来越多,很多人希望直接在手机上完成剪辑,不用电脑。Filmigo 就是一款轻量化安卓视频剪辑应用,适合自媒体新手快速制作短视频。核心功能基础剪辑:视频分割、裁剪、拼接、调速、倒放、旋转,基础…

作者头像 李华
网站建设 2026/10/18 5:14:29

用Dify与DeepSeek搭建拍照解题工作流:OCR+提示词设计实战

1. 先说说我为什么折腾这套拍照解题起因其实挺简单。我那段时间经常要帮家里小孩看作业,从小学数学到初中物理都混着来。试过市面上一些拍照搜题App,答案倒是快,但有两件事让我很不爽:一是它只给答案不解释思路,小孩看…

作者头像 李华
网站建设 2026/10/18 5:14:29

如何打造无可挑剔的项目:从标准到实践的完整方法论

1. 一个词引发的项目思考:为什么"impeccable"值得单独拿出来做第一次看到"impeccable"这个词被单独拎出来当作项目标题,我的直觉是:这要么是个文字游戏,要么背后藏着某种对"极致标准"的执念。impec…

作者头像 李华
网站建设 2026/10/19 16:43:31

Super账户与super文件:vivo/iQOO手机文件管理及数据迁移完全指南

换新手机的时候,最折腾人的往往不是挑选型号,而是旧手机里那一大堆照片、聊天记录、应用数据怎么完整搬过去。我身边用vivo和iQOO的朋友几乎都问过我同一个问题:手机里的系统文件到底哪些能删、哪些不能动,联系人备份到一半断了怎…

作者头像 李华