Danh sách bài viết

Bài 15: Stale-While-Revalidate & Probabilistic Early Expiration

Đây là bài nâng cao, dựng tiếp trên kỹ thuật chống cache stampede ở bài 14. Khi một hot key có chi phí rebuild lớn, ta thường muốn ba thứ cùng lúc: phục vụ nhanh (user không phải chờ rebuild), giữ dữ liệu tương đối tươi, và không để hàng loạt request cùng đập DB khi key hết hạn. Stale-While-Revalidate (SWR) giải bài này bằng cách trả ngay dữ liệu cũ rồi refresh ở background, dùng hai mốc soft TTL và hard expire. Probabilistic Early Expiration (XFetch) còn refresh sớm theo xác suất tăng dần khi gần hết hạn để làm trơn tải. Bài đi qua cơ chế chi tiết, công thức XFetch, code ioredis và redis-py, bảng so sánh với cache-aside và mutex lock, cùng các pitfall thường gặp.

25/05/2026
14 phút đọc
0 lượt xem
1

Mục Tiêu Bài Học

Đây là bài thuộc nhánh nâng cao (Advanced Path), dựng tiếp trên kỹ thuật chống cache stampede đã trình bày ở bài 14. Nếu chưa nắm chắc mutex lock và single-flight, bạn nên đọc lại bài 14 trước.

  • Hiểu rõ ba mục tiêu đồng thời mà SWR nhắm tới: phục vụ nhanh (luôn trả cache), giữ dữ liệu tương đối tươi (refresh nền), và chống cache stampede.
  • Nắm cơ chế Stale-While-Revalidate: trả ngay dữ liệu cũ (stale) rồi refresh ở background, cần lưu hai mốc thời gian — soft TTL (fresh until) và hard expire.
  • Viết được logic đọc ba nhánh: fresh thì trả ngay; trong khoảng soft đến hard thì trả stale và trigger refresh đúng một lần (qua lock); quá hard expire thì rebuild đồng bộ.
  • Hiểu và áp dụng được Probabilistic Early Expiration (XFetch): refresh sớm theo xác suất tăng dần khi gần hết hạn, kèm công thức và trực giác đằng sau.
  • Triển khai SWR với soft/hard TTL, background refresh có lock bằng cả ioredis (TypeScript) và redis-py (Python); minh hoạ tính toán XFetch.
  • So sánh SWR với cache-aside thường và mutex lock (bài 14) qua bảng; nhận diện trade-off và biết khi nào nên dùng.
  • Tránh được các pitfall: giữ stale quá lâu, không lock refresh, cài sai beta XFetch, quên hard-expire.
2

Bài Toán: Vừa Nhanh, Vừa Tươi, Vừa Chống Stampede

Hãy tưởng tượng một hot key đắt tiền để rebuild: dữ liệu trang chủ tổng hợp từ nhiều dịch vụ, một feed cá nhân hoá, hay một bảng cấu hình phải tính toán qua nhiều bước. Mỗi lần rebuild mất vài trăm mili-giây tới vài giây. Với cache-aside thuần (bài 9) và TTL cứng (bài 13), ta gặp một mâu thuẫn:

  • Muốn nhanh: user không nên phải chờ rebuild. Nhưng khi key hết hạn, request đầu tiên bị miss buộc phải rebuild đồng bộ và chịu toàn bộ độ trễ đó.
  • Muốn tươi: dữ liệu nên gần với thực tế. Nhưng nếu để TTL dài cho đỡ rebuild thì dữ liệu lại cũ.
  • Muốn chống stampede: tại đúng khoảnh khắc TTL hết hạn, hàng nghìn request đồng thời cùng miss và cùng lao xuống DB rebuild — đây chính là cache stampede (thundering herd) đã phân tích ở bài 14.

Mutex lock ở bài 14 giải quyết được stampede: chỉ một worker rebuild, các request khác chờ. Nhưng nó vẫn để một số request phải chờ trong lúc rebuild — vẫn có độ trễ. Câu hỏi đặt ra: liệu có thể không bao giờ bắt user chờ rebuild, mà vẫn giữ dữ liệu đủ tươi và không gây bão xuống DB?

Câu trả lời là chấp nhận phục vụ dữ liệu hơi cũ (stale) trong một khoảng ngắn, đổi lấy việc rebuild luôn diễn ra ở nền. Đó chính là tinh thần của Stale-While-Revalidate.

# Vấn đề với TTL cứng tại thời điểm hết hạn (t = expiry)
#
#   t-1ms:  cache HIT, mọi request trả ngay (nhanh)
#   t:      cache MISS đột ngột!
#   t+...:  N request đồng thời cùng rebuild (stampede)
#           hoặc với mutex lock: 1 rebuild, N-1 request CHỜ (vẫn trễ)
#
# Mong muốn: tại t vẫn còn dữ liệu để trả NGAY,
#            rebuild diễn ra ở nền, không ai phải chờ.
3

Stale-While-Revalidate Là Gì

Stale-While-Revalidate (SWR — trả dữ liệu cũ trong lúc làm mới) là chiến lược caching trong đó, khi dữ liệu vừa hết hạn tươi, ta trả ngay bản cũ cho client đồng thời kích hoạt một lần refresh chạy ở background. Client không phải chờ; lần đọc kế tiếp (hoặc lần sau nữa) sẽ thấy bản đã được làm mới.

Tên gọi này quen thuộc với ai từng dùng HTTP Cache-Control: stale-while-revalidate hay thư viện SWR ở frontend. Ở đây ta áp dụng đúng tư tưởng đó cho lớp cache Redis phía backend.

Điểm cốt lõi: thay vì một mốc TTL duy nhất, SWR dùng hai mốc thời gian:

  • Soft TTL (fresh until): thời điểm dữ liệu hết "tươi". Trước mốc này, dữ liệu được coi là fresh, trả thẳng không cần làm gì thêm.
  • Hard expire: thời điểm dữ liệu thực sự bị xoá khỏi Redis. Mốc này đặt xa hơn soft TTL khá nhiều, để giữ bản cũ làm "đệm" trong lúc refresh.

Giữa soft TTL và hard expire là vùng stale: dữ liệu vẫn còn trong cache nhưng đã quá hạn tươi. Khi một request rơi vào vùng này, ta trả ngay bản stale cho nó, đồng thời trigger refresh nền — và quan trọng là chỉ đúng một worker được refresh (dùng lock để các request stale khác không cùng rebuild, tránh stampede).

# Trục thời gian của một giá trị SWR
#
#  ghi cache        soft TTL (fresh_until)        hard expire (Redis xoá)
#     │                    │                              │
#     ▼                    ▼                              ▼
#     ├──── FRESH ─────────┼──────── STALE ───────────────┤
#     │  trả ngay          │  trả stale NGAY +            │ sau mốc này:
#     │  (không refresh)    │  trigger refresh nền (1 lần) │ MISS -> rebuild đồng bộ
#                          │  qua lock                    │
#

Như vậy trong điều kiện bình thường, không request nào phải chờ rebuild: hoặc thấy fresh (trả ngay), hoặc thấy stale (trả ngay bản cũ, refresh đẩy sang nền). Chỉ khi để key trôi quá hard expire (ví dụ refresh liên tục thất bại, hoặc key lạnh lâu không ai đọc) thì mới phải rebuild đồng bộ như cache-aside thường.

4

Cơ Chế Chi Tiết: Soft TTL, Hard Expire & Logic Đọc

Để biết một giá trị đang fresh hay stale, Redis cần lưu kèm metadata timestamp chứ không chỉ value trần. Cách phổ biến là gói value cùng mốc fresh_until vào một JSON (hoặc một Hash), rồi đặt hard expire bằng chính TTL của key Redis.

# Một bản ghi SWR lưu trong Redis (ví dụ dạng JSON)
# Key:  page:home
# TTL Redis (hard expire) = 600s
# Nội dung:
{
  "value":       "<html>... dữ liệu đã render ...</html>",
  "fresh_until": 1716600000,   // epoch giây: hết tươi lúc này (soft TTL)
  "delta":       0.8           // thời gian rebuild gần nhất (giây) - dùng cho XFetch
}
#
# Quy ước thời gian (ví dụ): soft TTL = 60s, hard expire = 600s
#   - now < fresh_until          -> FRESH
#   - fresh_until ≤ now (còn key)-> STALE  (trả stale + refresh nền)
#   - key đã bị Redis xoá         -> MISS   (rebuild đồng bộ)

Logic đọc của SWR chia làm ba nhánh:

  1. FRESH (now < fresh_until): trả value ngay, không làm gì thêm.
  2. STALE (key còn nhưng now ≥ fresh_until): trả value ngay (bản cũ) cho client, đồng thời thử lấy một lock. Nếu lấy được lock thì spawn một tác vụ nền: rebuild dữ liệu, ghi lại bản mới kèm fresh_until mới và gia hạn hard expire, rồi nhả lock. Nếu không lấy được lock (đã có worker khác đang refresh) thì thôi — chỉ trả stale.
  3. MISS (key đã bị Redis xoá vì quá hard expire): không còn gì để trả. Đây là lúc duy nhất phải rebuild đồng bộ; nên dùng lại mutex lock của bài 14 để chỉ một worker rebuild, số còn lại chờ kết quả.

Vì sao phải có lock ở nhánh STALE? Vì khi key vừa qua soft TTL, có thể hàng nghìn request đồng thời rơi vào nhánh STALE. Nếu mỗi request đều spawn một refresh thì ta lại tạo ra stampede ở background — đúng thứ ta muốn tránh. Lock đảm bảo chỉ một refresh chạy tại một thời điểm cho mỗi key. Lock này thường là một key Redis riêng với TTL ngắn (ví dụ SET lock:page:home 1 NX EX 10), tự hết hạn để tránh deadlock nếu worker refresh chết giữa chừng.

Lưu ý quan trọng: refresh nền phải có error handling. Nếu rebuild thất bại, ta không ghi đè bản cũ và không rút ngắn hard expire — cứ để bản stale tiếp tục phục vụ, lần sau thử lại. Hard expire chính là cận trên cho việc "stale tới mức nào thì bỏ".

5

SWR Với ioredis (TypeScript)

Triển khai SWR bằng ioredis. Ta gói value cùng freshUntildelta vào JSON, đặt hard expire bằng option EX, và dùng SET ... NX EX làm lock cho refresh nền.

import Redis from "ioredis";

const redis = new Redis({ host: "127.0.0.1", port: 6379 });

const SOFT_TTL = 60;   // giây: hết tươi sau 60s
const HARD_TTL = 600;  // giây: Redis xoá key sau 600s (đệm stale)
const LOCK_TTL = 10;   // giây: lock refresh tự hết hạn để tránh deadlock

interface Wrapped {
  value: string;       // dữ liệu đã serialize
  freshUntil: number;  // epoch giây (soft TTL)
  delta: number;       // thời gian rebuild gần nhất (giây)
}

const nowSec = () => Date.now() / 1000;
const dataKey = (id: string) => `page:${id}`;
const lockKey = (id: string) => `lock:page:${id}`;

// Hàm rebuild tốn kém (gọi DB, tổng hợp nhiều nguồn...)
async function rebuild(id: string): Promise<string> {
  // ... truy vấn và tính toán ...
  return await expensiveBuild(id);
}

// Ghi bản mới: đo delta, set freshUntil, gia hạn hard expire
async function writeCache(id: string, value: string, delta: number): Promise<void> {
  const wrapped: Wrapped = { value, freshUntil: nowSec() + SOFT_TTL, delta };
  await redis.set(dataKey(id), JSON.stringify(wrapped), "EX", HARD_TTL);
}

// Refresh ở background: chỉ chạy nếu lấy được lock
async function refreshInBackground(id: string): Promise<void> {
  // SET lock NX EX: chỉ 1 worker thắng lock, các request stale khác bỏ qua
  const got = await redis.set(lockKey(id), "1", "EX", LOCK_TTL, "NX");
  if (got !== "OK") return; // đã có worker khác đang refresh

  try {
    const t0 = nowSec();
    const fresh = await rebuild(id);
    const delta = nowSec() - t0;        // đo thời gian rebuild cho XFetch
    await writeCache(id, fresh, delta);
  } catch (err) {
    // Rebuild lỗi: KHÔNG ghi đè bản cũ, để stale phục vụ tiếp, lần sau thử lại
    console.error("SWR refresh failed", id, err);
  } finally {
    await redis.del(lockKey(id)); // nhả lock sớm (vẫn có EX phòng hờ)
  }
}

export async function getPage(id: string): Promise<string> {
  const raw = await redis.get(dataKey(id));

  // Nhánh MISS: key đã quá hard expire -> rebuild đồng bộ (chỉ 1 worker)
  if (raw === null) {
    const got = await redis.set(lockKey(id), "1", "EX", LOCK_TTL, "NX");
    if (got === "OK") {
      const t0 = nowSec();
      const fresh = await rebuild(id);
      await writeCache(id, fresh, nowSec() - t0);
      await redis.del(lockKey(id));
      return fresh;
    }
    // Không thắng lock: chờ ngắn rồi đọc lại (single-flight, xem bài 14)
    await new Promise((r) => setTimeout(r, 50));
    return getPage(id);
  }

  const wrapped = JSON.parse(raw) as Wrapped;

  // Nhánh FRESH: còn tươi -> trả ngay
  if (nowSec() < wrapped.freshUntil) {
    return wrapped.value;
  }

  // Nhánh STALE: trả ngay bản cũ + trigger refresh nền (không await)
  void refreshInBackground(id);
  return wrapped.value;
}

Điểm mấu chốt: ở nhánh STALE ta dùng void refreshInBackground(id)không await, để client nhận bản stale ngay lập tức trong khi refresh chạy nền. Lock NX đảm bảo chỉ một refresh diễn ra dù nhiều request cùng stale.

6

SWR Với redis-py (Python)

Phiên bản tương đương bằng redis-py. Refresh nền chạy trong một threading.Thread (hoặc một task của hàng đợi như Celery/RQ trong production thực tế). Đặt decode_responses=True để nhận str.

import json
import time
import threading
import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)

SOFT_TTL = 60     # giây: hết tươi
HARD_TTL = 600    # giây: Redis xoá key (đệm stale)
LOCK_TTL = 10     # giây: lock refresh tự hết hạn


def data_key(page_id: str) -> str:
    return f"page:{page_id}"


def lock_key(page_id: str) -> str:
    return f"lock:page:{page_id}"


def rebuild(page_id: str) -> str:
    # ... truy vấn DB, tổng hợp nhiều nguồn (tốn kém) ...
    return expensive_build(page_id)


def write_cache(page_id: str, value: str, delta: float) -> None:
    wrapped = {
        "value": value,
        "fresh_until": time.time() + SOFT_TTL,  # soft TTL
        "delta": delta,                          # thời gian rebuild (cho XFetch)
    }
    # EX = hard expire
    r.set(data_key(page_id), json.dumps(wrapped), ex=HARD_TTL)


def refresh_in_background(page_id: str) -> None:
    # SET NX EX: chỉ 1 worker thắng lock
    if not r.set(lock_key(page_id), "1", ex=LOCK_TTL, nx=True):
        return  # đã có worker khác đang refresh
    try:
        t0 = time.time()
        fresh = rebuild(page_id)
        write_cache(page_id, fresh, time.time() - t0)
    except Exception as exc:
        # Lỗi rebuild: KHÔNG ghi đè bản cũ, để stale phục vụ tiếp
        print("SWR refresh failed", page_id, exc)
    finally:
        r.delete(lock_key(page_id))


def get_page(page_id: str) -> str:
    raw = r.get(data_key(page_id))

    # Nhánh MISS: quá hard expire -> rebuild đồng bộ (1 worker)
    if raw is None:
        if r.set(lock_key(page_id), "1", ex=LOCK_TTL, nx=True):
            t0 = time.time()
            fresh = rebuild(page_id)
            write_cache(page_id, fresh, time.time() - t0)
            r.delete(lock_key(page_id))
            return fresh
        time.sleep(0.05)        # không thắng lock: chờ ngắn rồi đọc lại
        return get_page(page_id)

    wrapped = json.loads(raw)

    # Nhánh FRESH: còn tươi -> trả ngay
    if time.time() < wrapped["fresh_until"]:
        return wrapped["value"]

    # Nhánh STALE: trả ngay bản cũ + refresh nền (thread riêng, không chờ)
    threading.Thread(target=refresh_in_background, args=(page_id,), daemon=True).start()
    return wrapped["value"]

Cấu trúc ba nhánh (FRESH / STALE / MISS) giống hệt bản TypeScript. Trong production nên đẩy refresh_in_background vào một worker queue thay vì thread tạm để dễ kiểm soát tài nguyên và retry.

7

Probabilistic Early Expiration (XFetch)

SWR đã loại bỏ việc chờ rebuild, nhưng vẫn còn một điểm gợn: việc chuyển từ FRESH sang STALE xảy ra đột ngột tại đúng mốc fresh_until. Ngay sau mốc đó, rất nhiều request cùng rơi vào nhánh STALE và cùng tranh lock. Lock chỉ cho một refresh chạy nên không thành stampede xuống DB, nhưng việc co cụm refresh tại một thời điểm vẫn tạo gai tải.

Probabilistic Early Expiration (hết hạn sớm theo xác suất), thường gọi là XFetch, làm trơn vấn đề này. Thay vì chờ tới đúng lúc hết hạn mới refresh, mỗi lần đọc ta gieo một xác suất để refresh sớm. Xác suất này rất nhỏ khi còn xa hạn và tăng dần khi gần hết hạn. Nhờ vậy, một request "may mắn" sẽ refresh sớm trước khi đám đông kịp dồn vào mốc hết hạn, trải đều việc rebuild theo thời gian.

Công thức XFetch

Mỗi lần đọc cache, ta tính và kiểm tra điều kiện sau; nếu đúng thì kích hoạt refresh (early):

now - delta * beta * log(random()) >= expiry

# Trong đó:
#   now      = thời điểm hiện tại (epoch giây)
#   delta    = thời gian rebuild gần nhất (giây) - đã lưu kèm value
#   beta     = hệ số điều chỉnh độ "hung hăng" của refresh sớm (thường ~1.0)
#   random() = số ngẫu nhiên đều trong khoảng (0, 1)
#   log()    = logarit tự nhiên (ln). Vì random() < 1 nên log(random()) < 0,
#              do đó "- delta * beta * log(random())" là một lượng DƯƠNG cộng thêm
#   expiry   = mốc hết hạn (ở đây dùng fresh_until - mốc soft TTL)

Diễn giải lại: gọi gap = -delta * beta * log(random()) (luôn dương). Điều kiện trở thành now + gap ≥ expiry, nghĩa là "nếu cộng thêm một lượng ngẫu nhiên gap vào hiện tại mà đã vượt mốc hết hạn thì refresh ngay bây giờ".

Trực giác

  • Khi now còn xa expiry: cần gap rất lớn mới chạm điều kiện, mà gap lớn đòi log(random()) rất âm tức random() rất gần 0 — xác suất nhỏ. Vậy còn xa hạn thì gần như không refresh sớm.
  • Khi now tiến sát expiry: chỉ cần gap nhỏ là đủ vượt, mà gap nhỏ xảy ra với hầu hết giá trị random() — xác suất cao. Vậy càng gần hạn càng dễ refresh sớm, và xác suất tăng trơn (không nhảy bậc).
  • delta nhân vào nghĩa là rebuild càng đắt (delta lớn) thì refresh càng sớm — hợp lý, vì cần nhiều thời gian đệm hơn để kịp làm mới trước khi hết hạn thật.
  • beta điều chỉnh độ hung hăng: beta > 1 refresh sớm hơn (tốn tài nguyên hơn nhưng an toàn hơn), beta < 1 refresh muộn hơn (tiết kiệm nhưng rủi ro chạm hạn thật). Mặc định beta = 1.

Kỹ thuật này đến từ bài báo "Optimal Probabilistic Cache Stampede Prevention" (Vattani và cộng sự, VLDB 2015). Trong thực tế, XFetch thường được dùng kết hợp với SWR: XFetch quyết định khi nào nên refresh sớm, còn cơ chế stale + lock của SWR đảm bảo refresh không gây chờ và không stampede.

Minh hoạ tính toán

// Gọi mỗi lần đọc, để quyết định có refresh SỚM không (XFetch)
// Lưu kèm value: delta (giây rebuild), freshUntil (epoch giây)
function xfetchShouldRefresh(freshUntil: number, delta: number, beta = 1.0): boolean {
  const now = Date.now() / 1000;
  // gap luôn dương vì log(random in (0,1)) < 0
  const gap = -delta * beta * Math.log(Math.random());
  return now + gap >= freshUntil;
}

// Ví dụ số: delta = 0.8s, beta = 1, freshUntil còn 5s nữa
//   gap trung bình ~ delta = 0.8s  -> hiếm khi đủ vượt 5s -> ít refresh sớm
// Khi chỉ còn 0.3s nữa tới freshUntil:
//   chỉ cần gap ≥ 0.3s -> xảy ra với đa số random() -> dễ refresh sớm
import math
import random
import time

def xfetch_should_refresh(fresh_until: float, delta: float, beta: float = 1.0) -> bool:
    now = time.time()
    # gap luôn dương vì log(random trong (0,1)) < 0
    gap = -delta * beta * math.log(random.random())
    return now + gap >= fresh_until

# Tích hợp: ở nhánh đọc, nếu xfetch_should_refresh(...) True thì
# trigger refresh nền (qua lock) DÙ value vẫn còn fresh -> làm trơn tải.

Khi ghép XFetch vào SWR, ta gọi xfetchShouldRefresh ngay cả lúc dữ liệu còn FRESH; nếu trả về true thì trigger refresh nền (vẫn qua lock như nhánh STALE). Nhờ đó việc làm mới được rải sớm và đều, mốc hết hạn cứng gần như không bao giờ bị chạm tới.

8

So Sánh: SWR vs Cache-Aside vs Mutex Lock

Cả ba đều là biến thể đọc cache, khác nhau ở cách xử lý lúc hết hạn:

Tiêu chí Cache-Aside thường (bài 9) Mutex Lock / Single-flight (bài 14) Stale-While-Revalidate (+ XFetch)
Lúc key hết hạn Mọi request miss cùng rebuild 1 request rebuild, số còn lại chờ Trả ngay bản stale, refresh nền 1 lần
User có phải chờ rebuild? Có (mọi request miss) Có (request thắng lock và các request chờ) Không (trong vùng stale, trừ khi quá hard expire)
Chống cache stampede Không Có (lock giới hạn 1 rebuild) Có (lock cho refresh nền; XFetch trải đều)
Độ tươi dữ liệu Tươi tới TTL (rồi rebuild) Tươi tới TTL (rồi rebuild) Có thể stale ngắn (eventual freshness)
Số mốc thời gian lưu 1 (TTL) 1 (TTL) + lock 2 (soft TTL + hard expire) + lock + delta
Độ phức tạp Thấp Trung bình Cao (metadata, background job, lock, XFetch)
Phù hợp khi Dữ liệu rẻ rebuild, không hot lắm Hot key, cần tươi đúng TTL, chấp nhận chờ ngắn Hot key đắt rebuild, chấp nhận stale ngắn

Tóm tắt: cache-aside là nền tảng; mutex lock thêm khả năng chống stampede nhưng vẫn để một số request chờ; SWR (kèm XFetch) đi xa nhất về độ phản hồi — gần như không ai chờ rebuild — đổi lại chấp nhận dữ liệu hơi cũ và độ phức tạp cao hơn.

9

Trade-off & Khi Nào Nên Dùng

Trade-off

  • Phục vụ stale data: SWR chấp nhận trả dữ liệu hơi cũ trong vùng soft đến hard expire. Chỉ phù hợp khi hệ thống chấp nhận eventual freshness. Với dữ liệu phải tuyệt đối tươi (số dư tài khoản, tồn kho khi đặt hàng), đừng dùng SWR — hãy đọc thẳng nguồn sự thật.
  • Phức tạp hơn hẳn: phải lưu metadata (fresh_until, delta), quản lý background job, lock, và cài đặt XFetch. Nhiều thành phần hơn đồng nghĩa nhiều chỗ có thể sai hơn.
  • Cần lock để tránh refresh song song: nếu thiếu lock ở nhánh stale, nhiều worker cùng refresh một key — vẫn là stampede, chỉ chuyển từ đường đọc sang đường refresh.
  • Bộ nhớ và đường mạng: giữ bản cũ lâu hơn (hard expire dài) tốn thêm RAM; metadata làm value lớn hơn đôi chút.

Khi nào nên dùng

  • Hot key có chi phí rebuild lớn: trang chủ tổng hợp, feed, dashboard, kết quả tính toán nặng — nơi rebuild đồng bộ sẽ gây trễ rõ rệt cho user.
  • Chấp nhận stale ngắn: nội dung mà việc cũ vài chục giây tới vài phút không gây hậu quả nghiêm trọng (trang chủ, danh mục, cấu hình ít đổi, bảng xếp hạng, số liệu thống kê gần đúng).
  • Lưu lượng đọc cao, đều: SWR và XFetch phát huy tốt khi có dòng request liên tục để "khám phá" trạng thái stale và kích hoạt refresh sớm. Với key đọc thưa thớt, lợi ích giảm.

Nếu dữ liệu rẻ để rebuild hoặc không thực sự hot, cache-aside thường (bài 9) đã đủ. Nếu cần tươi đúng tới TTL và chỉ ngại stampede, mutex lock (bài 14) là lựa chọn gọn hơn. SWR dành cho trường hợp vừa hot vừa đắt vừa chấp nhận stale.

10

Pitfalls & Anti-patterns

  • Giữ stale quá lâu: đặt khoảng cách soft đến hard expire quá rộng khiến dữ liệu phục vụ lệch thực tế quá nhiều. Cân chỉnh soft TTL theo mức tươi cần thiết, và hard expire chỉ đủ làm đệm cho thời gian rebuild cộng vài lần retry.
  • Không lock refresh: nếu nhánh stale spawn refresh mà không qua lock NX, mọi request stale cùng rebuild — stampede tái xuất ở background, đập DB như cũ. Lock là bắt buộc, không phải tuỳ chọn.
  • Cài sai beta XFetch: beta quá lớn khiến refresh sớm liên tục, tốn tài nguyên rebuild vô ích; beta quá nhỏ khiến refresh muộn, gần như không có tác dụng làm trơn và để mốc hết hạn bị chạm. Bắt đầu với beta = 1 rồi tinh chỉnh theo quan sát thực tế.
  • Quên hard-expire: nếu không đặt TTL cứng (hard expire) cho key, mà refresh lại luôn thất bại (DB hỏng, dependency chết), thì bản stale sẽ tồn tại vĩnh viễn mà không bao giờ được làm mới hay xoá. Luôn đặt hard expire làm cận trên an toàn.
  • Ghi đè bản cũ khi rebuild lỗi: nếu refresh nền bắt được dữ liệu rỗng/lỗi rồi vẫn ghi đè vào cache, ta phá hỏng bản stale đang dùng tốt. Chỉ ghi cache khi rebuild thành công; lỗi thì giữ nguyên bản cũ.
  • Đo delta sai: dùng delta mặc định cứng hoặc không cập nhật theo lần rebuild gần nhất làm XFetch tính sai thời điểm refresh sớm. Hãy đo và lưu thời gian rebuild thực tế mỗi lần.
  • Block event loop / nuốt lỗi background: ở nhánh stale lỡ await refresh khiến user phải chờ (mất ý nghĩa SWR); hoặc spawn task nền mà không log lỗi khiến refresh thất bại âm thầm. Đừng chờ refresh nền, và luôn ghi log/metric cho nó.
11

Tổng Kết & Quiz

Tổng kết

  • SWR dùng hai mốc: soft TTL (fresh_until) và hard expire. Trước soft TTL trả fresh; giữa soft và hard trả stale ngay rồi refresh nền; quá hard expire mới rebuild đồng bộ.
  • Refresh nền phải qua lock (SET NX EX) để chỉ một worker rebuild mỗi key — nếu không vẫn stampede ở background.
  • XFetch refresh sớm theo xác suất tăng dần khi gần hết hạn, theo điều kiện now - delta * beta * log(random()) ≥ expiry, giúp trải đều việc làm mới và tránh dồn cục tại mốc hết hạn.
  • delta (thời gian rebuild) và beta (~1) điều chỉnh độ sớm của refresh; rebuild càng đắt thì XFetch refresh càng sớm.
  • So với cache-aside và mutex lock, SWR phản hồi tốt nhất (gần như không ai chờ rebuild) nhưng đổi lấy stale ngắn và độ phức tạp cao.
  • Dùng cho hot key đắt rebuild, chấp nhận stale ngắn (feed, trang chủ, config). Cẩn thận giữ stale quá lâu, thiếu lock, sai beta, quên hard-expire.

Quiz 5 câu

  1. SWR lưu hai mốc thời gian nào và mỗi mốc có ý nghĩa gì? Mô tả ba nhánh của logic đọc.
  2. Vì sao ở nhánh stale bắt buộc phải dùng lock khi trigger refresh nền? Không có lock thì điều gì xảy ra?
  3. Viết lại công thức XFetch và giải thích vì sao xác suất refresh tăng dần khi now tiến gần expiry.
  4. deltabeta trong XFetch ảnh hưởng tới thời điểm refresh sớm ra sao? Cài beta quá lớn hoặc quá nhỏ gây hậu quả gì?
  5. Nếu quên đặt hard expire và refresh nền liên tục thất bại thì cache sẽ rơi vào trạng thái gì? Vì sao?

Đáp án gợi ý

  1. Soft TTL (fresh_until): mốc hết tươi; trước nó dữ liệu là fresh. Hard expire: mốc Redis xoá key thật, đặt xa hơn soft TTL để giữ đệm stale. Ba nhánh: FRESH (now < fresh_until) trả ngay; STALE (key còn, now ≥ fresh_until) trả bản cũ ngay rồi refresh nền qua lock; MISS (key đã bị xoá) rebuild đồng bộ.
  2. Vì khi key vừa qua soft TTL, nhiều request đồng thời rơi vào nhánh stale; nếu mỗi request đều spawn refresh thì tạo stampede ở background, đập DB như khi không có cache. Lock NX đảm bảo chỉ một refresh chạy tại một thời điểm cho mỗi key.
  3. Điều kiện: now - delta * beta * log(random()) ≥ expiry. Đặt gap = -delta * beta * log(random()) (luôn dương), điều kiện thành now + gap ≥ expiry. Khi now còn xa expiry cần gap lớn (đòi random gần 0, hiếm) nên ít refresh; khi now sát expiry chỉ cần gap nhỏ (xảy ra với đa số random) nên xác suất refresh cao và tăng trơn.
  4. delta lớn (rebuild đắt) làm gap trung bình lớn hơn nên refresh sớm hơn — hợp lý vì cần nhiều đệm hơn. beta > 1 refresh sớm hơn (tốn tài nguyên), beta < 1 refresh muộn hơn (rủi ro chạm hạn thật, mất tác dụng làm trơn). Mặc định beta = 1.
  5. Bản stale sẽ tồn tại vĩnh viễn: không có hard expire thì Redis không bao giờ tự xoá key, mà refresh luôn fail nên cũng không bao giờ ghi bản mới. Hệ thống kẹt phục vụ dữ liệu cũ mãi mãi. Hard expire là cận trên an toàn để tránh tình trạng này.

Bài tiếp theo

Bài 16 chuyển sang Multi-layer Cache: kết hợp nhiều tầng cache (in-process / local memory, Redis, và nguồn dữ liệu) để giảm cả latency lẫn tải xuống Redis, cùng các vấn đề về đồng bộ và invalidation giữa các tầng.

Tham khảo