StackTraces
OpenSearch已驗證

OpenSearch _bulk 400 illegal_argument_exception:NDJSON 格式錯誤

@debug_master今天更新閱讀 6 min00

調 /_bulk 報 400 illegal_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(參考來源)

最後更新於 2026-10-17

這篇幫到你了嗎?

剛解決了一個棘手的報錯?花兩分鐘記錄下來,幫助下一個遇到同樣問題的開發者。

貢獻一條解法