Hồi mới đi làm, với mình caching đơn giản lắm: API chậm hả? Quăng con Redis vào, cache lại, xong. Nó nhanh thật, nên mình tưởng mình hiểu caching rồi. Nhưng dạo này ngồi ôn nghiêm túc để phỏng vấn vị trí architect, mình mới vỡ ra: cái động tác "quăng Redis vào" đó kéo theo cả một chuỗi bài toán mà nếu không hiểu, bạn sẽ vá được chỗ này lại lòi chỗ kia.
Mình muốn kể lại cả hành trình này, vì khi xâu chuỗi lại nó đẹp và logic hơn mình tưởng nhiều. Và có một câu mình treo trên đầu suốt lúc viết — câu nói đùa kinh điển của dân làm nghề:
"Trong khoa học máy tính chỉ có hai thứ thật sự khó: vô hiệu hoá cache, và đặt tên biến."
— Phil Karlton
Hồi xưa mình nghe câu này thấy buồn cười. Bây giờ mình thấy nó không đùa chút nào. Gần như mọi thứ rắc rối trong bài này đều quy về đúng một chữ: invalidation — biết khi nào dữ liệu trong cache đã cũ, và xử lý nó cho đúng.
Trước hết: cache ở khắp mọi nơi
Phải nói rõ một điều, vì hồi xưa mình hay quên: cache có ở khắp cái stack của chúng ta, mỗi tầng giải một bài toán khác nhau.
Phần mình kể tập trung vào cái mà backend đụng nhiều nhất: application-level cache, kiểu đặt một con Redis trước database để gánh traffic đọc. Mọi thứ dưới đây xoay quanh đúng cái lớp đó.
Cache-Aside, và chi tiết nhỏ nhiều người làm sai
Bắt đầu từ pattern phổ biến nhất: Cache-Aside, hay Lazy Loading. Luồng đọc thế này: request tới, ngó vào cache trước; có thì trả về luôn (cache hit); không có thì xuống database lấy, nhét vào cache rồi mới trả về (cache miss). Đơn giản, và nó phổ biến vì một lý do mình thấy rất đáng giá: nếu Redis chết, app vẫn chạy — chỉ là chậm hơn, đi thẳng xuống DB. Cache là phụ kiện tăng tốc, không phải đường sống.
Còn luồng ghi, khi dữ liệu thay đổi, thì có một chi tiết tinh tế mà nhiều người làm sai: nên XÓA key trong cache, chứ đừng UPDATE lại cache.
Lý do là chuyện thứ tự. Nếu hai request ghi xảy ra gần như đồng thời, thứ tự ghi-đè-cache có thể bị đảo: request chậm hơn (mang giá trị cũ) lại ghi vào cache sau request nhanh hơn (mang giá trị mới), khiến cache ôm giá trị cũ dù database đã là giá trị mới — và nó nằm đó mãi. Còn nếu chỉ xóa, thì lần đọc kế tiếp luôn dựng lại sự thật từ DB. Xóa là thao tác tự lành: không có kiểu "giá trị sai kẹt trong cache", tệ nhất chỉ là một lần cache miss.
Cái giá: mỗi lần ghi, bạn trả thêm một cache miss ở lần đọc kế tiếp. Rẻ chán so với việc ôm dữ liệu sai. (Đây cũng đúng là cách Facebook làm trong bài "Scaling Memcache" — họ invalidate chứ không update, vì chính cái lý do đảo thứ tự này.)
TTL: cái lưới mình từng nghĩ thừa
Một bài học mình từng xem nhẹ: TTL là bắt buộc, kể cả khi bạn đã chủ động xóa cache mỗi lần ghi.
Tại sao? Vì việc "xóa cache khi có thay đổi" dựa vào event — mà event thì có thể miss, có thể bug, có thể bị nuốt mất ở đâu đó. Nếu một lệnh xóa bị lọt, cache của bạn sẽ stale vĩnh viễn. TTL chính là lưới an toàn cuối cùng: dù có sai thì cũng chỉ sai trong vài giây hoặc vài phút rồi tự sửa, chứ không bao giờ sai mãi mãi. Mình hay nghĩ thế này: invalidation là đường nhanh để tươi mới, còn TTL là dây bảo hiểm để không bao giờ sai vĩnh viễn.
Bộ ba khiến database gục
Rồi tới bộ ba vấn đề kinh điển mà hồi xưa mình không hề biết tên, nhưng thực ra đã từng gặp ngoài production. Cả ba đều có chung một kết cục: cache không đỡ được, traffic dội thẳng xuống DB.
Ai đó spam query một id không hề có trong DB. Cache miss liên tục (vì có gì đâu mà cache), mỗi lần lại dội xuống database, mà DB cũng chẳng có gì để trả — vòng lặp đó bào chết DB. Cách xử lý: cache luôn cả kết quả rỗng với TTL ngắn, hoặc dùng Bloom filter chặn từ đầu những id chắc chắn không tồn tại (Bloom filter không bao giờ báo nhầm "không có" thành "có", nên dùng làm cửa chặn rất an toàn).
Tưởng tượng bạn nạp một triệu sản phẩm vào cache cùng một lúc, TTL y hệt nhau là một giờ. Đúng một giờ sau, tất cả hết hạn cùng một khoảnh khắc, toàn bộ traffic đổ ụp xuống database trong một giây, và DB sập. Cách chữa đơn giản mà hiệu quả bất ngờ: thêm một khoảng random vào TTL, để các key hết hạn rải đều ra thay vì dồn cục một chỗ.
Một sản phẩm đang flash sale, mấy chục nghìn request mỗi giây. Đúng cái giây nó hết hạn, hàng nghìn request cùng miss một lúc, cùng lao xuống DB query đúng một dòng — hiện tượng này gọi là thundering herd. (Khác với avalanche: avalanche là nhiều key, breakdown là một key cực nóng.) Cách xử lý: dùng một cái lock, chỉ cho duy nhất một request đi load DB rồi nạp lại cache, đám còn lại chờ một nhịp; hoặc dùng logical expiration — không để key hết hạn cứng, mà nhúng "hạn dùng" vào trong giá trị và để một worker nền âm thầm refresh.
Bài "Scaling Memcache at Facebook" có một con số mình rất thích về đúng cái thundering herd này. Họ thêm cơ chế lease — khi một key miss, chỉ phát một "vé" cho đúng một client đi dựng lại giá trị, phần còn lại chờ. Kết quả: lúc cao điểm, lượng query dội xuống DB tụt từ 17.000/giây xuống còn 1.300/giây. Cùng một bài toán hot key, giải đúng thì gấp mười mấy lần.
Câu hỏi khó nhất: xóa cache trước hay ghi DB trước?
Nhưng phần làm mình tâm đắc nhất, và cũng khó nhất, là chuyện nhất quán giữa cache và database. Câu hỏi nghe thì đơn giản: khi cập nhật dữ liệu, nên ghi DB trước rồi xóa cache, hay xóa cache trước rồi ghi DB?
Mình từng nghĩ xóa cache trước an toàn hơn — nếu ghi DB lỗi thì cùng lắm tốn một cache miss. Nhưng đó mới chỉ là một nửa câu chuyện. Cái nửa quan trọng hơn là chuyện hai request chạy đồng thời.
Cửa sổ giữa "xóa cache" và "ghi DB xong" khá rộng, và một request đọc chen vào đúng lúc này sẽ đọc giá trị cũ rồi nạp ngược nó vào cache. Ngược lại, nếu ghi DB trước rồi xóa cache sau, cửa sổ stale hẹp hơn nhiều — vì thao tác ghi thường chậm hơn thao tác đọc, nên cái khe để một reader chen vào vừa-đọc-vừa-nạp gần như khép lại. Đó là lý do khuyến nghị chuẩn của ngành là ghi DB trước, xóa cache sau, dù thoạt nghe có vẻ ngược trực giác.
Mà kể cả chọn đúng thứ tự thì vẫn còn khe hở nhỏ. Để vá triệt để, người ta xếp nhiều lớp:
- TTL làm nền — dây bảo hiểm, đã nói ở trên.
- Delayed double delete — xóa cache, ghi DB, chờ một chút rồi xóa cache lần nữa, để dọn sạch giá trị cũ mà một request đọc có thể đã kịp nạp vào trong lúc ghi.
- Retry queue — nếu lệnh xóa cache thất bại (Redis chớp một cái), đừng bỏ qua; đẩy nó vào hàng đợi để thử lại cho tới khi xóa được.
- CDC — cao cấp nhất: đọc binlog/WAL của database bằng công cụ như Debezium, hễ DB thay đổi thật là tự động phát tín hiệu để xóa cache. Không miss, và tách hẳn khỏi business code.
Khoảnh khắc vỡ òa: hoá ra là Dual Write Problem
Tới đây thì mình có một khoảnh khắc đứng hình: bài toán "ghi DB rồi xóa cache" thực ra CHÍNH LÀ cái Dual Write Problem mà mình từng gặp khi làm với message queue. Lại là chuyện ghi vào hai hệ thống (DB và cache) mà không có transaction chung — y hệt chuyện ghi DB rồi phát event lên broker.
Mình từng viết cả một bài về việc tách microservices đẻ ra Dual Write Problem, rồi Outbox và idempotency vá nó thế nào. Hôm nay nó hiện về dưới lớp vỏ "cache cũ không đồng bộ với DB". Vì bản chất giống nhau, lời giải cũng giống nhau: đừng tự tay ghi vào hai nơi trong code; hãy chọn một nguồn sự thật duy nhất — thường là log của database — làm gốc, rồi đồng bộ cái còn lại từ đó. Đúng là cái Outbox/CDC mình từng dùng cho event, giờ áp cho cache.
Khi nhận ra cùng một bài toán nấp dưới nhiều lớp vỏ khác nhau, mình thấy mình bắt đầu tư duy giống một architect hơn — bớt nhớ vẹt từng pattern, bắt đầu thấy cái khung chung phía sau.
Không phải dữ liệu nào cũng cache như nhau
Một bài học nữa, về cách nghĩ chứ không phải về kỹ thuật: không phải dữ liệu nào cũng nên cache như nhau.
- Tên sản phẩm, mô tả, hình ảnh — gần như không đổi. Cache TTL dài thoải mái.
- Giá — đổi thỉnh thoảng. Cache vừa phải, và xóa khi có thay đổi.
- Tồn kho — đổi liên tục và cần chính xác. Đây là chỗ phải cẩn thận nhất.
Với tồn kho, mình tách làm hai mức. Con số hiển thị trên màn hình danh sách thì không cần chính xác tuyệt đối — cache TTL vài giây, chấp nhận hơi cũ, không ai chết vì điều đó. Nhưng lúc khách bấm chốt đơn và trừ kho thật thì TUYỆT ĐỐI không quyết định dựa trên cache, mà đi thẳng vào nguồn sự thật bằng một thao tác atomic — vì bán quá số lượng (oversell) là lỗi không được phép xảy ra.
Cái giá: cache là để tăng tốc đọc, không phải để quyết định những nghiệp vụ cần chính xác từng đơn vị. Biết chỗ nào được phép sai một chút, và chỗ nào tuyệt đối không, mới là phần khó.
Redis không phải cái kho key-value — nó là data structure server
Phần này mình muốn nói riêng, vì nhiều người (có cả mình ngày xưa) chỉ coi Redis là cái kho key-value cho nhanh. Thực ra nó là một data structure server — value là cấu trúc dữ liệu có kiểu, không phải cục blob mù.
- Hash — lưu object theo từng field, nên update đúng một field tồn kho mà không phải ghi lại cả object.
- Sorted Set — làm leaderboard, rate limiter, hàng đợi ưu tiên (member sắp theo score).
- HyperLogLog — đếm số lượng unique xấp xỉ, sai số chỉ ~0,81%, mỗi key tốn tối đa ~12 KB mà đếm được tới 2⁶⁴ phần tử. Đếm unique visitor hàng tỷ mà tốn mười mấy KB.
- Bitmap — cờ nhị phân siêu gọn (active user theo ngày, feature flag).
- Geo — tìm theo bán kính, "quán gần tôi nhất".
- Stream — append-only log có consumer group, làm hàng đợi có ack đàng hoàng.
Biết chọn đúng cấu trúc cho đúng bài toán là một nửa sức mạnh của Redis. Nửa còn lại nằm ở chỗ hiểu nó chạy thế nào bên trong.
Vài thứ về Redis nên nắm cho sâu
Eviction policy. Khi RAM đầy, Redis phải quyết định xóa key nào. Bạn nên phân biệt LRU (xóa cái lâu rồi không dùng) với LFU (xóa cái ít được dùng thường xuyên) tùy access pattern của mình. Một chi tiết hay: Redis không theo dõi LRU/LFU chính xác cho từng key (tốn bộ nhớ quá), mà lấy mẫu ngẫu nhiên một nhúm key rồi chọn nạn nhân — gần đúng, đủ tốt, mà rẻ.
Single-thread. Redis xử lý lệnh trên một luồng duy nhất — đó là lý do nó nhanh và cũng là lý do tuyệt đối tránh các lệnh nặng như KEYS trên production (nó quét cả keyspace, khoá cứng server; dùng SCAN để duyệt theo cursor). Nhưng cái tính single-thread đó lại biến thành siêu năng lực khi cần atomic: gói một đoạn logic vào Lua script, Redis chạy trọn vẹn đoạn đó mà không cho lệnh nào chen vào. Nhờ vậy rate limiter hay trừ kho mới chống được race condition giữa hàng nghìn server — "kiểm tra còn hàng rồi trừ" gộp thành một bước không thể tách.
Độ bền. Redis có RDB (chụp snapshot — gọn, restore nhanh, nhưng mất dữ liệu giữa hai lần chụp) và AOF (ghi log mọi lệnh — bền hơn, thường fsync mỗi giây nên mất tối đa ~1 giây, nhưng nặng hơn). Production thường bật cả hai.
Scale. Khi một con Redis không gánh nổi: replication để scale đọc, Sentinel để tự động failover khi master chết, và Cluster để sharding dữ liệu ra nhiều node (chia 16.384 hash slot, key băm vào slot, slot trải trên các node).
Caching không bao giờ miễn phí
Ngồi viết lại hết những thứ này, mình nhận ra điều quan trọng nhất không phải từng kỹ thuật riêng lẻ, mà là cách nhìn. Bộ ba sập DB, thứ tự xóa cache, delayed double delete, CDC — tất cả đều là những dòng nợ đẻ ra từ đúng một quyết định ban đầu: "thêm con Redis cho nhanh".
Mỗi lần bạn thêm một lớp cache, bạn đổi tốc độ lấy sự phức tạp về nhất quán. Việc của người làm hệ thống không phải cache thật nhiều cho nhanh, mà là biết cache cái gì, cache bao lâu, chấp nhận sai lệch tới đâu — và chỗ nào thì tuyệt đối không được tin vào cache.
Phil Karlton nói đúng: invalidation là một trong hai thứ khó nhất. Nhưng khó không có nghĩa là né. Khó nghĩa là phải nhìn thấy cái giá trước khi ký vào hoá đơn — chứ không phải quăng con Redis vào rồi đợi production gửi hoá đơn về sau, như mình của mấy năm trước.
Đọc thêm
- trên blog Từ monolith đến idempotency: một sợi dây, không phải sáu flashcardcùng con quái vật Dual Write, lớp vỏ khác
- nguồn Database caching strategies — Cache-Aside & các pattern khácdocs.aws.amazon.com
- nguồn Scaling Memcache at Facebook (NSDI 2013)invalidate thay vì update, cơ chế lease cho hot key
- nguồn Redis key eviction — LRU vs LFU, lấy mẫu gần đúngredis.io
- nguồn Debezium — CDC đọc binlog/WAL để đồng bộdebezium.io