Redis có MULTI/EXEC và gọi nó là giao dịch. Bài này đo chính xác nó bảo đảm gì — và cái nó không bảo đảm khiến nhiều người bất ngờ.

Hai loại lỗi với hai kết cục, cơ chế WATCH, và chi phí hiệu năng

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 xSET y vẫn chạy và vẫn giữ kết quả. Không có quay lui.

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.

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á đó.

Hệ quả thực hành: 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.

Thử ba mươi giây

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ủ.