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êmHSETEX/HGETEX/HGETDEL). Bẫy:HSETlên field có TTL thì xoá TTL (nhưSET); TTL tốn thêm ~13 byte/field; nâng ngưỡng listpack để đỡ RAM thìHSETchậ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/SPOPkhô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ểu | Bài toán | Trước đó làm sao, giới hạn gì | Mốc |
|---|---|---|---|
| Hash | Object 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 key | Có từ 2.0 (2010). Instagram (2011) dùng hash nhỏ để nén 300 triệu cặp key–value |
| Hash field TTL | Dữ 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ười | Tách mỗi thứ một key (mất gom nhóm) hoặc tự dọn bằng cron/zset | 7.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 |
| Set | Không trùng + kiểm tra thành viên + giao/hợp: tag, follow chung, người đã like | Bảng DB + unique index: đúng nhưng mỗi lần kiểm tra là một truy vấn; giao/hợp là JOIN | Có 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
- 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). - 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).
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
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)
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
| Hash | Set | |
|---|---|---|
| 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ạp | Field: 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ạn | Key và từng field (7.4+), độ phân giải ms; field hết hạn không còn trong HLEN | Chỉ 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ên | HRANDFIELD 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ước | 2³²−1 field (docs); thực tế: vài chục nghìn đã cần chia | 2³²−1 member; giao/hợp 2 set 1 triệu đã 0,5–1,25 s |
| Cluster | Một hash nằm trọn một node | Giao/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ống | Hậu quả (đo) | Phát hiện | Chữa / phòng |
|---|---|---|---|---|
| 1 | HSET (hoặc HSETEX không kèm hạn) lên field đang có TTL | Field thành vĩnh viễn: hàng giữ chỗ không bao giờ nhả, token không hết hạn | HTTL trả -1 bất ngờ; RAM tăng dần | HSETEX … 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ơn | Key hết hạn sớm, mất cả hash | Dữ liệu biến mất trước hạn field | Một nguồn hạn duy nhất cho một hash |
| 3 | Nâng hash-max-listpack-entries để đỡ RAM | HSET 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ăng | Giữ mặc định; muốn đỡ RAM thì chia bucket đúng cỡ (bài 1) |
| 4 | Xoá nhiều field để "dọn RAM" | Bảng băm không co lại: còn 1 field vẫn 147 KB | MEMORY USAGE không giảm | Chép sang key mới rồi RENAME; hoặc xoá cả key |
| 5 | SINTER/SUNIONSTORE hai set lớn trên đường request (feed, gợi ý) | 0,5–1,25 s chặn cả server mỗi lần | SLOWLOG | Tính trước bằng job, SINTERCARD … LIMIT, giới hạn kích thước set |
| 6 | Rút thăm/trúng thưởng bằng SRANDMEMBER/SPOP | Không công bằng: 69–388 lần cho kỳ vọng 200 | Không ai thấy, trừ khi kiểm thống kê — rủi ro pháp lý/uy tín | CSPRNG ở app + lấy theo thứ hạng (zset/array/DB) |
| 7 | "Ai đang online" bằng set | Không ai bị xoá khi rớt mạng: đếm 100 000 thay vì 50 000 | Số online chỉ tăng | Hash TTL từng field (65 B/user) hoặc zset theo thời gian + dọn (86 B/user) |
| 8 | Mộ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
- Metric:
INFO keysizes→db0_distrib_hashes_items,db0_distrib_sets_items(8.2+) để thấy đuôi phân bố kích thước dài ra;INFO keyspacecó thêmsubexpiry= số hash đang có field mang TTL (lab 8.8:db0:keys=2,expires=1,avg_ttl=99923,subexpiry=1); SLOWLOG choSINTER*/SUNION*/HGETALL. - Cấu hình:
hash-max-listpack-entries 512,hash-max-listpack-value 64,set-max-intset-entries 512,set-max-listpack-entries 128— giữ mặc định trừ khi đã đo CPU như mục 3.2. - Capacity — ví dụ minh hoạ: giỏ hàng 200 000 người dùng hoạt động × trung bình 5 món, mỗi giỏ một hash listpack ~100 byte + phí key ~50 byte → ~30 MB. Presence 100 000 người trong lớp học online: hash TTL 65 B → 6,5 MB. Tag cho 50 000 sản phẩm, 2 000 tag, trung bình 8 tag/sản phẩm → 400 000 member trong set ~ 17 MB (tính theo 42 B/member).
- Upgrade: TTL từng field cần 7.4+;
HSETEX/HGETEX/HGETDELcần 8.0+ (Valkey 9.1.2 cũng có — lab bài 1). Replica bản cũ không hiểu lệnh mới → nâng replica trước. - Debug lúc 3 giờ sáng: "số online sai" → kiểm
HTTLvài field (có bịHSETxoá TTL không); "Redis chậm định kỳ" → SLOWLOG tìmSUNIONSTOREcủa job tính feed.
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 đúng | Ghi chú |
|---|---|---|---|---|
| Set | 47,6 B | Không | Khô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ểm | 85,5 B | Không — phải ZREMRANGEBYSCORE (18 ms / 50 000) | Có, khi đếm bằng ZCOUNT theo thời gian | Có thêm "ai mới online gần đây nhất" |
| Hash TTL từng field (8.0) | 65,4 B | Có | Có (HLEN) | Gọn nhất về code; cần Redis 7.4/8.0+ |
| Lưu object | RAM/sản phẩm (lab) | Sửa 1 field | Lồng nhau | Dùng khi |
|---|---|---|---|---|
| Hash | 241 B | HINCRBY/HSET, 1 lệnh | Không | Object phẳng, hay sửa từng field |
| String JSON | 305 B | Đọc–sửa–ghi (hoặc SET … IFEQ) | Có | Đọc nguyên khối, hiếm sửa |
| RedisJSON | 528 B | JSON.NUMINCRBY, 1 lệnh | Có | Cần cả lồng nhau lẫn sửa từng phần (bài 7) |
| Dòng PostgreSQL | — | UPDATE | jsonb | Nguồn sự thật, cần truy vấn theo field |
| Thành viên | Chính xác | Xoá được | RAM | Dùng khi |
|---|---|---|---|---|
| Set | Có | 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ất | Id 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 DB | Có | Có | Đĩa | Luậ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
- Gom vào một hash là quyết định vòng đời: các field chung eviction, chung node, chung
DEL. Viết rõ trong bảng key "hash này gom gì, vì sao". - Tính năng TTL field kéo theo yêu cầu phiên bản: 7.4+/8.0+ ở mọi môi trường (dev, CI, staging, managed service). Nếu còn môi trường cũ, code phải có đường lui (zset + dọn).
- Công bằng là yêu cầu nghiệp vụ: rút thăm, chia mã giảm giá, chọn người kiểm tra ngẫu nhiên — ghi vào yêu cầu "phân phối đều, kiểm được" và bắt test thống kê (như lab). Đừng để một lệnh tiện tay quyết định chuyện có thể bị khiếu nại.
- Exit strategy: hash và set là phần lõi, chạy được trên mọi bản tương thích (Valkey 9.1.2 có cả
HSETEX/HGETDEL). Dữ liệu dựng lại được từ DB (giỏ hàng nên lưu song song khi checkout; tag lấy từ bảng quan hệ). - Tốc độ team: pattern ở mục 6.1 nên thành vài class nhỏ có test (giỏ hàng, presence, tag filter, lottery) thay vì rải lệnh Redis khắp controller.
9. Anti-patterns và bài học thực tế
- Presence bằng set +
SREMkhi logout: người rớt mạng không bao giờ logout. Dùng TTL. - Feed =
SUNIONcủ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. - Hash khổng lồ "tất cả session"/"tất cả cấu hình": big key, hot key, không chia được.
- Đếm "đã thích" bằng
SCARDrồ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). - 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:HSETxoá TTL,EXPIREkey 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 +ZCOUNTtheo 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 … LIMITdừng sớm. - Rút thăm ngẫu nhiên công bằng trong Redis?
- Không dựa vào
SRANDMEMBER/SPOPcho 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-entrieslên 10 000 cho đỡ RAM? - Listpack quét tuần tự và chép cả khối khi thêm:
HSET3,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.)