Elasticsearch 的近实时模型与误用为数据库的后果

📅
2 分钟阅读
·

系列目录

  1. MySQL 索引与慢查询:B+ 树如何减少扫描
  2. 声明式事务之下:InnoDB 的 MVCC 与锁
  3. 接入层:Nginx 反向代理与 OpenResty 的边界
  4. Web 容器与 Netty:线程模型之下的 IO 模型
  5. Redis(上):缓存用法与单线程模型的限制
  6. Redis(下):超出缓存用途的用法:锁、队列与排行榜
  7. 数据库访问层:连接池与 MyBatis 的显式 SQL
  8. Kafka(上):吞吐的来源是顺序 IO
  9. Kafka(下):生产端、broker 与消费端的可靠性配置
  10. RPC 框架:像本地调用一样调远程的代价
  11. 消息语义:按 at-least-once 设计业务代码
  12. 熔断与限流中间件:把失败当作正常状态管理
  13. 唯一 ID 中间件 Leaf:号段模式与雪花模式
  14. RocketMQ 的事务消息与延迟消息
  15. 分布式任务调度:同一时刻只跑一份
  16. ZooKeeper/etcd:小数据强一致的协调服务
  17. 分库分表:用应用层复杂度换数据库容量
  18. 分布式事务:2PC 的代价与业务补偿模式
  19. Elasticsearch 的近实时模型与误用为数据库的后果(本篇)

写入后暂时无法检索

2021 年前后台系统有一批搜索需求,原来跑在 MySQL 上,用 LIKE %关键词% 做模糊匹配,数据量上来后慢查询增多。LIKE %词% 走全表扫描,B+ 树索引帮不上忙(MySQL 索引与慢查询:B+ 树如何减少扫描讲过最左前缀与 range 后失效,模糊匹配前置 % 是同类问题)。需求是多字段任意词命中、按状态和时间筛选、按相关度排序,MySQL 既要全文匹配又要条件过滤,单条查询要扫几百万行。团队决定把搜索迁到 Elasticsearch(以下接入场景记为 B 级,业务细节与接入年份以学习认知为准,本地起 ES 7.x 单节点复现过文中机制)。

接入后第一次遇到的问题是:写入一条文档,紧接着查询搜不到。代码层面索引调用返回了 success,过一秒再查就出现了。

POST /orders/_doc
{ "title": "手机壳硅胶透明", "status": "paid" }

# 紧接着查,可能搜不到(refresh 未触发)
GET /orders/_search
{ "query": { "match": { "title": "手机" } } }

# 手动触发 refresh 后立即可搜
POST /orders/_refresh

排查后定位到 refresh_interval,ES 默认 1 秒做一次 refresh,refresh 之前新写入的文档对查询不可见。这个行为和 MySQL 完全不同:MySQL 事务提交后,后续读在隔离级别允许的范围内立即可见;ES 的写入到可检索之间有一个固定窗口。测试环境里为了等这条文档可见,有人直接在写入后 sleep 一秒再查,或者手动调 POST /_refresh 强制刷新。这两种做法都有代价:sleep 拖慢链路,手动 refresh 在高频写入时会产生大量小 segment。

这个窗口来自 ES 的写入模型。写入、变为可检索和持久化分别发生在以下阶段,因此部分用法会受到该模型限制。

ES 从写入到可检索的路径

ES 写入到可检索的全链路:buffer、translog、refresh、segment 与近实时窗口

一条文档写入 ES,经过的步骤:

写入 buffer 与 translog。 index 请求到达分片的主副本,文档先写入内存中的 indexing buffer,同时追加写一份到 translog。translog 是预写日志,作用和 InnoDB 的 redo log 类似:记录尚未落盘的变更,崩溃后用来恢复。此时文档在 buffer 里,还没有生成倒排索引,查询搜不到。

refresh 生成 segment。 默认每 1 秒做一次 refresh:清空 buffer,把里面的文档生成一个新的 Lucene segment。segment 是倒排索引的物理单元,写入后不可变。refresh 生成的 segment 写到 segment 文件但未 fsync,数据在 OS page cache 中,Lucene 通过文件句柄读取即可命中。这就是「写入后 1 秒可搜」的来源,也是「近实时」的含义:接近实时,但有 refresh 间隔的延迟。

flush 落盘并清 translog。 translog 积累到阈值(index.translog.flush_threshold_size,默认 512MB)或定时触发 flush:把内存里所有 segment fsync 到磁盘,清空 translog。flush 之后,崩溃恢复不再需要这些 translog 记录。

segment 写入 OS page cache 后即可检索,不必等待 fsync,因此 ES 可以在 refresh 后提供查询结果。限制是 refresh 间隔内的新文档不可见;translog 尚未 fsync 的部分也可能在崩溃时丢失。translog 的持久化由 index.translog.durability 控制:request 模式每次 index 请求后 fsync,崩溃不丢但吞吐低;async 模式按 index.translog.sync_interval(默认 5 秒)fsync,崩溃可能丢最近几秒写入。生产上多数搜索场景选 async 换吞吐,能接受秒级丢失;不能丢的场景选 request。批量导入时会把 refresh_interval 调到 -1(关闭自动 refresh)或拉长到 30s,导完再恢复。否则每秒一次 refresh 会产生大量小 segment;当 merge 无法及时合并时,查询要在几十个段里过滤标记删除,延迟升高。

segment 不可变带来一个后果:更新和删除不修改原 segment。删除是在段内标记一个 bit,查询时过滤掉;更新是标记删除旧文档加写入新文档。频繁更新同一个文档会产生大量标记删除的旧版本,靠后台 merge 回收空间。merge 把多个小 segment 合并成大 segment,物理删除被标记删除的文档,降低段数量和磁盘占用。merge 是 IO 密集操作,大量小段频繁合并会挤占搜索资源。

倒排索引与分词

ES 通过倒排索引支持关键词检索。MySQL 的 B+ 树按「文档到字段值」组织,LIKE %词% 要逐行扫描字段做子串匹配。倒排索引先分词,再建立「词到文档列表」的映射。查询「手机」时可以定位到包含该词的文档列表,无需扫描所有文档。词表用 FST(finite state transducer)压缩存储并常驻内存;posting list 存储文档 ID,并使用帧编码(FOR)和位图压缩。通配符查询(*手机*)不能使用 FST 前缀定位,需要遍历词表,因此不适合用于左侧通配搜索。

分词由 analyzer 完成,分三段:char filter(处理字符,如去 HTML 标签)、tokenizer(切成词元)、token filter(小写化、停用词、词干提取)。standard analyzer 是默认分词器,对中文按单字切分:「手机壳」切成「手」「机」「壳」,搜「手机」能命中但也会命中「机器手」。中文搜索通常要换 ik 这类分词器,按词切分。

分词器配置错会导致搜不到或误命中。一个常见坑是字段用了 keyword 类型而非 textkeyword 不分词,整个值作为一个词元,精确匹配才能命中,做模糊搜索会查不到。排查方法是用 _analyze API 看分词结果:

POST /my_index/_analyze
{
  "analyzer": "ik_max_word",
  "text": "手机壳硅胶透明"
}

分词结果直接反映倒排索引里存了哪些词。搜不到时先确认分词,再确认查询用的是 match(会分词)还是 term(不分词,常用于精确匹配 keyword 字段)。

from + size 的深分页开销

后台管理页面常要分页遍历搜索结果。ES 的 from + size 分页在深度增大时变慢。原因是协调节点要把请求转发到每个分片,每个分片返回 from + size 条结果,协调节点从 分片数 × (from + size) 条里归并排序取 top size。翻到第 1000 页(from=9990, size=10)时,每个分片要返回 10000 条,协调节点归并几万条再丢掉大部分。ES 用 index.max_result_window(默认 10000)卡住 from+size 上限,超过直接报错。

深翻页有两种替代方案。scroll 把第一次查询的结果做成快照,返回一个 scroll_id,后续请求带 scroll_id 取下一页。快照在 keep-alive 时间内有效,期间新写入的文档不在快照里。scroll 适合全量导出这类一次性遍历,不适合实时分页:用户看到的是旧快照,且每个 scroll context 会占用服务端内存;较高并发或较长的 keep-alive 会导致 context 堆积。search_after 用上一页最后一条的排序值做游标,下一页从该值之后取。它无状态、不占服务端 context,但要求排序组合唯一且有值(通常补一个唯一字段做 tiebreaker;_id 默认没有开启 doc values,直接对它排序要走 fielddata,官方建议把 _id 复制到一个 keyword 字段再排),只能往后翻不能跳页。实时分页用 search_after,全量导出用 scroll;如果只是展示前几页,from+size 配合 max_result_window 即可。

高频更新的写入与 merge 开销

把 ES 当主存储用时,常遇到高频更新。ES 的 update 是 delete 加 index:标记旧文档删除,写入新文档。和 MySQL 的原地更新不同,每次更新都产生一个新版本和一个标记删除。后果有两个:segment 里堆积大量已删版本,磁盘和查询都要过滤无效数据;merge 压力增大,频繁更新产生的多版本文档靠 merge 回收。

并发更新靠乐观锁。ES 7.x 用 _seq_no_primary_term 做版本控制(_version 仍保留)。写入时带上读到的 _seq_no,如果期间被别人改过,_seq_no 不匹配,写入返回 409,业务层重试读改写。这和数据库的 CAS 一致,但代价不同:MySQL 是原地更新,ES 每次 update 都是 delete 加 index,写入放大和 merge 压力都更高。一个文档每秒被更新几十次的场景不适合由 ES 承担,应留在 MySQL。计数器、库存这类高频变更字段尤其不适合放 ES,常见的做法是热字段留 MySQL,ES 只存搜索需要的快照字段,查询时按需回库补全。

POST /orders/_doc/1?if_seq_no=5&if_primary_term=1
{ "title": "手机壳硅胶透明", "status": "shipped" }
# 若 _seq_no 已被别人改到 6,返回 409,业务层重试读改写

使用范围、同步与监控

  1. ES 适合的场景。 查询为主、文档写入后少更新、能接受秒级的一致性延迟。全文搜索、多维筛选、日志检索(ELK)是典型。写入通过 refresh 间隔聚合成 segment,查询通过倒排索引定位词项,因此适用于读多写少、查询条件复杂的场景。

  2. 误用为数据库的三种失效形态。 要求写入后立即可见(强一致读)受 refresh 间隔限制;深分页遍历受 from+size 的归并代价和 max_result_window 上限限制;高频更新会产生大量标记删除和 merge 压力。共同原因是 ES 的写入模型为搜索优化,不为事务性读写优化。

  3. DB 为主、ES 为辅、MQ 同步的组合。 实际架构里常见 MySQL 做主存储,写操作落库后发 MQ,消费方写 ES,查询走 ES。这套组合的同步延迟发生在 MySQL 提交成功但 ES 尚未写入,以及 MQ 消费失败重试期间;此时 ES 会读到旧数据。延迟大小由 MQ 投递延迟和重试策略决定,需要通过对账处理,和消息语义:按 at-least-once 设计业务代码的消息最终一致是同一类问题。应根据这段延迟决定哪些查询能走 ES,哪些必须回库。

  4. translog 持久化策略要明确。 request 模式不丢但吞吐低,async 模式吞吐高但崩溃丢最近几秒。搜索场景多数选 async,不能丢的场景选 request;上线前需确认默认值。

  5. 需监控的指标。 segment 数量和大小(_cat/segments,小段过多说明 refresh 过频或 merge 未及时完成)、refresh 耗时、merge 耗时与当前吞吐、translog 大小距 flush 阈值的比例、查询延迟分位。这些指标反映写入路径的运行情况:refresh 耗时增加说明 segment 生成有压力,merge 耗时增加说明标记删除可能堆积,查询延迟升高时可先检查段数量和 deleted docs 比例。

参考资料


728 字 · 56 段落
ximing

Follow onGitHub

相关文章