bulk 返回 200,也可能有一半没写进去
一个同步任务把订单从数据库批量写进 Elasticsearch,用的是 bulk 接口,代码检查了 HTTP 状态码,200 就认为成功。几周后,对账发现搜索里少了几百个订单。日志里没有任何报错,因为每一次 bulk 请求都返回了 200。
bulk 接口把多个操作打包成一个请求,整个请求的状态码只说明请求本身被正常处理了,每个操作是否成功要看响应体里逐项的结果。这是 Elasticsearch 客户端最常见的遗漏之一。重试同样有讲究:整批重试会重复写入已经成功的文档;而同一个文档的多次更新如果乱序到达,旧值会覆盖新值。
本文用单节点 Elasticsearch 9.5.5 和一个 dynamic: strict 的索引逐个验证。实验见文末。
errors=true,o2 与 o4 逐项 400,索引里只有 3 条。带 _id 只重试失败项得到 5 条;不指定 _id 整批重发,成功过的文档各多一份。version_type=external 让延迟到达的旧状态返回 409。一、200 与 errors: true
一次 bulk 写入 5 条,其中一条的 qty 是字符串「两件」,一条多了映射里没有声明的字段:
一次 bulk 5 条:HTTP 状态 200,响应顶层 errors=True
逐项结果:['o1:201', 'o2:400(document_parsing_exception)', 'o3:201', 'o4:400(strict_dynamic_mapping_exception)', 'o5:201']
只看 HTTP 状态的客户端认为 5 条都写入了,索引里实际 3 条响应顶层的 errors 字段表示是否至少有一项失败,items 数组按请求顺序给出每一项的状态码和错误。处理 bulk 响应的最低要求是:errors 为真时遍历 items,把失败项和原始请求对应起来。官方客户端的 bulk 辅助函数默认会在有失败项时抛出异常或返回失败列表,自己拼请求时这一步最容易漏掉。
逐项的错误要分两类:
| 类型 | 例子 | 处理 |
|---|---|---|
| 数据本身的问题 | 400:类型不符、未声明的字段 | 重试没有用,记录下来人工或程序修正 |
| 暂时性的问题 | 429:写入线程池队列已满 | 退避之后只重试这些项,见下文的复现 |
写入队列满时的逐项 429
429 能不能出现取决于负载,为了复现,实验把写入线程池调到 1 个线程、队列长度 1(bulk 的协调线程池保持 2 个线程、队列 1000),单节点 4 个主分片,然后让 24 个客户端同时各提交 100 条。这只是用来复现的极端配置,不是参数建议。
提交 2400 条:首次成功 55 条,逐项 429 共 2345 条;有 2 个 HTTP 200 的 bulk 响应里同时包含 201 和 429
节点统计:write 线程池 rejected 增加 94,write_coordination 线程池 rejected 增加 0
逐项错误类型 es_rejected_execution_exception,原因指向容量为 1 的 write 队列被拒绝的是分片级的写入任务,一个任务里有多条文档,所以线程池的拒绝计数(94)和失败的文档数(2345)不是一回事,监控时不要混为一谈。
处理方式是只收集明确返回 429 的 _id,等首轮负载过去后按 100 条一批重试,每轮指数退避(从 50 毫秒开始,封顶 1 秒,最多 8 轮),成功的从待重试集合里移除:
41 个重试请求后 2400 条全部写入,内容与 _id 一致,所有文档的 _version 都是 1;重试轨迹里没有任何此前已成功的项_version 全是 1,说明没有一条成功过的文档被重写。拒绝数、混合响应数和重试请求数受调度影响,每次运行可能不同,实验只断言机制和最终状态,不是性能基准。
还有一种情况是逐项结果根本没拿到:整个请求超时或连接中断。这时无法判断哪些项已经落库,「只重试失败项」不再适用,要靠源主键 _id 的幂等写入或版本号来承受结果未知,和超时之后对方到底执行了没有是同一个问题。持续洪峰下的自适应限流、协调线程池满、节点与副本失败,本文都没有验证。
二、重试:只重试失败项,并且带上 _id
修正数据后只重试失败的 2 条(带原来的 _id):['o2:201', 'o4:201'];索引里 5 条
不指定 _id、整批重试:B1 2 条,B3 2 条,B2 1 条第一种做法从逐项结果里取出失败项,修正后用原来的 _id 重试,最终正好 5 条。第二种做法没有指定 _id,由 Elasticsearch 自动生成,失败后整批重发:第一次已经成功的 B1、B3 又各写入了一份。
自动生成的 _id 让 index 操作不再幂等。从数据库同步时,应该用源数据的主键作为 _id,这样同一条数据无论重试几次,结果都是同一个文档。
三、create 与 index:重放时的冲突
用 create 重放:HTTP 200,['c1:409(version_conflict_engine_exception)', 'c2:201'];c1 的 qty 仍是 1index 操作遇到已存在的 _id 会整体覆盖;create 只在文档不存在时写入,已存在时这一项返回 409,其他项照常执行。用 create 重放一批「只应写入一次」的数据(例如事件日志)时,409 是预期结果,表示这一项之前已经写入成功,不应该当作错误告警。
四、乱序更新:旧值覆盖新值
同一个订单先是 paid,后变成 shipped。如果同步链路中两条消息乱序到达:
不带版本号:先写 shipped,延迟的 paid 后到,最终状态 paid
用源系统的版本号(version_type=external):版本 2 的旧消息后到,HTTP 409(version_conflict_engine_exception),最终状态 shipped,_version=3默认情况下后到的写入覆盖先到的,Elasticsearch 不知道哪一个才是更新的状态。version_type=external 让写入带上源系统的版本号(例如数据库行的版本列,或 binlog 的位置),只有版本号大于当前值的写入才会生效,旧消息被拒绝。这要求源系统能给出单调递增的版本号,并且同一个文档的所有写入路径都使用它。
这和按订单号发到同一个分区,为什么还是乱序了是同一类问题:在不保证顺序的链路上,靠的是写入方能判断新旧,而不是期望消息按顺序到达。
五、检查清单
- 处理 bulk 响应时检查
errors,遍历items取出失败项。 - 区分数据错误(400 类,不重试)与暂时错误(429,退避后只重试失败项)。
- 用源数据的主键作为
_id,让重试幂等;不要整批重发自动 ID 的请求。 - 只应写入一次的数据用
create,把 409 当作「已写入」。 - 同一文档可能乱序更新时,用
version_type=external带上源系统的版本号。 - 定期用源数据对账:数量、抽样内容,而不只是看写入日志里有没有报错。
配套实验
- codesphere-labs/storage/elasticsearch-bulk-failures:Elasticsearch 9.5.5 单节点:bulk 的逐项错误、只重试失败项与自动 ID 整批重试、
create冲突、外部版本号与乱序写入,写入队列满时的逐项 429 与退避重试(验证记录)
参考资料