很多人第一次用 Elasticsearch(下文简称 ES)会有个困惑:明明 index 接口返回 200 成功了,紧接着 search 却查不到这条数据;过了一秒再查,又有了。这不是 bug,而是 ES「近实时(Near Real-Time, NRT)」搜索模型的直接体现。要理解它,得先把 ES 的存储结构从上到下拆开:索引(index)→ 分片(shard)→ Lucene 段(segment)。

场景:一个索引是怎么被切开的

假设你有一个 orders 索引,每天写入上千万条订单。单台机器的磁盘和内存撑不住,于是 ES 把这个索引水平切成若干 主分片(primary shard)。每个主分片本质上就是一个完整、独立的 Lucene 实例——它有自己的倒排索引、自己的段文件,能独立完成索引和检索。

文档落到哪个分片,由路由公式决定:

1
shard_num = hash(_routing) % number_of_primary_shards

默认 _routing 就是文档 _id。注意公式里的分母是主分片数量,这就是为什么主分片数一旦创建就不能改——改了会让所有老文档的路由结果全部错位。需要扩容只能 _split(成倍拆)或 _shrink(成倍并)这类重建操作,或者干脆 reindex 到新索引。

副本数(number_of_replicas)则可以随时动态调整:

1
2
3
4
5
6
7
8
9
10
11
PUT /orders
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1
}
}

# 副本数随时可改,主分片数不行
PUT /orders/_settings
{ "number_of_replicas": 2 }

机制:主分片与副本分片的写入链路

副本分片(replica shard) 是主分片的完整拷贝,承担两个职责:高可用(主分片所在节点挂了,副本顶上被提升为主)和读吞吐(查询可以打到主或任一副本)。

写入时,请求先到主分片,主分片写成功后并行转发给所有副本分片,等到满足条件后才向客户端返回成功。这里有个常被忽略的概念:in-sync allocation IDs。ES 维护一份「当前与主分片保持同步的分片集合」。主分片挂掉后,集群只会从这个 in-sync 集合里选新主,绝不会把一个落后的副本提上来当主,从而避免静默丢数据。

一个关键约束是:主分片和它的副本永远不会被分配到同一个节点。否则节点一挂,主副双亡,高可用就成了空话。所以如果你只有 1 个节点却设了 number_of_replicas: 1,集群状态会是 yellow——副本无处安放,处于 unassigned 状态。这是新手最常见的「为什么我的集群一直黄的」。

机制:近实时搜索的三段式落盘

现在回到开头的问题。一条文档从写入到可被搜索,要经过 buffer、refresh、flush 三个阶段,配合一个写前日志 translog。

1
2
3
4
5
写入 → ① in-memory buffer + 追加 translog(已持久化到磁盘)
↓ refresh(默认 1s)
② 生成新的 Lucene segment(在 OS file cache 中即可搜索)
↓ flush(translog 满或定时)
③ segment fsync 落盘,清空 translog
  • refresh:把内存 buffer 里的文档生成一个新的 segment。注意——一旦生成 segment,文档就可被搜索了,哪怕这个 segment 此刻还在操作系统的 page cache、没真正 fsync 到磁盘。refresh 默认每秒一次,这正是「近实时」里那个 1 秒延迟的来源,也是为什么 ES 是「近」实时而非「实时」。

  • translog(事务日志):refresh 让文档可搜索,但 segment 还没落盘,宕机会丢。所以每条写入在进 buffer 的同时会追加到 translog,并默认在每次请求结束时 fsync。崩溃重启时回放 translog 即可恢复那些还没 flush 的数据。这是 ES 的持久性(durability)保证。

  • flush:触发 Lucene commit,把内存中的 segment 真正 fsync 到磁盘,然后清空已经安全的 translog。

为什么要把「可搜索」和「持久化」拆成两件事?因为 fsync 很贵。如果每条文档都立刻 fsync 一个 segment,吞吐会崩。ES 的设计是:用便宜的 translog fsync 保证不丢数据,用便宜的 refresh 保证近实时可见,把昂贵的 segment fsync 攒批延后做。

类比一下:refresh 像是把刚写好的稿子贴到公告栏(别人能看到了,但底稿还在桌上随时可能被风吹走);translog 是你同步抄了一份到保险柜的流水账;flush 才是把定稿正式归档进档案室。

段合并:搜索快但写入有代价

每次 refresh 都产生一个小 segment,segment 是**不可变(immutable)**的。删除其实是打 .del 标记,更新等于「标删旧文档 + 写入新文档」。久了就会累积大量小 segment 和被标删的垃圾文档,拖慢查询(每个 segment 都要被查一遍)。

ES 后台用 TieredMergePolicy 持续把小 segment 合并成大 segment,顺手物理清除被标删的文档。合并是 I/O 密集型操作,大批量导入时往往是吞吐瓶颈。

工程权衡与踩坑

分片数不是越多越好。 每个分片都是一个独立 Lucene 实例,有固定的内存(段元数据、FST 词典等)和文件句柄开销。几万个小分片会拖垮 master 的集群状态管理,这就是著名的「over-sharding」。经验法则是单个分片大小控制在数十 GB 量级,而不是按分片数量拍脑袋。

批量导入时关掉 refresh。 持续 refresh 会制造海量小 segment,反过来引发疯狂的段合并。导数据前把 refresh_interval 设为 -1,副本数设为 0,导完再恢复,吞吐能有数量级提升:

1
2
3
4
5
PUT /orders/_settings
{ "index.refresh_interval": "-1", "index.number_of_replicas": 0 }
# ... 批量导入 ...
PUT /orders/_settings
{ "index.refresh_interval": "1s", "index.number_of_replicas": 1 }

别用强制 refresh 救场。 写完立刻查不到,有人会用 ?refresh=true 强制刷新。低频可以,高频写入路径上这样做等于把攒批优势全丢了,每次都生成微型 segment,性能灾难。需要写后立即读的场景,优先用 GET /index/_doc/{id} 按主键取(走 translog/GET 实时通道),而不是依赖 search。

replica 提升读吞吐但放大写成本。 副本越多读越快、容灾越强,但每次写入都要同步到所有副本,写放大随副本数线性增长,磁盘占用也成倍。读多写少调高副本,写密集场景克制。

小结

  • 索引被切成不可变的主分片(独立 Lucene 实例),靠 hash(_id) % primary_count 路由,所以主分片数固定、副本数可变。
  • 写入靠 translog 保证持久性,靠 refresh(默认 1s 生成可搜索 segment)保证近实时可见,靠 flush 攒批做昂贵的 fsync——三者解耦是整个性能模型的核心。
  • 主副本不同节点保证高可用,in-sync 集合保证选主不丢数据;单节点设副本会导致集群 yellow。
  • 批量导入关 refresh、降副本;写后立即读用 GET 而非 search,是最常见的两个性能正解。