String, Bitmap, Bitfield, HyperLogLog — một mảng byte, bốn cách nhìn
String là kiểu dùng nhiều nhất: cache, session, bộ đếm, lock, rate limit, idempotency key. Ít người để ý rằng bitmap, bitfield và HyperLogLog
cũng là string — TYPE trả về string cho cả bốn. Bài này đi từ mảng byte đó ra bốn cách dùng, kèm hai nhóm lệnh mới
làm đổi cách viết code: compare-and-set ngay trên server (SET … IFEQ, DELEX — Redis 8.4) và bộ đếm có TTL + trần trong một lệnh
(INCREX — Redis 8.8). Ví dụ trộn LMS (điểm danh, lượt xem bài học) và e-commerce (giỏ hàng, tồn kho). Code ứng dụng bằng Laravel 12.64 + phpredis 6.3.
Nên đọc bài 1 trước (kiểu ≠ encoding, một luồng chạy lệnh).
Thuật ngữ dùng trong bài
SDS (simple dynamic string — chuỗi của Redis: có độ dài đi kèm, chứa được byte bất kỳ, có chỗ trống dự phòng ở đuôi) · binary-safe (chứa được mọi byte, kể cả\0) · preallocation (cấp phát dư trước để lần nối sau khỏi cấp phát lại) · compare-and-set / CAS (chỉ ghi nếu giá trị hiện tại đúng bằng giá trị mình đã đọc) · digest (giá trị băm ngắn đại diện cho cả chuỗi dài; ở đây là XXH3) · offset (vị trí bit/byte tính từ đầu chuỗi) · popcount (đếm số bit 1, CPU có lệnh riêng) · bitfield (nhiều số nguyên nhỏ, mỗi số vài bit, nằm sát nhau trong một chuỗi) · overflow WRAP/SAT/FAIL (khi vượt miền: quay vòng / dừng ở trần / từ chối) · cardinality (số phần tử khác nhau) · HyperLogLog (thuật toán đếm số phần tử khác nhau gần đúng bằng bộ nhớ cố định) · register (thanh ghi — một ô 6 bit trong HyperLogLog) · sparse / dense (dạng thưa nén cho HLL ít phần tử / dạng đặc 12 KB) · standard error (sai số chuẩn — khoảng 68% lần đếm lệch không quá con số này).
Kiểm chứng: tài liệu Redis qua Context7 ngày 03/10/2026 (SETBIT,BITOPvới 4 phép mới của 8.2,BITFIELD,PFCOUNT— mục "HyperLogLog representation",INCREX, release notes 8.2/8.4). Source Redis 8.8.0:sds.c(_sdsMakeRoomFor,SDS_MAX_PREALLOC),hyperloglog.c(HLL_P 14,HLL_BITS 6). Source Laravel 12.64:Cache/RedisStore.php,Redis/Connections/PhpRedisConnection.php. Bài gốc: blog Spool (2011) về bitmap, antirez.com/news/75 (2014) về HyperLogLog. Lab chạy thật: Redis 8.8.0, PHP 8.3.33 + phpredis 6.3.0 + Laravel 12.64. Output:labs/redis/out/02*.txt.
Trả lời 30 giây
String của Redis là một mảng byte có độ dài (SDS), tối đa 512 MB. Có bốn cách nhìn mảng đó: nguyên khối (cache, token —GET/SET), con số (bộ đếm —INCR,INCREX), mảng bit (cờ theo id số —SETBIT/BITCOUNT/BITOP) và 16 384 thanh ghi (đếm duy nhất gần đúng —PFADD/PFCOUNT). Chọn sai cách nhìn thì trả giá bằng RAM: điểm danh 100 000 sinh viên một ngày tốn 16 KB bằng bitmap nhưng 2,5 MB bằng set; 800 000 người xem khác nhau tốn 14 KB bằng HyperLogLog (lệch 1%) nhưng 34,7 MB bằng set. Ba bẫy hay dính:SETxoá TTL cũ (key sống mãi), bitmap với id lớn (1 bit ở offset cuối = 640 MB RAM và chặn server 0,48 giây), cộng tiền bằngINCRBYFLOAT(1000 lần 19 999,99 ra 19 999 989,999999999829015). Từ 8.4, cập nhật cả khối an toàn bằngSET … IFEQthay vìWATCH/Lua; từ 8.8,INCREXgộp INCR + EXPIRE + chặn trần thành một lệnh.
1. Bài toán gốc và bối cảnh ra đời
| Cách nhìn | Có từ | Bài toán nó giải | Giải pháp trước đó và giới hạn |
|---|---|---|---|
| String nguyên khối | Bản đầu (2009) | Cache kết quả, session, token | memcached làm được, nhưng value mặc định tối đa 1 MB và không có thao tác trên value |
Bộ đếm INCR | Bản đầu | Đếm lượt xem, rate limit — nhiều server cùng tăng một số | Đọc–cộng–ghi ở app thì mất lượt (bài 1: 101/1000); UPDATE … +1 trong DB thì khoá dòng nóng |
| Bitmap | 2.2 (2010) | Cờ có/không theo id số: user hoạt động hôm nay, điểm danh | Set hoặc bảng DB tốn hàng chục byte mỗi id. Spool (2011) kể: 128 triệu user, "số người dùng trong ngày" tính dưới 50 ms, 16 MB |
| Bitfield | 3.2 (2016) | Hàng triệu bộ đếm nhỏ (0–100, 0–65 535) cạnh nhau | Mỗi bộ đếm một key/field tốn 40+ byte cho vài bit dữ liệu |
| HyperLogLog | 2.8.9 (4/2014) | Đếm số phần tử khác nhau rất lớn bằng bộ nhớ cố định | Set chính xác nhưng RAM tỉ lệ với số phần tử. Thuật toán của Flajolet và cộng sự (2007); antirez đưa vào Redis kèm dạng thưa cho HLL nhỏ |
Tối ưu cho: giá trị nhỏ đọc/ghi nguyên khối; phép toán trên một số hoặc trên bit ngay tại server; đếm/đánh dấu rất nhiều thứ trong RAM nhỏ. Không tối ưu cho: sửa một phần cấu trúc lồng nhau (dùng hash/JSON); id thưa hoặc không phải số; đếm cần chính xác tuyệt đối sau sự cố; giá trị lớn (mỗi lần đọc/ghi chuyển cả khối qua mạng và chiếm luồng chạy lệnh).
2. Mô hình tư duy cốt lõi
Một string là một mảng byte có độ dài, không quan tâm bên trong chứa gì. Lệnh quyết định cách đọc mảng đó.
flowchart LR B["Một string = mảng byte (SDS), tối đa 512 MB"] --> V1["Nguyên khối
GET / SET / SET IFEQ"] B --> V2["Số nguyên 64 bit / số thực
INCR / INCRBYFLOAT / INCREX"] B --> V3["Mảng bit, bit thứ N = id N
SETBIT / BITCOUNT / BITOP"] B --> V4["Mảng số nguyên k bit
BITFIELD u7, u16…"] B --> V5["16 384 thanh ghi 6 bit
PFADD / PFCOUNT / PFMERGE"]
Hệ quả: (a) TYPE của bitmap và HLL đều là string — GET lấy được nguyên mảng để sao lưu/chuyển đi, và lỡ tay SET đè
lên thì mất sạch mà không báo lỗi kiểu; (b) cả bốn chung giới hạn 512 MB và chung "một key nằm trọn trên một node"; (c) với bitmap, RAM do offset lớn nhất
quyết định, không phải số bit được bật; (d) mọi lệnh ghi đều nguyên tử trên cả mảng, nên đếm và đánh dấu từ nhiều server không cần khoá.
3. Internals và quyết định thiết kế
3.1 SDS: có độ dài đi kèm, cấp phát dư khi nối
Chuỗi C thường kết thúc bằng \0 nên không chứa được byte 0 và muốn biết độ dài phải đếm. SDS để độ dài và dung lượng trong header, nên
STRLEN là O(1) và chứa được dữ liệu nhị phân (ảnh, protobuf, bitmap). Khi nối (APPEND, SETRANGE, SETBIT),
_sdsMakeRoomFor cấp phát dư: dưới 1 MB thì gấp đôi, từ 1 MB trở lên thì cộng thêm 1 MB (SDS_MAX_PREALLOC). Đánh đổi: nối nhiều lần nhanh
(ít lần cấp phát lại) nhưng tốn RAM thừa ở đuôi.
### APPEND dần vs SET một lần (cùng 100 000 byte) (02a-string.txt)
s:append STRLEN=100000 MEMORY USAGE=196640 # 1 000 lần APPEND 100 byte
s:set STRLEN=100000 MEMORY USAGE=114712 # 1 lần SET → APPEND tốn hơn 71%
### SETRANGE ở offset lớn: Redis cấp phát cả đoạn ở giữa
SETRANGE s:far 10000000 x → 14215 µs, STRLEN=10000001, MEMORY USAGE=10485784
SETRANGE s:far2 536870912 x → ERR string exceeds maximum allowed size (proto-max-bulk-len)
Ba encoding của string (int, embstr, raw) và ngưỡng mới "tên key + value ≤ 41 byte" của Redis 8.8 đã đo ở
bài 1 mục 3.1.
3.2 TTL: lệnh nào giữ, lệnh nào xoá
### key đang có TTL 100 giây, chạy lệnh rồi xem TTL (02a-string.txt)
SET k 2 → TTL -1 ← xoá TTL
SET k 2 KEEPTTL → TTL 100
INCR k → TTL 100
INCRBY k 5 → TTL 100
APPEND k 0 → TTL 100
SETRANGE k 0 9 → TTL 100
GETSET k 3 → TTL -1 ← xoá TTL
SET k 3 GET → TTL -1 ← xoá TTL
GETEX k PERSIST → TTL -1 (cố ý)
Quy tắc: lệnh thay cả value (SET, GETSET) coi như tạo key mới, xoá TTL — trừ khi có KEEPTTL; lệnh sửa
value tại chỗ (INCR, APPEND, SETRANGE, SETBIT) giữ TTL. Code "làm mới cache" bằng SET không
kèm EX sẽ biến key có hạn thành key vĩnh viễn — RAM rò từ từ, chỉ thấy sau vài tuần.
3.3 Bộ đếm: INCR, INCRBYFLOAT và INCREX (8.8)
### lỗi kiểu và tràn số (02b-counter.txt)
INCR trên 'abc' → ERR value is not an integer or out of range
INCR trên 2^63-1 → ERR increment or decrement would overflow
INCRBY trên '1.5' → ERR value is not an integer or out of range
### INCRBYFLOAT: cộng tiền bằng số thực
10 lần +0.1 → 1
1e16 INCRBYFLOAT 1.5 → 10000000000000001.5
1e20 INCRBYFLOAT 1 → 100000000000000000000 ← mất +1
1000 lần +19999.99 (đúng: 19999990) → 19999989.999999999829015
INCR đọc chuỗi thành số nguyên 64 bit, cộng, ghi lại thành chuỗi — đúng tuyệt đối trong miền int64. INCRBYFLOAT dùng long double
rồi in ra tối đa 17 chữ số: 10 lần 0,1 "trông đúng", nhưng 1000 lần 19 999,99 lệch ở chữ số thứ 18, và số lớn thì nuốt mất phần lẻ. Tiền luôn lưu số nguyên theo
đơn vị nhỏ nhất (đồng, xu) và dùng INCRBY.
INCREX (8.8) giải bài toán cũ của rate limit: INCR rồi EXPIRE là hai lệnh, process chết ở giữa thì key không bao giờ hết
hạn (series rate limit, bài 3 mục 3.2, đã tái hiện user bị chặn vĩnh viễn). INCREX làm cả hai trong một lệnh, thêm
trần/sàn, và trả về [giá trị mới, phần thật sự đã cộng]:
lần 1: INCREX rl:u1 BYINT 1 UBOUND 5 EX 60 ENX → 1 1
lần 5: INCREX rl:u1 BYINT 1 UBOUND 5 EX 60 ENX → 5 1
lần 6: INCREX rl:u1 BYINT 1 UBOUND 5 EX 60 ENX → 5 0 ← chạm trần: không cộng, phần cộng = 0 → từ chối request
đã có TTL 30, gọi lại với EX 60 ENX → TTL vẫn 30 (không gia hạn cửa sổ)
gọi với EX 60 (không ENX) → TTL 60 (gia hạn mỗi lần)
SET 99; INCREX BYINT 5 UBOUND 100 → 99 0
SET 99; INCREX BYINT 5 UBOUND 100 SATURATE → 100 1
tồn 2, lần 1: INCREX BYINT -1 LBOUND 0 → 1 -1
tồn 2, lần 2: INCREX BYINT -1 LBOUND 0 → 0 -1
tồn 2, lần 3: INCREX BYINT -1 LBOUND 0 → 0 0 ← hết hàng: không bao giờ xuống âm
INCREX không EX trên key mới → TTL -1
ENX= chỉ đặt TTL khi key chưa có TTL → cửa sổ cố định (fixed window). BỏENXthì mỗi lần gọi gia hạn lại → key của client gọi liên tục không bao giờ hết hạn, limiter thành "chặn mãi". Đây là chỗ dễ viết sai nhất.- Mặc định khi vượt trần là từ chối (trả
[giá trị hiện tại, 0], không đổi key và TTL);SATURATEthì cộng tới trần. - Trừ tồn kho flash sale trong một lệnh:
INCREX stock:sku BYINT -1 LBOUND 0— phần cộng = 0 nghĩa là hết hàng. Nguyên tử trên một node; nhưng failover vẫn có thể làm mất vài lần trừ (bất đồng bộ) → đơn hàng thật phải chốt ở DB.
3.4 Compare-and-set trên server: SET … IFEQ, DELEX, DIGEST (8.4)
SET cfg v2 IFEQ v1 → OK GET → v2 (02c-cas.txt)
SET cfg v3 IFEQ v1 → (nil) GET → v2 (giá trị đã đổi, không ghi)
SET missing x IFEQ y → (nil) EXISTS → 0
khoá do tok-A giữ; DELEX lock:order:9 IFEQ tok-B → 0 (không xoá khoá của người khác)
DELEX lock:order:9 IFEQ tok-A → 1
DIGEST doc (50 KB) → b1c5bd1a26395a4b (XXH3, hex)
SET doc new IFDEQ b1c5bd1a26395a4b → OK STRLEN → 3
MSETEX 3 a 1 b 2 c 3 EX 60 → 1; TTL a,b,c = 60 60 60
b,c còn, a đã xoá; MSETEX 3 a 9 b 9 c 9 NX EX 60 → 0; GET a = (nil) ← NX: một key đã có là không ghi key nào
Trước 8.4, "chỉ ghi nếu chưa ai đổi" cần WATCH/MULTI/EXEC (3 vòng mạng, chạy lại khi bị chen) hoặc Lua. Giờ nó là một tham số của SET.
DELEX … IFEQ token thay đoạn Lua "nhả lock nếu đúng chủ" quen thuộc. Với value lớn, gửi DIGEST (16 ký tự hex) thay cho cả value để so sánh.
SET. Người đến sau nhận nil và làm
lại từ bước đọc. Lab (02h-cas-race.txt): giỏ hàng JSON 20 process × 50 lần → đúng 1000, thử lại 3 944 lần. Cách này vẫn tốn vòng mạng khi tranh chấp cao —
nếu dữ liệu là các field phẳng, hash + HINCRBY (0 lần thử lại) vẫn tốt hơn.3.5 Bitmap: bit thứ N là id N
SETBIT key N 1 bật bit thứ N (byte thứ N/8); chuỗi tự dài ra tới offset đó. BITCOUNT đếm bit 1 bằng popcount của CPU. BITOP làm phép
logic trên nhiều bitmap và ghi kết quả vào key đích; Redis 8.2 thêm 4 phép: DIFF (có trong X, không có trong Y nào), DIFF1 (có trong ít nhất
một Y, không có trong X), ANDOR (có trong X và ít nhất một Y), ONE (có trong đúng một nguồn). Lab điểm danh 100 000 sinh viên × 7 ngày:
att:d1 bitmap: BITCOUNT=64960 MEMORY USAGE=16416 | set cùng dữ liệu: SCARD=64960 MEMORY USAGE=2530958 (02d-bitmap.txt)
phép tính kết quả µs
đi học đủ 7 ngày: BITOP AND res d1..d7 12618 1439 ← lần đầu, cấp phát key đích
đi ít nhất 1 ngày: BITOP OR res d1..d7 99997 29
hôm qua có, hôm nay vắng: BITOP DIFF res d6 d7 35863 21
hôm nay có, hôm qua vắng: BITOP DIFF1 res d6 d7 5968 19
có ngày 1 và có thêm ít nhất 1 ngày khác: ANDOR 64952 31
chỉ đi đúng 1 ngày trong 7: BITOP ONE 63 30
so với set: SINTERCARD 7 tập (đi đủ 7 ngày) 12618 85610
### đo lại 3 lần
BITOP AND 7 ngày: 29 µs | SINTERCARD 7 set: 57498 µs
BITOP AND 7 ngày: 34 µs | SINTERCARD 7 set: 49662 µs
BITOP AND 7 ngày: 26 µs | SINTERCARD 7 set: 48920 µs
SINTERCARD 49–86 ms (chậm hơn ~2 000 lần).Cái giá của bitmap là offset:
SETBIT sparse 10000000 1 (1 sinh viên id 10 triệu) → MEMORY USAGE=1310752
SETBIT huge 4294967295 1 (offset lớn nhất) → 481576 µs, MEMORY USAGE=671088664, used_memory_human=656.92M
BITCOUNT huge (512 MB) → 25486 µs
SETBIT huge 4294967296 1 → ERR bit offset is not an integer or out of range
Một bit duy nhất ở offset cuối: Redis cấp phát cả 512 MB (thành 640 MB vì cấp phát dư + làm tròn của jemalloc), và chặn server 0,48 giây trong lúc cấp phát —
đúng như cảnh báo trong docs SETBIT. Offset tối đa 2³²−1, nên id 64 bit (snowflake) hay UUID không dùng thẳng được.
3.6 Bitfield: hàng triệu số nhỏ trong một chuỗi
### Tiến độ % (0–100) của 100 000 học viên × 40 bài học (02e-bitfield.txt)
### A: 1 string, BITFIELD u7 (7 bit/ô) — ô thứ (student*40 + lesson)
MEMORY USAGE prog = 5242904 byte; đọc 1 ô: BITFIELD_RO prog GET u7 #(12345*40+7) → 30
### B: 1 hash / học viên, 40 field (listpack)
used_memory tăng 23931808 byte; HGET prog:12345 7 → 30
### OVERFLOW khi tăng vượt miền u7 (0..127)
WRAP (mặc định): +10 từ 120 → 2
SAT: +10 từ 120 → 127
FAIL: +10 từ 120 → (nil), giá trị giữ nguyên 120
4 triệu ô × 7 bit = 3,5 MB dữ liệu (5,2 MB thực tế do cấp phát dư) so với 23,9 MB nếu mỗi học viên một hash. Tiền phải trả: mặc định là WRAP — đếm vượt
127 quay về số nhỏ, im lặng; phải chọn OVERFLOW SAT hoặc FAIL rõ ràng. Toạ độ (học viên, bài) phải tự quy đổi ra số thứ tự; thêm bài học thứ 41 là
phải đổi cả cách đánh số. BITFIELD_RO chạy được trên replica.
3.7 HyperLogLog: đếm phần tử khác nhau bằng 16 384 thanh ghi
PFADD trả 0 kể cả khi phần
tử là mới (lab: brand-new-user → 0) — đừng dùng giá trị trả về để biết "khách mới hay cũ".### HyperLogLog: sai số và RAM theo số phần tử khác nhau thật (02f-hll.txt)
thật PFCOUNT sai % byte dạng
100 99 -1.00 408 sparse
1000 994 -0.60 2584 sparse
1500 1497 -0.20 3096 sparse
2000 2006 0.30 14360 dense ← vượt hll-sparse-max-bytes 3000 → đổi sang dạng đặc
10000 9977 -0.23 14360 dense
100000 100420 0.42 14360 dense
1000000 1005024 0.50 14360 dense
10000000 10089687 0.90 14360 dense
### 7 ngày, mỗi ngày 200 000 người xem (trùng 50% giữa 2 ngày liền nhau) → người khác nhau trong tuần
thật: 800000 | PFCOUNT 7 key (gộp tại chỗ) = 807767 (34 µs)
PFMERGE week d1..d7 (37 µs) → PFCOUNT week = 807767 (6 µs, lần 2 dùng cache: 7 µs)
SCARD wset = 800000, MEMORY USAGE wset = 34677578 | MEMORY USAGE week (HLL) = 14360
PFADD trả 1 khi thanh ghi đổi, 0 khi không: PFADD week u5 (đã có) → 0; PFADD week brand-new-user → 0
Thiết kế hai dạng: HLL ít phần tử để ở dạng thưa (nén các thanh ghi bằng 0) — vài trăm byte; vượt hll-sparse-max-bytes (3000) thì đổi sang dạng đặc 12 KB
(lab: giữa 1 500 và 2 000 phần tử). Header lưu sẵn kết quả lần đếm trước, nên PFCOUNT một key gần như miễn phí; nhiều key thì phải gộp tại chỗ (lab vẫn chỉ
34 µs với 7 key, docs cảnh báo có thể tới mức ms).
4. Đảm bảo và giới hạn
| Cách nhìn | Đảm bảo | Giới hạn |
|---|---|---|
| String | Mỗi lệnh nguyên tử; SET … IFEQ là CAS trên một key | 512 MB (proto-max-bulk-len); CAS không trải qua nhiều key |
| Bộ đếm | INCR đúng tuyệt đối trong int64 trên một node; INCREX gộp trần + TTL | Tràn → lỗi; INCRBYFLOAT lệch ở chữ số thứ 17–18; failover mất các lần cộng chưa kịp sang replica (series rate limit đo mất 60 lần đếm) |
| Bitmap | Chính xác; phép AND/OR trên từng byte rất nhanh | Offset < 2³²; RAM = offset lớn nhất / 8; id phải là số nguyên nhỏ, liền nhau |
| Bitfield | Nhiều lệnh con chạy nguyên tử trong một BITFIELD | Số không dấu tối đa 63 bit, có dấu 64 bit; mặc định WRAP |
| HyperLogLog | Sai số chuẩn 0,81% (lab: −1% tới +0,9%); RAM tối đa ~12 KB | Không xoá, không liệt kê, không biết phần tử đã có chưa; sai số tương đối nên số nhỏ cũng lệch (100 → 99) |
Khung lý thuyết: bitmap và HLL là hai cực của đánh đổi không gian ↔ độ chính xác khi đếm. Bitmap: n bit, chính xác, nhưng n là
miền giá trị (id lớn nhất), không phải số phần tử. HLL: O(log log n) bit mỗi thanh ghi, sai số ~1,04/√m với m thanh ghi — muốn sai số giảm một nửa phải tăng RAM
gấp 4. Lệnh nhiều key (BITOP, PFMERGE, PFCOUNT nhiều key, MSETEX) trong Cluster cần mọi key cùng slot.
5. Failure modes
| # | Tình huống | Hậu quả (đo) | Phát hiện | Chữa / phòng |
|---|---|---|---|---|
| 1 | "Làm mới" cache bằng SET không kèm EX (hoặc GETSET, SET … GET) | Key có hạn thành vĩnh viễn → RAM rò dần (nổ trễ) | Số key có TTL -1 tăng; INFO keyspace expires thấp bất thường | Luôn kèm EX hoặc KEEPTTL; quét định kỳ key không TTL theo prefix |
| 2 | Laravel Cache::increment() trên key chưa có | Tạo key không có TTL (lab: TTL -1) | Như trên | Cache::add($key, 0, $ttl) trước, hoặc INCREX … EX … ENX |
| 3 | Cộng tiền bằng INCRBYFLOAT | Lệch: 19 999 989,999999999829015 thay vì 19 999 990 | Đối soát với DB lệch vài đồng | Số nguyên theo đơn vị nhỏ nhất + INCRBY |
| 4 | Bitmap với id lớn/thưa, hoặc đổi id sang UUID/snowflake | 1 bit ở offset 2³²−1 = 640 MB + chặn 481 ms; id 10 triệu = 1,3 MB cho 1 bit; id > 2³² thì lỗi | RAM nhảy vọt; SLOWLOG có SETBIT ~0,5 s | Bảng ánh xạ id → số thứ tự liền nhau; hoặc dùng set/HLL |
| 5 | Bitfield tăng vượt miền với WRAP mặc định | 120 + 10 = 2 (im lặng) | Số liệu "tụt" bất thường | OVERFLOW SAT hoặc FAIL |
| 6 | Dùng HLL cho số liệu cần chính xác / cần xoá theo yêu cầu người dùng | Lệch 0,2–1%; không xoá được dữ liệu của một người | Khiếu nại số liệu; yêu cầu xoá dữ liệu cá nhân | Ghi rõ "xấp xỉ" trên UI; dữ liệu có nghĩa vụ xoá thì không đưa vào HLL |
| 7 | Gọi lệnh mới bằng executeRaw mà quên prefix | CAS không bao giờ thành công: 999/1000 lần bỏ cuộc, giỏ hàng = {"qty":1}, không lỗi nào | Chỉ thấy qua số liệu sai | Một helper tự gắn prefix; test tích hợp có REDIS_PREFIX khác rỗng |
| 8 | Laravel Redis::set($k, $v, null, null, 'NX') | NX bị bỏ qua, ghi đè như SET thường (lab: trả true, value đổi) | Khoá "lấy được" hai lần | Redis::set($k, $v, 'EX', $ttl, 'NX') hoặc setnx |
| 9 | Bộ đếm/tồn kho khi failover | Mất các lần cộng/trừ chưa sang replica → đếm thiếu, bán vượt | Đối soát DB | DB là nguồn sự thật; Redis chỉ chặn sớm |
5.1 Lab Laravel: những gì facade làm khác bạn nghĩ
### Cache::increment trên key chưa có (CACHE_STORE=redis) (02g-laravel.txt)
db 9, key thật: lab:clicon_locallesson:42:views | TTL: -1 <- -1 = không bao giờ hết hạn
Cache::put(..., 0, 3600) rồi increment: key lab:clicon_locallesson:43:views GET=5 TTL=3600
### Lệnh mới qua executeRaw: KHÔNG tự thêm prefix
key sau executeRaw INCREX 'rl:u9': rl:u9
thêm prefix bằng tay: lab:rl:u9, rl:u9
### phpredis 6.3 có method cho lệnh mới chưa?
hexpire / hgetex / hsetex / hgetdel / vadd / vsim có
delex / digest / msetex / increx / xackdel / xdelex KHÔNG → executeRaw
### Redis::set của Laravel KHÔNG phải set() của phpredis: (key, value, expireResolution, expireTTL, flag)
Redis::set('cfg', 'v9', ['IFEQ' => 'v0']) → TypeError: Cannot access offset of type array on array
Redis::set('cfg', 'v9', 'IFEQ', 'v0') → trả false, GET cfg = v1
Redis::set('cfg', 'v9', 'IFEQ', 'v1') → trả true, GET cfg = v9
Redis::set('cfg', 'v8', null, null, 'NX') → trả true, GET cfg = v8 ← NX bị bỏ, ghi đè
Redis::set('cfg', 'v7', 'EX', 60, 'NX') → trả false, GET cfg = v8
- Key cache thật là
REDIS_PREFIX+CACHE_PREFIX+ tên — vàCACHE_PREFIXcủa project này thiếu dấu:nên dính liền (clicon_locallesson:42:views). Cache nằm ởREDIS_CACHE_DB(ở đây db 9), không phải db 0. Chi tiết các bẫy prefix: bài Thiết kế tên key Redis. PhpRedisConnection::set()chỉ gửi option khi có tham số thứ 3:$expireResolution ? [$flag, $expireResolution => $expireTTL] : null. Nhờ vậyRedis::set($k, $new, 'IFEQ', $old)tình cờ chạy được như CAS (phpredis 6.3 hiểu optionIFEQ) — nhưng đó là dựa vào chi tiết cài đặt; nên bọc trong một hàm có test.
5.2 Code nên viết
use Illuminate\Support\Facades\Redis;
final class RedisCounters
{
private function key(string $name): string
{
// executeRaw KHÔNG tự thêm prefix của connection → tự thêm ở MỘT chỗ
return Redis::connection()->client()->getOption(\Redis::OPT_PREFIX) . $name;
}
/** Rate limit cửa sổ cố định: true = cho qua. Redis 8.8+ (INCREX). */
public function hit(string $name, int $limit, int $windowSeconds): bool
{
[, $applied] = Redis::executeRaw(['INCREX', $this->key($name), 'BYINT', '1',
'UBOUND', (string) $limit, 'EX', (string) $windowSeconds, 'ENX']);
return $applied === 1; // chạm trần → INCREX không cộng, trả [giá trị, 0]
}
/** Trừ tồn kho tạm (chặn sớm); đơn hàng thật vẫn chốt trong DB. */
public function reserve(string $sku): bool
{
[, $applied] = Redis::executeRaw(['INCREX', $this->key("stock:$sku"), 'BYINT', '-1', 'LBOUND', '0']);
return $applied === -1;
}
/** Nhả lock đúng chủ (8.4+), thay cho Lua. */
public function release(string $lock, string $token): bool
{
return Redis::executeRaw(['DELEX', $this->key($lock), 'IFEQ', $token]) === 1;
}
}
### chạy thật class trên (labs/redis/laravel/RedisCounters.php, 02-counters.php → 02i-counters.txt)
hit() 7 lần, limit 5: qua qua qua qua qua chặn chặn | TTL lab:rl:login:42 = 60
reserve() 3 lần, tồn 2: giữ được | giữ được | hết hàng | còn 0
release(tok-B) → false | release(tok-A) → true | lock còn? false
Bẫy của chính đoạn code này: chỉ chạy trên Redis ≥ 8.8 (Valkey 9.1.2 báo ERR unknown command 'INCREX' — bài 1 mục 8); kết quả
executeRaw không đi qua serializer/prefix của phpredis; Cluster thì key nằm ở node theo slot như mọi lệnh khác.
6. Vận hành trong production
- Metric:
INFO keysizes→db0_distrib_strings_sizes(histogram kích thước string, 8.2+) để thấy string lớn dần;INFO keyspace(keysso vớiexpires) để thấy key không TTL;redis-cli --bigkeyscho string lớn; SLOWLOG choSETBIT/SETRANGEoffset lớn vàBITCOUNT/BITOPtrên bitmap lớn (BITCOUNT 512 MB: 25 ms). - Cấu hình:
proto-max-bulk-len(512 MB — có thể hạ để chặn value khổng lồ),hll-sparse-max-bytes(3000; tăng thì HLL nhỏ gọn hơn nhưng PFADD trên dạng thưa chậm hơn),client-output-buffer-limit(GET value lớn cho nhiều client cùng lúc làm phình buffer). - Capacity planning — ví dụ minh hoạ (LMS 100 000 sinh viên, 2 000 bài học): điểm danh bitmap 16 KB/ngày → ~6 MB/năm, thoải mái. HLL "người xem khác nhau"
theo bài × ngày: bài ít người xem ở dạng thưa (vài trăm byte – 3 KB), bài đông là 12 KB; tệ nhất 2 000 × 365 × 14 KB ≈ 10 GB/năm → phải có TTL (ví dụ 90 ngày) và gộp
PFMERGEtheo tháng. Tiến độ 100 000 × 40 bài bằng bitfield: 5 MB. - Upgrade: code dùng
INCREX/SET … IFEQ/DELEXphải được deploy sau khi mọi Redis (kể cả staging, CI, máy dev) đã ≥ 8.4/8.8; ngược lại thì lỗiunknown commandhoặc tham số lạ. - Gánh nặng cho team: người trực cần biết đọc SLOWLOG tìm
SETBIToffset lớn, và biết số trên dashboard nào là xấp xỉ (HLL) để không đi "sửa bug" sai số 1%.
7. So sánh và ma trận quyết định
| Đếm phần tử khác nhau (800 000) | RAM (lab) | Độ chính xác | Liệt kê / xoá được? | Dùng khi |
|---|---|---|---|---|
| Set | 34,7 MB | Chính xác | Có / có | Cần biết "ai", cần xoá theo người, số lượng vừa phải |
| Bitmap (id số liền nhau) | ~100 KB (1 bit × id lớn nhất) | Chính xác | Có (quét bit) / có | Id nhỏ, liền nhau; cần phép giao/hợp theo ngày |
| HyperLogLog | 14 KB | ±1% | Không / không | Chỉ cần con số, rất nhiều key, chấp nhận sai số |
PostgreSQL COUNT(DISTINCT) | Theo bảng | Chính xác | Có / có | Báo cáo không cần realtime |
| Cập nhật an toàn khi nhiều server | Vòng mạng | Thử lại khi tranh chấp | Yêu cầu |
|---|---|---|---|
INCR/HINCRBY (gửi phép tính) | 1 | Không (bài 1: 0) | Mọi bản |
SET … IFEQ (CAS) | 2+ (đọc + ghi) | Có (lab: 3 944 / 1000 lượt) | Redis 8.4+; Valkey 9.1.2 cũng có (lab bài 1) |
WATCH/MULTI/EXEC | 4+ | Có (bài 1: 10 611) | Mọi bản |
| Lua | 1 | Không | Mọi bản; script phải ngắn |
Dùng khi: string cho giá trị đọc/ghi nguyên khối và bộ đếm; bitmap cho cờ theo id số liền nhau; bitfield cho rất nhiều số nhỏ; HLL cho số người khác nhau ở quy mô lớn khi chấp nhận ±1%. Tránh khi: cần sửa một phần object (hash/JSON), id thưa/UUID (set, hoặc ánh xạ trước), số liệu tiền/tồn kho cuối cùng (DB), dữ liệu cá nhân phải xoá được (không HLL).
8. Góc nhìn tech lead: tác động tổ chức
- Bitmap trói thiết kế id: dùng bitmap là cam kết id là số nguyên nhỏ, liền nhau. Ngày team đổi khoá chính sang UUIDv7 hay snowflake, mọi bitmap phải đổi sang bảng ánh xạ. Ghi điều này vào ADR khi chọn bitmap.
- Số xấp xỉ là quyết định sản phẩm: HLL lệch ±1% phải được product owner đồng ý và hiển thị là "khoảng". Không đưa HLL vào số liệu tính tiền, KPI hợp đồng hay dữ liệu có nghĩa vụ xoá.
- Lệnh mới = phụ thuộc phiên bản:
INCREX(8.8),DELEX(8.4) không có trên Valkey 9.1.2 (lab bài 1);SET … IFEQthì có.DIGEST,MSETEXchưa thử trên Valkey. Gói vào một class (mục 5.2) để có đường lui: đổi sang Lua mà không sửa nơi gọi. - Wrapper của framework là API riêng:
Redis::set()của Laravel không phảiset()của phpredis;Cache::incrementkhông đặt TTL;executeRawbỏ qua prefix. Checklist review nên có mục "lệnh Redis đi qua đâu, có prefix không, có TTL không". - Exit strategy: bộ đếm, bitmap, HLL đều dựng lại được từ log sự kiện/DB nếu log còn. Nếu không có log gốc (chỉ có HLL), dữ liệu không mang sang hệ khác được — cân nhắc giữ event log thô ở chỗ rẻ (object storage, ClickHouse) song song.
9. Anti-patterns và bài học thực tế
- Session/giỏ hàng JSON sửa từng field bằng GET–SET: mất cập nhật (bài 1). Dùng hash, hoặc ít nhất
SET … IFEQ. - Log nối bằng
APPENDvô hạn: string phình tới hàng chục MB, tốn thêm 71% vì cấp phát dư, mỗi lần đọc chuyển cả khối. Dùng list/stream có trần. - Bitmap theo user id toàn cục từ 10 000 000 trở đi: mỗi bitmap ngày tốn ≥ 1,25 MB dù chỉ vài người. Trừ offset gốc, hoặc ánh xạ.
- Dùng
PFADDtrả về để đếm "người mới": lab cho thấy phần tử mới vẫn có thể trả 0. - Rate limit bằng
INCRrồiEXPIRErời nhau: key rò không hết hạn (series rate limit). DùngINCREX … ENXhoặc Lua.
Nguồn công khai: blog Spool (11/2011) "Fast, easy, realtime metrics using Redis bitmaps" — 128 triệu user, "daily unique users" dưới 50 ms, 16 MB; antirez
(4/2014, antirez.com/news/75) giải thích HLL 12 KB, sai số 0,81% và dạng thưa. Instagram (2011) về bucket hash — xem bài 1. Chưa tìm được postmortem
công khai riêng về SETBIT offset lớn; cảnh báo nằm ngay trong docs lệnh.
Điều docs không nói rõ: SET xoá TTL là hành vi đúng nhưng hay bị quên; PFADD trả 0 không có nghĩa "đã thấy"; một bit = 640 MB vì cấp
phát dư; facade của framework đổi nghĩa tham số.
10. Câu hỏi phụ interviewer hay hỏi tiếp
- Vì sao Redis không dùng chuỗi C thường?
- Cần chứa byte bất kỳ (binary-safe), lấy độ dài O(1), và nối nhanh nhờ cấp phát dư. Giá: RAM thừa ở đuôi (lab: APPEND tốn hơn 71%).
- Đếm số người dùng hoạt động mỗi ngày cho 50 triệu user — chọn gì?
- Id số liền nhau → bitmap (~6 MB/ngày, AND/OR giữa các ngày cực nhanh). Id thưa/UUID và chỉ cần con số → HLL (12 KB, ±1%). Cần danh sách người → set, nhưng tốn hàng trăm MB.
- HyperLogLog hoạt động thế nào? Vì sao 12 KB?
- Băm phần tử, 14 bit chọn thanh ghi, đếm số 0 đứng đầu của phần còn lại, thanh ghi giữ giá trị lớn nhất; ước lượng bằng trung bình điều hoà. 2¹⁴ thanh ghi × 6 bit = 12 288 byte, sai số 1,04/√16384 ≈ 0,81%.
- Làm rate limit/bộ đếm có TTL mà không có race INCR + EXPIRE?
- Redis 8.8:
INCREX key BYINT 1 UBOUND N EX w ENX. Bản cũ: Lua, hoặcMULTI{INCR;EXPIRE … NX}. NhớENX, không thì cửa sổ gia hạn mãi. - Nhả distributed lock an toàn?
- Token ngẫu nhiên khi lấy (
SET … NX PX); khi nhả chỉ xoá nếu token khớp:DELEX key IFEQ token(8.4+) hoặc Lua. Lock vẫn có giới hạn của nó (TTL hết khi còn làm) — bài Redis — những thứ cần biết mục 1.7. SET … IFEQkhác gìWATCH?- Cùng là optimistic, nhưng điều kiện nằm trong một lệnh: ít vòng mạng hơn, không cần giữ trạng thái WATCH trên connection. Lab: 3 944 lần thử lại so với 10 611 cho cùng tải.
- Lưu tiền trong Redis được không?
- Số nguyên theo đơn vị nhỏ nhất với
INCRBYthì chính xác trên một node; nhưng failover mất được write → Redis chỉ là lớp chặn sớm, sổ cái ở DB. - Bitmap của user id 64 bit?
- Không dùng thẳng được (offset < 2³², và id lớn thì RAM khổng lồ). Ánh xạ sang số thứ tự liền nhau, chia bitmap theo khoảng id, hoặc dùng cấu trúc nén (Roaring) bên ngoài Redis.
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 (image laravel-app, project laravel-livewire mount
read-only, REDIS_PREFIX=lab:; cache dùng REDIS_CACHE_DB = 9 theo .env của project). Container tmp-rdt-redis, network
tmp-rdt-net. Máy 4 vCPU dùng chung.
cd labs/redis
./02-string-bitmap-hll.sh # chạy hết → out/02a … 02h
./02-string-bitmap-hll.sh bitmap # 1 phần: string | counter | cas | bitmap | bitfield | hll | laravel | race
Chưa verify: mất bộ đếm khi failover trong bài này (đã đo ở series rate limit, bài 3); BITFIELD và BITOP trên Cluster; hiệu năng
INCREX so với Lua dưới tải cao.