Pipelining, MULTI/EXEC và Lua: khi nào chuỗi lệnh Redis thật sự nguyên tử? Trung cấp
Thời gian đọc ước tính: 8 phút
Vấn đề
Phần tiêu đề “Vấn đề”API flash sale giữ tồn kho SKU 42 trong Redis: stock:{sku42} (số suất còn lại) và buyers:{sku42} (set người đã mua). Mở bán 10 suất, 50 request tới cùng lúc. Code đầu tiên: GET tồn kho, còn thì SET trừ 1 rồi SADD.
Demo bên dưới cho 50 người mua 10 suất. Còn job nạp 10.000 key trước giờ mở bán mất khoảng một giây vì gửi từng lệnh rồi chờ reply.
Hai vấn đề có chung một câu hỏi: gom nhiều lệnh Redis thì được gì: nhanh hơn, chạy liền không bị chen, hay nguyên tử theo điều kiện?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Nhiều lệnh độc lập (nạp key) | Pipeline | Lệnh client khác được xen giữa |
| Nhiều lệnh phải chạy liền, không cần đọc kết quả giữa chừng | MULTI/EXEC |
Lỗi runtime không rollback |
| Kiểm tra rồi ghi, giá trị ghi phụ thuộc giá trị đọc | Lua (EVALSHA hoặc FUNCTION) |
Script ngắn; key khai báo qua KEYS |
| Như trên, logic phải ở app, ít tranh chấp | WATCH + retry |
Giới hạn retry; tranh chấp cao thì đổi sang Lua |
Một lệnh đã đủ (INCR, SET NX) |
Một lệnh | Mỗi lệnh đã nguyên tử |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”| Không bị lệnh khác xen | Điều kiện theo giá trị đọc | Rollback | |
|---|---|---|---|
| Pipeline | Không | Không | Không |
MULTI/EXEC |
Có | Không | Không |
WATCH + MULTI/EXEC |
Có, huỷ nếu key bị đổi | Ở app, kèm retry | Huỷ trước khi chạy |
| Lua / Function | Có | Trên server | Không |
Cả bốn đều giảm round trip (RTT). Nguyên tử ở đây là không client nào chen vào giữa chừng, không phải “lỗi thì hoàn tác”.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Vì sao GET rồi SET bán vượt dù mỗi lệnh Redis đều nguyên tử? Rút gọn còn 1 suất, 2 client:
Còn 1 suất. A gọi GET stock:{sku42}, nhận 1, bắt đầu xử lý trong app.
| Bước | stock |
buyers |
|---|---|---|
A và B GET |
1 | rỗng |
A SET 0, SADD |
0 | user-a |
B SET 0, SADD |
0 | user-a, user-b (bán vượt) |
| Script A (B chờ) | 0 | user-a |
| Script B: đọc 0, không ghi | 0 | user-a |
Khoảng hở nằm giữa hai lệnh, ở phía app: một lost update.
Pipeline: bớt RTT, không bớt khoảng hở
Phần tiêu đề “Pipeline: bớt RTT, không bớt khoảng hở”Đang tải diagram…
Server vẫn chạy từng lệnh như lệnh rời, nên lệnh client khác chen vào giữa được. Lợi ích phụ theo tài liệu Redis: ít lời gọi read()/write() hơn.
MULTI/EXEC: xếp hàng rồi chạy liền
Phần tiêu đề “MULTI/EXEC: xếp hàng rồi chạy liền”MULTI: mỗi lệnh sau đó trảQUEUED, chưa chạy.EXEC: chạy cả hàng đợi liền một mạch, không client nào xen giữa.- Lệnh sai cú pháp lúc xếp hàng:
EXECtrảEXECABORT, không lệnh nào chạy. - Lệnh lỗi runtime (sai kiểu): chỉ lệnh đó lỗi, các lệnh khác vẫn chạy.
Lệnh trong MULTI chưa có kết quả nên không viết được “nếu stock > 0 thì trừ”. Điều kiện phải nằm ở WATCH hoặc Lua.
WATCH: optimistic locking
Phần tiêu đề “WATCH: optimistic locking”Nếu key đã WATCH bị đổi trước EXEC, EXEC trả nil, không lệnh nào chạy; app đọc lại và thử lại. Đây là optimistic locking của Redis.
Lua: logic chạy trên server
Phần tiêu đề “Lua: logic chạy trên server”Script nhận key qua KEYS, tham số qua ARGV, chạy nguyên tử: mọi client khác chờ đến khi script xong (xem Redis event loop).
-- KEYS[1] = stock:{sku42}, KEYS[2] = buyers:{sku42}, ARGV[1] = user idlocal stock = tonumber(redis.call('GET', KEYS[1]) or '0')if stock <= 0 then return 0 endif redis.call('SADD', KEYS[2], ARGV[1]) == 0 then return -1 end -- mua trùngredis.call('DECR', KEYS[1])return 1Script cũng không rollback: lỗi giữa script thì lệnh ghi trước đó vẫn còn (đã thử: SET rồi INCR lỗi, key vẫn giữ x). Kiểm tra mọi điều kiện trước lệnh ghi đầu tiên.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”Theo redis.io, kiểm chứng trên Redis 7.4.9:
SCRIPT LOAD/EVALSHA: gọi script bằng SHA1. Script cache mất khi restart, failover hoặcSCRIPT FLUSH;EVALSHAkhi đó trảNOSCRIPT No matching script. Client phải fallback sangEVAL(ioredisdefineCommandtự làm).FUNCTION LOAD(Redis 7.0+): thư viện được lưu cùng dữ liệu (RDB, AOF) và replicate; gọi bằngFCALL.busy-reply-threshold(tên mới từ Redis 7.0 củalua-time-limit;CONFIG GETcả hai đều trả5000): quá 5 giây, client khác nhậnBUSY. Script vẫn chạy tiếp.- Khai báo key: mọi key script dùng phải qua
KEYS; Cluster dựa vào đó để định tuyến. - Cluster: mọi key trong một
MULTIhay script phải cùng hash slot. Hash tag{sku42}làm Redis chỉ băm phần trong ngoặc. - Pipeline lớn: server giữ reply trong bộ nhớ; nên chia lô (tài liệu ví dụ 10.000 lệnh).
Thử trên một node cluster riêng (Redis 7.4.9):
CLUSTER KEYSLOT stock:sku42 → 7674 CLUSTER KEYSLOT buyers:sku42 → 8214CLUSTER KEYSLOT stock:{sku42} → 6177 CLUSTER KEYSLOT buyers:{sku42} → 6177EVAL ... 2 stock:sku42 buyers:sku42 u1 → CROSSSLOT Keys in request don't hash to the same slotMULTI / DECR stock:sku42 / SADD buyers:sku42 u1 / EXEC → CROSSSLOTVí dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo examples/src/redis-pipelining-lua/demo.ts (ioredis, prefix demo:redis-pipelining:, tự dọn khi xong):
docker compose -f examples/docker-compose.yml up -d redisnpx --prefix examples ts-node examples/src/redis-pipelining-lua/demo.tsOutput thật (Redis 7.4.9 trong Docker Desktop, Node cùng máy macOS arm64, qua 127.0.0.1:6380):
GET/SET từ app: stock=9, buyers=50, bán vượt=40Lua EVALSHA: stock=0, buyers=10, bán vượt=0, trả 1=10, trả 0=40WATCH + MULTI/EXEC: stock=0, buyers=10, bán vượt=0, số lần EXEC trả null (retry)=254Nạp 10000 SET: tuần tự=992 ms, pipeline=20 ms, nhanh hơn x48.9pipeline: 20000 INCR rồi GET trong cùng batch → GET=8236 (kỳ vọng 20000)multi: 20000 INCR rồi GET trong cùng batch → GET=20000 (kỳ vọng 20000)MULTI kết quả từng lệnh: [0] OK [1] lỗi: ERR value is not an integer or out of range [2] OK sau EXEC: a=1, b=2FCALL buy user-1: lần 1=1, lần 2 (mua trùng)=-1, stock=9Đọc output:
- GET/SET: 50 client cùng đọc 10, cùng ghi 9. Tồn kho còn 9, 50 người “đã mua”.
- Timing: ba lần chạy cho 992/1059/1064 ms tuần tự, 16–20 ms pipeline. Đây là localhost qua Docker; RTT mạng thật lớn hơn thì chênh lệch còn lớn hơn.
- Pipeline không nguyên tử: client khác
SET counter 0giữa chừng,GETcuối pipeline ra 8236 (lần khác 9905, 9132). TrongMULTIluôn ra 20000. EXECABORT(thử bằngredis-cli):INCRBYthiếu tham số trongMULTIlàmEXECtrảEXECABORT Transaction discarded because of previous errors.,SETđứng trước không chạy.
Demo thiếu phần production: timeout, idempotency key cho request lặp, ghi đơn bền vững ngoài Redis.
Trade-offs
Phần tiêu đề “Trade-offs”Được
- Pipeline giảm RTT hàng chục lần cho lệnh độc lập
- Lua/Function: kiểm tra-rồi-ghi trong một round trip, không retry
WATCHgiữ logic ở app, không chặn server
Mất
- Không cơ chế nào rollback lỗi runtime giữa chừng
- Lua chậm chặn mọi client; logic chia đôi giữa app và server
WATCHretry tăng vọt khi nhiều client tranh một key
Checklist production
Phần tiêu đề “Checklist production”- Đặt key theo hash tag (
stock:{sku42}) ngay từ đầu, kể cả khi chưa dùng Cluster. - Truyền mọi key qua
KEYS; không ghép tên key trong Lua. - Dùng
FUNCTION LOAD, hoặc client tự fallbackNOSCRIPTsangEVAL. - Giữ script ngắn, theo dõi
SLOWLOG;busy-reply-thresholdchỉ là lưới an toàn. - Script kiểm tra mọi điều kiện trước lệnh ghi đầu tiên.
WATCHcó giới hạn retry và backoff có jitter.- Pipeline lớn chia lô và kiểm tra lỗi từng reply.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Hay gặp nhất: coi MULTI như BEGIN của SQL.
Đọc tồn kho rồi trừ trong MULTI
Hay gặp
const stock = Number(await redis.get(STOCK)); // ngoài transactionif (stock > 0) await redis.multi().decr(STOCK).sadd(BUYERS, user).exec();Nên viết
await redis.evalsha(buySha, 2, STOCK, BUYERS, user); // đọc, kiểm tra, ghi trên serverVì sao
MULTI chỉ giữ DECR và SADD chạy liền. Khoảng hở giữa GET và MULTI vẫn còn.
Bẫy thứ hai nằm ở xử lý lỗi.
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| Pipeline là transaction | Lệnh client khác chen giữa | MULTI hoặc Lua |
| Redis single-threaded nên GET-SET an toàn | Bán vượt | Gộp đọc và ghi vào Lua |
EVALSHA luôn chạy được |
NOSCRIPT sau restart/failover |
Fallback EVAL hoặc dùng Function |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Vì sao
GETcuối pipeline 20.000INCRcó thể nhỏ hơn 20.000, còn trongMULTIthì không? MULTIgồmSET a,INCRtrên"abc",SET b: sauEXECcòna,bkhông? Nếu làINCRBYthiếu tham số?- Vì sao
WATCHcần retry còn script Lua thì không? - Vì sao
stock:sku42vàbuyers:sku42không dùng chung một script trên Cluster?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- Pipelining: RTT, syscall, chia lô.
- Transactions:
EXECABORT, không rollback,WATCH. - Scripting with Lua: nguyên tử,
KEYS,NOSCRIPT. - Functions:
FUNCTION LOAD, persist và replicate. - Cluster specification: hash slot, hash tag.