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, BITOP vớ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: SET xoá 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ằng INCRBYFLOAT (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ằng SET … IFEQ thay vì WATCH/Lua; từ 8.8, INCREX gộ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ìnCó từBài toán nó giảiGiải pháp trước đó và giới hạn
String nguyên khốiBản đầu (2009)Cache kết quả, session, tokenmemcached 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 INCRBả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
Bitmap2.2 (2010)Cờ có/không theo id số: user hoạt động hôm nay, điểm danhSet 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
Bitfield3.2 (2016)Hàng triệu bộ đếm nhỏ (0–100, 0–65 535) cạnh nhauMỗi bộ đếm một key/field tốn 40+ byte cho vài bit dữ liệu
HyperLogLog2.8.9 (4/2014)Đếm số phần tử khác nhau rất lớn bằng bộ nhớ cố địnhSet 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

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.

Compare-and-set bằng SET IFEQ Server A và B cùng đọc v1 (qty 5). A gửi SET v2 IFEQ v1, Redis đổi thành v2 và trả OK. B gửi SET IFEQ v1, giá trị đã là v2 nên Redis trả nil. B đọc lại được v2, tính lại, gửi SET v3 IFEQ v2 và được OK; giá trị cuối là qty 7. GET → sửa → SET mới IFEQ cũ; trả nil thì đọc lại, thử lại server Aserver B Redis v1 qty:5v2 qty:6v3 qty:7 GET GET v1 qty:5 v1 qty:5 SET v2 IFEQ v1 OK SET v2' IFEQ v1 nil GET v2 qty:6 SET v3 IFEQ v2 OK đọc v1, ghi v2 IFEQ v1 → OK đọc v1, tính qty 6… nil: đã có người đổi → đọc lại đọc v2, ghi v3 IFEQ v2 → OK Không lượt nào mất: lần ghi thua bị từ chối thay vì đè lab: 20 process × 50 lần +1 vào giỏ JSON → 1000 / 1000, thử lại 3 944 lần (WATCH ở bài 1: 10 611 lần)
Hình 1 — Compare-and-set đặt điều kiện "chỉ ghi nếu giá trị vẫn là cái tôi đã đọc" ngay trong lệ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
Bitmap điểm danh và BITOP AND Năm hàng ô vuông là năm ngày att:d1 đến att:d5, mỗi cột là một sinh viên id 0 đến 23. Ô xanh là đi học. Một vạch quét qua các cột, hàng kết quả AND hiện dần: chỉ sáu sinh viên đi đủ cả năm ngày. SETBIT att:dN <id> 1 — mỗi ngày 1 key, mỗi sinh viên 1 bit id 0id 23 att:d1att:d2att:d3att:d4att:d5 AND BITOP AND res att:d1 … att:d5 → BITCOUNT res = 6 sinh viên đi đủ 5 ngày lab 100 000 SV × 7 ngày: 16 KB/ngày, AND 26–34 µs | set cùng dữ liệu: 2,5 MB/ngày, SINTERCARD 49–86 ms
Hình 2 — Mỗi ngày là một bitmap; bit thứ N bật nếu sinh viên id N có mặt. Câu hỏi "ai đi đủ cả tuần" là một phép AND theo từng byte giữa các bitmap — CPU làm 64 sinh viên một lần. Lab: bitmap 16 KB/ngày, AND 7 ngày 26–34 µs; cùng dữ liệu bằng set: 2,5 MB/ngày và 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

HyperLogLog cập nhật một thanh ghi PFADD u_10293: băm ra 64 bit; 14 bit đầu chọn thanh ghi số 2907; phần còn lại có 3 số 0 đứng đầu nên hạng là 4; thanh ghi 2907 đổi từ 2 lên 4, PFADD trả 1. PFADD u_77 rơi vào cùng thanh ghi với hạng 1; max của 4 và 1 vẫn là 4, không đổi, PFADD trả 0. PFADD — mỗi phần tử chỉ chạm 1 trong 16 384 thanh ghi PFADD uv u_10293 hash = 00101101011011 00011010110… 14 bit đầu = 2 907 → thanh ghi #2907 3 số 0 đứng đầu → hạng 4 PFADD uv u_77 hash = 00101101011011 1 0110011001… cùng thanh ghi #2907, không có số 0 đứng đầu → hạng 1 #0#1…#2906 #2907#2908…#16383 03…1 24 0…2 max(2, 4) = 4 PFADD trả 1 max(4, 1) = 4 u_77: PFADD trả 0 PFCOUNT: ước lượng từ trung bình điều hoà của 2^(−thanh ghi); 16 384 × 6 bit = 12 KB cố định sai số chuẩn 1,04/√16384 ≈ 0,81% — lab: 10 triệu phần tử → 10 089 687 (+0,9%), 14 360 byte
Hình 3 — HyperLogLog không lưu phần tử. Nó băm phần tử ra 64 bit, dùng 14 bit đầu chọn 1 trong 16 384 thanh ghi, và ghi vào đó "số 0 đứng đầu dài nhất từng thấy". Thấy chuỗi số 0 dài là dấu hiệu đã gặp nhiều phần tử khác nhau. Hệ quả trực tiếp: không xoá được phần tử, không liệt kê được, và 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ảoGiới hạn
StringMỗi lệnh nguyên tử; SET … IFEQ là CAS trên một key512 MB (proto-max-bulk-len); CAS không trải qua nhiều key
Bộ đếmINCR đúng tuyệt đối trong int64 trên một node; INCREX gộp trần + TTLTrà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)
BitmapChính xác; phép AND/OR trên từng byte rất nhanhOffset < 2³²; RAM = offset lớn nhất / 8; id phải là số nguyên nhỏ, liền nhau
BitfieldNhiều lệnh con chạy nguyên tử trong một BITFIELDSố không dấu tối đa 63 bit, có dấu 64 bit; mặc định WRAP
HyperLogLogSai số chuẩn 0,81% (lab: −1% tới +0,9%); RAM tối đa ~12 KBKhô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ốngHậu quả (đo)Phát hiệnChữ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ườngLuôn kèm EX hoặc KEEPTTL; quét định kỳ key không TTL theo prefix
2Laravel Cache::increment() trên key chưa cóTạo key không có TTL (lab: TTL -1)Như trênCache::add($key, 0, $ttl) trước, hoặc INCREX … EX … ENX
3Cộng tiền bằng INCRBYFLOATLệch: 19 999 989,999999999829015 thay vì 19 999 990Đối soát với DB lệch vài đồngSố nguyên theo đơn vị nhỏ nhất + INCRBY
4Bitmap với id lớn/thưa, hoặc đổi id sang UUID/snowflake1 bit ở offset 2³²−1 = 640 MB + chặn 481 ms; id 10 triệu = 1,3 MB cho 1 bit; id > 2³² thì lỗiRAM nhảy vọt; SLOWLOG có SETBIT ~0,5 sBảng ánh xạ id → số thứ tự liền nhau; hoặc dùng set/HLL
5Bitfield tăng vượt miền với WRAP mặc định120 + 10 = 2 (im lặng)Số liệu "tụt" bất thườngOVERFLOW SAT hoặc FAIL
6Dùng HLL cho số liệu cần chính xác / cần xoá theo yêu cầu người dùngLệch 0,2–1%; không xoá được dữ liệu của một ngườiKhiếu nại số liệu; yêu cầu xoá dữ liệu cá nhânGhi rõ "xấp xỉ" trên UI; dữ liệu có nghĩa vụ xoá thì không đưa vào HLL
7Gọi lệnh mới bằng executeRaw mà quên prefixCAS 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àoChỉ thấy qua số liệu saiMột helper tự gắn prefix; test tích hợp có REDIS_PREFIX khác rỗng
8Laravel 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ầnRedis::set($k, $v, 'EX', $ttl, 'NX') hoặc setnx
9Bộ đếm/tồn kho khi failoverMất các lần cộng/trừ chưa sang replica → đếm thiếu, bán vượtĐối soát DBDB 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

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

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ácLiệt kê / xoá được?Dùng khi
Set34,7 MBChính xácCó / 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ácCó (quét bit) / cóId nhỏ, liền nhau; cần phép giao/hợp theo ngày
HyperLogLog14 KB±1%Không / khôngChỉ cần con số, rất nhiều key, chấp nhận sai số
PostgreSQL COUNT(DISTINCT)Theo bảngChính xácCó / cóBáo cáo không cần realtime
Cập nhật an toàn khi nhiều serverVòng mạngThử lại khi tranh chấpYêu cầu
INCR/HINCRBY (gửi phép tính)1Khô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/EXEC4+Có (bài 1: 10 611)Mọi bản
Lua1KhôngMọ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

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

  1. 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.
  2. Log nối bằng APPEND vô 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.
  3. 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ạ.
  4. Dùng PFADD trả về để đếm "người mới": lab cho thấy phần tử mới vẫn có thể trả 0.
  5. Rate limit bằng INCR rồi EXPIRE rời nhau: key rò không hết hạn (series rate limit). Dùng INCREX … ENX hoặ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ặc MULTI { 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 … IFEQ khá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 INCRBY thì 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.