Kiểu mới trong Redis 8 — JSON, Bloom/Cuckoo, Count-min, Top-K, t-digest, TimeSeries, Vector set, Query Engine

Redis 8.0 (5/2025) gộp vào bản chính những thứ trước đây là module của "Redis Stack" phải cài riêng: JSON, năm cấu trúc xác suất (Bloom, Cuckoo, Count-min sketch, Top-K, t-digest), TimeSeries và Query Engine; thêm vector set — kiểu mới do antirez viết. Câu hỏi phỏng vấn không còn là "Redis có những gì" mà là "khi nào dùng cái này thay vì PostgreSQL/Elasticsearch/Prometheus/vector DB, và nó sai theo hướng nào". Bài này đo từng cấu trúc với đáp án chính xác làm mốc: sai bao nhiêu, tốn bao nhiêu RAM, gãy ở đâu. Ví dụ: catalog sản phẩm, chống cache penetration, sản phẩm hot (e-commerce); độ trễ API, số người online, tìm khoá học tương tự (LMS).

Thuật ngữ dùng trong bài
JSONPath (cú pháp chỉ đường trong JSON: $.items[0].price, $..price) · sketch (cấu trúc tóm tắt dữ liệu trong bộ nhớ cố định, trả lời gần đúng) · false positive / sai dương (nói "có" khi thật ra không) · false negative / sai âm (nói "không" khi thật ra có) · Bloom filter (mảng bit + k hàm băm; chỉ sai dương) · Cuckoo filter (lưu "dấu vân tay" ngắn trong bảng; xoá được) · Count-min sketch (ma trận bộ đếm; ước lượng tần suất, chỉ đếm dư) · Top-K (giữ k phần tử xuất hiện nhiều nhất) · t-digest (tóm tắt phân phối bằng các "centroid"; ước lượng phân vị như p99) · quantile / phân vị (p99 = 99% giá trị nhỏ hơn nó) · retention (giữ dữ liệu bao lâu) · downsample / compaction (gộp dữ liệu thô thành điểm theo phút/giờ) · embedding (vector số thể hiện ý nghĩa của văn bản/ảnh) · HNSW (đồ thị nhiều tầng để tìm láng giềng gần đúng) · recall@10 (tỉ lệ 10 kết quả đúng nhất mà hệ thống tìm được) · quantization (lượng tử hoá: lưu số thực bằng ít bit hơn — Q8 = 8 bit, BIN = 1 bit) · inverted index (chỉ mục từ → danh sách tài liệu chứa từ đó).
Kiểm chứng: tài liệu Redis qua Context7 ngày 03/10/2026 (release notes 8.0 — danh sách thành phần gộp vào; BF.RESERVE công thức số bit/hàm băm; CMS.INITBYPROB; TOPK.RESERVE; TS.CREATE, TS.ADD, TS.CREATERULE; vector set: VADD, VSIM, trang "Quantization modes"; JSON.SET tham số FPHA mới ở 8.8; JSON.DEBUG MEMORY). Cấu hình mặc định đọc bằng CONFIG GET bf-*/cf-*/ts-* và VINFO trên Redis 8.8.0. Lab chạy thật: Redis 8.8.0, PHP 8.3.33 + phpredis 6.3.0 (+ Laravel 12.64 cho phần code ứng dụng); đáp án chính xác tính trong PHP để so. Output: labs/redis/out/07*.txt.

Trả lời 30 giây

Hai nhóm. Nhóm có cấu trúc/chỉ mục — JSON (sửa từng nhánh nguyên tử; RAM ~3,7 lần văn bản), TimeSeries (nén: 1,3–7,5 byte/mẫu so với 96 của zset), vector set (HNSW, Q8 mặc định: 819 byte/vector 128 chiều, recall 95% so với đáp án thật), Query Engine (index phụ cho hash/JSON: ~560 byte/doc). Nhóm phác thảo xác suất — RAM cố định, trả lời gần đúng, và phải biết sai theo hướng nào: Bloom/Cuckoo chỉ sai dương (lab 0,5–1,2%), Count-min chỉ đếm dư (phần tử hiếm bị đếm dư trung bình +489%), Top-K đúng 8–10/10, t-digest lệch ≤ 1,2% ở p50 và < 0,2% ở đuôi. Bẫy hay dính: CF.DEL phần tử chưa từng thêm xoá nhầm phần tử khác (sai âm); BIN tụt recall còn 25%; filter hẹp làm vector search chậm 6 lần và recall 70%; số JSON vượt u64 thành số thực; phpredis đổi giá trị TimeSeries thành true; Query Engine không tìm "ao" ra "áo". Và tất cả không có trên Valkey (bài 1) — chọn chúng là chọn Redis Ltd.

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

Cấu trúcBài toánTrước đó làm sao, giới hạn gì
JSONDocument lồng nhau, sửa một nhánh mà không đọc–ghi cả documentString JSON: đọc–sửa–ghi, mất cập nhật (bài 1); hash: chỉ phẳng
Bloom / Cuckoo"Chắc chắn chưa có" với RAM rất nhỏ: chống cache penetration, email đã đăng ký, URL đã crawlSet: chính xác nhưng 42 byte/phần tử (bài 3)
Count-min / Top-KTần suất và "top k" trên luồng rất lớn (sản phẩm hot, IP gọi nhiều)Hash/zset đếm từng phần tử: RAM tỉ lệ với số phần tử khác nhau
t-digestp50/p95/p99 trên hàng triệu mẫu, gộp được giữa các máyLưu hết mẫu rồi sắp xếp; trung bình của p99 các máy thì sai về toán
TimeSeriesSố liệu theo thời gian: nén, retention, gộp theo phút/giờ, lọc theo labelZset score = thời gian: ~96 byte/mẫu, tự gộp, tự dọn
Vector setTìm "giống nhất" theo embedding: gợi ý khoá học, tìm kiếm ngữ nghĩa, RAGQuét hết (O(N)) hoặc thêm vector DB riêng
Query EngineLọc/tìm theo nội dung field của hash/JSON (tag, khoảng số, full-text)Tự duy trì set/zset làm index bằng tay

Trước 8.0 chúng là module (RediSearch, RedisJSON, RedisBloom, RedisTimeSeries) gói trong "Redis Stack". Redis 8.0 đưa tất cả vào mọi bản phân phối, cùng lúc đổi license thành RSALv2/SSPLv1/AGPLv3 (bài 1 mục 8). Vector set ra đời ở 8.0 dưới dạng preview. Tối ưu cho: thêm khả năng mà không thêm hệ thống. Không tối ưu cho: dữ liệu lớn hơn RAM, nhu cầu chuyên sâu mà hệ thống chuyên dụng làm tốt hơn (search phức tạp, metrics dài hạn, vector hàng chục triệu).

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

  1. Nhóm "có cấu trúc": dữ liệu đầy đủ + chỉ mục trong RAM. JSON là cây; TimeSeries là chuỗi chunk nén; vector set là đồ thị HNSW; Query Engine là inverted index cạnh hash/JSON. Câu trả lời chính xác (vector set: gần đúng do HNSW + lượng tử hoá), giá là RAM và CPU duy trì chỉ mục.
  2. Nhóm "phác thảo": RAM cố định, đổi độ chính xác lấy bộ nhớ, sai số có hướng. Luật để nhớ: Bloom/Cuckoo nói "không" là chắc, nói "có" là "có thể"; Count-min nói số là "không ít hơn"; Top-K giữ phần tử nặng, bỏ phần đuôi; t-digest chính xác nhất ở hai đầu phân phối. Không xoá được (Bloom, CMS) hoặc xoá nguy hiểm (Cuckoo). Không liệt kê được phần tử.

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

3.1 JSON: cây trong RAM, sửa từng nhánh

### sửa từng phần, ngay trên server                                                      (07a-json.txt)
JSON.NUMINCRBY p:1 $.stock -1           → [56]
JSON.ARRAPPEND p:1 $.tags '"sale"'      → [2]
JSON.GET p:1 $..price                  → [199000,199000,219000]
JSON.NUMINCRBY p:1 $..price 1000       → [200000,200000,220000]   (một lệnh sửa mọi chỗ khớp)
JSON.SET p:1 $.color '"blue"' NX       → true; lần 2 → false
JSON.SET p:1 $.a.b 1 (cha chưa có)     → false
JSON.MERGE p:1 $ {stock:null, brand}   → true                     (null = xoá field)
### 20 process × 50 lần JSON.NUMINCRBY $.stock 1 (bài 1: GET/SET JSON còn 101/1000)
JSON.GET p:2 $.stock → [1000]
### số lớn
ghi {"id":9007199254740993,"order_id":12345678901234567890,"trace":123456789012345678901234567,"price":0.1}
JSON.GET n → {"id":9007199254740993,"order_id":12345678901234567890,"trace":1.2345678901234568e26,"price":0.1}
JSON.NUMINCRBY n $.price 0.2 → [0.30000000000000004]
### document lớn: 10 000 phần tử trong $.items
MEMORY USAGE big = 1331232 byte (JSON text 356741 byte)
JSON.GET big $.items[5000].price                                83 µs
JSON.GET big $..price                                         7280 µs
JSON.GET big $.items[?(@.price>499)].sku                      4210 µs
JSON.ARRINSERT big $.items 0 {"sku":"new","price":1,"qty":1}      99 µs
JSON.NUMINCRBY big $.items[5000].qty 1                          62 µs
JSON.GET big                                                  2817 µs
### FPHA (8.8): mảng 768 số thực (embedding)
mặc định       JSON.DEBUG MEMORY=  8208 byte, phần tử [0]: ghi -0.165956 đọc [-0.165956]
FPHA FP32      JSON.DEBUG MEMORY=  4112 byte, phần tử [0]: ghi -0.165956 đọc [-0.16595600545406342]
FPHA BF16      JSON.DEBUG MEMORY=  2064 byte, phần tử [0]: ghi -0.165956 đọc [-0.166015625]
FPHA FP16 với 70000.5 → value out of range for F16 at line 1 column 10

JSON được parse thành cây, mỗi nút một giá trị có kiểu: nhờ vậy sửa một nhánh là nguyên tử và rẻ (62–99 µs trên document 10 000 phần tử), và 20 process cùng cộng vẫn ra đúng 1000. Cái giá: RAM ~3,7 lần văn bản (1,33 MB cho 357 KB JSON; bài 3: 528 byte/sản phẩm so với 305 của string, 241 của hash). Đường dẫn quét đệ quy ($..price) hay có điều kiện ([?(@.price>499)]) là O(kích thước document): 4–7 ms chặn server. Số: nguyên tới u64 giữ nguyên, lớn hơn thành số thực — mất chữ số im lặng; số thực cộng dồn lệch như mọi double. FPHA (8.8) ép mảng số thực về FP32/BF16/FP16: RAM giảm 2–4 lần, đổi lại mất chính xác và lỗi khi vượt miền của kiểu nhỏ.

3.2 Bloom và Cuckoo: "chắc chắn không" với vài bit mỗi phần tử

Bloom filter: thêm, hỏi, và sai dương Mảng 20 bit. Thêm u1: ba hàm băm bật bit 2, 7, 13. Thêm u2: bật bit 4, 9, 16. Hỏi x5: bit 7, 11, 16 — bit 11 đang tắt nên trả chắc chắn không có. Hỏi x9: bit 2, 9, 13 đều đã được bật bởi u1 và u2 nên trả có thể có — sai dương. BF.ADD / BF.EXISTS — 20 bit, 3 hàm băm (minh hoạ) 024678 911131619 BF.ADD u1 → bit 2, 7, 13 BF.ADD u2 → bit 4, 9, 16 BF.EXISTS x5 → bit 7, 11, 16: bit 11 tắt → 0 = CHẮC CHẮN chưa có (không bao giờ sai) BF.EXISTS x9 → bit 2, 9, 13 đều đã bật (bởi u1, u2) → 1 = "có thể có" nhưng thật ra chưa → SAI DƯƠNG lab 100 000 phần tử, sai số đặt 1%: sai dương 0,51%, 11 bit/phần tử (138 KB) — set: 4,2 MB
Hình 1 — Bloom filter chỉ có bit, không có phần tử: bit đã bật thì nhiều phần tử dùng chung, nên (a) hỏi "có không" chỉ sai theo một hướng — sai dương; (b) không xoá được, vì tắt một bit là xoá luôn dấu vết của phần tử khác. Docs: sai số 1% cần 9,585 bit/phần tử và 7 hàm băm; lab đo được 11 bit/phần tử và sai dương 0,51%.
### Bloom: BF.RESERVE bf 0.01 100000 (sai dương 1%, sức chứa 100 000)                      (07b-prob.txt)
100 000 phần tử: MEMORY USAGE=137968 byte (11.0 bit/phần tử) | sai dương trên 100 000 phần tử lạ: 506 (0.51%) | sai âm: 0
nạp quá sức chứa lên 1 000 000: số sub-filter=4, MEMORY USAGE=2681176 | sai dương: 885 / 100 000 (0.89%)
NONSCALING (sức chứa 1 000), BF.ADD 1 100 phần tử khác nhau → {"thêm mới (1)":1000,"coi như đã có (0)":2,"lỗi":98} | lỗi: ERR non scaling filter is full
so sánh: set chứa cùng 100 000 phần tử: MEMORY USAGE=4237554 byte

### Cuckoo: CF.RESERVE cf 100000
100 000 phần tử: MEMORY USAGE=262240 byte | sai dương: 1177 / 100 000 (1.18%)
CF.EXISTS cf x25 (chưa từng thêm) → 1 (sai dương); CF.DEL cf x25 → 1; số phần tử ĐÃ thêm còn được thấy: 100000 → 99999  ← sai âm
CF.ADD dup 2 lần, CF.DEL dup 1 lần → CF.EXISTS dup = 1, CF.COUNT dup = 1 (cuckoo đếm như multiset)

Nạp quá sức chứa, Bloom mặc định tự thêm sub-filter (gấp đôi cỡ mỗi lần — bf-expansion-factor 2): không lỗi, nhưng RAM tăng và sai dương nhích lên (0,89%), mỗi lần hỏi phải dò mọi sub-filter. NONSCALING thì đầy là lỗi — và trước khi lỗi, có 2 phần tử mới bị coi là "đã có" (trả 0) mà không báo gì. Cuckoo lưu dấu vân tay ngắn nên CF.DEL được, nhưng chỉ an toàn với phần tử chắc chắn đã thêm: xoá một phần tử chưa thêm mà filter "tưởng" là có (sai dương) sẽ xoá nhầm dấu vân tay của phần tử khác → lần đầu tiên có sai âm (lab: 100 000 → 99 999).

3.3 Count-min sketch và Top-K: tần suất trên luồng lớn

Count-min sketch: vì sao chỉ đếm dư Ba hàng bộ đếm, mỗi hàng một hàm băm. Sản phẩm hot p1 cộng vào một ô mỗi hàng. Sản phẩm hiếm p999 rơi vào ô trùng với p1 ở hai hàng, và một ô riêng ở hàng ba. Ước lượng của p999 là số nhỏ nhất trong ba ô: 8, đúng; nếu cả ba hàng đều trùng thì ước lượng là số lớn — đếm dư. CMS.INCRBY / CMS.QUERY — 3 hàng × 8 ô (minh hoạ) hàng 1 (h1)hàng 2 (h2)hàng 3 (h3) 151 147 151 147 151 147 p1 (hot): 151 147 lượt cộng vào 1 ô mỗi hàng 8 p999 (hiếm, thật 8 lượt): h1, h2 trùng ô với p1; h3 rơi ô riêng CMS.QUERY p999 = min(151 155, 151 155, 8) = 8 — trùng ở mọi hàng mới bị đếm dư; không bao giờ đếm thiếu
Hình 2 — Count-min sketch: d hàng × w ô, mỗi hàng một hàm băm; cộng thì cộng vào một ô mỗi hàng, hỏi thì lấy ô nhỏ nhất. Va chạm chỉ cộng thêm, nên sai số luôn dương và tính theo tỉ lệ tổng lượt (0,1% của 1 000 000 = ~1 000). Với phần tử hot, 1 000 là nhỏ; với phần tử hiếm (8 lượt) thì lệch hàng trăm phần trăm.
### luồng 1 000 000 lượt xem trên 10 000 sản phẩm, phân phối Zipf (s = 1.1)                 (07b-prob.txt)
CMS (sai số 0.1% tổng, xác suất 99%): MEMORY USAGE=56056 byte | top 10: lệch tối đa +123 lượt | 5 000 sản phẩm ít xem nhất (trung bình thật 8.6 lượt):
    lệch trung bình +34.1, tối đa +182, lệch tương đối trung bình +489%
CMS có bao giờ đếm THIẾU? không (chỉ đếm dư)
Top-K (k=10): MEMORY USAGE=2800 byte | trùng với top 10 thật: 10/10 | p1=151146/151147 p2=70877/70889 p3=45153/45159 p4=32606/32753 … (ước lượng/thật)
(lần chạy trước: trùng 8/10)

CMS hợp để hỏi "p1 có bị xem quá 100 000 lần không" (hot), vô dụng để hỏi chính xác "p9999 được xem mấy lần" (đuôi). Top-K (HeavyKeeper) 2,8 KB giữ đúng nhóm đầu (8–10/10 qua hai lần chạy, có ngẫu nhiên bên trong) với số đếm sát (thiếu tối đa 0,5% ở nhóm đầu) — đủ cho "sản phẩm đang hot" trên trang chủ, không đủ cho bảng xếp hạng có thưởng.

3.4 t-digest: phân vị trên hàng triệu mẫu trong 10 KB

### t-digest: 1 000 000 độ trễ (log-normal, trung vị ~80 ms)                                (07b-prob.txt)
  p50    thật    79.94 ms | t-digest    80.85 ms | lệch +1.14%
  p90    thật   172.72 ms | t-digest   172.85 ms | lệch +0.07%
  p99    thật   322.71 ms | t-digest   322.08 ms | lệch -0.19%
  p99.9  thật   511.24 ms | t-digest   511.69 ms | lệch +0.09%
  MEMORY USAGE td = 9864 byte (1 000 000 mẫu) | mảng số thực thô ~ 8 000 000 byte

t-digest gom mẫu thành các centroid (trung bình + số lượng), cho phép centroid lớn ở giữa phân phối và bắt centroid nhỏ ở hai đầu — vì thế chính xác nhất ở đuôi (p99.9 lệch 0,09%), kém nhất ở trung vị (1,14%). Gộp được (TDIGEST.MERGE) — đó là cách đúng để có p99 của cả cụm từ nhiều máy, thay vì lấy trung bình p99 từng máy (sai về toán).

3.5 TimeSeries: nén, retention, downsample

### trùng timestamp (ts-duplicate-policy mặc định = block)                                  (07c-ts.txt)
TS.ADD cùng timestamp lần 2 → phpredis trả false, lỗi: ERR TSDB: Error at upsert, update is not supported when DUPLICATE_POLICY is set to BLOCK mode, …
TS.ADD … ON_DUPLICATE LAST → 1790000000000
TS.GET online:r1 (phpredis mặc định) → [1790000000000,true]   ← giá trị là simple string, phpredis đổi thành true
TS.GET online:r1 (OPT_REPLY_LITERAL)  → [1790000000000,"125"]
TS.ADD mẫu cũ hơn retention (đến muộn) → false ERR TSDB: Timestamp is older than retention
TS.ADD mẫu đến muộn nhưng trong retention → 1790089000000 (nhận, chèn đúng chỗ)

### RAM: 1 000 000 mẫu, mỗi giây 1 mẫu (≈ 11,6 ngày)
smooth  số nguyên đi chậm (số người online) MEMORY USAGE=  1275088 byte (1.28 byte/mẫu), chunk=302
noisy   số thực ngẫu nhiên (độ trễ ms)      MEMORY USAGE=  7531232 byte (7.53 byte/mẫu), chunk=1785
so sánh: zset score = ms, member = "ms:giá trị" → MEMORY USAGE=95726976 byte (95.7 byte/mẫu)

### downsample
TS.RANGE ts:smooth - + AGGREGATION max 3600000 (1 giờ) → 278 điểm, 13890 µs
TS.RANGE ts:smooth - + (thô, 1 000 000 điểm) → 207009 µs
compaction avg 1 phút: cpu:raw 3600 mẫu → cpu:avg1m 60 mẫu (bucket cuối chưa đóng), mẫu đầu [[1789999980000,"52"]]
TS.MRANGE - + FILTER kind=cpu → 2 series: cpu2:raw, cpu:raw

Mẫu được nén theo chunk (timestamp đều nhau → gần như miễn phí; giá trị ít đổi → vài bit): 1,3 byte/mẫu cho số nguyên đi chậm, 7,5 byte/mẫu cho số thực nhiễu — 13 tới 75 lần gọn hơn zset. Mặc định DUPLICATE_POLICY là block: client gửi lại (retry) cùng timestamp nhận lỗi — chọn LAST/SUM theo ý nghĩa dữ liệu. Compaction rule gộp tự động vào series khác (bucket căn theo mốc epoch, nên bucket đầu có thể bắt đầu trước dữ liệu); đọc thô 1 triệu điểm mất 0,2 giây chặn server — dashboard luôn đọc series đã gộp.

3.6 Vector set: HNSW + lượng tử hoá

HNSW: tìm tham lam từ tầng thưa xuống tầng dày Ba tầng đồ thị. Tầng 2 có ít điểm, tầng 0 có mọi điểm. Tìm bắt đầu từ điểm vào ở tầng 2, nhảy tới láng giềng gần truy vấn hơn, xuống tầng 1, nhảy tiếp, xuống tầng 0, rồi xét một nhóm ứng viên cỡ EF quanh đó để chọn 10 kết quả gần nhất. VSIM: tham lam từ tầng trên xuống (minh hoạ) tầng 2tầng 1tầng 0 truy vấn 1. từ điểm vào, nhảy tới láng giềng gần truy vấn hơn 2. xuống tầng, tiếp tục 3. tầng 0: xét EF ứng viên quanh đó, lấy COUNT gần nhất lab 10 000 × 128 chiều: Q8 recall 95,1%, BIN 24,7%
Hình 3 — HNSW không quét mọi vector: đi tham lam trên đồ thị nhiều tầng, nên nhanh (~0,7 ms cho 10 000 vector) nhưng có thể bỏ sót láng giềng thật (recall < 100%). Lượng tử hoá nén từng vector (Q8 mặc định: 1 byte/chiều; BIN: 1 bit/chiều) — sai số khoảng cách cộng thêm vào sai số của đồ thị. EF lớn chỉ cứu được phần sai của đồ thị, không cứu được phần sai do lượng tử hoá (lab: Q8 95,1% với EF mặc định lẫn EF 500).
### 10 000 vector 128 chiều, 100 cụm                                                         (07d-vec.txt)
Q8       nạp 10 000 vector:  8494 ms | MEMORY USAGE=  8188474 byte (  819 byte/vector) | recall@10 so với TRUTH cùng set: 100.0% | VSIM ~755 µs
NOQUANT  nạp 10 000 vector:  8527 ms | MEMORY USAGE= 12028482 byte ( 1203 byte/vector) | recall@10 so với TRUTH cùng set: 100.0% | VSIM ~740 µs
BIN      nạp 10 000 vector:  6757 ms | MEMORY USAGE=  7068482 byte (  707 byte/vector) | recall@10 so với TRUTH cùng set:  89.6% | VSIM ~717 µs
### recall thật: so với 10 láng giềng đúng tính trên vector gốc (NOQUANT + TRUTH)
Q8   mặc định recall@10 =  95.1%
Q8   EF 500    recall@10 =  95.1%
BIN  mặc định recall@10 =  24.7%
BIN  EF 500    recall@10 =  24.5%
### FILTER: thuộc tính JSON cho từng phần tử (20 danh mục, giá 10k–1tr)
MEMORY USAGE sau khi gắn thuộc tính: 8668474 byte
FILTER '.cat == 7' (khớp 5%): trả trung bình 10.0/10 kết quả, recall 83.4% so với TRUTH cùng filter, ~4830 µs
FILTER '.cat == 7 and .price < 60000' (khớp ~0,25%): trả trung bình 9.6/10 kết quả, recall 70.4% so với TRUTH cùng filter, ~6369 µs

Ba điều đọc ra: (1) "recall 100%" có thể là ảo — TRUTH trên chính set đã lượng tử hoá so sánh với vector đã méo; so với đáp án trên vector gốc thì Q8 còn 95%, BIN chỉ 25% (dữ liệu 128 chiều có cụm; BIN hợp hơn với embedding chiều cao và tìm thô — docs gọi là "coarse search"). (2) Phần lớn RAM là đồ thị, không phải vector: ở 128 chiều, Q8 tốn 819 byte trong đó vector chỉ 128 byte (hnsw-m 16 liên kết mỗi tầng). (3) Filter hẹp đắt và sót: HNSW phải duyệt thêm để tìm đủ phần tử khớp — chậm 6–8 lần, recall 70–83%, có lúc trả ít hơn COUNT; FILTER-EF tăng công sức lọc. Nạp ~0,7–0,9 ms/vector qua mạng.

3.7 Query Engine: index phụ cho hash/JSON

10 000 sản phẩm JSON; FT.CREATE idx:p xong: num_docs=10000, used_memory tăng 5639056 byte cho index (~563 byte/doc)        (07f-search.txt)
FT.SEARCH '@brand:{Coolmate} @price:[100000 300000] @stock:[1 +inf]' SORTBY price → tổng 177, 3 đầu: … (1482 µs)
FT.SEARCH idx:p '@name:áo' → 3750 kết quả
FT.SEARCH idx:p '@name:ao' → 0 kết quả
FT.SEARCH idx:p '@name:thoại' → 1250 kết quả
FT.SEARCH idx:p '@name:thoai' → 0 kết quả
FT.SEARCH '@brand:{Biti's}' (dấu nháy trong tag, không escape) → 0 kết quả
FT.SEARCH '@brand:{Biti\'s}' (escape) → 2000 kết quả
sửa JSON.NUMINCRBY p:5 → index cập nhật ngay: FT.SEARCH '@price:[89596 89596]' → 1 kết quả

Query Engine giữ inverted index (chữ, tag) và cây số (numeric) cạnh dữ liệu; mỗi lệnh ghi vào key khớp prefix được index đồng bộ — đọc thấy ngay. Giá: ~560 byte/doc cho 4 field, CPU mỗi lần ghi, và các bẫy của search engine "thô": không bỏ dấu tiếng Việt mặc định ("ao" không ra "áo"), ký tự đặc biệt trong tag phải escape (sai thì 0 kết quả, không lỗi). Search tiếng Việt nghiêm túc cần chuẩn hoá (lưu thêm field bỏ dấu) hoặc search engine có analyzer tiếng Việt.

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

Cấu trúcHướng sai số / đảm bảoLabGiới hạn chính
JSONChính xác; lệnh nguyên tử trên document1000/1000 khi 20 process cùng cộngRAM ~3,7 × văn bản; số > u64 thành double; path đệ quy O(N)
BloomChỉ sai dương, ≤ tham số (khi chưa quá sức chứa)0,51% (đặt 1%); quá sức chứa 0,89%Không xoá; nạp quá → sub-filter hoặc lỗi
CuckooSai dương; xoá được phần tử đã thêm1,18%; xoá nhầm → sai âmXoá phần tử chưa thêm làm hỏng filter
Count-minChỉ đếm dư, ≤ ε × tổng với xác suất 1 − δKhông đếm thiếu; đuôi +489%Vô dụng cho phần tử hiếm; không liệt kê
Top-KGần đúng, có ngẫu nhiên8–10/10 đúngPhần tử ở biên vào/ra thất thường
t-digestSai tương đối nhỏ, tốt nhất ở đuôi≤ 1,14% (p50), ≤ 0,19% (p90–p99.9)Không cho giá trị chính xác từng mẫu
TimeSeriesChính xác; trùng timestamp theo policy1,3–7,5 byte/mẫuMẫu cũ hơn retention bị từ chối; đọc thô nhiều điểm chặn server
Vector setGần đúng (HNSW + lượng tử)Q8 95%, BIN 25%, filter hẹp 70%RAM chủ yếu là đồ thị; một key = một node
Query EngineIndex cập nhật đồng bộ khi ghi~560 byte/docAnalyzer mặc định không hợp tiếng Việt; Cluster cần cấu hình riêng (chưa verify)

Khung lý thuyết: các sketch là ví dụ của đánh đổi không gian–độ chính xác có cận chặt: Bloom cần ~1,44·log₂(1/ε) bit/phần tử; Count-min cần O((1/ε)·log(1/δ)) ô; t-digest có sai số tương đối nhỏ dần về phía đuôi. HNSW là tìm kiếm gần đúng với độ phức tạp ~O(log N) mỗi truy vấn, đánh đổi recall lấy tốc độ qua EF và M.

5. Failure modes

#Tình huốngHậu quả (đo)Phát hiệnChữa / phòng
1Bloom chống cache penetration nhưng dữ liệu bị xoá ở DBKhông xoá được khỏi Bloom → vẫn "có thể có", request vẫn xuống DB (đúng nhưng lọt)Tỉ lệ lọt tăng dầnDựng lại filter định kỳ; Cuckoo nếu cần xoá
2Bloom nạp quá sức chứaSub-filter: RAM ×19 (138 KB → 2,7 MB), sai dương 0,89%; NONSCALING: lỗi + 2 phần tử bị coi là "đã có"BF.INFO số filter, itemsƯớc lượng sức chứa; theo dõi items/capacity
3CF.DEL phần tử chưa chắc đã thêmXoá nhầm → sai âm (100 000 → 99 999)Gần như không thểChỉ xoá khi nguồn sự thật xác nhận đã thêm
4Dùng CMS cho số liệu đuôi (thống kê từng sản phẩm)Lệch +489% trung bìnhSố liệu sản phẩm ít người xem "đẹp" bất thườngCMS chỉ cho câu hỏi về phần tử hot; đuôi đếm chính xác ở nơi khác
5Retry gửi lại mẫu TimeSeries cùng timestampLỗi DUPLICATE_POLICY … BLOCKLog lỗi ghi metricON_DUPLICATE LAST/SUM theo ý nghĩa
6Đọc TimeSeries qua phpredis/LaravelGiá trị thành trueBiểu đồ toàn 1Redis::OPT_REPLY_LITERAL
7Vector set dạng BIN cho embedding chiều thấp có cụmRecall 25%Đo recall với tập kiểm traQ8 mặc định; đo recall trên đáp án float trước khi chọn
8Vector search + filter rất hẹpChậm 6–8 lần, recall 70%, trả thiếu kết quảp99 tăng, số kết quả < COUNTTách set theo nhóm lớn (mỗi danh mục một key), FILTER-EF
9ID dài > u64 trong JSONThành số thực, mất chữ số im lặngID sai khi đọc lạiLưu ID dạng chuỗi
10Search tiếng Việt không chuẩn hoá"ao" → 0 kết quảNgười dùng báo "không tìm thấy"Field bỏ dấu song song, hoặc search engine chuyên dụng
11Chuyển sang Valkey / Redis cũ / managed service không hỗ trợERR unknown command; RDB 14 không nạp đượcLúc migrateDữ liệu dựng lại được; adapter

6. Vận hành trong production

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

use Illuminate\Support\Facades\Redis;

$prefix = Redis::connection()->client()->getOption(\Redis::OPT_PREFIX);
$k = fn (string $name) => $prefix . $name;                        // executeRaw KHÔNG tự thêm prefix

Redis::executeRaw(['JSON.SET', $k('product:9'), '$', json_encode($product, JSON_UNESCAPED_UNICODE)]);
Redis::executeRaw(['JSON.NUMINCRBY', $k('product:9'), '$.stock', '-1']);

Redis::executeRaw(['BF.RESERVE', $k('bf:email'), '0.001', '100000']);   // luôn RESERVE trước
$maybe = Redis::executeRaw(['BF.EXISTS', $k('bf:email'), $email]) === 1;  // 0 = chắc chắn chưa có

Redis::connection()->client()->setOption(\Redis::OPT_REPLY_LITERAL, true);   // để TS.GET trả "3" chứ không phải true
Redis::executeRaw(['TS.ADD', $k('ts:orders'), '*', '3', 'ON_DUPLICATE', 'SUM']);

Redis::vadd('emb:courses', $embedding, 'laravel-nang-cao');           // vector set: có method, có prefix
$similar = Redis::vsim('emb:courses', $queryVector, ['COUNT' => 5, 'WITHSCORES' => true]);
### output                                                                               (07e-laravel.txt)
JSON.NUMINCRBY qua executeRaw → [4]
Redis::get('product:9') trên key JSON → NULL  (key là JSON: WRONGTYPE → phpredis false → Laravel đổi thành null, giống key không tồn tại)
BF.EXISTS a@x.vn / b@x.vn → 1 / 0
TS.GET qua executeRaw → [1791016071803,true]   ← giá trị thành true (simple string)
Redis::vsim([0.1,0.85,0.25]) → {"laravel-nang-cao":0.9986881017684937,"laravel-co-ban":0.9983288049697876}
key thật: lab:bf:email, lab:emb:courses, lab:product:9, lab:ts:orders

Bẫy của đoạn code: bật OPT_REPLY_LITERAL trên connection dùng chung làm đổi cả kết quả các lệnh khác trả "OK" (thành chuỗi "OK" thay vì true) — nên dùng một connection riêng cho TimeSeries; Redis::get() trên key sai kiểu trả null như cache miss (bài 1).

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

Nhu cầuRedis 8Thay thếChọn Redis khiChọn thay thế khi
Document lồng nhau, sửa từng phầnJSONPostgreSQL jsonb, MongoDBDữ liệu nóng, cần < 1 ms, dựng lại đượcLà nguồn sự thật, cần truy vấn/transaction
"Chắc chắn chưa có"Bloom / CuckooSet (chính xác), unique index DBTập rất lớn, chấp nhận sai dươngCần chính xác hoặc cần xoá thường xuyên
Tần suất / hot items trên luồngCMS / Top-KHash/zset đếm thật, ClickHouseChỉ quan tâm phần tử hot, RAM cố địnhCần số chính xác cho mọi phần tử
Phân vị độ trễt-digestPrometheus histogram, HDR histogramCần phân vị realtime trong app, gộp đượcĐã có hệ thống metrics chuẩn
Số liệu theo thời gianTimeSeriesPrometheus, TimescaleDB, InfluxDBVài nghìn series, giữ ngắn, đọc nhanh trong appMetrics hạ tầng, giữ dài, nhiều series
Tìm vector tương tựVector set (hoặc vector index của Query Engine)pgvector, Qdrant, MilvusVài trăm nghìn vector, cần độ trễ thấp, đã có RedisHàng chục triệu vector (RAM), cần lọc phức tạp, cần bền
Lọc/tìm theo nội dungQuery EnginePostgreSQL index, Elasticsearch/OpenSearchLọc tag/khoảng số trên dữ liệu đã ở RedisFull-text tiếng Việt, facet phức tạp, dữ liệu lớn

Dùng khi: dữ liệu đã/nên ở Redis, cần độ trễ thấp, quy mô vừa RAM, chấp nhận (và hiểu) sai số của sketch. Tránh khi: cần là nguồn sự thật, cần chạy trên Valkey/managed service không hỗ trợ, hoặc hệ thống chuyên dụng đã có sẵn trong công ty — thêm một "Elasticsearch nhỏ" trong Redis là thêm một thứ phải vận hành mà ít người hiểu.

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. Thay PostgreSQL bằng RedisJSON "cho nhanh": mất bền vững, mất truy vấn, RAM gấp 3,7 lần văn bản.
  2. BF.ADD không BF.RESERVE: filter mặc định 100 phần tử, phình thành hàng chục sub-filter.
  3. Xoá trong Cuckoo theo sự kiện không chắc chắn: sai âm — phá đúng lời hứa của filter.
  4. Báo cáo lượt xem từng sản phẩm bằng CMS: đuôi lệch hàng trăm phần trăm.
  5. Chọn BIN để tiết kiệm RAM mà không đo recall: 25%.
  6. Lưu snowflake/ID lớn dạng số trong JSON: vượt u64 là mất chữ số; nhiều client JS còn mất từ 2⁵³.

Nguồn công khai: release notes Redis 8.0 (gộp module, license); docs BF.RESERVE (công thức bit/hàm băm), trang vector set "Quantization modes" (Q8/BIN/NOQUANT). Chưa tìm được postmortem công khai về các kiểu này — số liệu là lab tự dựng trên dữ liệu tổng hợp (Zipf, log-normal, vector có cụm).

Điều docs không nói rõ: "recall 100%" khi so TRUTH trên set đã lượng tử hoá; NONSCALING Bloom trả 0 cho vài phần tử mới trước khi báo đầy; phpredis đổi giá trị TimeSeries thành true; Query Engine mặc định không hợp tiếng Việt có dấu.

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

Bloom filter sai theo hướng nào? Dùng chống cache penetration thế nào?
Chỉ sai dương. Request cho id: BF.EXISTS = 0 → chắc chắn không có → trả 404 ngay, không chạm DB; = 1 → đi cache/DB như thường. Không xoá được → dựng lại định kỳ.
Bloom và Cuckoo khác gì?
Cuckoo lưu dấu vân tay nên xoá được và thường tốt hơn ở sai số thấp, nhưng tốn RAM hơn (lab 262 KB vs 138 KB) và xoá phần tử chưa thêm gây sai âm.
Count-min sketch đếm thiếu được không?
Không — va chạm chỉ cộng thêm, lấy min qua các hàng. Sai số tỉ lệ với tổng lượt, nên phần tử hiếm lệch tương đối rất lớn.
Lấy p99 độ trễ của cả cụm từ 10 server?
Mỗi server một t-digest, TDIGEST.MERGE rồi hỏi p99. Không lấy trung bình p99 của từng server.
TimeSeries khác zset theo thời gian thế nào?
Nén (1,3–7,5 vs 96 byte/mẫu), retention, compaction, lọc theo label, policy trùng timestamp. Zset hợp khi cần xoá/sửa theo member và đếm theo khoảng.
Vector set đủ cho RAG/gợi ý không?
Với vài trăm nghìn vector, đã có Redis: được. Đo recall trên đáp án float (Q8 ~95%); cẩn thận BIN và filter hẹp; RAM chủ yếu là đồ thị; một key một node; dữ liệu phải dựng lại được.
Khi nào không dùng các kiểu mới này?
Khi phải chạy trên Valkey/managed service không hỗ trợ, khi cần là nguồn sự thật, khi quy mô vượt RAM, hoặc khi hệ thống chuyên dụng (Elasticsearch, Prometheus, pgvector) đã có và team đã quen.

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

Môi trường: Redis 8.8.0 (redis:alpine, có sẵn module search 8.8.0, ReJSON 8.8.0, bf 8.8.0, timeseries 8.8.0, vectorset). Phần đo viết bằng PHP 8.3.33 + phpredis 6.3.0 (kết nối thẳng, không prefix), đáp án chính xác tính trong PHP; phần code ứng dụng qua Laravel 12.64 (REDIS_PREFIX=lab:). Dữ liệu tổng hợp có seed cố định.

cd labs/redis
./07-redis8.sh            # chạy hết → out/07a … 07f
./07-redis8.sh vec        # 1 phần: json | prob | ts | vec | search | laravel

Chưa verify: Query Engine trên Redis Cluster; vector index của Query Engine (HNSW/SVS-VAMANA) so với vector set; embedding thật (OpenAI 1 536 chiều) — lab dùng vector tổng hợp 128 chiều; nâng cấp dữ liệu module từ Redis Stack lên 8.x; hỗ trợ trên các managed service.