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 loopVò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-threadsLuồ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)
bucketNhóm cố định để gom nhiều object nhỏ vào một key, ví dụ progress:{id / 500}
lazy / active expirationHai 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)
evictionRedis đầy maxmemory thì tự xoá bớt key theo maxmemory-policy
RDB / AOFHai 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 asyncMaster trả OK cho client trước khi replica nhận được lệnh
WAITLệnh chờ tới khi N replica xác nhận đã nhận các lệnh ghi của connection này
SentinelTiế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 slotChia dữ liệu ra nhiều master theo 16384 slot. Mỗi key thuộc một slot
hash tagPhầ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
pipelineGửi nhiều lệnh một lần để bớt round-trip. Không atomic
MULTI/EXECGom lệnh chạy liền một mạch, không ai chen vào. Không rollback
fencing tokenSố 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-lab và tmp-redis-replica, dựng riêng rồi xoá, không đụng chiennn_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 image laravel-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ủa docker 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ệnhRedis đứng im (đo phía server)Ghi chú
KEYS nomatch* trên 1 triệu key177 msKhô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 mslazyfree-lazy-user-del no là mặc định trên 8.8
UNLINK cùng loại hash< 1 msGỡ key ngay, giải phóng RAM ở luồng nền
DEL khi đã bật lazyfree-lazy-user-del yes< 1 msDEL tự chạy như UNLINK
Lua script chạy vòng lặp 7 giây7 giâyXem 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: SCAN thay KEYS (quét 1 triệu key bằng --scan --count 1000 mất ~1000 lệnh nhỏ, mỗi lệnh ngắn, client khác chen vào được giữa các lệnh). UNLINK thay DEL cho 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ểuDùng cho (ví dụ LMS)Chú ý
StringCache, counter (INCR), lock, rate limitTối đa 512 MB — nhưng vài trăm KB đã là big key
HashObject nhiều field: tiến độ học của 1 sinh viên. Hàng triệu object nhỏ thì gom theo bucketNhỏ thì cực gọn (listpack, xem dưới). Mỗi key có phí RAM cố định
ListHàng đợi đơn giản. Laravel Redis queue dùng list queues:defaultLấy ra rồi process chết = mất việc, nếu không có cơ chế reserved
SetSinh viên đang online trong phòng thiSMEMBERS trên set lớn là lệnh chậm
Sorted SetBả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"
StreamLog 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 NId thưa thì tốn RAM
Bloom / Cuckoo filterChống cache penetrationRedis 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ưuSố keyRAMbyte / SV
A. 1 string key / SV: SET progress:$i 75100.0005.372 KB55
A + EXPIRE từng key100.000—~75
B. 1 hash / SV, 3 field (vẫn là listpack)100.0009.252 KB94
C. bucket 500 SV, 1 field / SV201672 KB6
C + HEXPIRE từng field (encoding listpackex)2011.909 KB19
D. bucket 500 SV × 3 field = 1.500 field / hash → hashtable20113.261 KB135 ❌
E. bucket 150 SV × 3 field = 450 field / hash6674.691 KB48

Đọ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 bucketHậu quả (đo)Cách tránh
Vượt ngưỡng số fieldThành hashtable, 135 byte/SV, không tự gọn lạiTí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 hashtableChỉ lưu số, id ngắn trong bucket
Mất TTL riêng từng SVEXPIRE áp cho cả bucketHEXPIRE (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 fieldallkeys-lru ép RAM: evicted_keys 6 = 6 bucket = 3.000 SV mất cache cùng lúcCache: để 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 loopGiữ ngưỡng mặc định nếu chưa đo CPU
Không dùng được Cache:: facadeTự 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 / NBucket lệch, có cái vượt ngưỡngcrc32 % 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ểuEncoding gọnNgưỡng mặc định
Hashlistpackhash-max-listpack-entries 512, hash-max-listpack-value 64
Set chỉ chứa số nguyênintsetset-max-intset-entries 512
Set có chuỗilistpackset-max-listpack-entries 128, set-max-listpack-value 64
Sorted setlistpackzset-max-listpack-entries 128 — nhỏ hơn hash nhiều, zset-max-listpack-value 64
Listquicklist (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-policyKhi đầy RAMHợp với
noevictionTừ chối lệnh ghi, đọc vẫn chạyInstance chứa state: session, lock, queue, idempotency
allkeys-lruXoá key lâu không dùng nhất, bất kỳ key nàoInstance chỉ chứa cache
allkeys-lfuXoá key ít được dùng nhấtCache có tập key nóng ổn định. Bật được --hotkeys
volatile-lru / volatile-lfu / volatile-ttlChỉ xoá key có TTLInstance trộn, nhưng key state cũng có TTL nên vẫn bị nhắm
allkeys-lrm / volatile-lrmXoá 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-randomXoá ngẫu nhiênHiế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:

# 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ìnhMất tối đa khi crashGiá phải trả
Không persistenceToà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âyGhi đĩa liên tục, file lớn dần, phải rewrite (cũng fork)
AOF appendfsync always~0Mỗ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

SentinelCluster
Giải quyếtTự failover cho một masterChia dữ liệu ra nhiều master (16384 slot) + failover
Khi nào cầnDữ liệu vừa RAM một máy, cần HADữ liệu hoặc tải ghi vượt một máy
Lệnh nhiều keyThoải máiChỉ khi mọi key cùng slot → phải dùng hash tag {...} (tài liệu Redis qua Context7)
SELECT dbDùng được 16 DBChỉ có DB 0
Laravelphpredis không hỗ trợ Sentinel (đo: mất write đã OK, rồi READONLY mãi). Phải dùng predis replication => sentinelCấu hình clusters. Tên queue phải có hash tag (đo: default lỗi, {default} chạy)
Failover đo đượcSentinel 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ácKết quả đoGhi chú
Redis::set/getOKClient tự theo MOVED
Redis::mget 3 key khác slotOKphpredis tự tách theo node rồi gom kết quả
Cache::putMany / Cache::many / Cache::tagsOK
Cache::lock get / releaseOK (TTL 10 → release → -2)Một key nên không dính slot
Redis::eval 2 key khác slotRedisCluster::eval(): All keys do not map to the same slotLua tự viết phải dùng hash tag
Redis::transaction 2 key khác slotError 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ácKhô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ếtKhông gọi keys trên cluster; quét từng node bằng SCAN
Queue tên defaultpush, pop, size đều lỗi All keys do not map to the same slotLua của queue đụng queues:default (slot 972) và queues:default:reserved (slot 5627)
Queue tên {default}push, pop, later + migrate đều OKMọi key của queue cùng slot 5718 → cả queue dồn vào một master
Cache::flushChạy FLUSHDB trên từng master: queue {default} từ 1 job còn 0Xem 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 failoverWrite 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ệnh69 / 69
timeout + read_timeout = 1 sLỗ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 đồ slot248 / 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 LaravelLúc master chếtLúc master cũ sống lạiCuối cùngApp nhận OKCò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ạiGhi vào master cũ đang rỗngREADONLY You can't write against a read only replica. mãi mãi13684 → mất 52
phpredis trỏ IP master, timeout 1 sOperation 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ãi19684 → mất 112
predis 3.6.0 + Sentinel1 lệnh chờ 8,4 s rồi thành côngKhông ảnh hưởngChạy bình thường trên master mới491491 → 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.php mặc định có 'cluster' => env('REDIS_CLUSTER', 'redis') trong options. Giữ dòng đó thì predis tạo Predis\Connection\Cluster\RedisCluster thay vì sentinel, và mọi lệnh đều lỗi: No connections left in the pool for `CLUSTER SLOTS`. Queue thì báo Cannot 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ỗiBớt round-trip
PipelineKhôngKhôngCó
MULTI/EXECCóKhôngCó (nếu gửi kèm pipeline)
Lua / FunctionCóKhôngCó, 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:

  1. Lock hết hạn khi người giữ còn đang chạy (vừa đo ở trên).
  2. 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.
  3. Đồ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/SubListStream
Subscriber offlineMất tinTin nằm chờTin nằm chờ
Nhiều consumer chia việcKhông (ai cũng nhận hết)Có (BRPOP)Có (consumer group)
Xác nhận đã xử lýKhôngTự 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ảnLuồ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
Retryconfig/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_timeoutKhô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
SentinelKhô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 / compressionLaravel 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ọnSession, 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ẫyHậu quả (đo trong bài)Cách tránh
1KEYS, FLUSHALL, SMEMBERS/HGETALL trên key lớnĐứng im 177 ms với 1 triệu keySCAN; chặn lệnh nguy hiểm bằng ACL (-@dangerous)
2DEL key lớnĐứng im 256 msUNLINK hoặc bật lazyfree-lazy-user-del yes
3Lua chạy lâuMọi client treo 5 giây rồi nhận BUSYScript ngắn; không lặp trên dữ liệu lớn
4Big key / hot keyLatency giật, một core 100%Bài Cache nâng cao 1.5–1.6
5State chung instance với cache + allkeys-lruKey idempotency/lock/session bị xoá trước hạnTách instance; state dùng noeviction
6noeviction đầy RAMMọi lệnh ghi lỗi OOM → session lỗi → 500 toàn siteAlert bộ nhớ; mọi key state đều có TTL
7Persistence có khoảng hởMất 1 giây tới vài phút dữ liệu khi crashDữ liệu không được mất thì để ở DB
8Fork 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
9Replication asyncWrite đã 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
10WAIT gọi trên connection khácLuôn trả "đã replicate" dù chưa (đã chứng minh)Gọi trên đúng connection vừa ghi
11Lock hết hạn khi còn đang làmHai người cùng giữ lock (đã chứng minh)TTL lock > thời gian chạy chậm nhất; fencing token / constraint ở DB
12Nhả lock bằng DEL thô / forceReleaseXoá mất lock của người khácLua check owner hoặc DELEX IFEQ
13MULTI không rollbackNửa lệnh chạy, nửa lỗiKiểm tra trước; dùng Lua cho logic có điều kiện
14Pub/Sub khi không ai ngheTin biến mất (PUBLISH trả 0)Stream hoặc queue nếu không được mất
15Cluster và lệnh nhiều keyCROSSSLOT; 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 slotQueue đặt tên {default}; thiết kế key từ đầu; chỉ gắn tag khi thật cần
16cache:clear khi cache chung DB với session/queueLaravel RedisStore::flush() gọi FLUSHDB → xoá luôn session/queue cùng DBMỗ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ẩuBị xoá dữ liệu, bị ghi cấu hìnhrequirepass/ACL, chỉ bind mạng nội bộ
18maxmemory mặc định 0Redis dùng RAM tới hết máy, bị OOM killer giết (suy ra)Luôn đặt maxmemory
19Chụp RDB lỗi + stop-writes-on-bgsave-error yesLỗ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
20Hết maxclientsERR max number of clients reached (đã chứng minh)Tính connection theo số worker; alert rejected_connections
21Hash / bucket vượt ngưỡng listpackRAM ×5,8 (512 → 513 field); bucket sai cỡ tốn hơn không gomCỡ bucket theo số field; value ≤ 64 byte; kiểm tra OBJECT ENCODING
22Laravel + phpredis không hỗ trợ SentinelMaster 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
23predis sentinel nhưng vẫn giữ options.cluster mặc địnhMọi lệnh lỗi No connections left in the pool (đã chứng minh)Bỏ cluster khỏi options
24Không đặt timeout / read_timeoutKhi 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
25Tin 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ệcRedisLựa chọn khácChọn Redis khi
CacheKiểu dữ liệu phong phú, persistence tuỳ chọnMemcached: đa luồng, đơn giản, chỉ có string; APCu: trong process, nhanh nhất nhưng không chia sẻ giữa nodeCần chia sẻ giữa nhiều node và cần hơn string
SessionNhanh, TTL sẵnBảng DB: bền, chậm hơn; cookie: không cần serverNhiều node app, không muốn thêm tải ghi vào DB
QueueNhanh, Laravel HorizonBảng DB (xem scale queue worker); SQS/RabbitMQ: bền và nhiều tính năng hơnJob mất được hoặc tự tạo lại được; khối lượng lớn
LockNhẹ, TTL tự nhảSELECT ... FOR UPDATE, advisory lock của PostgreSQL: đúng tuyệt đối trong phạm vi DBChố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ênPostgreSQLKhông bao giờ là nguồn sự thật duy nhất

Tiêu chí chọn cấu hình

Câu hỏiTrả 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õiAlert khi
used_memory / maxmemoryĐầy là eviction (cache) hoặc OOM (state)> 80%
evicted_keysTrên instance state thì phải luôn bằng 0> 0 trên instance state
keyspace_hits / missesHit ratio tụt đột ngột = avalanche/penetrationGiảm mạnh so với cùng giờ hôm qua
mem_fragmentation_ratioRAM 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_clientsRò connection, worker bị treo trên BLPOPTăng dần không giảm
rejected_connectionsĐã chạm maxclients, client mới bị từ chối> 0
expired_keysNhiều key hết hạn cùng lúc làm event loop bận dọnTăng vọt bất thường
latest_fork_usec, rdb_last_bgsave_statusFork chậm, chụp RDB lỗi (đĩa đầy)Fork > 100 ms; status khác ok
master_link_status, độ lệch offsetReplica mất kết nối hoặc lagdown; lag tăng
SLOWLOGTìm lệnh làm đứng serverCó 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).

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.

  1. INFO persistence → xem latest_fork_usec. Hàng trăm nghìn micro giây là thủ phạm.
  2. Xem INFO memory: dataset bao nhiêu GB, máy còn dư bao nhiêu RAM.
  3. 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.

  1. SLOWLOG GET 20 → thấy KEYS * từ một script debug, hoặc DEL một key lớn.
  2. CLIENT LIST tìm ai chạy (địa chỉ IP, tên client).
  3. Chữa: ACL chặn @dangerous cho user của app và user của người; bật lazyfree-lazy-user-del yes; tìm big key bằng --bigkeys và 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.

  1. Xác nhận: log của Sentinel có sự kiện failover đúng thời điểm.
  2. Dọn hậu quả: tìm bản ghi trùng trong DB theo idempotency key / job id.
  3. 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.

  1. Chặn máu: tăng maxmemory tạm thời nếu máy còn RAM (CONFIG SET maxmemory).
  2. redis-cli --memkeys / --bigkeys tìm key chiếm RAM; SCAN tìm key TTL -1 không lẽ ra phải có.
  3. Xoá bằng UNLINK, sửa code đặt TTL, thêm alert used_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.

  1. Kiểm tra .env: REDIS_DB và REDIS_CACHE_DB có trùng không; session dùng connection nào (SESSION_CONNECTION trong config/session.php).
  2. Tách DB số khác nhau, hoặc instance khác nhau (bắt buộc nếu dùng Cluster).
  3. Bỏ cache:clear khỏi mọi script deploy. Cần xoá thì xoá theo key/prefix bằng SCAN + 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.

  1. Xác nhận: REDIS_CLIENT=phpredis với REDIS_HOST là IP/host của master cũ. SENTINEL get-master-addr-by-name trả địa chỉ khác.
  2. Chặn máu: đổi REDIS_HOST sang master mới, chạy php artisan config:cache, reload PHP-FPM và restart queue worker (worker giữ connection cũ).
  3. Đá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.
  4. Chữa gốc: chuyển sang predis + sentinel (bỏ options.cluster), hoặc endpoint cố định; đặt timeout; bật persistence hoặc replicaof trong 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.

  1. 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.
  2. Chặn máu: sửa nguyên nhân (dọn đĩa, sửa quyền) rồi chạy BGSAVE lại. Nếu instance chỉ là cache thì CONFIG SET save "" ngay để site chạy lại.
  3. Chữa gốc: ghi cấu hình vào file (CONFIG SET sẽ mất khi restart, trừ khi chạy CONFIG REWRITE); thêm alert rdb_last_bgsave_status và 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).

  1. redis-cli --memkeys hoặc MEMORY USAGE progress:0 trên vài bucket; OBJECT ENCODING trả hashtable là đúng bệnh.
  2. Sửa code: chỉ lưu lesson_id, hoặc giảm cỡ bucket bằng tên key mới progress:v2:{id / 150}.
  3. 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ỏiTrả 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

  1. Một luồng chạy lệnh: KEYS, DEL key lớn, Lua dài làm đứng cả server. Dùng SCAN, UNLINK, script ngắn.
  2. Bộ nhớ: cache dùng allkeys-lru; state (session, lock, queue, idempotency) dùng instance riêng noeviction + alert. Luôn đặt maxmemory (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).
  3. Độ bền: RDB mất vài phút, AOF everysec mấ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ụ.
  4. Lock: SET NX EX + token + Lua release (hoặc DELEX IFEQ). Đúng tuyệt đối thì phải chặn ở DB.
  5. 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ọi keys. Luôn đặt timeout.
  6. Atomic: pipeline không atomic, MULTI khô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.