---
title: "Caching & Redis: Mô hình tư duy 'Tờ giấy nhớ'"
description: "Tại sao Redis làm mọi thứ nhanh hơn? Hướng dẫn chuyên sâu về cache invalidation (vấn đề khó nhất CS), các chiến lược eviction, và Redis data types."
pubDate: "2023-05-25"
lang: "vi"
slug: "caching-redis-mastery"
url: "https://hoangyell.com/vi/caching-redis-mastery/"
author: "HoangYell"
translation_en: "https://hoangyell.com/caching-redis-mastery/"
categories: ["System Design"]
tags: ["system-design", "redis", "performance", "python", "caching"]
heroImage: "https://raw.githubusercontent.com/yellcamap/img/refs/heads/main/photo-1515879218367-8466d910aaa4.webp"
---

"Thêm Redis vào là performance sẽ tăng thôi."

Câu này anh em dev nào cũng hay nói. Nhưng ít người hiểu *tại sao* nó lại hiệu quả - hay khi nào nó thất bại thảm hại.

Cache là tối ưu hiệu năng mạnh nhất bạn có thể áp dụng. Đồng thời cũng là cách dễ nhất để serve **dữ liệu cũ** và để user nhìn thấy thông tin sai trong nhiều giờ.

Đây là **Hướng dẫn chuyên sâu** về Caching. Chúng ta sẽ đi qua Redis data types, 3 chiến lược eviction, và vấn đề khó nhất Khoa học Máy tính, và các pattern phân biệt senior với junior.

***

## Phần 1: Nền tảng (Mô hình tư duy)

### Tờ giấy nhớ vs. Tủ hồ sơ ở tầng hầm

Mỗi khi app cần dữ liệu, nó phải chọn:

*   **Database = Tủ hồ sơ** (ở tầng hầm)
    Chính xác. Có tất cả mọi thứ. Nhưng bạn phải đi bộ xuống tầng hầm, mở ngăn, tìm hồ sơ, rồi leo lên lại. **Chậm**: 5–100ms mỗi lần.

*   **Cache (Redis) = Tờ giấy nhớ dán trên màn hình**
    Bạn đã ghi câu trả lời ở đây trước rồi. Cứ nhìn vào là xong. **Nhanh**: 0.1–1ms.

Mục tiêu: trả lời từ Tờ giấy nhớ bất cứ khi nào có thể. Chỉ xuống Tầng hầm khi tờ giấy không có hoặc thông tin đã lỗi thời.

### Hai câu hỏi cốt lõi

Mọi quyết định về caching đều xoay quanh hai câu hỏi:

1.  **Dữ liệu "tươi" được bao lâu?** (TTL - Time To Live)
2.  **Khi nào cache đầy thì làm gì?** (Eviction Strategy)

***

## Phần 2: Điều tra (Redis Data Types)

Redis không chỉ là key-value store đơn giản. Nó có 5 cấu trúc dữ liệu chính, mỗi cái giải quyết một vấn đề khác nhau.

| Kiểu | Vật thể tương ứng | Use Case |
|:---|:---|:---|
| **String** | Tờ giấy nhớ | Session token, cache đơn giản, bộ đếm. |
| **Hash** | Bảng tính mini | User profile (`user:123 → {name, email, tuổi}`). |
| **List** | Hàng đợi (FIFO) | Bản tin hoạt động (50 hành động gần nhất). Task queue. |
| **Set** | Hộp bi không trùng | Khách truy cập duy nhất hôm nay. Tags của bài viết. |
| **Sorted Set** | Bảng xếp hạng | Top 10 user theo điểm. Cửa sổ rate limiting. |

### TTL (Time To Live)

Mọi cache entry phải có TTL - thời gian hết hạn. Không có TTL là **memory leak**.

```python
import redis

r = redis.Redis()

# Set với TTL 5 phút
r.set("user:123:profile", json.dumps(user_data), ex=300)

# Kiểm tra thời gian còn lại
r.ttl("user:123:profile")  # Trả về giây còn lại, -1 nếu không TTL, -2 nếu key không tồn tại
```

***

## Phần 3: Chẩn đoán (Cache Invalidation - Vấn đề khó nhất)

Phil Karlton có câu nói nổi tiếng:
> "Chỉ có hai thứ khó trong Khoa học Máy tính: cache invalidation và đặt tên."

### 3 Chiến lược Eviction (Khi Cache đầy)

Khi Redis hết RAM, nó phải xóa thứ gì đó. Xóa cái nào?

| Chiến lược | Cái bị xóa | Dùng khi |
|:---|:---|:---|
| **LRU** (Ít được dùng nhất gần đây) | Key lâu không được truy cập nhất. | Tổng quát. Mặc định an toàn. |
| **LFU** (Ít được dùng nhất tổng cộng) | Key được truy cập ít lần nhất. | Khi tần suất truy cập quan trọng. |
| **Random** (allkeys-random) | Một key ngẫu nhiên. | Hầu như không bao giờ dùng. Nguy hiểm. |

**Cấu hình eviction policy**:
```
maxmemory-policy allkeys-lru
```

### 3 Dạng thất bại của Cache

**1. Cache Miss (Bình thường)**
Key không có trong cache → lấy từ DB → lưu vào cache → trả về. Đây là bình thường.

**2. Cache Stampede (Nguy hiểm)**
TTL hết hạn. 10,000 user cùng lúc request cùng một dữ liệu. Tất cả đều miss. Tất cả đều đánh vào DB cùng lúc. **DB sập.**

*Cách sửa*: Dùng **lock** (Redis `SETNX`) khi regenerate cache entry. Chỉ một process regenerate; các process còn lại chờ.

```python
import redis, time

r = redis.Redis()

def get_heavy_data(key: str):
    cached = r.get(key)
    if cached:
        return json.loads(cached)

    # Chỉ MỘT process được regenerate
    lock_key = f"lock:{key}"
    if r.set(lock_key, "1", nx=True, ex=10):  # nx=SETNX (chỉ set nếu chưa tồn tại)
        data = expensive_db_query()
        r.set(key, json.dumps(data), ex=300)
        r.delete(lock_key)
        return data
    else:
        time.sleep(0.1)
        return get_heavy_data(key)  # Thử lại
```

**3. Cache Poisoning (Thảm họa)**
Bạn cache dữ liệu sai. Giờ mọi user đều thấy thông tin sai cho đến khi TTL hết. Không có cách sửa dễ - phải xóa key thủ công.

```bash
# Xóa một key cụ thể
redis-cli DEL "user:123:profile"

# Xóa tất cả key theo pattern (cẩn thận khi dùng production!)
redis-cli --scan --pattern "user:*" | xargs redis-cli DEL
```

***

## Phần 4: Giải pháp (Sách nấu ăn Python)

### 1. Cache-Aside Pattern (Chuẩn mực)

App tự điều khiển cache. Pattern phổ biến nhất.

```markdy
scene width=1150 height=650
theme: light=doodle dark=nebula
layout auto

browser Client "Web / Mobile App"
service API "Backend Service"
cache Redis "Redis Cluster (TTL 300s)"
database DB "PostgreSQL (Master)"

group storage: Redis DB

beat cache_hit "1. Luồng nhanh (Cache Hit)" {
  show $nodes stagger=50ms
  frame Client API Redis zoom=1.12
  Client -> API "GET /user/123"
  API -> Redis "GET user:123"
  API <- Redis "cached JSON (0.5ms)"
  Client <- API "200 OK"
  glow Redis color=#22c55e

}
beat cache_miss "2. Luồng chậm (Cache Miss & Nạp lại)" {
  frame API Redis DB zoom=1.15
  Client -> API "GET /user/456"
  API -> Redis "GET user:456"
  API <- Redis "nil (Miss)"
  API -> DB "SELECT * FROM users"
  API <- DB "row data (15ms)"
  API ~> Redis "SET user:456 ex=300"
  Client <- API "200 OK"
  glow DB color=#f59e0b
}

player:
    playback:
      loop true
    controls:
      speed true
      speeds "0.25"
      fullscreen true
      gif true
      theme true
    interaction:
      zoom true
      pan true
      click_to_play true
      double_click_to_reset true
      keyboard false
    chrome:
      badge true
      progress none
```

```python
def get_user(user_id: int) -> dict:
    cache_key = f"user:{user_id}"
    
    # 1. Thử cache trước
    cached = redis_client.get(cache_key)
    if cached:
        return json.loads(cached)  # Cache HIT
    
    # 2. Cache miss: truy vấn DB
    user = User.objects.get(id=user_id)
    data = serialize(user)
    
    # 3. Lưu vào cache cho lần sau (TTL: 5 phút)
    redis_client.set(cache_key, json.dumps(data), ex=300)
    
    return data  # Cache MISS
```

### 2. Invalidate on Write (Giữ cache trung thực)

Khi dữ liệu thay đổi, xóa cache *ngay lập tức*.

```python
def update_user(user_id: int, new_data: dict):
    # 1. Cập nhật DB
    User.objects.filter(id=user_id).update(**new_data)
    
    # 2. XÓA NGAY cache (đừng chờ TTL!)
    redis_client.delete(f"user:{user_id}")
```

### 3. Rate Limiting (Redis làm bộ đếm)

Giới hạn API: 100 request mỗi user mỗi phút.

```python
def is_rate_limited(user_id: int, limit: int = 100) -> bool:
    key = f"rate:{user_id}:{int(time.time() // 60)}"  # Key đổi mỗi phút
    
    count = redis_client.incr(key)  # Tăng nguyên tử (Atomic increment)
    if count == 1:
        redis_client.expire(key, 60)  # Đặt TTL ngay lần đầu tăng
    
    return count > limit
```

***

## Mô hình tư duy chốt hạ

```
Cache (Redis) -> Tờ giấy nhớ. Nhanh, tạm thời, có thể lỗi thời.
Database      -> Tủ hồ sơ tầng hầm. Chậm, chính xác, vĩnh viễn.

LRU Eviction  -> "Xóa thứ lâu ngày không ai ngó đến."
Cache Stampede -> 10,000 người lật tờ giấy nhớ cùng lúc.
Cache Invalidation -> Vấn đề khó nhất: biết KHI NÀO vứt tờ giấy nhớ đi.
```

**Quy tắc vàng**:
*   Đặt TTL cho **mọi thứ**. Không TTL = Memory leak.
*   Xóa cache khi **ghi**, không chỉ chờ TTL hết hạn.
*   Cache **đúng tầng**: Tránh cache dữ liệu một nửa mà khó biết khi nào vô hiệu hóa.
