ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Elasticsearch 7.6 移除 type 实战:数据迁移与 reindex 全攻略

Elasticsearch 7.6 移除 type 实战:数据迁移与 reindex 全攻略

1. 先搞明白:type到底是个什么东西,为什么被“杀”掉了

1.1 一个老掉牙的概念,坑了多少人

如果你维护过早期的 Elasticsearch 版本,一定对PUT /blog/article/1这种索引路径不陌生。blog是索引名,article是 type,1是文档 ID。当年的设计是想让一个索引blog里同时装下article、comment、author这几类数据,通过 type 把不同类型的文档分开,把索引类比成数据库、type 类比成数据表,概念上很好理解。

但实际操作过就知道,这个类比从根上就有问题。ES 的索引和数据库表不一样,一个索引在物理上对应一个 Lucene 目录,分片数、副本数、刷新频率都是按整个索引配置的。type 只是索引内部一个逻辑分组,所有 type 的文档仍然活在同一个分片、同一个 Lucene 索引里,底层没有做物理隔离。这就导致几个很尴尬的问题:一个索引里某个 type 写爆了,整个索引的写入、查询、缓存都会受影响;不同的 type 用了同名字段但类型不一致,mapping 直接冲突;映射扁平化之后,本来属于不同 type 的字段全都堆在一个宽表里,字段数量爆炸,查询时还有额外的过滤成本。

我记得有一次帮某公司排查一个搜索慢的问题,那个索引里有七八个 type,最大的 type 几千万文档,最小的才几千条,但所有的数据都混在同一组分片上。搜索小 type 时,还是要扫描整个索引的所有分片,白白扛了不该扛的压力。这种事情出现几次之后,你就会理解为什么官方后来对 type 越来越不留情面。

1.2 官方为什么要移除 type

ES 官方对 type 的态度是逐步收紧的:5.x 开始强调单 type 最佳实践,6.x 阶段一个索引已经只允许保留一个 type,7.0 正式宣布移除 type,8.x 则彻底清理了相关参数。整个演进背后,最核心的考量是“物理存储和逻辑分组的错位”。

Lucene 层的字段都是扁平化的,ES 虽然允许不同 type 有不同的 mapping,但底层还是同一个倒排索引。这意味着article里的title字段和comment里的title字段如果类型不一样,必须想办法处理冲突,最后往往只能共用一个字段定义,设计者连分开约束字段的自由都没有。再往下看,同一分片内所有 type 的文档共享倒排文件、共享 doc_values、共享缓存,所谓“隔离”只是查询时用_type做了一个额外的过滤条件,成本一点不少,收益却几乎没有。

官方在 7.x 的破坏性变更里写得很直白:type 的隔离实现不彻底,与其保留一个会误导人的概念,不如引导大家用更现代的方式来组织数据。一个索引对应一类文档,数据结构不同就应该拆索引,如果需要保留类型维度,就用普通字段存一个doc_type之类的标识。这个思路后来变成了社区的主流做法,我自己在 7.6 项目里也完全是按这个路子走的。

1.3 7.6 里的“type 遗影”和兼容层

很多人望文生义,觉得 7.6 里 type 已经完全消失了,实际没那么绝对。7.6 版本正处在“兼容期”:新建索引的时候,API 默认不允许再传 type 层级,但如果你在处理 6.x 时代创建的旧索引,日常读写路径还是可以用传统的GET /index/type/id方式访问,响应的_source里也会保留_type字段。这是因为跨大版本升级需要给用户留一个缓冲,直接删干净会让很多人没法平滑升上来。

这个阶段里最关键的参数就是include_type_name。创建一个新索引时,如果请求里还带着 type 层级,必须显式加?include_type_name=true才能被接受;不加就默认当成_doc处理。查询、mapping 展示也是同理,想要看老索引里的 type 信息,得在 API 里带上这个参数,否则响应里看不到 type 节点。

换句话说,7.6 的现状是:新代码一律走_doc或不带 type,老数据还能喘一口气。千万别把“能跑”当成“不需要改”,等哪天集群升级到 8.x,这些兼容接口一旦消失,旧客户端和旧脚本会立刻报错。所以赶在 7.6 做改造,时机刚刚好。

2. 迁移前先盘家底:现状梳理与迁移路径选型

2.1 第一步:盘点存量索引的 type 使用情况

正式动手之前,一定要把集群里的 type 使用情况摸清楚,这一步越细后面越省事。我一般先拉一份全索引清单,确认哪些索引还在用多 type,哪些其实早就只剩一个_doc了。用 Kibana 的 Dev Tools 也行,直接敲 REST 命令更直观:

# 查看所有索引及文档量 GET _cat/indices?v&s=index # 查看完整 mapping,重点看 type 层级 GET _all/_mapping?include_type_name=true

返回的 mapping 里你会看到类似这样的结构:

{ "blog": { "mappings": { "article": { "properties": { "title": { "type": "text" } } }, "comment": { "properties": { "content": { "type": "text" } } } } } }

看到这种结构,说明blog索引里有两个 type。这时候我习惯顺手统计一下每个 type 的文档量,可以用_count逐个查,也可以按_type字段做一次聚合:

GET blog/_search { "size": 0, "aggs": { "by_type": { "terms": { "field": "_type" } } } }

这一步能直接看出每个 type 的体量差距,方便后续评估重放数据和切流的时间成本。别只盯着 mapping 看,还得把读写入口的 URL 模式、依赖这个索引的业务方清单一起列出来,后面改代码时才不会漏。

2.2 三条典型迁移路径对比

按我自己做过的几次迁移,主流方案大概是三条路,各有各的适用场景。

路径 A:保留原索引名,把多 type 合并成一个索引,数据里增加一个自定义字段(比如doc_type)来标识原类型。好处是业务侧改动最小,之前按blog索引查的数据还可以继续查;代价是原本不同类型的字段必须合并到一套 mapping 里,同名类型冲突要处理,而且索引内部仍然混合存放多种数据,本质上只是“治标”,把 type 从元数据挪到了普通字段。

路径 B:按 type 把数据拆分到独立索引,比如blog_article、blog_comment,再通过别名或路由规则统一入口。好处是数据彻底隔离,mapping 各自独立,之后扩容、调参、冷热分离都能按索引来,是最符合官方思路的长期方案;代价是读写代码都要改,原本一条语句跨 type 聚合的场景要做两次查询再合并。

路径 C:什么也不改,升到 7.6 后继续用老 url 访问旧索引。这个我只建议当过渡,不适合当长期方案。客户端里的 type 参数迟早要清掉,而且 8.x 以后这些接口基本不可用,最终还是要还债。

把这三条路的代价放到一张表里看更直观:

对比维度路径A 合并索引+自定义字段路径B 按type拆分索引路径C 继续用旧type
数据层改动需要reindex并把type写入字段需要reindex并拆到多个索引不需要
mapping复杂度高,字段冲突要协调低,各索引独立保持现状
查询代价单索引查询灵活,但字段冗余多跨类型查询要合并结果兼容查询,但后续不可用
应用代码改动中等较大最小
长期维护一般好差

2.3 我的选型建议

选哪条路,核心要看原索引里的 type 之间到底像不像。

如果两个 type 的字段高度重合,比如一个LogInfo和LogError,本质上都是日志数据,结构和查询模式都差不多,那路径 A 就很合适。自定义一个level字段区分来源,查询时按字段过滤,比分开维护两个索引反而舒服。

如果 type 之间八竿子打不着,比如一个user和一个order,字段差异极大,还各有各的写入高峰,那路径 B 是唯一正确的答案。硬要合成一个索引只会互相拖累,到时候大小分片配比都不知道该偏向谁。

还有一个容易忽略的场景:如果你的索引其实已经只剩一个 type,而且名字恰好就是_doc,那恭喜你,根本不需要任何数据改造,只需要把客户端里的 type 参数清掉就行。很多团队折腾半天,最后发现项目里早就不用多 type 了,只是 Java 客户端里那段老旧的构建代码还在传 type,属于自己有库存没盘点清楚。

3. 数据迁移实操:reindex、mapping改造与别名切换

3.1 mapping 合并改造范例

先看一个实际会遇到的旧 mapping 长什么样。某内容项目原来在content索引下放了article和comment两个 type,article有title和author,comment有content和author,关键问题在于author这个字段在两个 type 里类型不一致:

  • article.author在 6.x 里是keyword,因为要存作者 ID
  • comment.author是text,因为存的是作者昵称

这种情况下如果直接合并 mapping,新索引里author只能保留一个类型,另一个必须改名。我的处理方式是给comment的昵称字段改名为author_name,article的 ID 字段保留为author。看起来多了一步改名动作,其实是在替业务方梳理数据模型,反而把以前同名不同义的历史债还清了。

新索引的 mapping 大致是这样的:

PUT content_v1 { "mappings": { "properties": { "doc_type": { "type": "keyword" }, "title": { "type": "text", "fields": { "keyword": { "type": "keyword" } } }, "author": { "type": "keyword" }, "author_name": { "type": "text" }, "content": { "type": "text" } } } }

注意我把原来的 type 层级直接去掉了,mapping 顶层就是properties。这就是 7.6 的标准写法,不需要再嵌套一层 type 名。

3.2 用 reindex 把 type 写进自定义字段

mapping 定好之后,正式开始搬数据。最顺手的工具还是_reindex,它能把一个索引的数据搬到另一个索引,并通过 painless 脚本在搬的过程中修改文档内容。我们这步要做的事很简单:把原来_type的值写到_source.doc_type里。

POST _reindex { "source": { "index": "content" }, "dest": { "index": "content_v1" }, "script": { "lang": "painless", "source": "ctx._source.doc_type = ctx._type;" } }

有两点经验要提前说:

一是不要贪省事直接全库一把梭。文档量大、分片多的情况下,建议加上slices参数,比如"slices": 5,让 reindex 并行跑,速度能快好几倍,但对集群的压力也会同步放大,建议在业务低峰期操作。

二是 reindex 过程中如果源索引还有写入,reindex 快照到的数据可能不完整,最后要用_doc号做增量补录,或者干脆先把写入切到新索引再搬历史数据。我通常的做法是先停掉对旧索引的写入(或者让业务方先把写入切到新索引),再用 reindex 搬存量,数据的一致性最有保障。

3.3 用别名和索引模板平滑过渡

数据搬完并不代表改造结束,还要解决“老代码怎么无缝切到新索引”的问题。最常用的手段是索引别名。

假设原来业务方读的是content索引,我们新索引叫content_v1,那就先建一个别名:

POST _aliases { "actions": [ { "add": { "index": "content_v1", "alias": "content" } }, { "remove_index": { "index": "content" } } ] }

这里content_v1成为真实索引,content变成别名,业务方的查询语句不用改一行,数据已经从老的content切换到了新索引。如果因为合规或回滚要求老索引不能立刻删,可以把remove_index这步省掉,先只加别名,确认稳定后再清理。

同时建议顺手把索引模板配置好。尤其是索引命名里带日期的场景,比如logs-2024.01.01,提前写好一个全局模板:

PUT _template/logs_template { "index_patterns": ["logs-*"], "settings": { "number_of_shards": 3, "number_of_replicas": 1 }, "mappings": { "properties": { "doc_type": { "type": "keyword" } } } }

以后每天新建的logs-*索引都会自动带上doc_type字段,不用每次手动补 mapping。这个细节很不起眼,但少了它,等第二天日志索引一生成,你才会发现新数据根本没写进自定义类型字段,又是一轮排查。

4. 应用层改造:REST API 与客户端代码避坑记录

4.1 REST API 的变化点清单

数据层迁移做完,应用层的代码如果不跟着改,照样会有不少请求报错。REST API 层面的变化点主要集中在 URL 写法上,项目里凡是写死 type 的地方都要替换。

老写法普遍是这样的:

# 写入 PUT /content/article/1 # 查询 GET /content/article/_search # 删除 DELETE /content/article/1

7.6 之后的标准写法换成:

# 写入 PUT /content/_doc/1 # 查询 GET /content/_search # 删除 DELETE /content/_doc/1

如果是升级前创建的老索引,访问时可以带旧 type 名(比如content/article/1),但我强烈建议新代码一律用_doc。因为一旦后续再升级到 8.x,老索引的老路径也会被移除,到时候还得再改一轮。7.6 这个阶段就是“一次性改到位”的最后窗口,别给自己留二次整改的作业。

4.2 客户端代码改动的几个典型位置

以 Java High Level REST Client 7.6 为例。旧代码里如果构建了IndexRequest,常见写法是把 type 作为参数传进去;新版本里IndexRequest已经完全不接受 type 参数,直接把文档按_doc处理。

// 旧写法 IndexRequest request = new IndexRequest("content", "article", "1"); request.source(map, XContentType.JSON); // 新写法 IndexRequest request = new IndexRequest("content"); request.id("1"); request.source(map, XContentType.JSON);

Python 的elasticsearch-py也是同样逻辑,index方法的doc_type参数在 7.x 后已经弃用,不要继续传。Go、Ruby、PHP 的客户端类似,建议项目里全局搜一下article、comment这些 type 名,把硬编码全部替换成_doc或直接去掉。

搜索请求也一样。旧代码如果是SearchRequest("content").types("article"),新版直接删掉.types(...);如果原来习惯用TypeQueryBuilder,这一类构建器也已经移除,统一改用普通的QueryBuilder。这些 API 的删减都有兼容期,但 IDE 里会直接标红,编译期就能发现,反而是好事。

4.3 API 报错与排查方向

改造期间最常见的报错有几种,提前认识了能省不少排查时间。

第一种是创建索引时报Type is completely unsupported,说明你的请求里带了 type 层级但没开启兼容参数。如果是新索引,把 URL 里的 type 段删掉即可;特殊情况下确实需要兼容老 API,才能在创建时显式加?include_type_name=true,但我只建议在临时脚本里这么干。

第二种是_reindex时报Type [article] is not supported,说明你尝试往一个新索引里写入带 type 的数据。这种情况多半是 reindex 的dest索引之前用老格式创建过,或者有一些老旧脚本文档 ID 直接拼了 type。要把目标索引先删掉,用新格式重新创建,或者把写入请求里的 type 去掉。

第三种是升级后原来的多 type 查询报No mapping found for [article],这类问题通常发生在老索引还留着旧 type,但客户端已经按新格式访问。仔细看日志里用的 URL 是旧路径还是新路径,两边对齐就好。

5. 验证与回滚:上线前必做的检查清单

5.1 数据完整性校验

reindex 跑完别急着切流量,先做一轮数据完整性校验。最基础的是对比文档数:

GET content/_count GET content_v1/_count

两个数量对不上就说明 reindex 漏了数据,优先排查是不是源索引在搬的过程中还有写入。除了总数,还要按自定义字段分组抽查,确认doc_type被正确写入:

GET content_v1/_search { "size": 0, "aggs": { "by_doc_type": { "terms": { "field": "doc_type" } } } }

如果聚合结果里出现了空值或未知值,说明有一部分旧文档没有执行脚本,常见的坑是 painless 脚本在部分文档上抛了异常,reindex 默认不是原子操作,异常文档会被略过。真遇到这种情况,针对空值文档单独做一次轻量 reindex 就行。

5.2 功能回归与性能观察

数据没问题,接着做功能回归。我习惯列一张清单,覆盖最常见的读写场景:单文档查询、按 ID 删除、全文检索、聚合统计、分页翻页。每一条都用新旧地址各跑一遍,确认结果一致。特别要关注带doc_type过滤的查询是否生效,字段类型有没有选对,否则写了等于白写。

性能方面,重点看 reindex 期间和切流后的对比。用_cat/indices?v&s=index观察store.size、docs.count的变化,再用_cat/thread_pool?v看write和search线程池的占用情况。如果新索引查询明显慢,优先检查 mapping,看是不是keyword被误配成了text,或者该加keyword子字段的地方漏了。

慢日志也要开启一下:

PUT content_v1/_settings { "index.search.slowlog.threshold.query.info": "1s", "index.indexing.slowlog.threshold.index.info": "1s" }

有了慢日志,上线后就算用户不反馈,服务端也能自己发现潜在的性能劣化。

5.3 留好回滚策略

改造这种操作,永远要留退路。我的习惯是:老索引在迁移期间一律不物理删除,只改别名,保留至少一周。如果上线后发现业务方某个功能对旧 type 路径还有硬依赖,只需要把别名指回老索引就能快速回滚,不需要动数据。

如果老索引数据量太大、磁盘放不下,那就用快照备份:

PUT _snapshot/backup_repo/content_backup { "indices": "content", "ignore_unavailable": true, "include_global_state": false }

这样就算真的误删了数据,也有完整的恢复途径。回滚这种事,宁可多准备一点用不上,也别在出问题时抓瞎。

6. 常见问题速查:迁移路上的那些“神坑”

把我在多个项目里踩过的坑整理成一张速查表,按频率排序,遇到问题直接对号入座:

问题现象触发场景根本原因解决办法
创建索引报 type unsupported新索引写入路径带类型请求里带了 type 层级去掉 type,改用_doc
reindex 报 type not supported迁移脚本带 type 段目标索引或请求仍然旧格式重建目标索引,请求去掉 type
查询结果里_type丢失访问 7.6 的新索引新索引默认无自定义 type 元数据改用自定义doc_type字段
mapping 里多个 type 字段类型冲突合并索引同名不同义的历史脏数据统一类型或改名
同一索引两个 type 数据互相“污染”搜索小 type 很慢物理存储未隔离拆索引,走路径 B
聚合doc_type出现空值reindex 后校验painless 脚本异常跳过单独补录异常文档
客户端编译报types()方法不存在Java client 升级7.x 移除了 types 方法删除 or 用_doc兜底
老代码访问老索引路径 404升级后访问旧 type URL兼容层对新建索引失效改用_doc路径
更新文档时_type被覆盖reindex 结束后用户更新写入脚本未处理写入时通过 doc_type 字段区分
别名切换后未生效仍读到旧数据缓存或别名删除遗漏确认 alias 和索引名称唯一映射

这张表里最容易被忽视的是第一条和第六条。很多团队在数据迁移时很上心,但建新索引的代码里还留着旧时代的拼写习惯,结果第一波日志就疯狂报错;另一类则是 reindex 脚本里没写doc_type,看起来搬完了,实际等于白搬,查询时根本无法区分数据类型。

另外提一句,迁移完不要马上删掉旧索引就以为万事大吉。建议在新索引运行至少一个完整业务周期(比如 7 天或一个报表周期)后再清理,期间通过doc_type定期做新旧数据对比,等确认完全没有差异再动删除按钮。毕竟数据迁移这种事,回滚容易,数据重建难。

最后再分享一个实操中的小技巧:迁移期间,可以把include_type_name参数临时加到排查请求里,比如GET content/_mapping?include_type_name=true,它能看到老索引的 mapping 残留,结合_cat/indices就能判断哪些索引已经改造完成、哪些还在走旧模式。等全部业务切完,再全局搜一遍代码仓库里的type硬编码,确保没有漏网之鱼。我自己在几轮迁移里都是这套流程走下来,基本能保证切换过程平滑、数据不丢、应用无感。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进