Elasticsearch Bài học 11: Update API - Partial Update và Cơ chế khóa lạc quan (Optimistic Concurrency Control)
ở các bài trước chúng ta đã mổ xẻ Inverted Index và hiểu rằng Document trong Elasticsearch là bất biến (Immutable). Vậy khi một khách hàng mua "Sữa rửa mặt Cetaphil" trên Hasaki và hệ thống cần trừ tồn kho từ 150 xuống 149, Elasticsearch làm điều đó như thế nào nếu dữ liệu không thể sửa?
Bài học này sẽ đi sâu vào 2 cơ chế cập nhật cực kỳ quan trọng: Partial Update (Cập nhật một phần để tiết kiệm băng thông) và Optimistic Concurrency Control (OCC) (Khóa lạc quan để tránh xung đột khi có hàng nghìn luồng thanh toán đổ về cùng lúc).
1. Partial Update: Bản chất của sự "cập nhật"
Khi bạn gọi API POST /products/_update/1, bạn truyền lên một payload nhỏ gọn:
JSON
{
"doc": {
"stock": 149
}
}
Dù bạn chỉ gửi lên trường "stock", nhưng vì Inverted Index là bất biến, Elasticsearch KHÔNG chạy thẳng vào ổ cứng và đè số 150 thành 149. Thay vào đó, nó thực hiện một tiến trình 3 bước (Retrieve - Change - Reindex) ở background:
-
Retrieve (Lấy gốc): Nó lôi toàn bộ Document JSON gốc (
_source) từ Shard lên RAM. -
Change (Gộp chung): Nó lấy giá trị
"stock": 149của bạn ghi đè vào Document gốc trên RAM, tạo ra một JSON hoàn chỉnh mới. -
Reindex (Đánh chỉ mục lại): Nó đánh dấu Document cũ là "Đã xóa" (vào file
.del), và đưa Document mới vào luồng In-memory Buffer để tạo ra một Segment mới toanh (như đã học ở Bài 9).
Lợi ích của Partial Update: Nó giảm thiểu băng thông mạng (Network Round-trip). Backend của bạn không cần tự mình thực hiện vòng lặp GET dữ liệu về -> sửa bằng code PHP/Golang -> rồi PUT cục dữ liệu to đùng lên lại. Elasticsearch làm việc đó cục bộ tại Data Node, nhanh hơn rất nhiều.
2. Thảm họa Xung đột đa luồng (Concurrency Conflict)
Bây giờ, hãy tưởng tượng kho Hasaki chỉ còn đúng 1 chai sữa rửa mặt. Lúc 12:00:00 mùa sale, có hai luồng thanh toán diễn ra đồng thời:
-
Luồng A (Khách mua 1 chai): Đọc được kho còn 1. Bắt đầu tính toán trừ còn 0.
-
Luồng B (Khách mua 1 chai): Cũng đọc được kho còn 1 (vì Luồng A chưa kịp ghi). Cũng tính toán trừ còn 0.
Nếu hệ thống không có khóa bảo vệ:
-
Luồng A ghi
stock = 0xuống Elasticsearch. -
Vài mili-giây sau, Luồng B cũng ghi
stock = 0đè lên.
Kết quả: Hệ thống báo kho còn 0, nhưng Hasaki vừa bán thành công 2 đơn hàng trong khi thực tế chỉ có 1 chai. Đây gọi là lỗi Over-selling, ác mộng của mọi hệ thống thương mại điện tử.
3. Khóa Bi quan (Pessimistic Locking) vs Khóa Lạc quan (Optimistic Locking)
Để giải quyết bài toán trên, MySQL thường dùng Pessimistic Locking (Khóa bi quan) qua câu lệnh SELECT ... FOR UPDATE. Khi Luồng A chạm vào dòng dữ liệu, MySQL sẽ "khóa chặt" dòng đó lại. Luồng B đến sau phải đứng chờ (Block) cho đến khi Luồng A giải quyết xong.
- Nhược điểm: Khóa bi quan làm giảm thảm hại tốc độ (Throughput) của hệ thống. Nó không phù hợp với triết lý phân tán và truy xuất siêu tốc của Elasticsearch.
Vì vậy, Elasticsearch sử dụng Optimistic Concurrency Control - OCC (Khóa lạc quan). Tư tưởng của OCC là: "Cứ cho mọi người đọc ghi thoải mái, không khóa ai cả. Nhưng mỗi Document sẽ được gắn một số Version (Phiên bản). Nếu lúc bạn ghi xuống mà phát hiện có người khác đã nhanh tay tăng số Version lên rồi, thì lệnh ghi của bạn sẽ bị từ chối."
4. Cách OCC hoạt động trong Elasticsearch (Sử dụng Sequence Number)
Từ phiên bản Elasticsearch 6.7 trở lên, hệ thống sử dụng kết hợp 2 tham số: _seq_no (Số thứ tự của operation) và _primary_term (Nhiệm kỳ của Primary Shard, dùng để xử lý khi đổi Master).
Mỗi khi Document bị thay đổi (kể cả Update hay Delete), _seq_no sẽ tăng lên 1.
Quay lại bài toán Over-selling:
-
Luồng A gọi
GET /products/_doc/1. Trả về kho còn1chai,_seq_no = 15. -
Luồng B gọi
GET /products/_doc/1. Cũng trả về kho còn1chai,_seq_no = 15.
Bây giờ, Luồng A muốn trừ tồn kho. Thay vì update mù quáng, API Backend phải truyền kèm _seq_no và _primary_term mà nó vừa đọc được:
JSON
PUT products/_doc/1?if_seq_no=15&if_primary_term=1
{
"stock": 0
}
-
Elasticsearch kiểm tra: "À, Luồng A muốn sửa khi
_seq_nođang là 15. Khớp! Cho phép sửa." Dữ liệu cập nhật thành0, và_seq_notự động tăng lên 16. -
Ngay sau đó, Luồng B cũng muốn sửa và gửi payload:
JSON
PUT products/_doc/1?if_seq_no=15&if_primary_term=1
{
"stock": 0
}
-
Elasticsearch từ chối thẳng thừng và quăng lỗi
409 Conflict: "Bạn không thể sửa. Dữ liệu đã bị người khác thay đổi (Version hiện tại là 16, không phải 15 nữa)." -
Luồng B bị thất bại. Nó buộc phải bắt đầu lại chu kỳ:
GETlại để lấy thông tin mới -> Thấy kho đã bằng0-> Báo lỗi cho khách hàng thứ hai "Hết hàng".
5. Xử lý "Retry on Conflict" ở Backend
Trong thực tế, khi gặp lỗi 409 Conflict, không phải lúc nào chúng ta cũng quăng lỗi vào mặt user. Với các thay đổi độc lập (ví dụ: Luồng A tăng view sản phẩm, Luồng B đổi tên sản phẩm), bạn có thể bảo Elasticsearch tự động thử lại (Retry) thay vì bắt API backend tự viết vòng lặp.
Dùng tham số retry_on_conflict:
JSON
POST products/_update/1?retry_on_conflict=3
{
"doc": {
"stock": 149
}
}
Lệnh này có nghĩa là: Nếu gặp Conflict, Elasticsearch sẽ tự động làm lại chu kỳ "Lấy bản mới nhất -> Gộp -> Ghi" tối đa 3 lần ở dưới background trước khi thực sự trả về lỗi 409 cho ứng dụng của bạn. Điều này giúp code backend nhàn hơn rất nhiều.
All Rights Reserved