
Python后端中间件专题17LIKE查不到想要的工单——倒排索引与 Mapping客服说“我明明记得有一张 VPN 登录失败的工单为什么输入‘登录失败’能找到输入‘VPN 凭据’却没有”如果数据库里只有几十张工单临时写一个%关键词%似乎也能交差数据到了百万级再叠加租户、状态、优先级和标签过滤这条路很快会同时输掉性能和搜索质量。这一课不急着背 Elasticsearch DSL。我们先拿四张小工单做一次“搜索尸检”弄清楚倒排索引究竟保存了什么再为 TicketFlow 定下第一版 Mapping。目标不是把 Elasticsearch 变成第二个业务库而是建立一个随时可从 PostgreSQL 重建的搜索投影。这次排查结束时你应当能做到什么用“词项 → 文档编号集合”解释倒排索引为什么适合全文搜索分清text、keyword知道租户、状态和标签为什么不能被全文分词看懂dynamic: strict、多字段title.raw和日期/版本字段的用途运行 TicketFlow 的 Mapping 检查点并能指出它证明了什么、没有证明什么坚守事实边界索引里的一条 document 是可重建投影不是工单真相。动手前的最小准备请先完成 16尤其是version为什么可以仲裁乱序事件。本文的本地实验只需要导读中已经建立好的 Python 3.11 虚拟环境不需要在 Windows 上启动 Elasticsearch。进入project/并确认$env:PYTHONPATH (Resolve-Path.\src).Path python--version python-cfrom ticketflow.search.mapping import ticket_index_definition; print(ticket_index_definition()[mappings][dynamic])预期最后输出strict。本地 revision checkpoint 只核对 Mapping 合同远端 selectortests/integration/test_search_elasticsearch.py::test_real_elasticsearch_mapping_analyzer_and_keyword_filters会在隔离 ES8 中建唯一索引核对_analyzetoken、真实keyword过滤和 Mapping再精确删除该索引。未运行远端时保持 PENDING不能冒充真实 ES 集成通过。上一课练习答案先结清第 16 课的两道练习它们的“版本”和“事实边界”正是本课索引设计的前提。答案 EX-16-01 [code]下面是完整的version_gate_lab.py。它故意按1 → 3 → 2的顺序交付事件版本 2 虽然后到却不能覆盖版本 3。fromdataclassesimportdataclassdataclass(frozenTrue)classTicketEvent:version:inttitle:strclassVersionGateProjector:只保留更高版本模拟搜索投影处理乱序事件的决策边界。def__init__(self)-None:self.current:TicketEvent|NoneNonedefapply(self,event:TicketEvent)-str:ifself.currentisnotNoneandevent.versionself.current.version:returnstaleself.currenteventreturnappliedprojectorVersionGateProjector()deliveries[TicketEvent(version1,titleVPN 无法登录),TicketEvent(version3,titleVPN 凭据已重置),TicketEvent(version2,titleVPN 正在处理中),]fordeliveryindeliveries:decisionprojector.apply(delivery)print(fversion{delivery.version}decision{decision})assertprojector.currentTicketEvent(version3,titleVPN 凭据已重置)print(ffinal{projector.current.version}:{projector.current.title})从project/运行命令python version_gate_lab.py预期输出version1 decisionapplied version3 decisionapplied version2 decisionstale final3:VPN 凭据已重置这个小程序先把规则做成可观察的决定。TicketFlow 的真实实现不会先读当前版本再由 Python 比较因为两个 Worker 仍可能竞态第 20 课会把同一规则交给 Elasticsearch 的 external versioning 原子执行。答案 EX-16-02 [prose]SLA 升级事务提交后详情页必须以 PostgreSQL 为准所以它应立即看到statusescalated和递增后的version。搜索页读取 Elasticsearch 投影可能在 Outbox 尚未投递、Worker 尚未完成或索引尚未 refresh 的短暂窗口里仍显示旧状态。搜索接口可以明确表达“搜索结果最终一致”却不能反过来用旧搜索结果覆盖详情页。删除 Elasticsearch 索引只删除一份派生读模型。工单正文、状态和版本仍在 PostgreSQL重建程序从事实库分批读取最新记录写入新 concrete index校验后再切 alias。若删除索引会连带删除工单说明系统早已让投影越过了事实边界。事故现场为什么%VPN 凭据%不是全文搜索假设标题分别是1 VPN 登录失败 2 VPN 凭据过期 3 邮箱登录超时 4 退款到账失败SQLtitle LIKE %VPN 凭据%寻找的是一段连续字符因此找不到第 1 张也无法表达“VPN 更重要、登录也可以匹配”。前导%通常还让普通 B-tree 索引无从利用。你当然可以继续叠加数据库全文索引但本项目需要高亮、相关性、过滤、深分页和独立重建流程因此选择 Elasticsearch 作为专门的搜索投影。倒排索引换了观察方向。它不从文档出发挨个扫描而是保存近似这样的表vpn - [1, 2] 登录 - [1, 3] 失败 - [1, 4] 凭据 - [2]查询“VPN 登录”时先取两个 posting list再根据查询的 AND/OR 规则和相关性组合文档。真正的 analyzer 还会做字符过滤、分词和 token 过滤上表只是帮助你建立数据结构直觉不代表standardanalyzer 对所有中文句子都能得到理想词项。生产中文搜索通常还要评估语言分析器、同义词和领域词典那是搜索质量测试不应靠想象改 Mapping。Mapping 是写入前签下的字段契约TicketFlow 的 Mapping 有三组字段它们承担的查询职责不同。1. 会被搜索的自然语言texttitle和body使用text写入时经过 analyzer 生成词项。title额外带一个raw子字段{title:{type:text,analyzer:standard,fields:{raw:{type:keyword,ignore_above:256}}}}title用于全文相关性title.raw才适合做精确排序或管理视图。一个字段可以用两种索引方式服务不同读取需求但业务正文仍只存一份_source。2. 必须精确比较的边界keywordticket_id、tenant_id、status、priority、tags都是keyword。租户过滤尤其不能“差不多”请求来自tenant-a时只能命中精确值为tenant-a的文档。若把它设为text分析器可能拆分、规范化随后开发者又误用match查询租户隔离便从硬边界退化成搜索相关性。为什么_source里还要再存一次ticket_id明明 ES 文档已经有_id因为 Elasticsearch 8 不把_id当普通可排序字段使用。第 18、19 课需要一个支持排序的唯一 tie-breaker所以 Mapping 显式声明ticket_id: keyword投影写入时也必须填入同一个业务 ID。它不是第二套主键而是搜索投影里的可排序副本。状态、优先级和标签也不需要相关性评分。它们会进入第 18 课的bool.filter让 Elasticsearch 做精确过滤并有机会缓存过滤结果。3. 用来判断新旧和时间的类型字段version是long用于观察并调试投影版本真正拒绝旧写入的参数在索引请求上。updated_at是date服务排序和范围过滤。把日期保存成任意字符串表面能显示实际会失去可靠的时间语义。最后dynamic: strict会拒绝未声明字段。TicketFlow 宁可让拼错的tenant_di在投影 Worker 中暴露并重试/DLQ也不愿悄悄生成一个无法受租户过滤保护的新字段。严格不是为了“配置好看”而是防止写入形状无声漂移。逐项检查真实 Mapping而不是只看 JSON 长得像正式 selector 必须从课程根运行python tools/verify_executable_revisions.py--lesson 17--python python以下1 passed是 Mapping 单项本地示例不是远端 ES 结果正式 revision 命令运行累积历史合同. [100%] 1 passed in 0.04s这个测试逐项断言ticket_id/tenant_id/status/priority/tags是keywordtitle/body是textversion是long。如果有人把tenant_id改成text或删掉后续深分页依赖的ticket_id检查点会立刻失败。它没有证明真实 Elasticsearch 接受了这份 Mapping也没有证明中文分析质量合格。后者需要把代表性工单喂给真实 analyzer用_analyze和搜索结果做相关性验收真实服务未运行时只能记录SKIP。面试现场常追问的三个问题“既然 PostgreSQL 是事实库为什么搜索结果还要存一份正文”这是有意的数据冗余投影为读取模式优化可以延迟、丢失并重建。它减少跨库查询却必须接受最终一致和重建成本。“text 和 keyword 能混着查吗”能但查询语义不同。自然语言通常用match/multi_match租户、状态、标签用term/terms。在keyword上使用match有时看似能工作不代表边界表达正确。“为什么不允许 dynamic mapping”因为自动推断可能把同名字段在不同文档中推成冲突类型也可能悄悄接受拼写错误。多租户边界和长期演进都更适合显式 schema。常见失败怎么定位建索引时报strict_dynamic_mapping_exception先对照事件 envelope 与 Mapping 找多余/拼错字段不要第一反应把strict关掉。按状态过滤没有命中确认 Mapping 是keyword查询用term/terms并检查值的大小写与真实枚举。中文查询结果怪异调用_analyze查看 token再用业务语料测试 analyzer不要只靠英文例子推断中文效果。搜索里有工单、详情却 404先检查租户和 PostgreSQL 事实。搜索命中不能作为越权读取详情的凭证。本课练习练习 EX-17-01 [code]写一个mapping_probe.py调用ticket_index_definition()断言ticket_id、tenant_id与status都是keyword、title是text、dynamic为strict最后打印这五项。要求脚本能在project/下直接运行。第 18 课会给出完整代码、命令和输出。练习 EX-17-02 [prose]产品提出“搜索 VPN同时只看当前租户的 open/high 工单”。请分别说明关键词、租户、状态、优先级应放进bool查询的must还是filter并解释把租户条件放进相关性查询会造成什么风险。下一篇预告Mapping 只决定“字段如何被索引”。第 18 课会把用户关键词、不可绕过的租户边界、状态/优先级过滤、稳定排序和高亮合成一份查询我们还会专门验证空关键词和跨租户场景。本课完整核心模块search/mapping.py下面代码来自lesson-17阶段快照。它是本课修改后的完整模块不需要读者猜测应该把片段放到哪里。from__future__importannotationsdefticket_index_definition()-dict[str,object]:Return the versioned index contract used by writers and the reindex tool. Business filters are keywords so tenant and status checks stay exact. Human prose remains analyzed text; title.raw is retained only for deterministic administration views, not for the normal relevance query. return{settings:{number_of_shards:1,number_of_replicas:0},mappings:{dynamic:strict,properties:{# Elasticsearch 8 deliberately does not make _id a normal# sortable field. Keep a keyword copy in _source so the# final deep-pagination sort value is stable and supported.ticket_id:{type:keyword},tenant_id:{type:keyword},title:{type:text,analyzer:standard,fields:{raw:{type:keyword,ignore_above:256}},},body:{type:text,analyzer:standard},status:{type:keyword},priority:{type:keyword},tags:{type:keyword},version:{type:long},updated_at:{type:date},},},}