Bỏ qua để đến nội dung
Knowledge Hub

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

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?

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ô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”.

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:

GET rồi SET từ app bán 1 suất cho 2 người; Lua đọc và trừ liền một mạch nên không vượt
GET rồi SET từ app bán 1 suất cho 2 người; Lua đọc và trừ liền một mạch nên không vượtBước 1/7: Còn 1 suất. A gọi GET stock:{sku42}, nhận 1, bắt đầu xử lý trong app.thời gianA tin stock = 1 …GET stock→ 1Client Auser-a muaRedisstock:{sku42}Client Buser-b mua
Bước 1/7

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.

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

  1. MULTI: mỗi lệnh sau đó trả QUEUED, chưa chạy.
  2. EXEC: chạy cả hàng đợi liền một mạch, không client nào xen giữa.
  3. Lệnh sai cú pháp lúc xếp hàng: EXEC trả EXECABORT, không lệnh nào chạy.
  4. 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.

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.

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 id
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then return 0 end
if redis.call('SADD', KEYS[2], ARGV[1]) == 0 then return -1 end -- mua trùng
redis.call('DECR', KEYS[1])
return 1

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

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ặc SCRIPT FLUSH; EVALSHA khi đó trả NOSCRIPT No matching script. Client phải fallback sang EVAL (ioredis defineCommand tự 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ằng FCALL.
  • busy-reply-threshold (tên mới từ Redis 7.0 của lua-time-limit; CONFIG GET cả hai đều trả 5000): quá 5 giây, client khác nhận BUSY. 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 MULTI hay 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 → 8214
CLUSTER KEYSLOT stock:{sku42} → 6177 CLUSTER KEYSLOT buyers:{sku42} → 6177
EVAL ... 2 stock:sku42 buyers:sku42 u1 → CROSSSLOT Keys in request don't hash to the same slot
MULTI / DECR stock:sku42 / SADD buyers:sku42 u1 / EXEC → CROSSSLOT

Demo examples/src/redis-pipelining-lua/demo.ts (ioredis, prefix demo:redis-pipelining:, tự dọn khi xong):

Terminal window
docker compose -f examples/docker-compose.yml up -d redis
npx --prefix examples ts-node examples/src/redis-pipelining-lua/demo.ts

Output 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=40
Lua EVALSHA: stock=0, buyers=10, bán vượt=0, trả 1=10, trả 0=40
WATCH + MULTI/EXEC: stock=0, buyers=10, bán vượt=0, số lần EXEC trả null (retry)=254
Nạp 10000 SET: tuần tự=992 ms, pipeline=20 ms, nhanh hơn x48.9
pipeline: 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=2
FCALL 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 0 giữa chừng, GET cuối pipeline ra 8236 (lần khác 9905, 9132). Trong MULTI luôn ra 20000.
  • EXECABORT (thử bằng redis-cli): INCRBY thiếu tham số trong MULTI làm EXEC trả 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.

Đượ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
  • WATCH giữ 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
  • WATCH retry tăng vọt khi nhiều client tranh một key
  1. Đặt key theo hash tag (stock:{sku42}) ngay từ đầu, kể cả khi chưa dùng Cluster.
  2. Truyền mọi key qua KEYS; không ghép tên key trong Lua.
  3. Dùng FUNCTION LOAD, hoặc client tự fallback NOSCRIPT sang EVAL.
  4. Giữ script ngắn, theo dõi SLOWLOG; busy-reply-threshold chỉ là lưới an toàn.
  5. Script kiểm tra mọi điều kiện trước lệnh ghi đầu tiên.
  6. WATCH có giới hạn retry và backoff có jitter.
  7. Pipeline lớn chia lô và kiểm tra lỗi từng reply.

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 transaction
if (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 server

Vì 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
  1. Vì sao GET cuối pipeline 20.000 INCR có thể nhỏ hơn 20.000, còn trong MULTI thì không?
  2. MULTI gồm SET a, INCR trên "abc", SET b: sau EXEC còn a, b không? Nếu là INCRBY thiếu tham số?
  3. Vì sao WATCH cần retry còn script Lua thì không?
  4. Vì sao stock:sku42 và buyers:sku42 không dùng chung một script trên Cluster?