Bạn có một triệu bản ghi cần đưa vào Elasticsearch. Cách tự nhiên nhất — vòng lặp, mỗi bản ghi một lời gọi POST /_doc — là cách sai và sai nghiêm trọng. Đây là một trong những cái bẫy hiệu năng phổ biến nhất khi làm việc với ES: nạp dữ liệu từng document một chậm đến mức biến một công việc 30 giây thành nhiều giờ.

Giải pháp là _bulk API — gộp nhiều thao tác vào một request. Bài này (phần 8 loạt Elasticsearch) đo thật khoảng cách giữa hai cách, và đo luôn một câu hỏi thực tế: lô bao nhiêu document một request thì tối ưu? Mọi con số dưới đây đo bằng đồng hồ thật trên es-lab.

Vì sao từng-cái chậm, bulk nhanh

Khi bạn index từng document, mỗi lần là một chu trình đầy đủ: mở kết nối HTTP, gửi request qua mạng, ES phân tích và định tuyến, ghi, trả response. Chi phí cố định đó (round-trip mạng, phân tích) nhỏ với một document, nhưng nhân lên nghìn lần khi nạp nghìn document.

_bulk gộp nhiều thao tác vào một request bằng định dạng NDJSON (mỗi dòng một JSON): một dòng metadata (action), một dòng document, lặp lại. Cả nghìn document đi trong một round-trip, ES xử lý cả lô cùng nhau.

Cơ chế bulk indexing Elasticsearch: index từng doc mỗi doc một HTTP request chậm vòng lặp for doc in docs POST idx _doc mỗi doc một vòng HTTP cộng phân tích cộng refresh nghìn doc bằng nghìn vòng round-trip mạng chi phí cố định nhân nghìn lần; _bulk gộp nhiều thao tác vào một request định dạng NDJSON POST idx _bulk với dòng index rỗng là metadata action rồi dòng document n 1 body rồi tiếp cặp dòng nghìn cặp trong một request định dạng NDJSON mỗi dòng một JSON phân tách bằng newline kết thúc bằng newline; vì sao bulk nhanh hơn nhiều lần một request thay nghìn request chia sẻ chi phí cố định một vòng round-trip mạng thay vì nghìn phân tích định tuyến một lần cho cả lô ES xử lý cả lô cùng nhau hiệu quả hơn nhiều

Hình 1: Index từng doc = mỗi doc một round-trip HTTP (chi phí cố định nhân nghìn lần). _bulk = gộp nghìn doc vào một request NDJSON, chia sẻ chi phí cố định — một round-trip thay vì nghìn.

# _bulk: dinh dang NDJSON — moi dong 1 JSON, KET THUC bang \n
curl -XPOST localhost:9200/idx/_bulk -H 'Content-Type: application/x-ndjson' --data-binary '
{"index":{}}
{"n":1,"body":"..."}
{"index":{}}
{"n":2,"body":"..."}
'

Đo thật: từng cái vs bulk

Mình nạp cùng 2000 tài liệu bằng hai cách, đo bằng đồng hồ thật trên es-lab. Kết quả:

Bảng kết quả đo thật throughput nạp dữ liệu es-lab elasticsearch 8.15.3 một shard không replica đo wall-clock date cộng phần trăm s phần trăm N: index từng doc vs _bulk nạp cùng 2000 tài liệu, index từng doc 2000 request mất 8.189 ms tức 244 docs mỗi giây so sánh 1 lần, _bulk 1 request 2000 doc chỉ 27 ms tức 74.074 docs mỗi giây nhanh hơn 303 lần. Chỉnh kích thước lô nạp 100.000 tài liệu bằng _bulk: lô 500 doc mỗi bulk 100k trong 1.438 ms 69.541 docs mỗi giây, lô 2.000 doc 676 ms 147.928 docs mỗi giây, lô 10.000 doc 529 ms 189.035 docs mỗi giây. Badge output thật màu xanh

Hình 2: Kết quả thật. Nạp 2000 tài liệu từng cái mất 8,2 giây (244 docs/giây); _bulk một request chỉ 27ms (74.074 docs/giây) — nhanh hơn 303 lần. Lô lớn hơn nhanh hơn nhưng giảm dần: 69k → 148k → 189k docs/giây.

  • Index từng doc: 244 docs/giây. 2000 request mất 8.189 ms — hơn 8 giây cho 2000 tài liệu bé xíu. Phần lớn thời gian là chờ round-trip, không phải ghi.
  • _bulk một request: 74.074 docs/giây. Cùng 2000 tài liệu, chỉ 27 ms — nhanh hơn 303 lần. Khác biệt hoàn toàn đến từ việc bỏ 1999 round-trip thừa.

Hãy ngoại suy: với 1 triệu tài liệu, nạp từng cái ở tốc độ này mất ~68 phút; bulk ở 74k docs/giây mất ~13 giây. Đó là ranh giới giữa "chạy được" và "không bao giờ xong".

Đo thật: chọn kích thước lô

Bulk nhanh, nhưng lô bao nhiêu? Mình nạp 100.000 tài liệu với các kích thước lô khác nhau:

  • 500 doc/bulk: 69.541 docs/giây
  • 2.000 doc/bulk: 147.928 docs/giây (gấp đôi so với lô 500)
  • 10.000 doc/bulk: 189.035 docs/giây (chỉ hơn ~28% so với lô 2.000)

Rõ ràng: lô lớn hơn nhanh hơn, nhưng lợi ích giảm dần (diminishing returns). Từ 500 lên 2.000 throughput gấp đôi; từ 2.000 lên 10.000 chỉ thêm ~28% — bắt đầu bão hòa. Lý do: khi lô đủ lớn, chi phí round-trip đã được chia nhỏ đến mức không còn là nút thắt; nút thắt chuyển sang việc ES thực sự ghi và index. Tăng lô thêm nữa không giúp nhiều, mà còn gây rủi ro.

Đánh đổi cần cân nhắc

Lô quá lớn gây vấn đề — chọn 1.000-5.000 doc hoặc ~5-15 MB. Lô càng lớn càng ngốn RAM (cả client lẫn ES phải giữ cả lô trong bộ nhớ), và có thể vượt giới hạn kích thước request (http.max_content_length mặc định 100 MB → lỗi "request entity too large"), hoặc gây timeout. Khuyến nghị thực tế không phải "càng lớn càng tốt" mà là kích thước byte ~5-15 MB mỗi bulk (thường tương đương vài nghìn document), vì document to/nhỏ khác nhau. Đo trên dữ liệu thật của bạn để tìm điểm ngọt.

Khi nạp khối lượng lớn, tắt refresh và replica tạm thời. Mặc định ES refresh mỗi giây (làm document mới tìm được) và ghi ra replica — cả hai tốn công trong lúc nạp ồ ạt. Hai tinh chỉnh tăng tốc đáng kể khi nạp hàng loạt: đặt refresh_interval: -1 (tắt refresh, bật lại sau) và number_of_replicas: 0 (nạp vào primary thôi, tạo replica sau khi xong). Lab này đã dùng 0 replica. Sau khi nạp xong, bật lại cả hai. Đổi lại: trong lúc nạp, dữ liệu chưa tìm được ngay và chưa có bản sao dự phòng — chấp nhận được cho tác vụ nạp ban đầu, không cho dữ liệu production đang phục vụ.

Xử lý lỗi từng dòng trong bulk — một request thành công không nghĩa mọi doc thành công. _bulk trả về HTTP 200 ngay cả khi một số document trong lô lỗi (sai kiểu, mapping conflict). Response có trường errors: true và chi tiết từng item. Code nạp dữ liệu phải kiểm tra trường này, không chỉ mã HTTP — nếu không, bạn tưởng nạp thành công trong khi một phần dữ liệu âm thầm rớt. Đây là lỗi hay gặp trong pipeline nạp dữ liệu tự động.

Ba ý mang về

  1. Đừng bao giờ nạp từng document một — dùng _bulk. Đo thật: 2000 tài liệu nạp từng cái mất 8,2 giây (244 docs/giây), _bulk một request chỉ 27ms (74.074 docs/giây) — nhanh hơn 303 lần. Khác biệt đến từ việc bỏ nghìn round-trip HTTP thừa, không phải tốc độ ghi.
  2. Lô lớn hơn nhanh hơn nhưng giảm dần. Đo thật nạp 100k: lô 500 cho 69k docs/giây, lô 2.000 cho 148k (gấp đôi), lô 10.000 cho 189k (chỉ thêm 28% — bão hòa). Thực tế chọn 1.000-5.000 doc hoặc ~5-15 MB mỗi bulk; lô quá lớn tốn RAM và dễ "request entity too large".
  3. Tối ưu nạp hàng loạt: tắt refresh + replica, kiểm lỗi từng dòng. refresh_interval: -1 và number_of_replicas: 0 khi nạp ồ ạt (bật lại sau) tăng tốc đáng kể. Và luôn kiểm errors trong response bulk — HTTP 200 không bảo đảm mọi document trong lô thành công.

Nguồn

Phần sau ta đo thật cạm bẫy phân trang: from/size chậm dần khi lật sâu (deep paging), và vì sao search_after rẻ hơn hẳn cho cuộn qua lượng lớn kết quả.