☰
Elasticsearch nested 数组字段批量更新实战:update_by_query + painless 精准修改嵌套文档元素(附备份回滚 SOP)
2026/10/15 4:36:13 网站建设 项目流程

前言:一条"改不动"的数据修复需求

做资产/配置管理(CMDB)类系统的同学大概率遇到过这个数据模型:一个配置项(CI)在 Elasticsearch 里是一篇文档,它的几十个动态属性——IP、型号、负责人、服务实例 ID……——塞在一个数组字段里。某天排查发现,某个属性的值历史上批量填错了:"服务实例ID"这个属性存的是空串,而正确的值恰好等于每条数据自己的itemId。几千篇文档、每篇几十个数组元素,要在"只改命中条件的那个元素、其余元素原样保留"的前提下批量修复。

这不是POST /index/_update/{id}能轻松解决的活:它一次只改一篇文档,几千次调用既慢又难校验。正确姿势是update_by_query+ painless 脚本遍历数组。但嵌套字段(nested)的更新有几个容易翻车的点:查询必须用nested上下文、脚本改的是整个_source、动手前必须留后路(备份索引)。

本文完整复盘这次生产数据修复:从备份、定位、批量更新到校验回滚,全部 DSL 可复现。阅读前置知识是 ES 的基础查询语法,不熟悉的话可以先看这篇《Elasticsearch 日志检索 DSL 实战》。

一、数据模型:为什么属性数组必须用 nested

先看这篇文档的结构(节选自真实 mapping,字段已做等价改名):

PUT/asset_item_index{"mappings":{"dynamic_templates":[{"strings_as_keywords":{"match_mapping_type":"string","mapping":{"type":"keyword"}}}],"properties":{"itemId":{"type":"keyword"},"itemName":{"type":"keyword"},"modelId":{"type":"keyword"},"eiVersion":{"type":"long"},"updateTime":{"type":"date","format":"yyyy-MM-dd HH:mm:ss","ignore_malformed":true},"attrList":{"type":"nested","properties":{"attrId":{"type":"keyword"},"attrName":{"type":"keyword"},"attrValue":{"type":"keyword"},"attrRealValue":{"type":"keyword"},"attrType":{"type":"keyword"},"dataId":{"type":"keyword"},"unit":{"type":"keyword"},"updateTime":{"type":"date","format":"yyyy-MM-dd HH:mm:ss","ignore_malformed":true}}}}}}

一篇文档长这样:

{"itemId":"server-0107","itemName":"订单库主机","modelId":"host","attrList":[{"attrId":"attr-1001","attrName":"CPU","attrValue":"8C"},{"attrId":"attr-1002","attrName":"内存","attrValue":"32G"},{"attrId":"attr-1003","attrName":"服务实例ID","attrValue":""}]}

为什么attrList必须声明为nested,不能用默认的object?这是理解后面所有操作的前提。

如果映射类型是object,Lucene 建索引时会把数组"拍平":

attrList.attrName: [CPU, 内存, 服务实例ID] attrList.attrValue: [8C, 32G, ""]

数组元素之间的对应关系丢失了。此时查询"属性名是服务实例ID且属性值为空"的文档,条件会被拆成两个独立的 term 匹配再做交集——"属性名是 CPU 且值为 32G"这种张冠李戴的组合也会命中,数据修复的杀伤范围完全不可控。

声明为nested后,ES 会把每个数组元素索引成一篇独立的隐藏子文档,元素内部的字段关联被完整保留。查询时套上nested上下文,匹配的粒度就是"单个数组元素"。这次修复的精确性,全靠这一层映射保证。

顺带说一句:数据建模阶段就该想清楚这类"属性表"结构。如果走宽表 + 关系库 + 定时同步的路线,成本结构完全不同,这个取舍在《ClickHouse 可观测数据建模实战》里展开过,此处不赘。

二、修复 SOP 总览

动手改数据之前,先定流程。整个修复分四步,顺序不能乱:

① 备份:GET 原索引 mapping → PUT 备份索引(结构必须原样带回) → _reindex 全量拷贝 ② 定位:nested 查询精确圈出命中文档,人工核对数量与样例 ③ 更新:_update_by_query,query 与②一致,painless 遍历数组改元素 ④ 校验:重跑②的查询 + 抽样看值;出问题从备份索引回滚

"先查后改"和"先备份后动刀"是两条铁律:update_by_query没有确认对话框,脚本一旦跑偏就是全索引污染;而没有备份索引,回滚就只能靠原始数据源重灌。

三、第一步:备份索引——mapping 必须原样带回

3.1 取原索引 mapping

GET/asset_item_index/_mapping

3.2 用取回的 mapping 创建备份索引

把上一步返回的mappings节点原封不动粘进新索引的创建语句(注意去掉 ES 自动加的外层索引名包裹):

PUT/asset_item_index_bak_0620{"mappings":{...上一步返回的完整 mappings...}}

这里有个极易踩的坑:如果建备份索引时不带 mapping(或者手抖把attrList的"type": "nested"丢了),动态映射会接管,attrList被当成object拍平。_reindex照样成功、文档数一样、_source里看起来也一模一样——但隐藏子文档结构已经没了,备份索引无法执行任何 nested 查询。等你想回滚时才发现备份是"平"的,为时已晚。所以 mapping 必须显式带回,nested类型逐字段核对。

3.3 全量拷贝数据

POST_reindex?slices=auto{"source":{"index":"asset_item_index","size":10000},"dest":{"index":"asset_item_index_bak_0620"}}

slices=auto让 reindex 按分片数并行,size调大单批拉取量。数据量大时加wait_for_completion=false,请求立刻返回一个task id,用GET /_tasks/<task_id>轮询进度,避免 HTTP 连接超时误判失败。

四、第二步:nested 查询定位命中文档

更新前先跑一遍查询,确认要动的数据就是这些:

GET/asset_item_index/_search{"query":{"nested":{"path":"attrList","query":{"bool":{"must":[{"term":{"attrList.attrId":{"value":"attr-1003"}}},{"term":{"attrList.attrName":{"value":"服务实例ID"}}}]}}}}}

三个细节值得说:

  1. path指向数组字段,must里的字段路径都要带attrList.前缀。整个bool在"单元素"的粒度上求值——attrId和attrName必须在同一个数组元素里同时命中,这正是 object 类型给不了的精确性。
  2. attrId+attrName双条件是故意的冗余:属性 ID 是系统主键、全局唯一,属性名是人读的。双条件能挡住"同名属性在别的模型下也存在"这类脏数据干扰。生产上改数据,宁可多一个条件,不可少一道保险。
  3. 看 hits 别只看 total,抽样打开几篇命中文档,肉眼确认attrList里目标元素的现状(比如确认attrValue确实是空串),避免对数据的想象和现实不符。

五、第三步:update_by_query + painless 修改数组元素

确认无误后执行更新。query部分与上一步完全一致,脚本部分遍历数组改元素:

POST/asset_item_index/_update_by_query?conflicts=proceed{"query":{"nested":{"path":"attrList","query":{"bool":{"must":[{"term":{"attrList.attrId":{"value":"attr-1003"}}},{"term":{"attrList.attrName":{"value":"服务实例ID"}}}]}}}},"script":{"source":"""for(iteminctx._source.attrList){if(item['attrId']==params.attrId&&item['attrName']==params.attrName){item['attrValue']=item['itemId_of_doc'];}}""","lang":"painless","params":{"attrId":"attr-1003","attrName":"服务实例ID"}}}

等等——上面的脚本里有个故意留的破绽:item['itemId_of_doc']是不对的。itemId是文档级字段,不在数组元素里。正确写法是:

"script":{"source":"""for(iteminctx._source.attrList){if(item['attrId']==params.attrId&&item['attrName']==params.attrName){item['attrValue']=ctx._source.itemId;item['attrRealValue']=ctx._source.itemId;}}""","lang":"painless","params":{"attrId":"attr-1003","attrName":"服务实例ID"}}

逐行拆解这个脚本:

  • ctx._source是当前命中文档的完整原文。update_by_query的机制是:query 命中一篇文档 → 取出_source→ 交给脚本加工 → 整篇重新索引回去。所以脚本里既能拿到文档级字段ctx._source.itemId,也能拿到数组ctx._source.attrList。
  • for (item in ...)逐元素遍历,条件双判(attrId+attrName)与查询条件一一对应,只改命中的元素,其余元素原样随_source写回。
  • 一次回填两个字段:attrValue(展示值)和attrRealValue(真实值)是这套模型的两个值域字段,只改一个会造成两处展示不一致——改数据前先想清楚所有冗余字段。
  • 匹配值放params,不要拼进脚本文本。参数化一是防注入,二是脚本文本会被缓存编译,写死值会让每次不同参数的更新都触发一次脚本编译。

执行返回里重点看updated(实际更新篇数)——它应该等于第二步查询的hits.total。数量对不上就停下来查原因,不要继续。

六、第四步:校验与回滚

6.1 校验

更新完成后重跑第四节的 nested 查询,逐篇抽样确认attrList里目标元素的attrValue已回填为itemId。查询语义上命中的还是这批文档(元素仍在、属性名没变),要看的是值对没对:

GET/asset_item_index/_search{"query":{"nested":{"path":"attrList","query":{"bool":{"must":[{"term":{"attrList.attrId":{"value":"attr-1003"}}},{"term":{"attrList.attrName":{"value":"服务实例ID"}}}]}}}},"_source":["itemId","attrList.attrValue","attrList.attrName"]}

_source过滤只回显关心的字段,肉眼比对itemId与回填值是否一致。

6.2 回滚(如翻车)

脚本写错、误伤无辜元素时,从备份索引整体回灌:

POST_reindex?slices=auto{"source":{"index":"asset_item_index_bak_0620"},"dest":{"index":"asset_item_index"}}

reindex 是"只写不删"——如果误操作新增了不该有的文档,回灌后它们仍会留在目标索引,必要时先按条件 delete_by_query 清掉再回灌。业务侧有空闲窗口的话,更稳的做法是别名切换:读写都走别名,修复期间把别名指向备份索引验证,验证通过再切回,全程秒级可逆。别名与索引生命周期管理的更多玩法见《Elasticsearch 日增 30TB 日志架构实战》。

确认修复成功、观察一两个业务周期无异常后,删掉备份索引释放磁盘:

DELETE/asset_item_index_bak_0620

七、踩坑点清单

这次修复前后踩过/绕过的坑,按出现频率排序:

  1. update_by_query是整篇重索引,不是元素级补丁。命中文档的_source有多大、nested 子文档有多少,写入放大就有多大。资产文档动辄上百个属性元素,几千篇命中文档一口气跑,注意观察集群写入压力,必要时在请求里加?requests_per_second=500限速。
  2. 版本冲突。修复期间如果业务还在写入同一批文档,update_by_query 会因_version对不上而中止。加?conflicts=proceed让它跳过冲突文档继续跑,结束后统计version_conflicts数量,决定是否补跑。
  3. nested 数量上限。index.mapping.nested_fields(默认 50)限制 nested 字段个数,index.mapping.nested_objects(默认 10000)限制单篇文档里 nested 子文档总数。这篇资产模型一篇文档几十个元素没问题,但如果你的数组奔着上千个元素去(比如把全量告警塞进一个文档),写入会直接报错,那就该换数据模型而不是调参数。
  4. 备份索引忘了带 nested mapping,见 3.2 节,备份了个"平"的索引等于没备份。
  5. 脚本里引用文档级字段的路径。ctx._source.itemId是文档根上的字段;写成item.itemId(遍历变量)就是 undefined,painless 对 null/undefined 的容忍会让赋值静默失败——校验时才发现值没回填,白跑一轮。
  6. ignore_malformed的日期字段。mapping 里updateTime带了这个配置,脏日期不会让写入失败,但会变成不可查询的值。数据修复时顺手用exists+range探测一下异常日期的存量,别让它们沉底。
  7. 改完必须主动 refresh 才能立刻查到。update_by_query结束后默认等下一次自动 refresh(默认 1s),校验脚本如果跑得比刷新快,会看到"好像没改"的假象。着急就在校验查询前执行POST /asset_item_index/_refresh。

八、写在最后

回头看,这次"一个属性值批量回填"的小需求,串起来的是 nested 映射原理、_reindex备份、nested 查询、update_by_query+ painless 脚本四块知识。核心心法就三句话:改前必备份、先查后改、改后必校验。三条都做到了,生产数据修复就不是什么惊心动魄的事。

你的团队在 ES 里改嵌套数组数据时,是走脚本批量、还是干脆重灌?有没有踩过 nested 子文档数量上限的坑?评论区聊聊。


相关阅读:
《Elasticsearch 日志检索 DSL 实战:时间范围、字段去重、分钟级统计》
《Elasticsearch 日增 30TB 日志架构实战:可变索引粒度、分片规划与检索调优》
《ClickHouse 可观测数据建模实战:指标、日志、链路、告警四类数据的 MergeTree 表设计》

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询