Elasticsearch数据迁移断点续传:PIT与幂等写入
1. 迁移任务真正难的从来不是“跑起来”而是“断了还能接上”做过 Elasticsearch 数据迁移的人大概都有这种体验脚本跑得好好的眼看进度条走到 87%主机负载抖动了一下或者中间某次 bulk 请求超时重试次数耗尽进程直接挂了。第二天重新跑一遍几百 GB 的数据从头再来前面熬的那十几个小时全白费。更难受的是有些索引是线上正在写的业务索引你没法“停写再迁”只能边写边迁跑两遍还会撞上重复写入和版本冲突。这就是为什么我一直把 Elasticsearch 数据迁移里真正有价值的部分压在一个词上断点续传。断点续传听起来像个附加功能实际上它会反向决定你整个迁移架构怎么设计。因为一旦你要求“任意时刻被 kill 都能接着跑”你就必须回答一串问题进度存在哪儿以什么粒度存重跑之后怎么保证不会重复写入重复写入了会不会覆盖掉新数据源端游标scroll 上下文或者 PIT过期了怎么办这些问题不想清楚脚本写得再漂亮也是脆的。我这篇就把自己踩过的坑和一个落地过的实现思路摊开讲涵盖方案选型、游标状态设计、幂等写入、参数推算、校验和切换。适合已经会写 bulk 脚本、准备做跨集群或跨索引搬迁的读者也适合刚接手一个“迁移做到一半跑挂了”的烂摊子的同学。先说清楚适用范围本文讨论的是同版本或跨小版本的索引级数据搬迁几百 GB 到几 TB 量级允许短时间的双写或读放大不涉及索引结构的复杂改造。如果你的数据量在几十 GB 以内窗口又足够其实老老实实_reindex跑一遍最省事别过度设计——这一点我在后面还会再强调一次。2. 先搞清楚失败模式再谈续传设计2.1 三种常见迁移形态崩法完全不一样我把实际遇到的迁移需求归成三类每一类的续传压力差别很大。第一类是离线冷迁源索引已经停写甚至源集群本来就是只读的历史库这时候续传只需要关心“读到哪儿了”写入端没有任何并发竞争是最简单的一种。第二类是在线热迁源索引还在被业务写你在迁的同时新数据还在进常见做法是先做一次全量、再补一次增量、最后切读。这种场景下续传不只是“接着读”还要保证重跑的那一段不会把已经迁过去的新数据覆盖成旧值。第三类是跨集群搬迁源和目的不在同一个网络域里延迟高、偶发超时、带宽受限失败概率天然比本地高一个量级续传几乎是必须项而不是加分项。这三类的共同点是进程会挂、网络会抖、目标集群会限流。区别在于挂掉之后“重跑一段”的代价。冷迁重跑一段代价几乎为零热迁重跑一段可能引入脏数据跨集群重跑一段可能要再等几小时的带宽。所以你的续传粒度设计本质上是在权衡“重跑代价”和“状态维护代价”。2.2 断点续传到底在“续”什么很多人第一次实现续传思路是记录一个数字比如“已迁移 120 万条”。这个思路的问题特别明显Elasticsearch 的读取顺序在没有显式排序时是不保证稳定的你没法从“第 120 万条”精确恢复除非你每次都从头扫再跳过前 120 万条——那还不如重跑。正确的思路是把“进度”换成位置。位置是一个可以从任意时刻重建查询条件的锚点比如某个分页游标、某条文档的排序值、某个时间戳区间。我在实际项目里用的是组合式游标分片编号 排序字段值 文档 _id。有这三样就能构造出一个确定性的search_after条件从任意断点精确续读而且不依赖任何会过期的服务端上下文。这是整个方案里我认为最关键的一个决定后面会展开讲为什么放弃 scroll 而选 PIT。2.3 幂等是续传的地基没有它续传就是添乱断点续传必须建立在幂等写入之上这句话我建议写进团队规范里。道理很简单你记录的断点永远可能比实际写入的进度“旧一点”。原因太多了——bulk 请求返回了部分失败你只重试了失败的那几条断点没更新或者进程在“写完但还没来得及落盘断点”的瞬间被 kill再或者你的写入是异步的目标端 refresh 还没完成你就记了进度。这些情况下重跑必然有一段数据被再写一次。如果写入是覆盖式同_id直接 index重写一次在冷迁场景下问题不大但在热迁场景下就危险了源端那条文档在你上次迁移之后又被改过你重跑用的是旧快照数据一覆盖把新值写回旧值业务侧就会看到“数据回退”。这类问题排查起来极痛苦因为它是间歇性的、跟时间窗口相关的。所以我的做法是全量阶段用create语义op_typecreate已经存在的直接跳过并记录增量补偿阶段才用覆盖并且带上版本控制。这个分工后面会细说。3. 方案选型scroll、_reindex 和 PIT search_after 怎么选3.1_reindex能省很多事但它的续传能力比你想的弱_reindex是官方提供的现成搬运工具支持slices并行、支持remote跨集群、支持脚本过滤配置起来几行就完事。很多人第一反应是用它然后遇到第一个问题任务中断之后不能从断点恢复只能整体重跑。虽然任务 API 里能查到进度但那个进度是给人看的不是给你续跑用的。另外_reindex的slicesauto是按源索引主分片数自动切切出来的子任务之间进度不对齐你就算想手工补也没法精确知道哪个 slice 差多少。它还受reindex.remote.whitelist限制跨集群的大批量搬运容易触发超时和重试目标端写入压力也不好精细控制requests_per_second是全局阈值调小了整体慢调大了目标集群告警。所以我的结论是数据量小、允许全量重跑、结构简单用_reindex完全没问题一旦数据量大到“重跑一次要一整天”就别在上面花时间了自己写脚本更划算。3.2 scroll 的致命伤上下文会过期而且占内存scroll 是传统的大结果集遍历方式scroll_id配合scroll5m用起来很顺手我第一次做迁移就是用它。但 scroll 有三个绕不过去的问题。第一个是上下文有效期keep_alive每次请求会重置但只要你有一次间隔超过这个时间比如进程崩了、或者目标端写入阻塞了十分钟服务端就把上下文清了再拿旧的scroll_id去请求直接报找不到 search context。这时候你手上的scroll_id就是一张废票进度全丢。第二个是内存占用scroll 上下文要在协调节点上保留每个分片的快照和状态深分页时内存曲线是往上走的几千万文档的索引能吃掉好几个 GB遇上内存紧张的集群很容易触发熔断。第三个是不可并行续传一个scroll_id只能顺序往下翻你没法把它拆成两半交给两个 worker。所以 scroll 适合“一次性快速导出、时间可控、允许失败重来”的场景不适合作为断点续传的载体。3.3 PIT search_after我目前的默认选择Elasticsearch 7.10 之后引入的 Point In TimePIT配合search_after是解决深分页的标准姿势也是我做断点续传的首选。它的好处在于查询条件里带的是一组排序值而不是一个服务端上下文句柄。你把最后一条文档的排序值存下来下次请求直接用它当search_after参数哪怕中间隔了一天只要 PIT 重新开一个查询依然能精确定位到那个位置继续往下读。代价是 PIT 本身也有keep_alive过期之后快照点会变理论上可能漏掉新写入的文档或者重复读到。但这个风险可以用幂等写入兜住而且比起 scroll 的“整段作废”PIT 过期只是“稍微重一点”量级完全不同。另外 PIT 支持按分片做_shard_doc排序这个 tiebreaker 在单次 PIT 内是唯一且递增的翻页效率很高。我一般这么组织按分片切成 N 个独立子任务每个子任务有自己的 PIT 和 search_after 游标各自记各自的 checkpoint。这样一个 worker 崩了不影响其他 worker续跑时只重跑那一个分片重跑量最小。3.4 三种方案对照方案续传友好度内存/资源开销并行能力适用场景_reindex低只能整体重跑中slices自动切分小数据量、允许重来scroll低scroll_id易失效高上下文常驻只能顺序翻页一次性导出、时间可控PIT search_after高游标可持久化低到中可按分片/区间切大数据量、要求断点续传选型这件事我的判断标准非常朴素能不能接受最坏情况下从头再来一遍。能接受_reindex不能接受PIT。4. 把进度变成可持久化的状态checkpoint 设计细节4.1 checkpoint 里该记哪些字段checkpoint 文件看起来不起眼但字段设计错了后面全是坑。我用的结构大概是这样的{ task_id: orders_20240115_full, source_index: orders_v3, target_index: orders_v4, state: running, created_at: 2024-01-15T02:11:03Z, updated_at: 2024-01-15T06:42:19Z, slices: { 0: { status: done, last_sort: [orders_v3, 0, 18422], docs_written: 1280332, failures: 0 }, 1: { status: running, pit_id: gAEl..., last_sort: [orders_v3, 1, 9271], docs_written: 902114, failures: 3, last_error: es_rejected_execution_exception at bulk line 271 } } }这里每一层都有用意。state是任务级状态机我定义了init / running / paused / completed / failed五个值续跑时先读状态再决定走哪个分支。slices按分片拆开每个分片独立记录last_sort这就是续跑锚点。docs_written用于后续对账别指望它能精确等于目标端文档数它的作用是“前后对比看有没有明显异常”。failures是失败计数超过阈值就自动把分片状态置为failed避免死循环重试把整个任务卡住。注意checkpoint 里不要存 bulk 请求的响应体全文或者原始文档内容只存游标和计数。我见过把整批数据写进 checkpoint 的实现文件涨到几十 GB每次落盘都卡成 PPT。4.2 原子写别让 kill 恰好落在写文件的瞬间checkpoint 写入必须是原子的否则进程恰好在写文件写到一半时被 kill你会得到一个截断的 JSON下次启动解析失败进度彻底丢。标准做法是写临时文件再重命名import json, os, tempfile def save_checkpoint(path, data): d os.path.dirname(os.path.abspath(path)) fd, tmp tempfile.mkstemp(dird, prefix.ckpt_) try: with os.fdopen(fd, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse) f.flush() os.fsync(f.fileno()) os.replace(tmp, path) except Exception: if os.path.exists(tmp): os.remove(tmp) raiseos.replace在同一文件系统内是原子操作这一步保证了“要么是旧版本要么是新版本不会出现半截文件”。os.fsync是为了防止数据只到了页缓存没落盘代价是每次写会慢一点但 checkpoint 的写入频率每批一次或每几秒一次完全承受得起。还有一个容易忽略的点多实例并存。如果你不小心起了两个进程跑同一个任务两个进程会互相覆盖 checkpoint数据也可能被双份写入。我加了一个简单的锁文件启动时尝试独占创建task.lock内容写进程 PID退出时删除发现已存在就拒绝启动。这个土办法不优雅但足够用。4.3 分片级游标把大任务拆成可独立恢复的小单元分片切分我是这么做的先GET /source_index/_settings拿主分片数然后对每个分片构造一个带preference_shard:N或者按_shard_doc排序的查询。实际用 PIT 时更简单——count一下每个分片的文档量用时间范围或者 ID 区间做二次切分把超大分片再切成几块。为什么要按分片切因为 ES 的分片是天然的数据分界线按分片推进的进度是可观测、可对齐的而且读取压力天然分散到不同节点上。如果某个分片特别大比如你用了自定义 routing 导致数据倾斜可以再按时间字段二次切片切成“分片 1 的 2023 年段”“分片 1 的 2024 年段”这样的子单元。切片数量不是越多越好。我一般按“总数据量 / 单 worker 每小时能处理量”反推控制在 8 到 32 个之间。太多了 checkpoint 文件膨胀、调度开销变大太少了并行度不够跑不满带宽。4.4 状态机与重试策略任务状态流转必须收口不然各种“跑到一半卡死”的问题会层出不穷。我用的规则是单个分片连续失败次数超过 5 次状态置failed并停止该分片但其他分片继续跑最后汇总出一个“部分完成”的结果如果失败原因是可恢复的429、超时、连接重置走指数退避从 1 秒开始翻倍上限 60 秒重试 8 次如果是不可恢复的映射冲突、字段类型不匹配、文档解析失败立刻标记这一批为失败明细写入错误日志文件跳过继续避免单条脏数据阻塞全局。错误明细文件也要设计好记录slice last_sort 文档 _id 错误类型这样事后补数据的时候可以精确定位不用再全量扫一遍。5. 动手实现一个能被打断、能续跑的主循环5.1 环境和依赖准备我用的是 Python 3.10 elasticsearch8.x 官方客户端目标端 ES 版本 8.x源端 7.17。跨大版本时字段类型兼容要单独测尤其是text/keyword混用和日期格式这块后面问题排查里会讲。pip install elasticsearch8.13.0客户端初始化时我习惯把超时和重试显式写清楚默认值在迁移场景里基本都不合适from elasticsearch import Elasticsearch src Elasticsearch( [http://src-node-1:9200, http://src-node-2:9200], request_timeout60, max_retries3, retry_on_timeoutTrue, ) dst Elasticsearch([http://dst-node-1:9200], request_timeout120, max_retries2)读端超时短一些没关系写端要长——bulk 写入大批量时 30 秒默认超时经常不够尤其是目标集群在做 merge 的时候。5.2 打开 PIT 与构造续读条件续读条件的构造是整个脚本的心脏。第一次启动时没有last_sort就正常从头发起有last_sort就直接塞进search_afterdef open_pit(es, index, keep_alive10m): return es.open_point_in_time(indexindex, keep_alivekeep_alive)[id] def build_query(pit_id, last_sortNone): body { size: 1000, pit: {id: pit_id, keep_alive: 10m}, sort: [{_shard_doc: asc}], query: {match_all: {}}, } if last_sort: body[search_after] last_sort return body_shard_doc是 PIT 场景下最省资源的排序键它只在 PIT 内有效重启 PIT 之后值会变。这意味着你不能用_shard_doc做跨进程的续读锚点它只适合单次会话内的翻页。这里有个关键取舍为了做到真正跨进程续读我把排序键换成了业务上稳定且唯一的组合比如[{updated_at: asc}, {_id: asc}]。这样last_sort落盘之后哪怕隔三天重启重建 PIT 用同样的search_after也能准确定位。代价是排序字段必须有索引updated_at一般是date类型没问题而且排序开销比_shard_doc略高。实测在千万级索引上这个开销完全可以接受。提示排序组合里的最后一个字段必须是全局唯一的否则search_after会在相同值之间漏数据或者重复。_id是最省事的选择虽然它的排序性能一般但正确性优先。5.3 主循环读一批、写一批、落一次进度主循环的结构我刻意写得笨一点笨的好处是好排查def run_slice(src, dst, task, slice_id, ckpt): st ckpt[slices].setdefault(slice_id, {status: running, last_sort: None}) if st[status] done: return pit_id open_pit(src, task[source_index]) try: while True: body build_query(pit_id, st[last_sort]) body[query] task[slice_query](slice_id) resp src.search(bodybody) hits resp[hits][hits] if not hits: st[status] done save_checkpoint(task[ckpt_path], ckpt) break actions [] for h in hits: actions.append({index: {_index: task[target_index], _id: h[_id]}}) actions.append(h[_source]) r dst.bulk(operationsactions, refreshFalse) ok, bad parse_bulk_result(r) st[docs_written] ok st[failures] bad st[last_sort] hits[-1][sort] st[pit_id] pit_id save_checkpoint(task[ckpt_path], ckpt) if bad: log_failures(r, slice_id) finally: try: src.close_point_in_time(body{id: pit_id}) except Exception: pass几个细节值得单独说。bulk的响应必须逐条解析不能只看 HTTP 200def parse_bulk_result(resp): ok bad 0 if not resp.get(errors): return len(resp[items]), 0 for item in resp[items]: op next(iter(item.values())) if op.get(status, 500) 300: bad 1 else: ok 1 return ok, baderrors: true只代表“有失败”具体哪一条失败要看items里的status和error。我曾经因为只判断了顶层errors字段导致一批里 3 条映射错误被静默吞掉最后对账少了文档才发现。refreshFalse是必须的迁移期间让目标索引按默认周期 refresh 就行每批都强刷会把写入性能打掉一个数量级。5.4 批次大小和并发度怎么算这两个参数没有万能值我给一套推算方法。先测目标集群单批写入耗时拿 1000 条真实文档打一次 bulk记录耗时 t。目标端目标吞吐假设为 X 条/秒那么单 worker 每批条数约等于X * t。举个实测数字单文档平均 2KB1000 条 bulk 耗时 0.8 秒目标端压测得到的稳定写入能力约 3000 条/秒那么单 worker 每秒能处理约 1250 条需要 3 个左右 worker 才能吃满。批量字节数控制在 5~15 MB 之间比较稳超过 15MB 容易在目标端触发http.max_content_length或者 GC 抖动。按 2KB 均值算8MB 约等于 4000 条。我一般先设 2000 条一批、4 并发跑十分钟看监控如果目标端 CPU 和写入队列都没打满再往上加。参数起始值调整依据风险size每批条数1000~2000单批字节数控制在 5~15MB太大触发 429 或 GC并发 worker 数4目标端 CPU 70%、写入队列不堆积太大导致目标端拒绝PITkeep_alive10m单批耗时 × 20 以上太小游标失效checkpoint 落盘间隔每批一次或每 5 秒一次太频繁 IO 压力大PIT 的keep_alive我按“单批耗时 × 20”来留余量。单批 2 秒就设 40 秒起步实际我会直接给 10 分钟宁可多占一点资源也不要让游标失效因为失效意味着这个分片要重新定位虽然不致命但很烦。5.5 怎么验证续传真的有效写完不能只看“跑通了”必须做一次人为中断验证。我的做法是跑起来之后在进度大约 30% 的时候直接kill -9主进程然后重启观察三件事。第一日志里提示的起始位置是不是接近中断点而不是从 0 开始。第二目标端文档总数是不是单调增长没有出现数量倒退或者暴涨暴涨说明重复写入了。第三随机抽 20 条文档对比源和目标_source是否一致。这个验证至少要跑两轮第二轮故意在 checkpoint 写入前后制造中断验证原子写是否生效。我踩过一次坑checkpoint 用普通json.dump直接覆盖写结果中断后文件是空的json.load抛异常脚本直接退出整个任务卡住没人管第二天才发现。6. 幂等写入与版本控制重跑不翻车的核心6.1 三种写入语义的代价对比写入方式重复写入行为适用阶段代价op_typecreate已存在则返回 409不覆盖全量首轮需要处理 409 并计入“已存在”普通index直接覆盖增量补迁可能用旧数据覆盖新数据updatedoc_as_upsert存在则合并字段字段级补迁更慢且不能删除字段全量阶段我强烈建议用create。原因很直接全量阶段的目标索引应该是空的或者只包含本次迁移的数据create遇到已存在直接跳过天然幂等。即使断点没对齐、某一段被重复读取第二次写全是 409不会有任何副作用。你要做的只是把 409 从“失败”里摘出来单独计数if op.get(status) 409: already_exists 1 continue6.2 热迁场景下的版本保护如果源索引还在写全量迁完之后必须补增量。补增量的思路是按时间字段拉一个窗口比如覆盖“全量任务开始前 10 分钟到现在”的范围重新扫一遍。这一遍必须用覆盖写否则新数据进不去。覆盖写的风险是“用旧快照覆盖新值”。解决办法是带外部版本号。如果源端文档有updated_at或者自增版本字段可以把它作为version传进去version_typeexternal这样目标端只会接受版本更高的写入旧的会被拒绝actions.append({ index: { _index: orders_v4, _id: h[_id], version: int(h[_source][version]), version_type: external, } })源端没有合适版本字段怎么办退而求其次用updated_at转成毫秒时间戳当版本号精度够用但要注意时钟回拨。再不行就只能在切换前做一次“停写窗口”把增量追平后立刻切别名用业务层的短暂不可写换来数据一致性。这个取舍没有标准答案取决于业务能容忍多长的写中断。6.3 删除怎么处理这块特别容易被忽略。全量加增量只能同步“存在”的文档源端删掉的文档在目标端会永远残留。我的处理办法是迁移启动时记一个时间点 T0全量迁完之后用一个带must_not exists的查询把源端当前的所有_id拉出来做差集比对——数据量太大时不现实。更实际的做法是如果业务删除本身就有标记软删除字段is_deleted迁移时把这个字段一起带过去切换后由业务逻辑过滤。如果必须硬删除就在切换前的停写窗口里用一次全量_id扫描做差异对比量级控制在千万以内是可行的。再大的量就老老实实接受“切换后跑一次清理脚本”的方案。7. 目标端调优与限流429 不是错误是信号7.1 写索引的三个开关迁移期间目标索引的配置应该临时调一下跑完再改回来PUT /orders_v4/_settings { index: { refresh_interval: 60s, number_of_replicas: 0, translog.durability: async, translog.flush_threshold_size: 1gb } }refresh_interval从 1s 调到 60s写入吞吐能提升一大截因为不用那么频繁地生成新 segment。number_of_replicas临时设 0省掉副本同步的开销跑完再改回 1 让它自己追平——这一步能省将近一半的写入压力。translog.durability改成async是拿“极小概率丢数据”换吞吐只在迁移这种可重跑的场景下用千万别在生产查询索引上长期这么配。跑完之后一定要记得改回来我见过迁移完成了副本数忘了恢复跑了三个月没副本一次节点故障直接丢数据。7.2 遇到 429 应该退避而不是重试es_rejected_execution_exception是目标端在告诉你“写入队列满了”。这时候正确的反应是降低发送速率而不是立刻重试。立刻重试只会让队列更满形成雪崩。我的策略是连续出现 3 次 429就把当前 worker 的批量条数减半同时 sleep 一个指数退避时间连续 10 次没有 429再慢慢把批量加回去。这个自适应逻辑看起来复杂实现起来其实就几十行def adaptive_bulk(dst, actions, worker): while True: try: r dst.bulk(operationsactions, refreshFalse) if r.get(errors) and has_rejection(r): worker.batch max(200, worker.batch // 2) time.sleep(worker.backoff) worker.backoff min(worker.backoff * 2, 30) continue worker.backoff 1 return r except Exception as e: worker.backoff min(worker.backoff * 2, 60) time.sleep(worker.backoff)背压这件事本质上是在“跑得快”和“跑得稳”之间调参。我个人的偏好是宁可多花两小时也不要让目标集群被打到业务查询超时。目标集群如果同时还承载线上查询你的迁移吞吐上限应该由业务侧 P99 延迟来决定而不是由硬件极限决定。7.3 读端也别忘控制读端的问题主要是 PIT 上下文占用和深分页开销。并发 worker 数不要超过源索引的主分片数否则会有一堆 worker 排队等同一个分片。批量size不要设太大1000~5000 之间比较合适超过 10000 会明显拖慢单次请求反而降低整体吞吐。另外记得在迁移期间避开源集群的定时任务窗口比如快照备份、force merge、大批量写入的业务批处理。撞在一起的话读取延迟会翻好几倍你的 PITkeep_alive可能就不够了。8. 迁完不是结束对账、校验和切换8.1 三层校验从粗到细校验千万别只比一个count。count相等但内容错位的情况太常见了尤其是你中途用过覆盖写。我一般分三层第一层是总量校验源和目标各_count一次差异超过 0.1% 就要查。注意源端如果还在写这个数字天然对不上必须在停写窗口做或者按固定时间范围比对。第二层是抽样内容校验从源端随机抽 500~1000 个_id用mget从两端各取一次逐字段比对_source。这一步能抓到映射错误、字段丢失、日期格式错乱这类问题。抽样要覆盖不同类型的文档别只抽最新的。第三层是聚合校验挑几个关键数值字段做sum/avg/min/max两端比对。聚合的好处是能覆盖全量数据而且成本比逐条比对低得多。差一点点可能是浮点精度差得多就说明有数据问题GET /orders_v4/_search { size: 0, aggs: { amount_sum: {sum: {field: amount}}, cnt_by_status: {terms: {field: status, size: 20}} } }三层都过我才敢切。有一次第二层抽出了三条日期少 8 小时的文档追下去发现源端存的是带时区的字符串目标端映射成了date但没指定format解析成了 UTC。这种问题不做抽样根本发现不了。8.2 别名切换与回滚预案切换这一步的关键是别动业务代码。正确姿势是用别名业务读的是orders_read迁移前指向orders_v3迁完之后原子地把别名从 v3 挪到 v4。POST /_aliases { actions: [ {remove: {index: orders_v3, alias: orders_read}}, {add: {index: orders_v4, alias: orders_read}} ] }这两步在同一个请求里是原子执行的业务侧不会看到“别名不存在”的空窗。切完先别急着删源索引留至少 24 小时观察期。回滚就是把上面两条反过来一分钟的事。我建议切换时间选在业务低谷而且切换前必须做一次“预演”——在测试环境用同样的别名结构跑一遍完整流程把切换脚本、回滚脚本、校验脚本全跑通。真到线上的时候你会发现脚本里每个硬编码的索引名都是潜在的炸弹。9. 常见问题速查与几个血泪坑9.1 典型报错对照表报错根因处理方式search_context_missing_exceptionPIT 或 scroll 超过keep_alive重建 PIT用last_sort续读es_rejected_execution_exception429目标端写入队列满减半批量 指数退避别硬重试mapper_parsing_exception字段类型/格式不兼容单独改映射把失败明细落盘补数据version_conflict_engine_exception外部版本号低于当前值确认是否正常热迁场景应属预期circuit_breaking_exception读端上下文占内存过大减小批量、降并发、缩短 PIT 时长http.max_content_length exceeded单批字节数太大按字节数而非条数控制批量illegal_argument_exception: Fielddata is disabled对text字段做聚合校验改用keyword子字段9.2 几个文档里不会写的坑坑一_source不一定是完整的。有些索引为了省空间会配_source: {excludes: [...]}你迁过去的数据天生就缺字段。迁移前一定检查GET /index/_mapping里的_source配置有 exclude 的要么放开要么接受字段丢失。坑二动态映射会悄悄改你的字段类型。目标索引如果没预先建好 mapping靠动态映射自动推断123这种字符串字段第一次可能被推成long后面来了非数字直接报错。我的做法是迁移前先用源端 mapping 在目标端建好索引dynamic设成strict让错误早点暴露。坑三refresh影响对账时机。_count是基于已 refresh 的段你刚写完马上_count会偏少。对账前明确refreshtrue或者_count?refreshtrue等几秒再比。坑四时间字段的时区。前面提过了date类型的format没指定时默认按 UTC 解析源端如果是yyyy-MM-dd HH:mm:ss这种本地时间字符串迁过去会整体偏移。坑五别在同一个任务里混用不同的排序键。有一次我中途改了排序字段续跑时search_after的参数对不上sort定义直接抛illegal_argument_exception而且报错信息很不直观。排序定义一旦确定就写进 checkpoint 里续跑时先校验一致性。9.3 我自己的几条经验沉淀第一条checkpoint 的写入频率和数据的正确性要解耦。不要把“记住进度”当成“保证正确”的手段正确性靠幂等和校验保证checkpoint 只负责省时间。这样即使 checkpoint 丢了重跑一次也不会产生脏数据。第二条先小规模跑通再放大。拿一个几万条的小索引把完整流程走一遍包括中断、续跑、校验、切换全部验证完再上量。上量的时候按 10%、50%、100% 三档加每档观察半小时。第三条日志要能回答“现在到哪儿了”。我要求日志里每次 checkpoint 落盘都打一行分片号、累计条数、当前last_sort、失败数、速率。这行日志在排查“为什么卡住不动”的时候价值极高。最后再提一个容易被低估的事迁移脚本最好做成可重复执行的无状态入口。启动时读 checkpoint 决定从哪儿开始没 checkpoint 就从头开始跑完了就退出。这样无论被 kill 多少次、被谁重启行为都是一致的。我见过太多脚本写成“必须按顺序手工执行五个命令”的形式换个人接手直接懵出问题根本没法快速恢复。把恢复逻辑写进代码而不是写进操作手册这是我做迁移项目这些年最值钱的一条体会。