Bỏ qua để đến nội dung
Knowledge Hub

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

Một shop thương mại điện tử dùng Redis 7.4 cho sáu việc:

  1. Cache profile user.
  2. Giỏ hàng.
  3. Top sản phẩm bán chạy theo ngày.
  4. Đếm user truy cập duy nhất mỗi ngày.
  5. Đánh dấu user đã điểm danh theo ngày.
  6. 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?

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.

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…

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 cập nhật score và thứ hạng ngay khi ghi; trang chủ chỉ đọc 3 member đầu
ZINCRBY cập nhật score và thứ hạng ngay khi ghi; trang chủ chỉ đọc 3 member đầuBước 1/5: Đơn có 3 áo thun: ZINCRBY 3 sku-ao-thun. Member chưa có nên được tạo với score 3.Sorted settop:2026-10-24Checkoutghi khi có đơnsku-ao-thun3 · #1sku-giaysku-tuisku-nonTrang chủtop 3ZINCRBY 3
Checkout → sku-ao-thunZINCRBY 3
Bước 1/5

Đơ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.

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ờ):

  1. Worker LMOVE jobs jobs:processing RIGHT LEFT: job chuyển sang list processing, nguyên tử.
  2. Xử lý xong, LREM jobs:processing 1 <job> (tương đương ack).
  3. 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 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.

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). SETBIT offset 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, HPERSIST có từ Redis 7.4.0. Redis 8.0 thêm HSETEX/HGETEX để ghi field kèm TTL trong một lệnh.
  • KEYS, HGETALL/SMEMBERS trên key lớn là O(N) và chặn mọi client khác. Dùng SCAN, HSCAN, SSCAN theo lô (xem lệnh nguy hiểm).

Demo chạy trên Redis 7.4.9 (examples/docker-compose.yml), dùng key demo:redis-data-structures:* và xoá khi xong:

Terminal window
npx --prefix examples ts-node examples/src/redis-data-structures/demo.ts

Output 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 byte
hash 513 field: hashtable, 32160 byte
xoá bớt về 511 field: hashtable (không tự quay lại listpack)
zset 128 member: listpack
zset 129 member: skiplist
== 3. Đếm user duy nhất: HyperLogLog vs Set, 100.000 user ==
SCARD (chính xác): 100000
PFCOUNT (ướ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 byte
1 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.

Ư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

  1. Đặt EXPIRE cho mọi key theo ngày (top:, uv:, checkin:) ngay khi tạo.
  2. Giới hạn kích thước collection; chạy redis-cli --bigkeys hoặc --memkeys định kỳ.
  3. Chặn KEYS bằng ACL; code dùng SCAN/HSCAN/SSCAN với COUNT vừa phải.
  4. Đo MEMORY USAGE và OBJECT ENCODING trên dữ liệu mẫu thật trước khi chỉnh ngưỡng listpack.
  5. 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.
  6. Queue bằng list phải có list processing và tiến trình requeue; cần ack thật thì dùng stream.
  7. Xác nhận version Redis trước khi dùng HEXPIRE (7.4+) hay HSETEX (8.0+).

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ỏ
  1. Vì sao hai ZINCRBY đồng thời không mất lượt cộng, còn GET rồi SET thì có?
  2. Hash giỏ hàng có 520 SKU rồi bị xoá còn 10. OBJECT ENCODING trả gì, và vì sao?
  3. Vì sao bitmap chỉ chứa user ID 10.000.000 lại tốn khoảng 1,25 MB?
  4. Worker BRPOP xong thì bị kill. Job ở đâu? Với LMOVE thì sao?