调
/_bulk报 400illegal_argument_exception,基本都是 NDJSON 格式问题:要么Malformed action/metadata line(文档 JSON 被换行拆开,或缺了动作行),要么The bulk request must be terminated by a newline [\n](body 结尾少换行)。根因是 bulk 解析器逐行成帧,行边界就是记录边界。解法:每个 JSON 压成一行、动作行与文档行成对、body 末尾补换行、Content-Type 用application/x-ndjson。适用 OpenSearch 1.x–3.x 与 Elasticsearch 6.x/7.x。
Issue & Env(案发现场与环境)
- 业务场景:跟着 从零跑通 OpenSearch 第四步 bulk 导入 movies 示例数据,POST
/_bulk返回 400,一条都没写进去。 - 环境:OpenSearch 3.9.0,Docker 单节点,HTTPS 走 9200 端口;curl、Python、Go 等任何客户端表现一致。
- 关键区分:这个 400 是协调节点的 bulk 解析器在给请求体分帧时抛的,出现在顶层
error,不是items[].error。一行解析失败整个请求直接拒绝,零文档写入——和「部分文档因 mapping 冲突失败」是两回事。 - 高发场景:文档 JSON 过了一遍格式化工具(缩进 pretty-print);编辑器保存时没留行尾换行。
StackTrace(堆栈日志)
变体一:文档行被当成动作行。Elastic 论坛上一位用 esrally 的用户,数据文件里只有文档行、没有动作行,Python 客户端报出来的原文(ES 7.x):
{
"error" : {
"root_cause" : [
{
"type" : "illegal_argument_exception",
"reason" : "Malformed action/metadata line [1], expected START_OBJECT or END_OBJECT but found [VALUE_STRING]"
}
],
"type" : "illegal_argument_exception",
"reason" : "Malformed action/metadata line [1], expected START_OBJECT or END_OBJECT but found [VALUE_STRING]"
},
"status" : 400
}
变体二:缺尾换行。Elastic 论坛提问者按官方教程导入 accounts.json(ES 6.7.0),拿到的 400:
{
"error" : {
"root_cause" : [
{
"type" : "illegal_argument_exception",
"reason" : "The bulk request must be terminated by a newline [\n]"
}
],
"type" : "illegal_argument_exception",
"reason" : "The bulk request must be terminated by a newline [\n]"
},
"status" : 400
}
同一个报错在 OpenSearch 官方仓库也有记录:文件本身结尾有换行,大文档(大于 1 KB)在流式传输下仍会间歇报 The bulk request must be terminated by a newline,因为 Netty 的 chunk 边界把行切断了(opensearch-project/OpenSearch#16214,2.x 已由 PR #16337 修复)。
Reproduce(复现代码)
两个最小反例,直接打本地 3.9.0 复现。
反例一:文档 JSON 被格式化换行拆开。
cat > broken-split.ndjson <<'EOF'
{ "index": { "_index": "movies", "_id": "1" } }
{
"title": "Let the Wind Rise",
"year": 2013,
"genre": "drama"
}
EOF
curl -sk -u admin:Devfix@2026 -X POST "https://localhost:9200/_bulk?pretty" \
-H "Content-Type: application/x-ndjson" \
--data-binary @broken-split.ndjson
第 1 行是动作行,第 2 行的 { 被当成文档行整行存下,第 3 行以字符串开头、被当成下一条动作行,解析器当场抛错:
{
"error" : {
"root_cause" : [
{
"type" : "illegal_argument_exception",
"reason" : "Malformed action/metadata line [3], expected START_OBJECT but found [VALUE_STRING]"
}
],
"type" : "illegal_argument_exception",
"reason" : "Malformed action/metadata line [3], expected START_OBJECT but found [VALUE_STRING]"
},
"status" : 400
}
报错里的行号就是请求体里的行号,直接数到那一行就能看到被拆开的 JSON。
反例二:body 结尾缺换行。
printf '{ "index": { "_index": "movies", "_id": "1" } }\n{ "title": "Let the Wind Rise", "year": 2013 }' > broken-nonl.ndjson
# 确认最后一个字节不是换行:输出 7d,也就是右括号
tail -c 1 broken-nonl.ndjson | xxd -p
curl -sk -u admin:Devfix@2026 -X POST "https://localhost:9200/_bulk?pretty" \
-H "Content-Type: application/x-ndjson" \
--data-binary @broken-nonl.ndjson
返回的就是 StackTrace 变体二那条 The bulk request must be terminated by a newline [\n]。
还有一个隐蔽翻车:用 -d @文件 而不是 --data-binary @文件。官方文档明确写了 -d 会去掉请求体里的换行符,把成对的动作行和文档行粘成一行,NDJSON 直接失效;--data-binary 才原样保留换行。
Root Cause(根因分析)
NDJSON 逐行成帧:行边界即记录边界
bulk 解析器不会把整个请求体读成一棵 JSON 树。官方文档的说法是:每个动作占一行,需要源数据的动作(index、create、update)其文档跟在下一行,这样 OpenSearch 不必把整个 body 读进内存就能逐条处理。
成帧逻辑在 BulkRequestParser 里,核心是 findNextMarker(源码节选):
private static int findNextMarker(byte marker, int from, BytesReference data) {
final int res = data.indexOf(marker, from);
if (res != -1) {
assert res >= 0;
return res;
}
if (from != data.length()) {
throw new IllegalArgumentException("The bulk request must be terminated by a newline [\\n]");
}
return res;
}
marker 就是 \n。解析器逐字节找换行符,把 body 切成一段一段,每段独立解析:动作行、文档行交替出现,delete 没有文档行。它不认识 JSON 的嵌套结构,行边界就是记录边界。
为什么 JSON 内部换行会破坏解析
格式化后的文档 JSON 内部带 \n,成帧器照样在 \n 处切。一条文档被切成五行:{ 那行被当作文档行原样收下(source 字节此时不校验),"title": ... 那行被当作下一条动作行。动作行的第一个 token 必须是 START_OBJECT,遇到字符串 token 就抛 Malformed action/metadata line。ES 6.x/7.x 与 OpenSearch 在这条路径上报错措辞略有出入,但都是同一个成帧循环抛的。
尾换行是「最后一条记录结束」的信号
findNextMarker 只有把 \n 之后的字节全部消费完才返回 -1、结束解析。若还有剩余字节却找不到换行,from != data.length() 成立,直接抛 The bulk request must be terminated by a newline。尾换行不是分隔符,是解析器判定最后一条记录完整的信号——哪怕最后一行是文档行也必须补。
#16214 从反面印证了这套机制:流式传输下 Netty chunk 边界落在行中间,解析器看到「有剩余字节、没有换行」,哪怕文件本身没问题也报同一个错。
Content-Type 必须是 application/x-ndjson
bulk 请求体的正确 Content-Type 是 application/x-ndjson;写成 application/json 会被拒或行为异常。配合 curl 发文件时用 --data-binary,原因上面说过:-d 剥换行。
The Fix(解决方案)
正确写法:成对、一行一个 JSON、结尾带换行
cat > movies.ndjson <<'EOF'
{ "index": { "_index": "movies", "_id": "1" } }
{ "title": "Let the Wind Rise", "year": 2013, "genre": "drama" }
EOF
curl -sk -u admin:Devfix@2026 -X POST "https://localhost:9200/_bulk?pretty" \
-H "Content-Type: application/x-ndjson" \
--data-binary @movies.ndjson
heredoc 会自动给最后一行补上换行,这两行就是合法的最小 bulk body。验证看响应顶层的 "errors" : false 和每个 item 的 "result" : "created"。
发送前自检
# 1. 最后一个字节是换行(输出 0a)
tail -c 1 movies.ndjson | xxd -p
# 2. 行数检查:index/create/update 成对占两行,行数应为偶数
wc -l < movies.ndjson
行数那条注意:delete 只占一行,body 里有 delete 时奇数也可能是对的,成对动作才要求偶数。
程序化生成:紧凑序列化加显式尾换行
import json
lines = []
for movie in movies:
lines.append(json.dumps({"index": {"_index": "movies", "_id": movie["id"]}}, separators=(",", ":")))
lines.append(json.dumps(movie, separators=(",", ":")))
body = "\n".join(lines) + "\n" # 结尾换行别漏
json.dumps 默认输出单行 JSON,不会被格式化拆开;最后显式拼一个 \n。或者直接用官方客户端的 bulk helper(如 opensearch-py 的 helpers.bulk),成帧由库处理,格式坑一次绕开。
四点检查清单
- 动作行与文档行严格成对,
delete没有文档行。 - 每个 JSON 占一行,JSON 内部不能有换行。
- body 以
\n结尾,最后一行无论是动作行还是文档行都要补。 Content-Type: application/x-ndjson,curl 发文件用--data-binary。
完整的导入流程和其他新手坑(TLS 连不上、集群 yellow、刚导入搜不到)见 从零跑通 OpenSearch。
References(参考来源)
- Bulk API — OpenSearch 官方文档opensearch-docs
- BulkRequestParser.java — OpenSearch 源码github
- [BUG] [Streaming Indexing] Fix intermittent "The bulk request must be terminated by a newline" failuresgithub-issue
- "The bulk request must be terminated by a newline [\n]"github-issue
- Set custom document ids on bulk insert - Discuss the Elastic Stackelastic-forum
- Bulk request on ElasticSearch returns "The bulk request must be terminated by a newline"elastic-forum
