Redis — những thứ cần biết
Trong một app Laravel, Redis thường gánh 5 việc cùng lúc: cache, session, lock, queue, và idempotency key. Interviewer hỏi Redis ít khi để nghe "Redis là in-memory key-value store". Họ muốn biết bạn có hiểu nó gãy ở đâu không: lệnh nào làm đứng cả server, lúc nào mất dữ liệu, vì sao lock không an toàn tuyệt đối.
Bài này gom những thứ đó, mỗi thứ có số đo thật. Phần cache (stampede, hot key, bloom filter…) nằm ở bài Cache nâng cao. Phần eviction làm mất idempotency key nằm ở bài Idempotency key ở Redis.
Thuật ngữ dùng trong bài
| Từ | Nghĩa |
|---|---|
| event loop | Vòng lặp duy nhất của Redis: nhận lệnh, chạy từng lệnh một, trả kết quả |
io-threads | Luồng phụ chỉ để đọc/ghi socket. Việc chạy lệnh vẫn chỉ có một luồng |
| encoding (listpack / hashtable) | Cách Redis cất một kiểu dữ liệu trong RAM. Nhỏ thì dùng listpack (gọn), lớn thì đổi sang hashtable (nhanh, tốn RAM) |
| bucket | Nhóm cố định để gom nhiều object nhỏ vào một key, ví dụ progress:{id / 500} |
| lazy / active expiration | Hai cách Redis xoá key hết hạn: khi có người đọc tới (lazy), và tự quét mẫu định kỳ (active) |
| eviction | Redis đầy maxmemory thì tự xoá bớt key theo maxmemory-policy |
| RDB / AOF | Hai cách ghi xuống đĩa: chụp ảnh toàn bộ theo chu kỳ (RDB) / ghi nối từng lệnh (AOF) |
| fork / copy-on-write | Để chụp RDB, Redis tạo process con. Hai process dùng chung RAM, trang nào bị ghi thì mới copy ra |
| replication async | Master trả OK cho client trước khi replica nhận được lệnh |
WAIT | Lệnh chờ tới khi N replica xác nhận đã nhận các lệnh ghi của connection này |
| Sentinel | Tiến trình theo dõi master, tự bầu replica lên khi master chết. Không chia dữ liệu |
| Cluster / hash slot | Chia dữ liệu ra nhiều master theo 16384 slot. Mỗi key thuộc một slot |
| hash tag | Phần trong {} của tên key. Cluster chỉ băm phần này, nên các key cùng tag nằm cùng slot |
| pipeline | Gửi nhiều lệnh một lần để bớt round-trip. Không atomic |
MULTI/EXEC | Gom lệnh chạy liền một mạch, không ai chen vào. Không rollback |
| fencing token | Số tăng dần cấp kèm lock. Nơi nhận ghi từ chối token cũ hơn token đã thấy |
Môi trường kiểm chứng. Mọi output chạy thật trên Redis 8.8.0 (container tạm
tmp-redis-labvàtmp-redis-replica, dựng riêng rồi xoá, không đụngchiennn_redis). Lab HA: Cluster 6 node (3 master + 3 replica,cluster-node-timeout 5000) và Sentinel (1 master + 1 replica + 3 sentinel, quorum 2,down-after-milliseconds 3000). Laravel chạy trong imagelaravel-app(phpredis 6.3.0), predis 3.6.0 cài tạm, source Laravel 12.64.0. Tài liệu Redis tra qua Context7 (/redis/docs). Thời gian đo phía client có cộng ~100 ms củadocker exec, nên số nào quan trọng đều lấy từSLOWLOG(đo phía server). Chỗ nào chưa verify được sẽ ghi rõ.
1. Nó là gì / chạy thế nào
1.1. Một luồng chạy lệnh — điều quan trọng nhất
flowchart LR C1["Client 1"] --> Q["Hàng đợi lệnh"] C2["Client 2"] --> Q C3["Client 3 ... 5000"] --> Q Q --> EL["Event loop
1 luồng, chạy từng lệnh một"] EL --> M["RAM"] IO["io-threads
chỉ đọc/ghi socket"] -.-> Q
Mỗi lệnh chạy một mình, nên mỗi lệnh đều atomic mà không cần lock. Đó là lý do Redis nhanh và đơn
giản. Mặt trái: một lệnh chậm làm tất cả client phải đợi. io-threads mặc định
là 1 trên 8.8 (CONFIG GET io-threads). Kể cả khi bật thêm, nó chỉ giúp phần mạng,
không giúp phần chạy lệnh.
Đo thật những lệnh hay làm Redis đứng im:
| Lệnh | Redis đứng im (đo phía server) | Ghi chú |
|---|---|---|
KEYS nomatch* trên 1 triệu key | 177 ms | Không khớp key nào vẫn phải quét hết |
DEL một hash 1 triệu field (44 MB) | 256 ms | lazyfree-lazy-user-del no là mặc định trên 8.8 |
UNLINK cùng loại hash | < 1 ms | Gỡ key ngay, giải phóng RAM ở luồng nền |
DEL khi đã bật lazyfree-lazy-user-del yes | < 1 ms | DEL tự chạy như UNLINK |
| Lua script chạy vòng lặp 7 giây | 7 giây | Xem ngay dưới |
"< 1 ms" nghĩa là lệnh không vào SLOWLOG khi đặt ngưỡng slowlog-log-slower-than 1000
(micro giây).
$ redis-cli SLOWLOG GET
177144 KEYS nomatch* # micro giay
256186 DEL tmp:big1
Lua script chạy lâu còn tệ hơn:
$ redis-cli CONFIG GET busy-reply-threshold
busy-reply-threshold 5000
# client 1 chay script 7 giay. Giay thu 1, client 2 goi:
$ redis-cli PING
BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
-> cho 4117 ms # treo khong tra loi toi moc 5 giay, roi moi nhan loi BUSY
Trong 5 giây đầu, client treo không nhận được gì. Sau đó mọi lệnh đều nhận lỗi
BUSY. Với app Laravel, 5 giây đó là toàn bộ PHP-FPM worker đứng đợi.
Thay thế an toàn:
SCANthayKEYS(quét 1 triệu key bằng--scan --count 1000mất ~1000 lệnh nhỏ, mỗi lệnh ngắn, client khác chen vào được giữa các lệnh).UNLINKthayDELcho key lớn. Lua chỉ làm vài thao tác ngắn, không bao giờ vòng lặp trên dữ liệu lớn.
1.2. Kiểu dữ liệu — chọn đúng kiểu là một nửa thiết kế
Đọc sâu hơn: series Redis — kiểu dữ liệu (7 bài) đi vào từng kiểu: encoding và RAM mỗi phần tử, lệnh mới của Redis 8.0–8.8 (Array,INCREX,SET … IFEQ, TTL từng field, IDMP cho stream…), bẫy khi nhiều worker cùng ghi, khi failover, và các kiểu mới (JSON, Bloom, TimeSeries, vector set). Mục dưới đây là bản tóm tắt.
| Kiểu | Dùng cho (ví dụ LMS) | Chú ý |
|---|---|---|
| String | Cache, counter (INCR), lock, rate limit | Tối đa 512 MB — nhưng vài trăm KB đã là big key |
| Hash | Object nhiều field: tiến độ học của 1 sinh viên. Hàng triệu object nhỏ thì gom theo bucket | Nhỏ thì cực gọn (listpack, xem dưới). Mỗi key có phí RAM cố định |
| List | Hàng đợi đơn giản. Laravel Redis queue dùng list queues:default | Lấy ra rồi process chết = mất việc, nếu không có cơ chế reserved |
| Set | Sinh viên đang online trong phòng thi | SMEMBERS trên set lớn là lệnh chậm |
| Sorted Set | Bảng xếp hạng điểm, job trễ (Laravel dùng cho :delayed, :reserved) | Rất mạnh cho "top N", "trong khoảng thời gian" |
| Stream | Log sự kiện, hàng đợi có consumer group và xác nhận (XACK) | Thay Pub/Sub khi không được mất tin |
| HyperLogLog | Đếm số người xem khác nhau (sai ~1%, 12 KB mỗi key) | Chỉ đếm, không liệt kê được |
| Bitmap | Điểm danh theo ngày: bit thứ N = sinh viên id N | Id thưa thì tốn RAM |
| Bloom / Cuckoo filter | Chống cache penetration | Redis 8 có sẵn (MODULE LIST còn thấy search, timeseries, vectorset) |
Encoding: cùng một hash, RAM chênh gần 6 lần
$ redis-cli CONFIG GET hash-max-listpack-entries
hash-max-listpack-entries 512
512 field: listpack 4531 byte
513 field: hashtable 26119 byte # them DUNG 1 field, RAM x5,8
Hash nhỏ được cất dạng listpack (một mảng byte liền nhau). Vượt ngưỡng thì Redis đổi sang hashtable
và không đổi ngược lại. Đo thật: hash lên 600 field thì thành hashtable, xoá còn 1 field
vẫn là hashtable. Có hai ngưỡng, vượt cái nào cũng đổi: số field
(hash-max-listpack-entries 512) và độ dài value (hash-max-listpack-value 64 byte).
Chỉ ghi một value 65 byte là cả hash thành hashtable (đã đo). Ngưỡng khác nhau giữa các bản Redis
— kiểm tra bằng CONFIG GET trên bản mình chạy, đừng nhớ số.
Mẹo tiết kiệm RAM: gom nhiều object nhỏ vào bucket
Bài toán ví dụ: lưu tiến độ của 100.000 sinh viên, mỗi người một số nhỏ (vd 75). Cách tự
nhiên là mỗi sinh viên một key. Cách tiết kiệm là chia sinh viên thành bucket cố định (vd 500 người),
mỗi bucket là một hash, mỗi sinh viên là một field.
flowchart LR
subgraph K1["Mỗi SV 1 key: 100.000 key"]
direction TB
D1["keyspace dict"] --> E1["entry + header + tên key
progress:1 → 75"]
D1 --> E2["entry + header + tên key
progress:2 → 75"]
D1 --> E3["... lặp 100.000 lần"]
end
subgraph K2["Bucket 500: ~200 key"]
direction TB
D2["keyspace dict"] --> B1["progress:0 → listpack
1|75|2|75|...|499|75"]
D2 --> B2["progress:1 → listpack
500|75|...|999|75"]
end
Vì sao gom lại đỡ tốn: mỗi key ở tầng ngoài cùng phải trả một khoản phí cố định. Phí
đó gồm một ô trong bảng băm chính (keyspace dict), object header, chuỗi tên key progress:12345,
header riêng của listpack, và thêm một ô trong bảng expire nếu có TTL. Phí này không phụ thuộc
dữ liệu. Dữ liệu càng nhỏ thì phí này càng chiếm gần hết RAM. Trong bucket, mỗi sinh viên chỉ là một cặp
field/value nằm liền nhau trong mảng byte: vài byte, không con trỏ, không lặp tên key.
Đo thật trên 100.000 sinh viên (ghi bằng redis-cli --pipe, lấy chênh lệch
used_memory):
| Cách lưu | Số key | RAM | byte / SV |
|---|---|---|---|
A. 1 string key / SV: SET progress:$i 75 | 100.000 | 5.372 KB | 55 |
A + EXPIRE từng key | 100.000 | — | ~75 |
| B. 1 hash / SV, 3 field (vẫn là listpack) | 100.000 | 9.252 KB | 94 |
| C. bucket 500 SV, 1 field / SV | 201 | 672 KB | 6 |
C + HEXPIRE từng field (encoding listpackex) | 201 | 1.909 KB | 19 |
| D. bucket 500 SV × 3 field = 1.500 field / hash → hashtable | 201 | 13.261 KB | 135 ❌ |
| E. bucket 150 SV × 3 field = 450 field / hash | 667 | 4.691 KB | 48 |
Đọc bảng: B và C chênh ~15 lần dù cả hai đều là listpack — phần chênh là phí mỗi key. D còn tệ hơn B: bucket đặt sai cỡ tốn RAM hơn cả không gom. Công thức: số SV mỗi bucket ≤
hash-max-listpack-entries/ số field mỗi SV, và chừa khoảng trống cho field thêm sau này (3 field → bucket 150, không phải 170).
$bucket = intdiv($studentId, 150); // id là số tăng liên tục
Redis::hset("progress:{$bucket}", "{$studentId}:pct", 75);
Redis::hget("progress:{$bucket}", "{$studentId}:pct");
// id là UUID hoặc thưa: chọn số bucket cố định từ đầu
$bucket = crc32($uuid) % 1024;
| Bẫy của bucket | Hậu quả (đo) | Cách tránh |
|---|---|---|
| Vượt ngưỡng số field | Thành hashtable, 135 byte/SV, không tự gọn lại | Tính cỡ bucket theo số field; thêm field thì đổi tên key sang bản mới |
| Một value > 64 byte (JSON, tên bài học) | Cả bucket thành hashtable | Chỉ lưu số, id ngắn trong bucket |
| Mất TTL riêng từng SV | EXPIRE áp cho cả bucket | HEXPIRE (Redis 7.4+): 19 byte/SV, vẫn gọn hơn 1 key/SV có TTL ~4 lần |
| Eviction chạy theo key, không theo field | allkeys-lru ép RAM: evicted_keys 6 = 6 bucket = 3.000 SV mất cache cùng lúc | Cache: để dư RAM; dữ liệu state không dùng policy xoá key |
| Tìm field trong listpack là quét tuần tự O(N) | 500 field không sao; nâng ngưỡng lên hàng nghìn thì tốn CPU, chặn event loop | Giữ ngưỡng mặc định nếu chưa đo CPU |
Không dùng được Cache:: facade | Tự quản tên bucket, tự encode giá trị | Chỉ áp dụng ở vài chỗ thật sự tốn RAM |
Id thưa / UUID với id / N | Bucket lệch, có cái vượt ngưỡng | crc32 % N; đổi N là phải chia lại toàn bộ dữ liệu |
Khi nào KHÔNG dùng: vài chục nghìn object thì cách A chỉ tốn ~5 MB — không đáng để code phức tạp hơn. Mẹo này chỉ có giá khi có hàng triệu key trở lên và RAM Redis là chi phí thật.
Các kiểu khác cũng có encoding gọn và ngưỡng riêng (CONFIG GET '*-max-*' trên 8.8):
| Kiểu | Encoding gọn | Ngưỡng mặc định |
|---|---|---|
| Hash | listpack | hash-max-listpack-entries 512, hash-max-listpack-value 64 |
| Set chỉ chứa số nguyên | intset | set-max-intset-entries 512 |
| Set có chuỗi | listpack | set-max-listpack-entries 128, set-max-listpack-value 64 |
| Sorted set | listpack | zset-max-listpack-entries 128 — nhỏ hơn hash nhiều, zset-max-listpack-value 64 |
| List | quicklist (chuỗi các node listpack) | list-max-listpack-size -2 (mỗi node ≤ 8 KB theo redis.conf) |
Chỉ đo RAM cho hash. Số RAM của set, zset, list chưa đo; ý nghĩa -2 lấy từ chú thích
redis.conf, chưa verify.
1.3. Bộ nhớ và eviction
maxmemory-policy | Khi đầy RAM | Hợp với |
|---|---|---|
noeviction | Từ chối lệnh ghi, đọc vẫn chạy | Instance chứa state: session, lock, queue, idempotency |
allkeys-lru | Xoá key lâu không dùng nhất, bất kỳ key nào | Instance chỉ chứa cache |
allkeys-lfu | Xoá key ít được dùng nhất | Cache có tập key nóng ổn định. Bật được --hotkeys |
volatile-lru / volatile-lfu / volatile-ttl | Chỉ xoá key có TTL | Instance trộn, nhưng key state cũng có TTL nên vẫn bị nhắm |
allkeys-lrm / volatile-lrm | Xoá key lâu không bị sửa nhất (least recently modified) | Mới — CONFIG SET nhận trên 8.8. Chưa test hành vi |
allkeys-random / volatile-random | Xoá ngẫu nhiên | Hiếm khi đúng |
LRU của Redis là xấp xỉ: lấy mẫu vài key (maxmemory-samples) rồi xoá key
cũ nhất trong mẫu, không duyệt hết. Chứng minh allkeys-lru xoá cả key TTL 24 giờ nằm ở bài
Idempotency key mục 4.1.
maxmemory mặc định là 0 — không giới hạn
$ redis-cli CONFIG GET maxmemory
maxmemory 0
0 nghĩa là Redis dùng RAM tới khi hết máy. Không có eviction, cũng không có lỗi OOM của Redis. Khi đó
kernel OOM killer (phần của Linux giết process khi máy hết RAM) giết thẳng Redis, hoặc container bị kill vì
vượt memory limit. Dữ liệu chưa persist mất hết. Phần OOM killer là suy ra, chưa đo. Luôn đặt
maxmemory rõ ràng, thấp hơn RAM máy hoặc limit của container, chừa chỗ cho fork và buffer của
client.
Key hết hạn được xoá thế nào
Theo tài liệu Redis (Context7), có hai cách chạy song song:
- Lazy expiration: khi có client đọc key, Redis kiểm tra TTL. Hết hạn thì xoá và trả
nil. - Active expiration: mỗi 100 ms, Redis lấy mẫu ngẫu nhiên các key có TTL và xoá key đã hết hạn. Nếu hơn 25% mẫu đã hết hạn thì lặp lại ngay.
# ghi 200.000 key PX 300, khong ai doc lai
t~1s dbsize=0 expired_keys=200000 used_memory_human=1.41M # truoc do 4.44M
Khi nhiều key cùng hết hạn, active expiration tự lặp nhiều vòng nên dọn rất nhanh (200.000 key trong khoảng 1 giây). Mặt trái: trong lúc đó event loop bận dọn. Hàng triệu key đặt cùng TTL (vd lúc import hoặc warm cache) sẽ hết hạn cùng một lúc. Tài liệu Redis ghi đây là một nguồn gây latency. Cách tránh: cộng jitter (một số giây ngẫu nhiên) vào TTL. Độ giật latency khi hàng triệu key hết hạn cùng lúc chưa đo.
noeviction đầy RAM, đo thật:
$ redis-cli CONFIG SET maxmemory 2mb
# ghi 50.000 key
errors: 44701, replies: 50000
$ redis-cli SET one-more v
OOM command not allowed when used memory > 'maxmemory'.
$ redis-cli GET tmp:o:1 # doc van chay
xxxxxxxx...
$ redis-cli DEL tmp:o:1 # xoa van chay
1
Với Laravel: session không ghi được → mọi request có session đều ném exception → toàn bộ người
dùng đã đăng nhập nhận lỗi 500. noeviction không làm mất dữ liệu, nhưng biến "đầy RAM" thành
"sập". Luôn phải đi kèm alert used_memory / maxmemory.
1.4. Persistence — RDB, AOF, và cái giá của fork
| Cấu hình | Mất tối đa khi crash | Giá phải trả |
|---|---|---|
| Không persistence | Toàn bộ | Không |
RDB (save 300 100…) | Vài phút gần nhất (tài liệu Redis: "be prepared to lose the latest minutes of data") | Fork mỗi lần chụp |
AOF appendfsync everysec | ~1 giây | Ghi đĩa liên tục, file lớn dần, phải rewrite (cũng fork) |
AOF appendfsync always | ~0 | Mỗi lệnh ghi chờ fsync — mất lý do dùng Redis |
Chụp RDB cần fork. Đo thật với 1 triệu key:
used_memory_human: 53.79M
$ redis-cli BGSAVE
latest_fork_usec: 8547 # 8,5 ms Redis dung im de fork
rdb_last_bgsave_status: ok
Lúc fork, Redis đứng im. Thời gian fork tăng theo lượng RAM (phải chép bảng trang bộ nhớ). 54 MB mất
8,5 ms trên máy dev. Dataset hàng chục GB có thể mất hàng trăm ms tới vài giây, tuỳ máy —
phần này là suy ra, chưa đo trên dataset lớn. Trong lúc process con ghi file, trang nào process
chính sửa thì bị copy ra (copy-on-write). Ghi càng nhiều thì RAM phình càng lớn, nặng nhất có thể gần gấp
đôi. Vì vậy máy Redis có persistence phải để dư RAM, không đặt maxmemory sát RAM máy.
Bẫy: chụp RDB lỗi thì Redis từ chối mọi lệnh ghi
$ redis-cli CONFIG GET save # image redis:alpine mac dinh BAT RDB
save 3600 1 300 100 60 10000
$ redis-cli CONFIG GET stop-writes-on-bgsave-error
stop-writes-on-bgsave-error yes
# chay Redis voi thu muc /data khong co quyen ghi
$ redis-cli BGSAVE
Background saving started
rdb_last_bgsave_status:err
$ redis-cli SET b 2
MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk.
Commands that may modify the data set are disabled, ...
$ redis-cli GET a
1 # doc van chay
# log: Failed opening the temp RDB file temp-30.rdb (in server root dir /data) for saving: Permission denied
Đĩa đầy, sai quyền thư mục, hoặc fork thất bại là cả site không ghi được session — giống hệt OOM ở
mục 1.3, dù RAM vẫn còn dư. Instance chỉ làm cache mà để cấu hình mặc định vẫn dính, vì image mặc định bật
RDB. Cách xử lý: instance cache thuần thì tắt hẳn RDB (save ""). Instance cần persistence thì
giữ yes (để biết mình đang không có bản sao lưu), nhưng phải alert
rdb_last_bgsave_status và dung lượng đĩa. Về fork: chính Redis 8.8 in cảnh báo lúc khởi
động trên máy lab: WARNING Memory overcommit must be enabled! Without it, a background save or replication
may fail under low memory condition. Cách sửa là đặt vm.overcommit_memory = 1 trên máy chủ.
Chưa tái hiện được một lần fork lỗi thật.
1.5. Replication, failover, và write đã OK vẫn mất
Replication của Redis là async. Dựng master + replica thật, cắt mạng replica
(docker network disconnect), rồi giết master:
master_link_status:up
-- replica song, SET + WAIT CUNG connection:
OK
1 # 1 replica da nhan
-- cat mang replica
-- SET + WAIT CUNG connection:
OK # master van tra OK cho app
0 # nhung 0 replica nhan (doi 500 ms)
-- master chet, promote replica
GET tmp:order:1: paid
GET tmp:order:2: # MAT - du app da nhan OK
sequenceDiagram participant A as App participant M as Master participant R as Replica A->>M: SET order:2 paid M-->>A: OK Note over M,R: mạng tới replica đã đứt M--xM: master chết Note over R: Sentinel bầu replica lên master A->>R: GET order:2 R-->>A: nil (write đã OK nhưng mất)
Bẫy WAIT: chỉ tính lệnh ghi của chính connection
# cung luc replica dang mat mang:
SET + WAIT tren CUNG connection -> 0
WAIT tren connection MOI (chua ghi gi) -> 1 # tuong la an toan, thuc ra khong
Lần test đầu, tôi chạy SET và WAIT bằng hai lệnh redis-cli riêng
(hai connection), và WAIT luôn trả 1 — kết quả sai hoàn toàn. Trong app PHP,
WAIT phải gọi trên đúng connection vừa ghi. Và kể cả WAIT trả 1 cũng chỉ giảm rủi
ro, không biến Redis thành hệ thống nhất quán mạnh: replica đã nhận chưa chắc đã được chọn làm master mới.
Sentinel hay Cluster
| Sentinel | Cluster | |
|---|---|---|
| Giải quyết | Tự failover cho một master | Chia dữ liệu ra nhiều master (16384 slot) + failover |
| Khi nào cần | Dữ liệu vừa RAM một máy, cần HA | Dữ liệu hoặc tải ghi vượt một máy |
| Lệnh nhiều key | Thoải mái | Chỉ khi mọi key cùng slot → phải dùng hash tag {...} (tài liệu Redis qua Context7) |
SELECT db | Dùng được 16 DB | Chỉ có DB 0 |
| Laravel | phpredis không hỗ trợ Sentinel (đo: mất write đã OK, rồi READONLY mãi). Phải dùng predis replication => sentinel | Cấu hình clusters. Tên queue phải có hash tag (đo: default lỗi, {default} chạy) |
| Failover đo được | Sentinel bầu master mới sau ~4 s (down-after-milliseconds 3000) | ~8,4 s (cluster-node-timeout 5000); chỉ slot của master chết bị lỗi |
Redis Cluster đo thật: lệnh nào gãy
Tài liệu Redis (Context7): cluster chạy mọi lệnh một key, nhưng lệnh nhiều key chỉ chạy khi mọi key cùng slot, và chỉ có DB 0. Chạy thật trên lab 3 master:
$ redis-cli CLUSTER KEYSLOT a -> 15495 # b -> 3300, c -> 7365, {u}:1 va {u}:2 -> 11826
# noi vao node giu slot 0-5460, KHONG bat -c
$ redis-cli SET a 1
MOVED 15495 172.21.0.4:6379 # client phai tu di toi node dung
$ redis-cli -c MSET a 1 b 2
CROSSSLOT Keys in request don't hash to the same slot
$ redis-cli -c MGET a b -> CROSSSLOT
$ redis-cli -c EVAL "..." 2 a b -> CROSSSLOT
$ redis-cli -c MSET {u}:1 x {u}:2 y -> OK # cung hash tag -> cung slot
$ printf 'MULTI\nSET b 1\nSET c 1\nEXEC\n' | redis-cli
MOVED 7365 172.21.0.3:6379
EXECABORT Transaction discarded because of previous errors.
$ redis-cli SELECT 1
ERR SELECT is not allowed in cluster mode
# KEYS * chi tra key cua node dang noi: 3 master cho 3 ket qua khac nhau
Laravel 12.64 + phpredis trên cùng cluster (options.cluster = redis, khai báo
clusters):
| Thao tác | Kết quả đo | Ghi chú |
|---|---|---|
Redis::set/get | OK | Client tự theo MOVED |
Redis::mget 3 key khác slot | OK | phpredis tự tách theo node rồi gom kết quả |
Cache::putMany / Cache::many / Cache::tags | OK | |
Cache::lock get / release | OK (TTL 10 → release → -2) | Một key nên không dính slot |
Redis::eval 2 key khác slot | RedisCluster::eval(): All keys do not map to the same slot | Lua tự viết phải dùng hash tag |
Redis::transaction 2 key khác slot | Error processing EXEC across the cluster, không key nào được ghi. Tệ hơn: lệnh đọc ngay sau đó trên cùng connection trả NULL dù key có thật trên server; từ lần đọc thứ 2 mới đúng. Trong một test dài hơn, Cache::lock get + release sau transaction lỗi trả false (2/2 lần) — chưa rõ cơ chế chính xác | Không bao giờ để transaction đụng key khác slot. Lỡ lỗi thì Redis::purge() để mở connection mới |
Redis::select(1) | Call to undefined method RedisCluster::select() | Chỉ có DB 0 |
Redis::keys('*') | FatalError: Can't send KEYS to 172.21.0.4:6379 — try/catch không bắt được, process chết | Không gọi keys trên cluster; quét từng node bằng SCAN |
Queue tên default | push, pop, size đều lỗi All keys do not map to the same slot | Lua của queue đụng queues:default (slot 972) và queues:default:reserved (slot 5627) |
Queue tên {default} | push, pop, later + migrate đều OK | Mọi key của queue cùng slot 5718 → cả queue dồn vào một master |
Cache::flush | Chạy FLUSHDB trên từng master: queue {default} từ 1 job còn 0 | Xem Case 5 |
Cluster failover đo thật
# log cua cac node, kill master giu slot 5461-10922
14:44:02.50 replica: Connection with master lost
14:44:10.21 node khac: FAIL message received ... Cluster state changed: fail # ~7,7 s
14:44:10.81 Failover auth granted ... for epoch 7 # bau replica
14:44:10.91 Cluster state changed: ok # ~8,4 s tu luc chet
Trong ~8 giây đó chỉ key thuộc slot của master chết bị lỗi, slot khác vẫn ghi bình thường.
Bẫy: poll CLUSTER INFO ở giây 2, 4 và 5 sau khi kill đều thấy
cluster_state:ok, vì node chưa bị đánh dấu FAIL. ok không có nghĩa là mọi slot đang
phục vụ được.
| Client Laravel (phpredis cluster) | Trong lúc failover | Write app nhận OK / còn lại |
|---|---|---|
| Không đặt timeout (config mặc định) | 1 lệnh treo 38,9 s rồi mới thành công. Vòng test 35 s bị kéo thành 46 s, chỉ ghi được 69 lệnh | 69 / 69 |
timeout + read_timeout = 1 s | Lỗi xen kẽ Timed out attempting to find data in the correct node! trong ~10 s, mỗi lệnh lỗi mất ~2 s; sau đó tự học lại bản đồ slot | 248 / 248 |
Không mất write nào trong test này, nhưng tốc độ ghi chỉ 10 lệnh/giây. Replication vẫn async, nên write sát lúc master chết vẫn có thể mất (mục 1.5).
Sentinel failover đo thật: Laravel có theo master mới không
Chạy cùng lúc 3 app Laravel ghi liên tục (mỗi app một prefix key để đếm riêng), rồi kill master, rồi bật master cũ lên lại, giống máy chủ reboot:
[10.0s] docker kill master 172.21.0.8
[14.0s] sentinel bau replica 172.21.0.9 len master
[22.0s] docker start master cu # khong persistence -> khoi dong RONG, tu nhan la master
[23.6s] master cu: role=master, 0 key
[35.6s] sentinel doi master cu thanh replica -> full sync, xoa sach nhung gi no vua nhan
| Client Laravel | Lúc master chết | Lúc master cũ sống lại | Cuối cùng | App nhận OK | Còn trên master mới |
|---|---|---|---|---|---|
| phpredis trỏ IP master, config mặc định (không timeout) | 1 lệnh treo 19,8 s, không báo lỗi — tới khi IP cũ sống lại | Ghi vào master cũ đang rỗng | READONLY You can't write against a read only replica. mãi mãi | 136 | 84 → mất 52 |
| phpredis trỏ IP master, timeout 1 s | Operation timed out, mỗi lệnh tới 4,4 s (retry 3 lần) | Connection refused → went away → OK trên master cũ | READONLY mãi mãi | 196 | 84 → mất 112 |
| predis 3.6.0 + Sentinel | 1 lệnh chờ 8,4 s rồi thành công | Không ảnh hưởng | Chạy bình thường trên master mới | 491 | 491 → mất 0 |
Vì sao phpredis hỏng: connector của Laravel không biết Sentinel, nên giữ nguyên IP cũ. Master cũ khởi động
lại không có replicaof, nên tự nhận là master và nhận ghi trong ~12 giây. Sau đó sentinel đổi nó
thành replica, và full sync xoá sạch những gì nó vừa nhận. Đây là split-brain (hai master cùng nhận ghi): write
đã trả OK vẫn mất. Rồi app bị READONLY tới khi restart PHP-FPM hoặc đổi config. Nếu master cũ
không bao giờ sống lại, client không timeout sẽ treo bao lâu — chưa đo.
// REDIS_CLIENT=predis, composer require predis/predis (da test 3.6.0)
'redis' => [
'client' => 'predis',
'options' => [
// KHONG de 'cluster' => ... o day (config mac dinh co san dong nay)
'prefix' => env('REDIS_PREFIX', 'laravel-database-'),
],
'default' => [
'tcp://sentinel-1:26379', 'tcp://sentinel-2:26379', 'tcp://sentinel-3:26379',
'options' => [
'replication' => 'sentinel',
'service' => 'mymaster',
'parameters' => ['password' => env('REDIS_PASSWORD'), 'database' => 0],
],
],
// 'cache' => giong 'default', 'database' => 1
],
Đã test trên cấu hình này: Redis::set/get, Cache::put/get, Cache::lock,
queue push/pop đều chạy. Lab không đặt mật khẩu, nên dòng password chưa test. Tài liệu
Laravel 13.x (Context7) không nhắc tới Sentinel; cấu hình trên dựa theo tài liệu predis.
Bẫy
options.cluster:config/database.phpmặc định có'cluster' => env('REDIS_CLUSTER', 'redis')trongoptions. Giữ dòng đó thì predis tạoPredis\Connection\Cluster\RedisClusterthay vì sentinel, và mọi lệnh đều lỗi:No connections left in the pool for `CLUSTER SLOTS`. Queue thì báoCannot use 'EVAL' with redis-cluster(đã đo).
Cách khác: managed Redis có một endpoint cố định tự trỏ về master, hoặc HAProxy kiểm tra
role:master. Tài liệu Redis có min-replicas-to-write để master không có replica thì
ngừng nhận ghi, nhờ đó chặn được đúng kiểu mất dữ liệu ở trên. Cả ba cách này chưa test.
1.6. Atomic: MULTI, pipeline, Lua — ba thứ hay bị lẫn
| Atomic (không ai chen) | Rollback khi lỗi | Bớt round-trip | |
|---|---|---|---|
| Pipeline | Không | Không | Có |
MULTI/EXEC | Có | Không | Có (nếu gửi kèm pipeline) |
| Lua / Function | Có | Không | Có, và đọc–rồi–ghi được bên trong |
"Không rollback" đo thật:
$ redis-cli SET tmp:s abc
MULTI
INCR tmp:s -> QUEUED
SET tmp:k2 da-chay -> QUEUED
EXEC
1) ERR value is not an integer or out of range # lenh 1 loi
2) OK # lenh 2 VAN chay
$ redis-cli GET tmp:k2
da-chay
Ai quen transaction của PostgreSQL sẽ bị bất ngờ ở đây. MULTI chỉ đảm bảo "chạy liền
một mạch". Muốn "đọc rồi ghi có điều kiện" thì dùng WATCH (optimistic) hoặc Lua.
Redis 8.4+ có thêm lệnh so sánh–rồi–ghi, không cần Lua (tài liệu Redis qua Context7, đã chạy thật trên 8.8):
$ redis-cli SET tmp:lock ownerA
$ redis-cli SET tmp:lock ownerX IFEQ ownerB -> (nil) # khong khop, khong ghi
$ redis-cli SET tmp:lock ownerX IFEQ ownerA -> OK
$ redis-cli DELEX tmp:lock IFEQ ownerA -> 0 # xoa neu gia tri khop
$ redis-cli DELEX tmp:lock IFEQ ownerB -> 1
1.7. Distributed lock — đúng cách và giới hạn của nó
Laravel làm đúng chuẩn. RedisLock::acquire() (Illuminate/Cache/RedisLock.php:37):
return $this->redis->set($this->name, $this->owner, 'EX', $this->seconds, 'NX') == true;
Còn release() chạy Lua kiểm tra owner rồi mới xoá (LuaScripts::releaseLock()):
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
Vì sao phải kiểm tra owner — chạy thật tình huống lock hết hạn khi A còn đang làm:
A lay lock PX 1000: OK
# A bi cham 1,2 giay (GC, query lau, goi API ngoai)
B lay lock (A chua xong): OK # lock cua A da het han
A release bang Lua check owner: 0 # dung: khong xoa lock cua B
lock hien tai: ownerB
A release bang DEL tho: 1 # SAI: xoa mat lock cua B
Cache::lock()->forceRelease() của Laravel chính là DEL thô
(RedisLock.php:75). Chỉ dùng khi dọn lock kẹt bằng tay.
Giới hạn: kiểm tra owner không cứu được việc A đang ghi
Lua release chỉ bảo vệ được lock. Nó không ngăn A tiếp tục làm việc và ghi vào DB trong lúc B cũng đang ghi. Lock trên Redis chỉ nói "gần như chắc chỉ có một người". Có ba lý do nó không bao giờ tuyệt đối:
- Lock hết hạn khi người giữ còn đang chạy (vừa đo ở trên).
- Failover mất lock (mục 1.5): A lấy lock trên master, master chết trước khi replicate, B lấy được lock trên master mới.
- Đồng hồ và pause: process bị treo (GC, swap) không biết mình đã quá hạn.
Việc cần đúng tuyệt đối thì chặn ở chỗ ghi cuối cùng: fencing token (mỗi lần cấp lock
kèm một số tăng dần, DB từ chối số cũ:
UPDATE ... SET fence = :t WHERE id = :id AND fence < :t), hoặc đơn giản là unique constraint /
optimistic locking trong DB (xem Optimistic locking). Redlock (lấy
lock trên đa số N instance) giảm được lý do 2 nhưng không giải quyết lý do 1 và 3 — đây là tranh cãi
nổi tiếng giữa Martin Kleppmann và tác giả Redis; bài này không dựng Redlock để kiểm chứng.
1.8. Pub/Sub, List, Stream — gửi tin nhắn qua Redis
$ redis-cli PUBLISH tmp:ch hello
(integer) 0 # 0 nguoi nghe -> tin nhan bien mat, khong luu lai
| Pub/Sub | List | Stream | |
|---|---|---|---|
| Subscriber offline | Mất tin | Tin nằm chờ | Tin nằm chờ |
| Nhiều consumer chia việc | Không (ai cũng nhận hết) | Có (BRPOP) | Có (consumer group) |
| Xác nhận đã xử lý | Không | Tự làm (Laravel dùng sorted set :reserved) | Có (XACK, danh sách pending) |
| Dùng khi | Đẩy realtime, mất vài tin không sao (broadcast, xoá L1 cache các node) | Job queue đơn giản | Luồng sự kiện cần đọc lại, nhiều nhóm consumer |
1.9. Kết nối từ Laravel — số connection, retry, Sentinel
maxclients mặc định là 10000. Hết chỗ thì connection mới bị từ chối ngay. Đo với
maxclients 3 và 4 client đang giữ BLPOP:
$ redis-cli PING
ERR max number of clients reached
Số connection lúc cao điểm ≈ (số PHP-FPM worker × số node app) + queue worker + Horizon + cron.
Mặc định mỗi request PHP-FPM mở connection rồi đóng. Bật REDIS_PERSISTENT=true thì mỗi worker
giữ connection suốt đời process, nên tổng connection luôn bằng tổng worker, kể cả lúc rảnh. Scale thêm node
app là nhân số này lên. Công thức là suy ra, chưa đo trên cụm thật.
| Chủ đề | Laravel 12.64 (đọc source) | Ý nghĩa khi vận hành |
|---|---|---|
| Retry | config/database.php mặc định max_retries 3, backoff_algorithm decorrelated_jitter, base 100 ms, cap 1000 ms (PhpRedisConnector.php:91) | Redis chập chờn thì request đợi thêm tới vài giây rồi mới lỗi. Cache nên fail open (bắt exception, đọc thẳng DB) thay vì làm hỏng request |
read_timeout | Không đặt trong config mặc định | Đã đo khi failover (mục 1.5): không đặt timeout thì 1 lệnh treo 19,8 s (Sentinel) và 38,9 s (Cluster), không báo lỗi — PHP-FPM worker đứng im suốt thời gian đó. Đặt rõ timeout và read_timeout |
| Sentinel | Không có code Sentinel nào trong Illuminate/Redis (grep rỗng) | Đã đo (mục 1.5): app giữ IP master cũ, mất 52–112 write đã trả OK, rồi READONLY mãi. predis 3.6.0 + sentinel chạy đúng, mất 0 — nhưng phải bỏ options.cluster |
| Serializer / compression | Laravel docs 13.x (Context7): option serializer (igbinary, msgpack) và compression (lz4, zstd, lzf) | Image laravel-app có phpredis 6.3.0 nhưng build không có igbinary, msgpack, lz4, zstd, lzf (Redis::COMPRESSION_LZ4 không tồn tại) → phải build lại extension. Đổi serializer trên hệ thống đang chạy thì dữ liệu cũ đọc không ra (suy ra). RAM tiết kiệm được chưa đo |
2. Lợi ích
| Được gì | Trong hoàn cảnh nào thì thật sự được |
|---|---|
Latency dưới 1 ms (bài idempotency đo SET NX p50 0,047 ms, so với 10,5 ms của INSERT PG có fsync) | Việc cần nhanh và chấp nhận mất vài giây dữ liệu khi crash |
Lệnh atomic sẵn: INCR, SET NX, ZADD… | Counter, rate limit, lock, xếp hạng — không cần transaction |
| Kiểu dữ liệu phong phú | Bảng xếp hạng, đếm unique, điểm danh — làm bằng SQL sẽ nặng hơn nhiều |
| TTL tự dọn | Session, cache, idempotency — không cần cron xoá |
| Một công cụ cho nhiều việc | Đội nhỏ: cache + session + queue + lock trên cùng một thứ đã quen vận hành |
3. Tác hại / hạn chế — danh sách bẫy đầy đủ
| # | Bẫy | Hậu quả (đo trong bài) | Cách tránh |
|---|---|---|---|
| 1 | KEYS, FLUSHALL, SMEMBERS/HGETALL trên key lớn | Đứng im 177 ms với 1 triệu key | SCAN; chặn lệnh nguy hiểm bằng ACL (-@dangerous) |
| 2 | DEL key lớn | Đứng im 256 ms | UNLINK hoặc bật lazyfree-lazy-user-del yes |
| 3 | Lua chạy lâu | Mọi client treo 5 giây rồi nhận BUSY | Script ngắn; không lặp trên dữ liệu lớn |
| 4 | Big key / hot key | Latency giật, một core 100% | Bài Cache nâng cao 1.5–1.6 |
| 5 | State chung instance với cache + allkeys-lru | Key idempotency/lock/session bị xoá trước hạn | Tách instance; state dùng noeviction |
| 6 | noeviction đầy RAM | Mọi lệnh ghi lỗi OOM → session lỗi → 500 toàn site | Alert bộ nhớ; mọi key state đều có TTL |
| 7 | Persistence có khoảng hở | Mất 1 giây tới vài phút dữ liệu khi crash | Dữ liệu không được mất thì để ở DB |
| 8 | Fork khi chụp RDB / rewrite AOF | Đứng im 8,5 ms với 54 MB, lớn hơn nhiều với dataset lớn; RAM phình vì copy-on-write | Để dư RAM; chụp trên replica; tắt Transparent Huge Pages |
| 9 | Replication async | Write đã trả OK vẫn mất khi failover (đã chứng minh) | Dữ liệu quan trọng: DB là nguồn sự thật, Redis chỉ là lớp nhanh |
| 10 | WAIT gọi trên connection khác | Luôn trả "đã replicate" dù chưa (đã chứng minh) | Gọi trên đúng connection vừa ghi |
| 11 | Lock hết hạn khi còn đang làm | Hai người cùng giữ lock (đã chứng minh) | TTL lock > thời gian chạy chậm nhất; fencing token / constraint ở DB |
| 12 | Nhả lock bằng DEL thô / forceRelease | Xoá mất lock của người khác | Lua check owner hoặc DELEX IFEQ |
| 13 | MULTI không rollback | Nửa lệnh chạy, nửa lỗi | Kiểm tra trước; dùng Lua cho logic có điều kiện |
| 14 | Pub/Sub khi không ai nghe | Tin biến mất (PUBLISH trả 0) | Stream hoặc queue nếu không được mất |
| 15 | Cluster và lệnh nhiều key | CROSSSLOT; Laravel queue tên default lỗi; Redis::keys làm process chết; transaction khác slot lỗi xong thì lệnh đọc kế tiếp trả NULL sai (đã đo). Hash tag lại dồn tải về một slot | Queue đặt tên {default}; thiết kế key từ đầu; chỉ gắn tag khi thật cần |
| 16 | cache:clear khi cache chung DB với session/queue | Laravel RedisStore::flush() gọi FLUSHDB → xoá luôn session/queue cùng DB | Mỗi việc một DB hoặc một instance riêng |
| 17 | Để Redis mở ra mạng không có mật khẩu | Bị xoá dữ liệu, bị ghi cấu hình | requirepass/ACL, chỉ bind mạng nội bộ |
| 18 | maxmemory mặc định 0 | Redis dùng RAM tới hết máy, bị OOM killer giết (suy ra) | Luôn đặt maxmemory |
| 19 | Chụp RDB lỗi + stop-writes-on-bgsave-error yes | Lỗi MISCONF, mọi lệnh ghi bị từ chối (đã chứng minh) | Cache thuần: save ""; có persistence: alert trạng thái bgsave + đĩa |
| 20 | Hết maxclients | ERR max number of clients reached (đã chứng minh) | Tính connection theo số worker; alert rejected_connections |
| 21 | Hash / bucket vượt ngưỡng listpack | RAM ×5,8 (512 → 513 field); bucket sai cỡ tốn hơn không gom | Cỡ bucket theo số field; value ≤ 64 byte; kiểm tra OBJECT ENCODING |
| 22 | Laravel + phpredis không hỗ trợ Sentinel | Master cũ sống lại → split-brain, mất 52–112 write đã OK, rồi READONLY mãi (đã chứng minh) | predis + sentinel, hoặc endpoint cố định của managed Redis |
| 23 | predis sentinel nhưng vẫn giữ options.cluster mặc định | Mọi lệnh lỗi No connections left in the pool (đã chứng minh) | Bỏ cluster khỏi options |
| 24 | Không đặt timeout / read_timeout | Khi failover, 1 lệnh treo 19,8 s (Sentinel) và 38,9 s (Cluster), không báo lỗi (đã chứng minh) | Đặt timeout vài trăm ms tới 1 s |
| 25 | Tin cluster_state:ok ngay sau sự cố | 5 giây đầu vẫn ok dù master đã chết (đã chứng minh) | Alert theo tỉ lệ lỗi phía app, không chỉ theo trạng thái cluster |
4. Trade-off — chọn cái nào
Redis hay thứ khác
| Việc | Redis | Lựa chọn khác | Chọn Redis khi |
|---|---|---|---|
| Cache | Kiểu dữ liệu phong phú, persistence tuỳ chọn | Memcached: đa luồng, đơn giản, chỉ có string; APCu: trong process, nhanh nhất nhưng không chia sẻ giữa node | Cần chia sẻ giữa nhiều node và cần hơn string |
| Session | Nhanh, TTL sẵn | Bảng DB: bền, chậm hơn; cookie: không cần server | Nhiều node app, không muốn thêm tải ghi vào DB |
| Queue | Nhanh, Laravel Horizon | Bảng DB (xem scale queue worker); SQS/RabbitMQ: bền và nhiều tính năng hơn | Job mất được hoặc tự tạo lại được; khối lượng lớn |
| Lock | Nhẹ, TTL tự nhả | SELECT ... FOR UPDATE, advisory lock của PostgreSQL: đúng tuyệt đối trong phạm vi DB | Chống chạy trùng "gần như chắc", không phải bảo vệ tiền |
| Dữ liệu nghiệp vụ (đơn, điểm, tiền) | Không nên | PostgreSQL | Không bao giờ là nguồn sự thật duy nhất |
Tiêu chí chọn cấu hình
| Câu hỏi | Trả lời → cấu hình |
|---|---|
| Mất dữ liệu này có sao không? | Không sao (cache) → allkeys-lru, RDB hoặc không persistence. Có sao (state) → instance riêng, noeviction, AOF everysec |
| Dữ liệu có vừa RAM một máy? | Có → Sentinel (hoặc managed Redis có HA). Không → Cluster, và thiết kế lại key cho hash slot |
| Cần HA thật không? | Đội nhỏ, cache thuần → một instance + app fail open có khi là đủ. HA tốn thêm 2 máy và nhiều thứ phải hiểu |
| License có quan trọng không? | Redis 7.4 đổi sang RSALv2 / SSPLv1 (kiến thức sẵn có). Redis 8.0 cho chọn một trong ba: RSALv2, SSPLv1 hoặc AGPLv3 (release notes Redis 8.0, qua Context7). Valkey là bản fork từ Redis 7.2, license BSD — phần Valkey chưa verify. AGPLv3: dùng nội bộ thường không sao; sửa mã Redis rồi cung cấp cho người khác qua mạng thì phải công bố mã — hỏi pháp chế trước khi quyết |
5. Khó khăn khi vận hành thực tế
Chỉ số (INFO) | Vì sao phải theo dõi | Alert khi |
|---|---|---|
used_memory / maxmemory | Đầy là eviction (cache) hoặc OOM (state) | > 80% |
evicted_keys | Trên instance state thì phải luôn bằng 0 | > 0 trên instance state |
keyspace_hits / misses | Hit ratio tụt đột ngột = avalanche/penetration | Giảm mạnh so với cùng giờ hôm qua |
mem_fragmentation_ratio | RAM thật (RSS) so với RAM Redis tính. Cao = phân mảnh, thấp hơn 1 = đang swap | > 1,5 hoặc < 1 |
connected_clients, blocked_clients | Rò connection, worker bị treo trên BLPOP | Tăng dần không giảm |
rejected_connections | Đã chạm maxclients, client mới bị từ chối | > 0 |
expired_keys | Nhiều key hết hạn cùng lúc làm event loop bận dọn | Tăng vọt bất thường |
latest_fork_usec, rdb_last_bgsave_status | Fork chậm, chụp RDB lỗi (đĩa đầy) | Fork > 100 ms; status khác ok |
master_link_status, độ lệch offset | Replica mất kết nối hoặc lag | down; lag tăng |
SLOWLOG | Tìm lệnh làm đứng server | Có lệnh > 10 ms |
Stack monitoring sẵn có trong môi trường này (monitor-redis-exporter +
Prometheus + Grafana) xuất được hầu hết các chỉ số trên. Công cụ khi đang xử lý sự cố:
redis-cli --bigkeys, --memkeys, --hotkeys (chỉ chạy với policy LFU),
SLOWLOG GET, CLIENT LIST (ai đang giữ connection).
- Con người: developer quen
redis-cli KEYS *trên máy local sẽ gõ y như vậy trên production. Chặn bằng ACL, đừng dựa vào trí nhớ. - Chi phí: RAM đắt hơn đĩa nhiều lần. Có persistence thì phải để dư RAM cho fork. Có HA thì nhân 2–3 lần số máy.
- Debug 3h sáng: Redis không có "lịch sử" như DB. Key hết hạn là mất dấu. Việc quan trọng phải log ở app (key, giá trị, thời điểm).
6. Case thực tế và cách xử lý
Case 1 — Latency giật lên vài trăm ms, đều đặn theo chu kỳ
Triệu chứng: p99 của app giật lên theo chu kỳ, trùng với lịch chụp RDB.
INFO persistence→ xemlatest_fork_usec. Hàng trăm nghìn micro giây là thủ phạm.- Xem
INFO memory: dataset bao nhiêu GB, máy còn dư bao nhiêu RAM. - Chữa: chuyển việc chụp RDB sang replica (master tắt
save), giãn lịch chụp, tắt Transparent Huge Pages trên máy chủ, chia dataset nếu quá lớn.
Case 2 — Mọi request timeout vài giây, rồi tự hết
Triệu chứng: không deploy gì, không tăng traffic, toàn site treo 2–3 giây.
SLOWLOG GET 20→ thấyKEYS *từ một script debug, hoặcDELmột key lớn.CLIENT LISTtìm ai chạy (địa chỉ IP, tên client).- Chữa: ACL chặn
@dangerouscho user của app và user của người; bậtlazyfree-lazy-user-del yes; tìm big key bằng--bigkeysvà chia nhỏ.
Case 3 — Sau khi Redis failover, một job gửi email chạy 2 lần, một đơn bị trùng
Nguyên nhân: lock Cache::lock('send-report') hoặc idempotency key được ghi
trên master, master chết trước khi replicate (bẫy 9) → master mới không có lock → process thứ hai
lấy được lock.
- Xác nhận: log của Sentinel có sự kiện failover đúng thời điểm.
- Dọn hậu quả: tìm bản ghi trùng trong DB theo idempotency key / job id.
- Chữa gốc: job phải idempotent (kiểm tra "đã gửi chưa" trong DB); đơn hàng có unique constraint ở DB. Redis lock chỉ là lớp giảm tải, không phải lớp đảm bảo.
Case 4 — Toàn bộ người dùng lỗi 500: "OOM command not allowed"
Nguyên nhân: instance session/queue dùng noeviction và đã đầy — thường
do có key không TTL (session "remember me" quá dài, job failed tích tụ) hoặc một big key.
- Chặn máu: tăng
maxmemorytạm thời nếu máy còn RAM (CONFIG SET maxmemory). redis-cli --memkeys/--bigkeystìm key chiếm RAM;SCANtìm keyTTL -1không lẽ ra phải có.- Xoá bằng
UNLINK, sửa code đặt TTL, thêm alertused_memory> 80%.
Case 5 — Chạy php artisan cache:clear, người dùng bị đăng xuất hết
Nguyên nhân: cache store và session dùng chung một Redis DB. RedisStore::flush()
gọi FLUSHDB trên DB đó (Illuminate/Cache/RedisStore.php:284, bẫy 16).
Config mặc định của Laravel đã tách sẵn: connection default dùng REDIS_DB=0
(session, queue), connection cache dùng REDIS_CACHE_DB=1. Lỗi này chỉ xảy ra khi
ai đó đặt hai biến bằng nhau, hoặc khi chuyển sang Redis Cluster — chỉ có DB 0, nên
mọi thứ tự nhiên dồn vào một DB. Đo trên lab cluster: Cache::flush chạy FLUSHDB trên
từng master, và queue {default} đang có 1 job thì còn 0.
- Kiểm tra
.env:REDIS_DBvàREDIS_CACHE_DBcó trùng không; session dùng connection nào (SESSION_CONNECTIONtrongconfig/session.php). - Tách DB số khác nhau, hoặc instance khác nhau (bắt buộc nếu dùng Cluster).
- Bỏ
cache:clearkhỏi mọi script deploy. Cần xoá thì xoá theo key/prefix bằngSCAN+UNLINK.
Case 8 — Sau khi Redis failover, app báo READONLY liên tục
Triệu chứng: Sentinel đã bầu master mới từ lâu, nhưng log Laravel vẫn đầy
READONLY You can't write against a read only replica., user bị đăng xuất, một số giỏ hàng/session mất.
- Xác nhận:
REDIS_CLIENT=phpredisvớiREDIS_HOSTlà IP/host của master cũ.SENTINEL get-master-addr-by-nametrả địa chỉ khác. - Chặn máu: đổi
REDIS_HOSTsang master mới, chạyphp artisan config:cache, reload PHP-FPM và restart queue worker (worker giữ connection cũ). - Đánh giá mất mát: write vào master cũ trong lúc nó tự nhận master đã bị full sync xoá (lab mất 52–112 write). Việc gì Redis là nguồn sự thật thì phải đối chiếu lại với DB.
- Chữa gốc: chuyển sang predis + sentinel (bỏ
options.cluster), hoặc endpoint cố định; đặttimeout; bật persistence hoặcreplicaoftrong config file để master cũ không tự nhận master khi boot.
Case 6 — RAM còn dư mà mọi lệnh ghi đều lỗi MISCONF
Triệu chứng: log Laravel đầy MISCONF Redis is configured to save RDB
snapshots..., người dùng không đăng nhập được, nhưng used_memory vẫn thấp.
INFO persistence→rdb_last_bgsave_status:err. Log Redis cho biết lý do:Failed opening the temp RDB file ... Permission denied, đĩa đầy, hoặc fork lỗi.- Chặn máu: sửa nguyên nhân (dọn đĩa, sửa quyền) rồi chạy
BGSAVElại. Nếu instance chỉ là cache thìCONFIG SET save ""ngay để site chạy lại. - Chữa gốc: ghi cấu hình vào file (
CONFIG SETsẽ mất khi restart, trừ khi chạyCONFIG REWRITE); thêm alertrdb_last_bgsave_statusvà dung lượng đĩa.
Case 7 — RAM Redis tăng gấp mấy lần sau một lần deploy, số key không đổi
Nguyên nhân: tiến độ sinh viên đang được gom theo bucket. Bản mới thêm field
last_lesson_title (có tên bài dài hơn 64 byte), hoặc thêm field làm bucket vượt 512 field →
bucket thành hashtable (mục 1.2).
redis-cli --memkeyshoặcMEMORY USAGE progress:0trên vài bucket;OBJECT ENCODINGtrảhashtablelà đúng bệnh.- Sửa code: chỉ lưu
lesson_id, hoặc giảm cỡ bucket bằng tên key mớiprogress:v2:{id / 150}. - Ghi song song v1 và v2, đọc v2 và fallback về v1. Xong thì xoá v1 bằng
SCAN+UNLINK. Bucket đã thành hashtable không tự gọn lại nên phải ghi lại. Không nâng ngưỡng khi chưa đo CPU.
7. Câu hỏi phụ interviewer hay hỏi tiếp
| Hỏi | Trả lời ngắn |
|---|---|
| Redis đơn luồng sao lại nhanh? | Dữ liệu trong RAM, không cần lock, không đổi ngữ cảnh giữa các luồng, I/O kiểu event loop. Chỗ nghẽn thường là mạng. Mặt trái: một lệnh chậm chặn tất cả |
| Redis 6 đã đa luồng rồi mà? | Chỉ phần đọc/ghi socket (io-threads). Chạy lệnh vẫn một luồng. Mặc định 8.8 là 1 |
Vì sao không dùng KEYS? | Quét hết keyspace trong một lệnh, chặn server (177 ms với 1 triệu key). Dùng SCAN |
DEL khác UNLINK? | UNLINK gỡ key ngay, giải phóng RAM ở luồng nền. Với hash 1 triệu field: 256 ms so với < 1 ms |
| RDB hay AOF? | RDB: file gọn, khởi động nhanh, mất vài phút. AOF everysec: mất ~1 giây, file lớn. Hay dùng cả hai. Cache thuần thì có khi không cần |
| Redis có mất dữ liệu đã trả OK không? | Có. Replication async: master chết trước khi replicate là mất (đã chứng minh). WAIT giảm rủi ro, không loại bỏ |
MULTI có rollback không? | Không. Lệnh lỗi thì lệnh sau vẫn chạy. Muốn có điều kiện thì dùng WATCH hoặc Lua |
| Lock Redis có an toàn không? | Đủ để giảm chạy trùng. Không đủ cho tiền: lock hết hạn khi còn chạy, mất khi failover. Việc quan trọng chặn bằng fencing token / constraint ở DB |
| Redlock? | Lấy lock trên đa số N instance độc lập. Giảm rủi ro failover, không giải quyết GC pause / hết hạn. Tranh cãi Kleppmann – antirez |
| Sentinel khác Cluster? | Sentinel: HA cho một master. Cluster: chia dữ liệu theo 16384 slot + HA. Cluster hạn chế lệnh nhiều key, chỉ có DB 0 |
| Chọn eviction policy nào? | Cache: allkeys-lru/lfu. State: noeviction + instance riêng + alert bộ nhớ |
| Pub/Sub dùng làm queue được không? | Không. Không ai nghe là mất (PUBLISH trả 0). Dùng List hoặc Stream |
| Hash nhỏ tốn RAM thế nào? Sao phải gom bucket? | Dưới ngưỡng thì listpack: 512 field 4,5 KB; qua ngưỡng thành hashtable 26 KB. Mỗi key tầng ngoài có phí cố định: 100.000 SV mỗi người 1 key tốn 55 byte/SV, gom bucket 500 tốn 6 byte/SV. Cỡ bucket phải tính theo số field, sai cỡ thì tệ hơn không gom |
| Redis xoá key hết hạn thế nào? | Lazy (có người đọc thì kiểm tra) + active (mỗi 100 ms lấy mẫu, hơn 25% hết hạn thì lặp). 200.000 key hết hạn cùng lúc được dọn trong ~1 giây, nhưng event loop bận trong lúc đó → cộng jitter vào TTL |
maxmemory mặc định bao nhiêu? | 0 = không giới hạn. Không đặt thì OS giết Redis thay vì Redis tự eviction |
| Vì sao Redis từ chối ghi dù còn RAM? | MISCONF: chụp RDB lỗi + stop-writes-on-bgsave-error yes. Image mặc định bật RDB nên instance cache cũng dính |
| Laravel dùng Redis Sentinel được không? | phpredis thì không: đo được mất write đã OK và READONLY mãi sau failover. predis + replication => sentinel chạy đúng, mất 0 write, nhưng phải bỏ options.cluster |
| Laravel queue trên Redis Cluster? | Tên queue phải có hash tag {default}. Tên default lỗi ngay khi push vì Lua đụng key khác slot. Mặt trái: cả queue nằm trên một master |
| Cluster failover mất bao lâu? | Khoảng cluster-node-timeout + vài giây: đo ~8,4 s với 5000 ms. Chỉ slot của master chết bị lỗi. Client không đặt timeout có thể treo 39 s |
| Split-brain Redis là gì? | Hai node cùng tự nhận master và cùng nhận ghi. Khi một node bị đổi thành replica, full sync xoá dữ liệu nó vừa nhận → write đã OK biến mất |
Tóm tắt
- Một luồng chạy lệnh:
KEYS,DELkey lớn, Lua dài làm đứng cả server. DùngSCAN,UNLINK, script ngắn. - Bộ nhớ: cache dùng
allkeys-lru; state (session, lock, queue, idempotency) dùng instance riêngnoeviction+ alert. Luôn đặtmaxmemory(mặc định 0). Hàng triệu object nhỏ thì gom bucket, nhưng giữ dưới ngưỡng listpack (số field và 64 byte mỗi value). - Độ bền: RDB mất vài phút, AOF
everysecmất ~1 giây, replication async mất write đã OK khi failover. Redis không phải nguồn sự thật cho dữ liệu nghiệp vụ. - Lock:
SET NX EX+ token + Lua release (hoặcDELEX IFEQ). Đúng tuyệt đối thì phải chặn ở DB. - HA với Laravel: phpredis không biết Sentinel — dùng predis + sentinel (bỏ
options.cluster) hoặc endpoint cố định. Cluster: queue phải là{tên}, không gọikeys. Luôn đặt timeout. - Atomic: pipeline không atomic,
MULTIkhông rollback, Lua atomic nhưng chặn server.
Đọc thêm: Cache nâng cao, Idempotency key ở Redis, Chống double-submit, Bộ công cụ vận hành.