Software Architecture

Distributed Caching: Chiến lược Redis/Memcached cho hệ thống triệu request

7/19/2026 · 13p đọc


title: "Distributed Caching: Chiến lược Redis/Memcached cho hệ thống triệu request"
series: "Software Architecture Masterclass"
part: "Phần 3 — Hệ thống Phân tán & Hạ tầng"
order: 23
audience: "Solutions Architect & Technical Lead"
reading_time: "15 phút"
tags: ["distributed-caching", "redis", "memcached", "cache-invalidation", "cache-stampede", "performance"]

Distributed Caching: Chiến lược Redis/Memcached cho hệ thống triệu request

Một buổi sáng thứ Hai, dashboard APM đỏ rực. Database CPU nằm ở mức 95% suốt hai giờ, latency p99 của API list sản phẩm vọt từ 80ms lên 4 giây, và connection pool báo timeout hàng loạt. Root cause: một query đọc dữ liệu danh mục — thứ gần như không đổi trong nhiều giờ — bị gọi 8.000 lần/giây, mỗi lần đều đập thẳng vào MySQL để join ba bảng. Không có gì sai về mặt logic. Query đúng, index đủ, connection pool cấu hình hợp lý. Vấn đề là kiến trúc: hệ thống đang bắt một cỗ máy được thiết kế cho ghi bền vững (durable writes) phục vụ luôn vai trò đọc tốc độ cao, và nó gãy dưới tải.

Phản xạ đầu tiên của hầu hết kỹ sư là "thêm cache vào". Và đúng — caching gần như luôn là câu trả lời đúng ở quy mô lớn. Nhưng đây là chỗ nhiều đội mắc bẫy: họ nghĩ caching là một dòng code redis.set() được thả vào giữa hai bước hiện có, không phải một quyết định kiến trúc có hệ quả riêng. Cache không chỉ là một lớp tăng tốc — nó là một bản sao dữ liệu, và bất kỳ bản sao nào cũng mang theo bài toán muôn thuở của hệ phân tán: làm sao giữ hai (hoặc nhiều) bản sao đồng bộ khi một bên đổi mà bên kia chưa biết. Thêm cache không giải quyết vấn đề tải — nó hoán đổi một vấn đề (database quá tải) lấy một tập vấn đề mới (dữ liệu cũ, cache stampede, cache thành single point of failure). Bài này đi sâu vào việc hoán đổi đó là gì, và làm sao kiểm soát nó thay vì để nó âm thầm phá hệ thống.

The Problem

Đặt lại vấn đề cho chính xác: bạn có một tập dữ liệu được đọc nhiều hơn ghi rất nhiều lần (read-heavy workload) — tỷ lệ đọc/ghi 100:1, 1000:1 là bình thường với dữ liệu như profile người dùng, cấu hình hệ thống, danh mục sản phẩm, kết quả tính toán tốn kém. Database, dù đã có index tối ưu, vẫn phải trả giá cố định cho mỗi query: parse, plan, lock, disk I/O (hoặc ít nhất buffer pool lookup), network round-trip. Khi traffic tăng tuyến tính, tải database cũng tăng tuyến tính — cho đến khi chạm trần I/O hoặc CPU, và latency không tăng tuyến tính nữa mà tăng theo hàm mũ vì contention.

Giải pháp hiển nhiên: đặt một lớp lưu trữ key-value cực nhanh (in-memory, thường là Redis hoặc Memcached) giữa application và database, giữ các kết quả đọc phổ biến ở đó. Đọc từ RAM nhanh hơn đọc từ disk/DB 10-100 lần, và mỗi request phục vụ từ cache là một request database không phải xử lý.

Nhưng ngay khi cache tồn tại, bạn có hai bản sao của cùng một sự thật: bản gốc trong database, và bản sao trong cache. Hai bài toán mới xuất hiện ngay lập tức:

  1. Staleness (dữ liệu cũ): dữ liệu ghi vào DB nhưng cache chưa biết. User đổi email, nhấn F5, vẫn thấy email cũ vì API đang đọc từ cache đã cache trước khi ghi.
  2. Cache stampede (thundering herd): khi một cache key "hot" (được hàng nghìn request/giây truy cập) hết hạn cùng lúc, hàng nghìn request đó đồng loạt miss cache và đập thẳng vào database trong cùng một khoảnh khắc — tái tạo chính xác sự cố mà cache được sinh ra để ngăn chặn, chỉ là dồn vào một đợt sóng thay vì trải đều.

Cả hai đều không phải lỗi lập trình — chúng là hệ quả cấu trúc của việc có hai nguồn sự thật. Không xử lý nghiêm túc, cache sẽ là quả bom hẹn giờ: chạy êm ả hàng tháng trời cho đến ngày traffic tăng đột biến hoặc TTL của một key cực hot hết hạn đúng lúc cao điểm.

The Theory

Ba chiến lược caching cơ bản

Cache-aside (lazy loading): Application chịu trách nhiệm đọc/ghi cache một cách tường minh. Khi đọc: kiểm tra cache trước, nếu miss thì đọc DB rồi ghi ngược vào cache (backfill), nếu hit thì trả thẳng. Khi ghi: ghi vào DB, sau đó invalidate (xóa) key tương ứng trong cache — không update cache trực tiếp, để lần đọc tiếp theo tự backfill dữ liệu mới nhất. Đây là chiến lược phổ biến nhất vì đơn giản, cache chỉ chứa dữ liệu thực sự được yêu cầu (không lãng phí bộ nhớ cho dữ liệu không ai đọc), và cache có thể chết/khởi động lại mà không mất dữ liệu (DB luôn là nguồn sự thật).

Write-through: Mọi lần ghi đi qua cache trước, cache ghi đồng bộ xuống DB rồi mới trả kết quả. Cache luôn đồng bộ với DB tại thời điểm ghi. Đánh đổi: mọi write chịu thêm latency của cache, và cache chứa cả dữ liệu chưa từng được đọc (kém hiệu quả bộ nhớ hơn cache-aside).

Write-behind (write-back): Ghi vào cache trước, trả kết quả ngay, rồi cache bất đồng bộ flush xuống DB sau (theo batch hoặc theo chu kỳ). Latency ghi cực thấp, giảm tải ghi DB đáng kể vì có thể gộp nhiều write. Đánh đổi: rủi ro mất dữ liệu nếu cache crash trước khi flush — đây là chiến lược mạnh nhưng nguy hiểm, chỉ dùng khi chấp nhận được một cửa sổ mất dữ liệu nhỏ (ví dụ: đếm view, log phân tích) chứ không phải cho dữ liệu tài chính.

Chiến lược invalidation

Có ba cách chính để "biết" khi nào dữ liệu cache đã cũ:

  • TTL (Time-To-Live): mỗi key có thời hạn sống cố định, hết hạn tự động bị xóa. Đơn giản, không cần logic invalidation tường minh, nhưng chấp nhận một cửa sổ staleness bằng đúng TTL.
  • Invalidation tường minh (explicit): khi ghi DB thành công, chủ động xóa/update key cache liên quan. Chính xác hơn TTL (staleness gần như bằng 0) nhưng phức tạp hơn — phải tìm đúng mọi key liên quan (một record có thể xuất hiện trong nhiều cache key khác nhau: theo ID, theo danh sách, theo filter...), và dễ bỏ sót một đường ghi nào đó (qua batch job, qua service khác, qua thao tác DB trực tiếp).
  • Event-driven invalidation: DB thay đổi phát ra event (qua CDC — Change Data Capture, hoặc message queue), một consumer lắng nghe và invalidate cache tương ứng. Tách rời logic invalidation khỏi code path ghi, bắt được cả những thay đổi không qua application (migration, sửa tay), nhưng thêm độ trễ (eventual) và thêm một thành phần hạ tầng phải vận hành.

Không chiến lược nào miễn phí. Thực tế production gần như luôn là tổ hợp: invalidation tường minh cho đường ghi chính, cộng với TTL như lưới an toàn cho mọi trường hợp bị bỏ sót.

Topology cache phân tán

Ở quy mô một server, cache đơn giản là một tiến trình local. Ở quy mô phân tán, có ba mô hình phổ biến:

  • Cache tập trung (centralized): một cụm Redis/Memcached dùng chung cho toàn bộ application server. Nhất quán cao (mọi instance thấy cùng một cache), nhưng là điểm chịu tải chung và điểm lỗi chung.
  • Cache phân mảnh (sharded/clustered): dữ liệu cache được chia theo key hash ra nhiều node (Redis Cluster, Memcached với client-side hashing). Mở rộng theo chiều ngang, chịu tải cao hơn, nhưng thêm độ phức tạp về resharding khi thêm/bớt node và về việc một số thao tác multi-key trở nên khó hơn.
  • Cache cục bộ + phân tán (multi-tier / L1-L2): mỗi application instance giữ một cache in-process nhỏ (L1, cực nhanh nhưng không nhất quán giữa các instance), phía sau là cache phân tán dùng chung (L2). Giảm tải lên tầng L2 và giảm network round-trip, nhưng L1 làm tăng nguy cơ staleness vì mỗi instance có thể thấy dữ liệu khác nhau trong một khoảng thời gian ngắn.

The Trade-off

Đây là phần phải nói thẳng, không tô hồng.

Được: Với đúng tập dữ liệu read-heavy, cache giảm p99 latency từ hàng trăm mili-giây xuống một vài mili-giây (đọc RAM so với đọc DB qua network + disk). Nó giảm tải database đọc tới 90-99% với các key đủ hot — nghĩa là một database instance vừa phải có thể phục vụ traffic mà nếu không có cache sẽ cần horizontal scale DB tốn kém (và scale DB đọc luôn khó hơn scale cache). Chi phí hạ tầng cho một cụm Redis nhỏ thường rẻ hơn nhiều so với việc thêm read replica hoặc nâng cấp instance DB.

Mất — cụ thể, không mơ hồ:

  1. Rủi ro nhất quán trở thành vĩnh viễn, không phải tạm thời. Bất kỳ chiến lược invalidation nào cũng có một cửa sổ staleness — với TTL là cả khoảng TTL, với invalidation tường minh là khoảng thời gian giữa lúc DB commit và lúc lệnh xóa cache tới nơi (có thể fail âm thầm nếu lệnh xóa cache đó bị mất — network blip, cache node restart). Nghĩa là: hệ thống của bạn từ "luôn đọc dữ liệu mới nhất" chuyển sang "đọc dữ liệu mới nhất trong hầu hết trường hợp, trừ một cửa sổ xác định". Với dữ liệu hiển thị (tên sản phẩm, avatar) chấp nhận được. Với dữ liệu quyết định nghiệp vụ (số dư, trạng thái đơn hàng, quyền truy cập) đây là rủi ro thật — user thấy quyền cũ, thấy số dư cũ, và ra quyết định sai dựa trên đó.

  2. Một chế độ lỗi hoàn toàn mới xuất hiện: cache stampede. Trước khi có cache, hệ thống chỉ có một chế độ tải: tải tăng dần, database chịu dần. Sau khi có cache, có thêm một chế độ: cache miss đồng loạt. Khi một key cực hot hết hạn (hoặc cache node restart, mất toàn bộ dữ liệu), hàng nghìn request đang chờ key đó đồng thời miss và đổ xô vào DB cùng một khoảnh khắc — tải tức thời có thể vượt xa tải "không có cache" thông thường, vì bình thường traffic được trải đều theo thời gian, giờ dồn vào một điểm. Đây chính xác là kịch bản khiến cache — thứ được thêm vào để bảo vệ DB — trở thành nguyên nhân gây sập DB.

  3. Cache trở thành một thành phần hạ tầng phải vận hành, với chi phí vận hành riêng. Cụm Redis cần được monitor (memory eviction rate, hit ratio, replication lag nếu dùng Redis Sentinel/Cluster), cần capacity planning riêng, và khi nó down hoặc chậm, toàn bộ hệ thống chịu ảnh hưởng — kể cả khi DB hoàn toàn khỏe mạnh. Bạn không loại bỏ một điểm lỗi, bạn thêm một điểm lỗi mới song song.

  4. Debugging khó hơn. Khi có bug "user thấy dữ liệu sai", câu hỏi đầu tiên không còn là "query có đúng không" mà là "dữ liệu đến từ cache hay DB, cache được invalidate lúc nào, có race condition giữa write và invalidate không". Số lượng giả thuyết cần loại trừ tăng lên, và một số lớp bug (race condition giữa hai request ghi cùng lúc, invalidate bị mất) chỉ xuất hiện dưới tải cao — nghĩa là khó tái hiện ở môi trường dev/staging.

Nói cách khác: caching là một cuộc trao đổi latency/tải-đọc lấy độ phức tạp vận hành và rủi ro nhất quán. Nó gần như luôn đáng giá ở quy mô lớn — nhưng "đáng giá" không có nghĩa "miễn phí", và đội nào coi cache là chi tiết triển khai nhỏ thay vì một quyết định kiến trúc sẽ trả giá đúng vào ngày traffic cao nhất.

The Architecture Diagram

Luồng cache-aside pattern — chiến lược phổ biến nhất, với đường invalidation khi ghi:

flowchart TD
    Client["Client / API Request"]

    subgraph Read Path
        A["1. Đọc: GET key"] --> B{"Cache hit?"}
        B -- "Hit" --> C["Trả dữ liệu từ Cache\n(vài ms)"]
        B -- "Miss" --> D["2. Đọc từ Database"]
        D --> E["3. Backfill: SET key\nvào Cache kèm TTL"]
        E --> F["Trả dữ liệu"]
    end

    subgraph Write Path
        G["1. Ghi: UPDATE record"] --> H["Database commit"]
        H --> I["2. Invalidate:\nDEL key trong Cache"]
        I --> J["Trả kết quả ghi thành công"]
    end

    Client --> A
    Client --> G

    Cache[("Redis / Memcached\nCluster")]
    DB[("Primary Database")]

    A -.-> Cache
    E -.-> Cache
    I -.-> Cache
    D -.-> DB
    H -.-> DB

    style Cache fill:#f9a825,stroke:#333,color:#000
    style DB fill:#1565c0,stroke:#333,color:#fff

Lưu ý luồng: read path không bao giờ ghi trực tiếp dữ liệu "update" vào cache — nó chỉ backfill sau một lần đọc thực sự từ DB. Write path không update cache, chỉ xóa (invalidate) — nguyên tắc này tránh một lớp bug race condition phổ biến (hai write đồng thời ghi cache theo thứ tự khác thứ tự DB commit, để lại cache chứa giá trị cũ hơn giá trị trong DB).

Implementation Guardrails

  1. Luôn đặt TTL, kể cả khi đã có invalidation tường minh. TTL không phải là chiến lược invalidation chính — nó là lưới an toàn cho những đường ghi bạn không lường trước (batch job, migration, service khác ghi thẳng DB, lệnh invalidate bị mất do network). Một key không bao giờ hết hạn là một cam kết ngầm rằng invalidation của bạn hoàn hảo 100% — không có hệ thống nào đạt được điều đó lâu dài. Chọn TTL theo mức độ chấp nhận staleness của từng loại dữ liệu, không dùng một giá trị mặc định cho mọi key.

  2. Chống cache stampede bằng locking hoặc jitter cho mọi key có traffic cao. Hai kỹ thuật chính, dùng riêng hoặc kết hợp: (a) Distributed lock khi rebuild — khi một key miss, chỉ một request được phép đi query DB và backfill cache (dùng SETNX hoặc lock tương đương), các request khác chờ ngắn rồi đọc lại cache thay vì tất cả cùng đập vào DB. (b) TTL jitter — thay vì đặt TTL cố định (ví dụ đúng 300 giây cho mọi key), thêm một khoảng random nhỏ (ví dụ 300 ± 30 giây) để các key không đồng loạt hết hạn cùng một thời điểm nếu chúng được set gần như cùng lúc (ví dụ sau một lần cache warm-up hàng loạt).

  3. Không bao giờ để cache là nguồn sự thật duy nhất cho dữ liệu có thể tái tạo từ DB. Cache phải luôn có thể bị xóa toàn bộ (flush) mà không mất dữ liệu nghiệp vụ — nếu một tính năng chỉ hoạt động đúng khi cache còn dữ liệu, đó là dấu hiệu kiến trúc sai (đang dùng cache như primary store). Ngoại lệ hợp lệ duy nhất là khi bạn cố ý chọn write-behind và chấp nhận rủi ro mất dữ liệu trong cửa sổ chưa flush — quyết định đó phải được ghi rõ, không phải xảy ra ngẫu nhiên.

  4. Đo hit ratio và theo dõi nó như một chỉ số vận hành cấp một, không phải chỉ số phụ. Hit ratio thấp bất thường (ví dụ dưới 80% cho key được thiết kế để cache) là tín hiệu sớm của TTL sai, cache size không đủ (eviction quá sớm), hoặc key pattern sai (cache miss vì key không khớp giữa lần ghi và lần đọc). Đây là chỉ số nên có alert riêng, vì hit ratio giảm dần thường là nguyên nhân gốc của một sự cố database bị đè tải xảy ra vài giờ sau đó.

Architect's Verdict

Caching không còn là tùy chọn ở quy mô triệu request — nó là điều kiện tồn tại. Nhưng đối xử với cache invalidation bằng sự nghiêm túc của "một trong hai bài toán khó nhất khoa học máy tính" không phải là câu nói đùa để trích dẫn cho vui — nó là lời cảnh báo thật, vì phần lớn sự cố cache nghiêm trọng nhất không đến từ việc thiếu cache, mà từ việc có cache nhưng invalidation sai một góc nhỏ mà không ai để ý cho đến khi traffic tăng đúng vào giờ cao điểm. Thiết kế cache-aside với TTL làm lưới an toàn, chống stampede bằng lock hoặc jitter cho mọi key hot, và luôn tự hỏi "nếu tôi xóa sạch cache này ngay bây giờ, hệ thống có còn đúng không" — nếu câu trả lời là không, bạn đang xây trên cát.

🔗 Bài viết liên quan: Data Consistency · Database Sharding & Partitioning


Bài trước: Data Consistency · Bài tiếp theo: Database Sharding & Partitioning

Distributed Caching: Chiến lược Redis/Memcached cho hệ thống triệu request