☰
Elasticsearch数据迁移断点续传实战:游标、PIT与一致性核对
2026/11/4 14:50:19 网站建设 项目流程

凌晨两点四十,迁移脚本跑到 63% 的时候断了。目标集群的批量写入队列被打满,客户端抛出一串 429,进程退出。屏幕上那个进度停在原地,后面还有一亿多条文档没过去。这不是我第一次遇到这种场面,但每次遇到都还是想骂人,因为前 63% 白跑了,日志里连个能接着干的位置都没留下。后来我把这套流程重写了一遍,核心就一件事:让 Elasticsearch 数据迁移具备断点续传能力。断点续传不是一个花哨的功能点,它决定了你的迁移是“干一次就成”还是“断了三次还没干完”。这篇内容面向正在做 ES 集群搬迁、索引重建、跨环境同步的同行,不管你是写脚本硬怼的单兵,还是在维护一套内部迁移平台,下面这些设计取舍、参数计算和踩坑记录应该都能直接拿去用。

1. 迁移中断这件事,到底卡在哪

先说清楚问题边界。ES 的迁移大体分两类:一类是整集群搬迁,用快照仓库做 snapshot/restore,这条路有天然的可恢复性,不需要你自己操心断点;另一类是索引级的、字段级的、跨版本或者跨环境的数据搬运,通常靠_reindex、logstash 输入输出插件,或者自己写客户端脚本读写。断点续传这个需求,几乎全部集中在这第二类上。原因很简单:_reindex在服务端跑,任务有_tasks接口可以查进度,但它一旦被取消或者节点重启,任务就直接没了,恢复不了;而自研脚本如果不做状态持久化,进程一死就彻底归零。

1.1 三种典型的中断现场

我在实际项目里见到的中断,按频率排下来基本是三种。

第一种是目标端反压。源端读得飞快,目标端写不进去,bulk 请求批量返回 429,客户端如果没有退避逻辑,要么疯狂重试把目标集群压得更死,要么直接抛异常退出。这种情况在目标集群同时还在服务线上查询时特别常见,因为写入的 segment 合并和查询的缓存争抢会互相拖累。

第二种是源端游标超时。用 scroll 读数据的同学应该都熟悉那个search_context_missing_exception,scroll 上下文默认只保留一分钟,一旦你在两次 scroll 之间处理时间过长,或者客户端网络抖动导致请求没打过去,上下文就没了,后面所有请求全部报错。这个错不是业务错,是机制错,但你一样得从头再来。

第三种是进程层面的意外:机器被回收、OOM Killer 干掉了 Python 进程、容器被调度走了、有人手抖kill -9。这种中断最麻烦,因为它通常不会给你留任何优雅退出的机会。

1.2 断点续传真正要“续”的三样东西

很多人对断点续传的理解停在“记录一下处理到第几条”,这个理解是不够的。你要续的其实是三样东西,缺一样都续不上。

第一样是位置,也就是“下一次从哪里开始读”。它不是行号,不是偏移量,ES 里没有全局自增 ID 这个概念。可行的是排序值,你在上一次读取的最后一条文档上的sort数组,下次把它塞进search_after里。

第二样是上下文,也就是“这次读取的一致性快照是什么”。源索引在被读的过程中如果还在持续写入,那你这次的迁移视图是晃动的。这片区域需要靠point in time或者scroll来固定。

第三样是进度账本,也就是“已经成功写过去多少条、失败了多少条、哪些失败了”。这个账本不只是给人看的进度条,它还是最后一致性核对的依据。我见过不止一次迁移“跑完了”但目标端文档数少了几万的情况,原因就是失败批次被静默吞掉了。

提示:这三样东西里,位置必须持久化到磁盘或外部存储,上下文可以重建,账本要能支撑重放。很多人只做了位置,上下文一过期就崩,账本干脆没做,最后对账全靠_count硬碰,问题出在哪都不知道。

1.3 自研脚本、官方工具与商业方案的取舍

在动手之前值得先做一次选型。官方_reindex的优点是省事,支持script做字段改写,支持source.remote跨集群;缺点是它是服务端任务,做不了应用层的精确断点,而且大批量时对源端和目标端都是压力集中在一处。logstash 的优点是配置化、有sincedb记录位置;缺点是它的 sincedb 是针对输入文件的,用在 ES 输入上对 scroll 的恢复支持有限,中文社区里关于 logstash ES 输入中断后续跑的吐槽一直不少。

自研脚本的代价是要自己处理游标、幂等、反压、断点,好处是每一层都可控,能按分片并行,能做精细的失败重放。我的判断标准是:数据量在千万级以下、允许全量重跑、迁移窗口宽裕,用_reindex就够;数据量上亿或者窗口只有几个小时,值得写脚本。

选型定下来之后,还有一个隐含决策:这次迁移要不要保证“不停机”。如果源索引在迁移期间还在写入,那你需要一套双写或者增量补齐的方案,断点续传只负责全量主体的部分;如果能把源索引设为只读,事情简单一个数量级。我个人的偏好是能只读就只读,用别名切换来扛停机窗口,实在不行再加增量兜底。

2. 断点状态的设计与落地

断点这件事,设计阶段想清楚,后面能省掉大把时间。核心问题是两个:一条断点记录里应该放什么,以及这条记录放在哪儿。

2.1 一条断点记录应该长什么样

我习惯用 JSON 记录,字段不多,但每个都有明确用途。下面这个结构是经过几轮迭代之后稳定下来的:

{ "task_id": "orders_v3_to_v4_20240612", "source_index": "orders_v3", "target_index": "orders_v4", "shard": 3, "last_sort": [1728432012345, 88213], "done_count": 4820331, "fail_count": 112, "last_error": "mapper_parsing_exception: field [price] ...", "target_refresh_off": true, "started_at": "2024-06-12T01:10:22Z", "updated_at": "2024-06-12T03:41:07Z" }

last_sort是核心,它是上一次成功批次里最后一条文档的排序值数组,长度取决于你用的排序字段个数。done_count和fail_count分开放,因为这两个数在恢复时用途完全不同:前者用于进度估算和最终对账,后者用于决定是否需要重放。task_id用于防止不同任务之间串号,我吃过这个亏——同一个目录下跑了两个索引的迁移,断点文件同名覆盖,结果一个任务接着另一个任务的游标跑,数据错乱得一塌糊涂。

target_refresh_off这个字段看起来不起眼,实际很关键。迁移期间我们会把目标索引的refresh_interval设成-1、副本数设成 0 来提速,如果进程在中途崩了,这个状态就留在那儿了。下次恢复时你需要知道“目标索引当前还处于优化状态”,等迁移彻底结束后再统一恢复,而不是启动时盲目再设一遍。

2.2 存储在哪儿:文件、KV 还是索引本身

断点存储介质的选择,本质是在“依赖少”和“可靠”之间权衡。

本地文件是最轻的方案,一个 JSON 或者 SQLite 就够,好处是零外部依赖、写入快,坏处是绑机器——换一台机器跑就找不到断点了,容器环境下这个文件还得挂持久卷。我现在的做法是本地文件 + 对象存储定期同步,本地保证频繁写入的低延迟,每隔若干批次往对象存储推一份,换机器时从对象存储拉回来。

Redis 或类似的 KV 存储适合多任务并发的内部平台,能天然支持租约和心跳,缺点是多一个要运维的组件。如果你的迁移平台已经有一套配置中心或者任务调度系统,直接复用它的存储最省事。

把断点直接写进 ES 索引本身,这个方案我试过,不太推荐。原因是断点写入的频率和数据写入频率是一个量级,等于自己给自己制造了双倍的写压力,而且断点索引一旦因为集群问题不可用,你连退路都没有。

注意:不管你选哪种介质,写入必须是原子的。JSON 文件写到一半进程被杀,留下半个花括号,下次启动直接解析失败。用“写临时文件 +fsync+os.replace重命名”这套组合,能保证要么是旧的完整内容,要么是新的完整内容。

2.3 原子写与租约:别让两个进程抢同一个任务

原子写解决的是“文件写坏”的问题,租约解决的是“两个进程撞车”的问题。后者在实际运维里发生概率不低——运维同学看任务卡住了,重新起一个进程,结果原来那个其实没死,只是卡在某个慢查询上,于是两个进程同时往目标索引写同一批数据,再加各自的断点互相覆盖,场面会很难看。

租约的实现可以很轻:断点记录里加一个owner字段和lease_expire_at时间戳,进程启动时先读,如果lease_expire_at还在未来且owner不是自己,就拒绝启动;启动成功就立刻把自己的标识写进去并续期,运行过程中每个批次刷一次。这个机制不严谨但够用,能把绝大多数误操作挡住。

还要配套一个优雅退出的处理。给进程注册SIGTERM处理函数,收到信号后不再开新批次,等当前批次写完、断点落盘之后再退出。我现在的脚本里,SIGTERM的处理逻辑是设置一个全局STOP_FLAG,主循环每个批次开头检查一次。这个改动只有十几行,但能避免大量“最后一批写了一半”的脏数据。

3. 游标机制选型:scroll、search_after 与 PIT

断点续传的骨架是“能精确地从一个位置继续读”,这就绕不开 ES 的三种翻页机制。它们各自的能力差异,直接决定了你的续传方案能做到多可靠。

3.1 from + size 为什么不能用来迁移

from + size是最直观的写法,但它在迁移场景里是错的,而且错得很贵。原因在协调节点的内存:一次from=100000, size=1000的查询,每个分片都要取出前 101000 条文档,在内存里排序后丢弃前 100000 条。ES 默认的index.max_result_window是 10000,超过直接报错;就算你把这个值调大,深分页的代价也会随页码线性上涨,迁移到后半程时查询耗时会从几百毫秒涨到几秒甚至几十秒,同时协调节点 GC 压力剧增。

更致命的是,from + size没有快照语义。两次翻页之间如果有文档被删除,后面的数据会整体前移,你就会漏读;如果有新文档写入,你会重复读。对于一次需要跑几个小时的迁移,这个漂移是致命的。

3.2 scroll 的坑:scroll_id 不是长期饭票

scroll 是 ES 老牌的批量导出机制,它会在源端维持一个搜索上下文,每次带上scroll_id就能继续取下一批。理论上它能提供一致快照,看起来很适合迁移。

问题出在scroll_id的生命周期上。它的存活时间由scroll=1m这类参数控制,每次请求会重置计时器,但上限受集群配置约束,而且一旦超时,上下文被清理,那个scroll_id就永久失效了,不可恢复。你把这个字符串存到断点文件里,第二天恢复时拿它去请求,得到的只会是search_context_missing_exception。我早期就是踩了这个坑,以为存了scroll_id就等于存了断点,结果恢复时发现根本用不了。

所以用 scroll 做断点,正确的姿势是不存scroll_id,只存游标位置。但 scroll 本身不暴露“当前读到第几条”的排序值,它内部是有状态的。折中方案是:在 scroll 的查询里显式加上sort,把每批最后一条的sort值存下来;恢复时重新开一个 scroll,然后用search_after从上次的排序值往后取。这样一来,scroll 从“状态载体”降级成“遍历工具”,真正的断点锚点是排序值。

3.3 search_after 加 PIT 的正确姿势

search_after是 ES 5 之后引入的无状态翻页方式,它不维护服务端上下文,每次请求都告诉你“从这个排序值之后开始取”。无状态意味着它天然适合断点续传,你把排序值存下来就行,哪怕隔一天再恢复也能用。

但它单独用的时候没有快照语义,源索引一边写一边读,结果依然会漂。所以 7.10 之后引入了point in time(PIT),先开一个 PIT 拿到快照,后续查询都带上这个 PIT ID,就等价于在一个冻结的视图上翻页。关键的一点是:PIT 有keep_alive,也会过期,但即使 PIT 过期了,你保存的排序值依然有效。恢复时重新开一个 PIT,把保存的排序值作为起点继续读就行,因为 PIT 的排序值是基于文档的_shard_doc或你指定的字段,只要源数据没有大范围回滚,这个位置就是有意义的。

这就是我最推荐的组合:PIT 保证单次运行的快照一致性,search_after保证跨运行的位置可恢复。两者职责分开,比 scroll 那种把状态和位置绑死的设计稳得多。

对应的查询长这样:

{ "size": 1000, "pit": { "id": "<pit_id>", "keep_alive": "5m" }, "sort": [{ "_shard_doc": "asc" }], "search_after": [1728432012345], "query": { "match_all": {} } }

_shard_doc是 PIT 专用的排序字段,内部是一个跨分片单调递增的标识,用它排序不需要你操心业务字段是否有重复值。如果你的 ES 版本低于 7.10,没有 PIT,那就退回用业务字段排序,比如create_time加_id组成复合排序,保证唯一性,代价是必须建一个能覆盖这个排序的索引。

3.4 多分片并行与游标合并

单线程顺序读的吞吐有个天花板,我实测在批次 1000 条、文档平均 1.5KB 的情况下,单线程大概能跑到每秒 800 到 1200 条,这个数在亿级数据面前太慢了。

并行的方案有两种。第一种是在一个查询上做slice,把整个数据集切成 N 份,每份独立 scroll。这个方案的缺点是它只和 scroll 配合,和 PIT +search_after不兼容。

第二种是我更常用的:按分片拆任务,每个分片一条独立游标。原理是利用preference=_shard:3这种路由偏好把查询固定到某一个分片上,在这个分片内部用_doc或者_shard_doc排序,然后每个分片维护自己的断点文件。这样做的好处非常明显:每个分片的断点互相独立,一个分片卡住不影响其他分片;分片数和并行度天然对齐;恢复时可以只恢复失败的那几个分片。

代价是要处理分片数量的变化。如果迁移中途有人给源索引加了副本或者做了重分片,_shard:N的编号含义就变了。所以我把这个方案的前提写死在运维规范里:迁移窗口内冻结源索引的 settings,禁止任何分片调整。

4. 一套可复现的迁移脚本骨架

讲完设计,落到代码。下面这套骨架我用在过多个项目里,Python 加elasticsearch-py,8.x 版本的 API 风格(7.x 有几个参数名差异,下面会标出来)。

4.1 源端与目标端的参数准备

正式跑之前,先把两端的状态调好,这一步对速度的影响比代码优化大得多。

目标索引侧,我在开跑前会执行三件事:把refresh_interval设成-1,把number_of_replicas设成 0,把translog.durability设成async。前两个是为了减少写入时的段合并和副本同步开销,第三个是降低每次 bulk 的 fsync 次数。实测下来这三项加起来能让写入吞吐提升一倍以上。迁移全部结束后再恢复成目标值,一次PUT _settings就够。

PUT /orders_v4/_settings { "index": { "refresh_interval": "-1", "number_of_replicas": 0, "translog.durability": "async" } }

源索引侧,确认三件事:查询用的账号有read和view_index_metadata权限;如果有大量聚合或者脚本字段会拖慢查询,迁移用的查询要写得极简,只保留必要的过滤条件;如果源索引正在被高频写入,评估一下要不要先切只读。

4.2 批次大小与并发度的计算

批次大小不是拍脑袋定的,有两个约束:单批体积和单批耗时。

单批体积方面,bulk 请求的推荐区间是 5MB 到 15MB。假设你的文档平均 1.5KB,那么 1000 条大概是 1.5MB,3000 条大概是 4.5MB,5000 条大概 7.5MB。我一般从 1000 条起步,看目标端的响应情况再往上加。如果文档里有大字段(比如长文本、base64 图片),必须按体积来算而不是按条数,否则一批几十兆打过去,协调节点直接给你超时。

单批耗时可以用一个粗略的模型估:源端检索约 100 到 300 毫秒,网络传输约 30 到 100 毫秒,目标端 bulk 写入约 200 到 800 毫秒,合计一秒量级。按 1000 条一批算,单线程每秒约 1000 条。

并发度方面,我一般不超目标集群数据节点的数量,常见的 3 到 6 个数据节点,并发 6 到 8 就很合适了。再往上加,收益会迅速衰减,因为瓶颈已经转移到目标集群的段合并上,同时 429 的概率大幅上升。有一个估算可以做:1 亿条文档、单线程 1000 条每秒、8 并发、按 60% 的效率折算,大概是 5 到 6 小时。实际跑下来通常比估算还要慢一些,因为随着目标索引变大,写入会逐渐变慢,这是段合并带来的自然衰减。

4.3 主循环代码:读、写、存断点

先看断点的读写,这部分必须是原子的:

import json, os CKPT = "/data/migrate/ckpt_shard3.json" def load_ckpt(): if not os.path.exists(CKPT): return {"last_sort": None, "done": 0, "fail": 0, "owner": None} with open(CKPT, "r", encoding="utf-8") as f: return json.load(f) def save_ckpt(state): tmp = CKPT + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False) f.flush() os.fsync(f.fileno()) os.replace(tmp, CKPT)

然后是主循环。注意这里有一个很容易踩的坑:判断last_sort是否存在时,不能直接写if state["last_sort"],因为空列表是 falsy 的,而某些场景下search_after的起始值确实可能是个空数组。用is not None判断更稳。

from elasticsearch import Elasticsearch, helpers SRC = Elasticsearch("http://src-host:9200", request_timeout=60) DST = Elasticsearch("http://dst-host:9200", request_timeout=120) def open_pit(index): return SRC.open_point_in_time(index=index, keep_alive="5m")["id"] def migrate_shard(src_index, dst_index, shard, batch_size=1000): state = load_ckpt() pit_id = None consecutive_err = 0 while True: try: if pit_id is None: pit_id = open_pit(src_index) body = { "size": batch_size, "query": {"match_all": {}}, "sort": [{"_shard_doc": "asc"}], "pit": {"id": pit_id, "keep_alive": "5m"}, "preference": f"_shard:{shard}", } if state["last_sort"] is not None: body["search_after"] = state["last_sort"] resp = SRC.search(**body) hits = resp["hits"]["hits"] if not hits: print("shard done:", shard) break actions = [ { "_op_type": "index", "_index": dst_index, "_id": h["_id"], "_source": h["_source"], } for h in hits ] ok, errors = helpers.bulk( DST, actions, raise_on_error=False, request_timeout=120 ) state["done"] += ok state["fail"] += len(errors) # 关键:断点用的是本批最后一条,不是最成功的那条 state["last_sort"] = hits[-1]["sort"] save_ckpt(state) consecutive_err = 0 except Exception as e: # PIT 过期或者节点重启,重建 PIT 从断点继续 consecutive_err += 1 print("recover attempt", consecutive_err, repr(e)) pit_id = None if consecutive_err > 5: raise

代码里有两个设计值得单独说。第一,_op_type用index而不是create,_id显式指定为源文档的_id。这是幂等的基础:同一个文档重复写一百次,目标端还是一条,只是覆盖。如果你的目标索引用默认的自动生成 ID,那你不可能做断点续传,因为每次重跑都会产生重复文档。第二,断点存的是本批最后一条的sort,不是最后一条成功的。如果一批里有一半失败,你把断点推到最后一条成功的,那后半批就永远丢了。正确做法是推到最后一条,然后靠失败列表重放,或者直接牺牲这一批的部分写入、让幂等机制兜住重复。

helpers.bulk的第二个返回值是失败明细列表,每一项里带着文档内容和异常信息。这些错误我会单独落一个文件:

def dump_errors(errors, path="failed_bulk.jsonl"): with open(path, "a", encoding="utf-8") as f: for e in errors: f.write(json.dumps({ "id": e.get("index", {}).get("_id"), "error": e.get("index", {}).get("error"), }, ensure_ascii=False) + "\n")

阶段跑完之后,把这个文件逐条重放一遍,绝大多数 mapping 冲突、字段类型不匹配的问题都能在这时候暴露出来,而不是等到最后对账时才发现。

4.4 断点恢复验证与数据一致性核对

恢复本身不难,难的是确认“恢复之后数据是对的”。我的核对流程分三层。

第一层是数量核对,GET /dst/_count和GET /src/_count对比。注意这一步必须在迁移彻底结束、refresh_interval恢复之后做,否则目标端可能还有未刷新的文档在内存里,_count会偏小。我就因为提前刷新吃过一次误报,白排查了半小时。

第二层是抽样比对,从源端随机抽 100 到 200 个_id,两边_mget拉出来,对_source做深比较。这一步能抓到“数量对但内容错”的情况,比如字段被 mapping 悄悄改了类型、null值被丢弃、日期时区偏了。

第三层是关键字段聚合校验。选一两个数值字段做sum或者min/max,两边对比。如果源端有唯一的业务单号,可以对这个字段做cardinality聚合,基数应该完全一致。这一层比抽样更有说服力,因为它覆盖了全量数据。

def checksum(client, index, field): resp = client.search(index=index, size=0, aggs={ "cnt": {"value_count": {"field": field}}, "uniq": {"cardinality": {"field": field, "precision_threshold": 40000}}, }) return resp["aggregations"]

cardinality是近似算法,会有误差,所以对比时的容忍阈值要设成 1% 到 2%,别死磕完全相等。

5. 常见问题与排查速查

这一节是我这两年攒下来的问题清单,基本都是实际跑出来才发现的,文档里不太会写。

5.1 断点回退、重复写入、速率骤降的排查顺序

症状一:恢复后从零开始重跑。先看断点文件里的last_sort是不是空的那个陷阱——null和[]是两回事。然后看search_after的排序字段和查询里的sort是否完全一致,包括顺序。最后确认脚本读的断点路径和写的路径是同一个,容器环境下工作目录变了,相对路径会指到别的地方去。

症状二:目标端文档数比预期多。八成是_id没指定,或者_op_type用了create导致重复时全失败但被raise_on_error=False吞掉了。还有一种可能是你同时跑了两个进程,各自推着不同的断点,写的是同一批数据。

症状三:跑到一半速率肉眼可见地往下掉。这个太常见了。先看目标集群的_nodes/stats里的 segment 数量和 merge 情况,迁移过程中段数量会持续增长,合并压力上来之后写入必然变慢。缓解手段是把index.refresh_interval设成-1、副本设 0,这两项不做的话衰减会非常明显。如果还是慢,看源端是不是在执行大的 merge 或者有别的查询在抢资源。最后再看客户端的 GC 和网络,Python 这边处理大批量 JSON 时内存涨得很快,batch_size太大反而会因为 GC 拖慢整体节奏。

症状四:bulk 大量 429。这就是反压,不要硬刚。第一步把并发降下来,第二步在批量写入的异常里加指数退避,第三步考虑给目标集群临时扩容或者错开业务高峰。我在一次迁移里因为没做退避,脚本进入了一个疯狂重试的死循环,把目标集群的线程池彻底打死,最后连_cat都查不出来,只能重启节点。

5.2 数据一致性保障的几个关键动作

除了前面说的三层核对,还有几个动作建议加进流程。

迁移开始前把源索引的 mapping 导出来,和目标索引的 mapping 做一次 diff。字段类型从text变成keyword、integer变成long、object被压平,这些在写入阶段就会炸,提前发现能省掉大量返工。字段类型不兼容时,要么在目标端改 mapping,要么在写入前用脚本字段做转换。

迁移过程中维护一份失败明细,跑完后必须重放。我现在的习惯是把失败明细也当作一种断点:重放完成后在文件里追加一个完成标记,避免重复重放。

迁移完成后恢复refresh_interval和副本数,然后等一个完整的 refresh 周期再做最终核对。目标索引如果查询量大,可以考虑在业务低峰期做一次_forcemerge,把大量小段合成大段,查询性能会明显提升,但这一步 IO 开销很大,一定要挑时间。

5.3 问题速查表

现象最可能原因处理方式
恢复后从头跑last_sort为null或空数组被误判用is not None判断,检查断点路径
目标端文档数偏多未指定_id或用了create显式传_id,_op_type用index
search_context_missingscroll 上下文超时弃用 scroll_id,改用排序值续传
PIT 相关 404PIT 超过keep_alive被清理捕获异常重建 PIT,从last_sort继续
search_after报参数错排序字段与上次不一致迁移期间冻结排序定义,不允许中途改
bulk 持续 429目标端反压降并发、指数退避、临时扩容
写入速率逐渐下降目标端段合并压力refresh_interval=-1、副本设 0
字段解析异常mapping 类型不兼容迁移前做 mapping diff,必要时转换
断点文件解析失败非原子写入临时文件 + fsync + 重命名
两个进程抢同一任务缺少租约机制加owner与过期时间戳

6. 几条踩过坑之后才明白的经验

最后分享几个不在设计文档里、但我觉得比代码本身更值钱的点。

关于断点粒度。一开始我是一批存一次,后来发现批次大到几千条的时候,如果进程死在批次中间,重启后要重跑一整批。这个代价其实可以接受,因为幂等写入保证了重复不会出问题,重跑一批最多浪费一两秒。真正的风险在于断点存得太稀疏,比如每 100 批存一次,那就是几千条数据的不确定性。现在我的原则是每批必存,一次fsync的开销远小于重跑的成本。

关于要不要用官方_reindex。我的结论是:如果你能接受失败的代价、数据量在千万级、并且源索引可以只读,就大胆用_reindex,配合wait_for_completion=false和_tasks轮询,省下来的开发时间比什么都值。自研脚本的复杂度主要不在读写在状态管理,而状态管理的复杂度随着“要支持多少种异常”成倍上升。

关于迁移窗口的预估。我现在的规矩是先拿 1% 的数据做一次试跑,量出真实速率,再乘上 1.8 到 2.5 的系数作为窗口预留。这个系数覆盖的是后期写入衰减、失败重放、以及不可预知的抖动。试跑这一步花二十分钟,能避免后面几个小时的手忙脚乱,性价比极高。

关于最后的一致性核对。我见过太多“跑完了就上线”的例子,然后线上用户报数据缺失。核对这一步不能省,而且不能只做数量核对。数量对上但内容错的情况我遇到过两次,一次是时间字段的时区被写成了 UTC,一次是某个嵌套字段被 mapping 压平。这两次都是抽样比对抓出来的,_count完全看不出来。

关于文档。每次迁移我都会在断点目录旁边放一个README.md,写清楚这次迁移的源、目标、排序字段、批次大小、起始时间、负责人,以及“如果断了怎么恢复”的三行命令。这个习惯是吃过亏之后才养成的:某次迁移中断在周末,接手的人对着一个 JSON 文件完全无从下手,最后只能全量重跑。一份五行的说明,能省掉一整天。

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

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

立即咨询