NoSQL 5 — Redis: kho key-value trong bộ nhớ

13 thg 7, 2026 2 lượt xem
#sql
#data-structures
#nosql
#redis
#in-memory
#key-value

NoSQL 5 — Redis: kho key-value trong bộ nhớ

Sau ba bài về MongoDB — mô hình dữ liệu, CRUDindex & 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:

  1. 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.

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

  3. 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 cachecounter.

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/INCRBYnguyê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 N ra top-N; ZRANK cho 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 1800Gắn TTL 1800 giây cho key có sẵn
PEXPIRE k 500Như trên nhưng tính bằng mili-giây
TTL kSố giây còn lại (-1 không TTL, -2 không tồn tại)
PERSIST kGỡ 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ễ SCAN theo 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 pattern trên production — dùng SCAN (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 MULTIEXEC, 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 *, SMEMBERS khổng lồ) chặn cả server — dùng SCAN, 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:attr với :, nhúng chiều thời gian cho counter/leaderboard, dùng SCAN chứ không KEYS trê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.

13 thg 7, 2026 4

Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.

13 thg 7, 2026 4

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.

13 thg 7, 2026 3

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.

13 thg 7, 2026 3