Redis là data structure server — key, kiểu, encoding, độ phức tạp và cách chọn kiểu
Câu hỏi "Redis có những kiểu dữ liệu nào?" chỉ là câu mở màn. Interviewer cho vị trí senior hỏi tiếp: vì sao cùng 1 triệu phần tử mà sorted set tốn gấp 8 lần list, vì sao một lệnh đọc làm cả hệ thống đứng 0,3 giây, vì sao code chạy đúng ở máy dev lại sai ở production, và nâng Redis lên 8.x rồi có quay lại được không. Bài này là nền cho cả series: nó giải thích "kiểu dữ liệu" trong Redis thật ra là gì, từ đó suy ra cách chọn kiểu. Các bài sau đi sâu từng nhóm kiểu. Mọi con số trong bài đo trên Redis 8.8.0, so với Redis 7.4.11 và Valkey 9.1.2.
Thuật ngữ dùng trong bài
data structure server (server giữ cấu trúc dữ liệu trong RAM và chạy thuật toán trên đó, thay vì chỉ cất/trả một khối byte) · type (kiểu logic mà client thấy: string, hash, set…) · encoding (cách Redis thật sự cất kiểu đó trong RAM; một kiểu có thể có 2–3 encoding) · listpack (mảng byte liền nhau, gọn, tìm phải quét tuần tự) · intset (mảng số nguyên đã sắp xếp) · hashtable (bảng băm, nhanh nhưng mỗi phần tử tốn vài con trỏ) · skiplist (danh sách nhiều tầng để tìm theo thứ tự) · quicklist (danh sách liên kết, mỗi nút là một listpack) · kvobj (khối nhớ của Redis 8 chứa header + tên key + có khi cả value) · big key (key có quá nhiều phần tử hoặc quá nhiều byte, làm lệnh O(N) chậm) · O(1) / O(log N) / O(N) (chi phí không đổi / tăng theo log / tăng tuyến tính theo số phần tử) · lazyfree (giải phóng RAM ở luồng nền:UNLINK, cấu hìnhlazyfree-lazy-*) · read-modify-write (đọc, sửa ở app, ghi lại — 3 bước tách rời) · lost update (cập nhật bị ghi đè mất vì hai bên cùng đọc giá trị cũ) · RDB format (định dạng file snapshot và payloadDUMP; bản mới có số version mới).
Kiểm chứng: tài liệu Redis qua Context7 ngày 03/10/2026 (trang data types, release notes 8.0/8.2/8.6, các lệnhXADD,INCREX,XREADGROUP,MEMORY USAGE,INFO keysizes). Đọc source Redis 8.8.0 (object.h,object.chàmkvobjSet,t_list.c,t_set.c,sparsearray.h,lazyfree.c,server.h) và Laravel 12.64 (PhpRedisConnection.php). Danh sách lệnh mới lấy từ chính server bằngCOMMAND DOCS. Lab chạy thật: Redis 8.8.0, Redis 7.4.11, Valkey 9.1.2, PHP 8.3.33 + phpredis 6.3.0 + Laravel 12.64 (project laravel-livewire mount read-only). Output:labs/redis/out/01*.txt. Máy lab 4 vCPU dùng chung — số thời gian chỉ xem bậc độ lớn.
Trả lời 30 giây
Redis không phải "cache key–value". Nó là data structure server: mỗi key trỏ tới một cấu trúc dữ liệu nằm trong RAM của server, và lệnh là thuật toán chạy ngay cạnh dữ liệu, lần lượt từng lệnh trên một luồng. Hai hệ quả: (1) gửi phép tính lên Redis (HINCRBY,ZADD) thì tự động nguyên tử — còn đọc về app, sửa, ghi lại thì mất cập nhật (lab: 20 process cùng +1, ra 101 thay vì 1000); (2) một lệnh O(N) trên key lớn chặn mọi client (lab:HGETALLhash 1 triệu field chặn client khác 311 ms). Ngoài ra, kiểu ≠ encoding: Redis tự đổi cách cất theo kích thước, nên RAM nhảy bậc (zset 128 → 129 member: 948 → 11 736 byte) và thứ tự trả về của hash/set đổi theo. Chọn kiểu = chọn lệnh mình cần chạy trên server + chi phí RAM mỗi phần tử (list 10 byte, hash 47, zset 86 trên 8.8) + giới hạn "một key nằm trọn trên một node".
1. Bài toán gốc và bối cảnh ra đời
Năm 2009, Salvatore Sanfilippo (antirez) làm một dịch vụ phân tích log web theo thời gian thực cho startup của mình ở Ý (LLOOGG). Việc chính là: mỗi lượt xem
trang ghi thêm một dòng, và luôn đọc ra "N lượt xem gần nhất" của từng site. MySQL không theo kịp lượng ghi đó. Ông viết bản thử bằng Tcl giữ mọi thứ trong RAM,
rồi viết lại bằng C với kiểu list có sẵn LPUSH + LTRIM. Bản đầu tiên ra ngày 26/02/2009 (nguồn: Wikipedia, bài kể lại của
antirez; chi tiết LLOOGG chưa đối chiếu với bài gốc).
Điểm đáng học không phải "nằm trong RAM nên nhanh" — memcached đã làm vậy từ 2003. Điểm khác là value có cấu trúc, và server biết thao tác trên cấu trúc đó:
| Database SQL | memcached | Redis | |
|---|---|---|---|
| Dữ liệu ở đâu | Đĩa (có cache RAM) | RAM | RAM (persistence là phụ) |
| Value là gì | Dòng có schema | Khối byte "đục" — server không hiểu | Cấu trúc có kiểu: list, hash, set, zset, stream… |
| Muốn "+1 số lượng" trong một object | UPDATE … SET qty = qty + 1 (khoá dòng, ghi WAL) | GET cả khối → sửa ở app → SET lại (2 vòng mạng, có khe race) | HINCRBY cart:7 qty 1 — 1 vòng mạng, nguyên tử |
| Thêm 1 phần tử vào danh sách | INSERT + index | Đọc cả danh sách, nối, ghi lại cả danh sách | LPUSH O(1) |
Tối ưu cho: nhiều thao tác nhỏ mỗi giây trên dữ liệu vừa RAM, mỗi thao tác chạm một key, cần độ trễ dưới 1 ms và cần nguyên tử mà không muốn khoá. Không tối ưu cho: dữ liệu lớn hơn RAM; truy vấn tuỳ ý theo nội dung value (không có index phụ — trừ Query Engine ở bài 7); transaction nhiều key nằm trên nhiều node; value rất lớn; và bền vững dữ liệu như database (replication bất đồng bộ, xem bài Redis — những thứ cần biết mục 1.4–1.5).
2. Mô hình tư duy cốt lõi
Hai ý. Mọi thứ khác trong series là hệ quả của hai ý này.
- Một key là một cấu trúc dữ liệu sống trong RAM của server; lệnh là thuật toán chạy cạnh dữ liệu, lần lượt từng lệnh trên một luồng. Hệ quả: (a) mỗi lệnh tự nó nguyên tử — không cần khoá, vì không có lệnh thứ hai chạy cùng lúc; (b) muốn đúng khi nhiều client cùng sửa, hãy gửi phép tính lên server, đừng gửi kết quả đã tính ở app; (c) lệnh nào phải duyệt N phần tử thì chiếm luồng duy nhất đó suốt thời gian duyệt — mọi client khác đứng chờ; (d) key là đơn vị của mọi thứ: TTL, eviction, slot trong Cluster, replication — một collection không thể chia ra nhiều node.
- Kiểu (type) là hợp đồng API; encoding là cách cất trong RAM, Redis tự chọn và tự đổi theo kích thước. Nhỏ thì cất gọn (mảng byte liền nhau, quét tuần tự cũng nhanh); lớn thì đổi sang cấu trúc có con trỏ (nhanh với N lớn, tốn RAM hơn nhiều). Hệ quả: RAM nhảy bậc ở ngưỡng, thường không co lại; thứ tự phần tử trả về đổi theo encoding; chi phí "O(N)" của key nhỏ gần như bằng 0 nhưng của key lớn thì không.
01f-lost-update.txt): 20 process PHP × 50 lần → cách JSON còn 101 (lần chạy trước: 98), HINCRBY và Lua ra 1000,
WATCH ra 1000 nhưng phải chạy lại 10 611 lần vì bị chen.3. Internals và quyết định thiết kế
3.1 Từ tên key tới value: keyspace và kvobj của Redis 8
flowchart LR KS["keyspace của db
(bảng băm: tên key → con trỏ)"] --> KV["kvobj: header 16 byte
type · encoding · LRU/LFU · refcount"] META["8 byte hạn TTL
(chỉ khi key có TTL)"] -.-> KV KV --> KN["tên key nhúng ngay sau header"] KV --> VAL["value"] VAL --> V1["string: int / embstr (nhúng chung khối) / raw"] VAL --> V2["hash · set · zset: listpack hoặc intset → hashtable hoặc skiplist"] VAL --> V3["list: listpack → quicklist"] VAL --> V4["stream: radix tree + listpack
array (8.8): sliced-array"]
Mỗi database là một bảng băm từ tên key tới một object. Từ Redis 8, object đó là kvobj: header 16 byte (4 bit type, 4 bit encoding, 23 bit
refcount, 24 bit LRU/LFU, con trỏ value), tên key nhúng ngay sau header, và nếu key có TTL thì thêm 8 byte hạn đặt trước header
(object.h). Trước đây tên key là một chuỗi riêng — thêm một lần cấp phát và một con trỏ. Đánh đổi: bớt RAM và bớt nhảy con trỏ, đổi lại logic cấp
phát phức tạp hơn và một hệ quả lạ: ngưỡng embstr giờ phụ thuộc cả tên key.
### embstr khi 16 + (len(key) + 3) + (4 + len(value)) <= 64, tức len(key) + len(value) <= 41 (object.c, kvobjSet)
key "k" ( 1 byte) + value 40 byte = 41 → embstr MEMORY USAGE=64
key "k" ( 1 byte) + value 41 byte = 42 → raw MEMORY USAGE=72
key "product:12345" (13 byte) + value 28 byte = 41 → embstr MEMORY USAGE=64
key "product:12345" (13 byte) + value 29 byte = 42 → raw MEMORY USAGE=63
SET s:int 12345 → int, MEMORY USAGE=24
7.4: key product:12345 + value 44 → embstr # Redis 7.4.11: ngưỡng cũ "value ≤ 44 byte", không tính tên key
7.4: key product:12345 + value 45 → raw
Câu trả lời thuộc lòng "string ≤ 44 byte là embstr" đúng với Redis ≤ 7.x, sai với 8.8. Đây là ví dụ nhỏ cho một nguyên tắc lớn: encoding là chi
tiết cài đặt, đổi giữa các bản. Kiểm tra trên bản mình chạy bằng OBJECT ENCODING và MEMORY USAGE, đừng nhớ số.
3.2 Một kiểu, nhiều encoding — đổi theo kích thước, phần lớn không đổi ngược
| Kiểu | Encoding gọn | Ngưỡng mặc định (8.8) | Encoding lớn | Co lại có đổi ngược? |
|---|---|---|---|---|
| string | int, embstr | số nguyên 64 bit; key + value ≤ 41 byte | raw | Không (APPEND vào int → raw) |
| list | listpack | list-max-listpack-size -2 (8 KB) | quicklist | Có: còn ≤ nửa ngưỡng và chỉ 1 node |
| set | intset; listpack | set-max-intset-entries 512; set-max-listpack-entries 128, value ≤ 64 byte | hashtable | Không (SREM); riêng SPOP có count lớn dựng set mới |
| zset | listpack | zset-max-listpack-entries 128, value ≤ 64 byte | skiplist + dict | Không |
| hash | listpack / listpackex (có TTL field) | hash-max-listpack-entries 512, value ≤ 64 byte | hashtable | Không |
| stream | luôn là radix tree, mỗi nút là một listpack (stream-node-max-entries 100, stream-node-max-bytes 4096) | — | ||
| array (8.8) | sliced-array: thư mục → slice 4096 phần tử, slice thưa hoặc đặc (array-slice-size 4096) | — | ||
### Set: intset → listpack → hashtable (labs/redis/out/01b-encoding.txt)
512 số nguyên → intset 1056 byte
513 số nguyên → hashtable 23041 byte # thêm 1 phần tử, RAM ×21,8
3 số + 1 chuỗi → listpack
129 chuỗi → hashtable
SREM còn 1 phần tử → hashtable (không đổi ngược)
ngoại lệ: SPOP set:p 128 (count lớn) còn 1 → listpack (SPOP dựng set mới từ phần còn lại)
### Sorted set: listpack (≤128) → skiplist
128 member → listpack 948 byte
129 member → skiplist 11736 byte # thêm 1 member, RAM ×12,4
ZREMRANGEBYRANK còn 1 → skiplist (không đổi ngược)
### List: listpack → quicklist, CÓ đổi ngược khi co lại (≤ nửa ngưỡng, còn 1 node)
phần tử 20 byte: đổi sang quicklist ở phần tử thứ 373 (8372 byte)
RPOP dần: về lại listpack khi còn 185 phần tử
### Hash có TTL từng field (HEXPIRE, 7.4+)
HSET → listpack 41 byte
HEXPIRE 1 field → listpackex 77 byte
Vì sao thiết kế vậy: với vài chục phần tử, quét tuần tự một mảng byte nằm gọn trong cache CPU còn nhanh hơn nhảy con trỏ trong bảng băm, và tốn ít RAM hơn nhiều. Với vài nghìn phần tử thì quét tuần tự thành chậm. Ngưỡng là chỗ cắt giữa hai đường cong đó. Đã đánh đổi gì: RAM không tỉ lệ tuyến tính với số phần tử (có bậc nhảy), và không co lại khi xoá (trừ list) — muốn gọn lại phải ghi key mới. Nâng ngưỡng lên hàng nghìn để tiết kiệm RAM thì mỗi lệnh trên key đó quét lâu hơn, ăn CPU của luồng duy nhất.
3.3 RAM mỗi phần tử theo kiểu — cùng 1 triệu phần tử, chênh 8 lần
Đo bằng MEMORY USAGE key SAMPLES 0, mỗi phần tử là chuỗi 8 byte (m0000001), score/value là số nhỏ:
| Kiểu (encoding lúc 1 triệu) | byte/phần tử, N = 100 | byte/phần tử, N = 1 triệu (8.8.0) | Cùng N trên 7.4.11 | Vì sao |
|---|---|---|---|---|
| list (quicklist) | 10,4 | 10,1 | 10,1 | Phần tử nằm liền nhau trong listpack, chỉ có con trỏ ở mức node |
| array 8.8, số nguyên (sliced-array) | 14,0 | 10,0 | — | Số nhỏ nằm thẳng trong ô 8 byte (tagged pointer) |
| array 8.8, chuỗi 8 byte | 30,0 | 26,0 | — | Chuỗi ≤ 7 byte nằm trong ô; 8 byte trở lên phải cấp phát riêng |
| stream (1 field/entry) | 20,9 | 18,7 | 25,4 | ID lưu dạng chênh lệch; tên field lưu 1 lần mỗi node |
| set (hashtable) | 10,3 | 42,4 | 48,4 | Mỗi member là một chuỗi riêng + ô trong bảng băm |
| set chỉ chứa số (hashtable khi > 512) | 2,4 (intset) | 40,3 | — | Qua 512 là mất hẳn lợi thế intset |
| hash (hashtable) | 12,4 | 47,4 | 56,4 | Field + value + ô bảng băm |
| zset (skiplist + dict) | 12,4 | 85,7 | 94,2 | Hai cấu trúc cho cùng dữ liệu (hình 2) |
| 1 key string / phần tử | 47,4 | 46,1 | — | Phí mỗi key ở keyspace (xem bài Redis — những thứ cần biết, mục bucket) |
| HyperLogLog | 536 byte cả key (N = 100) → 14 360 byte cả key (N = 1 triệu) | — | Kích thước cố định, chỉ đếm gần đúng (bài 2) | |
| bitmap (id 0…999 999) | 64 byte → 131 104 byte cả key (1 bit/id) | — | Chỉ hợp khi id là số liền nhau (bài 2) | |
Đọc bảng: 1 triệu phần tử nhỏ — list 10 MB, set/hash 42–47 MB, zset 86 MB. Lên 8.8 từ 7.4, hash lớn bớt 16%, stream bớt 26%, zset bớt 9% (release notes 8.6 có ghi "giảm đáng kể RAM cho hash và zset lớn"; lab khớp). Đây là con số để làm capacity planning (mục 6) — và lý do câu "dùng zset cho mọi thứ có thứ tự" là đắt.
3.4 Một luồng chạy lệnh: chi phí lệnh là chi phí của cả hệ thống
### Thời gian Redis tự đo (SLOWLOG, µs) trên key 1 triệu phần tử (01d-lenh-on.txt)
HGET t:hash m0500000 12 HGETALL t:hash 384180
SISMEMBER t:set m0500000 8 SMEMBERS t:set 599262
LINDEX t:list 500000 19 LRANGE t:list 0 -1 88453
ZRANK t:zset m0500000 11 ZRANGE t:zset 0 -1 75585
XRANGE t:stream - + COUNT 10 50 XRANGE t:stream - + 469621
ARGET t:arrstr 500000 7 ARGETRANGE t:arrstr 0 999999 83799
### Xoá key 1 triệu phần tử: DEL (luồng chính) vs UNLINK (luồng nền)
t:hash DEL 305855 µs UNLINK 20 µs
t:set DEL 265787 µs UNLINK 25 µs
t:zset DEL 85563 µs UNLINK 21 µs
t:list DEL 619 µs UNLINK 21 µs
t:stream DEL 4891 µs UNLINK 23 µs
### Client khác thấy gì khi 1 client chạy HGETALL / DEL trên hash 1 triệu field
probe (client khác): 23236 lần PING, chậm nhất 311.3 ms (ở giây 0.91), 1 lần > 10 ms
SLOWLOG của HGETALL: 310766 µs
probe (client khác): 18480 lần PING, chậm nhất 389.0 ms (ở giây 0.83), 4 lần > 10 ms
SLOWLOG của DEL: 388595 µs
- Lệnh trỏ đúng một phần tử (
HGET,SISMEMBER,ZRANK,ARGET): 7–19 µs, không phụ thuộc kích thước key. - Lệnh trả hết: 76–599 ms — gấp 10 000 lần. Và client khác chờ đúng bằng chừng đó (probe 311 ms ≈ SLOWLOG 311 ms): đó là hệ quả trực tiếp của ý 1 trong mục 2. Với PHP-FPM, 311 ms đó là 311 ms mọi worker đang gọi Redis đứng im.
- Xoá cũng là O(N):
DELphải giải phóng từng phần tử. Hash và set đắt nhất (mỗi phần tử một lần free), list và stream rẻ vì phần tử nằm gọn trong vài nghìn node.UNLINKgỡ key khỏi keyspace rồi giao phần giải phóng cho luồng nền (chỉ khi "công giải phóng" > 64,LAZYFREE_THRESHOLDtronglazyfree.c). - "O(N)" trên giấy không luôn chậm:
LINDEXgiữa list 1 triệu phần tử chỉ 19 µs vì quicklist nhảy theo node (mỗi node chứa hàng trăm phần tử nhỏ). Ngược lại "O(1)" mỗi phần tử nhân với 1 triệu phần tử thành 0,6 giây. Đọc độ phức tạp cùng với kích thước thật.
io-threads, không chạy lệnh). Lệnh ngắn xen nhau thoải mái; một lệnh
duyệt 1 triệu phần tử chiếm trọn luồng. Lab: HGETALL 311 ms → PING của client khác chậm nhất 311,3 ms; DEL 389 ms → 389,0 ms.
UNLINK cùng key: 20 µs.3.5 Không ai gọi lệnh mà vẫn giật: key lớn hết hạn
### lazyfree-lazy-expire no: set 1 triệu phần tử, EXPIRE 2 giây, không ai gọi lệnh vào key này (01h-expire.txt, 3 lần chạy: 184 / 205 / 236 ms)
probe (client khác): 54037 lần PING, chậm nhất 236.3 ms (ở giây 1.74), 6 lần > 10 ms
LATENCY LATEST: expire-del 1791007350 238 238 expire-cycle 1791007350 237 237
### lazyfree-lazy-expire yes: cùng thí nghiệm (3 lần: 18 / 14 / 14 ms — nhiễu của máy chung)
probe (client khác): 44816 lần PING, chậm nhất 14.0 ms (ở giây 0.79), 3 lần > 10 ms
Key có TTL bị xoá trong vòng "active expire" của luồng chính. Key lớn hết hạn = một DEL O(N) xảy ra vào thời điểm không ai đoán trước, không có trong
SLOWLOG (vì không phải lệnh) — chỉ thấy ở LATENCY LATEST (sự kiện expire-del) nếu đã bật latency-monitor-threshold. Trên 8.8, cả 5 tham
số lazyfree-lazy-eviction/expire/server-del/user-del/user-flush và replica-lazy-flush mặc định đều là no (đã kiểm bằng
CONFIG GET). Eviction key lớn khi đầy RAM cũng giật y như vậy.
3.6 Bản đồ lệnh mới 8.0 → 8.8 (lấy từ COMMAND DOCS của chính server)
| Bản | Thêm gì cho kiểu dữ liệu | Ý nghĩa | Bài |
|---|---|---|---|
| 8.0 | HGETDEL, HGETEX, HSETEX; vector set (VADD, VSIM…); JSON, Bloom, Cuckoo, CMS, Top-K, t-digest, TimeSeries, Query Engine vào bản chính | TTL từng field thao tác trong 1 lệnh; hết cảnh "Redis Stack" | 3, 7 |
| 8.2 | XDELEX, XACKDEL; XADD/XTRIM … KEEPREF|DELREF|ACKED; BITOP DIFF/DIFF1/ANDOR/ONE | Trim stream không làm hỏng consumer group; phép bitmap mới | 6, 2 |
| 8.4 | SET … IFEQ/IFNE/IFDEQ/IFDNE, DELEX, DIGEST, MSETEX; XREADGROUP … CLAIM | Compare-and-set ngay trên string, không cần WATCH/Lua | 2, 6 |
| 8.6 | XADD … IDMP/IDMPAUTO, XCFGSET; HOTKEYS; policy allkeys-lrm | Producer gửi lại không tạo bản trùng (trong cửa sổ); tìm hot key | 6 |
| 8.8 | Kiểu Array (ARSET, ARRING, ARGREP, AROP… 18 lệnh); INCREX; XNACK; ZUNION/ZINTER … AGGREGATE COUNT | Mảng thưa theo index + ring buffer; INCR có TTL và chặn trần trong 1 lệnh | 4, 2, 6, 5 |
Source 8.8 còn có sẵn một kiểu OBJ_GCRA (rate limit GCRA — xem series rate limit) nhưng chỉ biên dịch khi bật
-DENABLE_GCRA; image chính thức không có lệnh này (COMMAND INFO GCRA rỗng).
4. Đảm bảo và giới hạn
| Khía cạnh | Đảm bảo | Giới hạn / điều kiện |
|---|---|---|
| Nguyên tử | Mỗi lệnh chạy trọn vẹn, không lệnh nào chen giữa. Lua/Function cũng vậy | Nhiều lệnh thì phải MULTI/EXEC (không rollback), WATCH (optimistic, phải chạy lại — lab: 10 611 lần cho 1000 lượt +1), hoặc Lua (chặn cả server khi chạy) |
| Nhất quán trên 1 node | Các lệnh xếp hàng trên 1 luồng → mọi client thấy cùng một thứ tự (linearizable trên primary) | Đọc từ replica là dữ liệu cũ; replication bất đồng bộ |
| Thứ tự phần tử trả về | list: thứ tự chèn; zset: score rồi tới member (so byte); stream: theo ID; array: theo index | hash, set: phụ thuộc encoding — nhỏ thì theo thứ tự chèn (intset thì sắp xếp), lớn thì ngẫu nhiên (lab mục 5) |
| Kích thước | string tới 512 MB; collection tới 2³²−1 phần tử (docs); array index tới 2⁶⁴−2 | Giới hạn thật là RAM và độ trễ — vài trăm nghìn phần tử đã là big key |
| Phân tán | Cluster chia key theo 16 384 slot | Một key nằm trọn trên một node: zset 50 GB không chia được; lệnh nhiều key phải cùng slot (CROSSSLOT) |
| Bền vững | Theo RDB/AOF đã cấu hình | Failover có thể mất write đã trả OK (đã chứng minh ở bài Redis — những thứ cần biết) |
Đặt vào khung lý thuyết: theo PACELC, Redis replication là PA/EL — khi mạng chia cắt thì ưu tiên còn phục vụ (failover lên replica, chấp
nhận mất phần chưa sao chép); khi bình thường thì ưu tiên độ trễ hơn nhất quán (trả OK trước, sao chép sau). WAIT giảm cửa sổ mất dữ liệu nhưng không biến Redis
thành hệ đồng thuận. Về hiệu năng, giới hạn của một node là giới hạn của một luồng: muốn nhiều hơn phải chia key ra nhiều node — và kiểu dữ liệu quyết định
có chia được không.
5. Failure modes
| # | Tình huống | Hậu quả (đo) | Phát hiện | Phục hồi / phòng |
|---|---|---|---|---|
| 1 | App đọc object (JSON) về sửa rồi ghi đè, nhiều server cùng làm | Mất cập nhật, im lặng: 101/1000 | Số liệu lệch DB; không có lỗi nào | Hash + HINCRBY/HSET từng field; Lua; SET … IFEQ (8.4); WATCH khi ít tranh chấp |
| 2 | Lệnh O(N) trên key lớn trong luồng request | Chậm toàn hệ thống: 311–599 ms mỗi lần | SLOWLOG GET, INFO commandstats (usec_per_call), latency p99 phía app | HSCAN/SSCAN/ZRANGE … LIMIT; chia key; giới hạn kích thước collection |
| 3 | Key lớn hết hạn hoặc bị evict | Giật 184–236 ms không gắn với lệnh nào | LATENCY LATEST (expire-del, eviction-del) | lazyfree-lazy-expire yes, lazyfree-lazy-eviction yes; tránh key lớn có TTL |
| 4 | Collection vượt ngưỡng encoding | RAM ×12 (zset), ×21,8 (set số) ngay tại phần tử vượt; không co lại | OBJECT ENCODING, INFO keysizes, RAM tăng bậc | Thiết kế cỡ collection dưới ngưỡng nếu có hàng triệu key nhỏ; ghi lại sang key mới để gọn lại |
| 5 | Code dựa vào thứ tự của HGETALL/SMEMBERS | Đúng ở dev (dữ liệu nhỏ), sai ở production (lớn) — nổ trễ | Bug "thỉnh thoảng sai thứ tự" chỉ ở môi trường có dữ liệu thật | Cần thứ tự thì dùng list/zset, hoặc sắp xếp ở app |
| 6 | Ghi nhầm kiểu (key đang là string mà gọi lệnh hash) | WRONGTYPE — nhưng phpredis trả false, không ném exception | Chỉ thấy nếu kiểm false hoặc đọc getLastError() | Quy ước tên key theo kiểu; test tích hợp; kiểm === false |
| 7 | Nâng 7.4 → 8.8 rồi muốn quay lại, hoặc chuyển sang Valkey | RDB format 14 không nạp được vào Redis 7.4.11 / Valkey 9.1.2; RESTORE cũng lỗi | Server cũ không khởi động: Can't handle RDB format version 14 | Kế hoạch rollback không dựa vào RDB; Redis chỉ giữ dữ liệu dựng lại được (mục 8) |
5.1 Lab: thứ tự đúng ở dev, sai ở production
HGETALL hash 3 field (listpack), ghi theo thứ tự c,a,b → c,a,b
HKEYS hash 603 field (hashtable), 6 field đầu → f576,f268,f591,f118,f427,f36
SMEMBERS set số (intset), thêm 9,3,7,1 → 1,3,7,9
SMEMBERS set chuỗi (listpack), u9,u3,u7,u1 → u9,u3,u7,u1
cùng set đó sau khi thêm 200 phần tử (hashtable), 6 phần tử đầu → x63,x8,x104,x73,x140,x34
Tài liệu Redis chỉ nói set "không có thứ tự". Lab cho thấy chuyện nguy hiểm hơn: với dữ liệu nhỏ, kết quả trông như có thứ tự (theo thứ tự chèn, hoặc tăng dần với intset). Test và màn hình dev đều đúng. Lên production, collection vượt ngưỡng, thứ tự thành ngẫu nhiên — đúng loại bẫy "nổ trễ".
5.2 Lab: ghi nhầm kiểu, Laravel không báo lỗi
redis-cli HSET vào key đang là string → WRONGTYPE Operation against a key holding the wrong kind of value
Laravel Redis::hset(...) trả về: false
phpredis getLastError(): WRONGTYPE Operation against a key holding the wrong kind of value
Redis::hget(...) trên key string trả về: false <- giống hệt "không có field"
executeRaw HSET: không ném lỗi, trả false
Laravel PhpRedisConnection::command() chỉ bắt RedisException (lỗi kết nối) rồi ném lại; lỗi phía server như WRONGTYPE thì phpredis
trả false. HGET trên key sai kiểu trả false — y hệt khi field không tồn tại — nên code "không thấy thì đọc DB" chạy tiếp vui vẻ, cache
không bao giờ được dùng, và không ai biết.
5.3 Lab: nâng cấp một chiều
### DUMP 1 key string trên Redis 8.8 rồi RESTORE sang Valkey (cách các tool migrate hay dùng)
phiên bản RDB ghi ở cuối payload DUMP (2 byte trước CRC64): 14
valkey RESTORE → ERR DUMP payload version or checksum are wrong
### RDB của Redis 8.8 (chỉ có string + hash) nạp vào máy khác
valkey/valkey:alpine: Valkey version=9.1.2 … |Can't handle RDB format version 14|Fatal error loading the DB, check server logs. Exiting.
redis:7.4-alpine: Redis version=7.4.11 … |Can't handle RDB format version 14|Fatal error loading the DB, check server logs. Exiting.
Chỉ có string và hash — không dùng kiểu mới nào — vẫn không nạp được. Kế hoạch "nâng lên 8.8, có gì thì restore bản backup vào 7.4" không chạy: backup chụp
sau khi nâng là RDB 14. Chuyển sang Valkey cũng không đi được bằng RDB, DUMP/RESTORE hay replication (replication full sync cũng gửi RDB —
suy ra, chưa thử). Đường đi còn lại: chép logic (đọc theo kiểu bằng SCAN + lệnh đọc, ghi lại bên kia) hoặc dựng lại từ nguồn sự thật.
6. Vận hành trong production
| Cần biết | Công cụ / metric | Ngưỡng gợi ý (ví dụ minh hoạ) |
|---|---|---|
| Key nào lớn | redis-cli --bigkeys, --memkeys, --keystats (dùng SCAN, chạy được trên replica); MEMORY USAGE key SAMPLES 0 | Collection > 10 000 phần tử hoặc > 1 MB thì xem lại thiết kế |
| Phân bố kích thước | INFO keysizes (8.2+): histogram theo kiểu, mỗi db (db0_distrib_hashes_items:…) | Theo dõi đuôi histogram tăng dần |
| Lệnh chậm | SLOWLOG GET, INFO commandstats (calls, usec_per_call) | slowlog-log-slower-than 10000 (10 ms) |
| Giật không do lệnh | LATENCY LATEST, LATENCY DOCTOR | Bật latency-monitor-threshold 10 |
| Key nóng | HOTKEYS START/GET (8.6+); --hotkeys cần policy LFU | Một key chiếm > 10% lệnh |
| RAM | used_memory, mem_fragmentation_ratio, evicted_keys | Dư ≥ 50% cho fork khi chụp RDB (bài Redis — những thứ cần biết mục 1.4) |
Cấu hình ảnh hưởng lớn: bật cả lazyfree-lazy-eviction, lazyfree-lazy-expire, lazyfree-lazy-server-del,
lazyfree-lazy-user-del, replica-lazy-flush (đổi lại: RAM được trả chậm hơn một chút, luồng nền tốn CPU). Các ngưỡng
*-max-listpack-*: giữ mặc định nếu chưa đo CPU. slowlog-log-slower-than và latency-monitor-threshold phải bật trước khi có sự cố.
Capacity planning — ví dụ minh hoạ: bảng xếp hạng 5 triệu học viên bằng zset: 5 000 000 × 86 byte ≈ 430 MB dữ liệu. Cộng phân mảnh (×1,2–1,5) và chỗ cho fork khi chụp RDB (tới ×2 nếu ghi nhiều lúc chụp) → máy 1–2 GB cho riêng key này. Cùng dữ liệu để trong hash (47 byte) chỉ ~240 MB nhưng mất khả năng lấy top N — chọn kiểu là chọn đánh đổi này.
Upgrade: lên 8.8 được 9–26% RAM cho collection lớn (bảng mục 3.3) và nhiều lệnh mới, nhưng là đường một chiều (mục 5.3). Trước khi nâng: chụp RDB
bằng bản cũ và giữ lại; thử nâng trên staging có dữ liệu thật; kiểm ACL vì Redis 8 thêm category mới (@json, @bloom… — release notes 8.0);
quyết định trước "nếu phải quay lại thì mất gì".
Debug lúc 3 giờ sáng — thứ tự xem: (1) LATENCY LATEST — có expire-del, eviction-del, fork không;
(2) SLOWLOG GET 20 — lệnh nào, key nào; (3) INFO commandstats — lệnh nào tăng usec_per_call; (4) redis-cli --bigkeys -i 0.1
trên replica; (5) CLIENT LIST — client nào đang chạy lệnh gì. Người trực cần hiểu mục 2 của bài này; còn lại là đọc số.
7. So sánh và ma trận quyết định
flowchart TD
Q["Cần làm gì với dữ liệu?"] --> A1{"Chỉ đọc/ghi cả khối?"}
A1 -- "có" --> T1["String (cache, token, lock)"]
A1 -- "không" --> A2{"Sửa từng phần của 1 object?"}
A2 -- "field phẳng" --> T2["Hash (HINCRBY, HEXPIRE)"]
A2 -- "cây lồng nhau" --> T3["JSON (bài 7)"]
A2 -- "không" --> A3{"Cần thứ tự?"}
A3 -- "theo điểm / thời gian" --> T4["Sorted set (top N, khoảng)"]
A3 -- "theo thứ tự chèn, lấy ở 2 đầu" --> T5["List (queue, N mới nhất)"]
A3 -- "theo index, có lỗ" --> T6["Array 8.8 (ring buffer)"]
A3 -- "log nhiều consumer, ack" --> T7["Stream"]
A3 -- "không" --> A4{"Thành viên / đếm duy nhất?"}
A4 -- "chính xác" --> T8["Set; id số liền nhau → Bitmap"]
A4 -- "gần đúng, RAM cố định" --> T9["HyperLogLog / Bloom (bài 2, 7)"]
| Nhu cầu | Kiểu Redis | Thay thế ngoài Redis | Tránh khi |
|---|---|---|---|
| Cache object, token, session | String | memcached, cache trong process | Object lớn (> 100 KB) hoặc hay sửa một phần |
| Bộ đếm | String INCR/INCREX | Cột trong PostgreSQL (dòng nóng bị khoá) | Cần đúng tuyệt đối sau failover (tiền, tồn kho cuối cùng) |
| Object nhiều field | Hash | Dòng PostgreSQL, RedisJSON | Cần lọc/tìm theo giá trị field (không có index) |
| Thành viên, chống trùng | Set | Unique index PostgreSQL | Set hàng triệu phần tử mà hay đọc hết |
| Xếp hạng, theo thời gian | Sorted set | ORDER BY + index PostgreSQL | Cần lọc theo nhiều tiêu chí cùng lúc |
| Hàng đợi việc | List / Stream | SQS, RabbitMQ, bảng queue + SKIP LOCKED | Không được mất job khi Redis failover |
| Đếm duy nhất xấp xỉ | HyperLogLog | COUNT(DISTINCT), ClickHouse uniq | Cần liệt kê hoặc cần số chính xác |
| Vị trí gần đây | Geo (trên zset) | PostGIS | Cần polygon, vùng phức tạp |
| Trục | Redis (core types) | memcached | PostgreSQL | Valkey 9.x |
|---|---|---|---|---|
| Hiệu năng 1 thao tác | µs, 1 luồng chạy lệnh | µs, đa luồng | ms (đĩa, WAL, khoá) | µs (tương thích lệnh core) |
| Độ phong phú thao tác | Cao nhất | Chỉ get/set/incr | SQL đầy đủ | Core + vài thứ riêng; không có JSON/Bloom/TS/vector/array trong bản core |
| Bền vững | Tuỳ cấu hình, mất được khi failover | Không | Có (ACID) | Như Redis |
| License | RSALv2 / SSPLv1 / AGPLv3 (8.x) | BSD | PostgreSQL License | BSD-3 |
| Tuyển người | Dễ (phổ biến) | Dễ | Dễ | Dễ nếu chỉ dùng phần chung |
Dùng khi: dữ liệu nóng, vừa RAM, thao tác khớp đúng một kiểu (đếm, xếp hạng, thành viên, hàng đợi), và mất một ít khi sự cố là chấp nhận được hoặc dựng lại được. Tránh khi: Redis sẽ là nơi duy nhất giữ dữ liệu quan trọng; cần truy vấn tuỳ ý trên value; dữ liệu một key vượt một node; hoặc team không kiểm soát được kích thước key.
8. Góc nhìn tech lead: tác động tổ chức
- Schema ẩn: Redis không có schema hay migration. Kiểu của một key là hợp đồng ngầm giữa mọi service đọc/ghi nó. Bắt buộc có bảng key (tên, kiểu, TTL,
kích thước tối đa, ai ghi) — xem Thiết kế tên key Redis. Đổi kiểu của một key đang chạy = key mới + ghi kép + đổi
chỗ đọc; không có
ALTER. - Nguyên tắc dữ liệu dựng lại được: mọi thứ trong Redis nên dựng lại được từ nguồn sự thật (PostgreSQL, event log) trong thời gian chấp nhận được. Đây vừa là cách sống sót qua failover mất write, vừa là exit strategy duy nhất chắc chắn khi RDB không mang sang chỗ khác được (lab mục 5.3).
- Vendor lock-in theo từng lệnh: Redis đổi license sang RSALv2/SSPLv1 từ 7.4 (3/2024); Linux Foundation fork bản BSD cuối cùng thành Valkey; từ 8.0
(5/2025) Redis thêm lựa chọn AGPLv3. Lab trên Valkey 9.1.2: lệnh core, TTL từng field của hash (
HEXPIRE,HGETEX,HSETEX,HGETDEL) vàSET … IFEQchạy;DELEX,INCREX, Array,XADD IDMP, JSON, Bloom, TimeSeries, vector set đềuERR unknown command. Quy tắc: dùng lệnh riêng của Redis 8.x thì gói sau một lớp adapter trong code và ghi vào ADR; managed service (ElastiCache, MemoryDB…) hỗ trợ gì thì phải kiểm bản cụ thể — chưa verify trong bài này. - Tốc độ team và onboarding: người mới cần hiểu 2 ý ở mục 2 trước khi viết code Redis. Checklist review: lệnh O(N) nào chạy trên đường request? Collection có
giới hạn kích thước/TTL không? Có ai đọc-sửa-ghi object không? Kiểm
falsetừ phpredis chưa? - Đoạn chép vào ADR: "Dùng Redis 8.x cho dữ liệu nóng có thể dựng lại. Mỗi key trong bảng key có kiểu, TTL, kích thước tối đa. Không lệnh O(N) không giới hạn trên đường request. Lệnh chỉ có ở Redis 8.x đi qua adapter. Rollback nâng cấp = dựng instance bản cũ và nạp lại từ nguồn sự thật, không dùng RDB."
9. Anti-patterns và bài học thực tế
- "Redis là chỗ cất JSON": mọi object thành string JSON, sửa một field phải đọc–sửa–ghi cả object. Mất cập nhật (lab 101/1000) và tốn băng thông. Dùng hash cho object phẳng, JSON type nếu thật sự cần cây (bài 7).
- Collection không có trần: list log không
LTRIM, set "user đã xem" không TTL, zset sliding window không dọn. Sớm muộn thành big key với mọi failure mode ở mục 5. - Lệnh O(N) trên đường request:
KEYS,SMEMBERS/HGETALLtrên key lớn,LRANGE 0 -1. Lab: 0,3–0,6 giây mỗi lần cho cả hệ thống. - Mỗi object nhỏ một key: 46 byte phí cho mỗi key. Instagram (2011) kể việc ánh xạ 300 triệu ảnh → user: 1 triệu key string tốn ~70 MB; gom thành 1 000 hash × 1 000 field (dạng gọn) còn ~16 MB (nguồn: bài Storing hundreds of millions of simple key-value pairs in Redis, Instagram Engineering). Bài Redis — những thứ cần biết đo lại trên 8.8 và chỉ ra bẫy bucket sai cỡ.
- Dựa vào thứ tự của hash/set: đúng ở dev, sai ở production (mục 5.1).
- Bỏ qua
falsetừ phpredis: lỗi kiểu dữ liệu biến thành "cache miss" vĩnh viễn (mục 5.2). - Nâng cấp không có đường lui: RDB 14 (mục 5.3).
Postmortem công khai: chưa tìm được postmortem công khai nào ghi rõ nguyên nhân là một lệnh O(N) cụ thể; tài liệu "diagnosing latency" của Redis và các hướng dẫn vận hành đều xếp big key và lệnh O(N) vào nhóm nguyên nhân giật hàng đầu. Phần số liệu trong bài này là lab tự dựng.
Điều docs không nói rõ mà người vận hành lâu năm biết: thứ tự của hash/set phụ thuộc encoding; key hết hạn cũng là một lệnh xoá trên luồng chính; ngưỡng embstr đổi theo bản; nâng cấp Redis thường là một chiều với file dữ liệu.
10. Câu hỏi phụ interviewer hay hỏi tiếp
- Redis đơn luồng sao vẫn nhanh?
- Dữ liệu trong RAM, cấu trúc đơn giản, không khoá, I/O multiplexing (epoll). "Đơn luồng" là phần chạy lệnh; đọc/ghi socket có thể dùng I/O thread, xoá lớn dùng luồng nền. Cái giá: một lệnh chậm làm chậm tất cả (lab 311 ms).
- Type và encoding khác nhau thế nào? Cho ví dụ.
- Type là API (zset), encoding là cách cất (listpack khi ≤ 128 member, skiplist + dict khi lớn hơn). RAM nhảy 948 → 11 736 byte ở member 129 và không co lại.
- Vì sao sorted set tốn RAM nhất?
- Hai cấu trúc cho một dữ liệu: skiplist để đi theo thứ tự/rank, dict để tra score theo member O(1). Lab 1 triệu member: 85,7 byte/member, gấp ~2 hash, ~8 list.
- Big key là gì, tìm và xử lý ra sao?
- Key mà lệnh O(N) trên nó tốn hàng chục ms trở lên, hoặc to tới mức làm lệch RAM giữa các node. Tìm:
--bigkeys/--memkeys,INFO keysizes, SLOWLOG. Xử lý: đọc dần bằng*SCAN, chia key, đặt trần, xoá bằngUNLINK, bật lazyfree. DELvàUNLINKkhác gì?UNLINKcó nhược điểm gì?UNLINKgỡ key ngay rồi giải phóng ở luồng nền: 20 µs thay vì 306 ms. Nhược: RAM trả về chậm hơn (used_memorychưa giảm ngay), luồng nền ăn CPU; key nhỏ thì vẫn xoá đồng bộ (ngưỡng 64).- Cập nhật một phần object an toàn khi nhiều server cùng ghi?
- Gửi phép tính lên server (
HINCRBY,HSETtừng field), hoặc Lua; compare-and-set bằngSET … IFEQ(8.4+);WATCHchỉ hợp khi ít tranh chấp (lab: 10 611 lần chạy lại cho 1 000 lượt). - Redis có transaction không?
MULTI/EXEC: các lệnh chạy liền nhau, không ai chen, nhưng không rollback và không có điều kiện bên trong;WATCHthêm kiểm tra lạc quan. Lua cho logic có điều kiện.- Nâng Redis 7 lên 8 có rủi ro gì?
- File dữ liệu một chiều (RDB 14 không nạp được vào 7.4 hay Valkey), ACL category mới, ngưỡng encoding/embstr đổi; được lợi 9–26% RAM cho collection lớn và lệnh mới.
- Khi nào không nên dùng Redis?
- Khi nó thành nơi duy nhất giữ dữ liệu không được mất, khi cần truy vấn tuỳ ý, khi một collection lớn hơn một node, hoặc khi không kiểm soát được kích thước key.
Phụ lục: lab đã chạy
Môi trường: Redis 8.8.0 (redis:alpine, chạy với --save '' --appendonly no), Redis 7.4.11 (redis:7.4-alpine), Valkey 9.1.2
(valkey/valkey:alpine), PHP 8.3.33 + phpredis 6.3.0 + Laravel 12.64 (image laravel-app, project laravel-livewire mount read-only,
REDIS_PREFIX=lab:). Container tạm tmp-rdt-* trên network tmp-rdt-net, không đụng Redis của project. Máy 4 vCPU dùng chung.
cd labs/redis
./01-nen-tang.sh # chạy hết → out/01a … 01i
./01-nen-tang.sh encoding # 1 phần: cmds | encoding | ram | ram74 | on | order | race | wrongtype | expire | compat
docker rm -f $(docker ps -aq -f name=tmp-rdt-); docker network rm tmp-rdt-net # dọn sau khi xong mọi lab
Chưa verify: hành vi của big key trên Redis Cluster khi resharding (di chuyển một key lớn); replication full sync từ 8.8 sang Valkey (suy ra từ RDB 14);
managed service hỗ trợ lệnh nào; ảnh hưởng của io-threads tới các số đo (lab chạy io-threads 1).