Distributed Caching: Thiết kế hệ thống Cache phân tán từ A đến Z

Posted on: 4/21/2026 2:12:49 AM

<1msLatency đọc cache local
1-5msLatency đọc cache phân tán
90%+Cache hit rate mục tiêu
10-100xGiảm tải cho database

Cache là một trong những kỹ thuật quan trọng nhất trong System Design. Tuy nhiên, khoảng cách giữa "thêm Redis vào giữa app và database" với "thiết kế một hệ thống cache phân tán thực sự đáng tin cậy" là rất lớn. Bài viết này đi sâu vào kiến trúc cache phân tán từ góc độ System Design: các pattern cơ bản, chiến lược invalidation, cách phân phối dữ liệu qua consistent hashing, xử lý cache stampede, và thiết kế multi-layer caching cho hệ thống production.

1. Tại sao cần Cache phân tán?

Trước khi đi vào các pattern, hãy hiểu rõ vấn đề mà cache phân tán giải quyết:

  • Local cache (in-process, ví dụ MemoryCache trong .NET) có latency sub-microsecond, nhưng mỗi instance ứng dụng giữ một bản sao riêng — dẫn đến inconsistency khi data thay đổi
  • Distributed cache (Redis, Memcached) thêm 1-5ms latency do network, nhưng cung cấp một single view nhất quán cho tất cả instances
  • Database query trung bình mất 10-100ms, nên cache hit ở 1-5ms vẫn nhanh hơn 10-100 lần
graph LR
    subgraph Local Cache
    A1[App Instance 1] --> LC1[MemoryCache A]
    A2[App Instance 2] --> LC2[MemoryCache B]
    A3[App Instance 3] --> LC3[MemoryCache C]
    LC1 -.->|Khác nhau| LC2
    LC2 -.->|Khác nhau| LC3
    end

    subgraph Distributed Cache
    B1[App Instance 1] --> DC[Redis Cluster]
    B2[App Instance 2] --> DC
    B3[App Instance 3] --> DC
    end

    style A1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style A2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style A3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style LC1 fill:#ff9800,stroke:#fff,color:#fff
    style LC2 fill:#ff9800,stroke:#fff,color:#fff
    style LC3 fill:#ff9800,stroke:#fff,color:#fff
    style B1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style B2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style B3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style DC fill:#4CAF50,stroke:#fff,color:#fff

Local cache: mỗi instance có bản sao riêng (inconsistent) — Distributed cache: single source of truth

2. Bốn Caching Pattern cốt lõi

Mọi hệ thống cache đều xây dựng trên một trong bốn pattern sau. Mỗi pattern có trade-off riêng giữa consistency, latency, và complexity.

2.1 Cache-Aside (Lazy Loading)

Đây là pattern phổ biến nhất. Application kiểm soát hoàn toàn: khi đọc, kiểm tra cache trước; nếu miss, lấy từ database rồi ghi vào cache. Khi ghi, ghi thẳng vào database rồi invalidate cache.

sequenceDiagram
    participant App
    participant Cache
    participant DB

    App->>Cache: GET user:123
    Cache-->>App: MISS
    App->>DB: SELECT * FROM users WHERE id=123
    DB-->>App: {name: "Anh Tú", ...}
    App->>Cache: SET user:123 (TTL 5min)
    App-->>App: Return data

    Note over App,DB: Lần đọc tiếp theo
    App->>Cache: GET user:123
    Cache-->>App: HIT → {name: "Anh Tú", ...}

Cache-Aside: Application kiểm soát read/write path

// Cache-Aside trong .NET với IDistributedCache
public async Task<UserProfile?> GetUserProfile(int userId)
{
    var cacheKey = $"user:{userId}";

    // 1. Thử đọc từ cache
    var cached = await _cache.GetStringAsync(cacheKey);
    if (cached != null)
        return JsonSerializer.Deserialize<UserProfile>(cached);

    // 2. Cache miss → đọc từ DB
    var user = await _dbContext.Users.FindAsync(userId);
    if (user == null) return null;

    // 3. Ghi vào cache với TTL
    await _cache.SetStringAsync(cacheKey,
        JsonSerializer.Serialize(user),
        new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
        });

    return user;
}

Ưu và nhược điểm

Ưu: Chỉ cache dữ liệu thực sự được đọc (tránh lãng phí memory), application kiểm soát hoàn toàn logic, dễ implement. Nhược: Cache miss đầu tiên luôn chậm (cold start), có staleness window giữa lúc DB update và cache invalidation.

2.2 Read-Through

Tương tự Cache-Aside nhưng cache layer tự chịu trách nhiệm fetch từ database khi miss. Application chỉ nói chuyện với cache, không biết database tồn tại.

// Read-Through abstraction
public class ReadThroughCache<T>
{
    private readonly IDistributedCache _cache;
    private readonly Func<string, Task<T?>> _loader;

    public async Task<T?> Get(string key)
    {
        var cached = await _cache.GetStringAsync(key);
        if (cached != null)
            return JsonSerializer.Deserialize<T>(cached);

        // Cache tự load từ data source
        var value = await _loader(key);
        if (value != null)
        {
            await _cache.SetStringAsync(key,
                JsonSerializer.Serialize(value),
                new DistributedCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
                });
        }
        return value;
    }
}

Khác biệt chính: Code nghiệp vụ không cần biết về cache miss/hit — cache layer đóng gói toàn bộ. Phù hợp khi bạn muốn tách biệt cache logic khỏi business logic.

2.3 Write-Through

Mọi write đều đi qua cache trước khi xuống database. Cache luôn chứa dữ liệu mới nhất, nhưng đổi lại write latency tăng (vì phải ghi cả hai nơi synchronously).

sequenceDiagram
    participant App
    participant Cache
    participant DB

    App->>Cache: SET user:123 = {updated data}
    Cache->>DB: UPDATE users SET ... WHERE id=123
    DB-->>Cache: OK
    Cache-->>App: OK

    Note over App,DB: Read luôn consistent
    App->>Cache: GET user:123
    Cache-->>App: HIT → {updated data}

Write-Through: Cache làm proxy cho mọi write, đảm bảo consistency

Khi nào dùng: Khi consistency là ưu tiên số 1, ví dụ session management, user profile hay shopping cart. Không phù hợp cho workload write-heavy vì mọi write đều bị thêm latency.

2.4 Write-Behind (Write-Back)

Write chỉ đi vào cache, database được cập nhật bất đồng bộ sau đó (batch, periodic flush). Đây là pattern nhanh nhất cho write, nhưng có rủi ro mất dữ liệu nếu cache node chết trước khi flush.

sequenceDiagram
    participant App
    participant Cache
    participant Queue
    participant DB

    App->>Cache: SET counter:page_views = 15042
    Cache-->>App: OK (ngay lập tức)

    Note over Cache,DB: Async flush mỗi 10 giây
    Cache->>Queue: Batch updates
    Queue->>DB: BULK UPDATE counters
    DB-->>Queue: OK

Write-Behind: Ghi cache trước, flush DB sau — nhanh nhưng rủi ro mất data

Khi nào dùng: Page view counters, analytics events, log aggregation — các workload write-heavy mà mất vài giây dữ liệu cuối là chấp nhận được.

3. So sánh 4 Pattern

PatternRead LatencyWrite LatencyConsistencyComplexity
Cache-AsideMiss chậm, hit nhanhThấp (ghi DB trực tiếp)Eventual (staleness window)Thấp
Read-ThroughMiss chậm, hit nhanhThấpEventualTrung bình
Write-ThroughLuôn nhanh (cache hit)Cao (ghi 2 nơi sync)StrongTrung bình
Write-BehindLuôn nhanhRất thấp (ghi cache thôi)Weak (có thể mất data)Cao

Thực tế trong production

Hầu hết các hệ thống lớn kết hợp nhiều pattern. Ví dụ: Cache-Aside cho user profile (read-heavy), Write-Behind cho analytics counters (write-heavy), Write-Through cho shopping cart (cần consistency).

4. Cache Invalidation — Vấn đề khó nhất

"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton

Khi database thay đổi, cache cần biết để không phục vụ dữ liệu cũ. Có ba chiến lược chính:

4.1 TTL-Based (Time To Live)

Mỗi cache entry tự hết hạn sau một khoảng thời gian cố định. Đơn giản nhất nhưng tạo ra staleness window dài nhất.

// TTL cố định
await _cache.SetStringAsync("product:456", data,
    new DistributedCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
    });

// TTL trượt (sliding) — reset mỗi khi được đọc
await _cache.SetStringAsync("session:abc", sessionData,
    new DistributedCacheEntryOptions
    {
        SlidingExpiration = TimeSpan.FromMinutes(30)
    });

Quy tắc chọn TTL: TTL ngắn (30s-2min) cho dữ liệu thay đổi thường xuyên (stock price, live scores). TTL trung bình (5-15min) cho dữ liệu ít thay đổi (product catalog, user profile). TTL dài (1-24h) cho dữ liệu gần như tĩnh (config, translations).

4.2 Event-Based Invalidation

Khi dữ liệu trong database thay đổi, một event được phát ra để invalidate cache entry tương ứng. Đây là cách chính xác nhất nhưng phức tạp hơn.

graph LR
    A[App: UPDATE user] --> B[Database]
    B --> C[CDC / Change Event]
    C --> D[Message Bus]
    D --> E[Cache Invalidator]
    E --> F[Redis: DEL user:123]

    style A fill:#e94560,stroke:#fff,color:#fff
    style B fill:#2c3e50,stroke:#fff,color:#fff
    style C fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style D fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style E fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style F fill:#4CAF50,stroke:#fff,color:#fff

Event-based invalidation qua Change Data Capture (CDC)

// Event-based invalidation trong .NET
public class UserUpdatedHandler : INotificationHandler<UserUpdatedEvent>
{
    private readonly IDistributedCache _cache;

    public async Task Handle(UserUpdatedEvent notification,
        CancellationToken cancellationToken)
    {
        // Invalidate mọi cache key liên quan
        await _cache.RemoveAsync($"user:{notification.UserId}");
        await _cache.RemoveAsync($"user-profile:{notification.UserId}");
        await _cache.RemoveAsync($"user-permissions:{notification.UserId}");
    }
}

4.3 Version-Based Invalidation

Thay vì xóa cache entry, bạn gắn version number vào cache key. Khi data thay đổi, increment version → client tự động miss trên key cũ.

// Version-based: không cần invalidate, chỉ cần đổi version
var version = await _cache.GetStringAsync("product:456:version") ?? "0";
var cacheKey = $"product:456:v{version}";

var cached = await _cache.GetStringAsync(cacheKey);
if (cached != null) return cached;

// Khi update product
var newVersion = int.Parse(version) + 1;
await _cache.SetStringAsync("product:456:version", newVersion.ToString());
// Key cũ tự expire theo TTL, không cần DEL

Ưu điểm: Old/new versions có thể tồn tại song song, không bị race condition khi deploy nhiều instance. Nhược điểm: cần thêm 1 lookup cho version key.

5. Consistent Hashing — Phân phối dữ liệu trên cluster

Khi cache cluster có nhiều node, cần quyết định key nào nằm ở node nào. Consistent hashing giải quyết vấn đề này với đặc tính quan trọng: khi thêm/bớt node, chỉ cần di chuyển ~1/N dữ liệu (N = số node), thay vì phải rehash toàn bộ.

graph TD
    subgraph Hash Ring
    N1[Node A
0°-120°] N2[Node B
120°-240°] N3[Node C
240°-360°] end K1[Key user:1
hash=85°] --> N1 K2[Key user:2
hash=190°] --> N2 K3[Key user:3
hash=310°] --> N3 K4[Key user:4
hash=45°] --> N1 style N1 fill:#e94560,stroke:#fff,color:#fff style N2 fill:#2c3e50,stroke:#fff,color:#fff style N3 fill:#4CAF50,stroke:#fff,color:#fff style K1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style K2 fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50 style K3 fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50 style K4 fill:#f8f9fa,stroke:#e94560,color:#2c3e50

Consistent hashing ring: mỗi key được map đến node gần nhất theo chiều kim đồng hồ

Trong thực tế, Redis Cluster sử dụng hash slots (16,384 slots) thay vì consistent hashing ring thuần túy, nhưng nguyên lý tương tự: mỗi node chịu trách nhiệm một dải slots, và khi scale thì chỉ migrate một phần slots.

Virtual nodes giải quyết vấn đề phân bổ không đều

Nếu chỉ dùng 3 node trên hash ring, phân bổ có thể rất lệch (node A nhận 60% keys, node C chỉ nhận 10%). Giải pháp: mỗi node vật lý được biểu diễn bằng 100-200 virtual nodes phân tán đều trên ring. Kết quả: phân bổ gần như đều nhau, sai số dưới 5%.

6. Xử lý Cache Stampede (Thundering Herd)

Cache stampede xảy ra khi một cache entry phổ biến hết hạn, hàng trăm request đồng thời miss cache và đổ dồn vào database cùng lúc. Đây là một trong những lỗi phổ biến nhất khi deploy cache mà không có guard.

sequenceDiagram
    participant R1 as Request 1
    participant R2 as Request 2
    participant R3 as Request N...
    participant Cache
    participant DB

    Note over Cache: Key "hot:product" hết hạn!

    R1->>Cache: GET hot:product
    Cache-->>R1: MISS
    R2->>Cache: GET hot:product
    Cache-->>R2: MISS
    R3->>Cache: GET hot:product
    Cache-->>R3: MISS

    R1->>DB: SELECT * FROM products...
    R2->>DB: SELECT * FROM products...
    R3->>DB: SELECT * FROM products...

    Note over DB: 💥 Database overload!

Cache stampede: hàng trăm requests cùng miss → database quá tải

Ba kỹ thuật phòng chống:

6.1 Mutex Lock (Singleflight)

Chỉ cho phép một request rebuild cache, các request khác đợi kết quả:

public async Task<T?> GetWithLock<T>(string key,
    Func<Task<T?>> factory, TimeSpan ttl)
{
    var cached = await _cache.GetStringAsync(key);
    if (cached != null)
        return JsonSerializer.Deserialize<T>(cached);

    var lockKey = $"lock:{key}";
    var lockAcquired = await _redis.StringSetAsync(
        lockKey, "1", TimeSpan.FromSeconds(10), When.NotExists);

    if (lockAcquired)
    {
        try
        {
            // Chỉ 1 request được rebuild
            var value = await factory();
            if (value != null)
            {
                await _cache.SetStringAsync(key,
                    JsonSerializer.Serialize(value),
                    new DistributedCacheEntryOptions
                    {
                        AbsoluteExpirationRelativeToNow = ttl
                    });
            }
            return value;
        }
        finally
        {
            await _redis.KeyDeleteAsync(lockKey);
        }
    }

    // Các request khác: đợi và retry
    await Task.Delay(100);
    return await GetWithLock(key, factory, ttl);
}

6.2 Randomized TTL (TTL Jitter)

Thêm random offset vào TTL để các key không hết hạn cùng lúc:

// Thay vì TTL cố định 5 phút cho mọi key
var baseTtl = TimeSpan.FromMinutes(5);
var jitter = TimeSpan.FromSeconds(Random.Shared.Next(0, 60));
var ttl = baseTtl + jitter; // 5:00 đến 6:00 phút

6.3 Early Recompute (Probabilistic)

Trước khi key hết hạn, một request ngẫu nhiên sẽ proactively rebuild cache. Xác suất rebuild tăng dần khi TTL gần hết:

// Kiểm tra xem có nên rebuild sớm không
var remainingTtl = await _redis.KeyTimeToLiveAsync(key);
if (remainingTtl.HasValue)
{
    var totalTtl = TimeSpan.FromMinutes(5);
    var ratio = remainingTtl.Value / totalTtl;
    // Khi TTL còn dưới 20%, xác suất rebuild = 30%
    if (ratio < 0.2 && Random.Shared.NextDouble() < 0.3)
    {
        _ = Task.Run(() => RebuildCacheAsync(key)); // Fire-and-forget
    }
}

7. Multi-Layer Caching — Kiến trúc thực tế

Hệ thống production thường sử dụng nhiều tầng cache, mỗi tầng có đặc tính khác nhau:

graph TD
    U[User Request] --> CDN[L1: CDN / Edge Cache
Cloudflare, CloudFront] CDN -->|MISS| LB[Load Balancer] LB --> APP[Application Server] APP --> L2[L2: In-Process Cache
MemoryCache, ConcurrentDict] L2 -->|MISS| L3[L3: Distributed Cache
Redis Cluster] L3 -->|MISS| DB[Database
SQL Server, PostgreSQL] DB -->|Populate| L3 L3 -->|Populate| L2 APP -->|Set Cache-Control| CDN style U fill:#e94560,stroke:#fff,color:#fff style CDN fill:#2c3e50,stroke:#fff,color:#fff style LB fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style APP fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style L2 fill:#4CAF50,stroke:#fff,color:#fff style L3 fill:#4CAF50,stroke:#fff,color:#fff style DB fill:#2c3e50,stroke:#fff,color:#fff

Multi-layer caching: CDN → In-Process → Distributed Cache → Database

LayerLatencyCapacityConsistencyScope
L1: CDN/Edge<10ms (từ edge)Rất lớn (distributed)Eventual (TTL)Public, static content
L2: In-Process<0.1msNhỏ (RAM của instance)Inconsistent across instancesHot data, per-instance
L3: Distributed1-5msLớn (cluster RAM)Consistent (single source)Shared state
Database10-100msRất lớn (disk)Strong (ACID)Source of truth
// Multi-layer cache trong .NET
public class MultiLayerCache<T>
{
    private readonly IMemoryCache _l2;
    private readonly IDistributedCache _l3;

    public async Task<T?> Get(string key, Func<Task<T?>> factory)
    {
        // L2: In-process cache (sub-microsecond)
        if (_l2.TryGetValue(key, out T? l2Value))
            return l2Value;

        // L3: Distributed cache (1-5ms)
        var l3Data = await _l3.GetStringAsync(key);
        if (l3Data != null)
        {
            var value = JsonSerializer.Deserialize<T>(l3Data);
            _l2.Set(key, value, TimeSpan.FromMinutes(1));
            return value;
        }

        // Database (10-100ms)
        var dbValue = await factory();
        if (dbValue != null)
        {
            var json = JsonSerializer.Serialize(dbValue);
            await _l3.SetStringAsync(key, json,
                new DistributedCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
                });
            _l2.Set(key, dbValue, TimeSpan.FromMinutes(1));
        }
        return dbValue;
    }
}

L2 cache: tại sao TTL phải ngắn hơn L3?

Khi data thay đổi, bạn có thể invalidate L3 (Redis) qua event. Nhưng L2 (in-process) trên mỗi instance không nhận được event đó — phải đợi TTL hết hạn. Vì vậy, L2 TTL nên bằng 10-20% L3 TTL để giảm staleness window. Ví dụ: L3 = 10 phút → L2 = 1-2 phút.

8. Redis vs Memcached — Chọn công cụ phù hợp

Hai giải pháp cache phân tán phổ biến nhất, mỗi cái tối ưu cho use case khác:

Tiêu chíRedisMemcached
Data structuresString, Hash, List, Set, Sorted Set, Stream, JSON, Vector...Chỉ String (key-value)
PersistenceRDB + AOF (tuỳ chọn)Không
ReplicationMaster-Replica, Redis ClusterKhông (client-side sharding)
Pub/SubKhông
Lua scriptingKhông
Memory efficiencyOverhead do data structuresTốt hơn cho pure key-value
Multi-threadSingle-thread (I/O threads từ v6)Multi-thread native
Max value size512MB1MB (default)

Chọn Redis khi

  • Cần data structures phong phú (sorted sets cho leaderboard, streams cho event log)
  • Cần persistence (khôi phục cache sau restart)
  • Cần pub/sub cho cache invalidation across services
  • Cần atomic operations phức tạp (Lua scripts)
  • Cache doubles as session store, rate limiter, queue

Chọn Memcached khi

  • Chỉ cần pure key-value cache, không cần data structures
  • Cần memory efficiency tối đa cho large working set
  • Workload đơn giản: GET/SET/DELETE
  • Muốn multi-thread native (Memcached xử lý 200K+ ops/sec trên multi-core tốt hơn)

9. Eviction Policies — Khi cache đầy

Khi cache đạt giới hạn memory, cần quyết định xóa entry nào. Đây là các eviction policy phổ biến:

PolicyLogicPhù hợp khi
LRU (Least Recently Used)Xóa entry không được truy cập lâu nhấtWorkload có temporal locality — recent access dự đoán future access
LFU (Least Frequently Used)Xóa entry ít được truy cập nhấtWorkload có stable hot set — một số key luôn "nóng"
RandomXóa ngẫu nhiênKhông có access pattern rõ ràng, đơn giản nhất
TTL-basedXóa entry hết hạn trướcKhi mọi entry đều có TTL và cần ưu tiên fresh data

Redis mặc định dùng gì?

Redis sử dụng approximated LRU (lấy mẫu 5 keys ngẫu nhiên, xóa key cũ nhất). Từ Redis 4.0, bạn có thể bật maxmemory-policy allkeys-lfu cho LFU. Trong thực tế, LFU thường cho hit rate cao hơn LRU 5-10% trên workload có hot keys rõ ràng.

10. Monitoring — Đo lường hiệu quả cache

Cache không tự chứng minh giá trị — bạn cần monitor các metrics sau:

Hit Rate

Tỷ lệ request được phục vụ từ cache. Mục tiêu: >90% cho hầu hết workload. Nếu dưới 80%, kiểm tra: TTL quá ngắn? Working set lớn hơn cache capacity? Key pattern quá unique (mỗi user 1 key riêng)?

hit_rate = cache_hits / (cache_hits + cache_misses)

Eviction Rate

Số entries bị xóa do đầy memory/giây. Eviction rate cao = cache quá nhỏ hoặc TTL quá dài. Giải pháp: tăng memory, giảm TTL, hoặc chỉ cache hot data.

Latency (P50/P99)

Cache read P50 nên <1ms (local) hoặc <5ms (distributed). P99 >10ms là dấu hiệu network issue, cluster overload, hoặc key quá lớn.

Memory Usage

Redis INFO memory cho biết used_memory vs maxmemory. Giữ usage dưới 80% maxmemory để có buffer cho peak traffic.

Tổng kết

Thiết kế hệ thống cache phân tán không chỉ là "thêm Redis" — mà là một tập hợp các quyết định kiến trúc: chọn đúng pattern (Cache-Aside, Write-Through, Write-Behind), xây dựng chiến lược invalidation phù hợp (TTL, event-based, version-based), phân phối dữ liệu hiệu quả qua consistent hashing, phòng chống cache stampede, và thiết lập multi-layer caching cho từng tầng của hệ thống.

Không có giải pháp "one size fits all". Mỗi hệ thống có workload pattern riêng — read-heavy hay write-heavy, cần strong consistency hay eventual consistency, working set lớn hay nhỏ. Hiểu rõ các trade-off sẽ giúp bạn đưa ra quyết định thiết kế phù hợp nhất.

Nguồn tham khảo: