🤖
AI审核中

从Refresh、实时GET到搜索可见性的排查实战

中间件 18分钟 112浏览 0评论

设想一个文章发布接口:用户点击保存,后端写入 Elasticsearch,收到 result: created,随后跳转到文章列表。

奇怪的事情发生了:列表里没有刚发布的文章,但拿着文章 ID 查询详情,数据又确实存在。过一会儿重新打开列表,文章才出现。

这时很容易产生三个判断:数据没有真正写进去、主从复制出现延迟,或者搜索缓存没有更新。

但在排查这些问题之前,应该先确认一个更基础的事实:

Elasticsearch 的“写入成功”和“能够被搜索到”,不是同一个完成条件。 Elasticsearch 提供近实时搜索,新写入的数据需要经过 Refresh,才能进入新的搜索视图。(Elastic)

本文通过一个独立测试索引拆解这个问题,再把排查范围扩展到批量写入、索引别名、路由以及 PIT 搜索视图,避免把所有“搜不到”都归因于刷新延迟。

一、先分清:写入确认、搜索可见和持久化

理解这个问题,首先要把三个容易混淆的概念拆开。

概念 主要回答的问题 不能据此直接推断什么
写入成功响应 这次写入是否按照当前配置完成处理 文档已经能被普通搜索命中
Refresh 最近的变更是否已经进入搜索视图 这些变更已经完成 Lucene commit
Flush 是否完成 Lucene commit,并开始新的 translog generation 应该把它当作业务上的“发布到搜索”操作

Refresh 面向搜索可见性;Flush 面向 Lucene 提交和事务日志生命周期。它们不是同一个操作,也不应该在排障时互相替代。(Elastic)

尤其需要纠正一种理解:

文档还没被搜索到,所以它肯定只在内存里,进程一重启就会丢。

在默认的 index.translog.durability=request 配置下,Elasticsearch 会在主分片和参与写入的副本上完成 translog 的相应持久化后,再报告写入成功。尚未进入最近一次 Lucene commit 的操作,可以在恢复时通过 translog 重放。(Elastic)

因此,“还没 Refresh”不能直接等同于“没有持久化”。

反过来也一样:文档已经能够被搜索,并不代表它必须先完成一次 Flush。

排查时应该问的是:

现在缺少的是写入结果、搜索可见性,还是业务查询条件下的可见性?

这三个问题,需要不同的证据。

二、用一个测试索引复现“详情有数据,搜索没有”

下面使用 Kibana Dev Tools Console 的请求格式,示例面向自建 Elasticsearch 的普通索引。

所有操作都限定在 refresh_visibility_lab 测试索引中。该索引关闭自动刷新、设置零副本,仅用于观察机制,不应照搬到生产索引。如果同名索引已经存在,请换一个测试名称。

1. 创建索引,关闭自动刷新

PUT /refresh_visibility_lab
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0,
    "refresh_interval": "-1"
  },
  "mappings": {
    "properties": {
      "article_id": {
        "type": "keyword"
      },
      "title": {
        "type": "text"
      },
      "status": {
        "type": "keyword"
      }
    }
  }
}

refresh_interval=-1 表示关闭周期性自动刷新。这样可以减少实验对执行速度的依赖,而不是要求你在默认刷新发生前“手速足够快”。(Elastic)

2. 写入一篇文章

PUT /refresh_visibility_lab/_doc/1001?refresh=false
{
  "article_id": "1001",
  "title": "理解 Elasticsearch 的搜索可见性",
  "status": "published"
}

首次成功创建时,响应中的 result 应为 created

这里显式使用 refresh=false,表示本次请求不主动安排额外的刷新动作。它并不是“禁止任何刷新”,其他操作仍可能触发刷新。(Elastic)

3. 先通过搜索接口查询

GET /refresh_visibility_lab/_search
{
  "query": {
    "ids": {
      "values": ["1001"]
    }
  }
}

在全新测试索引、没有其他客户端干预、没有额外刷新发生的条件下,预期 hits.total.value0

这里特意使用 ids 查询,而不是对标题做全文检索,是为了排除分词、查询表达式和字段类型的干扰。ids 查询按照文档的 _id 查找,但它仍然属于搜索接口,不会因此变成实时 GET。(Elastic)

4. 再通过文档 GET 接口读取

GET /refresh_visibility_lab/_doc/1001

这次预期能够得到 found: true,并看到文档内容。

原因是:文档 GET API 默认具有实时读取语义,不受索引刷新频率对搜索可见性的限制。(Elastic)

注意,区分依据不是 HTTP 方法有没有写成 GET

下面两个请求都使用 HTTP GET,但读取语义不同:

GET /refresh_visibility_lab/_doc/1001

GET /refresh_visibility_lab/_search
{
  "query": {
    "ids": {
      "values": ["1001"]
    }
  }
}

前者是按 ID 读取文档;后者是在搜索视图中执行查询。

实验中先执行搜索、再执行文档 GET,也是为了先观察搜索状态,减少其他读取操作对实验过程的干扰。不要把实时 GET 的实现简单理解为“永远只读 translog、绝不会触发任何内部刷新”;Elastic 工程师对某些实时读取路径的说明也指出了内部刷新的可能性。(Discuss the Elastic Stack)

5. 显式刷新后,再次搜索

POST /refresh_visibility_lab/_refresh

确认刷新响应没有分片失败,然后重复前面的 ids 搜索。

此时预期可以命中文档 1001。Refresh API 是同步操作:刷新完成后才返回,用于让相关索引此前尚不可搜索的变更进入搜索视图。(Elastic)

到这里,实验验证的不是“数据偶尔丢失”,而是:

同一篇文档,可以已经被实时 GET 读取,却尚未进入普通搜索使用的视图。

三、正确选择刷新策略,而不是统一加 refresh=true

发现问题后,最直接的改法,是给所有写入请求加上 refresh=true

这样做可能解决眼前的可见性问题,但还需要考虑刷新成本。写入接口支持的三个选项,可以这样理解:

参数 行为 返回时是否等待本次变更可搜索
refresh=false 不额外干预刷新 不等待
refresh=true 写入后立即刷新涉及的分片 等待
refresh=wait_for 等待刷新使本次变更可见 等待,但通常不主动强刷

这些选项作用于本次请求涉及的分片,不能理解成一次全索引、全系统的同步屏障。(Elastic)

1. 只对确实需要“写后即搜”的请求等待

例如,发布接口的产品要求是:

接口返回成功后,用户马上进入搜索列表,必须能查到刚发布的文章。

这时可以考虑 refresh=wait_for。Elastic 官方也将它作为“先写入,再搜索该文档”场景的推荐方式。(Elastic)

先为测试索引重新开启自动刷新:

PUT /refresh_visibility_lab/_settings
{
  "index": {
    "refresh_interval": "1s"
  }
}

然后写入新文章:

PUT /refresh_visibility_lab/_doc/1002?refresh=wait_for
{
  "article_id": "1002",
  "title": "为发布接口定义搜索可见性",
  "status": "published"
}

成功返回后,再通过同一个索引执行匹配该文档的新搜索。

这里的保证针对本次变更的搜索可见性,不代表文档一定能通过任意业务筛选,也不代表旧 PIT 会自动看到新数据。

2. 不要同时关闭自动刷新,又无限等待自动刷新

如果索引仍然是:

{
  "index.refresh_interval": "-1"
}

却发送 refresh=wait_for,请求可能一直等待,直到其他动作触发刷新。关闭周期性刷新,并不意味着 Elasticsearch 会自动为每个等待请求补一次刷新。(Elastic)

这能解释一种看似反常的现象:写入请求迟迟不返回,但另一个请求按 ID 已经能读到文档。

因此,把 wait_for 引入业务接口之前,要一起检查索引配置和客户端超时策略。客户端超时只能说明没有按时收到结果,不能单凭它认定文档没有写入;重试前应核对业务 ID 和当前文档状态。

3. wait_for 也不是完全没有成本

频繁使用 refresh=true 会产生较小的 segment,带来写入、搜索以及后续合并成本。另一方面,当刷新监听槽位耗尽时,wait_for 请求也可能退化为强制刷新,并在响应中出现 forced_refresh: true。(Elastic)

所以,合理的目标不是把所有请求都改成“立即可搜”,而是:

只让真正需要搜索可见性承诺的业务请求,为这个承诺付出等待或刷新成本。

四、如果刷新后仍然搜不到,按这个顺序排查

Refresh 只是一个分支,不是所有搜索缺失问题的答案。

下面的流程以“写入响应中的实际索引和文档 ID 已经记录下来”为前提:

graph LR
    A["文档搜不到"] --> B{"单条写入确实成功?"}
    B -->|否| C["检查写入错误和 Bulk 子项"]
    B -->|是| D{"目标索引中的实时 GET 可读?"}
    D -->|否| E["核对索引、ID、路由和后续变更"]
    D -->|是| F{"新搜索可见?"}
    F -->|否| G["检查刷新配置与刷新结果"]
    F -->|是| H["检查业务查询、别名和旧视图"]
    G --> I{"刷新后仍不命中?"}
    I -->|是| H
    I -->|否| J["明确业务需要的可见性保证"]

这是一条定位路径,不是“GET 读得到,就必然只有刷新问题”的判断规则。

1. 先确认 Bulk 中的那一条真的成功

批量写入时,最危险的检查方式是:只看整个 HTTP 请求有没有成功。

Bulk 响应可以包含 errors: true,并在 items 中返回各个操作的成功或失败结果。因此,必须检查目标文档对应子项的 statuserror_index_id,而不是只记录外层状态码。(Elastic)

例如,整个批次已经得到响应,但某篇文章因为字段解析失败而没有写入。此时继续增加 Refresh 次数,没有任何意义。

建议把“批次请求结果”和“文档处理结果”分开记录。补偿与重试也应定位到失败子项,而不是不加区分地重放整个批次。

2. 核对写入索引和搜索别名

假设系统分别使用 articles-writearticles-read 两个别名,可以检查:

GET /_alias/articles-write

GET /_alias/articles-read

重点对照写入响应中的实际 _index,确认读别名是否覆盖它,以及别名是否配置了 filterindex_routingsearch_routing。别名可以包含过滤条件,也可以为写入和搜索设置不同路由。(Elastic)

一个值得单独记住的细节是:

别名过滤器应用于 Query DSL 查询,但不应用于按 ID 获取文档。(Elastic)

因此,即使已经完成刷新,也可能出现“GET 查得到、通过别名搜索查不到”。

例如,别名只允许搜索 status=published 的文章,而文档实际状态是 draft。这不是刷新失败,而是搜索过滤条件正在生效。

排查应在授权范围内对照实际索引和业务别名,不能把临时绕过业务筛选的诊断方式变成线上接口实现。

3. 自定义 routing 必须保持一致

文档写入时使用了自定义路由,读取时也要使用相同的路由值:

GET /articles-v2/_doc/1001?routing=tenant-a

路由不一致可能使请求落到错误分片。GET 返回不存在时,应先核对原始写入的路由参数,不能直接得出“数据丢了”的结论。(Elastic)

对于多租户系统,建议把实际索引、文档 ID 和 routing 一起纳入写入日志。只保存 _id,可能不足以重建当时的访问路径。

4. 检查真实刷新配置,不要只背默认值

可以读取显式配置和默认值:

GET /refresh_visibility_lab/_settings?include_defaults=true&flat_settings=true

include_defaults=true 用于同时返回默认设置;flat_settings=true 则方便按完整配置键检查。(Elastic)

需要区分“默认值”和“显式配置为同样的值”。

在没有显式设置刷新间隔时,Elasticsearch 会进行搜索空闲优化:分片长时间没有搜索流量后,可以暂停后台刷新;后续搜索遇到待刷新的空闲分片时,会触发相应刷新。显式设置刷新间隔可以退出这种默认行为。(Elastic)

因此,不应把“默认大约一秒刷新一次”直接写成业务承诺,更不应该在代码里固定休眠一秒后就断言文档必然可搜索。

5. 检查是否仍然使用旧 PIT

PIT,即 Point in Time,是某个时间点的搜索视图,用来让多次搜索保持一致。

它的目标恰好不是“每次都看到最新数据”。PIT 创建之后的新变更,不会因为后续发生 Refresh 就自动进入旧视图。(Elastic)

所以,下面这个过程并不矛盾:分页查询先创建 PIT,随后新增文章并完成刷新,但继续使用原 PIT 翻页时仍然看不到新文章。

需要查看最新结果时,应开始一次新的搜索会话,必要时重新创建 PIT,而不是期待延长旧 PIT 的有效期能够更新其内容。

五、把修复方案放回业务链路,而不是只改一个参数

定位到机制之后,还要回答:用户真正需要什么?

下面给出三种业务设计思路。

1. 保存后只查看详情:未必需要搜索

如果页面只是展示刚刚保存的文章,可以返回保存结果,或者通过业务详情接口按 ID 读取。

当详情确实来自 Elasticsearch 时,可以利用文档 GET 的实时读取能力,但仍要保留原有鉴权、租户隔离和路由规则。

这里的设计重点是:不要为了展示一个已知 ID 的对象,强行依赖一次全文搜索。

2. 数据库异步同步到搜索:明确“保存”和“可搜索”两个状态

假设系统以数据库为事实来源,通过消息队列同步到 Elasticsearch,那么可以把流程明确表达为:

graph LR
    A["业务数据库提交"] --> B["记录待同步事件"]
    B --> C["消费者写入 Elasticsearch"]
    C --> D["确认搜索可见性"]
    D --> E["标记搜索同步完成"]
    E --> F["前端展示可搜索状态"]

这是一个业务状态设计示例,而不是 Elasticsearch 自动提供的完整流程。

在这种架构里,即使消费者使用 refresh=wait_for,也只解决 Elasticsearch 写入之后的一段等待。消息尚未被消费、写入失败或者事件仍在重试,都需要由上游链路单独处理。

因此,接口文案可以区分“保存成功”和“搜索同步完成”,而不是在数据库刚提交时就承诺所有搜索入口立即可见。

3. 离线重建索引:批次完成后统一验收

对于可以暂时不提供搜索的新索引,关闭自动刷新能够用于提高大批量导入效率;导入结束后需要恢复刷新配置。频繁刷新会影响索引速度,因此不宜对每条导入数据强制刷新。

一个可采用的发布流程是:

保存原配置 → 导入并处理所有失败子项 → 恢复原配置 → 显式刷新并检查结果 → 验证关键查询 → 完成读流量切换。

这里强调“恢复原配置”,而不是一律改成 1s

如果重建期间仍有增量写入,还需要把增量追平和写入切换纳入验收。Refresh 不是业务数据同步完整性的证明。

六、监控“多久能搜到”,而不只是“写入用了多久”

写入接口耗时,不能完整回答用户何时能够在搜索页面看到数据。

可以先查看索引的刷新、合并、segment 和 translog 统计:

GET /refresh_visibility_lab/_stats/refresh,merge,segments,translog?level=shards

这些指标可以帮助观察相关操作的变化;level=shards 用于查看分片级数据。分析时要注意,primaries 只汇总主分片,total 则包含主分片和副本,不能把不同统计口径直接混在一起比较。

但内部统计之外,还建议增加一项自定义的业务观测指标:

搜索可见性观测延迟 = 首次搜索命中的时间 − 收到写入成功响应的时间。

这不是 Elasticsearch 自带的同名指标,而是可以由测试程序或应用侧探测实现的测量方式。

一种设计是:使用唯一 ID 写入探测文档,收到响应后开始有截止时间的搜索轮询,记录首次命中的时间。如果目标是测量普通写入的可见性窗口,探测写入应使用默认刷新策略,而不是先用 wait_for 消除这个窗口。

测量时还应写清楚几个边界:

测量内容 需要注意的边界
Elasticsearch 内部可见性 直接访问目标索引,不混入应用缓存
业务端到端可见性 从业务提交开始,覆盖消息同步和真实搜索入口
首次命中时间 包含轮询间隔和查询耗时,不是精确的内部 Refresh 耗时
空闲索引表现 探测搜索本身会改变索引的搜索活跃状态

最后一点尤其重要:持续探测会影响默认搜索空闲优化的触发条件,因此测试结果必须和测试负载一起解释。

与其承诺“所有写入一定一秒可见”,不如根据真实业务测出可见性延迟分布,再定义可验证的目标。

七、总结:先问“在哪条读取路径上不可见”

面对“写入成功却搜不到”,不要一开始就重建索引、增加副本,或者把所有请求都改成强制刷新。

更有效的排查,是把问题不断缩小:

确认单条写入结果,再核对实际索引、ID 和路由;区分实时读取与搜索视图,最后检查别名过滤、业务条件和旧 PIT。

Refresh 解决的是搜索视图更新,不负责修复写入失败、错误路由、别名指向或者上游同步缺失。

真正需要建立的,不是“所有写入都立刻刷新”的规则,而是清晰的业务契约:

哪些请求只承诺保存成功,哪些请求承诺搜索可见,以及这个承诺如何被验证。

边界一旦明确,“为什么详情有数据,列表却没有”就不再是玄学,而是一个可以复现、定位和验收的工程问题。

0 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论