+1

Giải phẫu Redis: Vì sao RAM có thể nhanh đến mức phi lý?

"Cái máy chủ đó chịu không nổi nữa rồi anh ơi." — Câu nói mình nghe lần đầu lúc 2 giờ sáng, khi database production đang thở hổn hển vì một cái flash sale mà không ai chuẩn bị đủ.


Mở đầu — Khoảnh khắc database bắt đầu nghẹt thở

Năm 2018, mình đang làm việc tại một công ty e-commerce tầm trung, khoảng 500 nghìn user active mỗi tháng. Không lớn lắm, nhưng đủ để gây ra đau tim trong các đợt sale.

Hôm đó là Black Friday đầu tiên của công ty — team marketing hào hứng đẩy campaign, CEO khoe trên LinkedIn, còn mình và đồng đội ngồi phòng server nhìn dashboard Datadog mà mặt tái dần. Response time leo từ 200ms lên 800ms, rồi 2 giây, rồi timeout. Database MySQL lúc này đang nhận khoảng 8.000 query/giây, trong khi bình thường chỉ 400-500. Connection pool cạn kiệt. Disk I/O đỏ lòm.

Cái cảm giác đó — nhìn hệ thống chết dần trong khi user đang đổ vào — là thứ không thể quên được.

Sau đó, chúng mình implement Redis. Ba tuần sau, flash sale tiếp theo diễn ra êm ru. Database query giảm xuống còn khoảng 300 query/giây, dù traffic tăng gấp đôi. Response time ổn định dưới 50ms.

Redis không phải phép màu. Nhưng nếu bạn hiểu nó đúng cách, nó gần giống phép màu đến mức đáng sợ.

Bài này mình sẽ "mổ xẻ" Redis từ trong ra ngoài — từ lý do tại sao RAM nhanh đến mức phi lý, đến cái "phép thuật" bên trong kiến trúc của nó, đến những bài toán thực chiến và cả những cái bẫy mà mình từng sa vào.

Rót cà phê đi. Bài dài đấy.


Phần 1 — Redis thực sự là gì? (Và không phải là gì)

Redis không chỉ là Cache

Đây là điểm mà mình thấy nhiều junior và thậm chí mid-level hiểu sai nhất.

Khi nói "Redis", 90% người sẽ nghĩ ngay đến: "Ừ, cái thằng cache. Set key, get value, hết TTL thì xóa."

Không sai, nhưng chưa đủ — và sự thiếu sót này khiến rất nhiều người dùng Redis một cách lãng phí, như mua dao mổ về để... gọt hoa quả.

Redis là Remote Dictionary Server — một in-memory database với khả năng persistence, hỗ trợ nhiều cấu trúc dữ liệu phức tạp, có thể hoạt động như message broker, event stream, distributed lock manager, và nhiều hơn nữa. Nó được Salvatore Sanfilippo (antirez) viết vào năm 2009, ban đầu để giải quyết bài toán scale log analysis cho startup của ông.

Những đặc điểm cốt lõi:

  • In-memory database: Dữ liệu sống trong RAM, không phải disk
  • Key-Value store với rich data structures: Không chỉ String, mà còn Hash, List, Set, Sorted Set, Stream...
  • Single-thread event loop: Một thread xử lý tất cả, nhưng cực kỳ hiệu quả
  • Persistence tùy chọn: Không phải "lưu tạm rồi mất" — Redis có thể bền vữa dữ liệu nếu bạn muốn
  • Replication và Cluster: Có thể scale ngang

Tại sao RAM nhanh hơn Disk đến mức "phi lý"?

Đây là phần mình muốn đi sâu nhất, vì đây là nền tảng của mọi thứ.

Hãy nhìn vào cái gọi là memory hierarchy của máy tính hiện đại:

CPU Register    →  ~0.3 ns
L1 Cache        →  ~1 ns
L2 Cache        →  ~4 ns
L3 Cache        →  ~10 ns
RAM (DRAM)      →  ~60-100 ns
NVMe SSD        →  ~100-200 µs  (microseconds)
SATA SSD        →  ~500 µs
HDD             →  ~5-10 ms    (milliseconds)
Network (LAN)   →  ~0.5 ms

Nhìn vào con số thôi đã chóng mặt rồi. RAM nhanh hơn NVMe SSD khoảng 1.000 đến 3.000 lần. Nhanh hơn HDD khoảng 100.000 lần.

Mình hay dùng cái ví dụ này để giải thích cho người mới:

Tưởng tượng bạn đang làm việc. RAM giống như đồ vật đang nằm trên bàn làm việc của bạn — tay với tới là lấy được, không cần suy nghĩ. SSD giống như tủ hồ sơ ngay cạnh bàn — phải đứng dậy, mở ngăn kéo, lấy file ra. HDD giống như kho lưu trữ ở tầng hầm — phải đi thang máy xuống, tìm kiếm trong cả tòa nhà, rồi mang lên.

Khi database truyền thống như MySQL hay PostgreSQL xử lý query, nó phải:

  1. Parse query
  2. Tìm data trên disk (random I/O — cực kỳ chậm)
  3. Load vào buffer pool (RAM)
  4. Xử lý và trả về

Vấn đề là disk random I/O — khi bạn đọc record không liên tiếp nhau trên disk, đầu đọc phải nhảy qua nhảy lại. Với HDD cơ học, đây là thảm họa vật lý. Với SSD tốt hơn, nhưng vẫn không thể so sánh với RAM.

Redis làm gì? Nó giữ toàn bộ dataset trong RAM. Không có bước "tìm trên disk" nào cả. Mỗi lần đọc là đọc thẳng từ RAM.

Nhưng chỉ dùng RAM thôi chưa đủ

Đây là cái hay — nhiều người nghĩ "à vậy thì mình tự làm in-memory cache bằng HashMap là xong, cần gì Redis". Mình từng thấy code base dùng ConcurrentHashMap trong Java application để cache dữ liệu.

Nhưng Redis không nhanh chỉ vì RAM. Redis nhanh vì cả một hệ thống kiến trúc được thiết kế để không lãng phí một nanosecond nào:

  1. Data structure tối ưu: Redis không lưu string thông thường — nó dùng SDS (Simple Dynamic String), Hash Table với thiết kế đặc biệt, Skip List cho Sorted Set...
  2. Single-thread event loop: Loại bỏ hoàn toàn overhead của locking và context switching
  3. Zero system call overhead: Redis batching và tối ưu cách gọi kernel
  4. Memory layout cực gọn: Ít allocation, ít fragmentation

Chúng ta sẽ đi vào từng cái một.


Phần 2 — "Phép thuật" bên trong Redis

Single-thread nhưng vẫn cực nhanh — Hiểu lầm nguy hiểm nhất

Mình đã phỏng vấn hàng trăm kỹ sư backend trong nhiều năm qua. Câu hỏi mình hay hỏi: "Redis single-thread, vậy tại sao nó handle được hàng chục nghìn request/giây?"

70% câu trả lời: "Vì RAM nhanh mà."

Đúng, nhưng chưa đủ. Câu trả lời thực sự nằm ở hiểu lầm về multi-threading.

Hiểu lầm phổ biến: "Multi-thread thì phải nhanh hơn single-thread."

Thực tế phức tạp hơn nhiều.

Khi bạn có nhiều thread cùng truy cập shared data, bạn cần locking. Và locking gây ra:

  • Context switching: OS phải lưu state của thread này, load state của thread kia. Chi phí này không hề nhỏ — mỗi context switch tốn khoảng 1-10 microseconds.
  • Lock contention: Nhiều thread tranh nhau một lock — thread thắng thì chạy, thread thua thì chờ (blocked). CPU sitting idle.
  • Deadlock: Thread A giữ lock 1, chờ lock 2. Thread B giữ lock 2, chờ lock 1. Cả hai cùng đứng yên mãi mãi.
  • Cache invalidation: Khi nhiều CPU core cùng modify một vùng nhớ, cache coherence protocol phải sync lại — tốn hàng chục nanoseconds.

Redis tránh tất cả những cái này bằng cách chỉ dùng một thread cho data operations.

Hãy nghĩ theo cách này:

Multi-thread giống như một văn phòng 10 nhân viên cùng sửa một bộ hồ sơ. Họ phải liên tục hỏi nhau "mày đang sửa trang nào?", chờ đợi, đôi khi gây conflict. Năng suất thực tế không tăng tuyến tính với số nhân viên.

Redis giống như một thư ký cực kỳ nhanh, có tổ chức hoàn hảo, xử lý từng request một theo thứ tự, không bao giờ bị interrupt. Không cần coordination, không cần lock, không cần sync.

Kết quả? Với workload read-heavy và operation nhỏ (< 1ms), single-thread event loop thường nhanh hơn multi-thread có locking.

Lưu ý: Redis 6.0 trở đi có thêm I/O threading để tận dụng multi-core, nhưng data processing vẫn single-thread. Đây là thiết kế có chủ đích.

Event Loop — Trái tim của Redis

Bây giờ mình sẽ giải thích cái "trái tim" — Event Loop.

Câu hỏi đặt ra: nếu chỉ có một thread, làm sao Redis handle hàng chục nghìn connection đồng thời mà không bị block?

Câu trả lời: Non-blocking I/O kết hợp với I/O multiplexing.

Redis dùng epoll trên Linux (hoặc kqueue trên BSD/macOS) — đây là syscall cho phép một thread giám sát hàng nghìn file descriptor (socket connection) cùng một lúc, và chỉ xử lý những cái nào có data ready.

Cơ chế hoạt động:

1. Client A gửi request → Redis nhận vào buffer
2. Client B gửi request → Redis nhận vào buffer
3. Client C gửi request → Redis nhận vào buffer

Event Loop:
  while(true) {
    events = epoll_wait(fds, timeout)  // Chờ event, không block CPU
    for each event:
      if readable:
        read_request(fd)
        process_command(cmd)   // Thực hiện command
        write_response(fd)
  }

Điểm quan trọng: epoll_wait không busy-wait (không ngốn CPU khi không có gì làm). Khi không có event, thread ngủ và nhường CPU cho OS. Khi có event, thread thức dậy và xử lý.

Mình từng benchmark trên một máy 4-core, Redis xử lý 200.000 SET operations/giây và 300.000 GET operations/giây. Một Node.js server multi-process với clustering tương tự chỉ đạt 80.000-120.000 req/s với overhead cao hơn nhiều.

Tại sao Redis giữ được latency thấp kể cả khi nhiều client?

Vì không có "quản lý thread" nào cả. Mỗi command được xử lý end-to-end trong một lần lặp event loop — không chờ đợi, không schedule, không context switch. P99 latency của Redis thường dưới 1ms ngay cả khi có 10.000 concurrent connections.

Data Structure — Vũ khí bí mật thực sự

Đây là phần nhiều người nhất hay bỏ qua, nhưng mình nghĩ đây mới là lý do thực sự khiến Redis bá đạo hơn các in-memory store khác.

Redis không lưu "text". Redis lưu cấu trúc dữ liệu được tối ưu cho từng bài toán cụ thể.

String — Không đơn giản như bạn nghĩ

Redis không dùng C string thông thường (null-terminated char array). Nó dùng SDS — Simple Dynamic String:

struct sdshdr {
    int len;      // Độ dài hiện tại
    int free;     // Số byte còn trống
    char buf[];   // Buffer thực tế
};

Tại sao?

  • O(1) length: Lấy độ dài string không cần duyệt toàn bộ
  • Binary safe: Có thể lưu bất kỳ byte nào, kể cả null byte (dùng được cho binary data, JSON, serialized object)
  • Pre-allocation: Tránh realloc liên tục khi string grow

Use case thực tế:

# Cache JSON response
SET user:1001:profile '{"name":"Minh","age":28,"city":"HCM"}' EX 3600

# Counter
INCR page:view:article:5502  # Atomic increment, thread-safe

# Distributed lock (sẽ nói ở phần sau)
SET lock:payment:order:999 "worker-1" NX EX 30

Hash — Profile và Session tối ưu

Hash trong Redis là perfect fit cho object với nhiều field. Thay vì serialize cả object thành string, bạn lưu từng field riêng.

# Lưu user profile
HSET user:1001 name "Minh Tran" age 28 city "HCM" plan "premium"

# Lấy một field
HGET user:1001 name          # "Minh Tran"

# Update một field — không cần đọc rồi ghi lại cả object
HSET user:1001 city "Hanoi"

# Lấy tất cả
HGETALL user:1001

Cái hay: khi số field nhỏ (< 128 field, mỗi field < 64 bytes — có thể configure), Redis tự động dùng ziplist encoding thay vì hash table. Ziplist là một cấu trúc compact, lưu tất cả trong một vùng nhớ liên tiếp, tiết kiệm RAM đáng kể và cache-friendly (CPU cache sẽ pre-load toàn bộ structure).

Khi data lớn hơn threshold, Redis tự động convert sang hash table — bạn không cần làm gì cả.

Mình từng optimize session storage của một app: ban đầu lưu session object serialize thành JSON string, tốn ~2KB/session. Chuyển sang Hash, còn ~800 bytes/session. Với 500.000 active sessions, tiết kiệm được hơn 600MB RAM.

List — Queue pattern cực mạnh

Redis List là doubly-linked list (hoặc ziplist khi nhỏ), hỗ trợ O(1) push/pop từ cả hai đầu.

# Job queue
LPUSH queue:email:send '{"to":"user@example.com","template":"welcome"}'
LPUSH queue:email:send '{"to":"other@example.com","template":"promo"}'

# Worker lấy job (blocking pop — chờ cho đến khi có job)
BRPOP queue:email:send 30   # Block tối đa 30 giây

# Capped log (giữ 1000 entry gần nhất)
LPUSH app:log:errors "Error at 14:32:10: NullPointerException"
LTRIM app:log:errors 0 999   # Giữ 1000 entry đầu

BRPOP là một trong những command mình thích nhất — nó cho phép implement blocking queue mà không cần polling. Worker thread ngủ cho đến khi có job, không ngốn CPU.

Set — Unique tracking và relationship

# Unique visitors hôm nay
SADD visitors:2024-11-29 "user:1001" "user:1002" "user:1003"
SCARD visitors:2024-11-29   # Đếm số unique visitor

# Mutual friends giữa user A và user B
SINTERSTORE mutual:friends:A:B friends:userA friends:userB
SMEMBERS mutual:friends:A:B

Điểm mạnh của Set: Các set operations như SUNION, SINTER, SDIFF đều rất hiệu quả — O(N) với N là tổng số element. Lấy intersection của hai tập bạn bè hàng nghìn người trong microseconds.

Sorted Set — Leaderboard thần thánh

Đây là data structure mình ấn tượng nhất. Sorted Set = Set + Score, và luôn được sort theo score.

Bên dưới, Redis implement Sorted Set bằng Skip List + Hash Table:

  • Hash Table để lookup O(1) theo member
  • Skip List để duyệt theo thứ tự O(log N)
# Game leaderboard
ZADD leaderboard 98500 "player:nguyen_van_a"
ZADD leaderboard 75200 "player:tran_thi_b"
ZADD leaderboard 112000 "player:le_van_c"

# Top 10 players (descending)
ZREVRANGE leaderboard 0 9 WITHSCORES

# Rank của một player
ZREVRANK leaderboard "player:le_van_c"   # 0 (rank 1)

# Tăng score (atomic!)
ZINCRBY leaderboard 5000 "player:nguyen_van_a"

# Lấy players trong range score
ZRANGEBYSCORE leaderboard 80000 120000 WITHSCORES

Mình đã implement leaderboard realtime cho một mobile game với 200.000 players. Cập nhật score sau mỗi match, query top 100 sau mỗi 5 giây. Redis handle dễ dàng — mỗi ZINCRBY chỉ tốn ~2-3 microseconds.

Stream — Kafka mini cho bài toán vừa phải

Redis Stream (thêm vào từ v5.0) là append-only log với consumer groups, giống Kafka nhưng nhẹ hơn nhiều.

# Producer: ghi event
XADD events:orders * order_id 12345 user_id 1001 amount 599000 status "placed"

# Consumer group
XGROUP CREATE events:orders notification-service $ MKSTREAM
XGROUP CREATE events:orders inventory-service $ MKSTREAM

# Consumer đọc
XREADGROUP GROUP notification-service consumer-1 COUNT 10 BLOCK 2000 STREAMS events:orders >

# Acknowledge đã xử lý
XACK events:orders notification-service 1701234567890-0

Khi nào dùng Redis Stream, khi nào dùng Kafka?

Tiêu chí Redis Stream Kafka
Throughput Tốt (~100K msg/s) Rất cao (triệu msg/s)
Retention Giới hạn bởi RAM Disk-based, lâu dài
Ordering Trong một stream Trong partition
Consumer groups
Replay history Giới hạn Tốt
Ops complexity Đơn giản Phức tạp
Khi nào dùng Notification, light events Audit log, big data pipeline

Mình hay recommend Redis Stream khi team nhỏ, data volume vừa phải (< vài triệu message/ngày), và không muốn vận hành Kafka cluster. Kafka khi bạn cần durability cao, replay history dài, và throughput khổng lồ.


Phần 3 — Redis giữ dữ liệu thế nào nếu RAM bị mất điện?

Redis có thực sự an toàn?

Câu hỏi đầu tiên mọi CTO hay hỏi khi mình propose Redis: "Nhỡ server die thì mất hết dữ liệu à?"

Không hẳn. Redis có hai cơ chế persistence.

RDB — Snapshot nhanh như chụp ảnh

RDB (Redis Database) chụp toàn bộ in-memory dataset vào một file nhị phân (.rdb) tại các thời điểm định kỳ.

# redis.conf
save 900 1      # Snapshot nếu có ít nhất 1 key thay đổi trong 900 giây
save 300 10     # Snapshot nếu có ít nhất 10 key thay đổi trong 300 giây
save 60 10000   # Snapshot nếu có ít nhất 10000 key thay đổi trong 60 giây

Cách Redis tạo snapshot không block: Redis dùng fork() syscall để tạo child process. Child process inherit toàn bộ memory của parent (nhờ Copy-on-Write của OS), rồi dump ra file. Parent process tiếp tục phục vụ request bình thường.

Copy-on-Write hoạt động như thế này: ban đầu parent và child cùng trỏ vào cùng một vùng nhớ. Khi parent modify một page, OS mới copy page đó ra một vùng mới cho parent. Child vẫn đọc page cũ (snapshot tại thời điểm fork).

Tradeoff: Có thể mất tối đa vài giây đến vài phút dữ liệu nếu server crash giữa hai snapshot. Phù hợp cho use case mà một ít data loss là chấp nhận được (cache, leaderboard, session...).

Ưu điểm: File .rdb compact, load nhanh khi restart, dễ backup và transfer.

AOF — Bền dữ liệu như nhật ký kế toán

AOF (Append Only File) ghi lại từng lệnh write dưới dạng text.

# Ví dụ AOF file
*3
$3
SET
$8
user:key
$5
value

Khi restart, Redis replay toàn bộ AOF để tái tạo dataset.

Ba chế độ fsync:

# redis.conf
appendfsync always    # Fsync sau mỗi write — bền nhất, chậm nhất
appendfsync everysec  # Fsync mỗi giây — cân bằng tốt (default)
appendfsync no        # Để OS quyết định — nhanh nhất, ít an toàn nhất

everysec là sweet spot cho hầu hết trường hợp: tối đa mất 1 giây dữ liệu trong worst case crash.

Vấn đề của AOF: File ngày càng lớn. Redis giải quyết bằng AOF Rewrite — định kỳ compact lại file, thay thế nhiều lệnh redundant bằng lệnh ngắn gọn hơn. Ví dụ 100 lần INCR key có thể compact thành SET key 100.

Hybrid Persistence — Tốt nhất cả hai thế giới

Từ Redis 4.0, có thêm hybrid persistence: AOF file bắt đầu bằng một RDB snapshot, tiếp theo là AOF tail (incremental commands sau snapshot đó).

# redis.conf
aof-use-rdb-preamble yes

Kết quả: restart nhanh như RDB (load binary snapshot), bền dữ liệu như AOF (chỉ replay phần nhỏ commands sau snapshot). Đây là config mình recommend mặc định cho production.

Bảng so sánh persistence:

RDB AOF Hybrid
Data safety Có thể mất vài phút Tối đa 1 giây Tốt
Restart speed Nhanh Chậm (replay) Nhanh
File size Compact Lớn dần Cân bằng
CPU overhead Thấp Trung bình Trung bình
Phù hợp Backup, cache Transactional data Production general

Phần 4 — Khi Redis bước vào thế giới phân tán

Replication — Redis clone chính nó

Một Redis instance thường có RAM giới hạn và là single point of failure. Replication giải quyết cả hai vấn đề.

# redis.conf trên replica
replicaof 192.168.1.100 6379

Quá trình replication:

  1. Replica gửi PSYNC tới master
  2. Master fork, tạo RDB snapshot, gửi cho replica (full sync lần đầu)
  3. Từ đó, master gửi từng command write cho replica (async)

Async replication nghĩa là: master xác nhận write thành công trước khi replica confirm. Điều này cho phép master phục vụ nhanh, nhưng có risk: nếu master die trước khi replica nhận đủ data, sẽ có lag.

Giá trị thực tế:

  • Read scaling: Distribute read request sang replicas (master chỉ handle write)
  • Hot standby: Replica sẵn sàng promote lên master nếu cần
  • Geo-distribution: Đặt replica ở region khác để giảm latency

Redis Sentinel — Người canh gác không ngủ

Trong production, bạn không thể ngồi canh Redis 24/7. Sentinel làm việc đó.

# sentinel.conf
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
sentinel parallel-syncs mymaster 1

Sentinel là một process riêng (thường chạy 3 instance để đảm bảo quorum) liên tục:

  1. Monitor: PING định kỳ master và replica
  2. Detect failure: Nếu master không respond trong down-after-milliseconds, đánh dấu là "subjectively down"
  3. Agree: Nếu majority sentinels đồng ý, đánh dấu "objectively down"
  4. Failover: Bầu chọn replica tốt nhất, promote lên master, cập nhật các replica khác

Scenario Black Friday: 2 giờ sáng, master Redis die vì một lý do nào đó. Trong 5-30 giây (tùy config), Sentinel tự động promote replica lên, ứng dụng reconnect, hệ thống tiếp tục chạy. Không cần gọi on-call engineer.

Mình từng cấu hình Sentinel cho một hệ thống fintech — trong 2 năm, có 3 lần master failover xảy ra tự động. Zero downtime cảm nhận bởi user.

Redis Cluster — Chia nhỏ thế giới

Khi dataset vượt quá RAM của một máy, hoặc cần throughput cao hơn, đến lúc dùng Redis Cluster.

Redis Cluster chia không gian key thành 16384 hash slots. Mỗi node chịu trách nhiệm một phần hash slots.

# Tính hash slot cho một key
slot = CRC16(key) % 16384

Ví dụ: 3 master nodes, mỗi node giữ ~5461 slots:

  • Node A: slots 0-5460
  • Node B: slots 5461-10922
  • Node C: slots 10923-16383

Khi client gửi GET user:1001, Redis tính CRC16("user:1001") % 16384, tìm ra node tương ứng, route request đến đó (hoặc trả về MOVED redirect cho client).

Hash tags: Nếu bạn muốn nhiều key cùng nằm trên một node (để dùng multi-key operations), dùng {} syntax:

SET {user:1001}.profile "..." 
SET {user:1001}.settings "..."
# Cả hai key đều hash theo "user:1001", đảm bảo cùng slot

Limitation quan trọng: Trong Cluster mode, multi-key commands (MGET, MSET) chỉ hoạt động nếu tất cả keys cùng hash slot. Lua scripts và transactions cũng bị giới hạn tương tự. Đây là tradeoff bạn phải chấp nhận khi dùng Cluster.


Phần 5 — Những bài toán thực chiến Redis đang giải quyết

Cache chống sập database — Cache Aside Pattern

Đây là pattern cơ bản nhất, nhưng có nhiều chi tiết tinh tế mà làm sai sẽ gây vấn đề.

Cache Aside Pattern (còn gọi là Lazy Loading):

def get_user(user_id):
    # 1. Check cache trước
    cache_key = f"user:{user_id}"
    cached = redis.get(cache_key)
    
    if cached:
        return json.loads(cached)  # Cache hit
    
    # 2. Cache miss — load từ DB
    user = db.query("SELECT * FROM users WHERE id = ?", user_id)
    
    if user:
        # 3. Store vào cache
        redis.setex(cache_key, 3600, json.dumps(user))  # TTL 1 giờ
    
    return user

Write strategy:

def update_user(user_id, data):
    # Update DB trước
    db.execute("UPDATE users SET ... WHERE id = ?", user_id, data)
    
    # Invalidate cache (không update cache — tránh race condition)
    redis.delete(f"user:{user_id}")

Tại sao delete cache thay vì update? Vì update có thể gây race condition: nếu hai request A và B cùng update user, sau khi cả hai write xong DB, thứ tự write vào cache có thể bị đảo ngược, cache kết thúc với giá trị cũ hơn DB.

Cache hit ratio là KPI quan trọng nhất. Mình thường monitor:

  • Hit ratio < 80%: Cache không hiệu quả, cần review TTL và key strategy
  • Hit ratio > 95%: Lý tưởng
  • Hit ratio đột ngột drop: Có thể do cache flush, deploy mới, hoặc traffic pattern thay đổi

Một key hot (hot key) — một key được truy cập hàng nghìn lần/giây — có thể cứu cả hệ thống. Ngược lại, cache miss trên hot key có thể là trigger cho cache stampede (sẽ nói ở phần Góc tối).

Rate Limiting — Tấm khiên chống spam

Rate limiting là một trong những bài toán Redis giải quyết đẹp nhất.

Simple counter approach:

def is_rate_limited(user_id, action, limit=100, window=60):
    key = f"rate:{action}:{user_id}:{int(time.time() // window)}"
    
    count = redis.incr(key)
    if count == 1:
        redis.expire(key, window)  # Set TTL cho key mới
    
    return count > limit
# Ví dụ manual
INCR rate:login:user:1001:1701234560   # → 1 (lần đầu trong window này)
EXPIRE rate:login:user:1001:1701234560 60
INCR rate:login:user:1001:1701234560   # → 2
# ... sau 100 lần:
INCR rate:login:user:1001:1701234560   # → 101 → Rate limited!

Sliding window approach (chính xác hơn nhưng tốn RAM hơn):

def sliding_window_rate_limit(user_id, limit=100, window=60):
    now = time.time()
    key = f"rate:sliding:{user_id}"
    
    pipe = redis.pipeline()
    # Xóa entries cũ hơn window
    pipe.zremrangebyscore(key, 0, now - window)
    # Thêm request hiện tại
    pipe.zadd(key, {str(now): now})
    # Đếm
    pipe.zcard(key)
    # Set TTL
    pipe.expire(key, window)
    
    results = pipe.execute()
    count = results[2]
    return count > limit

Thực tế triển khai tại một API Gateway mình đã làm:

Rate limits:
- /api/otp/send: 5 requests/phone/hour
- /api/search: 1000 requests/user/minute
- /api/payment: 10 requests/user/minute
- Global: 100,000 requests/IP/minute (anti-DDoS basic)

Tất cả implement bằng Redis, tốn chưa đến 100MB RAM cho hàng triệu user.

Distributed Lock — Ngăn chặn double payment

Đây là bài toán mình gặp nhiều nhất trong hệ thống payment và e-commerce.

Vấn đề: User click "thanh toán" hai lần liên tục. Hai request cùng lúc đến hai server khác nhau. Cả hai check inventory, thấy còn hàng, cả hai deduct. Bạn vừa bán âm hàng.

Giải pháp với Redis distributed lock:

import uuid
import time

def acquire_lock(redis_client, lock_name, expire_seconds=30):
    lock_id = str(uuid.uuid4())
    key = f"lock:{lock_name}"
    
    # SET NX EX — atomic operation
    acquired = redis_client.set(key, lock_id, nx=True, ex=expire_seconds)
    return lock_id if acquired else None

def release_lock(redis_client, lock_name, lock_id):
    key = f"lock:{lock_name}"
    
    # Lua script để đảm bảo atomic check-and-delete
    lua_script = """
    if redis.call("GET", KEYS[1]) == ARGV[1] then
        return redis.call("DEL", KEYS[1])
    else
        return 0
    end
    """
    return redis_client.eval(lua_script, 1, key, lock_id)

# Sử dụng:
def process_payment(order_id, user_id):
    lock_name = f"payment:order:{order_id}"
    lock_id = acquire_lock(redis, lock_name, expire_seconds=30)
    
    if not lock_id:
        raise Exception("Order đang được xử lý, vui lòng thử lại")
    
    try:
        # Xử lý thanh toán
        do_payment_processing(order_id, user_id)
    finally:
        release_lock(redis, lock_name, lock_id)

Tại sao dùng Lua script để release?

Nếu không có Lua script, sequence có thể là:

  1. Check: GET lock:order:123 → "worker-1" ✓ (là của mình)
  2. Ngay lúc này, lock expire vì xử lý lâu
  3. Worker khác acquire lock mới
  4. DEL lock:order:123 → Xóa lock của worker khác!

Lua script là atomic — bước check và delete là một operation không thể bị interrupt.

Nhưng distributed lock không đơn giản như vậy: Đây là cái mình ghi đậm trong document khi onboard team mới. Network partition, clock skew, process pause (GC pause trong Java) — tất cả có thể gây ra lock safety violation. Nếu bạn cần garanteed safety cho critical sections, hãy xem xét Redlock algorithm hoặc thậm chí dùng hệ thống chuyên biệt hơn như ZooKeeper hay etcd. Redis lock là "good enough" cho 90% trường hợp, nhưng không phải silver bullet.

Leaderboard Realtime — Sorted Set phát huy hết sức mạnh

Mình đã từng implement leaderboard cho một platform game có 500.000 người dùng. Yêu cầu:

  • Update score sau mỗi match (< 100ms)
  • Query top 100 (< 10ms)
  • Query rank của một player cụ thể (< 5ms)
  • Leaderboard weekly và all-time
class GameLeaderboard:
    def __init__(self, redis_client, leaderboard_id):
        self.redis = redis_client
        self.key = f"leaderboard:{leaderboard_id}"
    
    def update_score(self, player_id, score_delta):
        """Cập nhật score, trả về rank mới"""
        new_score = self.redis.zincrby(self.key, score_delta, player_id)
        rank = self.redis.zrevrank(self.key, player_id)
        return {"score": new_score, "rank": rank + 1}
    
    def get_top(self, n=100):
        """Lấy top N players"""
        return self.redis.zrevrange(self.key, 0, n-1, withscores=True)
    
    def get_player_rank(self, player_id):
        rank = self.redis.zrevrank(self.key, player_id)
        score = self.redis.zscore(self.key, player_id)
        return {"rank": rank + 1 if rank is not None else None, "score": score}
    
    def get_nearby(self, player_id, range_size=5):
        """Lấy players xung quanh một player (±5)"""
        rank = self.redis.zrevrank(self.key, player_id)
        if rank is None:
            return []
        start = max(0, rank - range_size)
        end = rank + range_size
        return self.redis.zrevrange(self.key, start, end, withscores=True)

Weekly leaderboard: Tạo key với suffix ngày/tuần, set TTL tự động expire.

# Key pattern
leaderboard:weekly:2024-W48
leaderboard:monthly:2024-11
leaderboard:alltime

# Expire weekly leaderboard sau 8 ngày
EXPIRE leaderboard:weekly:2024-W48 691200

Với 500.000 players, toàn bộ leaderboard tốn khoảng 30-40MB RAM. Query top 100 tốn ~50 microseconds. Không có relational database nào làm được tốt hơn với latency này ở scale này.

Pub/Sub và Stream — Notification realtime và Event-driven

Pub/Sub: Simple, fire-and-forget messaging.

# Publisher
def publish_notification(channel, message):
    redis.publish(f"notifications:{channel}", json.dumps(message))

# Subscriber (trong một thread hoặc process riêng)
def start_notification_listener(channel):
    pubsub = redis.pubsub()
    pubsub.subscribe(f"notifications:{channel}")
    
    for message in pubsub.listen():
        if message['type'] == 'message':
            notification = json.loads(message['data'])
            handle_notification(notification)

Quan trọng: Redis Pub/Sub không lưu message. Nếu subscriber offline lúc publish, message mất. Dùng cho realtime notification, websocket push, live update — không dùng cho critical events.


Phần 6 — Góc tối của Redis

Cache Stampede — Hàng nghìn request cùng miss cache

Đây là scenario kinh điển mà mình từng trải qua lần đầu năm 2019.

Một key hot (ví dụ: homepage featured products) expire đúng lúc traffic đỉnh. Hàng nghìn request cùng thấy cache miss, cùng query database, database crush.

T=0: Key expire
T=0.001: Request 1 - cache miss - query DB
T=0.001: Request 2 - cache miss - query DB  
T=0.001: Request 3 - cache miss - query DB
...
T=0.001: Request 1000 - cache miss - query DB  ← Database dies

Giải pháp 1 — Mutex lock (Cache Aside với Lock):

def get_with_lock(key, fetch_fn, ttl=3600, lock_timeout=5):
    value = redis.get(key)
    if value:
        return json.loads(value)
    
    lock_key = f"lock:{key}"
    lock_id = acquire_lock(redis, lock_key, lock_timeout)
    
    if lock_id:
        try:
            value = fetch_fn()  # Chỉ một request vào DB
            redis.setex(key, ttl, json.dumps(value))
            return value
        finally:
            release_lock(redis, lock_key, lock_id)
    else:
        # Khác chờ và thử lại
        time.sleep(0.1)
        return get_with_lock(key, fetch_fn, ttl, lock_timeout)

Giải pháp 2 — Random TTL: Thay vì set TTL cố định, add random jitter:

ttl = base_ttl + random.randint(0, base_ttl * 0.1)  # ±10% jitter
redis.setex(key, ttl, value)

Điều này đảm bảo các key không expire cùng một lúc.

Giải pháp 3 — Logical Expiration (mình thích nhất cho hot keys):

def get_with_logical_expiry(key, fetch_fn, ttl=3600):
    cached = redis.get(key)
    
    if cached:
        data = json.loads(cached)
        if data['expires_at'] < time.time():
            # "Expired" — nhưng vẫn trả về data cũ, refresh trong background
            threading.Thread(target=refresh_cache, 
                           args=(key, fetch_fn, ttl)).start()
        return data['value']  # Luôn trả về ngay, không bao giờ block
    
    # Cache miss thật sự — fetch và store
    value = fetch_fn()
    store_with_logical_expiry(key, value, ttl)
    return value

Key không bao giờ thực sự expire (set TTL rất dài), nhưng có expires_at trong data. Khi "expired", serve data cũ và async refresh. User không bao giờ thấy delay.

Big Key — "Quả bom RAM" nguy hiểm

Big key là key có value quá lớn (List với hàng triệu element, Hash với hàng trăm nghìn field, String vài trăm MB).

Nguy hiểm vì:

  1. Block event loop: DEL một list có 1 triệu elements là O(N) — block Redis hàng giây
  2. Slow network transfer: HGETALL trả về 100MB data
  3. Memory pressure: Một key chiếm quá nhiều RAM, gây fragmentation
# Tìm big keys
redis-cli --bigkeys

# Xóa an toàn một big key (không block)
UNLINK large_list_key  # Async delete — Redis 4.0+

Thay vì DEL, dùng UNLINK — nó mark key là deleted ngay lập tức (không block client), sau đó xóa memory trong background thread.

Prevention: Design key để tránh unbounded growth. Dùng LTRIM sau LPUSH để cap size list. Split hash lớn thành nhiều hash nhỏ hơn.

Memory Fragmentation — RAM dùng nhưng không đủ

Tình huống khó chịu: Redis report used_memory: 10GB nhưng used_memory_rss (RAM thực sự OS allocate) là 16GB. Bạn đang "mất" 6GB.

INFO memory
# used_memory: 10737418240
# used_memory_rss: 17179869184
# mem_fragmentation_ratio: 1.6   ← Fragmentation cao!

Fragmentation ratio > 1.5 là đáng lo ngại. Nguyên nhân: jemalloc (allocator Redis dùng) allocate memory theo chunks cố định. Khi bạn xóa nhiều keys nhỏ, để lại "holes" không dùng được.

Giải quyết:

# Redis 4.0+ — Active defragmentation
CONFIG SET activedefrag yes
CONFIG SET active-defrag-ignore-bytes 100mb
CONFIG SET active-defrag-threshold-lower 10   # Bắt đầu defrag khi fragmentation > 10%

Active defragmentation chạy trong background, dần dần move data để fill holes. Không cần restart Redis.

Redis không phù hợp cho mọi thứ

Mình muốn kết thúc phần này bằng một thứ ít ai nói: khi nào KHÔNG dùng Redis.

Đừng dùng Redis cho:

  1. Complex queries và JOIN: Bạn cần JOIN users u ON u.id = o.user_id WHERE o.status = 'pending' AND u.tier = 'premium'? Dùng PostgreSQL. Redis không có query language, không có index theo nhiều dimension.

  2. Full-text search: Redis có một số tính năng search (với module RediSearch), nhưng cho search phức tạp, Elasticsearch hay Typesense là lựa chọn đúng hơn.

  3. Large dataset không fit vào RAM: Nếu data của bạn là 500GB và RAM chỉ có 64GB, Redis không phải lựa chọn. Xem xét Apache Cassandra hay RocksDB.

  4. Complex transactions: Redis có MULTI/EXEC (transaction), nhưng không có ROLLBACK thực sự theo nghĩa SQL. Nếu cần ACID transaction phức tạp, PostgreSQL là đúng.

  5. Reporting và analytics: Aggregation phức tạp, GROUP BY, window functions — đây là đất của data warehouse và SQL database.

Redis là dao mổ, không phải Swiss Army Knife.

Khi bạn cần speed cho những operation đơn giản — lookup, counter, cache, queue, leaderboard — Redis là vô địch. Nhưng đừng cố nhét mọi bài toán vào Redis chỉ vì nó nhanh.


Kết thúc — Redis không nhanh vì "ma thuật"

Đến cuối bài này, mình muốn pull back và nhìn bức tranh toàn cảnh.

Redis nhanh không phải vì magic. Redis nhanh vì mỗi quyết định kiến trúc đều được đưa ra với một mục tiêu rõ ràng: không lãng phí từng nanosecond.

Hãy tổng kết:

RAM thay vì Disk: Loại bỏ bottleneck chậm nhất trong hệ thống. Latency giảm từ milliseconds xuống microseconds.

Single-thread Event Loop: Loại bỏ hoàn toàn locking overhead, context switching, deadlock. Sequential execution trong single thread đơn giản hơn và nhanh hơn cho workload của Redis.

Non-blocking I/O với epoll/kqueue: Một thread handle hàng chục nghìn connection mà không block. CPU không idle chờ network.

Data structure tối ưu: SDS thay vì C string. Ziplist thay vì linked list khi data nhỏ. Skip list cho ordered set. Mỗi cấu trúc được chọn để giảm thiểu CPU cycles và memory allocation cho operation phổ biến nhất.

Zero unnecessary overhead: Redis không có query parser phức tạp, không có optimizer, không có transaction manager đầy đủ. Nó làm ít thứ hơn, nhưng làm cực kỳ tốt những thứ nó làm.

Bảng tổng kết — Vì sao Redis nhanh:

Yếu tố Vấn đề giải quyết Tác động
In-memory Disk I/O latency 1000x faster access
Single-thread Lock contention, context switch Zero coordination overhead
epoll/kqueue Blocking I/O Handle 10K+ connections/thread
SDS String operation O(N) O(1) length, binary safe
Ziplist Memory fragmentation Compact layout, cache-friendly
Skip List Sorted access overhead O(log N) balanced vs O(N)
Event loop Scheduler overhead Predictable low latency

Mình từng có một junior hỏi: "Anh ơi, mình có cần hiểu sâu Redis không, hay chỉ cần biết dùng là đủ?"

Câu trả lời mình đưa ra: Chỉ biết dùng thì bạn sẽ cache. Hiểu sâu thì bạn sẽ biết khi nào không nên cache, khi nào cache là sai, và khi nào một key có thể cứu cả hệ thống.

Khoảng cách giữa engineer biết dùng Redis và engineer hiểu Redis là khoảng cách giữa người dùng công cụ và người làm chủ công cụ.

Redis được thiết kế bởi những người hiểu rằng phần cứng không phải vô hạn, mỗi nanosecond đều có giá trị, và kiến trúc đúng quan trọng hơn phần cứng mạnh. Đó là triết lý mà mình nghĩ bất kỳ kỹ sư backend nghiêm túc nào cũng nên internalize.

Lần tới khi bạn gõ redis.get(key), hãy nhớ rằng đằng sau cái operation tưởng chừng đơn giản đó là một hệ sinh thái kỹ thuật được tối ưu đến từng bit, từng CPU cycle, từng nanosecond.

Redis không cố biến phần cứng yếu thành mạnh. Nó thiết kế toàn bộ kiến trúc để không lãng phí từng nanosecond phần cứng đang có.

Và đó chính xác là lý do nó thay đổi cách chúng ta xây dựng hệ thống.


Bài viết được viết bởi một Senior Backend Engineer với hơn 11 năm kinh nghiệm deploy Redis trong production. Nếu bạn có câu hỏi, case study thực tế muốn thảo luận, hoặc phát hiện điểm nào cần bổ sung — comment bên dưới nhé.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí