
Python后端中间件专题03详情页为什么越热门越慢——Redis cache-aside 从零接入一张 P1 故障工单被几十个人反复刷新时每次 GET 都读 PostgreSQL 并不是正确的“保守”它会把可重用的读压力堆在事实库上。本课只改造详情读路径用 cache-aside 完成 miss→DB→set 和 hit→直接返回。本课只观察一个可重复的现象同一详情连读两次底层只回源一次。为此会把 cache-aside 放在 service 而不是 repository保存带租户、版本和时间戳的 JSON 副本这个证据证明加速并不改变 PostgreSQL 的事实地位。先带着第 02 篇的 HTTP/幂等基线理解 JSON 序列化和装饰器式 service 组合。进入project/后本篇 selector 使用内存 cache fake它不要求本机 Redis也不证明 Redis TTL 或网络协议。上一课练习答案在加速之前复核写入根本没有变。答案 EX-02-01 [code]以下代码是可运行的 TestClient SQLite 实验。它不只比较 HTTP 返回值还直接检查事务结果fromfastapi.testclientimportTestClientfromsqlalchemyimportcreate_engine,func,selectfromsqlalchemy.ormimportsessionmakerfromsqlalchemy.poolimportStaticPoolfromticketflow.api.appimportcreate_appfromticketflow.domain.ticketsimportTicketServicefromticketflow.storage.modelsimportBase,OutboxEventModel,TicketModelfromticketflow.storage.repositoryimportTicketRepository enginecreate_engine(sqlitepysqlite:///:memory:,connect_args{check_same_thread:False},poolclassStaticPool,)Base.metadata.create_all(engine)sessionssessionmaker(bindengine,expire_on_commitFalse)clientTestClient(create_app(TicketService(TicketRepository(sessions))))headers{X-Tenant-Id:acme,X-Actor-Id:agent-7,Idempotency-Key:request-001,}request_a{title:VPN unavailable,body:Cannot connect,priority:high}request_b{**request_a,body:A different intent}firstclient.post(/tickets,headersheaders,jsonrequest_a)replayclient.post(/tickets,headersheaders,jsonrequest_a)conflictclient.post(/tickets,headersheaders,jsonrequest_b)assert[first.status_code,replay.status_code,conflict.status_code][201,201,409]assertfirst.json()[id]replay.json()[id]withsessions()assession:ticket_countsession.scalar(select(func.count()).select_from(TicketModel))outbox_countsession.scalar(select(func.count()).select_from(OutboxEventModel))assert(ticket_count,outbox_count)(1,1)print(first.status_code,replay.status_code,conflict.status_code,ticket_count,outbox_count)运行命令python idempotency_probe.py预期输出201 201 409 1 1同键同体返回 201 是本项目的明确契约它重放原始创建结果没有创建第二个 Outbox 事件。同键异体则不可被伪装成重放因此是 409。答案 EX-02-02 [prose]把缓存只加在get之后三次 POST 结果必须保持 201、201、409数据库行数仍是 1/1。读缓存不应参与写幂等裁决。“连读两次repository 只读一次”只证明第二次从副本命中当 Redis 中的 ID 不存在于 PostgreSQL 时它仍然没有创建或修改业务事实的权力。先观察第二次读取为什么没有资格查库检查点给底层 service 加了get_count连续读两次同一(tenant_id, ticket_id)断言返回的Ticket相同但底层读取计数是 1。运行命令python -m pytest tests/unit/test_cached_ticket_service.py::test_cached_service_reads_database_once_and_rehydrates_ticket -q本地 Python 3.11 实际输出. [100%] 1 passed这个本地测试只证明装饰服务控制流。可信远端 selector 会清除唯一 key首次 GET 后检查真实 Redis 的签名 envelope 与 PTTL再停 PostgreSQL执行第二次 GET只有第二次仍返回同一版本才构成可观察的、无需查库的认证 Redis hit 证据。未运行仍为 PENDING。由读数倒推 service 边界要让“第二次不查库”仍不越过事实边界我们用CachedTicketService装饰原有TicketService而不是把 Redis 逻辑插进 repository。repository 继续只处理数据库事务因此缓存宕机不会改变 SQL 提交语义API 面向的 service 接口也保持不变可以单独检验命中、回源和故障。缓存 key 必须同时包含租户与工单 ID。如果 key 只是ticket:{id}即使 UUID 冲突概率很低代码也丧失了“租户隔离在每层都显式”的可审计性。真正的 Redis adapter 会 URL-escape key 片段避免tenant:a ticket和tenant a:ticket一类组合碰撞payload 与短期负标记均用部署密钥做 HMAC。这样正常 hit 不必额外查库Redis 故障仍可回源而未知 JSON 只能当作 miss不能当作业务事实。密钥经TICKETFLOW_CACHE_SIGNING_KEY在 API 与 Worker 间一致配置轮换令旧缓存自然退化为 miss。副本还要包含version、created_at和updated_at。version有助于后续发现陈旧副本时间戳帮助解释它何时生成两者都不自动提供强一致。loader 始终调用原 service只把严格 JSON 数据交给缓存命中后_deserialize重建领域Ticket。因此不能把 ORM 实例塞进 Redis、不能丢掉 tenant key、也不能只看两次响应相同就声称命中——本实验的读取计数正是在排除最后一种误读。本课练习为下一次写入准备两个问题。练习 EX-03-01 [code]写两个缓存 payload一个是 version1 的旧标题一个是 version2 的新标题。实现choose_newer(cached, database)断言新版本被选中同时打印updated_at用于排障并在注释中解释为什么光有版本与时间戳仍不是强一致。练习 EX-03-02 [prose]画出 PATCH 已提交、删缓存、与一个更早启动的 GET 回填旧值之间的顺序说明为什么“先更新 DB再删缓存”是必要基线却仍然留有一个并发窗口。第 04 篇会回答 EX-03-01/02并把更新路径改成“数据库提交成功后删除缓存”。不要期待它承诺强一致我们会刻意重演旧 GET 在删除之后回填 v1 的残余竞态。完整核心模块缓存装饰服务from__future__importannotationsfromdatetimeimportdatetimefromtypingimportProtocolfrom.ticketsimportTicket,TicketServiceclassTicketCache(Protocol):defget_or_load_ticket(self,tenant_id:str,ticket_id:str,loader):...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(idstr(payload[id]),tenant_idstr(payload[tenant_id]),actor_idstr(payload[actor_id]),titlestr(payload[title]),bodystr(payload[body]),prioritystr(payload[priority]),statusstr(payload[status]),versionint(payload[version]),created_atdatetime.fromisoformat(str(payload[created_at])),updated_atdatetime.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._serviceservice self._cachecachedefcreate(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:ticketself._service.get(tenant_id,ticket_id)return_serialize(ticket)ifticketisnotNoneelseNonepayloadself._cache.get_or_load_ticket(tenant_id,ticket_id,load)return_deserialize(payload)ifpayloadisnotNoneelseNonedefupdate(self,tenant_id:str,ticket_id:str,title:str|NoneNone,body:str|NoneNone,priority:str|NoneNone,)-Ticket|None:returnself._service.update(tenant_id,ticket_id,titletitle,bodybody,prioritypriority)one,) - Ticket | None:return self._service.update(tenant_id, ticket_id, titletitle, bodybody, prioritypriority)