
Python后端中间件专题20工单已创建却搜不到——refresh、异步索引与版本乱序用户刚提交“VPN 凭据失效”详情页马上能打开搜索页却暂时找不到。客服以为工单丢了后端看见 PostgreSQL 行、Outbox 事件、Worker 日志都在怀疑是搜索延迟。这时最糟糕的“修复”是让创建接口等待 ES 刷新并在 ES 不可用时把数据库事务也判失败这会把一个可重建的投影升格成业务事实。这一课用一条时间线把四个时刻分开数据库提交、事件发布、投影写入、搜索 refresh 可见再让两个 Worker 乱序完成验证旧版本不能回盖新版本。上一课练习答案答案 EX-19-01 [code]保存为cursor_guard_probe.py从project/运行fromticketflow.search.queryimport(InvalidCursor,SearchFilters,build_search_body,decode_cursor,encode_cursor,)sort_values[2.5,2026-09-21T09:00:0000:00,ticket-9]cursorencode_cursor(sort_values)assertdecode_cursor(cursor)sort_values bodybuild_search_body(tenant_idtenant-a,queryVPN,filtersSearchFilters(),size10,cursorcursor,)assertbody[search_after]sort_valuesprint(cursor round-trip ok)try:decode_cursor(not-base64)exceptInvalidCursor:print(invalid cursor rejected)else:raiseAssertionError(invalid cursor was accepted)从project/运行命令python cursor_guard_probe.py预期输出cursor round-trip ok invalid cursor rejected注意这个测试证明当前非法样本被拒并不证明所有畸形/超长游标都经充分校验Base64 更不是授权边界。任何后续页面请求仍由服务端加租户过滤。答案 EX-19-02 [prose]v1 的游标保存的是 v1 命中列表最后一条的评分、时间、ID切到 v2 后Mapping、分析器、数据回填时间甚至评分都可能变了。游标可解码只说明它是三元组不说明它在 v2 的相同位置。简单产品策略是在索引 generation 变化时返回“结果已更新请重新搜索”不偷偷接着翻页。更强的策略是用 PIT 锁定同一 concrete index 的读取视图在游标中绑定 PIT ID/查询指纹设置过期和清理此方案成本更高必须处理 PIT 失效。当前课程模块没有 PIT不应声称跨切换无重无漏。四个时刻不要用“创建成功”合并t0 POST /tickets → PostgreSQL 中 ticket Outbox 同事务提交 → HTTP 201 t1 relay 发布事件到 RabbitMQ 并拿到 confirm t2 Worker 读取最新 ticket向 ES 写入投影 t3 ES refresh 后这份 document 对 _search 可见t0是事实提交详情页应以它为准t3是读投影可见可能晚于t0。在t2后马上用_get和_search得到不同观察并不矛盾近实时搜索依赖 refresh。默认每次写都强制refreshtrue会让批量索引付出显著代价TicketFlow 的project_ticket()明确传refreshFalse让 ES 以正常刷新节奏处理。本地用一个最小实验核对“写请求不强制刷新”和“版本随请求传递”但它不模拟真实 ES refreshimportasynciofromdatetimeimportdatetime,timezonefromticketflow.search.projectorimportTicketProjection,project_ticketclassRecordingIndexer:def__init__(self):self.calls[]asyncdefindex(self,**kwargs):self.calls.append(kwargs)asyncdefmain():backendRecordingIndexer()ticketTicketProjection(ticket_idticket-20,tenant_idtenant-a,titleVPN 凭据失效,body登录报错,statusopen,priorityhigh,tags(vpn,),version3,updated_atdatetime(2026,9,22,tzinfotimezone.utc),)awaitproject_ticket(backend,aliasticketflow-tickets,ticketticket)assertbackend.calls[0][version]3assertbackend.calls[0][version_type]external_gteassertbackend.calls[0][refresh]isFalseprint(version3 external_gte refreshFalse)asyncio.run(main())保存为projection_probe.py从project/运行命令python projection_probe.py预期输出version3 external_gte refreshFalse真实服务上应记录 Outbox/Worker 的处理时间并有界轮询_search看到 t2 之后短暂未命中是预期窗口超过预定延迟阈值才报警。不要把“目前无结果”直接等同于“索引服务不可用”二者在 HTTP 契约上不同。两个 Worker 反着到谁说了算假设工单从 version 1 改到 2、再改到 3。消息至少一次投递两个 Worker 可以分别拿着 version 3 与 version 2 的投影完成顺序是 3→2。如果只按“最后到达”覆盖搜索会倒退。project_ticket()将 PostgreSQL 的版本传给 ESPUT /ticketflow-tickets/_doc/ticket-20?version3version_typeexternal_gte PUT /ticketflow-tickets/_doc/ticket-20?version2version_typeexternal_gte第二次写收到 version conflict409适配器把它当作旧事件的预期终态而非反复重试的 Worker 故障。相等版本允许重复写入是external_gte的语义因此同一个 version 必须对应确定的业务快照如果生产系统允许同版本不同内容那应重新设计版本/幂等合同。这里有一处值得留意的细节Worker 实际如何从事件取版本、是否读取最新 PostgreSQL 快照、索引 doc 与 Outbox 关联都要用真实端到端旅程观察。本课 unit 测试只证明project_ticket()发出的参数与 HTTP adapter 对 409 的处理不代替 Worker、broker、ES 的集成证据。跑本课检查点并知道它的证明范围从课程根运行python tools/verify_executable_revisions.py--lesson 20--python python正式本地命令对 lesson-20 独立安装并执行 baseline 与 09–20 全部历史合同包括投影 document、外部版本与refreshFalse。远端 selectortests/integration/test_search_elasticsearch.py::test_real_refresh_window_and_external_version_rejects_stale_event在唯一 ES8 索引记录写后、显式 refresh 前的可见性不强求每次都不可见核对写请求未强制 refresh、刷新后可搜索以及迟到旧版本不能覆盖新版本。它不模拟两个真实 Worker 乱序未运行仍是 PENDING。若生产中“长时间搜不到”沿着ticket → outbox pending → relay confirm → worker effect → ES alias/doc → refresh/_search逐站定位。若版本反退先查是否遗漏 external version、是否向错误 index alias 写入、是否多个系统独立产生 version。若索引 HTTP 500不能按 409 旧事件吞掉它应进入有限重试/DLQ 与告警。本课练习练习 EX-20-01 [code]写一段httpx.MockTransport实验让 ES 对 version 3 返回 201、对迟到 version 2 返回 409。用ElasticsearchHTTP.index()两次提交同一 ID断言请求都带version_typeexternal_gte最终打印new accepted; stale ignored。第 21 课给出完整代码和命令。练习 EX-20-02 [prose]列出创建工单后 t0–t3 四个时刻如果 t1 后 Worker 暂停而搜索没有命中详情页与搜索页各应怎样表现解释为什么不能让“搜索尚未可见”回滚已经提交的工单。下一站新 Mapping 如何上线第 21 课不删业务数据、不直接修改正在读的索引从 PostgreSQL 回填新 concrete index核对文档集合再原子切换 alias。这个过程也会暴露“搜索是可重建投影”真正的操作成本。本课完整核心模块search/projector.py以下是lesson-20的完整投影写入模块。完整代码说明了输入形状、版本仲裁参数和刷新策略阅读时请对照上面的四时刻不要把await index()误读成搜索已经立即可见。from__future__importannotationsfromdataclassesimportdataclassfromdatetimeimportdatetimefromtypingimportProtocolclassAsyncIndexer(Protocol):asyncdefindex(self,**kwargs:object)-None:...dataclass(frozenTrue,slotsTrue)classTicketProjection:ticket_id:strtenant_id:strtitle:strbody:strstatus:strpriority:strtags:tuple[str,...]version:intupdated_at:datetimeasyncdefproject_ticket(indexer:AsyncIndexer,*,alias:str,ticket:TicketProjection)-None:Write a search projection without allowing an older event to win. Outbox delivery is at-least-once and different workers can finish out of order. Elasticsearch external versioning turns the PostgreSQL ticket version into the conflict arbiter; the queue arrival time is irrelevant. awaitindexer.index(indexalias,idticket.ticket_id,document{ticket_id:ticket.ticket_id,tenant_id:ticket.tenant_id,title:ticket.title,body:ticket.body,status:ticket.status,priority:ticket.priority,tags:list(ticket.tags),version:ticket.version,updated_at:ticket.updated_at.isoformat(),},versionticket.version,version_typeexternal_gte,refreshFalse,)