0

Nỗi ám ảnh "Deep Pagination" và rào cản 10.000 bản ghi (Window Result Limit) trong Elasticsearch

Chào anh em Viblo, vẫn là mình đây!

Nếu anh em làm backend đủ lâu, chắc chắn sẽ có một ngày đẹp trời, sếp hoặc team Data Analysis vỗ vai và đưa ra một yêu cầu nghe rất bình thường: "Em xuất cho anh file báo cáo toàn bộ log giao dịch AFC của tháng trước nhé, chắc cỡ vài triệu dòng thôi".

Bạn tự tin mở source code Node.js (hoặc Golang) lên, viết một vòng lặp while, dùng from và size (tương tự như LIMIT và OFFSET trong SQL) để chia nhỏ dữ liệu kéo về. Bạn test với trang 1, trang 2, mọi thứ mượt mà. Code được deploy, chạy ngon lành... cho đến khi vòng lặp chạm tới trang thứ 1000 (tức là bản ghi thứ 10.000).

Hệ thống crash! Log báo đỏ chót một dòng chữ đầy ám ảnh: Result window is too large, from + size must be less than or equal to: [10000] but was [10010]...

Chào mừng anh em đến với Window Result Limit – một cơ chế bảo vệ "tàn nhẫn nhưng cần thiết" của Elasticsearch.

1. Window Result Limit là gì và tại sao nó tồn tại?

Theo mặc định, tham số cấu hình index.max_result_window trong Elasticsearch được set cứng ở mức 10.000. Điều này có nghĩa là tổng của from (vị trí bắt đầu) + size (số lượng bản ghi lấy ra) không được vượt quá con số này.

Ví dụ: Bạn muốn lấy 10 bản ghi, bắt đầu từ vị trí 9.995 (from=9995, size=10). Tổng là 10.005 > 10.000 -> Elasticsearch sẽ từ chối phục vụ (ném ra Exception).

Tại sao lại có sự "kẹt xỉn" này? Elasticsearch là một hệ cơ sở dữ liệu phân tán (Distributed). Dữ liệu của bạn không nằm chung một chỗ mà nằm rải rác ở nhiều Shard (mảnh dữ liệu) trên nhiều Node khác nhau.

Giả sử bạn query from=9990, size=10. Để trả về đúng 10 kết quả xếp hạng chính xác nhất, mỗi Shard trong hệ thống đều phải lục lọi và trả về 10.000 bản ghi (9990 + 10) lên cho Node điều phối (Coordinating Node). Nếu bạn có 5 Shards, Coordinating Node sẽ phải ngậm vào RAM tổng cộng 50.000 bản ghi, sau đó nó mới tiến hành sort (sắp xếp) lại toàn bộ đống đó chỉ để bóc ra đúng 10 bản ghi cuối cùng trả cho bạn.

Nếu không có cái rào cản 10.000 kia, mà user yêu cầu trang thứ 1.000.000, con server của bạn sẽ bị OOM (Out of Memory) và sập ngay lập tức vì ngốn sạch Heap Memory.

2. Cách vượt qua rào cản: Những con đường từ "Tà đạo" đến "Chân ái"

Khi gặp lỗi này, phản xạ đầu tiên của anh em thường là lên Google gõ "How to bypass Elasticsearch 10000 limit". Và đây là những thứ bạn sẽ tìm thấy:

Cách 1: Tăng giới hạn max_result_window (Cạm bẫy chết người) Bạn hoàn toàn có thể chạy một câu lệnh Settings để nới lỏng giới hạn này lên 1 triệu hoặc hơn:

PUT /metro_transactions/_settings
{
  "index": {
    "max_result_window": 1000000
  }
}

Kinh nghiệm: Đừng làm vậy! Đây là cách giải quyết cục súc nhất. Nó chỉ đẩy cái chết của server lùi lại một chút thôi. Trừ khi bạn quản trị một cluster siêu khủng với lượng RAM dư thừa, còn không, hệ thống sẽ sập bất đắc kỳ tử khi có vài request xuất dữ liệu chạy cùng lúc.

Cách 2: Scroll API (Cựu vương đã nhường ngôi) Trước đây, để export hàng triệu transaction logs, anh em thường dùng Scroll API. Cơ chế của nó giống như việc bạn mở một "con trỏ" (cursor) giữ kết nối trực tiếp vào database, chụp lại một bức ảnh (snapshot) của dữ liệu tại thời điểm đó và cứ thế kéo về từ từ. Tuy nhiên, việc duy trì cái snapshot này trên server rất tốn tài nguyên (memory và file descriptors). Elasticsearch hiện tại đã không còn khuyến khích (deprecated) dùng Scroll API cho các bài toán deep pagination nữa.

Cách 3: Lướt sóng cùng search_after (Chân ái hiện tại) Đây là cách tối ưu và được khuyên dùng nhất hiện nay. Thay vì nói: "Cho tôi 10 bản ghi, bỏ qua 10.000 bản ghi đầu tiên", bạn sẽ nói với Elasticsearch: "Cho tôi 10 bản ghi, bắt đầu từ ngay sau cái bản ghi cuối cùng của trang trước".

Để làm được việc này, bạn cần một trường dữ liệu dùng để sort có tính duy nhất (ví dụ: timestamp kết hợp với _id để tránh trùng lặp).

Trang 1:

GET /metro_transactions/_search
{
  "size": 10,
  "sort": [
    {"tap_in_time": "asc"},
    {"_id": "asc"}
  ]
}

Kết quả trả về sẽ có một mảng sort ở bản ghi cuối cùng, ví dụ: [1685548800000, "doc_999"]. Bạn lấy cái mảng này làm mốc cho trang tiếp theo.

Trang 2 (và các trang sau):

GET /metro_transactions/_search
{
  "size": 10,
  "search_after": [1685548800000, "doc_999"],
  "sort": [
    {"tap_in_time": "asc"},
    {"_id": "asc"}
  ]
}

Với search_after, Elasticsearch không cần phải fetch hàng chục ngàn bản ghi lên bộ nhớ nữa, nó chỉ việc tìm đúng cái mốc kia và quét tiếp. Tốc độ nhanh như chớp và bộ nhớ luôn an toàn, dù bạn có kéo đến bản ghi thứ mấy triệu đi chăng nữa.

3. Giải pháp về mặt UI/UX

Đôi khi bài toán kỹ thuật lại được giải quyết tốt nhất bằng... business logic. Nếu bạn đang làm trang kết quả tìm kiếm cho người dùng (chứ không phải làm tính năng xuất file hay crawl dữ liệu), hãy tự hỏi: "Có user nào thực sự bấm Next đến trang thứ 1000 để tìm đồ không?". Chắc chắn là không. Họ sẽ đổi từ khóa khác từ trang thứ 3 hoặc thứ 4 rồi.

Do đó, các hệ thống lớn (như Google) thường chỉ hiển thị giới hạn số lượng trang tối đa (ví dụ: tối đa 100 trang). Nếu người dùng muốn xem sâu hơn, hãy yêu cầu họ sử dụng bộ lọc (filter) để thu hẹp kết quả lại.

Lời kết

Window Result Limit không phải là một lỗi của Elasticsearch, nó là một tính năng bảo vệ kiến trúc phân tán khỏi những câu query ngốn tài nguyên. Nắm vững bản chất của nó và biết cách áp dụng search_after sẽ giúp anh em backend tự tin đối phó với những bài toán trích xuất dữ liệu khổng lồ mà không lo đánh sập hệ thống.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí