背景:我们到底想解决什么问题

业务里有一类字段很「烦人」:PV、下载量、在线状态、实时计数——写得多、变得快、又要参与排序和聚合,但几乎不需要全文检索。

用 Elasticsearch 的标准路径处理这类数据,会遇到几个硬约束:

  1. Lucene segment 不可变:文档一旦 flush 进 segment,字段值就「写死了」,改一个数往往意味着 reindex 或走 _update 重写文档。
  2. _update / reindex 成本高:高频计数场景下,每次 +1 都触发文档级更新,IO 和 merge 压力都很大。
  3. _source 与检索字段分离的需求:展示层可能仍读 _source,但 sort / agg / filter 希望读「最新计数」,且不希望为了改一个 long 去动整段 JSON。

我们的目标是:在 ES 8.17 上为数值型字段提供 docvalues 级的原地局部更新——写入索引后,通过 REST API 按 _id 或 Query 批量改值;查询、排序、聚合读 mmap 里的最新值,不修改 _source

这就是 es_update 插件的由来。但在真正上线、加副本时,我们又撞上了 ES 内核的另一堵墙:Store 层的 checksum 校验。最终方案分成两层:

  • 插件层(es_update):Lucene Codec + mmap + Transport 复制,解决「怎么改值、怎么查」。
  • Server 补丁层(elasticsearch-server-patch):改 ES 源码,解决「改完值后 recovery 为什么把分片标成 corrupt」。

整体架构

elasticsearch-server-patch

Elasticsearch 内核

es_update 插件

业务层

patch jar

索引 / 全量写入

高频计数变更

dense_field Mapper

DenseField912Codec

旁路 mmap 文件
.dfd / .dfdc / .dfm

_dense_update REST

Transport 复制到 replica

Lucene segment 标准文件

Store 文件校验与 recovery

SkipChecksumFiles
跳过 dense 旁路 checksum


技术实现(一):es_update 插件

1. 字段模型:docvalues-only 的 dense_field

我们没有再造一个「可索引的 numeric」,而是定义 dense_field

  • 仅 docvalues,无倒排(不支持 index: true)。
  • 支持 bool / byte / short / int / longvalue_type
  • 索引写入时在 Lucene FieldInfo 上打标:dense_field=true、字节宽度、默认值等。

这样 sort、terms agg、range 过滤仍走 ES 熟悉的 docvalues 路径,但不会为高频变更字段维护 posting list。

2. 存储:旁路 fix-width 文件 + mmap

每个 segment 为 dense 字段生成旁路文件:

扩展名 含义
.dfd dense 数值数据区(按 docId × 字段宽度排列)
.dfm 字段元数据(偏移、类型、每值字节数)

flush 时由 DenseDocValuesConsumer 写入;查询时 DenseDocValuesProducer 通过 DenseMappedBuffer mmap 打开,返回可更新的 ReadOrUpdateDenseNumericDocValues

更新路径

REST _dense_update
  → TransportDenseUpdateAction(广播各 shard)
    → DenseUpdateShardService
      → acquireSearcher → Lucene Query 命中 docId
        → collector → updateValue(docId, value) 写 mmap
Fix-width 的设计思路

Lucene 标准 NumericDocValues 在 segment 里是压缩、只读的:flush 后布局由 Codec 决定,没有「按 docId 随机改 8 字节」的公开 API。我们要的是 数组式存储,因此把 dense 数据单独放在旁路文件里,用 定长(fix-width) 编码。

核心公式(单个 segment、单个 dense 字段):

byteOffset(docId) = valuesOffset + docId × bytesPerValue
segment 数据区大小 = maxDoc × bytesPerValue

其中 bytesPerValue 由 mapping 的 value_type 决定(如 long → 8 字节),valuesOffset 写在 .dfm 里。这样 docId 与物理地址之间是 O(1) 线性映射,无需维护 _id → offset 的二级索引,也无需在更新时解析变长结构。

.dfd 数据区 fix-width 数组

offset = off + docId×8

.dfm 元数据

valuesOffset

bytesPerValue = 8

value_type = long

docId=0 → 8 bytes

docId=1 → 8 bytes

docId=n → 8 bytes

_dense_update(docId)

为什么选 fix-width,而不是变长或稀疏结构?

布局 随机更新 与 Lucene docId 对齐 实现复杂度
变长(类似 _source 需 realloc / 重写后续字节
稀疏 Hash / 外部 KV 可以 docId 变更(merge)需 remap
fix-width 稠密数组 O(1) 定位 与 segment maxDoc 一一对应

dense 字段的业务前提是:segment 内绝大多数 doc 都有该字段(或至少有默认值 0)。这与「稠密数组」假设一致——空 slot 用 mapping 里的 default 在 flush 时填满,之后更新只改对应 slot。

索引期写入时,DenseDocValuesConsumer 按 docId 顺序扫描 NumericDocValues,以 16MiB 为块流式写入 .dfd,避免整块 byte[] OOM;.dfm 记录每个字段的 valuesOffsetvaluesLength,并校验 valuesLength == maxDoc × bytesPerValue,从格式上 锁死 fix-width 约束

配合 mmap 实现 docvalues-only 的优势

fix-width 解决的是 「改哪里」;mmap 解决的是 「怎么改才够快、并能被查询读到」。两者叠加,才构成完整的 docvalues-only 原地更新能力。

1. 更新成本:从「文档级」降到「几个字节」

标准 ES 更新路径:解析 _source → 合并 JSON → 写 translog → eventually 新 segment。
我们的路径:offset = valuesOffset + docId × bytesPerValueMappedByteBuffer.putLong(offset, value)
一次 _dense_update 本质是 常数时间、常数 IO 的随机写,与文档里其它字段数量、_source 大小无关。

2. 读路径统一:sort / agg / filter 仍走 docvalues

dense_field ** deliberately 不做倒排**。查询侧 DenseMappedFieldType 基于 NumericDocValues 提供 term / range / sort。
Producer 打开 mmap 后,longValue() 直接从 fix-width 槽位读——与 Lucene docvalues 调用模型一致,ES 聚合、排序代码几乎不用改。

也就是说:

  • :旁路 fix-width + mmap 随机写(扩展能力)。
  • :仍是 NumericDocValues,对上层透明(兼容能力)。

3. mmap 与 fix-width 的天然匹配

变长数据 mmap 后很难安全原地改长度;fix-width 槽位大小固定,每个 slot 的 mmap 写边界清晰
DenseMappedBuffer 按 1GiB 分片映射(对齐 Lucene MMapDirectory 策略),支持大 segment;对单槽位的 putLong / compareAndAddinc 增量)加细粒度写锁,避免并发更新同一 chunk 时撕裂。

4. 与 docvalues-only 互补:省倒排、省 _source 联动

能力 标准 numeric + index dense_field + fix-width mmap
倒排 有 posting,更新语义等于 reindex 无 posting,不为计数维护 terms
_source 常一起变 刻意不变,检索面与展示面解耦
存储 Codec 压缩 docvalues 旁路 uncompressed 数组,换更新速度
适用 低变更、要高 QPS 检索 高变更、要高 QPS sort/agg

docvalues-only 去掉了倒排维护;fix-width + mmap 去掉了 docvalues 的「只读压缩」限制——两者目标一致:把资源集中在「列式读、单点写」,而不是通用文档引擎的全量 rewrite。

5. 代价(设计里必须接受的)

  • 空间maxDoc × bytesPerValue × 字段数,删除的 doc 仍占 slot(与 Lucene 软删 docId 一致)。
  • docId 绑定:更新 API 内部 _id → Query → docId,merge 后 docId 变但 segment 内仍自洽。
  • 无倒排:纯 term 过滤走 docvalues 慢路径,适合 bool 后置 filter,不适合作为主检索键。
# 单 segment 容量粗算(1 个 long dense 字段)
.dfd 大小 ≈ maxDoc × 8 字节
例:3000 万 doc ≈ 240 MB / segment / 字段

3. 为什么要把 dense 文件放在 compound 外面

Lucene 默认会把 segment 小文件打进 .cfe/.cfs compound。我们实现了 DenseCompoundFormat:compound 阶段把 .dfd/.dfm 复制到 compound 外部.dfdc/.dfmc),保证 mmap 始终落在真实文件路径上。

否则:compound 内部是只读视图,无法做原地 mmap 写;这也是「必须在 Codec 层动手」而不是纯 REST 能搞定的原因。

4. 副本一致性:Transport 复制

_dense_updateTransportReplicationAction:primary 改 mmap 后,同一请求下发到 replica 执行相同更新。这与 ES 标准写入复制模型一致,不依赖 reindex。

5. 运维兜底:_dense_sync_footer

mmap 改了 payload 后,Lucene footer 里的 CRC 可能与字节内容不一致。插件提供:

POST /{index}/_dense_sync_footer?primary_only=true

对每个已提交 segment 的 .dfdc 执行 force + refreshFooter,与 ES VerifyingIndexOutput 使用的 CRC 算法对齐。

在打上 server 补丁之前,加副本前必须跑这一步;否则就会踩下文说的 recovery 坑。


技术实现(二):elasticsearch-server-patch

事故现象

一次典型线上路径是:

  1. 索引已有数据,执行过多次 _dense_update(mmap payload 已变,footer CRC 未刷新)。
  2. 没有_dense_sync_footer,直接 number_of_replicas: 1
  3. Peer recovery 拷贝 .dfdc 时,VerifyingIndexOutput.verify() 报错:
verification failed : expected=12l7j20 actual=1u0gvr9
resource=name [_2_...dfdc], length [122], checksum [...]
  1. 分片进入 ALLOCATION_FAILED / no_valid_shard_copy,Primary 也可能被标 corrupted_*

根因不是「数据丢了」,而是 ES Store 认为文件 checksum 与 commit metadata 不一致——对我们这种 故意原地改文件 的场景,标准校验语义与需求冲突。

补丁做了什么

elasticsearch-server-patch 工程中,fork ES 8.17.0 的 Store 相关类,新增 SkipChecksumFiles,对以下扩展名 跳过 checksum,仅保留 length 校验

  • .dfd / .dfdc / .dfm / .dfmc
修改类 作用
SkipChecksumFiles 识别 dense 旁路文件
VerifyingIndexOutput recovery 文件后不再比 CRC
Store.checkIntegrity 分片打开 / integrity 检查跳过 CRC
Store.MetadataSnapshot.checksumFromLuceneFile 构建 metadata 时不读 stale footer
StoreFileMetadata.isSame recovery diff 对 skip 文件只比 name + length
Store.VerifyingIndexInput snapshot restore 路径同样 skip

部署方式(轻量):mvn compile 后把 class 注入 lib/elasticsearch-8.17.0.jar,无需 clone 整个 ES 仓库。

cd elasticsearch-server-patch
mvn test compile
ES_HOME=/path/to/elasticsearch-8.17.0 ./scripts/patch-es-jar.sh
# 重启 ES → 再装 es_update 插件

为什么这样设计:几个关键取舍

1. 为什么用 mmap 原地写,而不是每次 _update 文档

fix-width 让 单次更新的地址可算;mmap 让 这次更新不必经过 Lucene IndexWriter。二者缺一不可:只有 fix-width 没有 mmap,仍要 rewrite 文件;只有 mmap 没有 fix-width,无法 O(1) 定位 docId 对应槽位。

方案 优点 缺点
标准 _update / reindex 不改 ES/Lucene 文档级 rewrite,高频计数扛不住
外部 Redis + 查询时合并 实现简单 sort/agg 难走 ES 原生路径
fix-width + mmap 改 docvalues 单次更新 O(1),sort/agg 原生 要自定义 Codec,要处理 recovery/checksum

我们的核心诉求是 检索面在 ES 内、更新面足够轻。fix-width 数组 + mmap + docvalues-only,是在 Lucene 语义下能拿到的接近最优解。

2. 为什么不动 _source

_source 是 JSON 快照,改它等于文档级更新。业务上常出现:

  • 检索 / 排序 / 聚合:要最新 PV(读 docvalues / mmap)。
  • GET /_doc 展示:可以容忍 _source 里是旧值,或由业务层读 Redis。

把 dense 字段从 _source 语义里「切开」,能避免为了计数把整文档重写一遍。

3. 为什么旁路文件仍带 Lucene header/footer(插件侧)

旁路文件若完全无格式,ES 既不知道文件边界,也难以在 recovery 时拷贝。我们选择在 插件侧 写标准 Lucene footer,并在更新后 refreshFooter,与 ES metadata 对齐。

这能缓解问题,但 无法从根上消除「忘记 sync 就加副本」的人为失误,也无法改变「payload 已变、metadata 里仍是旧 checksum」时 ES 的校验行为——后者必须动 Store。

4. 为什么 checksum 问题不能只靠插件解决

这是本文最想强调的一点。

Elasticsearch 插件能扩展什么?

  • MapperPlugin:新字段类型。
  • EnginePlugin:换 Codec、Engine 工厂。
  • ActionPlugin:新 REST / Transport Action。
  • Codec SPI:自定义 Lucene 存储。

插件扩展不了什么?

  • org.elasticsearch.index.store.Store 在 recovery 时如何校验拷贝的字节。
  • VerifyingIndexOutput.verify() 是否抛出 CorruptIndexException
  • MetadataSnapshot 如何从 footer 计算 StoreFileMetadata.checksum
  • 分片分配时 checkIntegrity 的硬逻辑。

这些类在 server 模块里,没有 SPI、没有 Setting 开关、没有「按文件名后缀跳过校验」的钩子。Recovery 路径是:

RecoverySourceHandler
  → MultiFileWriter.innerWriteFileChunk
    → Store.createVerifyingOutput
      → VerifyingIndexOutput.verify()   // 插件无法 intercept

我们在插件里已经做了:

  • 更新后 refreshFooterDenseFileFormat)。
  • 运维 API _dense_sync_footer
  • Transport 复制到 replica。

但只要 有一次 在 footer 过期状态下触发 peer recovery,ES 内核仍会判 corrupt——这不是插件多写一个 REST 能修的,因为 校验发生在插件 classpath 之外的 server 代码里

5. 为什么选「改源码 + 注入 jar」,而不是别的方式

我们也评估过其它路:

思路 结论
加副本前强制 _dense_sync_footer 有效,但依赖运维 SOP,漏一步就炸
旁路文件不进 commit / 无 footer 要自建 recovery 灌数据,复杂度更高
用 REST「修复 checksum」事后补救 已 unassigned 的分片往往调不到 API
改 Store,skip dense 旁路 checksum 从根上允许 mmap 与 recovery 共存

最终采用 server_jar_only:只 fork 4 个 Java 文件,编译后 jar uf 进官方 ES 安装包。代价是 ES 小版本升级要 rebase 补丁;收益是 加副本不再绑死 sync_footer 时序,与 mmap 更新模型一致。

trade-off 必须说清楚:skip checksum 后,ES 不再帮我们发现 dense 旁路文件的 bit rot;_dense_sync_footer 退化为可选对账手段,而不是 recovery 的前置门禁。


推荐部署顺序

# 1. 打 server 补丁
cd elasticsearch-server-patch && mvn test compile
ES_HOME=/path/to/elasticsearch-8.17.0 ./scripts/patch-es-jar.sh

# 2. 重启 ES,安装插件
elasticsearch-plugin install file:///path/to/es_update-1.0-SNAPSHOT.zip

# 3. 建索引时指定 codec
PUT my_index
{
  "settings": {
    "index.codec": "DenseField912Codec"
  },
  "mappings": {
    "properties": {
      "pv": { "type": "dense_field", "value_type": "long" }
    }
  }
}

# 4. 原地更新
POST my_index/_dense_update/12
{ "inc": { "pv": 1 } }

# 5. 加副本(补丁后可不强制 sync_footer)
PUT my_index/_settings
{ "index": { "number_of_replicas": 1 } }

若分片已经 no_valid_shard_copy:先 replicas: 0 → 删 corrupted_* → 部署补丁并重启 → 再拉高副本。


小结

层次 职责 能否只用插件
es_update dense_fieldfix-width 旁路存储、mmap 更新、docvalues 读路径、Transport 复制
elasticsearch-server-patch recovery / Store 对 dense 旁路文件 skip checksum 否,必须改 server 源码

一句话:fix-width + mmap 解决了「怎么 O(1) 改 docvalues」;改源码解决了「ES 文件校验模型与原地改文件不兼容」。两层叠加,才是在 ES 8.17 上可运维、可加副本的完整方案。


仓库代码

  • 插件:es_update/dense_field + _dense_update + _dense_sync_footeres_update
  • Server 补丁:../elasticsearch-server-patch/(ES 8.17.0 Store checksum skip)elasticsearch-server-patch

基于 Elasticsearch 8.17.0 / Lucene 9.12.0 / JDK 17。ES 升级请同步 rebase 补丁并全量回归 recovery 场景。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐