Phần trước đo được cache nhanh hơn query database 30 đến 300 lần khi tính toán gốc đắt. Nhưng "thêm cache" không phải một nút bấm — nó là một mẫu code mà ứng dụng phải tự viết, và viết sai thì cache hoặc vô dụng, hoặc trả dữ liệu sai. Mẫu được dùng nhiều nhất, tới mức gần như mặc định, tên là cache-aside (còn gọi lazy loading). Bài này dựng nó bằng Go nối Redis và PostgreSQL, rồi đo xem nó thực sự tiết kiệm được bao nhiêu.

Cache-aside: ứng dụng tự lo cache, cache không biết gì về database

Điểm mấu chốt của cache-aside nằm ở chữ aside — cache nằm bên cạnh đường đi, không nằm trên đường đi. Redis không biết PostgreSQL tồn tại; PostgreSQL không biết Redis tồn tại. Chính ứng dụng là nơi nối hai thứ lại, theo đúng ba bước:

  1. Đọc cache. Có thì trả luôn (hit), không chạm database.
  2. Miss thì đọc nguồn sự thật — database.
  3. Điền kết quả vào cache kèm TTL, rồi trả về.

Hệ quả của thiết kế này là cache lười: một key chỉ vào cache khi có người hỏi tới nó lần đầu. Cache khởi động lạnh và nóng dần theo lưu lượng thật — đó chính là thứ sẽ đo ở phần sau.

Ảnh chụp đoạn mã nền tối cache-aside ba bước ứng dụng tự lo cache với Go Redis PostgreSQL, bước 1 đọc cache raw err rdb Get ctx key Bytes nếu err nil là HIT trả luôn không chạm DB json Unmarshal else if err khác redis Nil thì lỗi Redis không phải miss đừng nuốt trả về lỗi, bước 2 MISS lấy từ nguồn sự thật db QueryRow ctx truyVan id Scan, bước 3 điền vào cache luôn kèm TTL rdb Set ctx key b 60 giây, ba thứ dễ làm sai SET không TTL thì key sống mãi cache phình và cũ vĩnh viễn, coi lỗi Redis bằng miss thì Redis sập toàn bộ tải đổ thẳng vào DB, không lưu dấu vắng mặt thì id không tồn tại miss 100 phần trăm xuyên thủng cache

Hình 1: Ba bước của cache-aside trong Go. Chú ý hai chi tiết dễ bỏ qua: Set luôn kèm TTL, và lỗi Redis (err != redis.Nil) được phân biệt rõ với cache miss.

Hai chi tiết trong đoạn mã trên đáng dừng lại:

err != redis.Nil không phải miss. redis.Nil nghĩa là "key không có" — đó là miss thật, đi tiếp xuống database là đúng. Nhưng mọi lỗi khác (mất kết nối, timeout, Redis quá tải) lại là chuyện khác hẳn. Nếu code gộp chung hai thứ và coi mọi lỗi là miss, thì lúc Redis sập, 100% lưu lượng đổ thẳng vào database cùng một lúc — đúng khoảnh khắc bạn cần cache nhất. Gộp chung cho code ngắn hơn ba dòng, đổi lại một chế độ hỏng dây chuyền.

Set luôn kèm TTL. Quên TTL thì key sống mãi trong Redis: cache phình tới khi chạm maxmemory (chủ đề phần 4), và tệ hơn, dữ liệu cũ nằm lại vĩnh viễn nếu nhánh invalidation có bug. TTL là lưới an toàn cuối cùng cho tính đúng đắn — nó đặt trần cho mức độ cũ của dữ liệu kể cả khi mọi thứ khác hỏng.

Đo thật: hit ratio leo từ 37,6% lên 95%

Mình dựng bảng products 50.000 dòng và reviews 400.000 dòng trên pg-lab, một hàm đọc sản phẩm theo đúng ba bước trên, rồi bắn 20.000 request vào một tập làm việc 1.000 sản phẩm (chọn ngẫu nhiên, hạt giống cố định để chạy lại ra cùng số), TTL 60 giây:

Ảnh chụp output thật nền tối hit ratio leo 37,6 phần trăm lên 95 phần trăm Go Redis 7 PostgreSQL 16 với 1000 sản phẩm 20000 request, bảng cache nóng dần cùng một tập 1000 key mốc 1000 request hit 376 miss 624 hit ratio 37.60 phần trăm truy vấn DB 624, mốc 2000 hit 1131 miss 869 tỉ lệ 56.55 phần trăm DB 869, mốc 5000 hit 4006 miss 994 tỉ lệ 80.12 phần trăm DB 994, mốc 10000 hit 9000 miss 1000 tỉ lệ 90.00 phần trăm DB 1000, mốc 20000 hit 19000 miss 1000 tỉ lệ 95.00 phần trăm DB 1000 đứng yên vì mọi key đã nằm trong cache, độ trễ trung bình đo trong ứng dụng gồm đi về mạng HIT chỉ GET Redis 0.037 ms 19000 lượt MISS gồm GET cộng query DB cộng SET 0.152 ms 1000 lượt, truy vấn DB 1000 trên 20000 request tiết kiệm 95 phần trăm lượt chạm database, Redis INFO stats tự đếm keyspace_hits 19000 keyspace_misses 1000, xuyên thủng cache 5000 request cho 200 id không tồn tại không lưu dấu vắng mặt truy vấn DB 5000 trên 5000 là 100 phần trăm tổng 446.8 ms còn có lưu dấu vắng mặt truy vấn DB 200 trên 5000 là 4 phần trăm tổng 203.9 ms, nói thẳng query ở đây có index nên MISS chỉ chậm hơn HIT khoảng 4 lần cái thắng lớn không phải độ trễ một request mà là 95 phần trăm lượt chạm database biến mất

Hình 2: Kết quả thật. Hit ratio leo 37,60% → 56,55% → 80,12% → 90,00% → 95,00%, trong khi số truy vấn database dừng hẳn ở 1.000 từ mốc 10.000 request. Redis tự đếm khớp: keyspace_hits:19000, keyspace_misses:1000.

Đọc từng con số:

  • Cột "truy vấn DB" là thứ đáng nhìn nhất. Nó leo 624 → 869 → 994 → 1.000 rồi đứng yên. Trần 1.000 không ngẫu nhiên: tập làm việc có đúng 1.000 sản phẩm, nên sau khi mỗi key bị hỏi lần đầu, database không còn việc gì. 19.000 request sau đó được Redis trả hoàn toàn.
  • Hit ratio là tích luỹ, nên nó leo chậm. Ở mốc 1.000 request mới 37,60% — thấp, vì cache đang lạnh. Nhưng tới mốc 20.000 đã 95,00%, và nếu chạy tiếp nó sẽ tiệm cận 100% (chỉ bị TTL 60 giây kéo xuống). Con số 95% này không nói cache đang hoạt động ở mức 95%; nó nói trung bình cả quãng đã chạy là 95%. Phân biệt hit ratio tích luỹ với hit ratio hiện tại quan trọng khi giám sát — chủ đề phần 9.
  • Redis tự đếm khớp với bộ đếm trong ứng dụng: keyspace_hits:19000 / keyspace_misses:1000. Hai nguồn độc lập cho cùng một con số, nên không phải lỗi đếm trong code.
  • HIT 0,037ms vs MISS 0,152ms — khoảng 4 lần. Đây là độ trễ đo trong ứng dụng, đã gồm đi-về mạng tới Redis và tới PostgreSQL.

Và đây là chỗ phải nói thẳng, vì nó dễ gây hiểu lầm nếu chỉ nhìn phần 1: tỉ lệ 4 lần này nhỏ hơn nhiều so với con số 30–300 lần ở bài trước. Lý do đơn giản — query ở đây lấy một sản phẩm theo khoá chính và gộp đánh giá theo index, EXPLAIN ANALYZE cho 0,127ms, tức là query vốn đã nhanh. Cache một query nhanh thì phần thắng về độ trễ cũng nhỏ. Nếu bạn đang cache để kéo p99 xuống mà query gốc vốn đã nhanh, cache-aside sẽ làm bạn thất vọng.

Cái thắng lớn nằm ở cột khác: 1.000 thay vì 20.000 lượt chạm database. Database không phải chạy 19.000 query, bất kể mỗi query nhanh hay chậm. Đây là lý do cache-aside đáng có ngay cả khi độ trễ chỉ cải thiện vài lần: nó bảo vệ dung lượng của tài nguyên khó scale nhất. Với 20.000 request, tiết kiệm 95% kết nối, 95% công planner, 95% buffer pool bị khuấy.

Lỗ hổng của cache-aside: id không tồn tại xuyên thẳng xuống database

Cache-aside có một lỗ hổng gắn liền với chính thiết kế lười của nó. Cache chỉ được điền khi database trả về dữ liệu. Vậy nếu database trả về không có gì thì sao? Đoạn code ở Hình 1 không ghi gì vào cache cả — nên lần sau hỏi cùng id đó, lại miss, lại chạm database. Mãi mãi.

Đây gọi là xuyên thủng cache (cache penetration), và nó không phải chuyện lý thuyết: một con bot quét id tuần tự, hoặc một client có bug gửi id cũ đã xoá, là đủ để biến cache thành vô dụng cho nhánh đó. Mình đo bằng 5.000 request hỏi 200 id chắc chắn không tồn tại (products chỉ có id 1–50.000, mình hỏi 900000+):

  • Không lưu dấu vắng mặt: 5.000/5.000 request chạm database (100%), tổng 446,8ms. Cache đứng đó không làm gì.
  • Có lưu dấu vắng mặt: 200/5.000 (4,0%), tổng 203,9ms. Đúng 200 — mỗi id ma chỉ chạm database một lần.

Cách sửa là ghi một dấu vắng mặt (tombstone / negative cache) vào cache khi database trả về rỗng:

if err.Error() == "no rows in result set" {
    // TTL NGẮN: id này có thể được tạo thật sau đó
    rdb.Set(ctx, key, "__KHONG_CO__", 30*time.Second)
    return
}

TTL của dấu vắng mặt phải ngắn hơn TTL dữ liệu thật (ở đây 30 giây so với 60 giây). Lý do: một id chưa tồn tại có thể được tạo ngay sau đó, và nếu bạn cache "không có" trong một giờ thì người dùng vừa tạo sản phẩm sẽ không thấy nó trong một giờ. Dấu vắng mặt đánh đổi tính mới lấy việc chặn xuyên thủng, nên hãy đánh đổi ít thôi.

Đánh đổi cần cân nhắc

Cache-aside luôn có một cửa sổ dữ liệu cũ, và đó là lựa chọn có chủ ý. Trong khoảng TTL 60 giây, nếu ai đó sửa giá sản phẩm trong database, cache vẫn trả giá cũ. Cache-aside không tự biết database đã đổi — nó chỉ biết TTL. Muốn hẹp hơn thì phải xoá key chủ động khi ghi (invalidation, phần 7) hoặc đổi sang write-through (phần 8), và cả hai đều thêm phức tạp. Với dữ liệu chịu được hơi cũ thì TTL là câu trả lời đủ tốt và rẻ nhất; với số dư tài khoản thì không.

Cache lạnh là trạng thái thật, không phải trạng thái bất thường. Hình 2 cho thấy hit ratio chỉ 37,6% ở nghìn request đầu. Mỗi lần deploy Redis mới, mỗi lần restart, mỗi lần FLUSHALL nhầm, hệ thống quay về đúng trạng thái đó — và lúc ấy database nhận gần như toàn bộ lưu lượng. Nếu database không gánh nổi tải không có cache, thì cache đang che giấu một vấn đề về dung lượng chứ không giải quyết nó. Đây cũng là lúc hiệu ứng stampede xuất hiện mạnh nhất (phần 5).

Số đo ở đây là micro-benchmark trong container cùng máy. HIT 0,037ms là độ trễ khi Redis ở ngay cạnh; production thường có 0,2–1ms độ trễ mạng tới Redis, và khi đó tỉ lệ HIT/MISS sẽ khác. Dữ liệu cũng nhỏ và nóng trong bộ nhớ PostgreSQL, nên query gốc 0,127ms là trường hợp tốt nhất cho database — ở production với buffer pool lạnh hơn, chênh lệch sẽ nghiêng về phía cache nhiều hơn. Cơ chế (3 bước, cache nóng dần, trần truy vấn DB bằng kích thước tập làm việc) thì đúng ở mọi quy mô; con số tuyệt đối phải đo lại ở hệ của bạn.

Ba ý mang về

  1. Cache-aside là ba bước và ứng dụng tự viết cả ba: đọc cache → miss thì đọc DB → điền vào cache kèm TTL. Hai chỗ dễ sai nằm ngay trong ba bước đó: coi lỗi Redis là cache miss (Redis sập thì toàn bộ tải đổ vào DB) và Set quên TTL (mất lưới an toàn cuối cùng cho dữ liệu cũ).
  2. Giá trị đo được nằm ở số truy vấn DB, không phải độ trễ một request: 20.000 request chỉ sinh 1.000 truy vấn (tiết kiệm 95%), trong khi độ trễ chỉ cải thiện ~4 lần (0,152ms → 0,037ms) vì query gốc vốn đã có index. Trần truy vấn DB bằng đúng kích thước tập làm việc.
  3. Cache-aside xuyên thủng với id không tồn tại — đo thật 5.000/5.000 request chạm DB. Dấu vắng mặt kéo xuống còn 200/5.000, nhưng TTL của nó phải ngắn hơn TTL dữ liệu thật để id vừa được tạo không bị giấu đi.

Nguồn

Phần sau ta mổ xẻ chính cái TTL vừa đặt bừa 60 giây ở đây: TTL quyết định dữ liệu cũ tới mức nào, đo thật mức độ lệch giữa cache và database theo từng giá trị TTL, và vì sao TTL đồng loạt lại tự tạo ra một cơn bão miss.