Hash và Set — object có field, tập không trùng: TTL từng field, listpack, phép tập hợp, rút thăm

Hash và set là hai kiểu "anh em": set là hash không có value. Hash giữ object có field — giỏ hàng, hồ sơ, bộ đếm theo đối tượng. Set giữ tập không trùng — tag, lượt like, người đã xem, danh sách người tham gia. Bài này đi vào những chỗ hay sai khi dùng thật: TTL từng field (Redis 7.4, thêm lệnh ở 8.0) giữ và mất lúc nào, cái giá CPU khi cố giữ listpack, phép giao/hợp trên set lớn, và một chuyện ít ai kiểm: rút thăm bằng SRANDMEMBER không công bằng. Ví dụ: giỏ hàng, giữ hàng flash sale, lọc sản phẩm theo tag (e-commerce); ai đang online trong phòng học, rút thăm học bổng (LMS). Nền tảng ở bài 1.

Thuật ngữ dùng trong bài
field / value (cặp khoá–giá trị bên trong một hash) · member (phần tử của set) · field TTL / HFE (hash field expiration — hạn sống riêng cho từng field, từ Redis 7.4) · ebuckets (cấu trúc radix tree xếp các field theo thời điểm hết hạn, để Redis tìm field cần xoá mà không quét cả hash) · listpack / listpackex (mảng byte liền nhau; bản "ex" có thêm chỗ cho TTL) · hashtable (bảng băm) · intset (mảng số nguyên đã sắp xếp) · set algebra (giao, hợp, hiệu tập hợp: SINTER, SUNION, SDIFF) · cardinality (số phần tử) · chi-square (con số đo độ lệch so với phân phối đều; đều thì xấp xỉ "số phần tử − 1") · presence (trạng thái online/offline) · heartbeat (tín hiệu "tôi còn đây" gửi định kỳ).
Kiểm chứng: tài liệu Redis qua Context7 ngày 03/10/2026 (HEXPIRE, HPERSIST, release notes 7.4 về hash field expiration, SPOP — mục "Distribution of returned elements", SRANDMEMBER — mục "Specification of the behavior when count is passed"). Source Redis 8.8.0: t_hash.c, ebuckets.h, t_set.c, dict.c (dictGetFairRandomKey). Lab chạy thật: Redis 8.8.0, PHP 8.3.33 + phpredis 6.3.0 + Laravel 12.64. Output: labs/redis/out/03*.txt. Máy 4 vCPU dùng chung.

Trả lời 30 giây

Hash = một key chứa một bảng field → value nhỏ: sửa từng field nguyên tử (HINCRBY), chung TTL/slot/eviction, và từ 7.4 có TTL riêng từng field (HEXPIRE, 8.0 thêm HSETEX/HGETEX/HGETDEL). Bẫy: HSET lên field có TTL thì xoá TTL (như SET); TTL tốn thêm ~13 byte/field; nâng ngưỡng listpack để đỡ RAM thì HSET chậm từ 13 µs lên 3,4 ms. Set = hash không value: kiểm tra thành viên O(1), giao/hợp ngay trên server; nhưng giao/hợp hai set 1 triệu phần tử tốn 0,5–1,25 giây (chặn cả server), member không có TTL riêng (làm "ai đang online" bằng set là sai — dùng hash TTL hoặc zset), và SRANDMEMBER/SPOP không đều với set lớn (lab: phần tử bị chọn 69–388 lần, kỳ vọng 200) — rút thăm công bằng thì để app sinh số ngẫu nhiên và lấy theo thứ hạng trong zset.

1. Bài toán gốc và bối cảnh ra đời

KiểuBài toánTrước đó làm sao, giới hạn gìMốc
HashObject có nhiều field, hay sửa từng field (số lượng trong giỏ, điểm từng bài)JSON trong string: đọc–sửa–ghi cả object, mất cập nhật (bài 1: 101/1000). Mỗi field một key: tốn ~46 byte phí mỗi keyCó từ 2.0 (2010). Instagram (2011) dùng hash nhỏ để nén 300 triệu cặp key–value
Hash field TTLDữ liệu cùng nhóm nhưng hết hạn khác nhau: giữ từng món hàng 10 phút, token từng thiết bị, online từng ngườiTách mỗi thứ một key (mất gom nhóm) hoặc tự dọn bằng cron/zset7.4 (7/2024): "một trong những yêu cầu được hỏi nhiều nhất" (release notes); 8.0 thêm HSETEX/HGETEX/HGETDEL
SetKhông trùng + kiểm tra thành viên + giao/hợp: tag, follow chung, người đã likeBảng DB + unique index: đúng nhưng mỗi lần kiểm tra là một truy vấn; giao/hợp là JOINCó từ bản đầu

Tối ưu cho: object nhỏ/vừa nhiều field; thao tác trên một field/member; tập vừa phải với phép giao nhỏ. Không tối ưu cho: truy vấn theo giá trị field (không có index phụ); object lồng nhau (JSON — bài 7); tập hàng triệu phần tử mà hay đọc hết hoặc giao/hợp toàn bộ; cần thứ tự (zset — bài 5).

2. Mô hình tư duy cốt lõi

  1. Hash = một key chứa một bảng key–value nhỏ. Từng field sửa riêng, nguyên tử; nhưng mọi thứ "cấp key" là chung: TTL của key, eviction, slot trong Cluster, DEL. Hệ quả: gom đúng thứ hay đi cùng nhau vào một hash (cùng vòng đời, cùng node); đừng gom thứ không liên quan chỉ để đỡ RAM (cùng bị evict, cùng nóng).
  2. Set = hash chỉ có field. Member là duy nhất, kiểm tra O(1), không có thứ tự, không có giá trị kèm theo, không có TTL từng member. Muốn thêm thông tin cho member (thời điểm, điểm) là đã sang zset hoặc hash.

Cả hai có cùng ba encoding theo kích thước (bài 1 mục 3.2): nhỏ thì listpack/intset (gọn, quét tuần tự), lớn thì hashtable (nhanh, ~42–53 byte mỗi phần tử).

3. Internals và quyết định thiết kế

3.1 TTL từng field: ebuckets, và lệnh nào giữ/xoá TTL

Redis cần biết field nào sắp hết hạn mà không quét cả hash. Field có TTL được ghi vào ebuckets — radix tree theo thời điểm hết hạn, mỗi lá là một đoạn nhỏ nhiều field (ebuckets.h); mỗi hash có TTL field lại được ghi vào một ebuckets cấp database với hạn sớm nhất của nó, để vòng "active expire" lấy ra. Giá: hash nhỏ đổi sang encoding listpackex, hash lớn tốn thêm RAM mỗi field.

### key h có field a, b, cả hai TTL 100 giây; chạy lệnh rồi xem HTTL a        (03a-hash-ttl.txt)
HSET h a 9                               → HTTL a = -1      ← ghi đè value = mất TTL
HINCRBY h a 1                            → HTTL a = 100
HSETEX h KEEPTTL FIELDS 1 a 9            → HTTL a = 100
HSETEX h FIELDS 1 a 9                    → HTTL a = -1      ← HSETEX không kèm hạn cũng xoá TTL
HGETEX h PERSIST FIELDS 1 a              → HTTL a = -1      (cố ý)
HSETNX h a 9                             → HTTL a = 100     (không ghi vì field đã có)
HDEL h b                                 → HTTL a = 100

field a TTL 100, rồi EXPIRE cả key 5 giây → TTL key = 5, HTTL a = 100 (key hết hạn thì mất cả hash)
mọi field hết hạn → EXISTS h = 0  (field cuối hết hạn thì key bị xoá)
HSETEX FNX/FXX: HSETEX h2 FNX EX 60 FIELDS 1 a 1 → 1; lần 2 → 0; HGET → 1
HGETDEL h2 FIELDS 1 a → 1; EXISTS h2 → 0   (lấy token dùng 1 lần)

### Field hết hạn có còn bị đếm không? hash 10 000 field, 9 999 field PX 300, 1 field không TTL
ngay sau khi đặt TTL: HLEN=10000
sau thêm 0.1 s: HLEN=1      HGET sv:5=(nil)  MEMORY USAGE=147571
sau thêm 2.0 s: HLEN=1      HGET sv:5=(nil)  MEMORY USAGE=147571

### RAM của TTL từng field (100 000 field, hashtable)
không TTL: 5261842 byte (52.6 byte/field) | có TTL: 6537546 byte (65.4 byte/field)

Ba điều rút ra: (1) quy tắc giống string ở bài 2 — ghi đè value thì mất TTL, sửa tại chỗ (HINCRBY) thì giữ; (2) field hết hạn không còn bị đếm trong HLEN (lab: sau 0,1 giây chỉ còn 1); (3) xoá gần hết field không trả RAM của bảng băm — còn 1 field vẫn 147 KB (bảng 16 384 ô chưa co lại sau nhiều giây).

Ai đang online: set giữ mãi, hash TTL từng field tự xoá Bên trái là set online có 6 sinh viên; ba người tắt app nhưng vẫn nằm trong set. Bên phải là hash room, mỗi field có thanh TTL ngắn dần; ba người gửi heartbeat nên thanh được nạp lại, ba người còn lại hết hạn và bị xoá; HLEN giảm từ 6 xuống 3. Ai đang online trong phòng học? SADD online sv:1 … sv:6 HSETEX room EX 30 FIELDS 1 sv:N 1 sv:1sv:2sv:3 sv:4sv:5sv:6 sv:1,3,5 đã tắt app — vẫn ở đây sv:1 sv:2 sv:3 sv:4 sv:5 sv:6 ← heartbeat← heartbeat← heartbeat SCARD online = 6 HLEN room = 6HLEN room = 3 lab 100 000 user: set 47,6 B/user, sai mãi | zset 85,5 B, phải tự dọn (18 ms/50 000) | hash TTL 65,4 B, tự đúng
Hình 1 — Set không có TTL từng member, nên "ai đang online" bằng set chỉ đúng nếu mọi client nhớ SREM khi đi — điều không bao giờ xảy ra khi mất mạng hay tắt app. Hash với TTL từng field: mỗi heartbeat HSETEX … EX 30 nạp lại hạn; ai im lặng quá 30 giây tự biến mất, HLEN luôn đúng. Lab (03f-online.txt): sau 5 giây, set vẫn đếm 100 000; hash đếm đúng 50 000.

3.2 Listpack đổi CPU lấy RAM — và vì sao không nên nâng ngưỡng

### HGET/HSET trên hash 10 000 field: hashtable (mặc định) vs ép listpack (hash-max-listpack-entries 20000)      (03b-hash-listpack.txt)
ht  hashtable  RAM=616688   HGET đầu=11  µs HGET cuối=10   µs HSET field mới=13   µs HSET sửa field đầu=14µs
lp  listpack   RAM=160031   HGET đầu=8   µs HGET cuối=105  µs HSET field mới=3391 µs HSET sửa field đầu=11µs

### HSCAN COUNT chỉ là gợi ý: hash nhỏ (listpack) trả hết trong 1 lần
HSCAN small 0 COUNT 10 (listpack) → lần đầu trả 500 field
HSCAN big   0 COUNT 10 (hashtable) → lần đầu trả 10 field
Listpack quét tuần tự, hashtable nhảy thẳng Hàng trên: listpack 10 000 field là một khối byte 160 KB; con trỏ phải quét từ đầu tới cuối để tìm f09999, mất 105 µs; thêm field mới phải cấp phát lại và chép cả khối, 3 391 µs. Hàng dưới: hashtable 617 KB; băm tên field ra ô và nhảy thẳng tới, 10 µs. Cùng hash 10 000 field, hai cách cất listpack160 KB f00000|v00000|f00001|v00001|…………………|f09999|v09999 HGET f09999quét: 105 µs HSET field mới: cấp phát lại + chép cả khối 160 KB → 3 391 µs mỗi lần hashtable617 KB hash("f09999") → ô 6 → 10 µs; HSET field mới 13 µs listpack gọn hơn 3,9 lần nhưng mỗi lệnh chiếm luồng duy nhất lâu hơn 10–260 lần
Hình 2 — Listpack là mảng byte liền nhau: tìm field phải quét, thêm field phải cấp phát lại và chép cả khối. Với vài trăm field thì không đáng kể; ép 10 000 field vào listpack (để đỡ 75% RAM) thì HSET từ 13 µs thành 3 391 µs — và vì Redis chạy lệnh trên một luồng, mọi client khác chờ theo. Đó là lý do ngưỡng mặc định là 512.

HSCAN: với encoding gọn, Redis không có "con trỏ giữa chừng" nên trả cả hash trong một lần, bất kể COUNT. Code nào giả định "mỗi lần ≤ COUNT phần tử" để chia lô sẽ nhận 500 thay vì 10. COUNT chỉ là gợi ý công việc cho hashtable.

3.3 Object: hash, string JSON hay RedisJSON?

### 10 000 sản phẩm, mỗi cái 12 field (JSON 187 byte)             (03c-object.txt)
string  →  3046712 byte tổng,    305 byte/sản phẩm, encoding p:1 = raw
hash    →  2407336 byte tổng,    241 byte/sản phẩm, encoding p:1 = listpack
json    →  5281536 byte tổng,    528 byte/sản phẩm, encoding p:1 = raw

### trừ tồn kho 1 đơn vị
string: GET (~187 byte về) → json_decode → stock-1 → SET (~187 byte đi), 2 vòng mạng, có race (bài 1)
hash:   HINCRBY p:1 stock -1 → 56, 1 lệnh ~30 byte
json:   JSON.NUMINCRBY pj:1 $.stock -1 → [56], 1 lệnh

Object phẳng ≤ 512 field, value ≤ 64 byte: hash vừa gọn nhất (listpack) vừa sửa từng field được. JSON trong string gọn thứ hai nhưng mất khả năng sửa từng phần. RedisJSON tốn gần gấp đôi string — đổi lại có cây lồng nhau và đường dẫn JSONPath (phân tích ở bài 7). Lưu ý: hash chỉ chứa chuỗi — kiểu số, null, mảng con phải tự quy ước.

3.4 Phép tập hợp trên set lớn

### a, b: 1 triệu phần tử (trùng 50%); c: 100 phần tử                 (03d-set-ops.txt)
lệnh                                  kết quả        µs
SISMEMBER a m777                              1          9
SMISMEMBER a m1 m2 x                      1,1,0         10
SINTER c a                            100 dòng        146
SINTER a c                            100 dòng        572
SINTERCARD 2 a b                         500000     498013
SINTERCARD 2 a b LIMIT 10                    10         25
SINTERSTORE ab a b                       500000     716990
SUNIONSTORE u a b                       1500000    1251693
SDIFFSTORE d a b                         500000     927497
MEMORY USAGE u (1,5 triệu) = 66666186

SINTER sắp các set theo kích thước, duyệt set nhỏ nhất và hỏi set còn lại "có không?" — nên giao với một set nhỏ là rẻ (146–572 µs) dù set kia 1 triệu. Giao/hợp/hiệu hai set lớn là duyệt hàng triệu phần tử: 0,5–1,25 giây chặn cả server, và SUNIONSTORE tạo thêm 67 MB. SINTERCARD … LIMIT (7.0+) dừng sớm khi đủ số cần: 25 µs cho câu hỏi "có ít nhất 10 người chung không?".

3.5 Rút ngẫu nhiên: SRANDMEMBER và SPOP không đều

### SRANDMEMBER key (không count) lặp nhiều lần, đếm số lần mỗi phần tử được chọn          (03e-set-random.txt)
small   10 phần tử (listpack),    200 000 lần: kỳ vọng 20000 lần/phần tử | ít nhất 19839, nhiều nhất 20157 | chi-square 5 (đều thì ≈ 9)
big     1 000 phần tử (hashtable), 200 000 lần: kỳ vọng 200 lần/phần tử | ít nhất 69, nhiều nhất 388 | chi-square 13870 (đều thì ≈ 999)
big     lần 2:                                 kỳ vọng 200 lần/phần tử | ít nhất 75, nhiều nhất 376 | chi-square 13977 (đều thì ≈ 999)
huge    100 000 phần tử,         2 000 000 lần: kỳ vọng 20 lần/phần tử | ít nhất 1, nhiều nhất 59 | chi-square 226056 (đều thì ≈ 99999)

### cách đều: zset điểm = số thứ tự, app tự rút số ngẫu nhiên (random_int) rồi ZRANGE i i
zset 1 000 phần tử, 200 000 lần: kỳ vọng 200 | ít nhất 159, nhiều nhất 244 | chi-square 994 (đều thì ≈ 999)
Số lần được chọn: SRANDMEMBER lệch, random_int đều Hai biểu đồ cột cho 40 người đầu trong 1 000 người, mỗi cột là số lần được chọn sau 200 000 lần rút. Bên trái SRANDMEMBER: cột cao thấp rất lệch, từ 111 tới 382. Bên phải zset cộng random_int: các cột gần đều quanh 200. SRANDMEMBER (hashtable 1 000) zset + random_int của app u1…u40 sau 200 000 lần rút — lab thật cùng 40 người, cùng số lần rút 200 ít nhất 111, nhiều nhất 382 lần (40 cột này) 173–231 lần, chi-square 994 ≈ 999
Hình 3 — Mỗi cột là số lần một người được chọn sau 200 000 lần rút (dữ liệu thật, 03e-hist.txt). SRANDMEMBER trên set dạng hashtable chọn một đoạn liên tiếp của bảng băm rồi lấy ngẫu nhiên trong đoạn đó (dictGetFairRandomKey — chính comment trong source nói nó chỉ "làm phân phối tốt hơn"); phần tử nằm sau nhiều ô trống được chọn nhiều hơn. Rút thăm công bằng: app sinh số bằng CSPRNG rồi lấy theo thứ hạng.

Docs của SPOP có ghi "không phù hợp khi cần phân phối đều đảm bảo", nhưng nhiều người đọc lướt. Lab cho thấy mức lệch không nhỏ: người may nhất có cơ hội gấp 5 lần người kém nhất (388/69). Với set nhỏ dạng listpack thì đều (chi-square 5 ≈ 9). SRANDMEMBER key -N (count âm) dùng cùng cách chọn, nên cũng lệch; HRANDFIELD và ZRANDMEMBER cũng vậy (lab bài 5: chi-square 13 436 và 13 829 cho 1 000 phần tử).

4. Đảm bảo và giới hạn

HashSet
Nguyên tửMỗi lệnh trên nhiều field là nguyên tử (HSET nhiều cặp, HGETDEL)Mỗi lệnh; SPOP không trả cùng member cho 2 client
Độ phức tạpField: O(1) hashtable, O(N) listpack (N ≤ 512); HGETALL O(N)SISMEMBER O(1); SINTER O(N×M) với N là set nhỏ nhất; SUNION/SDIFF O(tổng)
Hết hạnKey và từng field (7.4+), độ phân giải ms; field hết hạn không còn trong HLENChỉ cả key
Thứ tựKhông (listpack thì theo thứ tự chèn — đừng dựa vào)Không (intset tăng dần, listpack thứ tự chèn — đừng dựa vào)
Ngẫu nhiênHRANDFIELD cũng lệch khi là hashtable (lab bài 5: 67–388 lần cho kỳ vọng 200)Không đều khi là hashtable (lab)
Kích thước2³²−1 field (docs); thực tế: vài chục nghìn đã cần chia2³²−1 member; giao/hợp 2 set 1 triệu đã 0,5–1,25 s
ClusterMột hash nằm trọn một nodeGiao/hợp nhiều key phải cùng slot (hash tag) → dồn tải về một node

5. Failure modes

#Tình huốngHậu quả (đo)Phát hiệnChữa / phòng
1HSET (hoặc HSETEX không kèm hạn) lên field đang có TTLField thành vĩnh viễn: hàng giữ chỗ không bao giờ nhả, token không hết hạnHTTL trả -1 bất ngờ; RAM tăng dầnHSETEX … KEEPTTL hoặc luôn kèm EX; HINCRBY thì giữ TTL
2Đặt EXPIRE cho cả key khi các field có TTL dài hơnKey hết hạn sớm, mất cả hashDữ liệu biến mất trước hạn fieldMột nguồn hạn duy nhất cho một hash
3Nâng hash-max-listpack-entries để đỡ RAMHSET 13 µs → 3 391 µs ở 10 000 field; chậm toàn hệ thống khi hash lớn dần (nổ trễ)SLOWLOG có HSET; CPU Redis tăngGiữ mặc định; muốn đỡ RAM thì chia bucket đúng cỡ (bài 1)
4Xoá nhiều field để "dọn RAM"Bảng băm không co lại: còn 1 field vẫn 147 KBMEMORY USAGE không giảmChép sang key mới rồi RENAME; hoặc xoá cả key
5SINTER/SUNIONSTORE hai set lớn trên đường request (feed, gợi ý)0,5–1,25 s chặn cả server mỗi lầnSLOWLOGTính trước bằng job, SINTERCARD … LIMIT, giới hạn kích thước set
6Rút thăm/trúng thưởng bằng SRANDMEMBER/SPOPKhông công bằng: 69–388 lần cho kỳ vọng 200Không ai thấy, trừ khi kiểm thống kê — rủi ro pháp lý/uy tínCSPRNG ở app + lấy theo thứ hạng (zset/array/DB)
7"Ai đang online" bằng setKhông ai bị xoá khi rớt mạng: đếm 100 000 thay vì 50 000Số online chỉ tăngHash TTL từng field (65 B/user) hoặc zset theo thời gian + dọn (86 B/user)
8Một hash/set chung cho mọi user (sessions với 1 triệu field)Big key + hot key: không chia được qua Cluster, mọi request vào một node--bigkeys, HOTKEYS (8.6)Một key mỗi user, hoặc bucket theo hash của id

6. Vận hành trong production

6.1 Code Laravel (đã chạy thật)

use Illuminate\Support\Facades\Redis;

// Giỏ hàng = hash: field = sku, value = số lượng; cả giỏ sống 7 ngày
Redis::hincrby("cart:$userId", $sku, $qty);
Redis::expire("cart:$userId", 7 * 86400);

// Bớt 1, về 0 thì xoá field — một script, không ai thấy trạng thái 0 hay âm
$decr = <<<'LUA'
local q = redis.call('HINCRBY', KEYS[1], ARGV[1], -1)
if q <= 0 then redis.call('HDEL', KEYS[1], ARGV[1]) end
return q
LUA;
Redis::eval($decr, 1, "cart:$userId", $sku);        // phpredis tự thêm prefix cho KEYS của eval

// Giữ hàng flash sale 10 phút cho từng sku (8.0+), chỉ khi chưa giữ
Redis::hsetex("hold:$userId", [$sku => 1], ['FNX', 'EX' => 600]);

// Ai đang online: mỗi heartbeat nạp lại hạn 30 giây
Redis::hsetex("room:$roomId", ["sv:$studentId" => time()], ['EX' => 30]);
$online = Redis::hlen("room:$roomId");

// Lọc sản phẩm theo tag
$ids = Redis::sinter('tag:ao-thun', 'tag:cotton', 'tag:sale');
### output                                                        (03g-laravel.txt)
HGETALL = {"sku-1":"1","sku-2":"1"}, TTL = 604800
bớt sku-2 → 0, giỏ còn {"sku-1":"1"}
Redis::hsetex('hold:u42', ['sku-9' => 1], ['EX' => 600]) → 1; HTTL = [600]
lần 2 với FNX (chỉ ghi khi field chưa có) → 0; HGET = 1
HGETDEL (lấy rồi xoá, dùng cho token 1 lần) → {"sku-9":"1"}; EXISTS = 0
HLEN = 3, HTTL sv:7 = [30]
SINTER ao-thun ∩ cotton ∩ sale = ["p3"]
SINTERCARD [ao-thun, cotton] = 2
key thật trong Redis: lab:cart:u42, lab:room:lms-101, lab:tag:ao-thun, lab:tag:cotton, lab:tag:sale

phpredis 6.3 có sẵn method cho HSETEX/HGETEX/HGETDEL/HEXPIRE/HTTL nên đi qua facade bình thường, có prefix. Bẫy còn lại của đoạn code: hincrby rồi expire là 2 lệnh — process chết ở giữa thì giỏ không có hạn (gộp bằng MULTI hoặc Lua nếu quan trọng); sinter trả mảng không thứ tự.

Rút thăm công bằng (đã chạy, laravel/03-fair.php): ZADD lottery 0 u1 1 u2 … khi đăng ký; lúc rút: $i = random_int(0, Redis::zcard('lottery') - 1); [$winner] = Redis::zrange('lottery', $i, $i); rồi ZREM người trúng trong cùng một Lua/MULTI nếu rút nhiều lần.

7. So sánh và ma trận quyết định

"Ai đang online" (100 000 user)RAM/user (lab)Tự hết hạnĐếm đúngGhi chú
Set47,6 BKhôngKhông (100 000 thay vì 50 000)Chỉ dùng khi luôn có sự kiện "rời đi" chắc chắn
Zset score = thời điểm85,5 BKhông — phải ZREMRANGEBYSCORE (18 ms / 50 000)Có, khi đếm bằng ZCOUNT theo thời gianCó thêm "ai mới online gần đây nhất"
Hash TTL từng field (8.0)65,4 BCóCó (HLEN)Gọn nhất về code; cần Redis 7.4/8.0+
Lưu objectRAM/sản phẩm (lab)Sửa 1 fieldLồng nhauDùng khi
Hash241 BHINCRBY/HSET, 1 lệnhKhôngObject phẳng, hay sửa từng field
String JSON305 BĐọc–sửa–ghi (hoặc SET … IFEQ)CóĐọc nguyên khối, hiếm sửa
RedisJSON528 BJSON.NUMINCRBY, 1 lệnhCóCần cả lồng nhau lẫn sửa từng phần (bài 7)
Dòng PostgreSQL—UPDATEjsonbNguồn sự thật, cần truy vấn theo field
Thành viênChính xácXoá đượcRAMDùng khi
SetCóCó~42 B/member (hashtable)Cần liệt kê, giao/hợp nhỏ
Bitmap (bài 2)CóCó1 bit × id lớn nhấtId số liền nhau
Bloom/Cuckoo (bài 7)Có dương tính giảCuckoo có~1–2 byte/phần tửChỉ cần "chắc chắn chưa có"
Unique index DBCóCóĐĩaLuật nghiệp vụ phải đúng tuyệt đối (like không trùng — bài forum like)

Dùng hash khi: object phẳng, sửa từng field, cùng vòng đời; dữ liệu cùng nhóm nhưng hết hạn khác nhau (TTL field). Tránh khi: cần tìm theo giá trị field, object lồng nhau sâu, một hash gom dữ liệu của mọi người. Dùng set khi: cần không trùng, kiểm tra thành viên, giao/hợp với một tập nhỏ. Tránh khi: cần TTL từng phần tử, cần thứ tự/điểm, cần ngẫu nhiên công bằng, hay giao/hợp hai tập rất lớn trên đường request.

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. Presence bằng set + SREM khi logout: người rớt mạng không bao giờ logout. Dùng TTL.
  2. Feed = SUNION của mọi người mình theo dõi, tính mỗi request: 1,25 s cho 2 set 1 triệu. Tính trước (fan-out on write) hoặc giới hạn.
  3. Hash khổng lồ "tất cả session"/"tất cả cấu hình": big key, hot key, không chia được.
  4. Đếm "đã thích" bằng SCARD rồi tin như sổ sách: failover mất được member; luật "không like trùng" phải nằm ở DB (bài forum like).
  5. Rút thăm bằng SPOP: lệch (mục 3.5).

Nguồn công khai: release notes Redis 7.4 (field TTL là yêu cầu được hỏi nhiều), bài Instagram (2011) về hash nhỏ; docs SPOP ghi rõ không đảm bảo phân phối đều. Chưa tìm được postmortem công khai về rút thăm lệch do SRANDMEMBER — số liệu ở đây là lab tự dựng.

Điều docs không nói rõ: mức lệch thật sự của SRANDMEMBER; HSET xoá TTL field; HSCAN trả cả hash nhỏ; xoá field không trả RAM của bảng băm.

10. Câu hỏi phụ interviewer hay hỏi tiếp

Lưu object vào hash hay JSON string?
Phẳng và hay sửa từng field → hash (241 B so với 305 B, sửa 1 lệnh nguyên tử). Đọc nguyên khối, lồng nhau, hiếm sửa → JSON string. Cần cả hai → RedisJSON (528 B).
TTL từng field hoạt động thế nào, có bẫy gì?
7.4+: HEXPIRE…; field có hạn nằm trong ebuckets theo thời điểm hết hạn, Redis xoá chủ động. Bẫy: HSET xoá TTL, EXPIRE key thắng tất cả, tốn thêm ~13 B/field.
Làm "ai đang online" thế nào?
Hash TTL từng field với heartbeat (đếm bằng HLEN), hoặc zset score = lần cuối thấy + ZCOUNT theo khoảng và dọn định kỳ. Không dùng set.
Độ phức tạp của SINTER?
O(N×M): N là set nhỏ nhất, M số set. Giao với set nhỏ rẻ; hai set lớn thì đắt (0,5 s cho 1 triệu ∩ 1 triệu). SINTERCARD … LIMIT dừng sớm.
Rút thăm ngẫu nhiên công bằng trong Redis?
Không dựa vào SRANDMEMBER/SPOP cho set lớn (lab lệch 5 lần). Sinh số ngẫu nhiên bằng CSPRNG ở app, lấy theo thứ hạng trong zset (hoặc index trong array 8.8).
Vì sao không nâng hash-max-listpack-entries lên 10 000 cho đỡ RAM?
Listpack quét tuần tự và chép cả khối khi thêm: HSET 3,4 ms thay vì 13 µs ở 10 000 field — trên luồng chạy lệnh duy nhất.
Hash và set trong Cluster?
Mỗi key nằm trọn một node; lệnh nhiều key (SINTER, SUNIONSTORE) cần cùng slot → hash tag → dễ dồn tải. Thiết kế để giao/hợp trong một key-space nhỏ.

Phụ lục: lab đã chạy

Môi trường: Redis 8.8.0 (redis:alpine), PHP 8.3.33 + phpredis 6.3.0 + Laravel 12.64 (project laravel-livewire mount read-only, REDIS_PREFIX=lab:). Container tmp-rdt-redis, network tmp-rdt-net. Máy 4 vCPU dùng chung.

cd labs/redis
./03-hash-set.sh            # chạy hết → out/03a … 03g
./03-hash-set.sh random     # 1 phần: ttl | listpack | object | setops | random | online | laravel

Chưa verify: bảng băm có co lại sau thời gian dài hơn vài giây không; SINTER trong Cluster. (HRANDFIELD, ZRANDMEMBER đã đo ở bài 5: lệch y như SRANDMEMBER.)