NoSQL 5 — Redis: kho key-value trong bộ nhớ
NoSQL 5 — Redis: kho key-value trong bộ nhớ
Sau ba bài về MongoDB — mô hình dữ liệu, CRUD và index & hiệu năng — series chuyển sang họ NoSQL thứ hai đã giới thiệu ở bài tổng quan: key-value store, mà đại diện thực chiến số một là Redis (REmote DIctionary Server). Nếu MongoDB là database chính lưu trữ bền vững cho dữ liệu bán cấu trúc, thì Redis đứng ở một vị trí khác hẳn: nó là lớp tốc độ đặt trước hoặc bên cạnh database chính, gánh những nhu cầu cần độ trễ dưới mili-giây mà PostgreSQL hay MongoDB không kham nổi — cache, đếm counter thời gian thực, session, hàng đợi, bảng xếp hạng.
Lưu ý về sandbox: SQL sandbox của Knowledge Base chạy trên PostgreSQL (chỉ đọc). Mọi lệnh Redis trong bài này đều không chạy được ở đây — chúng chỉ mang tính minh hoạ cú pháp. Chỉ khối SQL duy nhất ở cuối bài (đối chiếu với Postgres) được đánh dấu "▶ Chạy được".
Redis là gì
Redis là một in-memory key-value store: toàn bộ dữ liệu sống trong RAM, được truy cập qua một khoá (key) là chuỗi. Nhưng gọi Redis là "key-value store" đơn thuần là bán rẻ nó. Khác với memcached — nơi value chỉ là một khối byte mờ đục — value trong Redis là một cấu trúc dữ liệu có kiểu: chuỗi, băm (hash), danh sách, tập hợp, tập hợp có thứ tự, bitmap, stream... Redis biết bên trong value là gì và cung cấp các lệnh thao tác trực tiếp trên cấu trúc đó (thêm phần tử vào list, tăng một trường trong hash, lấy top-N của sorted set) mà không cần đọc value về, sửa, rồi ghi lại. Người ta hay gọi Redis là "data structure server" — máy chủ cấu trúc dữ liệu.
Ba đặc điểm định hình mọi thứ về Redis:
-
Nằm trong RAM. Đọc/ghi không đụng đĩa (trong đường dẫn nóng), nên độ trễ tính bằng micro-giây tới dưới một mili-giây. Đổi lại, dung lượng bị giới hạn bởi RAM và đắt hơn đĩa; dữ liệu có thể mất nếu không cấu hình persistence — chủ đề bài vận hành Redis.
-
Đơn luồng cho việc xử lý lệnh. Một server Redis xử lý các lệnh tuần tự, từng lệnh một trên một luồng chính duy nhất (các phiên bản mới có I/O đa luồng cho phần đọc/ghi socket, nhưng phần thực thi lệnh vẫn đơn luồng). Điều này nghe phản trực giác lại chính là điểm mạnh.
-
Nguyên tử ở cấp lệnh. Vì lệnh chạy tuần tự trên một luồng, mỗi lệnh Redis là nguyên tử (atomic) — không có chuyện hai lệnh chồng lấn nửa vời. Đây là nền tảng cho các mẫu như counter và distributed lock.
Vì sao Redis nhanh
Tốc độ của Redis đến từ ba yếu tố cộng hưởng, không phải một phép màu đơn lẻ.
-
RAM thay vì đĩa. Truy cập bộ nhớ nhanh hơn đĩa (kể cả SSD) từ vài chục tới hàng nghìn lần. Đây là yếu tố lớn nhất.
-
Mô hình đơn luồng. Vì chỉ một luồng thực thi lệnh, Redis không cần lock, không cần latch, không có context switch giữa các luồng tranh chấp dữ liệu, không có chi phí đồng bộ hoá. Trong hệ đa luồng, phần lớn thời gian và độ phức tạp bị đốt vào việc khoá và điều phối; Redis né toàn bộ điều đó. Trên một core hiện đại, một instance Redis xử lý được cỡ hàng trăm nghìn lệnh mỗi giây — thường đủ cho hầu hết ứng dụng, và khi cần hơn thì scale bằng nhiều instance/sharding thay vì nhiều luồng.
-
Cấu trúc dữ liệu tối ưu. Redis cài đặt sẵn các cấu trúc dữ liệu với độ phức tạp tốt và tối ưu bộ nhớ (ví dụ sorted set dùng skip list + hash table cho tra cứu O(log N); hash/list nhỏ dùng biểu diễn nén ziplist/listpack tiết kiệm RAM). Lệnh chạy đúng thuật toán phù hợp, không phải quét toàn bộ.
Hệ quả thực tế: vì đơn luồng, một lệnh chậm (ví dụ KEYS * quét toàn bộ keyspace, hay SMEMBERS trên set hàng triệu phần tử) sẽ chặn mọi lệnh khác đang xếp hàng phía sau. Đây là bài học vận hành quan trọng nhất về Redis: tránh lệnh O(N) lớn trên đường dẫn nóng — dùng SCAN thay KEYS, phân trang thay vì lấy hết một lần.
Bản đồ các kiểu dữ liệu
Trước khi đi vào từng kiểu, đây là bức tranh tổng thể — kiểu dữ liệu nào hợp với bài toán nào:
String — GET/SET/INCR
String là kiểu cơ bản nhất: một key ánh xạ tới một chuỗi byte (tối đa 512 MB), có thể là văn bản, JSON đã serialize, hay một số. Đây là kiểu dùng cho cache và counter.
SET session:af93k2 "{\"cust_id\":10023,\"role\":\"teller\"}"
GET session:af93k2
SET rate:usd 25480 EX 60 -- lưu kèm hết hạn 60 giây
INCR txn:count:2026-07-03 -- tăng bộ đếm lên 1, trả về giá trị mới
INCRBY txn:amount:2026-07-03 5000000
Điểm mấu chốt: INCR/INCRBY là nguyên tử. Hai tiến trình cùng gọi INCR trên một key sẽ không bao giờ đè lên nhau — mỗi lần đúng một đơn vị. Đây là lý do Redis là công cụ chuẩn để đếm giao dịch/lượt xem/rate-limit thời gian thực, thay vì đọc-cộng-ghi trên RDBMS (vốn phải khoá dòng).
Hash — HSET/HGETALL
Hash là một bảng băm bên trong một value: một key chứa nhiều cặp field-value. Dùng để lưu một object/hồ sơ gọn gàng dưới một key duy nhất, và cập nhật/đọc từng trường mà không phải serialize/deserialize cả object.
HSET customer:10023 name "Nguyen Van A" city "Hanoi" tier "gold" balance 15000000
HGET customer:10023 tier -- lấy đúng một trường
HGETALL customer:10023 -- lấy toàn bộ field-value
HINCRBY customer:10023 login_count 1
So với việc nhét cả JSON vào một String rồi mỗi lần sửa phải đọc-parse-sửa-ghi lại, Hash cho phép sửa một trường (HINCRBY, HSET) rẻ và nguyên tử. Hợp với hồ sơ khách, cấu hình, giỏ hàng, đối tượng phiên có nhiều thuộc tính.
List — LPUSH/RPOP
List là danh sách liên kết các chuỗi, có thứ tự, cho phép thêm/lấy ở hai đầu với chi phí O(1). Use case kinh điển: hàng đợi (queue).
LPUSH queue:notifications "sms:0912345678:OTP" -- đẩy vào đầu
RPOP queue:notifications -- lấy ra từ đuôi -> FIFO
LRANGE queue:notifications 0 9 -- xem 10 phần tử đầu
BRPOP queue:notifications 5 -- blocking pop, chờ tối đa 5s
Cặp LPUSH + RPOP tạo hàng đợi FIFO (vào đầu, ra đuôi); dùng LPUSH + LPOP thì thành stack LIFO. BRPOP/BLPOP là bản blocking — consumer chờ tới khi có phần tử thay vì poll liên tục, nền tảng cho worker xử lý job. Chi tiết mẫu producer-consumer nằm ở bài pattern Redis.
Set — SADD/SINTER
Set là tập hợp các chuỗi không trùng lặp, không thứ tự. Dùng khi cần đảm bảo thành viên duy nhất và các phép toán tập hợp (giao, hợp, hiệu).
SADD product:loan:viewers 10023 10056 10023 -- 10023 chỉ được lưu một lần
SISMEMBER product:loan:viewers 10023 -- kiểm tra thành viên: 1/0
SCARD product:loan:viewers -- đếm số phần tử
SINTER product:loan:viewers product:card:viewers -- KH xem CẢ vay LẪN thẻ (tập giao)
SDIFF campaign:target product:loan:viewers -- trong target nhưng chưa xem vay
SINTER/SUNION/SDIFF biến những bài toán "khách nào thuộc cả hai nhóm", "hợp danh sách chiến dịch", "loại trừ nhóm đã tiếp cận" thành một lệnh duy nhất — thay vì kéo dữ liệu về ứng dụng rồi so sánh vòng lặp.
Sorted Set (ZSet) — ZADD/ZRANGE
Sorted Set là "ngôi sao" của Redis. Mỗi phần tử vừa duy nhất (như Set) vừa gắn một score kiểu số dùng để sắp thứ tự. Redis giữ phần tử luôn được sắp theo score, nên lấy top-N, xếp hạng, hay truy vấn theo khoảng score đều nhanh.
ZADD leaderboard:sales:2026-07 15 "cn_hoan_kiem" -- score 15 (tỷ đồng)
ZADD leaderboard:sales:2026-07 22 "cn_cau_giay"
ZADD leaderboard:sales:2026-07 8 "cn_thanh_xuan"
ZREVRANGE leaderboard:sales:2026-07 0 2 WITHSCORES -- top 3 theo score giảm dần
ZRANK leaderboard:sales:2026-07 "cn_thanh_xuan" -- thứ hạng của một chi nhánh
ZRANGEBYSCORE leaderboard:sales:2026-07 10 20 -- các chi nhánh có score 10..20
Ba use case chính:
- Bảng xếp hạng (leaderboard): score = doanh số/điểm, lấy
ZREVRANGE 0 Nra top-N;ZRANKcho thứ hạng cá nhân — tất cả O(log N). - Hàng đợi ưu tiên (priority queue): score = mức ưu tiên hoặc thời điểm; lấy phần tử score nhỏ nhất ra xử lý trước.
- Delayed queue / lịch: score = timestamp cần chạy; định kỳ lấy các phần tử có score ≤ hiện tại.
Bitmap và HyperLogLog
Đây là hai kiểu "chuyên biệt" tiết kiệm bộ nhớ đáng kinh ngạc.
Bitmap thực chất là một String được thao tác ở cấp bit. Dùng để lưu cờ nhị phân cho hàng loạt id: ví dụ "khách nào đã đăng nhập hôm nay". Mỗi khách chiếm đúng 1 bit — một triệu khách chỉ tốn 125 KB.
SETBIT login:2026-07-03 10023 1 -- đánh dấu khách 10023 đã đăng nhập
GETBIT login:2026-07-03 10023 -- kiểm tra: 1/0
BITCOUNT login:2026-07-03 -- đếm số khách đã đăng nhập
HyperLogLog giải bài toán đếm số phần tử distinct xấp xỉ với sai số cỡ 0.81%, nhưng chỉ tốn tối đa ~12 KB cho bất kỳ số lượng phần tử — kể cả hàng tỷ. Đánh đổi độ chính xác tuyệt đối lấy bộ nhớ cực nhỏ.
PFADD unique_visitors:2026-07-03 cust:10023 cust:10056 cust:10023
PFCOUNT unique_visitors:2026-07-03 -- ~ số khách duy nhất (xấp xỉ)
PFMERGE unique_visitors:week uv:d1 uv:d2 uv:d3 -- gộp nhiều ngày
Dùng HyperLogLog khi bạn cần biết "khoảng bao nhiêu unique users/IP/thẻ" chứ không cần con số chính xác từng đơn vị — ví dụ báo cáo lượt truy cập.
Stream và Geo (sơ lược)
Stream (XADD/XREAD/XREADGROUP) là cấu trúc log sự kiện chỉ-thêm (append-only), mỗi bản ghi có id thời gian tự tăng. Nó giống một Kafka thu nhỏ trong Redis: hỗ trợ consumer group để nhiều worker cùng đọc và chia phần, có acknowledgement. Dùng cho event sourcing, audit log, pipeline sự kiện thời gian thực. Khi nhu cầu vượt tầm Redis, đây là lúc cân nhắc Kafka.
XADD txn:events * account 55021 amount 5000000 kind debit
XREAD COUNT 10 STREAMS txn:events 0 -- đọc từ đầu
Geo (GEOADD/GEOSEARCH) lưu toạ độ kinh-vĩ độ (thực chất cài trên Sorted Set với geohash làm score) và truy vấn "các điểm trong bán kính X km". Use case: tìm ATM/chi nhánh gần vị trí khách.
GEOADD atm:hanoi 105.8542 21.0285 "atm_hoan_kiem"
GEOSEARCH atm:hanoi FROMLONLAT 105.83 21.02 BYRADIUS 3 km ASC
TTL và expiration — nền của cache
Mọi key trong Redis có thể gắn thời gian sống (TTL). Hết hạn, Redis tự động xoá key. Đây là cơ chế nền tảng của cache: dữ liệu tự dọn, không cần job dọn rác riêng.
SET rate:usd 25480 EX 300 -- hết hạn sau 300 giây
EXPIRE session:af93k2 1800 -- đặt TTL 30 phút cho key đã tồn tại
TTL session:af93k2 -- còn bao nhiêu giây (-1: không TTL; -2: đã hết/không tồn tại)
PERSIST session:af93k2 -- bỏ TTL, biến thành key vĩnh viễn
Bảng nhanh các lệnh liên quan:
| Lệnh | Ý nghĩa |
|---|---|
SET k v EX 300 | Đặt key kèm TTL 300 giây trong một lệnh |
EXPIRE k 1800 | Gắn TTL 1800 giây cho key có sẵn |
PEXPIRE k 500 | Như trên nhưng tính bằng mili-giây |
TTL k | Số giây còn lại (-1 không TTL, -2 không tồn tại) |
PERSIST k | Gỡ TTL |
Redis dọn key hết hạn theo hai cơ chế: lazy (xoá khi có ai đó truy cập key đã hết hạn) và active (định kỳ lấy mẫu ngẫu nhiên và xoá). Hệ quả: một key "đã hết hạn" có thể vẫn chiếm RAM một lúc trước khi bị dọn — cần lưu ý khi tính dung lượng.
Quy ước đặt tên key
Redis không có schema, không có bảng — chỉ có một không gian key phẳng khổng lồ. Kỷ luật đặt tên key vì thế là quy ước bắt buộc để keyspace không thành đống hỗn độn. Chuẩn thực tế: dùng dấu hai chấm : làm phân cách phân cấp, theo mẫu <đối tượng>:<id>:<thuộc tính>.
customer:10023 -- hash hồ sơ khách 10023
customer:10023:sessions -- set các phiên của khách
session:af93k2 -- string dữ liệu một phiên
txn:count:2026-07-03 -- counter giao dịch theo ngày
leaderboard:sales:2026-07 -- sorted set doanh số theo tháng
rate:usd -- tỷ giá USD (có TTL)
Nguyên tắc:
- Nhất quán và có tiền tố: cùng loại dữ liệu dùng cùng prefix để dễ
SCANtheo pattern (SCAN 0 MATCH session:*). - Nhúng chiều thời gian vào key (ngày/tháng) cho counter và bảng xếp hạng, để reset/rollover tự nhiên và gắn TTL.
- Đủ ngắn nhưng đọc được: key dài tốn RAM khi có hàng triệu key, nhưng đừng viết tắt tới mức khó hiểu.
- Tránh
KEYS patterntrên production — dùngSCAN(không chặn) để duyệt.
Nguyên tử, transaction và pipeline
Tính nguyên tử của lệnh đơn
Nhắc lại điểm cốt lõi: mỗi lệnh Redis là nguyên tử vì mô hình đơn luồng. INCR, SADD, ZADD, LPUSH... không bao giờ bị xen ngang giữa chừng. Nhiều bài toán tưởng cần transaction thực ra chỉ cần chọn đúng một lệnh nguyên tử (đếm bằng INCR, chống trùng bằng SADD).
MULTI/EXEC — gộp nhiều lệnh
Khi cần nhiều lệnh chạy liền một mạch, dùng transaction MULTI/EXEC. Giữa MULTI và EXEC, các lệnh được xếp hàng; EXEC thực thi cả khối tuần tự, không lệnh nào khác chen vào giữa.
MULTI
INCR txn:count:2026-07-03
INCRBY txn:amount:2026-07-03 5000000
SADD txn:accounts:2026-07-03 55021
EXEC -- ba lệnh chạy như một khối nguyên tử
Cần hiểu rõ giới hạn: transaction Redis không có rollback như SQL. Nếu một lệnh trong khối lỗi lúc thực thi (ví dụ sai kiểu dữ liệu), các lệnh khác vẫn chạy — Redis không hoàn tác. Nó đảm bảo cô lập (không xen ngang) chứ không đảm bảo all-or-nothing theo nghĩa ACID. Muốn logic điều kiện ("chỉ trừ tiền nếu số dư đủ") thì dùng WATCH (optimistic lock) hoặc Lua script.
Lua script — nguyên tử có điều kiện
EVAL chạy một đoạn Lua nguyên tử trên server: toàn bộ script thực thi như một lệnh, không bị xen ngang, và có thể chứa logic điều kiện. Đây là cách chuẩn cho các thao tác "đọc-quyết-định-ghi" phải nguyên tử, ví dụ trừ hạn mức nếu còn đủ.
EVAL "local q = tonumber(redis.call('GET', KEYS[1]) or '0')
if q >= tonumber(ARGV[1]) then
return redis.call('DECRBY', KEYS[1], ARGV[1])
else return -1 end" 1 quota:otp:0912345678 1
Vì script chạy nguyên tử trên luồng chính, phải giữ nó ngắn và nhanh — script chậm chặn cả server.
Pipeline — giảm round-trip
Pipeline không phải transaction. Nó là kỹ thuật gửi nhiều lệnh trong một lần thay vì gửi từng lệnh rồi chờ hồi đáp mới gửi lệnh tiếp. Với 100 lệnh, không pipeline nghĩa là 100 vòng đi-về mạng (round-trip); có pipeline chỉ còn 1. Khi client và server cách nhau vài mili-giây độ trễ mạng, pipeline tăng thông lượng lên hàng chục lần mà không đổi logic.
Lưu ý phân biệt: pipeline tối ưu mạng (gộp gửi), MULTI/EXEC đảm bảo cô lập (không xen ngang), Lua đảm bảo nguyên tử có logic. Ba thứ độc lập và có thể kết hợp.
Use case thực tế
Bối cảnh: Hệ thống mobile banking NCB cần một dashboard giám sát thời gian thực hiển thị: tổng số giao dịch trong ngày, tổng giá trị, số khách duy nhất phát sinh giao dịch, và bảng xếp hạng 5 chi nhánh có doanh số cao nhất tháng. Nguồn giao dịch: ~2 triệu giao dịch/ngày, đỉnh 300 giao dịch/giây. Nếu mỗi giao dịch phải UPDATE ... SET count = count + 1 trên PostgreSQL, khoá dòng counter thành điểm nghẽn nghiêm trọng ở giờ cao điểm.
Thiết kế bằng Redis — mỗi khi core banking phát sinh một giao dịch (account 55021, số tiền 5.000.000, chi nhánh Cầu Giấy), ứng dụng bắn một khối lệnh:
MULTI
INCR txn:count:2026-07-03 -- +1 tổng số giao dịch ngày
INCRBY txn:amount:2026-07-03 5000000 -- cộng dồn giá trị
PFADD txn:unique_cust:2026-07-03 10023 -- đếm khách distinct (HLL)
ZINCRBY leaderboard:sales:2026-07 5000000 "cn_cau_giay" -- cộng doanh số CN
EXEC
Dashboard đọc số liệu bằng vài lệnh O(1)/O(log N), trả về dưới mili-giây:
GET txn:count:2026-07-03
GET txn:amount:2026-07-03
PFCOUNT txn:unique_cust:2026-07-03
ZREVRANGE leaderboard:sales:2026-07 0 4 WITHSCORES -- top 5 chi nhánh
Kết quả: mỗi giao dịch tốn cỡ vài chục micro-giây phía Redis, không khoá dòng, không tranh chấp; toàn bộ counter tự reset theo ngày nhờ nhúng ngày vào key và gắn TTL (ví dụ 40 ngày). Redis ở đây không thay sổ cái — sổ cái thật vẫn nằm trong RDBMS với ACID; Redis chỉ gánh phần đếm/xếp hạng thời gian thực mà RDBMS làm chậm và tốn kém.
Đối chiếu — cùng bài toán "top chi nhánh" nếu chạy trên PostgreSQL (sandbox chỉ đọc), để thấy vì sao truy vấn tổng hợp thời gian thực trên bảng giao dịch tỷ dòng là gánh nặng còn Redis thì không:
-- ▶ Chạy được
SELECT a.customer_id, COUNT(*) AS so_gd, SUM(t.amount) AS tong_tien
FROM transactions t
JOIN accounts a ON a.id = t.account_id
GROUP BY a.customer_id
ORDER BY tong_tien DESC
LIMIT 5;
Câu SQL trên phải quét/tổng hợp toàn bộ transactions mỗi lần chạy; với Redis, cùng thông tin đã được cộng dồn tăng dần (incremental) và đọc ra trong một lệnh. Các mẫu triển khai đầy đủ (cache-aside, rate limiting, distributed lock, hàng đợi) là nội dung bài pattern Redis, còn tổng hợp use case ngân hàng ở bài use case ngân hàng.
Ghi nhớ
- Redis = in-memory key-value store, xử lý lệnh đơn luồng → nhanh (dưới mili-giây) và tuần tự. Value không phải byte mờ đục mà là cấu trúc dữ liệu có kiểu.
- Nhanh nhờ RAM + đơn luồng (không lock/context-switch) + cấu trúc dữ liệu tối ưu. Hệ quả: một lệnh O(N) lớn (
KEYS *,SMEMBERSkhổng lồ) chặn cả server — dùngSCAN, phân trang. - Chọn kiểu theo bài toán: String (cache/counter,
INCR), Hash (object/hồ sơ,HSET), List (hàng đợi,LPUSH/RPOP/BRPOP), Set (thành viên duy nhất & tập giao,SADD/SINTER), Sorted Set (leaderboard/priority/top-N,ZADD/ZREVRANGE). - Kiểu chuyên biệt: Bitmap (cờ nhị phân, 1 bit/id), HyperLogLog (đếm distinct xấp xỉ, ~12 KB cho tỷ phần tử), Stream (log sự kiện có consumer group), Geo (tìm theo bán kính).
- TTL/expiration (
EXPIRE/TTL/SET ... EX) là nền của cache — key tự dọn; hết hạn xử lý lazy + active. - Đặt tên key theo phân cấp
object:id:attrvới:, nhúng chiều thời gian cho counter/leaderboard, dùngSCANchứ khôngKEYStrên production. - Nguyên tử: mỗi lệnh đơn đã nguyên tử; MULTI/EXEC gộp nhiều lệnh cô lập nhưng không rollback; Lua (
EVAL) cho nguyên tử có điều kiện; pipeline chỉ giảm round-trip mạng, không phải transaction. - Redis là lớp tốc độ, không thay sổ cái ACID — dùng nó cho đếm real-time, cache, session, hàng đợi, xếp hạng.
Bài viết liên quan
Cách index hoạt động (B-Tree), đọc EXPLAIN, seq scan vs index scan và mẫu tối ưu truy vấn.
Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.
Kết nhiều bảng đúng cách: các loại JOIN, bẫy thường gặp và phép hợp tập.
Truy vấn lồng, CTE (WITH), CTE đệ quy và hàm cửa sổ — vũ khí cho phân tích nâng cao.