Skip to content

Redis ​

Redis là kho dữ liệu nằm trong RAM. Ngoài cặp key–value dạng chuỗi, nó có nhiều kiểu dữ liệu như Hash, Sorted set, Stream, để chọn đúng cấu trúc cho bài toán. Trang này đi từ các kiểu dữ liệu, tới kiến trúc của Redis (vì sao một thread mà vẫn nhanh, và cái giá của thiết kế đó), cách Redis xoá key hết hạn và đẩy key ra khi đầy bộ nhớ, rồi tới lưu bền, replication và cluster.

Redis và Valkey. Từ bản 7.4 (tháng 3/2024), Redis chuyển sang license RSALv2/SSPLv1, không còn là mã nguồn mở theo định nghĩa của OSI. Linux Foundation lập dự án Valkey, tiếp tục từ Redis 7.2.4, với sự ủng hộ của AWS, Google Cloud, Oracle và một số công ty khác. AWS và Google Cloud đều có dịch vụ Valkey. Từ Redis 8 (2025), Redis có thêm lựa chọn license AGPLv3. Phần lớn nội dung trang này đúng cho cả hai.

Kiểu dữ liệu ​

KiểuDùng choLệnh tiêu biểu
String (tối đa 512 MB)Cache object, access token, counter, lockSET k v EX 300, INCR, SET lock v NX PX 30000
HashObject nhiều trường, sửa từng trường; từ 7.4 đặt TTL cho từng fieldHSET user:1 name An, HINCRBY, HEXPIRE
ListHàng đợi đơn giản, log gần nhấtLPUSH, BRPOP, LTRIM
SetTập không trùng, giao và hợp tậpSADD, SISMEMBER, SINTER
Sorted setBảng xếp hạng, dữ liệu theo score, hàng đợi hẹn giờZADD, ZRANGE … REV, ZRANGE … BYSCORE
HyperLogLogĐếm unique xấp xỉ: tối đa 12 KB mỗi key, sai số khoảng 0,81%PFADD, PFCOUNT
BitmapCờ theo id, ví dụ điểm danh theo ngày, 1 bit mỗi userSETBIT, BITCOUNT
GeoTìm điểm gần nhất theo bán kínhGEOADD, GEOSEARCH
StreamLog sự kiện có lưu, nhiều consumer groupXADD, XREADGROUP, XACK
Vector set (ra mắt ở Redis 8.0 dưới dạng beta)Tìm vector gần nhất (embedding)VADD, VSIM

ZREVRANGE và ZRANGEBYSCORE bị coi là deprecated từ Redis 6.2, nên dùng ZRANGE với REV hoặc BYSCORE. Redis 8 Open Source tích hợp sẵn những thứ trước đây phải cài module riêng: JSON, Query Engine (trước là RediSearch), time series và các cấu trúc xác suất như Bloom filter, Top-k. Không phải bản phân phối nào cũng có đủ, ví dụ image redis:8.4.0 trên Docker Hub không có JSON và Bloom, nên kiểm bản đang chạy trước khi dùng.

TTL giữ cho cache không phình vô hạn. Gắn TTL cho mọi key cache, và cộng thêm một khoảng ngẫu nhiên (jitter) để các key không hết hạn cùng lúc (Cache avalanche).

Encoding gọn cho key nhỏ ​

Hash, Set, Sorted set và List nhỏ không dùng bảng băm hay skiplist mà được lưu liền một khối, gọi là listpack (Set toàn số nguyên thì là intset). Tìm trong một khối nhỏ bằng cách duyệt tuần tự vẫn nhanh, mà tiết kiệm được con trỏ và phần quản lý của từng phần tử. Tài liệu Redis nói cách này tốn ít bộ nhớ hơn tới 10 lần, trung bình 5 lần.

Vượt ngưỡng thì Redis tự đổi sang encoding thường. Trên Redis 8.4, Hash giữ listpack tới 512 field (hash-max-listpack-entries), với mỗi giá trị không quá 64 byte (hash-max-listpack-value). Set và Sorted set có ngưỡng 128 phần tử. Bài viết cũ hay ghi ngưỡng Hash là 128, nên kiểm bằng CONFIG GET trên bản đang chạy. OBJECT ENCODING key cho biết key đang dùng encoding nào.

Mỗi key còn tốn thêm vài chục byte cho phần quản lý của chính nó. Vì vậy lưu một user thành 5 key String riêng (user:1:name, user:1:email…) tốn gấp đôi so với một Hash user:1 có 5 trường. Hash và một String chứa JSON tốn ngang nhau, nhưng Hash cho đọc và sửa từng trường (HGET, HINCRBY) mà không phải tải cả object về.

Kiến trúc: một thread chạy lệnh ​

Client AClient B… hàng nghìnKernelepoll / kqueueThread chínhevent loopđọc lệnh → chạy → trả lờitừng lệnh một,không lockbáoKeyspacetrong RAMThread nềngiải phóng key lớn,fsync, đóng fileProcess con (fork)ghi RDB, viết lại AOF
Mọi lệnh chạy lần lượt trên thread chính. Chỉ những việc chậm và không cần thứ tự mới được đẩy sang thread nền hoặc process con.

Redis dùng đúng mô hình của Node.js (Event loop). Kernel canh hàng nghìn socket bằng epoll và báo socket nào có dữ liệu. Thread chính đọc lệnh, chạy lệnh trên dữ liệu trong RAM, ghi kết quả vào socket, rồi chuyển sang lệnh tiếp theo. Tài liệu Redis gọi thiết kế này là mostly single threaded: mọi lệnh chạy trên một thread, chỉ vài việc chậm như giải phóng bộ nhớ của key lớn, fsync file AOF, đóng file mới chạy ở thread nền.

Vì sao một thread vẫn nhanh ​

  • Dữ liệu nằm trong RAM, nên đọc một key không phải chờ disk.
  • Mỗi lệnh chỉ tốn vài µs CPU. GET, SET, HGET là thao tác trên bảng băm trong bộ nhớ.
  • Không có lock. Chỉ một thread chạm vào dữ liệu, nên không phải khoá, không phải chờ thread khác, và mỗi lệnh tự nhiên là nguyên tử.
  • Cấu trúc dữ liệu tối ưu cho RAM, ví dụ Hash nhỏ được lưu liền một khối (Encoding gọn cho key nhỏ).

Vì vậy, nút thắt của Redis thường không nằm ở CPU chạy lệnh mà nằm ở mạng và socket. Mỗi lệnh gửi riêng lẻ cần một lần read() và một lần write() trên socket, và client phải chờ trọn một vòng mạng mới gửi được lệnh tiếp. Một core chạy được khoảng 260.000 GET mỗi giây khi client gửi từng lệnh, và hơn 2 triệu khi gửi theo lô.

Pipelining: đừng chờ từng lệnh ​

Pipelining là gửi nhiều lệnh liền một mạch rồi mới đọc các kết quả. Redis đọc nhiều lệnh trong một lần read() và trả nhiều kết quả trong một lần write(), còn client chỉ trả một vòng mạng cho cả lô.

js
// Chậm: mỗi lệnh chờ một vòng mạng
for (const id of ids) users.push(await redis.get(`user:${id}`))

// Nhanh: các lệnh đi chung, chỉ chờ một lần
const users = await Promise.all(ids.map((id) => redis.get(`user:${id}`)))

// Hoặc gom rõ ràng thành lô
const p = redis.pipeline()
for (const id of ids) p.get(`user:${id}`)
const results = await p.exec()

Promise.all nhanh gần bằng pipeline(), vì ioredis ghi mỗi lệnh vào socket ngay khi được gọi. Với 10.000 key qua mạng Docker, cách đầu mất gần 3 giây, hai cách sau chỉ vài chục ms. Đây chính là lỗi N+1 query (N+1 query), chỉ là với Redis thay vì database.

Mạng
Cách gửiSố vòng mạngThời gianĐo thật
await từng lệnh10.0003.100 ms
2.677–3.623 ms
Promise.all140,3 ms
33–55 ms
pipeline(), lô 10.000 lệnh116,3 ms
13–27 ms

Gửi lần lượt thì mỗi lệnh chờ trọn một vòng mạng, dù Redis chỉ tốn vài µs để chạy nó. Gom lệnh lại thì chỉ trả tiền mạng một lần cho cả lô, và thời gian còn lại chủ yếu là chi phí ở phía client. Mạng càng xa thì khoảng cách giữa hai cách càng lớn.

Thang log; chi phí mỗi lệnh lấy từ số đo với ioredis.

Cái giá: một lệnh chậm chặn mọi client ​

Vì lệnh chạy lần lượt, lệnh nào chạy lâu thì mọi client khác phải xếp hàng chờ. Đây là chuyện giống hệt một callback chạy lâu chặn event loop của Node (Chặn event loop). Ví dụ KEYS * trên 1 triệu key chặn mọi client khoảng 130 ms. Tài liệu Redis gọi KEYS là một nguồn latency rất phổ biến trên production.

Những lệnh cần tránh trên dữ liệu lớn, và cách thay thế:

Lệnh chậmVì saoThay bằng
KEYS patternDuyệt cả keyspace trong một lệnhSCAN cursor MATCH … COUNT …: mỗi lần chỉ xét một ít key, lệnh của client khác chen vào được giữa hai lần gọi
HGETALL, SMEMBERS, LRANGE 0 -1 trên key rất lớnĐọc và gửi về hết mọi phần tửHSCAN, SSCAN, ZSCAN, hoặc đọc có giới hạn
DEL key lớnGiải phóng từng phần tử trên thread chínhUNLINK: gỡ key khỏi keyspace rồi trả lời ngay, việc giải phóng chạy ở thread nền
Script Lua chạy lâuScript chặn cả server tới khi xongGiữ script ngắn, chia việc thành nhiều lần gọi

Muốn DEL cũng chạy như UNLINK thì bật lazyfree-lazy-user-del. Key lớn hết hạn hay bị đẩy ra cũng được giải phóng trên thread chính, trừ khi bật lazyfree-lazy-expire và lazyfree-lazy-eviction. Cả ba đều mặc định tắt.

Chọn lệnh và kích thước để xem lệnh chậm chiếm thread bao lâu, và client khác phải chờ ra sao:

Lệnh chậm
133 msthread bị chiếm liền lâu nhất
123 msp99 GET của client khác
133 msmax GET
133 mslệnh chậm xong sau
Thread lệnhLatency GETcao nhất 133 ms0 ms67 ms133 ms200 ms267 ms333 ms
lệnh chậm đang chạyGET của client khác (chiều cao là latency)GET chậm hơn 5 ms

KEYS * chiếm thread chạy lệnh 133 ms liền. Mọi GET đến trong khoảng đó phải xếp hàng chờ, dù chính GET chỉ tốn vài µs. Redis không chen được lệnh khác vào giữa một lệnh đang chạy.

2.000 GET mỗi giây; thời gian lệnh lấy từ SLOWLOG trên Redis 8.4, chưa tính lúc gửi kết quả lớn về client.

Big key và hot key. Big key là key có giá trị quá lớn hoặc quá nhiều phần tử, làm lệnh đọc, lệnh xoá và việc chuyển slot đều chậm. Hot key là key nhận quá nhiều request, dồn tải vào một node. Cách xử lý ở trang Sự cố thường gặp. Để tìm chúng, dùng redis-cli --bigkeys (key nhiều phần tử nhất), --memkeys (key tốn bộ nhớ nhất) và --hotkeys (cần policy LFU). Cả ba đều duyệt bằng SCAN nên không chặn server. MEMORY USAGE key cho biết một key chiếm bao nhiêu byte.

Theo dõi lệnh chậm. SLOWLOG GET liệt kê lệnh chạy lâu hơn slowlog-log-slower-than (mặc định 10 ms). Thời gian trong slowlog chỉ tính lúc lệnh chạy trong server, không tính lúc chờ hay lúc gửi kết quả. Latency monitor (latency-monitor-threshold, mặc định tắt) ghi lại các sự kiện chậm, xem bằng LATENCY LATEST và LATENCY DOCTOR. Các chỉ số khác nên đưa lên dashboard ở trang Giám sát cache.

io-threads ​

Từ Redis 6 có tuỳ chọn io-threads (mặc định 1, tức tắt). Các thread này lo đọc ghi socket và parse giao thức, còn lệnh vẫn chạy trên thread chính. Redis 8.0 thay bằng một cài đặt io-threads mới, và theo blog của Redis, đặt 8 thread trên CPU Intel nhiều core cho throughput tăng tới 112%. redis.conf khuyên chỉ bật khi máy có từ 4 core trở lên và chừa lại ít nhất một core. io-threads chỉ có ích khi phần đọc ghi socket đã thành nút thắt, tức khi có rất nhiều client. Nó không chữa được lệnh chậm. Muốn dùng nhiều core cho phần chạy lệnh thì phải chạy nhiều instance, hoặc dùng Redis Cluster.

Gửi nhiều lệnh: pipelining, transaction, script ​

CáchLệnh khác có chen vào giữa khôngLỗi giữa chừng
PipeliningCó, các lệnh chỉ được gửi chung một lầnMỗi lệnh có kết quả riêng
MULTI / EXECKhông, cả nhóm chạy liền một mạchKhông rollback, các lệnh khác vẫn chạy
Script Lua (EVAL) và FunctionKhông, và còn chặn cả server trong lúc chạyLệnh đã chạy không được hoàn tác

Transaction không rollback. Tài liệu Redis giải thích rằng hỗ trợ rollback sẽ ảnh hưởng nhiều tới sự đơn giản và hiệu năng. Lỗi có hai loại:

  • Lỗi lúc xếp hàng, ví dụ lệnh sai số tham số. Redis báo lỗi ngay, rồi EXEC huỷ cả nhóm với EXECABORT.
  • Lỗi lúc chạy, ví dụ INCR một key chứa chuỗi chữ. Chỉ lệnh đó lỗi, các lệnh trước và sau nó vẫn được ghi.
MULTI
SET x 1
INCR name        ← name là "An", không phải số
SET y 2
EXEC  →  ["OK", "ERR value is not an integer or out of range", "OK"]

Vì vậy app phải tự kiểm tra kết quả từng lệnh trong EXEC. WATCH cho phép làm optimistic lock, tức không giữ lock mà kiểm tra xung đột lúc ghi. WATCH stock, đọc giá trị, rồi MULTI … EXEC. Nếu có client khác sửa stock trước khi EXEC chạy, EXEC trả null và không ghi gì, và app đọc lại rồi thử lại.

Script chặn cả server. Trong lúc script Lua chạy, mọi hoạt động khác của server đều bị chặn. Vì thế script dùng được cho các thao tác "đọc rồi ghi" cần nguyên tử, như release lock khi đúng token (Distributed lock), nhưng phải thật ngắn. Script chạy quá busy-reply-threshold (mặc định 5 giây) vẫn không bị dừng. Redis chỉ bắt đầu trả lỗi BUSY cho client khác. Lúc đó chỉ dừng được script bằng SCRIPT KILL khi script chưa ghi gì, còn nếu đã ghi thì chỉ còn SHUTDOWN NOSAVE.

Từ Redis 7, Function (FUNCTION LOAD, gọi bằng FCALL) thay thế dần EVAL. Function được lưu cùng dữ liệu, replicate sang replica và ghi vào AOF, nên không cần mỗi client tự nạp lại script.

Key hết hạn bị xoá thế nào ​

Redis xoá key hết hạn theo hai cách:

  • Lazy: khi một client truy cập key đã hết hạn, Redis xoá nó rồi trả về như key không tồn tại.
  • Active: một chu kỳ chạy nền quét các key có TTL và xoá những key đã hết hạn.

Chỉ có lazy thì key ghi một lần rồi không ai đọc lại sẽ nằm mãi trong RAM. Active expire thì không quét hết mọi key, vì như vậy sẽ chặn thread chính. Mỗi lần, nó chỉ xét một nhóm nhỏ key có TTL rồi xoá những key đã hết hạn. Nếu nhóm vừa xét còn nhiều key hết hạn thì nó xét tiếp, còn nếu thấy ít thì dừng để nhường CPU cho lệnh của client.

Hệ quả là key đã hết hạn có thể còn nằm trong RAM một lúc, nhất là khi chúng chỉ chiếm một phần nhỏ trong số key có TTL. Bộ nhớ dùng thật vì vậy có thể cao hơn lượng dữ liệu còn hạn, nên so keys với expires trong INFO keyspace khi thấy RAM cao bất thường. active-expire-effort (1 đến 10, mặc định 1) cho Redis dọn mạnh tay hơn, đổi lại tốn thêm CPU. Chi tiết thuật toán và mô phỏng ở phần Số đo chi tiết.

Eviction: khi Redis đầy bộ nhớ ​

maxmemory-policyXoá key nàoDùng khi
noeviction (mặc định)Không xoá; lệnh có thể làm tăng bộ nhớ bị từ chốiRedis làm kho dữ liệu chính, không được mất key
allkeys-lruKey lâu nhất chưa được dùng, xét mọi keyCache thông thường
allkeys-lfuKey ít được dùng nhất, bộ đếm có suy giảm theo thời gianCache có nhóm key nóng ổn định
allkeys-randomNgẫu nhiênTruy cập đều
volatile-lru / -lfu / -randomNhư trên nhưng chỉ xét key có TTLTrộn cache (có TTL) với dữ liệu cố định
volatile-ttlKey có TTL sắp hết nhấtƯu tiên giữ key còn hạn dài

Với noeviction, Redis từ chối theo loại lệnh. Lệnh nào có thể làm tăng bộ nhớ đều trả lỗi OOM command not allowed, kể cả INCR hay SET ghi đè bằng giá trị ngắn hơn. Lệnh đọc, DEL, UNLINK, EXPIRE vẫn chạy. Vì vậy một service dùng Redis làm cache mà quên đặt policy sẽ lỗi ở mọi lệnh ghi khi đầy.

LRU và LFU của Redis là xấp xỉ. Giữ một danh sách LRU chính xác thì mỗi lần truy cập lại phải sửa danh sách, quá tốn. Redis chỉ lấy mẫu ngẫu nhiên maxmemory-samples key (mặc định 5) rồi đẩy ra key lâu chưa dùng nhất trong mẫu. Từ 3.0, nó giữ thêm một nhóm ứng viên tốt qua nhiều lần lấy mẫu, nên kết quả khá gần LRU thật. Thử trong lab, với mặc định 5 mẫu thì khoảng 80–90% key vừa được dùng còn sống sau khi Redis phải đẩy ra một nửa dữ liệu. Tăng số mẫu thì gần LRU hơn nhưng tốn CPU hơn. LFU dùng bộ đếm truy cập tăng theo hàm log và giảm dần theo thời gian (lfu-log-factor, lfu-decay-time), để key từng nóng rồi nguội không nằm lì mãi. Ba policy chạy trên cùng một chuỗi truy cập có ở trang Sự cố thường gặp.

Lưu bền: RDB và AOF ​

RDBAOF
Là gìSnapshot toàn bộ dữ liệu tại một thời điểm, một file gọnGhi lại mọi lệnh ghi, phát lại khi khởi động
Mất bao nhiêu khi sậpMọi lệnh ghi sau snapshot cuối, thường là vài phútVới appendfsync everysec (mặc định), tới khoảng 1 giây khi máy sập
Khởi động lạiNhanhChậm hơn, file lớn hơn

Với AOF everysec, Redis ghi lệnh vào file ngay nhưng chỉ fsync xuống disk mỗi giây. Nếu chỉ process Redis chết thì không mất gì, vì dữ liệu đã nằm trong page cache của hệ điều hành. Mức mất tới 1 giây chỉ xảy ra khi cả máy sập hay mất điện. Từ Redis 7.0, AOF gồm một file nền và các file ghi thêm, quản lý bằng một file manifest. File nền mặc định ở dạng RDB (aof-use-rdb-preamble yes), nên khởi động lại nhanh hơn phát lại từng lệnh. Tài liệu Redis khuyên dùng cả hai cơ chế nếu cần mức an toàn dữ liệu gần với PostgreSQL.

Fork và copy-on-write ​

Để ghi RDB hay viết lại AOF mà không dừng phục vụ, Redis gọi fork(). Process con ghi file từ bản chụp bộ nhớ lúc fork, còn process chính tiếp tục chạy lệnh. Hai process dùng chung trang nhớ theo kiểu copy-on-write: trang nào bị process chính sửa thì hệ điều hành mới chép ra một bản riêng.

Nếu trong lúc ghi file có nhiều lệnh ghi rải khắp dữ liệu, gần như mọi trang nhớ đều bị chép. Mỗi lệnh ghi làm bẩn cả trang 4 KB chứa key đó, nên tổng RAM có thể gần gấp đôi lượng dữ liệu. Thử trong lab, khoảng 100.000 lệnh ghi ngẫu nhiên trong lúc lưu 272 MB làm tới 80% bộ nhớ bị chép. Vì vậy không đặt maxmemory sát RAM của máy khi có bật lưu bền: phải chừa chỗ cho fork, cho buffer của client và replica, nếu không hệ điều hành có thể kill Redis khi hết RAM.

Bản thân fork() cũng chặn thread chính trong lúc chép bảng trang nhớ. Tài liệu Redis đo khoảng 10 ms mỗi GB trên Linux máy thật, nhưng trên một số máy ảo cũ tới hàng trăm ms mỗi GB. Tài liệu cũng khuyên tắt transparent huge pages, vì với trang 2 MB, mỗi lệnh ghi trong lúc fork làm chép cả 2 MB.

Replication, Sentinel và WAIT ​

Replica nhận các lệnh ghi từ master theo kiểu async replication: master trả lời client trước rồi mới gửi lệnh sang replica (Replication). Khi master sập, những lệnh ghi chưa kịp sang replica sẽ mất sau failover.

WAIT numreplicas timeout chặn client tới khi các lệnh ghi trước đó tới được đủ số replica, hoặc hết timeout. Nó không huỷ lệnh ghi khi hết timeout: lệnh vẫn nằm trên master, và WAIT chỉ trả về số replica đã nhận. Tài liệu Redis nói rõ WAIT không biến Redis thành kho strongly consistent (đọc luôn thấy lệnh ghi mới nhất). Nó chỉ làm tăng khả năng replica được chọn lúc failover có lệnh ghi đó.

Sentinel giám sát master và tự failover sang replica. Có hai con số hay bị nhầm:

  • Quorum là số Sentinel phải cùng thấy master không trả lời thì master mới bị đánh dấu là lỗi. Nó chỉ dùng để phát hiện lỗi.
  • Muốn thực hiện failover, đa số Sentinel phải đồng ý. Nếu đa số Sentinel không liên lạc được với nhau thì không có failover.

Tài liệu Redis yêu cầu ít nhất 3 Sentinel đặt trên 3 máy khác nhau.

Redis Cluster ​

Một node không đủ RAM hoặc throughput thì chia dữ liệu ra nhiều node. Chia bằng hash(key) % N thì thêm một node làm gần như mọi key đổi chỗ, còn consistent hashing giảm xuống khoảng 1/N (Partitioning). Redis Cluster (từ 3.0) chọn cách thứ ba là hash slot.

Hash slot ​

  • Không gian key chia cố định thành 16384 slot, không phụ thuộc số node: slot = CRC16(key) mod 16384.
  • Mỗi node giữ một dải slot. Cluster 3 node: A giữ 0–5460, B giữ 5461–10922, C giữ 10923–16383. Node mạnh hơn có thể được giao nhiều slot hơn.
  • Thêm hoặc bớt node là chuyển một số slot, cùng dữ liệu bên trong, sang node khác.
  • Vì sao 16384: mỗi node gửi bitmap các slot mình giữ trong mỗi gói heartbeat, và 16384 bit là 2 KB, đủ nhỏ để gửi thường xuyên.

Hash tag và lệnh nhiều key ​

Redis Cluster từ chối lệnh nhiều key (MGET, MSET, MULTI, Lua script) khi các key nằm ở slot khác nhau, với lỗi CROSSSLOT. Hash tag giải quyết chuyện này: nếu key có cặp {…} thì chỉ phần bên trong được hash. Quy tắc chính xác là lấy đoạn giữa dấu { đầu tiên và dấu } đầu tiên sau nó. Nếu đoạn đó rỗng, hoặc không có }, thì hash cả key.

  • user:{100}:profile và user:{100}:orders chỉ hash 100, nên chung slot.
  • foo{bar}{zap} chỉ hash bar, vì chỉ cặp ngoặc đầu tiên được tính.
  • foo{{bar}}zap hash {bar.
  • foo{}{bar} hash cả key, vì cặp ngoặc đầu tiên rỗng.

Đừng đặt cùng một tag cho quá nhiều key, vì chúng dồn hết vào một slot và thành hot slot.

Thử: key rơi vào slot nào (cluster 3 node)
Phần trong {} là hash tag, chỉ phần này được hash.
Key (phần được hash tô màu)CRC16SlotNode
user:{100}:cart0xC153339A (0–5460)
user:{100}:profile0xC153339A (0–5460)
user:{100}:orders0xC153339A (0–5460)
user:100:profile0xD2E44836A (0–5460)
user:100:orders0xA3018961B (5461–10922)

Hai key user:{100}:… luôn cùng slot nên dùng chung được trong MGET, MULTI hay Lua script. Hai key không có hash tag thường rơi vào slot khác nhau, và lệnh nhiều key trên chúng bị từ chối với lỗi CROSSSLOT.

Định tuyến: MOVED và ASK ​

  1. Client giữ bảng "slot → node" trong RAM, tự tính slot của key rồi gửi thẳng tới node giữ slot đó.
  2. Bảng của client đã cũ (slot đã chuyển đi) thì node nhận trả lỗi MOVED <slot> <ip>:<port>. Client gửi lại tới node đúng và cập nhật bảng.
  3. Đang chuyển slot thì node nguồn đánh dấu slot là MIGRATING, node đích là IMPORTING, và công cụ quản trị (redis-cli --cluster reshard, dùng lệnh MIGRATE) chuyển dần từng key. Client hỏi một key đã được chuyển đi thì nhận ASK <slot> <ip>:<port>. Lệnh này chỉ chuyển hướng một lần và client không cập nhật bảng. Client phải gửi ASKING trước lệnh thật tới node đích, nếu không node đích sẽ trả MOVED về lại node nguồn.

Thư viện client như ioredis (chế độ cluster) tự xử lý MOVED và ASK.

Failover và giới hạn của cluster ​

  • Mỗi master có replica. Khi master không trả lời quá cluster-node-timeout (mặc định 15 giây), một replica của nó xin phiếu bầu của các master khác và lên thay. Giống Sentinel, lệnh ghi chưa sang replica sẽ mất.
  • Mặc định cluster-require-full-coverage yes: nếu có slot không node nào giữ (một master và mọi replica của nó cùng chết), cả cluster ngừng nhận lệnh, kể cả lệnh tới các slot vẫn còn.
  • Cluster chỉ có database 0, không dùng được SELECT.
  • Replica không phục vụ đọc, trừ khi client gửi READONLY trên connection đó, tức chấp nhận đọc dữ liệu có thể cũ.
  • Pub/Sub thường phát tin qua cluster bus tới mọi node. Từ Redis 7.0 có Sharded Pub/Sub (SSUBSCRIBE, SPUBLISH): channel được gán vào slot như key, nên tin chỉ đi trong shard giữ slot đó.

Gossip ​

Các node tự trao đổi trạng thái với nhau, không cần server trung tâm. Mỗi giây, mỗi node chọn ngẫu nhiên 5 node rồi gửi PING tới node lâu nhất chưa trả lời nó, kèm bitmap slot, cờ trạng thái và thông tin về vài node khác mà nó biết. Ngoài ra, node nào im lặng quá nửa cluster-node-timeout cũng được ping ngay, để không node nào bị bỏ sót quá lâu. Luật thứ hai làm lượng gossip tăng theo số node, nên cluster spec đặt quy mô khuyến nghị tối đa khoảng 1.000 node.

Bảo mật ​

  • Tài liệu Redis viết rằng Redis được thiết kế cho client tin cậy trong môi trường tin cậy. Không mở Redis ra internet, chỉ cho app server trong mạng nội bộ kết nối tới.
  • Protected mode (từ 3.2): khi chạy cấu hình mặc định mà không có mật khẩu, Redis chỉ trả lời connection từ loopback. Image Docker chính thức lại tắt sẵn protected mode, nên đừng map cổng container ra ngoài mà không có mật khẩu.
  • ACL (từ Redis 6) cho từng user một mật khẩu và danh sách lệnh, key được phép. Đây là cách nên dùng để chặn các lệnh nguy hiểm như FLUSHALL, KEYS, DEBUG, CONFIG với user của app. Cách cũ rename-command đã deprecated.

Lưu ý vận hành ​

  • Cache sập phải có đường lui: app vẫn đọc được database khi Redis lỗi, kèm circuit breaker và rate limit để database không bị dồn toàn bộ tải (Sự cố thường gặp).

  • Redis làm database chính: cần AOF, replica, và chấp nhận rằng failover với async replication có thể mất vài lệnh ghi cuối, kể cả khi có WAIT.

  • Client-side caching (từ Redis 6, CLIENT TRACKING): Redis nhớ client nào đã đọc key nào và báo khi key đó đổi, để app giữ bản sao trong RAM của mình mà không đọc phải dữ liệu cũ lâu. Chế độ broadcasting gửi tin theo prefix key mà client đăng ký, nên server không phải nhớ client nào đã đọc key nào và không tốn bộ nhớ theo từng key.

  • Lock: SET key token NX PX 30000 không an toàn tuyệt đối, kể cả trên một node: client bị treo lâu hơn TTL vẫn có thể ghi sau khi lock đã sang tay người khác, và release phải so token rồi mới xoá. Chi tiết ở trang Distributed lock.

Ghi nhớ

  1. Redis chạy mọi lệnh trên một thread với event loop trên epoll: nhanh vì dữ liệu trong RAM và không có lock. Nút thắt thường là mạng, nên gửi lệnh theo lô thay vì await từng lệnh.
  2. Một lệnh chậm chặn mọi client: dùng SCAN thay KEYS, UNLINK thay DEL cho key lớn, chia nhỏ big key, giữ script ngắn.
  3. MULTI/EXEC không rollback. Script Lua nguyên tử nhưng chặn cả server.
  4. Key hết hạn bị xoá dần chứ không xoá ngay. Mặc định noeviction làm mọi lệnh có thể tăng bộ nhớ lỗi OOM khi đầy.
  5. Fork khi lưu RDB hay AOF có thể làm RAM gần gấp đôi khi đang có ghi, nên đừng đặt maxmemory sát RAM máy.
  6. Cluster chia key theo 16384 slot; muốn dùng lệnh nhiều key thì đặt các key chung hash tag.

Nguồn tham khảo ​

Đã đối chiếu (đọc nguồn ngày 2026-10-22 và 2026-10-23):

NguồnDùng cho phần
Redis licenses, Redis 8 GARSALv2/SSPLv1 từ 7.4, AGPLv3 từ Redis 8, các tính năng tích hợp trong Redis 8, vector set đang beta
Linux Foundation, Valkey; ElastiCache for Valkey; Memorystore for ValkeyValkey tách từ Redis 7.2.4 (28/3/2024), các công ty ủng hộ, dịch vụ trên AWS và Google Cloud
Redis docs, Diagnosing latency issuesMostly single threaded, lệnh chậm chặn mọi client, KEYS, thời gian fork theo loại máy, transparent huge pages
Redis docs, Benchmarks, PipeliningMột thread chạy lệnh; pipelining bớt vòng mạng và lời gọi read()/write()
redis.conf bản 8.4io-threads, ngưỡng listpack, maxmemory-samples, active-expire-effort, lazyfree-*, aof-use-rdb-preamble, appendfsync, busy-reply-threshold, slowlog-log-slower-than, latency-monitor-threshold, cluster-node-timeout
Mã nguồn expire.c bản 8.4Thuật toán active expire và cách active-expire-effort thay đổi nó
Redis docs, EXPIRE, Key evictionLazy và active expire; noeviction; LRU xấp xỉ, nhóm ứng viên từ 3.0, LFU
Redis docs, Transactions, Programmability, FunctionsKhông rollback, hai loại lỗi, WATCH; script chặn server, BUSY, SCRIPT KILL; Function từ Redis 7
Redis docs, Memory optimization, PersistenceEncoding gọn; RDB và AOF, fork và copy-on-write, multi-part AOF từ 7.0
Redis docs, UNLINK, WAIT, HEXPIRE, ZREVRANGE, SSUBSCRIBEUNLINK từ 4.0; WAIT không làm Redis strongly consistent; HEXPIRE từ 7.4; ZREVRANGE, ZRANGEBYSCORE deprecated từ 6.2; sharded pub/sub từ 7.0
Redis docs, Cluster spec, SentinelQuy tắc hash tag, failover, chỉ database 0, READONLY; quorum, đa số, ít nhất 3 Sentinel
Redis docs, Security, ACL, redis-cli, Client-side cachingProtected mode, ACL, rename-command deprecated; --bigkeys, --memkeys, --hotkeys; client-side caching

Chưa có tài liệu xác nhận trực tiếp:

  • Image redis:8.4.0 không có JSON và Bloom, và chạy với protected-mode no: kiểm trên máy lab, chưa đọc Dockerfile của image.
  • Hành vi của cluster-require-full-coverage khi thiếu slot: giá trị mặc định đọc bằng CONFIG GET, chưa chạy cluster nhiều node để thử.
  • AOF không mất lệnh khi chỉ process bị kill vì dữ liệu đã nằm trong page cache: lời giải thích từ kiến thức chung, chạy thử khớp. io-threads có từ Redis 6.0: từ kiến thức chung.
  • Redis Cluster có từ 3.0, gossip tăng theo bình phương số node, khuyến nghị tối đa khoảng 1.000 node: từ nội dung cũ của trang, chưa đối chiếu lần này.

Số đo chi tiết ​

Đo bằng script lab/redis/ ngày 2026-10-23 trên MacBook M2 (Redis 8.4.0 trong Docker Desktop 8 CPU), hai tới ba lượt mỗi phép đo, ghi khoảng giá trị qua các lượt. Cách chạy ở trang Lab.

Thread và throughput

Process redis-server có 6 thread: redis-server (thread chính), bio_close_file, bio_aof, bio_lazy_free và hai thread nền của jemalloc. Sau khi chạy benchmark, used_cpu_user_main_thread gần bằng toàn bộ used_cpu_user (1,81 so với 1,81 giây).

redis-benchmark chạy trong container (không qua mạng Docker), 50 client, 400.000 lệnh:

Cách gửiSETGET
Gửi từng lệnh229.000 lệnh/s, p50 0,13 ms261.000–264.000 lệnh/s, p50 0,10 ms
Pipelining 16 lệnh (-P 16)1,52–1,59 triệu lệnh/s2,35–2,60 triệu lệnh/s

io-threads 4 (process có 9 thread), 200 client, 2.000.000 lệnh: 399.000–421.000 lệnh/s, so với 320.000–381.000 khi tắt. redis-benchmark chạy cùng container và tranh CPU với Redis, nên con số này không đủ để kết luận mức lợi thật.

Từ Node.js (ioredis) qua mạng Docker Desktop, một GET lúc rảnh mất 0,35–0,49 ms (p50). 10.000 GET trên một connection, 9 lượt:

Cách gửiThời gian
await từng lệnh2.677–3.623 ms
Promise.all33–55 ms
pipeline()13–27 ms
Lệnh chậm chặn client khác

Một client gửi PING liên tục trong lúc connection khác chạy lệnh chậm, ba lượt:

LệnhTrong server (SLOWLOG)Client thấyPING của client khác, lâu nhất
KEYS * trên 1.000.000 key122–133 ms405–457 ms124–158 ms
SCAN … COUNT 1000 tới hết (1.000 lần gọi)mỗi lần dưới 10 ms847–962 ms6–52 ms
HGETALL Hash 1.000.000 field (69 MB)212–224 ms1.146–1.434 ms613–735 ms
DEL Hash 1.000.000 field117–145 ms118–146 ms117–145 ms
UNLINK Hash 1.000.000 fielddưới 1 ms0,43–0,57 ms4,3–7,4 ms

Với HGETALL, PING của client khác chậm hơn cả thời gian lệnh chạy trong server, vì 69 MB kết quả đi chung đường mạng của Docker Desktop. Latency monitor (đặt 50 ms) ghi sự kiện command lâu nhất 583–635 ms, là lệnh DEBUG POPULATE tạo dữ liệu.

Encoding và bộ nhớ

OBJECT ENCODING trên Redis 8.4: Hash 512 field là listpack, 513 field là hashtable; giá trị 64 byte là listpack, 65 byte là hashtable. Set toàn số nguyên là intset, thêm một chuỗi thành listpack. String "12345" là int, chuỗi ngắn là embstr, chuỗi 50 ký tự là raw.

100.000 user, mỗi user 5 trường:

Cách lưuBộ nhớMỗi user
5 key String mỗi user31,2 MB312 byte
1 Hash mỗi user14,3 MB143 byte
1 String JSON mỗi user14,3–14,4 MB143–144 byte
Transaction và script
  • MULTI, SET x 1, INCR name (chuỗi chữ), SET y 2, EXEC trả ["OK", "ERR value is not an integer or out of range", "OK"], và x, y đều được ghi.
  • Lệnh sai số tham số trong MULTI làm EXEC trả EXECABORT Transaction discarded because of previous errors, không lệnh nào chạy.
  • WATCH stock, client khác DECR stock chen vào giữa, EXEC trả null.
  • busy-reply-threshold đặt 100 ms, script chạy 300 ms: GET gửi lúc 61 ms chờ khoảng 40 ms rồi nhận BUSY, các GET sau nhận BUSY ngay.
Key hết hạn: thuật toán active expire và mô phỏng

Thuật toán trong expire.c (Redis 8.4):

  1. Chu kỳ chạy 10 lần mỗi giây (hz 10).
  2. Mỗi chu kỳ quét tiếp bảng key có TTL từ chỗ lần trước dừng (bằng một cursor), mỗi lượt xét 20 key và xoá những key đã hết hạn.
  3. Nếu hơn 10% số key vừa xét đã hết hạn thì chạy lượt tiếp, không thì dừng chu kỳ. Mỗi chu kỳ dùng tối đa 25% thời gian CPU.
  4. active-expire-effort tăng số key mỗi lượt (tới 65), giảm ngưỡng (tới 1%) và tăng phần CPU (tới 43%).
Số đo có sẵn
94.650key hết hạn còn trong RAM sau 10 s
12,0 MBbộ nhớ chúng chiếm (khoảng 127 byte mỗi key)
20 key, 10%mỗi lượt xét, ngưỡng để lặp lại
050k100k0 s2 s4 s6 s8 s10 s
mô phỏng: số key đã hết hạn còn trong RAMđo thật trên Redis 8.4 (0–3 s)

Chu kỳ đầu chỉ chạy 5 lượt rồi dừng, xoá 25 key, vì chỉ cần một lượt có không quá 10% key hết hạn là Redis coi như đủ và dừng chu kỳ. Với tỉ lệ này, lượt "đủ sạch" đến sớm, nên Redis để lại phần lớn key hết hạn trong RAM. Tăng active-expire-effort để Redis xét nhiều key hơn mỗi lượt và hạ ngưỡng xuống.

Mọi key hết hạn cùng lúc ở giây 0, không ai đọc lại; chỉ mô phỏng chu kỳ chậm 10 lần mỗi giây.

Tắt active expire (DEBUG SET-ACTIVE-EXPIRE 0): 200.000 key hết hạn đã một giây vẫn còn, chiếm 25,3–25,4 MB. GET một key thì DBSIZE giảm đúng 1. Bật lại thì 200.000 key biến mất sau 756–963 ms.

400.000 key có TTL, một phần hết hạn cùng một lúc, không client nào đọc lại; số key đã hết hạn còn trong RAM:

effortTỉ lệ đã hết hạnsau 0,5 ssau 1 ssau 3 s
1100% (400.000 key)296.689–302.584173.885–180.0590
150%144.077–150.68479.106–91.6490
140%144.449–149.426134.780–136.68097.624–101.483
125%99.581–99.69499.230–99.30898.139–98.536
110%39.979–39.98639.963–39.97739.897–39.922
1025%0–4.48700
1010%35.504–37.43233.300–34.20415.046–18.693

Dòng 25% khớp với thuật toán: xác suất một lượt 20 key có không quá 2 key hết hạn là khoảng 9%, nên trung bình mỗi chu kỳ chạy khoảng 11 lượt rồi dừng, tức khoảng 550 key bị xoá mỗi giây. Phép so ngưỡng dùng chia nguyên, nên với effort 10 (65 key mỗi lượt, ngưỡng 1%), một key hết hạn trong lượt cũng chưa đủ để chạy tiếp.

Eviction

used_memory 22 MB đã vượt maxmemory 20 MB, policy noeviction: SET key mới, SET ghi đè bằng giá trị ngắn hơn và INCR key đã có đều trả OOM command not allowed when used memory > 'maxmemory'. GET, EXPIRE, DEL, UNLINK chạy bình thường.

LRU xấp xỉ: ghi 100.000 key, đọc lại nửa sau để nửa này thành "mới dùng", rồi ghi thêm để Redis phải đẩy ra khoảng 52.000 key. LRU chính xác sẽ giữ lại toàn bộ nửa mới dùng. Ba lượt:

maxmemory-samplesNửa mới dùng còn sống
160%
5 (mặc định)80–90%
1088–95%
Lưu bền, WAIT, hash tag

BGSAVE trên 1.000.000 key (272 MB), ba lượt:

Trong lúc BGSAVEThời gian forkBộ nhớ bị chép (copy-on-write)
Không ai ghi4–10 ms1,1 MB
Một client ghi đè key ngẫu nhiên (80.500–138.500 lệnh)2,5–3,1 ms145–218 MB

kill -9 process Redis: chỉ có RDB thì counter tăng tới 2.000 sau snapshot lúc 1.000 quay về 1.000; với AOF everysec, counter 3.000 còn nguyên.

WAIT 1 1000 lúc bình thường trả 1 sau 1,5–1,7 ms. Khi docker pause replica, WAIT 1 500 trả 0 sau 572–600 ms, và lệnh ghi vẫn nằm trên master.

CLUSTER KEYSLOT: user:{100}:profile và user:{100}:orders đều 339, {user1000}.following và {user1000}.followers đều 3443, foo{bar}{zap} bằng slot của bar (5061), foo{{bar}}zap bằng slot của {bar (4015), foo{}{bar} là 8363 (cả key).

Code đã dùng để chạy thử ​

Chạy bằng npm run lab -- redis:up, rồi npm run lab -- redis (khoảng 3 phút), xong thì npm run lab -- redis:down. Lab Redis tách khỏi lab MySQL/PostgreSQL và không nằm trong npm run lab -- all.

lab/redis/docker-compose.yml
yml
# Redis 8.4 cho trang cache/redis. Tách riêng khỏi lab MySQL/PostgreSQL.
#   docker compose -f redis/docker-compose.yml up -d --wait     (npm run redis:up)
#   docker compose -f redis/docker-compose.yml down -v          (npm run redis:down)
# lab-redis (127.0.0.1:63790) là node chính, lab-redis-replica (63791) là replica của nó,
# lab-redis-cluster (63792) bật cluster-enabled chỉ để dùng lệnh CLUSTER KEYSLOT.
# protected-mode no và enable-debug-command yes chỉ dùng cho lab: cổng chỉ mở trên 127.0.0.1.
x-redis: &redis
  image: redis:8.4.0
  networks: [redis]
  healthcheck:
    test: ['CMD', 'redis-cli', 'ping']
    interval: 1s
    retries: 30
services:
  redis:
    <<: *redis
    container_name: lab-redis
    ports: ['127.0.0.1:63790:6379']
    command: redis-server --protected-mode no --enable-debug-command yes --save "" --appendonly no
  replica:
    <<: *redis
    container_name: lab-redis-replica
    ports: ['127.0.0.1:63791:6379']
    command: redis-server --protected-mode no --replicaof lab-redis 6379 --save ""
    depends_on: [redis]
  cluster:
    <<: *redis
    container_name: lab-redis-cluster
    ports: ['127.0.0.1:63792:6379']
    command: redis-server --protected-mode no --cluster-enabled yes --save ""
networks:
  redis:
    name: lab-redis
lab/redis/common.mjs
js
// Dùng chung cho các script của trang cache/redis. Cần `npm run redis:up` trước.
import Redis from 'ioredis'
import { execFileSync } from 'node:child_process'

export const PORTS = { main: 63790, replica: 63791, cluster: 63792 }
export const connect = (port = PORTS.main) => new Redis({ host: '127.0.0.1', port, maxRetriesPerRequest: 0 })
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
export const now = () => performance.now()

// redis-cli và redis-benchmark chạy bên trong container, để không tính mạng của Docker Desktop.
export const cli = (container, ...args) => execFileSync('docker', ['exec', container, 'redis-cli', ...args], { encoding: 'utf8' }).trim()
export const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim()

export const ms = (v) => (v >= 100 ? Math.round(v).toLocaleString('vi-VN') : v.toFixed(v < 1 ? 2 : 1).replace('.', ',')) + ' ms'
export const mb = (b) => (b / 1e6).toFixed(1).replace('.', ',') + ' MB'

// Lấy một trường trong INFO.
export async function info(r, field) {
  const text = await r.info()
  const m = text.match(new RegExp(`^${field}:(.*)$`, 'm'))
  return m ? m[1].trim() : undefined
}

// Client đo: gửi PING liên tục, lần sau ngay khi lần trước xong, ghi latency từng lần.
// Dùng để thấy một lệnh chậm trên connection khác chặn mọi client.
export function probe(r) {
  const lat = []
  let stop = false
  const done = (async () => {
    while (!stop) {
      const t0 = now()
      await r.ping()
      lat.push(now() - t0)
    }
  })()
  return { lat, stop: async () => { stop = true; await done; return lat } }
}

export const pct = (arr, p) => {
  const s = [...arr].sort((a, b) => a - b)
  return s[Math.min(s.length - 1, Math.ceil(p * s.length) - 1)]
}
lab/redis/throughput.mjs: thread, redis-benchmark, ba cách gửi lệnh từ Node
js
// Vì sao Redis nhanh dù chạy lệnh trên một thread.
//   1. Đếm thread của process redis-server và xem tên từng thread.
//   2. redis-benchmark chạy trong container (không qua mạng Docker): có và không có pipelining, io-threads 1 và 4.
//   3. Từ Node (ioredis, qua mạng Docker Desktop): 10.000 GET gửi lần lượt, Promise.all, pipeline.
import { connect, cli, docker, ms, sleep, now } from './common.mjs'

const threads = (c) => docker('exec', c, 'sh', '-c', 'for t in /proc/1/task/*; do cat $t/comm; done').split('\n')
console.log('\n=== Thread của redis-server (Redis 8.4, io-threads 1) ===')
const t = threads('lab-redis')
console.log(`${t.length} thread: ${t.join(', ')}`)

// --csv in mỗi lệnh một dòng: "test","rps","avg_latency_ms","min_latency_ms","p50_latency_ms","p95_latency_ms","p99_latency_ms","max_latency_ms"
const bench = (c, args, n = 400_000) => {
  const out = docker('exec', c, 'redis-benchmark', '--csv', '-n', String(n), '-t', 'set,get', ...args)
  return out.split('\n').slice(1).map((l) => {
    const [name, rps, , , p50, , p99] = l.split(',').map((x) => x.replace(/"/g, ''))
    const f = (x) => x.replace('.', ',')
    return `${name} ${Math.round(+rps).toLocaleString('vi-VN')} lệnh/s (p50 ${f(p50)} ms, p99 ${f(p99)} ms)`
  }).join('   ')
}
console.log('\n=== redis-benchmark trong container, 50 client, 400.000 lệnh ===')
console.log('không pipelining (-P 1):  ', bench('lab-redis', ['-c', '50']))
console.log('pipelining 16 lệnh (-P 16):', bench('lab-redis', ['-c', '50', '-P', '16']))

// io-threads không đổi được lúc đang chạy, nên dựng tạm một container có io-threads 4.
docker('run', '-d', '--rm', '--name', 'lab-redis-io', '--cpus', '6', 'redis:8.4.0', 'redis-server', '--io-threads', '4', '--save', '')
await sleep(1000)
console.log(`\n=== io-threads 4 (container tạm, ${threads('lab-redis-io').length} thread), redis-benchmark 4 thread, 200 client, 2.000.000 lệnh ===`)
console.log('io-threads 4:', bench('lab-redis-io', ['-c', '200', '--threads', '4'], 2_000_000))
docker('rm', '-f', 'lab-redis-io')
docker('run', '-d', '--rm', '--name', 'lab-redis-io', '--cpus', '6', 'redis:8.4.0', 'redis-server', '--save', '')
await sleep(1000)
console.log('io-threads 1:', bench('lab-redis-io', ['-c', '200', '--threads', '4'], 2_000_000))
docker('rm', '-f', 'lab-redis-io')

console.log('\n=== Từ Node qua mạng Docker Desktop: 10.000 GET trên một connection (3 lượt) ===')
const r = connect()
await r.set('k', 'v')
const RTT = []
for (let i = 0; i < 200; i++) { const t0 = now(); await r.get('k'); RTT.push(now() - t0) }
RTT.sort((a, b) => a - b)
console.log(`latency một GET lúc rảnh: p50 ${ms(RTT[100])}`)
for (let round = 0; round < 3; round++) {
  let t0 = now()
  for (let i = 0; i < 10_000; i++) await r.get('k')
  const seq = now() - t0
  t0 = now()
  await Promise.all(Array.from({ length: 10_000 }, () => r.get('k')))
  const all = now() - t0
  t0 = now()
  const p = r.pipeline()
  for (let i = 0; i < 10_000; i++) p.get('k')
  await p.exec()
  const pipe = now() - t0
  console.log(`lần lượt (await từng lệnh) ${ms(seq).padStart(9)}   Promise.all ${ms(all).padStart(8)}   pipeline() ${ms(pipe).padStart(8)}`)
}
r.disconnect()
// CPU mà redis-server đã dùng: gần như toàn bộ nằm ở main thread
console.log('\nused_cpu_main_thread / used_cpu_user:', cli('lab-redis', 'info', 'cpu').match(/used_cpu_user_main_thread:([\d.]+)/)?.[1], '/', cli('lab-redis', 'info', 'cpu').match(/used_cpu_user:([\d.]+)/)[1], 'giây')
lab/redis/blocking.mjs: lệnh chậm chặn client khác, SLOWLOG, LATENCY
js
// Một lệnh chậm chặn mọi client: trong lúc một connection chạy lệnh chậm, một client khác gửi PING liên tục và ghi latency.
//   KEYS * trên 1.000.000 key, so với duyệt cả keyspace bằng SCAN COUNT 1000
//   DEL so với UNLINK một Hash 1.000.000 field, HGETALL Hash đó
// Sau đó xem SLOWLOG và LATENCY LATEST ghi lại những gì.
import { connect, ms, sleep, now, probe, pct } from './common.mjs'

const r = connect(), p = connect()
await r.flushall()
await r.config('SET', 'latency-monitor-threshold', '50')
await r.config('SET', 'slowlog-log-slower-than', '10000')
await r.slowlog('RESET')
await r.call('LATENCY', 'RESET')
await r.call('DEBUG', 'POPULATE', '1000000', 'key', '10')

async function measure(name, fn) {
  const pr = probe(p)
  await sleep(200)
  const t0 = now()
  const extra = await fn()
  const took = now() - t0
  await sleep(200)
  const lat = await pr.stop()
  console.log(`${name.padEnd(36)} ${ms(took).padStart(10)}   PING của client khác: p50 ${ms(pct(lat, 0.5))}, max ${ms(Math.max(...lat)).padStart(8)}${extra ? '   ' + extra : ''}`)
}

console.log('\n=== Lệnh chậm trên 1.000.000 key (thời gian lệnh tính từ phía client) ===')
await measure('KEYS * (1.000.000 key)', async () => `trả về ${(await r.keys('*')).length.toLocaleString('vi-VN')} key`)
await measure('SCAN COUNT 1000 hết keyspace', async () => {
  let cursor = '0', n = 0, calls = 0
  do {
    const [next, keys] = await r.scan(cursor, 'COUNT', 1000)
    cursor = next
    n += keys.length
    calls++
  } while (cursor !== '0')
  return `${calls.toLocaleString('vi-VN')} lần gọi, ${n.toLocaleString('vi-VN')} key`
})

// Hash 1.000.000 field, mỗi lần HSET 1.000 field
async function bigHash(key) {
  for (let i = 0; i < 1000; i++) {
    const args = []
    for (let k = 0; k < 1000; k++) args.push(`f${i * 1000 + k}`, 'v'.repeat(20))
    await r.hset(key, ...args)
  }
}
await bigHash('big')
console.log(`MEMORY USAGE của Hash 1.000.000 field: ${(await r.memory('USAGE', 'big')) / 1e6 | 0} MB`)
await measure('HGETALL Hash 1.000.000 field', async () => `${(Object.keys(await r.hgetall('big')).length).toLocaleString('vi-VN')} field`)
await measure('DEL Hash 1.000.000 field', () => r.del('big'))
await bigHash('big')
await measure('UNLINK Hash 1.000.000 field', () => r.unlink('big'))

console.log('\n=== SLOWLOG GET (lệnh chạy lâu hơn 10 ms, thời gian tính trong server) ===')
for (const [id, ts, us, args] of await r.slowlog('GET', 10)) console.log(`${(us / 1000).toFixed(1).replace('.', ',').padStart(7)} ms  ${args.slice(0, 3).join(' ')}`)
console.log('\n=== LATENCY LATEST (sự kiện vượt 50 ms) ===')
for (const [event, , last, max] of await r.call('LATENCY', 'LATEST')) console.log(`${event}: lần gần nhất ${last} ms, lớn nhất ${max} ms`)
await r.flushall()
r.disconnect()
p.disconnect()
lab/redis/memory.mjs: encoding và bộ nhớ
js
// Encoding gọn cho key nhỏ (listpack, intset) và bộ nhớ thật.
//   1. Ngưỡng mặc định của Redis 8.4 và OBJECT ENCODING trước/sau ngưỡng.
//   2. 100.000 user, mỗi user 5 trường: 5 key String riêng, một Hash nhỏ mỗi user, hay gom 100 user vào một Hash.
import { connect, mb } from './common.mjs'

const r = connect()
await r.flushall()
console.log('\n=== Ngưỡng encoding mặc định (Redis 8.4) ===')
for (const k of ['hash-max-listpack-entries', 'hash-max-listpack-value', 'set-max-intset-entries', 'set-max-listpack-entries', 'zset-max-listpack-entries', 'list-max-listpack-size']) {
  console.log(`${k} ${(await r.config('GET', k))[1]}`)
}

console.log('\n=== OBJECT ENCODING ===')
const enc = (k) => r.object('ENCODING', k)
await r.hset('h', ...Array.from({ length: 512 }, (_, i) => [`f${i}`, 'v']).flat())
console.log(`Hash 512 field ngắn: ${await enc('h')}`)
await r.hset('h', 'f512', 'v')
console.log(`Hash 513 field: ${await enc('h')}`)
await r.hset('h2', 'a', 'x'.repeat(64))
console.log(`Hash có giá trị 64 byte: ${await enc('h2')}`)
await r.hset('h2', 'b', 'x'.repeat(65))
console.log(`Hash có giá trị 65 byte: ${await enc('h2')}`)
await r.sadd('s', 1, 2, 3)
console.log(`Set toàn số nguyên: ${await enc('s')}`)
await r.sadd('s', 'abc')
console.log(`Set thêm một chuỗi: ${await enc('s')}`)
await r.set('n', '12345')
await r.set('str', 'hello')
await r.set('long', 'x'.repeat(50))
console.log(`String "12345": ${await enc('n')}, "hello": ${await enc('str')}, 50 ký tự: ${await enc('long')}`)

console.log('\n=== 100.000 user × 5 trường (name, email, age, city, plan) ===')
const used = async () => Number((await r.info('memory')).match(/used_memory:(\d+)/)[1])
const N = 100_000
const fields = (i) => ({ name: `user${i}`, email: `user${i}@example.com`, age: String(20 + (i % 50)), city: ['HN', 'HCM', 'DN'][i % 3], plan: i % 7 ? 'free' : 'pro' })
async function run(name, write) {
  await r.flushall()
  const before = await used()
  for (let i = 0; i < N; i += 1000) {
    const p = r.pipeline()
    for (let k = i; k < i + 1000; k++) write(p, k)
    await p.exec()
  }
  const after = await used()
  console.log(`${name.padEnd(50)} ${mb(after - before).padStart(9)}  (${Math.round((after - before) / N)} byte mỗi user, ${(await r.dbsize()).toLocaleString('vi-VN')} key)`)
}
await run('5 key String mỗi user (user:1:name …)', (p, i) => { for (const [f, v] of Object.entries(fields(i))) p.set(`user:${i}:${f}`, v) })
await run('1 Hash mỗi user (HSET user:1 name … email …)', (p, i) => p.hset(`user:${i}`, fields(i)))
await run('1 String JSON mỗi user', (p, i) => p.set(`user:${i}`, JSON.stringify(fields(i))))
await r.flushall()
r.disconnect()
lab/redis/transaction.mjs: MULTI/EXEC, WATCH, script Lua
js
// MULTI/EXEC không rollback, WATCH, và script Lua chặn server.
import { connect, ms, sleep, now } from './common.mjs'

const a = connect(), b = connect()
await a.flushall()
const show = (res) => res === null ? 'null' : JSON.stringify(res.map(([err, v]) => (err ? 'LỖI ' + err.message.split('\n')[0] : v)))

console.log('\n=== 1. Lỗi lúc chạy (INCR một chuỗi không phải số) giữa MULTI/EXEC ===')
await a.set('name', 'An')
console.log('EXEC trả:', show(await a.multi().set('x', '1').incr('name').set('y', '2').exec()))
console.log('Sau EXEC: x =', await a.get('x'), ', y =', await a.get('y'), '→ hai lệnh còn lại vẫn chạy, không rollback')

console.log('\n=== 2. Lỗi lúc xếp hàng (sai cú pháp) ===')
await a.call('MULTI')
await a.call('SET', 'z', '1')
try { await a.call('SET', 'only-key') } catch (e) { console.log('lệnh sai cú pháp:', e.message) }
try { await a.call('EXEC') } catch (e) { console.log('EXEC:', e.message) }
console.log('z =', await a.get('z'), '→ cả transaction bị huỷ')

console.log('\n=== 3. WATCH: client khác sửa key đang watch ===')
await a.set('stock', '10')
await a.watch('stock')
const stock = Number(await a.get('stock'))
await b.decr('stock') // client khác chen vào giữa lúc đọc và EXEC
const res = await a.multi().set('stock', String(stock - 1)).exec()
console.log('EXEC trả:', res, '→ không ghi, stock =', await a.get('stock'))

console.log('\n=== 4. Script Lua chạy 300 ms (busy-reply-threshold đặt 100 ms) ===')
await a.config('SET', 'busy-reply-threshold', '100')
const lua = `local t = redis.call('TIME'); local start = t[1] * 1000000 + t[2]
while true do t = redis.call('TIME'); if t[1] * 1000000 + t[2] - start > 300000 then break end end
return 'xong'`
const t0 = now()
const script = a.eval(lua, 0).then((v) => console.log(`script trả "${v}" sau ${ms(now() - t0)}`))
const out = []
for (let i = 0; i < 4; i++) {
  await sleep(60)
  const s = now()
  try { await b.get('stock'); out.push(`lúc ${Math.round(s - t0)} ms: GET trả sau ${ms(now() - s)}`) }
  catch (e) { out.push(`lúc ${Math.round(s - t0)} ms: ${e.message.split('.')[0]} (sau ${ms(now() - s)})`) }
}
await script
console.log(out.join('\n'))
await a.config('SET', 'busy-reply-threshold', '5000')
await a.flushall()
a.disconnect()
b.disconnect()
lab/redis/expire.mjs: lazy và active expire
js
// Redis xoá key hết hạn theo hai cách: lazy (khi có client truy cập key) và active (chu kỳ chạy nền quét key có TTL).
//   1. Tắt active expire (DEBUG SET-ACTIVE-EXPIRE 0): key hết hạn nằm lại trong RAM tới khi có client truy cập.
//   2. 400.000 key có TTL, một tỉ lệ trong đó hết hạn cùng một lúc (PXAT), không ai đọc lại:
//      đếm số key đã hết hạn còn nằm trong RAM sau 0,5 s, 1 s, …, với active-expire-effort 1 (mặc định) và 10.
import { connect, sleep, now, mb } from './common.mjs'

const r = connect()
const used = async () => Number((await r.info('memory')).match(/used_memory:(\d+)/)[1])
const serverMs = async () => { const [s, us] = await r.time(); return Number(s) * 1000 + Math.floor(Number(us) / 1000) }

console.log('\n=== 1. Tắt active expire: chỉ còn lazy expire ===')
await r.flushall()
await r.call('DEBUG', 'SET-ACTIVE-EXPIRE', '0')
const m0 = await used()
for (let i = 0; i < 200_000; i += 5000) {
  const p = r.pipeline()
  for (let k = i; k < i + 5000; k++) p.set(`tmp:${k}`, 'x'.repeat(50), 'PX', 300)
  await p.exec()
}
await sleep(1000)
console.log(`1 s sau khi 200.000 key đã hết hạn: DBSIZE ${(await r.dbsize()).toLocaleString('vi-VN')}, bộ nhớ của chúng ${mb(await used() - m0)}`)
console.log(`GET tmp:7 → ${await r.get('tmp:7')}; DBSIZE ${(await r.dbsize()).toLocaleString('vi-VN')} (lazy expire xoá đúng key vừa đọc)`)
await r.call('DEBUG', 'SET-ACTIVE-EXPIRE', '1')
const t1 = now()
while (await r.dbsize() > 0 && now() - t1 < 5000) await sleep(20)
console.log(`Bật lại active expire: còn ${await r.dbsize()} key sau ${Math.round(now() - t1)} ms`)

console.log('\n=== 2. 400.000 key có TTL, một phần hết hạn cùng lúc: số key đã hết hạn còn trong RAM ===')
console.log('effort  tỉ lệ hết hạn   0 s       0,5 s     1 s       1,5 s     2 s       2,5 s     3 s')
const TOTAL = 400_000
for (const [effort, frac] of [[1, 1], [1, 0.5], [1, 0.4], [1, 0.34], [1, 0.25], [1, 0.1], [10, 0.25], [10, 0.1]]) {
  await r.flushall()
  await r.config('SET', 'active-expire-effort', String(effort))
  const T = (await serverMs()) + 2500
  let n = 0
  for (let i = 0; i < TOTAL; i += 5000) {
    const p = r.pipeline()
    for (let k = i; k < i + 5000; k++) {
      // Rải đều: key thứ k hết hạn nếu phần nguyên của k × frac tăng
      if (Math.floor(k * frac) !== Math.floor((k - 1) * frac)) { n++; p.set(`k${k}`, 'x'.repeat(50), 'PXAT', T) }
      else p.set(`k${k}`, 'x'.repeat(50), 'EX', 3600)
    }
    await p.exec()
  }
  await sleep(T - (await serverMs()))
  const t0 = now(), out = []
  for (let t = 0; t <= 3000; t += 500) {
    await sleep(Math.max(0, t0 + t - now()))
    out.push((await r.dbsize() - (TOTAL - n)).toLocaleString('vi-VN').padEnd(9))
  }
  console.log(`${String(effort).padEnd(7)} ${(Math.round(frac * 100) + '%').padEnd(14)} ${out.join(' ')}`)
}
await r.config('SET', 'active-expire-effort', '1')
await r.flushall()
r.disconnect()
lab/redis/eviction.mjs: noeviction và LRU xấp xỉ
js
// Khi Redis đầy bộ nhớ.
//   1. noeviction (mặc định): lệnh nào bị từ chối, lệnh nào vẫn chạy.
//   2. allkeys-lru xấp xỉ: ghi 100.000 key theo thứ tự, đọc lại nửa sau để nó "mới dùng", rồi ghi thêm để Redis phải đẩy key ra.
//      LRU chính xác sẽ đẩy nửa đầu (key lâu chưa dùng nhất) ra trước. Đếm xem nửa sau còn bao nhiêu với maxmemory-samples 1, 5, 10.
import { connect, sleep } from './common.mjs'

const r = connect()
const err = async (fn) => { try { return 'OK: ' + JSON.stringify(await fn()) } catch (e) { return 'LỖI: ' + e.message } }

console.log('\n=== 1. Đã vượt maxmemory với noeviction ===')
await r.config('SET', 'maxmemory', '0') // phòng khi lần chạy trước bị ngắt giữa chừng
await r.flushall()
await r.config('SET', 'maxmemory-policy', 'noeviction')
await r.set('counter', '1')
// Ghi tới khoảng 22 MB rồi mới đặt maxmemory 20 MB, để chắc chắn Redis đang vượt ngưỡng lúc thử từng lệnh.
const used = async () => Number((await r.info('memory')).match(/used_memory:(\d+)/)[1])
for (let n = 0; (await used()) < 22e6; n += 1000) { const p = r.pipeline(); for (let k = n; k < n + 1000; k++) p.set(`k${k}`, 'x'.repeat(100)); await p.exec() }
await r.config('SET', 'maxmemory', '20mb')
console.log(`used_memory ${(await used() / 1e6).toFixed(1).replace('.', ',')} MB, maxmemory 20 MB`)
console.log('SET key mới:         ', await err(() => r.set('new', 'v')))
console.log('SET ghi đè bằng giá trị ngắn hơn:', await err(() => r.set('k1', 'y')))
console.log('INCR key đã có:      ', await err(() => r.incr('counter')))
console.log('EXPIRE:              ', await err(() => r.expire('k3', 100)))
console.log('GET:                 ', await err(() => r.get('k1')))
console.log('DEL:                 ', await err(() => r.del('k4', 'k5')))
console.log('UNLINK:              ', await err(() => r.unlink('k6')))

console.log('\n=== 2. allkeys-lru xấp xỉ: nửa key mới dùng còn sống được bao nhiêu ===')
for (const samples of [1, 5, 10]) {
  await r.config('SET', 'maxmemory', '0')
  await r.flushall()
  await r.config('SET', 'maxmemory-policy', 'allkeys-lru')
  await r.config('SET', 'maxmemory-samples', String(samples))
  const N = 100_000
  for (let i = 0; i < N; i += 1000) { const p = r.pipeline(); for (let k = i; k < i + 1000; k++) p.set(`k${k}`, 'x'.repeat(100)); await p.exec() }
  await sleep(1100) // LRU clock của Redis có độ phân giải 1 giây
  for (let i = N / 2; i < N; i += 1000) { const p = r.pipeline(); for (let k = i; k < i + 1000; k++) p.get(`k${k}`); await p.exec() }
  const mem = Number((await r.info('memory')).match(/used_memory:(\d+)/)[1])
  await r.config('SET', 'maxmemory', String(mem)) // đầy đúng mức hiện tại
  for (let i = 0; i < N / 2; i += 1000) { const p = r.pipeline(); for (let k = i; k < i + 1000; k++) p.set(`new${k}`, 'x'.repeat(100)); await p.exec() }
  let hot = 0
  for (let i = N / 2; i < N; i += 1000) { const p = r.pipeline(); for (let k = i; k < i + 1000; k++) p.exists(`k${k}`); hot += (await p.exec()).reduce((s, [, v]) => s + v, 0) }
  const evicted = Number((await r.info('stats')).match(/evicted_keys:(\d+)/)[1])
  console.log(`maxmemory-samples ${String(samples).padEnd(2)}: nửa mới dùng còn ${(hot / (N / 2) * 100).toFixed(1).replace('.', ',')}% (${hot.toLocaleString('vi-VN')} / 50.000), đã đẩy ra ${evicted.toLocaleString('vi-VN')} key`)
  await r.config('RESETSTAT')
}
await r.config('SET', 'maxmemory', '0')
await r.config('SET', 'maxmemory-policy', 'noeviction')
await r.config('SET', 'maxmemory-samples', '5')
await r.flushall()
r.disconnect()
lab/redis/persistence.mjs: fork, copy-on-write, RDB và AOF khi bị kill
js
// Lưu bền: fork và copy-on-write, mất bao nhiêu dữ liệu khi process bị kill.
//   1. 1.000.000 key (~200 MB). BGSAVE trong khi một client ghi đè key liên tục: thời gian fork,
//      bộ nhớ copy-on-write (trang nhớ bị chép vì bị sửa trong lúc process con đang ghi file RDB).
//   2. Chỉ RDB: INCR tới 1.000, BGSAVE, INCR tiếp tới 2.000, kill -9 rồi khởi động lại.
//   3. AOF appendfsync everysec: INCR tới 3.000, kill -9 ngay, khởi động lại.
// Script restart container lab-redis.
import { connect, docker, sleep, now, mb, ms } from './common.mjs'

let r = connect()
const field = async (section, f) => (await r.info(section)).match(new RegExp(`^${f}:(.*)$`, 'm'))?.[1].trim()

console.log('\n=== 1. BGSAVE trên 1.000.000 key trong lúc có ghi ===')
await r.flushall()
await r.call('DEBUG', 'POPULATE', '1000000', 'key', '200')
console.log(`used_memory ${mb(Number(await field('memory', 'used_memory')))}`)
for (const writing of [false, true]) {
  let stop = false, writes = 0
  const w = connect()
  const writer = writing && (async () => {
    while (!stop) {
      const p = w.pipeline()
      for (let i = 0; i < 500; i++) p.set(`key:${Math.floor(Math.random() * 1_000_000)}`, 'y'.repeat(200))
      await p.exec()
      writes += 500
    }
  })()
  await sleep(200)
  const t0 = now()
  await r.bgsave()
  while ((await field('persistence', 'rdb_bgsave_in_progress')) === '1') await sleep(20)
  const took = now() - t0
  stop = true
  await writer
  w.disconnect()
  // rdb_last_cow_size: lượng bộ nhớ bị chép vì main process sửa trang nhớ trong lúc process con đang ghi file
  console.log(`${writing ? 'có client ghi đè liên tục' : 'không ai ghi'}: BGSAVE mất ${ms(took)}, fork ${ms(Number(await field('stats', 'latest_fork_usec')) / 1000)}, ` +
    `copy-on-write ${mb(Number(await field('persistence', 'rdb_last_cow_size')))}` + (writing ? `, ${writes.toLocaleString('vi-VN')} lệnh ghi trong lúc đó` : ''))
}

async function restart(kill) {
  r.on('error', () => {}) // connection bị đứt khi container bị kill là điều cố ý
  r.disconnect()
  if (kill) docker('kill', '-s', 'KILL', 'lab-redis')
  docker('start', 'lab-redis')
  for (let i = 0; i < 50; i++) { try { r = connect(); await r.ping(); return } catch { r.disconnect(); await sleep(200) } }
}

console.log('\n=== 2. Chỉ RDB: kill -9 sau khi đã có snapshot ===')
await r.flushall()
for (let i = 0; i < 1000; i++) await r.incr('counter')
await r.save()
for (let i = 0; i < 1000; i++) await r.incr('counter')
console.log('trước khi kill: counter =', await r.get('counter'))
await restart(true)
console.log('sau khi khởi động lại: counter =', await r.get('counter'), '→ mất mọi lệnh ghi sau snapshot cuối')

await r.flushall()
r.disconnect()

// Container tạm bật AOF từ lúc khởi động (lab-redis chạy với --appendonly no, khởi động lại sẽ không đọc AOF).
console.log('\n=== 3. AOF appendfsync everysec: kill -9 ngay sau lệnh ghi cuối ===')
docker('run', '-d', '--name', 'lab-redis-aof', '-p', '127.0.0.1:63793:6379', 'redis:8.4.0', 'redis-server', '--appendonly', 'yes', '--appendfsync', 'everysec', '--save', '')
const up = async () => { for (let i = 0; i < 50; i++) { const c = connect(63793); try { await c.ping(); return c } catch { c.disconnect(); await sleep(200) } } }
let a = await up()
for (let i = 0; i < 3000; i++) await a.incr('counter')
console.log('trước khi kill: counter =', await a.get('counter'))
a.disconnect()
docker('kill', '-s', 'KILL', 'lab-redis-aof')
docker('start', 'lab-redis-aof')
a = await up()
console.log('sau khi khởi động lại: counter =', await a.get('counter'))
a.disconnect()
docker('rm', '-f', 'lab-redis-aof')
lab/redis/wait.mjs và keyslot.mjs: WAIT, hash tag
js
// WAIT: chờ lệnh ghi tới được N replica. Khi replica không trả lời, WAIT hết timeout và trả về số replica đã nhận,
// còn lệnh ghi thì đã nằm trên master rồi. Script dùng docker pause / unpause lab-redis-replica.
import { connect, docker, ms, sleep, now } from './common.mjs'

const m = connect(), rep = connect(63791)
// Chờ replica theo kịp (vd ngay sau khi persistence.mjs khởi động lại lab-redis, replica còn đang đồng bộ lại)
while ((await m.wait(1, 200)) < 1) await sleep(200)
await m.del('order:1')
let t0 = now()
await m.set('order:1', 'paid')
console.log(`\nSET rồi WAIT 1 1000 lúc bình thường: ${await m.wait(1, 1000)} replica đã nhận, mất ${ms(now() - t0)}`)
await sleep(50)
console.log(`Đọc trên replica: ${await rep.get('order:1')}`)

docker('pause', 'lab-redis-replica')
try {
  t0 = now()
  await m.set('order:1', 'refunded')
  const n = await m.wait(1, 500)
  console.log(`\nReplica bị treo (docker pause): WAIT 1 500 trả ${n} sau ${ms(now() - t0)}`)
  console.log(`Trên master lúc này: ${await m.get('order:1')} → lệnh ghi không bị huỷ, client phải tự xử lý khi WAIT trả ít hơn yêu cầu`)
} finally {
  docker('unpause', 'lab-redis-replica')
}
await sleep(500)
console.log(`Sau khi replica chạy lại: replica đọc ra ${await rep.get('order:1')}`)
await m.del('order:1')
m.disconnect()
rep.disconnect()
js
// Quy tắc hash tag của Redis Cluster, kiểm bằng CLUSTER KEYSLOT trên lab-redis-cluster (cluster-enabled, chưa chia slot).
// Phần được hash là đoạn giữa dấu { đầu tiên và dấu } đầu tiên sau nó, nếu đoạn đó không rỗng; không thì hash cả key.
import { connect } from './common.mjs'

const c = connect(63792)
const slot = (k) => c.cluster('KEYSLOT', k)
const keys = [
  ['foo', ''], ['user:1000', ''],
  ['user:{100}:profile', '100'], ['user:{100}:orders', '100'],
  ['{user1000}.following', 'user1000'], ['{user1000}.followers', 'user1000'],
  ['foo{}{bar}', 'foo{}{bar}'], ['foo{{bar}}zap', '{bar'], ['foo{bar}{zap}', 'bar'], ['{}user', '{}user']
]
console.log('\nkey                       slot    phần được hash (dự đoán)   slot của phần đó')
for (const [k, part] of keys) {
  const s = await slot(k)
  const p = part ? await slot(part) : s
  console.log(`${k.padEnd(25)} ${String(s).padStart(5)}    ${(part || '(cả key)').padEnd(26)} ${String(p).padStart(5)} ${s === p ? '✓' : '✗'}`)
}
c.disconnect()