Cache: Strategies và Patterns
1. Tổng quan — 5 Cache Patterns
Có 5 pattern cơ bản, khác nhau ở câu hỏi: ai chịu trách nhiệm populate cache và khi nào?
Pattern Populate by Read flow Write flow
─────────────────────────────────────────────────────────────────
Cache-Aside Application App checks cache App writes DB, invalidates cache
Read-Through Cache layer Cache fetches DB App writes DB only
Write-Through Cache layer App reads cache App writes cache → cache writes DB
Write-Behind Cache layer App reads cache App writes cache → async write DB
Write-Around Application App reads cache App writes DB directly, skip cache
2. Cache-Aside (Lazy Loading)
Pattern phổ biến nhất, còn gọi là Lazy Loading. Application tự quản lý cache — DB và cache không biết nhau.
flowchart TB
subgraph R["READ"]
C["Client"] --> A["App Server"]
A --> CR["Cache (Redis)"]
CR --> H{"HIT / MISS"}
H -->|HIT| RD["Return data"]
H -->|MISS| DB["Database (query)"]
DB --> SET["Cache.SET(key, val, TTL)"]
SET --> RD
end
subgraph W["WRITE"]
CW["Client"] --> AW["App Server"]
AW --> DBW["Database.WRITE(data)"]
AW --> DEL["Cache.DELETE(key)"]
endREAD:
Client ──► App Server
│
▼
Cache (Redis)
│
┌──HIT─┴──MISS──┐
│ │
▼ ▼
Return Database
data (query)
│
▼
Cache.SET(key, val, TTL)
│
▼
Return data
WRITE:
Client ──► App Server
│
├──► Database.WRITE(data)
│
└──► Cache.DELETE(key) ← invalidate, không ghi vào cache
Go implementation
type UserService struct {
redis *redis.Client
db *sql.DB
}
func (s *UserService) GetUser(ctx context.Context, userID int64) (*User, error) {
cacheKey := fmt.Sprintf("user:%d", userID)
// 1. Thử đọc từ cache
data, err := s.redis.Get(ctx, cacheKey).Bytes()
if err == nil {
var user User
if err := json.Unmarshal(data, &user); err == nil {
return &user, nil // cache hit
}
}
// 2. Cache miss → đọc từ DB
user, err := s.queryUserFromDB(ctx, userID)
if err != nil {
return nil, err
}
// 3. Populate cache (fire-and-forget hoặc với error handling)
if data, err := json.Marshal(user); err == nil {
s.redis.Set(ctx, cacheKey, data, 5*time.Minute)
}
return user, nil
}
func (s *UserService) UpdateUser(ctx context.Context, user *User) error {
// 1. Ghi vào DB trước
if err := s.writeUserToDB(ctx, user); err != nil {
return err
}
// 2. Xóa cache (không ghi vào cache — tránh race condition)
s.redis.Del(ctx, fmt.Sprintf("user:%d", user.ID))
return nil
}
Trade-offs
Ưu điểm:
+ Cache chỉ chứa data được request thực sự → ít wasted memory
+ DB và cache độc lập — cache có thể restart mà không mất data
+ Flexible: application quyết định caching logic
Nhược điểm:
- Cold start: cache trống, 3 requests đầu tiên đều hit DB
- Race condition: nếu 2 writes xảy ra đồng thời, cache có thể stale
- Cache miss penalty: user phải chờ lâu hơn cho lần đầu
Race condition ví dụ:
Thread A: đọc DB → nhận val=1
Thread B: ghi DB val=2 → DELETE cache
Thread A: SET cache val=1 ← stale! Cache chứa val cũ
💡 Interview: "Tại sao delete cache thay vì update cache khi write?" — Vì update cache có thể gây race condition (write 1 xảy ra sau write 2 nhưng set cache trước → stale). Delete là safer: worst case là cache miss, không phải stale data.
3. Read-Through
Cache layer đứng giữa app và DB. Khi cache miss, cache tự fetch từ DB, không phải application.
flowchart TB
subgraph RT["READ"]
A["App"] --> C["Cache Layer"]
C --> HM{"HIT / MISS"}
HM -->|HIT| R["Return data"]
HM -->|MISS| F["Cache fetches from Database"]
F --> S["Cache stores + returns data"]
end
subgraph WT["WRITE (giống Cache-Aside)"]
AW["App"] --> DB["Database (trực tiếp)"]
AW --> INV["Cache invalidated/updated"]
endREAD:
App ──► Cache Layer
│
┌─HIT─┴─MISS─┐
│ │
▼ ▼
Return Cache fetches
data from Database
│
▼
Cache stores
+ returns data
WRITE (giống Cache-Aside):
App ──► Database (trực tiếp)
Cache ──► invalidated hoặc updated
Khác Cache-Aside ở chỗ nào?
Cache-Aside:
App code:
val = redis.GET(key)
if miss:
val = db.query(...)
redis.SET(key, val)
Read-Through:
App code:
val = cacheLayer.GET(key) // một dòng duy nhất
// cache layer tự lo việc fetch DB nếu miss
Với Go, thường implement bằng wrapper hoặc library như groupcache (từ Brad Fitzpatrick, author của memcached):
// groupcache tự động deduplicate concurrent requests cho cùng key
// (giải quyết thundering herd tự động)
var userCache = groupcache.NewGroup("users", 64<<20, // 64MB
groupcache.GetterFunc(func(ctx context.Context, key string, dest groupcache.Sink) error {
userID, _ := strconv.ParseInt(key, 10, 64)
user, err := db.QueryUser(ctx, userID)
if err != nil {
return err
}
data, _ := json.Marshal(user)
return dest.SetBytes(data)
}),
)
func GetUser(ctx context.Context, userID int64) (*User, error) {
var data []byte
err := userCache.Get(ctx, strconv.FormatInt(userID, 10),
groupcache.AllocatingByteSliceSink(&data))
if err != nil {
return nil, err
}
var user User
json.Unmarshal(data, &user)
return &user, nil
}
Trade-offs
Ưu điểm:
+ Application code gọn hơn (không cần viết cache logic)
+ Cache layer có thể tối ưu (request coalescing, prefetching)
+ Consistent: mọi read đều qua một điểm
Nhược điểm:
- Coupling: cache layer phải biết cách query DB
- Khó customize: không dễ implement complex caching logic
- Thêm một tầng trừu tượng → khó debug
4. Write-Through
Mọi write đều đi qua cache → cache đồng bộ ghi vào DB. Cache và DB luôn nhất quán.
flowchart TB
subgraph WTT["WRITE"]
A["App"] --> C["Cache Layer"]
C --> D["Database"]
C --> RS["Return success (sau khi cả 2 ghi xong)"]
end
subgraph RTT["READ"]
AR["App"] --> CR["Cache Layer (đã warm)"]
CR --> HIT["Always HIT (trừ cold start)"]
HIT --> RD["Return data"]
endWRITE:
App ──► Cache Layer ──► Database
│
└──► return success (sau khi cả 2 ghi xong)
READ:
App ──► Cache Layer (luôn có data, vì write đã populate)
│
Always HIT (trừ cold start)
│
▼
Return data
type WriteThroughCache struct {
redis *redis.Client
db *sql.DB
}
func (c *WriteThroughCache) Set(ctx context.Context, key string, user *User) error {
// 1. Ghi vào DB trước
if err := c.db.WriteUser(ctx, user); err != nil {
return fmt.Errorf("db write failed: %w", err)
}
// 2. Ghi vào cache (synchronous)
data, _ := json.Marshal(user)
if err := c.redis.Set(ctx, key, data, 24*time.Hour).Err(); err != nil {
// Cache write failed — không rollback DB, chấp nhận inconsistency tạm thời
// Background job sẽ reconcile sau
log.Warnf("cache write failed for key %s: %v", key, err)
}
return nil
}
Trade-offs
Ưu điểm:
+ Cache luôn có data mới nhất → ít cache miss
+ Read rất nhanh (cache luôn warm)
+ Đơn giản về consistency
Nhược điểm:
- Write latency cao hơn (phải chờ cả DB và cache)
- Cache bị fill với data ít được đọc (write nhiều, read ít)
- Nếu cache write fail → inconsistency
Use case tốt nhất:
- Write và Read đều frequent cho cùng data
- User profile, session data, inventory count
💡 Interview: "Write-through vs Cache-aside khi nào dùng cái nào?" — Write-through khi không muốn cache miss trên read (luôn cần fresh data, e.g. user session). Cache-aside khi write nhiều nhưng chỉ một số keys được đọc lại (e.g. product catalog — chỉ popular products cần cache).
5. Write-Behind (Write-Back)
App ghi vào cache, cache ghi vào DB sau (bất đồng bộ). Write latency thấp nhất, nhưng có risk mất data.
flowchart TB
subgraph WBW["WRITE"]
A["App"] --> C["Cache"]
C --> RS["Return success ngay (~1ms)"]
C -->|async| D["Database"]
end
subgraph WBR["READ"]
AR["App"] --> CR["Cache (primary)"]
endWRITE:
App ──► Cache ──► return success (ngay lập tức, ~1ms)
│
│ (async, sau vài giây)
▼
Database
READ:
App ──► Cache (luôn có data mới nhất, vì cache là primary)
type WriteBehindCache struct {
redis *redis.Client
db *sql.DB
writeQ chan writeJob
}
type writeJob struct {
key string
data []byte
}
func NewWriteBehindCache(redis *redis.Client, db *sql.DB) *WriteBehindCache {
c := &WriteBehindCache{
redis: redis,
db: db,
writeQ: make(chan writeJob, 10000), // buffered queue
}
// Background goroutine flush to DB
go c.flushWorker()
return c
}
func (c *WriteBehindCache) Set(ctx context.Context, key string, user *User) error {
data, _ := json.Marshal(user)
// Ghi vào cache ngay
if err := c.redis.Set(ctx, key, data, 1*time.Hour).Err(); err != nil {
return err
}
// Enqueue để ghi DB async
select {
case c.writeQ <- writeJob{key: key, data: data}:
default:
// Queue full — ghi DB synchronously như fallback
return c.db.WriteUser(ctx, user)
}
return nil
}
func (c *WriteBehindCache) flushWorker() {
ticker := time.NewTicker(100 * time.Millisecond)
batch := make([]writeJob, 0, 100)
for {
select {
case job := <-c.writeQ:
batch = append(batch, job)
if len(batch) >= 100 {
c.flushBatch(batch)
batch = batch[:0]
}
case <-ticker.C:
if len(batch) > 0 {
c.flushBatch(batch)
batch = batch[:0]
}
}
}
}
Trade-offs
Ưu điểm:
+ Write latency cực thấp (~1ms thay vì 10-100ms)
+ Batching DB writes → throughput cao hơn
+ Absorb write spikes tốt
Nhược điểm:
- Risk mất data nếu cache crash trước khi flush DB
- Complexity cao: phải handle retry, ordering, crash recovery
- Inconsistency window: DB có thể stale trong vài giây/phút
Use case:
- Metrics counter (view count, like count) — mất vài count không sao
- Session activity timestamp
- Game score updates
- Logging aggregation
KHÔNG dùng cho:
- Financial transactions
- Inventory/stock deduction (overselling!)
- Bất kỳ thứ gì cần ACID guarantees
6. Write-Around
App ghi thẳng vào DB, bỏ qua cache hoàn toàn. Cache chỉ được populate khi read (lazy).
flowchart TB
subgraph WAW["WRITE"]
A["App"] --> D["Database (trực tiếp, bỏ qua cache)"]
end
subgraph WAR["READ"]
AR["App"] --> C["Cache"]
C --> HM{"HIT / MISS"}
HM -->|HIT| R["Return data"]
HM -->|MISS| DB["Database"] --> SET["Cache.SET"] --> R
endWRITE:
App ──► Database (trực tiếp, cache không được update)
READ:
App ──► Cache ──MISS──► Database ──► Cache.SET ──► Return
│
HIT (nếu đã từng đọc)
│
▼
Return data
Khi nào dùng Write-Around?
Phù hợp khi:
- Data được ghi một lần, ít khi đọc lại ngay
- Muốn tránh cache bị "polluted" bởi data ít đọc
- Write là bulk operation (ETL, batch import)
Ví dụ: Upload log files, batch user import, data migration
→ Không cần cache những records này ngay sau khi ghi
→ Cache sẽ tự populate khi có user query sau
7. So sánh tổng hợp
Pattern Read latency Write latency Consistency Data loss risk Complexity
────────────────────────────────────────────────────────────────────────────────────────
Cache-Aside Fast (hit) Fast Eventual None Low
Slow (miss)
Read-Through Fast (hit) Fast Eventual None Medium
Slow (miss)
Write-Through Always fast Slow Strong None Medium
Write-Behind Always fast Very fast Eventual HIGH High
Write-Around Slow (1st) Fast Eventual None Low
Fast (repeat)
Decision tree
flowchart TD
Q1{"Cần write latency thấp nhất?"}
Q1 -->|YES| WB["Write-Behind (chấp nhận data loss risk)"]
Q1 -->|NO| Q2{"Cache cần luôn warm?"}
Q2 -->|YES| WT["Write-Through"]
Q2 -->|NO| Q3{"Write nhiều, ít key đọc lại?"}
Q3 -->|YES| WA["Write-Around"]
Q3 -->|NO| CA["Default: Cache-Aside"]Cần write latency thấp nhất có thể?
YES → Write-Behind (nếu chấp nhận data loss risk)
NO ↓
Cache cần luôn warm (không muốn cache miss)?
YES → Write-Through
NO ↓
Data được write nhiều nhưng chỉ ít key được đọc lại?
YES → Write-Around (tránh pollute cache)
NO ↓
Default: Cache-Aside (đơn giản, linh hoạt, phổ biến nhất)
💡 Interview: "Hệ thống của bạn đang dùng pattern nào và tại sao?" — Hầu hết production systems dùng Cache-Aside vì đơn giản và flexible. Write-Through cho session data. Write-Behind cho analytics counters. Biết trade-off là điều quan trọng hơn biết tên pattern.
8. Một số lỗi phổ biến
1. Cache everything blindly
Vấn đề: cache fill với data ít dùng, evict data quan trọng
Fix: chỉ cache hot data, monitor hit rate theo key pattern
2. TTL quá dài
Vấn đề: user thấy stale data lâu sau khi data đổi
Fix: kết hợp TTL hợp lý + event-based invalidation
3. TTL quá ngắn
Vấn đề: cache miss rate cao, DB bị hammer
Fix: analyze read/write ratio, set TTL >= average time between writes
4. Cache key không đủ specific
Bug kinh điển:
GET /users?page=1 → cache key "users"
GET /users?page=2 → cache key "users" → trả về page 1!
Fix: include tất cả query params vào cache key
key = "users:page=1:limit=20:sort=created_at"
5. Không có cache stampede protection
Vấn đề: 10,000 concurrent requests, cache expire → tất cả hit DB
Fix: xem file invalidation-and-consistency.md