Redis data structures: chọn kiểu dữ liệu theo use case thay vì nhét mọi thứ vào string Cơ bản
Thời gian đọc ước tính: 9 phút
Vấn đề
Phần tiêu đề “Vấn đề”Một shop thương mại điện tử dùng Redis 7.4 cho sáu việc:
- Cache profile user.
- Giỏ hàng.
- Top sản phẩm bán chạy theo ngày.
- Đếm user truy cập duy nhất mỗi ngày.
- Đánh dấu user đã điểm danh theo ngày.
- Hàng đợi việc nhẹ như gửi email.
Cách dễ nhất là SET JSON vào string cho mọi thứ. Khi đó cộng điểm bán phải đọc-sửa-ghi cả chuỗi, hai request đồng thời ghi đè nhau, và đếm 100.000 user duy nhất tốn vài MB.
Câu hỏi của bài: với mỗi use case, kiểu dữ liệu nào cho lệnh nguyên tử phù hợp, độ phức tạp chấp nhận được và tốn ít bộ nhớ nhất?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Use case | Structure | Lệnh chính | Độ phức tạp | Bẫy |
|---|---|---|---|---|
| (1) Profile user | Hash user:{id} |
HSET, HGET, HMGET |
O(1) mỗi field | HGETALL hash lớn là O(N) |
| (2) Giỏ hàng | Hash cart:{id}, field = SKU |
HINCRBY, HDEL, HEXPIRE (7.4+) |
O(1) mỗi field | Field TTL không có trước 7.4 |
| (3) Top bán chạy ngày | Sorted set top:{ngày} |
ZINCRBY, ZRANGE ... REV |
O(log N), O(log N + M) | Quên TTL cho key theo ngày |
| (4) User duy nhất/ngày | HyperLogLog uv:{ngày} |
PFADD, PFCOUNT |
O(1) | Chỉ ước lượng, không liệt kê được |
| (5) Điểm danh theo ngày | Bitmap checkin:{ngày} |
SETBIT, GETBIT, BITCOUNT |
O(1); BITCOUNT O(N) |
ID lớn, thưa tốn bộ nhớ |
| (6) Hàng đợi việc nhẹ | List hoặc stream | LMOVE/LREM; XREADGROUP/XACK |
O(1) | BRPOP không có ack |
| Bộ đếm đơn | String | INCR, INCRBY |
O(1) | Value tối đa 512 MB |
Cần số chính xác hoặc liệt kê thành viên thì dùng set thay cho HyperLogLog.
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Mỗi key Redis có một kiểu (type) quyết định lệnh nào dùng được, và một encoding là cách lưu trong bộ nhớ. Kiểu là hợp đồng với ứng dụng; encoding Redis tự chọn và tự đổi.
Bitmap và HyperLogLog thực chất là string, thao tác bằng lệnh bit hoặc PF*. Sorted set là set mà mỗi member có score, luôn giữ thứ tự theo score.
Cây quyết định hỏi “cần thao tác gì” trước, rồi mới tới tên kiểu.
Đang tải diagram…
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Sorted set: leaderboard cập nhật trong một lệnh
Phần tiêu đề “Sorted set: leaderboard cập nhật trong một lệnh”ZINCRBY cộng score và đặt lại vị trí member trong O(log N). Không cần đọc trước, nên hai checkout đồng thời không mất lượt cộng (command chạy tuần tự, xem event loop).
ZINCRBY 3Đơn có 3 áo thun: ZINCRBY 3 sku-ao-thun. Member chưa có nên được tạo với score 3.
| Bước | Lệnh | Trạng thái top:2026-10-24 sau bước |
|---|---|---|
| 1 | ZINCRBY 3 sku-ao-thun |
ao-thun 3 |
| 2 | ZINCRBY 5 sku-giay |
giay 5, ao-thun 3 |
| 3 | ZINCRBY 4 sku-ao-thun |
ao-thun 7, giay 5 |
| 4 | ZINCRBY 6 sku-tui, ZINCRBY 2+1 sku-non |
ao-thun 7, tui 6, giay 5, non 3 |
| 5 | ZRANGE top:2026-10-24 0 2 REV WITHSCORES |
Trả ao-thun 7, tui 6, giay 5 |
Key theo ngày cần EXPIRE (ví dụ giữ 7 ngày), nếu không mỗi ngày để lại một sorted set vĩnh viễn.
List: queue không có ack
Phần tiêu đề “List: queue không có ack”BRPOP lấy và xoá job khỏi list trong cùng một lệnh. Worker chết sau khi pop mà trước khi gửi email thì job mất, Redis không còn bản nào. Tài liệu LMOVE gọi queue kiểu này là không tin cậy.
Mẫu LMOVE (hoặc BLMOVE để chặn chờ):
- Worker
LMOVE jobs jobs:processing RIGHT LEFT: job chuyển sang list processing, nguyên tử. - Xử lý xong,
LREM jobs:processing 1 <job>(tương đương ack). - Một tiến trình khác quét
jobs:processing, đẩy lại job treo quá lâu.
Bước 3 do ứng dụng tự làm, và job có thể chạy hai lần, nên side effect cần idempotency.
Stream: khi cần ack thật
Phần tiêu đề “Stream: khi cần ack thật”Stream có consumer group: XREADGROUP giao message cho một consumer và ghi vào pending entries list; XACK xoá khỏi danh sách đó; XAUTOCLAIM chuyển message treo cho consumer khác. Retry, poison message và replay dài hạn thì xem nhóm Messaging.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”Ngưỡng encoding nhỏ mặc định của Redis 7.4 (CONFIG GET '*-max-listpack-*'):
| Kiểu | Encoding nhỏ | Đổi sang encoding lớn khi |
|---|---|---|
| Hash | listpack |
> hash-max-listpack-entries 512 field, hoặc value > hash-max-listpack-value 64 byte |
| Sorted set | listpack |
> zset-max-listpack-entries 128, hoặc member > 64 byte → skiplist |
| Set | intset (toàn số nguyên, ≤ 512) / listpack (≤ 128, từ 7.2) |
Vượt ngưỡng → hashtable |
Listpack là mảng byte liền nhau: tiết kiệm bộ nhớ nhưng tìm field phải quét tuyến tính, nên chỉ dùng dưới ngưỡng.
Những giới hạn khác:
- String và bitmap tối đa 512 MB (offset < 2^32).
SETBIToffset lớn trên key mới cấp phát cả vùng nhớ phía trước, có thể chặn server. - HyperLogLog dùng tối đa 12 KB, sai số chuẩn 0,81% (tài liệu Redis); số đếm là ước lượng.
HEXPIRE,HTTL,HPERSISTcó từ Redis 7.4.0. Redis 8.0 thêmHSETEX/HGETEXđể ghi field kèm TTL trong một lệnh.KEYS,HGETALL/SMEMBERStrên key lớn là O(N) và chặn mọi client khác. DùngSCAN,HSCAN,SSCANtheo lô (xem lệnh nguy hiểm).
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo chạy trên Redis 7.4.9 (examples/docker-compose.yml), dùng key demo:redis-data-structures:* và xoá khi xong:
npx --prefix examples ts-node examples/src/redis-data-structures/demo.tsOutput thật, rút gọn (MEMORY USAGE ... SAMPLES 0; số phụ thuộc version và allocator):
== 1. Profile 1.000 user: 1 hash/user vs 6 string key/user ==hash : 1000 key, encoding=listpack, tổng 208000 byte (208 byte/user)string : 6000 key, encoding=embstr, tổng 615928 byte (616 byte/user)string/hash = 2.96x
== 2. Encoding đổi khi vượt ngưỡng ==hash 512 field: listpack, 6224 bytehash 513 field: hashtable, 32160 bytexoá bớt về 511 field: hashtable (không tự quay lại listpack)zset 128 member: listpackzset 129 member: skiplist
== 3. Đếm user duy nhất: HyperLogLog vs Set, 100.000 user ==SCARD (chính xác): 100000PFCOUNT (ước lượng): 100165, sai số 0.165%MEMORY USAGE: HLL 14424 byte (STRLEN 12304), Set 5572928 byte
== 5. Điểm danh theo ngày bằng bitmap ==BITCOUNT: 4 , STRLEN: 13 byte1 user id 10.000.000: BITCOUNT 1, STRLEN 1250001 byte, MEMORY USAGE 1310808 byteĐọc kết quả:
- Hash vượt 512 field: bộ nhớ tăng từ 6.224 lên 32.160 byte, và xoá field không đưa về listpack.
- HyperLogLog lệch 0,165% trên dữ liệu này, tốn khoảng 1/386 bộ nhớ của set. 0,81% là sai số chuẩn, không phải giới hạn trên.
- Bitmap chỉ có user 10.000.000 vẫn tốn 1,25 MB, vì byte 0 đến offset đó đều được cấp phát.
Trade-offs
Phần tiêu đề “Trade-offs”Ưu điểm
-
Lệnh nguyên tử theo đúng thao tác (
ZINCRBY,HINCRBY,PFADD), không cần đọc-sửa-ghi. -
Encoding nhỏ giảm bộ nhớ đáng kể khi collection dưới ngưỡng.
-
HyperLogLog và bitmap đếm hàng triệu phần tử trong vài KB hoặc vài MB.
< slot="cons"> >
-
Collection lớn thành big key: lệnh O(N), xoá và di chuyển chậm.
-
Cấu trúc xác suất không cho liệt kê, không chính xác tuyệt đối.
-
List không có ack; stream có ack nhưng ứng dụng vẫn phải xử lý pending và trùng lặp.
Nhược điểm
Checklist production
Phần tiêu đề “Checklist production”- Đặt
EXPIREcho mọi key theo ngày (top:,uv:,checkin:) ngay khi tạo. - Giới hạn kích thước collection; chạy
redis-cli --bigkeyshoặc--memkeysđịnh kỳ. - Chặn
KEYSbằng ACL; code dùngSCAN/HSCAN/SSCANvớiCOUNTvừa phải. - Đo
MEMORY USAGEvàOBJECT ENCODINGtrên dữ liệu mẫu thật trước khi chỉnh ngưỡng listpack. - Bitmap chỉ dùng khi ID số nguyên dày; ID thưa thì dùng set hoặc ánh xạ sang ID liên tục.
- Queue bằng list phải có list processing và tiến trình requeue; cần ack thật thì dùng stream.
- Xác nhận version Redis trước khi dùng
HEXPIRE(7.4+) hayHSETEX(8.0+).
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Lỗi phổ biến nhất là cộng điểm bằng đọc-sửa-ghi trên string JSON:
Đọc-sửa-ghi leaderboard trên string
Hay gặp
const top = JSON.parse((await redis.get('top:2026-10-24')) ?? '{}');top['sku-giay'] = (top['sku-giay'] ?? 0) + 5;await redis.set('top:2026-10-24', JSON.stringify(top));Nên viết
await redis.zincrby('top:2026-10-24', 5, 'sku-giay');await redis.expire('top:2026-10-24', 7 * 86400, 'NX');Vì sao
Hai checkout cùng GET rồi SET thì mất một lượt cộng (lost update). ZINCRBY cộng trong một command.
Lỗi thứ hai hay gặp với queue:
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
HGETALL luôn nhanh vì là O(1) |
Hash 1 triệu field chặn event loop | HMGET field cần, hoặc HSCAN |
PFCOUNT là số chính xác |
Báo cáo lệch, không đối soát được | Dùng set khi cần chính xác |
| Xoá bớt field thì hash về listpack | Bộ nhớ vẫn cao | Ghi lại key mới nếu cần thu nhỏ |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Vì sao hai
ZINCRBYđồng thời không mất lượt cộng, cònGETrồiSETthì có? - Hash giỏ hàng có 520 SKU rồi bị xoá còn 10.
OBJECT ENCODINGtrả gì, và vì sao? - Vì sao bitmap chỉ chứa user ID 10.000.000 lại tốn khoảng 1,25 MB?
- Worker
BRPOPxong thì bị kill. Job ở đâu? VớiLMOVEthì sao?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- Redis Docs — Understand Redis data types: lệnh chính của từng kiểu.
- Redis Docs — HEXPIRE: field TTL, từ 7.4.0.
- Redis Docs — LMOVE: mẫu reliable queue.
- Redis Docs — HyperLogLog: 12 KB, sai số chuẩn 0,81%.
- Redis Docs — Memory optimization: encoding nhỏ.