Elasticsearch索引在文档却为0?排查数据路由与模板别名陷阱 📅 发布时间:2026/9/9 18:08:44 👁 浏览次数: 做后端开发这些年Elasticsearch 相关的坑我踩过不少但“索引在文档没有”这个问题让我印象最深刻。4月27号接到线上反馈大麦项目的订单查询接口突然返回空数据登录 Kibana 一看索引 order_info 明明还在文档数量却一直显示 0用_count去数也是 0。索引存在数据却像凭空消失一样那一下午排查得相当焦灼。先说结论这不是数据丢失而是“数据写到了另一个索引里”。整个排查过程涉及 ES 的索引模板Index Template、别名Alias、动态索引创建机制以及查询代码里一个看似无害的硬编码。这篇文章把这次排障的完整过程、底层原理和通用排查方法整理出来希望能帮遇到类似问题的人省下半天时间。1. 理解问题本质索引和文档之间发生了什么1.1 ES 的写入链路和索引的生命周期要搞懂“只有索引没有文档”得先清楚一条数据从业务服务到 ES 可查询中间经历了什么。正常情况下客户端Java Client、Logstash、Filebeat 等发出一条写入请求ES 集群里会有一个协调节点Coordinating Node接收请求解析索引名然后根据文档 ID 的哈希值路由到对应的主分片。主分片写入内存缓冲区Indexing Buffer同时写 Translog 日志。默认情况下每隔 1 秒触发一次 Refresh缓冲区里的数据生成一个新的 Segment 文件此时文档才变得可搜索。之后副本分片会同步数据Translog 定期落盘并最终被清理。这个过程中有多个环节可能导致“索引存在但文档找不到”写入请求被路由到了别的索引Refresh 没发生或延迟写入被拒绝或部分失败查询时索引名/别名用错了索引模板把数据“转移”到了其他索引索引本身的生命周期也需要注意。ES 里有两种创建索引的方式显式创建和自动创建。显式创建是开发者在代码里调用 CreateIndex API类似于关系型数据库里的建表自动创建则是写入一条不存在的索引时ES 根据配置action.auto_create_index默认帮你把索引建出来。这两种方式如果没有配合好很容易出现“一边在写 A 索引一边在查 B 索引”的情况。1.2 “只有索引没有文档”的三种典型表现这次事故暴露出的问题其实可以归纳成三种典型表现大家以后可以根据自己的现象对号入座。第一种表现是_cat/indices里能看到索引但docs.count永远是 0怎么写入都是 0。这种大概率是写入请求根本就没到这个索引要么代码里索引名配错了要么被模板或别名重定向了。第二种表现是索引有文档但查询返回 0 条。这种情况往往是查询时用了别名而别名指向了一个错误的具体索引——比如指向了一个刚刚初始化还没有写入数据的空索引。ES 的别名就像一个 CNAME 记录你写的查询请求最终会被转发到它指向的真实索引上如果指向错了自然查不到。第三种表现比较隐蔽索引有时候能看到文档有时候看不到。这种通常和 Refresh 间隔、路由Routing设置有关。比如写入时指定了自定义 Routing数据分散到了不同分片查询时没带相同的 Routing 值ES 只检索部分分片结果就可能不完整。虽然严格说这不完全是“索引没有文档”但表现上非常像。大麦项目这次属于第一种和第二种的混合体后面细说。2. 现场还原427 大麦项目排障全过程2.1 第一轮排查基础 API 检查确认问题范围接到反馈后我第一时间连上测试环境的 Kibana执行了下面几条命令# 查看所有索引和文档数量 GET _cat/indices?vsindex # 查看目标索引的文档数 GET order_info/_count # 直接查询 GET order_info/_search { query: { match_all: {} } }输出结果显示order_info这个索引确实存在但docs.count为 0_count返回 0 条_search也是空结果。索引的 health 是 green分片也都正常。这说明索引本身没坏问题出在数据写入链路。然后我扩大了查询范围# 查看 order_info 开头的所有索引 GET _cat/indices/order_info*?vsindex这一看就发现问题了——集群里其实有两个索引索引名healthdocs.countstore.sizeorder_infogreen01.2kborder_info-2024.04.27green2358137.6mb真正有数据的是带日期的索引order_info-2024.04.27而order_info是个空壳。我当时的第一反应是写入代码是不是把索引名拼错了于是去查了当天发布的配置发现日志采集端 Logstash 的 output 配置里确实写的是order_info-%{YYYY.MM.dd}而这是个大坑——因为 ES 里存在一个宽泛的索引模板。2.2 第二轮排查怀疑 Refresh 和查询方式逐一排除在确认两个索引存在之后我先把 Refresh 的问题排除掉。因为如果 Refresh 间隔设得很大或者写入时带了refreshfalse那么文档会暂时待在缓冲区和 Translog 里_search查不到也是正常的。我执行了强制刷新POST order_info/_refresh POST order_info-2024.04.27/_refresh刷新之后order_info依然没有文档而order_info-2024.04.27的文档数没有变化。这说明不是 Refresh 的问题数据从物理层面就没有落在order_info。接着我怀疑是查询方式的问题。因为那段时间正好在看 Query Context 和 Filter Context 的文档我特意检查了查询语句。ES 的查询分为 Query Context 和 Filter Context 两种Query Context计算相关度得分会影响排序比如match、term查询Filter Context只过滤文档不计算得分有缓存机制性能更好比如bool.filter、constant_score里的查询我需要确认项目里是不是用了filter导致结果异常。但仔细一想filter只是不计算相关度得分不会改变文档是否匹配的结果——匹配就是匹配不匹配就是不匹配跟 Query Context 只是返回值里多了_score的差别。所以这个方向很快就排除了。真正让我警醒的是我把查询语句里的索引名改成了order_info-2024.04.27之后数据正常返回了。这时候问题基本锁定代码里查询的是order_info但数据写入的是order_info-2024.04.27两边根本不在一个索引上。2.3 第三轮排查抓到了真凶——模板和别名的路由问题既然确认了“查询索引”和“写入索引”不一致接下来要弄清楚为什么写入时 ES 会创建带日期的索引以及我们的代码为什么没有感知到这个变化。我查了集群里的索引模板GET _cat/templates?vsname结果发现有一个名叫order_info_template的模板index_patterns匹配的是order_info*。这个模板是在项目早期为了做按天滚动索引而创建的里面定义了两件事所有order_info开头的索引都继承同样的 mapping 和 setting模板里设置了别名order_info_write指向最新创建的带日期索引因为模板的匹配规则是order_info*所以当 Logstash 往order_info-2024.04.27写入时这个索引自动继承了模板配置然后正常写入数据。而order_info这个索引是应用启动时初始化代码里通过 CreateIndex API 显式创建的创建时同样命中了模板所以它也存在但没有文档写入。问题到这里就很清晰了order_info初始化代码显式创建无人写入所以 docs.count 0order_info-2024.04.27Logstash 写入数据正常查询代码硬编码查询order_info所以永远查不到数据这就是“只有索引没有文档”的真相——不是丢失是路由分叉了。3. 为什么文档会“消失”底层原理与关键机制拆解3.1 索引模板是如何把数据“偷走”的索引模板Index Template是 ES 里一个非常强大但也容易被误用的功能。它可以根据索引名的通配符匹配规则自动为新创建的索引套用预设的 Mapping、Setting 和 Alias。它的工作流程是这样的当 ES 收到一条写入请求协调节点会先解析索引名然后去匹配已有的模板列表。如果一个索引名匹配了多个模板按照priority字段的大小决定优先顺序优先级高的模板配置覆盖优先级低的。最后ES 用这套合并后的配置去创建索引如果索引还不存在。在大麦项目里模板order_info_template的匹配规则是order_info*。这意味着写入order_info-2024.04.27匹配套用模板创建新索引写入成功写入order_info匹配但在索引已存在的情况下模板的 mapping 设置不会覆盖已有索引的配置模板本身不会创建索引只有在第一条数据写入时才触发索引创建。但我们的应用初始化代码用 CreateIndex API 显式创建了order_info所以这个索引提前存在了看起来一切正常实际上是个空壳。这里有一个非常关键的认知ES 的索引名就是数据的路由入口对同一份业务数据前后端必须严格使用同一个索引名或同一个别名只要中间有一层不一致数据就“丢”了。模板虽然方便但它做的是“按名称匹配”如果你一边用固定名称建索引一边用带日期的名称写数据又不配置别名那必然出问题。3.2 别名Alias在读写中的双面性别名是 ES 提供的另一个重要特性。它本质上是一个逻辑指针可以指向一个或多个真实索引。读写请求可以打到别名上由 ES 自动转发到背后的物理索引。为什么说它有双面性因为别名能解决“索引名变更”导致的问题但前提是你所有代码都统一走别名而不是混用物理索引名和别名。理想情况下一个按天滚动索引的架构应该是这样的写入 - order_info_write 别名 - 指向 order_info-2024.04.27 查询 - order_info_read 别名 - 指向 order_info-2024.04.27写入别名的目的是让业务代码不用关心今天到底是 4 月 27 号还是 4 月 28 号底层的索引滚动对业务完全透明。但大麦项目的实际情况是查询代码用了物理索引名order_info写入链路Logstash用了带日期的物理索引名order_info-2024.04.27模板里配置了order_info_write别名但查询代码并没有走这个别名于是查询代码看到的是一个没有文档的索引而数据安安静静地躺在带日期的索引里。这个问题的本质不是 ES 的问题而是使用方没有统一“路由约定”。3.3 关于 Refresh、Translog 和段合并的几个常见误解排障过程中我还整理了几个容易被误解的知识点一起分享出来。第一个是 Refresh 和 Translog 的关系。Refresh 是让缓冲区数据变成可搜索的 Segment默认 1 秒执行一次所以写入后最多 1 秒就可以查到了。Translog 是防止宕机丢数据的日志每次写入都会追加到磁盘但它不参与查询。如果写入时显式设置了refreshfalse比如批量写入文档会暂时不可查但重新 Refresh 后就能查到了。这个机制不会导致数据永久消失但确实会让“写入后立刻查询”出现空结果也算是常见的“假丢失”场景。第二个是 Segment 合并与文档删除。ES 的删除和更新是“标记删除”机制文档先在 Segment 里标记为 deleted真正物理删除要等后面的段合并Merge才发生。所以大家查询的docs.count统计的是未删除文档的数量。如果你用了 Delete By QueryIndex 的 docs.count 不会立即减少这是个正常现象。第三个是关于路由的问题。ES 默认根据_id的哈希值把文档分布到主分片上查询时会广播到所有分片再汇总。如果写入时指定了routinguser_123文档会被固定路由到某个分片查询时如果也想用自定义路由必须带上同样的 routing 值否则结果不完整。这个坑也非常常见尤其是在用了父子关系Join或个性化查询的场景。4. 修复与验证从临时处理到长期方案4.1 临时恢复数据访问问题定位了第一要务是让业务先恢复。既然查询代码查的是order_info而数据在order_info-2024.04.27里最直接的办法就是重新定义别名让order_info指向真实数据索引。ES 的别名操作是原子的可以一次完成POST /_aliases { actions: [ { add: { index: order_info-2024.04.27, alias: order_info } } ] }但这里有个坑如果order_info已经是一个真实索引就不能直接给它加别名。ES 不允许同一名称既是指标名又是别名。所以需要先删除旧索引再加别名# 步骤1删除空索引 DELETE /order_info # 步骤2添加别名 POST /_aliases { actions: [ { add: { index: order_info-2024.04.27, alias: order_info } } ] }这个操作执行后查询代码不用做任何改动GET order_info/_search就能访问到order_info-2024.04.27的数据了。临时方案是为了尽快止血但也意味着把“历史包袱”暂时扛了下来——之后必须做彻底的代码整改。4.2 修改索引模板和写入配置临时恢复之后真正的修复要从源头解决“路由分叉”的问题。我的处理思路分三步第一步修改索引模板的别名配置。既然系统里引入了按天滚动索引模板里就要明确写清楚别名让所有查询统一走稳定别名而不是物理索引名。{ index_patterns: [order_info*], priority: 100, template: { settings: { number_of_shards: 3, number_of_replicas: 1, refresh_interval: 5s }, mappings: { properties: { order_id: { type: keyword }, amount: { type: double }, status: { type: keyword }, create_time: { type: date } } }, aliases: { order_info_read: {} } } }这里重点是把稳定别名order_info_read放进模板这样以后任何order_info开头的索引被自动创建都会带上这个别名查询端永远只需要访问order_info_read不用关心底层索引叫什么。第二步修改应用里的初始化逻辑。删除显式创建order_info索引的代码让索引完全交给模板和写入链路自管理。如果一定要保证索引提前存在也应该用order_info-2024.04.27这样的带日期索引名并在写入前加上index.exists判断避免重复创建。第三步统一配置管理。把索引名、别名、模板名都收口到配置中心禁止在业务代码里硬编码索引名。Logstash 侧的 output 配置也要使用配置中心的变量避免和 Java 服务侧不一致。4.3 验证结果和回归测试修复完成后我做了三组验证。第一组验证是确认数据可查询GET order_info_read/_count GET order_info_read/_search返回的文档数和order_info-2024.04.27一致说明别名转发正常。第二组验证是模拟第二天的数据写入。因为按天滚动索引的核心是“每天自动创建新索引”所以我手动创建了一个假的次日索引order_info-2024.04.28并往里面写入测试文档POST order_info-2024.04.28/_doc/1 { order_id: TEST001, amount: 99.9, status: PAID }然后查询order_info_read确认新索引的数据能被查到。这一步是为了验证模板里的别名配置对未来的索引同样生效。第三组验证是回归线上查询接口。把业务服务切到新配置后通过接口调用查询订单返回结果和数据库里的记录一致。另外我盯了一段时间的写入指标确认日志采集端和业务写入端都稳定指向了带日期的索引没有再往旧的空索引里写数据。5. 排查 ES“空索引”问题的速查手册5.1 必查的四个核心 API这次排障总结下来有几条 API 是排查“索引有、文档无”问题的核心工具建议大家收藏。第一个是查看索引列表GET _cat/indices?vsindex这个命令能让你快速看清集群里到底有哪些索引、每个索引的文档数和存储大小。不要只看你关心的那个索引要按同样的前缀把整个家族的索引都拉出来特别容易发现带日期的索引里藏着数据。第二个是查看索引模板GET _cat/templates?vsname GET /_template/order_info_template查模板主要是为了确认有没有“宽泛匹配规则”把你的索引名劫持了。注意检查模板的index_patterns、priority和aliases字段。第三个是查看别名GET _alias GET /order_info*/_alias这一步是为了搞清楚你查的索引名到底是真的索引还是别名以及别名指向了哪些物理索引。第四个是查看分片分配情况GET _cat/shards/order_info*?v GET /_cluster/allocation/explain如果索引的分片没有正常分配比如磁盘水位线过高、副本数配置问题也可能出现写入失败但索引可见的情况。5.2 常见原因对照表根据这次经验和平时踩过的坑我把常见的“只有索引没有文档”的原因整理成一个速查表现象原因排查方式解决建议索引存在docs.count 为 0写入索引名和查询索引名不一致_cat/indices对比同名前缀所有索引统一索引名使用别名索引存在docs.count 为 0模板 match 规则太宽数据被路由到其他索引查_cat/templates对比index_patterns收窄模板匹配规则增加别名有文档但查询返回 0查询走别名别名指向了空索引_alias查看别名指向修改别名指向写入后立刻查询为 0Refresh 间隔设置过大或写入指定了refreshfalse执行POST /index/_refresh后重新查询调整 refresh_interval或写入后显式 refresh写入部分失败但外层没发现Bulk 请求里部分文档失败业务代码忽略错误看 Bulk response 的errors字段完善错误处理写入失败告警有索引有文档但搜索不全写入用了 routing查询没带 routing对比写入和查询的 routing 参数保持读写 routing 一致5.3 预防建议从架构层面避免这类问题代码层面的修复只是治标从架构层面规避才是治本。这里分享几个我的实践经验。第一引入索引生命周期管理ILM而不是纯手写按天滚动索引。ILM 可以自动完成从热节点到冷节点的阶段转换而且索引的 Rollover 策略可以保证物理索引永远按标准命名生成配合别名使用非常稳定。如果你还在手动拼接yyyy.MM.dd的索引名建议尽早迁移。第二查询端强制走别名禁止在代码里直接引用物理索引名。物理索引名是易变的今天叫order_info-2024.04.27明天叫order_info-2024.04.28代码里写死了就等于给自己埋雷。别名一旦上线业务代码就只认识一个稳定的名字。第三上线前做一次“写入即可见”的联调验证。很多问题都是因为开发环境和测试环境的数据量小、索引名碰巧一致掩盖了问题。真正到生产环境后多个服务、多个环境共用集群模板、别名一冲突就暴露了。最好是每次发布前用一条真实链路的数据做端到端验证确认写进去能查出来。第四监控索引文档数的增长。如果某个核心索引的 docs.count 长期为 0或者增长曲线突然断崖说明写入链路可能有问题要第一时间报警。这个监控做起来很简单定时跑_cat/indices采集指标就行但价值非常高。排查完这个问题的当天晚上我在项目群里同步了结论和修复方案大家都觉得这问题很“冤”——代码一行没改错只是索引名的路由约定乱了。但仔细想想这种问题恰恰是最值得警惕的。ES 本身是可靠的出问题的往往是使用姿势。把模板规则、别名指向、索引命名这些都提前规划好才能避免半夜爬起来看 Kibana。这次的经验希望也能帮各位少踩一次坑。