Từ "giao dịch" mang theo một kỳ vọng rất mạnh từ thế giới cơ sở dữ liệu quan hệ: tất cả cùng thành công, hoặc không gì cả — có một cái nút "huỷ toàn bộ" nếu giữa chừng hỏng. Redis mượn đúng cái từ ấy nhưng chỉ giữ một nửa lời hứa, và nửa bị bỏ đi thường làm người ta ngã ngửa. Hãy nghĩ về MULTI không phải như một bản hợp đồng "cùng ký hoặc xé", mà như việc đóng cửa phòng bếp lại làm một loạt việc không ai chen ngang — và nếu bước thứ ba làm hỏng, bạn không hoàn lại quả trứng đã đập, bạn chỉ ghi chú "bước ba lỗi" rồi làm tiếp bước bốn. Bài này đo chính xác Redis bảo đảm gì, và cái nó không bảo đảm.

Redis có MULTI/EXEC và gọi nó là giao dịch. Ta đo xem nó thật sự hứa gì.

Hai loại lỗi, hai kết cục

Lỗi cú pháp, phát hiện lúc xếp hàng:

MULTI
SET a 1               QUEUED
LENH-KHONG-CO b 2     ERR unknown command
SET c 3               QUEUED
EXEC                  EXECABORT Transaction discarded

a rỗng, c rỗng

Cả khối bị huỷ. Đây là hành vi người ta trông đợi ở một giao dịch.

Lỗi lúc chạy, chỉ lộ ra khi EXEC:

MULTI
SET x 1               QUEUED
INCR chuoi            QUEUED      (chuoi = "khong-phai-so")
SET y 2               QUEUED
EXEC                  OK / ERR / OK

x = 1, y = 2

INCR thất bại, nhưng SET x và SET y vẫn chạy và vẫn giữ kết quả. Không có quay lui — bạn không hoàn lại quả trứng đã đập.

Redis không quay lui, và đó là chủ ý

"Giao dịch" của Redis không có chữ A trong ACID theo nghĩa quen thuộc từ cơ sở dữ liệu quan hệ. Nó bảo đảm cách ly: từ EXEC tới lệnh cuối cùng, không khách hàng nào chen vào được. Đó là bảo đảm thật và hữu ích. Nó không bảo đảm tất-cả-hoặc-không-gì. Một lệnh hỏng thì báo lỗi, phần còn lại chạy tiếp.

Lý do thiết kế: lỗi lúc chạy trong Redis gần như luôn là lỗi lập trình — gọi INCR trên một chuỗi, gọi LPUSH trên một Hash. Kiểu dữ liệu sai không phải chuyện xảy ra ngẫu nhiên; nó có nghĩa là mã của bạn sai. Dựng cơ chế quay lui để đỡ cho một lỗi lập trình sẽ làm mọi giao dịch chậm đi, và Redis chọn không trả giá đó.

Nên hãy kiểm kiểu dữ liệu trước, đừng dựa vào giao dịch để cứu, và đừng viết mã giả định rằng EXEC trả về lỗi nghĩa là không có gì thay đổi.

WATCH: khoá lạc quan

WATCH là phần thực sự mạnh của giao dịch Redis, và nó hay bị bỏ qua.

A: WATCH dem        +OK
A: GET dem          100
A: MULTI            +OK
A: SET dem 200      +QUEUED
B: SET dem 999      +OK          <- khách khác chen vào
A: EXEC             *-1          <- nil, giao dịch bị bỏ
giá trị cuối cùng   999

EXEC trả về nil, không phải lỗi. Toàn bộ khối không chạy, và thay đổi của B được giữ. Đây là mẫu đọc-sửa-ghi an toàn:

while True:
    p = r.pipeline()
    p.watch("dem")
    v = int(p.get("dem"))
    p.multi()
    p.set("dem", v * 2)
    try:
        p.execute()
        break
    except WatchError:
        continue          # có người sửa, thử lại

Hai điều đáng nhớ:

  • EXEC trả nil là kết quả bình thường, không phải sự cố. Mã của bạn phải có vòng lặp thử lại.
  • Dưới tranh chấp cao, vòng lặp đó có thể quay rất nhiều lần. Nếu thao tác diễn đạt được bằng một lệnh nguyên tử sẵn có — INCR, INCRBY, SETNX, GETSET — hãy dùng nó thay vì WATCH.

WATCH bị huỷ sau EXEC hoặc DISCARD, và cũng bị huỷ khi kết nối đóng. Không cần UNWATCH trong đường đi bình thường.

MULTI không tốn gì thêm

đường ống thuần   499.235 ops/s
MULTI/EXEC        498.302 ops/s
Lua               616.017 ops/s

MULTI/EXEC chạy nhanh bằng một đường ống thuần cùng số lệnh — chênh lệch 0,2%, nằm trong nhiễu. Điều này có nghĩa: nếu bạn đã gửi lệnh theo lô, việc bọc chúng trong MULTI gần như miễn phí. Không có lý do hiệu năng để tránh giao dịch.

Lua nhanh hơn 23% vì nó gửi một lệnh EVAL thay vì 100 khối RESP riêng — máy chủ phân tích một lần thay vì một trăm lần. Đó là cùng lý do MGET thắng đường ống ở phần trước.

Đường ống, MULTI, hay Lua

Cần gì Dùng gì
Nhiều lệnh, không cần nguyên tử Đường ống
Nhiều lệnh, không ai được chen vào MULTI/EXEC
Đọc rồi ghi dựa trên giá trị đọc được WATCH + MULTI, hoặc Lua
Logic có rẽ nhánh Lua

Ranh giới giữa WATCH và Lua: WATCH lạc quan — thử, phát hiện xung đột, làm lại. Lua bi quan — chặn mọi người trong lúc chạy. Với tranh chấp thấp WATCH tốt hơn; với tranh chấp cao Lua tránh được vòng lặp thử lại vô tận. Nhưng Lua phải trả bằng thời gian chặn, và phần sau của sê-ri đo đúng khoản đó.

Ba điều dễ nhầm

MULTI không bảo đảm độ bền. Giao dịch chạy xong rồi Redis tắt đột ngột thì kết quả vẫn có thể mất — mức độ tuỳ cấu hình lưu trữ, sê-ri sẽ đo ở phần sau. EXEC trả về OK chỉ nghĩa là đã áp vào bộ nhớ.

DISCARD chỉ huỷ hàng đợi, không huỷ gì đã chạy. Nó dùng trước EXEC; sau EXEC thì không có gì để huỷ.

Lệnh trong hàng đợi không trả về giá trị. Bạn nhận QUEUED, còn kết quả thật chỉ có sau EXEC, dưới dạng một mảng. Nghĩa là không thể dùng kết quả lệnh trước làm tham số cho lệnh sau trong cùng giao dịch — đó là lúc phải dùng WATCH hoặc Lua.

Mẫu số chung: khoá lạc quan so với khoá bi quan

Cái làm WATCH mạnh là một trong hai triết lý điều khiển đồng thời mà mọi hệ thống có trạng thái chia sẻ đều phải chọn giữa. Lạc quan (optimistic): giả định xung đột hiếm, cứ đọc và tính, rồi lúc ghi mới kiểm "giá trị có bị ai đổi từ lúc tôi đọc không" — nếu có, bỏ và thử lại; đó chính là WATCH + vòng lặp retry. Bi quan (pessimistic): giả định xung đột hay xảy ra, giữ khoá độc quyền suốt thời gian thao tác để không ai chen vào — đó là Lua, và là SELECT ... FOR UPDATE. Lạc quan thắng khi tranh chấp thấp (không ai phải chờ ai), bi quan thắng khi tranh chấp cao (tránh vòng lặp thử lại đốt công vô tận).

Trong thế giới Java, đây đúng là @Version của JPA: mỗi bản ghi có một cột phiên bản, cập nhật kèm WHERE version = ?, và nếu số dòng đổi bằng 0 thì ném OptimisticLockException để bạn thử lại — cùng ngữ nghĩa EXEC trả nil. Đối lại là LockModeType.PESSIMISTIC_WRITE / SELECT FOR UPDATE. Ở mức nhỏ hơn, AtomicInteger.compareAndSet trong một vòng lặp là đúng cùng mẫu CAS-rồi-thử-lại.

Trong Go, sync/atomic.CompareAndSwapInt64 trong vòng for là khoá lạc quan thuần tuý: đọc giá trị, tính giá trị mới, hoán đổi chỉ khi nó chưa đổi, thất bại thì lặp — đúng cái vòng WATCH ở trên.

Còn ở cơ sở dữ liệu, đây là lằn ranh nền tảng: UPDATE ... WHERE version = ? (lạc quan) so với SELECT FOR UPDATE (bi quan), và ở mức cô lập SERIALIZABLE là việc bắt serialization_failure rồi chạy lại giao dịch — chính là vòng lặp retry mà mọi hệ lạc quan đòi hỏi.

Mẫu số chung: điều khiển đồng thời lạc quan (kiểm-lúc-ghi rồi thử lại) rẻ và song song hoá tốt khi xung đột hiếm, còn bi quan (giữ khoá suốt thao tác) đúng khi xung đột thường — nên chọn theo mức tranh chấp thật, và mã lạc quan bắt buộc phải có vòng lặp thử lại vì "bị bỏ vì có người chen vào" là kết quả bình thường, không phải lỗi. Câu hỏi đúng không phải "cái nào an toàn hơn" (cả hai đều đúng) mà là "khoá này bị tranh giành thường xuyên tới đâu" — vì đó mới là thứ quyết định retry-nhiều hay chờ-nhiều. Và nếu thao tác gói được vào một lệnh nguyên tử đơn (INCR), đừng dùng cả hai.

Muốn tự xem hệ thống của bạn có đang dùng giao dịch và có bị huỷ nhiều không:

redis-cli info commandstats | grep -E 'cmdstat_(multi|exec|watch|discard):'

So calls của exec với calls của multi: chênh nhiều nghĩa là nhiều giao dịch bị bỏ dở — thường do WATCH phát hiện xung đột, hoặc do mã lỗi giữa chừng và không gọi EXEC. Và nếu watch có số gọi lớn mà exec nhỏ, bạn đang ở tình huống tranh chấp cao và vòng lặp thử lại đang đốt thời gian. Đó là lúc nên đổi sang một lệnh nguyên tử sẵn có, hoặc sang Lua.

Phần sau: Lua script — đo tính nguyên tử của nó, và cái giá của việc chặn cả máy chủ.