Bài toán kinh điển: hai client cùng đọc tồn kho thấy "còn 1", cả hai cùng quyết định bán, và bạn vừa bán một món hàng hai lần. Đây là race condition, và nó xuất hiện bất cứ khi nào logic là "đọc một giá trị rồi ghi dựa trên nó". Redis — dù xử lý lệnh một luồng (mỗi lệnh đã nguyên tử, bài 1) — vẫn gặp vấn đề này khi một thao tác nghiệp vụ gồm nhiều lệnh. Redis cho ba công cụ để gộp nhiều lệnh thành một đơn vị nguyên tử: MULTI/EXEC, WATCH, và script Lua. Hiểu khác biệt giữa chúng là biết chọn đúng công cụ. Bài này (phần 4/12) đo thật cả ba trong redis-lab.

Ba công cụ, ba mức mạnh

Ảnh chụp đoạn mã nền tối minh hoạ transaction và Lua làm nhiều lệnh một cách nguyên tử không ai xen vào, MULTI EXEC gộp lệnh thành một khối WATCH chống race kiểu lạc quan Lua chạy cả một đoạn logic nguyên tử phía server. MULTI EXEC xếp hàng lệnh rồi chạy liền một khối MULTI bắt đầu transaction DECRBY tk 30 QUEUED chưa chạy chỉ xếp hàng INCRBY tk 5 QUEUED EXEC chạy cả khối liền nhau không lệnh ngoài xen vào. WATCH khoá lạc quan optimistic lock WATCH bal theo dõi key bal MULTI DECRBY bal 50 nếu bal bị client khác đổi giữa WATCH và EXEC thì EXEC trả nil transaction bị huỷ thử lại không ghi đè nhầm. Lua EVAL nguyên tử cộng có logic điều kiện MULTI không rẽ nhánh được Lua thì có if else đọc rồi quyết định EVAL local s bằng redis.call GET KEYS 1 if s lớn hơn bằng ARGV 1 then redis.call DECRBY end 1 stock 3 chạy trọn script trên luồng đơn nguyên tử không ai xen giữa đọc và ghi

Hình 1: MULTI/EXEC xếp hàng lệnh (QUEUED) rồi chạy cả khối liền; WATCH là khoá lạc quan — EXEC trả nil nếu key bị đổi; Lua (EVAL) chạy trọn một đoạn logic có điều kiện, nguyên tử phía server. MULTI không rẽ nhánh được, Lua thì có.

  • MULTI/EXEC: xếp hàng nhiều lệnh (mỗi lệnh trả QUEUED), rồi EXEC chạy cả khối liền nhau, không lệnh client khác chen vào giữa. Nhưng không rẽ nhánh được — không thể "đọc rồi quyết định".
  • WATCH: khoá lạc quan. WATCH một key trước khi MULTI; nếu key đó bị client khác đổi trước khi EXEC, thì EXEC trả nil (transaction bị huỷ) — client tự thử lại. Chống race mà không khoá bi quan.
  • Script Lua (EVAL): chạy trọn một đoạn script nguyên tử trên luồng đơn của Redis. Khác MULTI, Lua có if/else — đọc một giá trị, quyết định, rồi ghi, tất cả trong một lần, không ai xen giữa đọc và ghi.

Đo thật: ba kịch bản

Ảnh chụp bảng kết quả đo thật nguyên tử với MULTI WATCH và Lua output thật redis:7 redis-cli MULTI EXEC WATCH EVAL. Một MULTI EXEC chạy cả khối tk khởi đầu 100 MULTI DECRBY tk 30 QUEUED INCRBY tk 5 QUEUED GET tk QUEUED EXEC trả 70 75 75 cả ba lệnh chạy liền kết quả trả theo lô các lệnh được xếp hàng QUEUED khi gõ chỉ thực thi khi EXEC và chạy liền nhau không lệnh client khác chen vào giữa. Hai WATCH huỷ transaction khi key bị đổi WATCH bal 100 MULTI DECRBY bal 50 QUEUED client khác SET bal 999 giữa chừng EXEC trả nil transaction bị huỷ bal sau cùng 999 không phải 949 DECRBY đã không chạy bal 999 chứ không phải 949 chứng minh DECRBY bị huỷ WATCH thấy bal đổi nên EXEC trả nil client tự thử lại chống race mà không cần khoá bi quan. Ba Lua trừ tồn kho chỉ khi đủ stock khởi đầu 5 mua 3 còn 5 trả 1 2 thành công còn 2 mua 4 còn 2 trả 0 2 từ chối không đủ giữ nguyên 2 mua 2 còn 2 trả 1 0 thành công còn 0 toàn bộ đọc tồn kho kiểm đủ trừ chạy nguyên tử trong một EVAL không hai client nào cùng mua được quá số hàng MULTI không làm được vì không rẽ nhánh theo giá trị đọc

Hình 2: Đo thật. (1) MULTI/EXEC: DECRBY+INCRBY+GET trả 70/75/75 chạy liền. (2) WATCH: client khác SET bal 999 giữa chừng → EXEC nil, bal=999 (không phải 949) chứng minh DECRBY bị huỷ. (3) Lua trừ tồn kho: mua 3 (còn 5) OK còn 2; mua 4 (còn 2) từ chối; mua 2 OK còn 0.

Kết quả thật:

  • ① MULTI/EXEC (tk khởi đầu 100): các lệnh DECRBY 30, INCRBY 5, GET được QUEUED khi gõ, chỉ thực thi khi EXEC — trả về 70, 75, 75 theo lô. Cả ba chạy liền nhau như một khối.
  • ② WATCH (bal khởi đầu 100): WATCH bal, MULTI, DECRBY bal 50 (QUEUED). Giữa chừng một client khác SET bal 999. Khi EXEC → trả nil, transaction bị huỷ. Bằng chứng: bal sau cùng = 999, không phải 949 — nếu DECRBY đã chạy thì phải là 999−50. WATCH phát hiện bal bị đổi nên huỷ transaction, buộc client thử lại. Đây là chống race không cần khoá bi quan (không chặn client khác).
  • ③ Lua trừ tồn kho (stock khởi đầu 5): script "đọc tồn kho → nếu đủ thì trừ → trả kết quả" chạy nguyên tử. Mua 3 (còn 5) → 1, 2 (thành công, còn 2); mua 4 (còn 2) → 0, 2 (từ chối, không đủ, giữ nguyên 2); mua 2 (còn 2) → 1, 0 (thành công, còn 0). Không bao giờ bán quá số hàng, dù nhiều client gọi đồng thời — vì toàn bộ đọc-kiểm-trừ nằm trong một EVAL không ai xen được. MULTI không làm được điều này vì nó không rẽ nhánh theo giá trị đọc.

Vì sao Lua nguyên tử, và khi nào chọn gì

Lua nguyên tử nhờ đúng cơ chế đã học ở bài 1: Redis xử lý một luồng, và nó chạy trọn một script EVAL trước khi phục vụ lệnh tiếp theo. Không một lệnh nào của client khác chen vào giữa các bước của script — nên "đọc rồi ghi có điều kiện" an toàn tuyệt đối.

Chọn công cụ: MULTI/EXEC cho khi chỉ cần gộp vài lệnh không phụ thuộc giá trị thành một khối (ví dụ ghi vào hai key cùng lúc). WATCH cho check-and-set đơn giản với retry (ít tranh chấp). Lua cho logic có điều kiện phức tạp cần nguyên tử (trừ tồn kho, rate limit, lock — các bài sau dùng Lua liên tục). Lua là công cụ mạnh nhất và linh hoạt nhất.

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

Lua chạy trên luồng đơn — script chậm chặn cả server. Vì EVAL chạy trọn vẹn trước lệnh kế, một script dài hoặc lặp nhiều (duyệt hàng nghìn phần tử, gọi nhiều lệnh) sẽ chặn toàn bộ Redis trong suốt thời gian đó — mọi client khác chờ. Giữ script Lua ngắn và nhanh: chỉ vài lệnh, không vòng lặp lớn, không gọi lệnh O(n) nặng. Lua là để nguyên tử, không phải để chuyển nguyên business logic vào Redis.

WATCH chỉ đáng khi tranh chấp thấp. Khoá lạc quan giả định hiếm khi có xung đột — khi xung đột xảy ra, transaction huỷ và client thử lại. Nếu tranh chấp cao (nhiều client liên tục đụng cùng key), WATCH gây retry dồn dập, phí công. Lúc đó Lua (làm luôn trong một lần, không retry) thường tốt hơn. Chọn WATCH khi xung đột là ngoại lệ, không phải thường lệ.

Transaction Redis KHÔNG rollback như SQL. Một điểm hay gây sốc: nếu một lệnh trong MULTI/EXEC lỗi lúc chạy (ví dụ sai kiểu dữ liệu), các lệnh khác trong khối vẫn chạy — Redis không rollback. MULTI chỉ đảm bảo "chạy liền một khối không ai xen vào", không đảm bảo "tất cả hoặc không gì". Lỗi cú pháp (phát hiện lúc QUEUE) thì huỷ cả khối, nhưng lỗi lúc thực thi thì không. Đừng giả định ngữ nghĩa ACID đầy đủ của database.

Ba ý mang về

  1. Ba công cụ cho nguyên tử nhiều lệnh. Đo thật: MULTI/EXEC chạy khối DECRBY+INCRBY+GET liền (70/75/75); WATCH huỷ transaction khi bal bị đổi (bal=999 không phải 949); Lua trừ tồn kho có điều kiện an toàn. Mỗi cái một mức mạnh.
  2. Lua mạnh nhất vì có logic điều kiện, chạy nguyên tử trên luồng đơn. Đo thật: mua 4 khi còn 2 bị từ chối, không bán quá hàng dù nhiều client. MULTI không làm được vì không rẽ nhánh theo giá trị đọc — đây là lý do Lua là nền cho lock/rate-limit ở các bài sau.
  3. Mỗi công cụ có cái giá. Lua chạy dài chặn cả server (giữ script ngắn); WATCH chỉ hợp tranh chấp thấp (cao thì retry dồn); và MULTI không rollback khi lệnh lỗi lúc chạy — không phải ACID đầy đủ như SQL.

Nguồn

Phần sau ta tìm hiểu Redis quản lý vòng đời key: TTL cho key tự hết hạn, và eviction — khi bộ nhớ đầy, Redis chọn key nào để đẩy ra theo chính sách nào.