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ình lazyfree-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à payload DUMP; 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ệnh XADD, INCREX, XREADGROUP, MEMORY USAGE, INFO keysizes). Đọc source Redis 8.8.0 (object.h, object.c hàm kvobjSet, 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ằng COMMAND 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: HGETALL hash 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 SQLmemcachedRedis
Dữ liệu ở đâuĐĩa (có cache RAM)RAMRAM (persistence là phụ)
Value là gìDòng có schemaKhối byte "đục" — server không hiểuCấu trúc có kiểu: list, hash, set, zset, stream…
Muốn "+1 số lượng" trong một objectUPDATE … 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áchINSERT + indexĐọc cả danh sách, nối, ghi lại cả danh sáchLPUSH 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.

  1. 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.
  2. 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.
GET rồi SET mất cập nhật, HINCRBY thì không Bên trái: server A và B cùng GET giỏ hàng, cùng đọc qty 5, cùng tính 6 rồi lần lượt SET 6; Redis cuối cùng là 6, mất một lần cộng. Bên phải: A và B gửi HINCRBY; Redis tự cộng lần lượt 5 thành 6 rồi 7. GET → sửa ở app → SET (JSON) HINCRBY: gửi phép tính lên Redis server Aserver Bserver Aserver B Redis Redis qty 5qty 6qty 6 qty 5qty 6qty 7 GET GET 5 5 SET 6 SET 6 HINCRBY +1 HINCRBY +1 đọc 5, tính 5+1 = 6 đọc 5, tính 5+1 = 6 2 lần +1 mà chỉ tăng 1: lần ghi sau đè lần trước lab: 20 process × 50 lần +1 → còn 98–101 / 1000 Redis cộng lần lượt trên 1 luồng → 7, đúng lab: HINCRBY, Lua, WATCH đều ra 1000 / 1000
Hình 1 — Cùng một việc "+1 số lượng trong giỏ hàng". Bên trái là cách hay gặp khi coi Redis là chỗ cất JSON: đọc cả object về app, sửa, ghi đè. Hai server đọc cùng giá trị cũ thì một lần cộng biến mất. Bên phải gửi phép tính lên server: Redis chạy từng lệnh một nên không có khe hở. Lab (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ểuEncoding gọnNgưỡng mặc định (8.8)Encoding lớnCo lại có đổi ngược?
stringint, embstrsố nguyên 64 bit; key + value ≤ 41 byterawKhông (APPEND vào int → raw)
listlistpacklist-max-listpack-size -2 (8 KB)quicklistCó: còn ≤ nửa ngưỡng và chỉ 1 node
setintset; listpackset-max-intset-entries 512; set-max-listpack-entries 128, value ≤ 64 bytehashtableKhông (SREM); riêng SPOP có count lớn dựng set mới
zsetlistpackzset-max-listpack-entries 128, value ≤ 64 byteskiplist + dictKhông
hashlistpack / listpackex (có TTL field)hash-max-listpack-entries 512, value ≤ 64 bytehashtableKhông
streamluô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
Sorted set đổi encoding ở member thứ 129 Một thanh listpack dài dần khi thêm member 1, 64, 128; RAM 948 byte. Ở member thứ 129, listpack mờ đi, thay bằng skiplist nhiều tầng và một dict; cột RAM nhảy lên 11 736 byte, gấp 12 lần. ZADD lần lượt — zset-max-listpack-entries 128 1 member64 member 128 member — vẫn là listpack129 member → đổi sang skiplist + dict [m1|1][m2|2][m3|3]… liền nhau, không con trỏ skiplist (thứ tự theo score) dict: member → score m1 → 1m2 → 2… 129 mục 2 cấu trúc cho 1 dữ liệu: skiplist để lấy theo thứ tự, dict để ZSCORE O(1) RAM 948 B 11 736 B ×12
Hình 2 — Encoding gọn (listpack) là một mảng byte liền nhau: tốn ít RAM, nhưng chèn và tìm phải quét — chỉ chấp nhận được khi nhỏ. Qua ngưỡng, Redis đổi sang cấu trúc có con trỏ: với zset là skiplist cộng thêm một dict, nên tốn gấp đôi các kiểu khác. Lab: 128 → 129 member, 948 → 11 736 byte; xoá còn 1 member vẫn là skiplist.

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 = 100byte/phần tử, N = 1 triệu (8.8.0)Cùng N trên 7.4.11Vì sao
list (quicklist)10,410,110,1Phầ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,010,0—Số nhỏ nằm thẳng trong ô 8 byte (tagged pointer)
array 8.8, chuỗi 8 byte30,026,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,918,725,4ID lưu dạng chênh lệch; tên field lưu 1 lần mỗi node
set (hashtable)10,342,448,4Mỗ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,447,456,4Field + value + ô bảng băm
zset (skiplist + dict)12,485,794,2Hai cấu trúc cho cùng dữ liệu (hình 2)
1 key string / phần tử47,446,1—Phí mỗi key ở keyspace (xem bài Redis — những thứ cần biết, mục bucket)
HyperLogLog536 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
Một luồng chạy lệnh: HGETALL lớn chặn mọi client Trục thời gian của luồng chính Redis. GET và PING ngắn chạy xong ngay. Sau đó HGETALL hash 1 triệu field chiếm 311 ms; PING của client B gửi tới đúng lúc đó phải chờ hết 311 ms. Cuối trục, UNLINK một key lớn trả lời ngay, phần giải phóng RAM chạy trên luồng nền. luồng chínhclient Bluồng nền Trục thời gian của luồng chạy lệnh (lab: hash 1 triệu field) GETPING PING BUNLINK HGETALL 1 triệu field: 311 ms, mọi client chờ PING của B tới lúc này → chờ 311 ms mới được trả lời UNLINK trả lời sau ~20 µs; giải phóng RAM ở luồng nền
Hình 3 — Redis chạy lệnh trên một luồng (I/O thread chỉ đọc/ghi socket khi bậ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ảnThêm gì cho kiểu dữ liệuÝ nghĩaBài
8.0HGETDEL, HGETEX, HSETEX; vector set (VADD, VSIM…); JSON, Bloom, Cuckoo, CMS, Top-K, t-digest, TimeSeries, Query Engine vào bản chínhTTL từng field thao tác trong 1 lệnh; hết cảnh "Redis Stack"3, 7
8.2XDELEX, XACKDEL; XADD/XTRIM … KEEPREF|DELREF|ACKED; BITOP DIFF/DIFF1/ANDOR/ONETrim stream không làm hỏng consumer group; phép bitmap mới6, 2
8.4SET … IFEQ/IFNE/IFDEQ/IFDNE, DELEX, DIGEST, MSETEX; XREADGROUP … CLAIMCompare-and-set ngay trên string, không cần WATCH/Lua2, 6
8.6XADD … IDMP/IDMPAUTO, XCFGSET; HOTKEYS; policy allkeys-lrmProducer gửi lại không tạo bản trùng (trong cửa sổ); tìm hot key6
8.8Kiểu Array (ARSET, ARRING, ARGREP, AROP… 18 lệnh); INCREX; XNACK; ZUNION/ZINTER … AGGREGATE COUNTMảng thưa theo index + ring buffer; INCR có TTL và chặn trần trong 1 lệnh4, 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ảoGiớ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ậyNhiề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 nodeCá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 indexhash, 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ướcstring tới 512 MB; collection tới 2³²−1 phần tử (docs); array index tới 2⁶⁴−2Giới hạn thật là RAM và độ trễ — vài trăm nghìn phần tử đã là big key
Phân tánCluster chia key theo 16 384 slotMộ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ữngTheo RDB/AOF đã cấu hìnhFailover 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ốngHậu quả (đo)Phát hiệnPhục hồi / phòng
1App đọc object (JSON) về sửa rồi ghi đè, nhiều server cùng làmMất cập nhật, im lặng: 101/1000Số liệu lệch DB; không có lỗi nàoHash + HINCRBY/HSET từng field; Lua; SET … IFEQ (8.4); WATCH khi ít tranh chấp
2Lệnh O(N) trên key lớn trong luồng requestChậm toàn hệ thống: 311–599 ms mỗi lầnSLOWLOG GET, INFO commandstats (usec_per_call), latency p99 phía appHSCAN/SSCAN/ZRANGE … LIMIT; chia key; giới hạn kích thước collection
3Key lớn hết hạn hoặc bị evictGiật 184–236 ms không gắn với lệnh nàoLATENCY LATEST (expire-del, eviction-del)lazyfree-lazy-expire yes, lazyfree-lazy-eviction yes; tránh key lớn có TTL
4Collection vượt ngưỡng encodingRAM ×12 (zset), ×21,8 (set số) ngay tại phần tử vượt; không co lạiOBJECT ENCODING, INFO keysizes, RAM tăng bậcThiế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
5Code 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ậtCần thứ tự thì dùng list/zset, hoặc sắp xếp ở app
6Ghi nhầm kiểu (key đang là string mà gọi lệnh hash)WRONGTYPE — nhưng phpredis trả false, không ném exceptionChỉ 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
7Nâng 7.4 → 8.8 rồi muốn quay lại, hoặc chuyển sang ValkeyRDB format 14 không nạp được vào Redis 7.4.11 / Valkey 9.1.2; RESTORE cũng lỗiServer cũ không khởi động: Can't handle RDB format version 14Kế 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ếtCông cụ / metricNgưỡng gợi ý (ví dụ minh hoạ)
Key nào lớnredis-cli --bigkeys, --memkeys, --keystats (dùng SCAN, chạy được trên replica); MEMORY USAGE key SAMPLES 0Collection > 10 000 phần tử hoặc > 1 MB thì xem lại thiết kế
Phân bố kích thướcINFO 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ậmSLOWLOG GET, INFO commandstats (calls, usec_per_call)slowlog-log-slower-than 10000 (10 ms)
Giật không do lệnhLATENCY LATEST, LATENCY DOCTORBật latency-monitor-threshold 10
Key nóngHOTKEYS START/GET (8.6+); --hotkeys cần policy LFUMột key chiếm > 10% lệnh
RAMused_memory, mem_fragmentation_ratio, evicted_keysDư ≥ 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ầuKiểu RedisThay thế ngoài RedisTránh khi
Cache object, token, sessionStringmemcached, cache trong processObject lớn (> 100 KB) hoặc hay sửa một phần
Bộ đếmString INCR/INCREXCộ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 fieldHashDòng PostgreSQL, RedisJSONCần lọc/tìm theo giá trị field (không có index)
Thành viên, chống trùngSetUnique index PostgreSQLSet hàng triệu phần tử mà hay đọc hết
Xếp hạng, theo thời gianSorted setORDER BY + index PostgreSQLCần lọc theo nhiều tiêu chí cùng lúc
Hàng đợi việcList / StreamSQS, RabbitMQ, bảng queue + SKIP LOCKEDKhông được mất job khi Redis failover
Đếm duy nhất xấp xỉHyperLogLogCOUNT(DISTINCT), ClickHouse uniqCần liệt kê hoặc cần số chính xác
Vị trí gần đâyGeo (trên zset)PostGISCần polygon, vùng phức tạp
TrụcRedis (core types)memcachedPostgreSQLValkey 9.x
Hiệu năng 1 thao tácµs, 1 luồng chạy lệnhµs, đa luồngms (đĩa, WAL, khoá)µs (tương thích lệnh core)
Độ phong phú thao tácCao nhấtChỉ get/set/incrSQL đầy đủCore + vài thứ riêng; không có JSON/Bloom/TS/vector/array trong bản core
Bền vữngTuỳ cấu hình, mất được khi failoverKhôngCó (ACID)Như Redis
LicenseRSALv2 / SSPLv1 / AGPLv3 (8.x)BSDPostgreSQL LicenseBSD-3
Tuyển ngườiDễ (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

9. Anti-patterns và bài học thực tế

  1. "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).
  2. 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.
  3. Lệnh O(N) trên đường request: KEYS, SMEMBERS/HGETALL trên key lớn, LRANGE 0 -1. Lab: 0,3–0,6 giây mỗi lần cho cả hệ thống.
  4. 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ỡ.
  5. Dựa vào thứ tự của hash/set: đúng ở dev, sai ở production (mục 5.1).
  6. Bỏ qua false từ phpredis: lỗi kiểu dữ liệu biến thành "cache miss" vĩnh viễn (mục 5.2).
  7. 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ằng UNLINK, bật lazyfree.
DEL và UNLINK khác gì? UNLINK có nhược điểm gì?
UNLINK gỡ 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_memory chư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, HSET từng field), hoặc Lua; compare-and-set bằng SET … IFEQ (8.4+); WATCH chỉ 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; WATCH thê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).