調
/_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
