0

Elasticsearch Bài học 30: Giải quyết Deep Paging hiệu quả với Search After API và Scroll API

Ở Bài 29, chúng ta đã hiểu tại sao giới hạn index.max_result_window = 10000 là tấm khiên bảo vệ cụm Elasticsearch khỏi thảm họa OOM (Out Of Memory) do Deep Paging.

Nhưng nếu nghiệp vụ của bạn yêu cầu bắt buộc phải duyệt qua hàng triệu bản ghi? Ví dụ:

  1. Viết một worker Golang xuất (export) 2 triệu giao dịch soát vé AFC ra file Excel để báo cáo tài chính cuối tháng.

  2. Làm tính năng "Infinite Scroll" (Cuộn vô hạn) trên ứng dụng điện thoại giống như bảng tin Facebook.

Lúc này, bạn phải vứt bỏ from và size. Hãy làm quen với hai công cụ phân trang hạng nặng: Scroll API (Cách cổ điển) và Search After + Point In Time (Chuẩn mực hiện đại).

1. Scroll API: Đóng băng thời gian (Cách cổ điển)

Ý tưởng của Scroll API giống hệt như việc bạn mở một Cursor trong MySQL. Thay vì bắt hệ thống phải tự sort lại 50.000 kết quả ở mỗi trang, Scroll API sẽ tạo ra một Snapshot (Bản chụp trạng thái) của toàn bộ dữ liệu ngay tại khoảnh khắc bạn gọi hàm.

Bước 1: Khởi tạo Scroll (Mở phiên)

Bạn gọi API search bình thường, nhưng thêm tham số ?scroll=1m vào URL. Tham số này báo cho Elasticsearch biết: "Hãy giữ Snapshot này sống trong bộ nhớ 1 phút nhé".

JSON

POST products/_search?scroll=1m
{
  "size": 1000,
  "query": { "match_all": {} },
  "sort": [ "_doc" ] 
}

(Mẹo: Khi export file, hãy sort theo _doc thay vì sort theo trường cụ thể, tốc độ lấy dữ liệu sẽ nhanh gấp nhiều lần).

Kết quả trả về ngoài 1000 sản phẩm đầu tiên, sẽ có một chuỗi ID cực dài gọi là _scroll_id.

Bước 2: Cuộn dữ liệu (Lấy trang tiếp theo)

Để lấy 1000 kết quả tiếp theo, bạn KHÔNG gọi lại câu Query ban đầu, cũng KHÔNG truyền from. Bạn chỉ cần ném cái _scroll_id vừa nhận được vào API /search/scroll.

JSON

POST _search/scroll
{
  "scroll": "1m", // Gia hạn thêm 1 phút
  "scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4..."
}

Cứ tiếp tục vòng lặp này trong code cho đến khi mảng hits trả về rỗng (tức là đã duyệt hết data). Cuối cùng, bạn phải gọi API DELETE để xóa scroll context, giải phóng RAM cho server.

Cảnh báo từ Elasticsearch 8.x: Mặc dù Scroll API rất mạnh, nhưng tài liệu chính thức của Elastic hiện tại đã không còn khuyến nghị sử dụng nó cho Deep Paging nữa. Lý do là việc duy trì hàng loạt Snapshot trong RAM cực kỳ tốn tài nguyên Cluster. Nếu có 10 user cùng bấm Export file, cụm của bạn vẫn có nguy cơ nghẽn. Giải pháp thay thế chính là search_after.

2. Search After + Point in Time (PIT): Chuẩn mực hiện đại

search_after không lưu trạng thái (Stateless). Ý tưởng của nó giống như một danh sách liên kết: "Cho tôi 20 sản phẩm tiếp theo, nằm ngay sau sản phẩm cuối cùng của trang trước".

Làm sao để biết "sản phẩm cuối cùng" là ai?

Chúng ta dựa vào mảng sort. Yêu cầu bắt buộc của search_after là câu query phải có mảng sort, và mảng sort này phải tuyệt đối duy nhất (Unique) cho mỗi Document để dùng làm điểm neo (Tie-breaker). Thường ta sẽ sort theo 1 trường (ví dụ price) và kẹp thêm trường _id để tránh trùng lặp.

Ví dụ: Bạn lấy Trang 1 và sản phẩm cuối cùng của Trang 1 có giá 250000, ID là SP123. Mảng sort trả về của nó sẽ là [250000, "SP123"].

Để lấy Trang 2, bạn lấy chính cái mảng đó nhét vào search_after:

JSON

GET products/_search
{
  "size": 20,
  "query": { "match": { "category": "Chăm sóc da" } },
  "sort": [
    { "price": "desc" },
    { "_id": "asc" }
  ],
  "search_after": [ 250000, "SP123" ]
}

Nhờ biết được điểm mốc này, Elasticsearch không cần phải sort lại toàn bộ từ đầu, nó nhảy thẳng đến ID SP123 và lấy 20 Document kế tiếp. Vượt qua giới hạn 10.000 vô cùng nhẹ nhàng!

Điểm yếu của search_after thuần và sự xuất hiện của PIT

Vì search_after không lưu Snapshot, nếu trong lúc khách hàng đang lướt từ Trang 1 sang Trang 2, có ai đó Xóa hoặc Thêm một sản phẩm mới làm thay đổi thứ tự sắp xếp, khách hàng có thể sẽ thấy một sản phẩm bị lặp lại 2 lần hoặc bị bỏ sót.

Để giải quyết, Elasticsearch 8.x giới thiệu Point in Time (PIT). PIT tách rời tính năng "Đóng băng trạng thái" ra khỏi API search.

Quy trình chuẩn ES 8.x:

  1. Tạo một PIT (Snapshot nhẹ) và nhận lại pit_id:

    POST products/_pit?keep_alive=1m

  2. Đưa pit_id này vào khối pit trong câu search_after. Không cần truyền tên Index trên URL nữa vì PIT đã gắn sẵn với Index đó.

3. Tổng kết chiến thuật phân trang

Là một Backend Engineer, bạn hãy lưu ngay "bảng cửu chương" phân trang này vào cẩm nang thiết kế hệ thống:

  1. Phân trang UI (Dưới 10.000 kết quả): Dùng from và size. Khách hàng muốn nhảy cóc từ Trang 1 sang Trang 5? Mọi thứ xử lý cực nhanh và đơn giản.

  2. Infinite Scroll trên Mobile App (Cuộn vô tận): Dùng search_after. Không dùng PIT nếu không thực sự cần tính nhất quán tuyệt đối (tiết kiệm tài nguyên).

  3. Export file CSV / Đồng bộ lượng lớn Data (Trên 10.000 kết quả): BẮT BUỘC dùng search_after kết hợp với Point In Time (PIT) để đảm bảo dữ liệu không bị lệch trong lúc tải. (Hoặc dùng Scroll API nếu hệ thống của bạn đang chạy bản Elasticsearch cũ hơn 7.10).

Chúng ta vừa chính thức khép lại Phần 5: Nghệ thuật Tìm kiếm (Query DSL). Ở Phần 6 tiếp theo, chúng ta sẽ chuyển sang một công năng khác biến Elasticsearch thành một công cụ làm Dashboard BI thời gian thực mạnh mẽ không kém gì truy vấn tìm kiếm: Aggregations (Phân tích & Thống kê).


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.