Cache nâng cao — penetration, stampede, avalanche, hot key, lệch dữ liệu

Bài Tối ưu Laravel (Phần 3) nói cache cơ bản: cache ở tầng nào, Cache::remember, và nhắc qua stampede. Bài này đi sâu vào 6 cách cache hỏng — đúng những thứ CV đang ghi (bloom filter, avalanche, hot key…) và interviewer chắc chắn hỏi dồn. Mỗi cách có cơ chế, số đo thật, và chỗ cách chữa lại tự gãy.

Bối cảnh xuyên suốt: LMS phục vụ sinh viên, cao điểm là sáng ngày thi — vài nghìn người mở cùng một trang trong vài giây.

Thuật ngữ dùng trong bài

TừNghĩa
cache-aside (read-aside)App tự đọc cache; miss thì đọc DB rồi tự ghi vào cache. Cache không biết gì về DB. Đây là kiểu Cache::remember
hit / missTìm thấy trong cache / không thấy, phải xuống DB
cache penetrationXuyên thủng cache — hỏi key không bao giờ tồn tại, nên lần nào cũng miss và rơi xuống DB
cache stampede (breakdown, dogpile)Một key nóng hết hạn, hàng trăm request cùng miss và cùng chạy lại query nặng
cache avalancheTuyết lở — rất nhiều key hết hạn cùng lúc, hoặc mất cả cache (Redis restart, flush), DB đỡ toàn bộ tải
hot keyMột key nhận lượng request quá lớn, dồn hết vào một luồng / một node Redis
big keyMột key quá to (value vài MB, hash hàng triệu field)
bloom filterCấu trúc xác suất: trả lời "chắc chắn không có" hoặc "có thể có". Rất ít RAM
false positiveBáo "có thể có" nhưng thật ra không có
jitterCộng một khoảng ngẫu nhiên vào TTL để các key không hết hạn cùng giây
stale-while-revalidate (SWR)Trả bản cũ ngay, rồi làm mới ở phía sau. Laravel: Cache::flexible()
CAS (compare-and-set)Chỉ ghi khi giá trị hiện tại đúng như mình mong đợi (ví dụ version cũ hơn)
L1 / L2 cacheL1: cache trong RAM của chính process/node (APCu). L2: cache dùng chung qua mạng (Redis)

Môi trường kiểm chứng. Mọi output trong bài chạy thật trên Redis 8.8.0 (container tạm tmp-redis-lab, không đụng chiennn_redis của project), Laravel 12.64.0 + phpredis 6.3.0 + PHP 8.3.33 (image laravel-app, mount code read-only), và PostgreSQL 18.4 (my-test-pgdb). Máy dev chạy qua docker exec — đọc tỉ lệ, đừng đọc số tuyệt đối. Chỗ nào chưa verify được sẽ ghi rõ.

1. Nó là gì / chạy thế nào

1.1. Cache-aside — gốc của mọi vấn đề

flowchart LR
  subgraph READ["Đường đọc"]
    R1["Request"] --> G["GET key"]
    G -->|"hit"| RET["Trả về"]
    G -->|"miss"| Q["Query DB"]
    Q --> S["SET key EX ttl"]
    S --> RET
  end
  subgraph WRITE["Đường ghi"]
    W1["UPDATE DB"] --> CM["COMMIT"]
    CM --> D["DEL key"]
  end

Nhìn sơ đồ thì thấy ngay 6 chỗ hỏng. Mỗi chỗ ứng với một mũi tên:

Kiểu hỏngMũi tên bị hỏngDấu hiệu trên dashboardCách chữa chính
Penetrationmiss → DB không có → không SET → lần sau lại missHit ratio thấp bất thường, DB có nhiều query trả 0 dòngValidate input, cache giá trị rỗng, bloom filter
StampedeMột key hết hạn → nhiều request cùng đi nhánh missDB CPU nhảy vọt đúng chu kỳ TTLLock, Cache::flexible, làm mới trước bằng scheduler
AvalancheNhiều key cùng đi nhánh missDB sập ngay sau deploy / sau Redis restartJitter, không flush cache khi deploy, giới hạn tải xuống DB
Hot keyNhánh hit quá nhiều cho một keyRedis 1 core 100%, các core khác rảnhL1 cache trong process, nhân bản key
Big keyGET/DEL một key quá toLatency Redis giật từng đợt, băng thông mạng caoChia nhỏ key, UNLINK
Lệch dữ liệuQuery DB (đọc bản cũ) chen giữa COMMIT và DELKhông có dấu hiệu gì — chỉ có người dùng báo "sao vẫn thấy dữ liệu cũ"Xoá sau commit, double delete, version CAS

1.2. Cache penetration — hỏi thứ không tồn tại

sequenceDiagram
  participant B as Bot
  participant A as App
  participant C as Redis
  participant DB as PostgreSQL
  B->>A: GET /courses/99999123
  A->>C: GET course:99999123
  C-->>A: nil (miss)
  A->>DB: SELECT ... WHERE id = 99999123
  DB-->>A: 0 dòng
  Note over A: không có gì để SET vào cache
  B->>A: GET /courses/99999124 (id khác)
  Note over A,DB: lại miss, lại xuống DB, mãi mãi

Cache chỉ nhớ thứ có. Ai gửi id ngẫu nhiên (bot, kẻ tấn công, hay chỉ là link hỏng trong email cũ) thì 100% request đi thẳng xuống DB. Có ba lớp chữa, từ rẻ tới đắt.

Lớp 1 — validate input (rẻ nhất, hay bị quên)

Id phải là số nguyên dương, nằm trong khoảng hợp lý. Route model binding của Laravel với whereNumber('course') chặn được id dạng chữ trước khi chạm DB. Không chặn được id số hợp lệ nhưng không tồn tại — cần lớp 2.

Lớp 2 — cache cả kết quả "không có"

Bẫy lớn nhất ở đây nằm trong chính Laravel. Đọc source Repository::remember() (Illuminate/Cache/Repository.php:553):

$value = $this->get($key);
if (! is_null($value)) {      // null bị coi là MISS
    return $value;
}
$value = $callback();
$this->put($key, $value, value($ttl, $value));

Callback trả về null (vì Course::find() không thấy) thì lần sau vẫn là miss. Chạy thật, gọi remember 3 lần liên tiếp cho cùng một key:

callback tra ve null:  goi DB 3/3 lan     # KHONG cache duoc
callback tra ve false: goi DB 1/3 lan
callback tra ve []:    goi DB 1/3 lan

Vậy phải đổi null thành một giá trị canh gác (sentinel), và cho nó TTL ngắn hơn hẳn key thật:

$course = Cache::remember("course:v1:{$id}", fn ($v) => $v === false ? 60 : 3600,
    fn () => Course::find($id) ?? false);   // false = "chac chan khong co"

abort_if($course === false, 404);

TTL dạng closure fn ($v) => ... chạy được vì remember gọi value($ttl, $value) — nhận chính giá trị vừa tính.

Mặt trái: bot gửi 1 triệu id khác nhau → bạn ghi 1 triệu key rác vào Redis. Redis đầy RAM → allkeys-lru đuổi bớt key thật để lấy chỗ cho key rác → hit ratio của key thật cũng rơi. Lớp 2 chặn được tải DB nhưng dời vấn đề sang RAM Redis. Vì vậy TTL key rỗng phải ngắn (30–60 giây), và bot phải bị chặn bằng rate limit ở tầng trước.

Lớp 3 — bloom filter: hỏi "có thể có không" trước khi hỏi cache

flowchart LR
  R["Request id"] --> BF{"BF.EXISTS courses id"}
  BF -->|"0 = chắc chắn không có"| N["404 ngay
không chạm cache, không chạm DB"] BF -->|"1 = có thể có"| C["GET cache"] C -->|"miss"| DB["Query DB"]

Bloom filter là một mảng bit. Thêm phần tử = băm nó ra k vị trí và bật bit. Hỏi = xem k vị trí đó có bật hết không. Vì bit của các phần tử khác có thể trùng, nó có thể báo nhầm "có" (false positive) nhưng không bao giờ báo nhầm "không". Redis 8 có sẵn lệnh BF.*, không cần cài module riêng.

Đo thật: 100.000 id, đặt tỉ lệ sai 1%:

$ redis-cli BF.RESERVE tmp:bf 0.01 100000
# them user:1 .. user:100000 bang BF.ADD

false positive tren 100000 id KHONG ton tai: 468      # 0,47% - duoi muc 1% da dat
id da them ma bao khong co (false negative):  0

MEMORY USAGE bloom: 137976         # ~135 KB
MEMORY USAGE set:   5161847        # ~4,9 MB neu luu SET cua chinh 100k id do

Nhỏ hơn SET 37 lần. Đó là toàn bộ lý do tồn tại của bloom filter.

Thêm quá sức chứa (200.000 id vào filter đặt cho 100.000):

false positive sau khi qua tai: 506 / 100000         # van ~0,5%
Capacity                  300000   # Redis tu them 1 filter con (scaling)
Number of filters         2
Number of items inserted  199456   # them 200000, dem duoc 199456

Hai điều rút ra: (1) Redis tự nới bằng cách xếp thêm filter con, mỗi lần hỏi phải kiểm tra thêm một tầng; (2) bộ đếm sai — 544 id mới bị báo "đã có" lúc thêm (chính là false positive), nên BF.ADD trả 0 và không được đếm. Đừng dùng bloom filter để đếm.

Nói thẳng khi phỏng vấn: LMS có 10.000 khoá học thì bloom filter là thừa — lớp 1 + lớp 2 là đủ. Bloom filter đáng tiền khi tập key rất lớn (hàng chục triệu: mã voucher, username, URL rút gọn) và đang bị tấn công thật. Nếu CV ghi bloom filter, hãy có sẵn câu trả lời "vì sao cần" bằng số liệu.

1.3. Cache stampede — một key nóng hết hạn

Đây là thí nghiệm quan trọng nhất của bài. 50 process PHP chạy song song, cùng một vạch xuất phát (mỗi process bootstrap Laravel xong thì đợi tới cùng một mốc thời gian mới chạy), cùng đọc một key. Query DB giả lập bằng usleep(500ms), số lần query đếm bằng Cache::increment().

CáchSố lần query DBLatency min / median / maxTrả về
Cache::remember, cache trống50513 / 593 / 666 ms50 bản mới
Cache::lock()->block(5) + remember, cache trống1564 / 1374 / 2685 ms50 bản mới
Cache::flexible, cache trống (cold start)50512 / 549 / 696 ms50 bản mới
Cache::flexible, đã có bản cũ quá hạn tươi16 / 86 / 144 ms50 bản cũ

Chú thích: lần chạy đầu không có vạch xuất phát, remember chỉ tính lại 3 lần — vì 50 process khởi động lệch nhau, con đến sau thấy cache đã có. Test stampede mà không đồng bộ điểm xuất phát sẽ ra kết quả đẹp giả.

Đọc từng dòng:

Vì sao flexible không chống được cold start

Source Repository::flexible() (Repository.php:622), rút gọn:

[$key => $value, "illuminate:cache:flexible:created:{$key}" => $created] = $this->many([...]);

if (in_array(null, [$value, $created], true)) {
    return tap(value($callback), ...);     // chua co gi -> goi thang callback, KHONG lock
}
if (($created + $ttl[0]) > now()) {
    return $value;                          // con tuoi
}
defer(function () {                         // qua han tuoi -> tra ban cu, lam moi SAU response
    $this->store->lock("illuminate:cache:flexible:lock:{$key}", $lock['seconds'] ?? 0)
        ->get(fn () => /* tinh lai + putMany */);
});
return $value;

Cold start (sau deploy flush, sau Redis restart, key mới) đi nhánh đầu — không lock. Mà cold start lại chính là lúc nguy hiểm nhất (xem 1.4 avalanche).

Bẫy: lock của flexible không có TTL

Dòng $lock['seconds'] ?? 0 → RedisLock::acquire() với seconds = 0 dùng SETNX không hạn (RedisLock.php:40). Bắt lock đang được giữ trong lúc làm mới:

# Cache::flexible($key, [5, 60], $compute)
db8 laravel-database-clicon_localilluminate:cache:flexible:lock:tmp:stampede  TTL=-1

# Cache::flexible($key, [5, 60], $compute, ['seconds' => 30])
db8 laravel-database-clicon_localilluminate:cache:flexible:lock:tmp:stampede  TTL=30

TTL=-1 = không bao giờ hết hạn. Process đang làm mới mà bị giết (OOM, hết request_terminate_timeout, deploy kill container) thì lock nằm đó mãi. Từ đó flexible không bao giờ làm mới ngầm nữa: trả bản cũ tới hết $ttl[1], rồi rơi về nhánh cold start không lock. Tức là âm thầm biến thành remember. Nếu process chỉ ném exception thì không sao — Lock::get() nhả lock trong finally. Chỉ khi process chết hẳn thì lock mới bị kẹt.

// Luon truyen seconds. Lon hon thoi gian tinh lai cham nhat.
$stats = Cache::flexible("exam:{$examId}:stats", [300, 900], fn () => $this->compute($examId),
    ['seconds' => 30]);

Cách tốt nhất cho key biết trước: đừng để nó hết hạn

Key nóng mà biết trước (thống kê dashboard, danh sách môn học của kỳ thi) thì cho scheduler làm mới mỗi 5 phút, TTL để 30 phút. Request người dùng không bao giờ gặp miss. Stampede biến mất vì không còn "lúc hết hạn".

// routes/console.php
Schedule::call(fn () => Cache::put('exam:today:courses', $this->build(), now()->addMinutes(30)))
    ->everyFiveMinutes()->onOneServer();   // onOneServer: chi 1 node chay

Còn một cách nữa là probabilistic early expiration (XFetch): càng gần hết hạn, mỗi request càng có xác suất cao tự tính lại sớm, không cần lock. Laravel không có sẵn — chưa verify trong bài này.

1.4. Cache avalanche — nhiều key cùng chết

Có hai kiểu:

  1. Cùng TTL, cùng lúc tạo: job warm-up lúc 00:00 nạp 10.000 khoá học với TTL 3600 → 01:00:00 cả 10.000 key cùng hết hạn trong cùng một giây.
  2. Mất cả cache: Redis restart không có persistence, php artisan cache:clear trong script deploy, failover, hoặc đổi cache prefix khi deploy (xem bài cache khi rollback blue-green).
flowchart LR
  subgraph NOJ["TTL cố định 3600s"]
    A1["10.000 key hết hạn
trong 1 giây"] --> A2["10.000 query / giây
DB sập"] end subgraph J["TTL 3600 + random 0..720"] B1["10.000 key rải đều
trong 12 phút"] --> B2["~14 query / giây
DB không để ý"] end

Số trong sơ đồ là phép tính minh hoạ: 10.000 key / 720 giây ≈ 14 key/giây.

// Jitter: cong ngau nhien toi 20% TTL
$ttl = 3600 + random_int(0, 720);
Cache::put("course:v1:{$id}", $course, $ttl);

Jitter chỉ chữa kiểu 1. Kiểu 2 (mất cả cache) thì jitter vô dụng — mọi key đều trống cùng lúc. Kiểu 2 phải chữa bằng:

1.5. Hot key — một key, quá nhiều người đọc

Redis chạy lệnh trên một luồng (io-threads 1 là mặc định, đã kiểm tra bằng CONFIG GET). Ở Redis Cluster, một key nằm trên một node. Thêm node không giúp gì cho một key. Ví dụ LMS: key exam:today:config được 8.000 sinh viên đọc trong đúng phút 8:00.

Hai giới hạn thật:

Tìm hot key

$ redis-cli --hotkeys                  # voi policy mac dinh
Error: ERR An LFU maxmemory policy is not selected, access frequency not tracked.

$ redis-cli CONFIG SET maxmemory-policy allkeys-lfu
# ban 200.000 GET vao key:42, 20.000 GET vao key:7
$ redis-cli OBJECT FREQ key:42   -> 196
$ redis-cli OBJECT FREQ key:7    -> 71
$ redis-cli OBJECT FREQ key:99   -> 0
$ redis-cli --hotkeys
hot key found with counter: 195	keyname: "key:42"
hot key found with counter: 70	keyname: "key:7"

Hai điều cần nhớ: --hotkeys chỉ chạy khi policy là LFU, và bộ đếm là logarit (200.000 lần đọc → 196, trần là 255). Nó cho biết key nào nóng hơn, không cho biết bao nhiêu request. Muốn số thật thì phải đếm ở app (lấy mẫu 1% request, đếm theo key). Trên production đừng dùng MONITOR để tìm hot key — nó in mọi lệnh ra và làm Redis chậm hẳn.

Chữa hot key

CáchChạy thế nàoGiá phải trả
L1 cache trong process (APCu, TTL 1–5 giây)Mỗi node PHP chỉ hỏi Redis 1 lần mỗi vài giây. 10 node → 10 lệnh / 5 giây thay vì 20.000 / giâyCác node lệch nhau tối đa bằng TTL L1. Không xoá được L1 của node khác → chỉ dùng cho dữ liệu chấp nhận cũ vài giây
Cache::memo()Nhớ giá trị trong một request/job (tài liệu Laravel 13 qua Context7, có trong source 12.64)Chỉ chống đọc lặp trong cùng request. Không chống hot key giữa các request
Nhân bản keyGhi N bản exam:config:#0..#9, đọc ngẫu nhiên một bản → ở Cluster tải chia ra nhiều nodeGhi và xoá phải làm N lần. Có lúc các bản lệch nhau
Đẩy lên CDN / HTTP cacheDữ liệu công khai giống nhau cho mọi người → không chạm serverChỉ hợp với dữ liệu không riêng từng user

1.6. Big key — một key quá to

Đo thật một hash 1 triệu field:

MEMORY USAGE tmp:big1: 46277592                  # ~44 MB
$ redis-cli DEL tmp:big1                          # lazyfree-lazy-user-del = no (mac dinh 8.8)
SLOWLOG: 256186 DEL tmp:big1                      # Redis dung im 256 ms, khong phuc vu ai
$ redis-cli UNLINK tmp:big2                       # giai phong RAM o luong nen
                                                  # khong vao slowlog (< 1 ms)

Trong Laravel, big key hay đến từ câu kiểu Cache::remember('all_courses', ..., fn () => Course::with('lessons')->get()): cả collection Eloquent bị serialize thành vài MB. Mỗi lần GET kéo vài MB qua mạng, rồi PHP phải unserialize ra hàng nghìn object. Chữa: cache theo từng item hoặc từng trang, chỉ lưu các cột cần (toArray() của select gọn), không lưu model. Chi tiết DEL/UNLINK ở bài Redis — những thứ cần biết.

1.7. Lệch dữ liệu giữa cache và DB

Có 5 cách giữ cache khớp DB. Chỉ có 1 cách mặc định nên dùng:

CáchChạy thế nàoChỗ gãy
Ghi đè cache sau khi ghi DBUPDATE xong thì SET cache giá trị mớiHai người ghi cùng lúc: A ghi DB=1, B ghi DB=2, B SET cache=2, A SET cache=1 → cache sai tới hết TTL
Xoá cache trước khi ghi DBDEL rồi mới UPDATETrong lúc transaction chưa commit, request khác đọc DB cũ và nạp lại cache cũ. Cửa sổ lỗi bằng cả thời gian transaction
Xoá cache sau commit (mặc định nên dùng)COMMIT rồi DELCòn một cửa sổ hẹp — chứng minh ngay dưới đây
Double delete có trễXoá sau commit, rồi xoá thêm lần nữa sau N ms (delayed job)N là đoán. Reader chậm hơn N vẫn lọt
Version CASCache lưu kèm version, chỉ nhận bản có version mới hơnCần cột version và Lua script. Phức tạp hơn

Chứng minh: xoá sau commit vẫn lệch

Chạy tay từng bước trên Redis + PostgreSQL thật (A là request đọc bị chậm, ví dụ đang GC hay đang chờ CPU):

t1 A: cache miss, doc DB -> price=100 (A bi GC pause / cham)
t2 B: UPDATE price=120, COMMIT, xoa cache -> DEL = 0      # cache dang trong, khong co gi de xoa
t3 A: SET gia tri cu vao cache -> OK
Cache: 100
DB:    120                                                # lech toi het TTL
sequenceDiagram
  participant A as Request A (đọc, chậm)
  participant B as Request B (ghi)
  participant C as Redis
  participant DB as PostgreSQL
  A->>C: GET product:1
  C-->>A: nil
  A->>DB: SELECT price (được 100)
  Note over A: bị chậm vài chục ms
  B->>DB: UPDATE price=120, COMMIT
  B->>C: DEL product:1 (không có gì để xoá)
  A->>C: SET product:1 = 100
  Note over C,DB: cache 100, DB 120, lệch tới hết TTL

Cửa sổ này hẹp (phải trùng đúng lúc cache trống và có reader chậm), nên xoá sau commit vẫn là lựa chọn mặc định. Nhưng nếu dữ liệu đó không được sai (điểm thi, trạng thái thanh toán), dùng version CAS:

-- KEYS[1]=product:1, ARGV[1]=price, ARGV[2]=version, ARGV[3]=ttl
local cur = redis.call("HGET", KEYS[1], "v")
if cur and tonumber(cur) >= tonumber(ARGV[2]) then return 0 end   -- ban cu hon: bo
redis.call("HSET", KEYS[1], "price", ARGV[1], "v", ARGV[2])
redis.call("EXPIRE", KEYS[1], ARGV[3])
return 1
t1 A: doc DB -> price=100 version=1
t2 B: UPDATE -> price=120 version=2, ghi cache version 2 -> 1
t3 A: ghi cache price=100 version=1 -> 0                   # bi tu choi
Cache: price 120 v 2

Hai bẫy riêng của Laravel

  1. Xoá cache bên trong transaction. Observer saved() chạy trước khi transaction commit. Cache::forget() ở đó = "xoá trước khi ghi" (dòng 2 của bảng). Chữa: DB::afterCommit(fn () => Cache::forget($key)) (ManagesTransactions.php:354), hoặc cho observer implement ShouldHandleEventsAfterCommit.
  2. Nạp lại cache từ read replica. Hệ thống có replica (như LMS Uni dùng MySQL primary/replica): ghi vào primary, xoá cache, request kế tiếp đọc từ replica chưa kịp nhận → nạp bản cũ vào cache → lệch tới hết TTL, dù replica lag chỉ có 200 ms. Chữa: đọc từ primary khi nạp lại những key vừa bị xoá (->useWritePdo()), hoặc double delete với độ trễ lớn hơn replica lag.

2. Lợi ích

Kỹ thuậtMua được gì (số đo trong bài)Đáng tiền khi
Cache false thay nullTừ 3/3 xuống 1/3 lần xuống DB cho id không tồn tạiLuôn luôn — gần như miễn phí
Bloom filterChặn 99,5% id rác, RAM nhỏ hơn SET 37 lầnTập key hàng chục triệu, có tấn công thật
Lock khi miss50 query → 1 queryQuery cực nặng, DB là chỗ nghẽn, người dùng đợi thêm được
Cache::flexible1 query, mọi người nhận bản cũ trong <150 msDữ liệu cũ vài phút chấp nhận được, và key đã được nạp sẵn
Scheduler làm mới trướcKhông còn miss trên đường requestTập key nóng nhỏ và biết trước
Jitter10.000 key rải trong 12 phút thay vì 1 giâyNhiều key được tạo cùng lúc (warm-up, import)
L1 cache20.000 lệnh/giây → vài lệnh/giây vào RedisHot key, dữ liệu chấp nhận cũ vài giây
Version CASCache không bao giờ lùi về bản cũDữ liệu không được sai (điểm, tiền)

3. Tác hại / hạn chế — danh sách bẫy đầy đủ

#BẫyHậu quảCách tránh
1remember không cache được nullPenetration vẫn còn dù "đã có cache"Trả false / sentinel, TTL ngắn
2Cache key rỗng cho mọi idBot làm đầy RAM, key thật bị đuổiTTL 30–60 giây, rate limit, validate id
3Bloom filter không xoá được phần tửKhoá học đã xoá vẫn báo "có thể có" → xuống cache/DB (không sai, chỉ tốn)Rebuild định kỳ từ DB
4Thêm vào bloom sau khi insert DBKhoá học vừa tạo bị 404 trong khoảng hởThêm vào bloom trước khi commit insert
5Bloom mất khi Redis mất dữ liệuMọi id thật đều 404Filter trống thì fail open (bỏ qua bloom) cho tới khi rebuild xong
6Lock khi missĐuôi latency 2,7 s, giữ PHP-FPM worker, LockTimeoutException khi quá block(5)Con không lấy được lock thì trả bản cũ/trang rút gọn thay vì xếp hàng
7flexible không chống cold start50 query cùng lúc ngay sau deployWarm-up trước khi nhận traffic, hoặc lock riêng cho lần nạp đầu
8Lock của flexible không TTLProcess chết → không bao giờ làm mới ngầm nữaLuôn truyền ['seconds' => N]
9flexible làm mới trong defer()Callback lỗi thì người dùng không thấy, chỉ có log. Dữ liệu cũ tới hết $ttl[1]Alert trên log lỗi, đặt $ttl[1] vừa phải
10Jitter không chữa mất cả cacheSau Redis restart DB vẫn sậpKhông flush khi deploy, funnel giới hạn tải DB
11L1 cacheCác node lệch nhau vài giây, không xoá được từ xaChỉ cho dữ liệu chấp nhận cũ; TTL L1 rất ngắn
12Nhân bản keyGhi/xoá N lần; các bản lệch nhauChỉ cho key đọc rất nhiều, ghi rất ít
13Xoá cache trong transactionLệch dữ liệu tới hết TTLDB::afterCommit
14Nạp lại cache từ replicaLệch dù đã xoá đúng chỗĐọc primary khi nạp lại
15Cache model Eloquent đã serializeDeploy đổi class → unserialize lỗi hoặc __PHP_Incomplete_ClassLưu array; bump version key — xem bài rollback
16rememberForever cho dữ liệu nghiệp vụQuên xoá một chỗ = sai vĩnh viễnLuôn có TTL tối đa, dù dài

4. Trade-off — chọn cái nào

Vấn đềMặc định nên dùngNâng lên khiĐừng dùng khi
PenetrationValidate + cache false TTL ngắnBloom filter khi tập key > vài triệu và có tấn côngBloom cho vài nghìn key — thêm hạ tầng không lý do
Stampedeflexible (có seconds) + warm-upScheduler làm mới cho key biết trướcLock blocking cho trang người dùng đang chờ
AvalancheJitter + không flush khi deployFunnel giới hạn tải DB, L1 cache—
Hot keyCDN nếu dữ liệu công khaiL1 APCu, rồi nhân bản keyNhân bản key cho dữ liệu hay đổi
Lệch dữ liệuXoá sau commit (afterCommit)Double delete khi có replica; CAS cho dữ liệu nhạy cảmGhi đè cache sau khi ghi DB

Tiêu chí chọn

  1. Dữ liệu cũ bao lâu thì chấp nhận được? Vài phút → flexible, L1. Không được cũ → đừng cache, hoặc CAS.
  2. Tập key lớn cỡ nào? Nhỏ và biết trước → scheduler. Lớn và không biết trước → cache-aside + chống penetration.
  3. Chỗ nghẽn là DB hay Redis? DB → lock/flexible/funnel. Redis → L1, nhân bản key.
  4. Đội có đủ người nuôi không? CAS, bloom, CDC đều phải có người hiểu lúc 3h sáng.

Khi nào KHÔNG cache: dữ liệu phải đúng tuyệt đối tại thời điểm đọc (điểm thi lúc đang chấm, số dư), dữ liệu ghi nhiều hơn đọc, và query vốn đã nhanh (<5 ms có index tốt). Cache một query 8 giây vẫn là query 8 giây — sửa query trước (bài Tối ưu Laravel mục 3.4).

5. Khó khăn khi vận hành thực tế

Vấn đềThực tế
Hit ratio theo từng loại keyRedis chỉ cho keyspace_hits/misses tổng. Muốn biết key course:* hit bao nhiêu thì phải đo ở app: Laravel bắn event CacheHit, CacheMissed (Illuminate/Cache/Events/) → đếm theo prefix, đẩy lên Prometheus
Alert cần cóHit ratio rơi đột ngột (dấu hiệu avalanche/penetration), evicted_keys tăng, DB QPS tăng cùng lúc hit ratio giảm, lỗi trong callback của flexible
Debug 3h sáng: "cache cũ hay DB sai?"Phải có sẵn: lệnh artisan xoá một key (không phải cache:clear), header cho admin để bỏ qua cache, và log key + version khi ghi cache
Tìm lock kẹtredis-cli --scan --pattern '*flexible:lock*' rồi TTL. Thấy -1 là kẹt (bẫy 8)
Đặt tên keyThống nhất <domain>:<version>:<id>. Chú ý CACHE_PREFIX nên có dấu : ở cuối — project này đang ghép thành clicon_localilluminate:..., khó đọc khi SCAN
Con ngườiJunior thấy chậm là thêm Cache::remember mà không nghĩ tới chỗ xoá. Review code phải hỏi: "key này bị xoá ở đâu, dữ liệu cũ bao lâu thì chấp nhận được?"
TestStampede chỉ hiện ra khi test có vạch xuất phát chung (đã thấy ở 1.3). Load test bằng k6 phải có kịch bản "cache trống" riêng

6. Case thực tế và cách xử lý

Case 1 — Deploy lúc 7:50, 7:55 DB CPU 100%, sáng ngày thi

Triệu chứng: deploy xong 5 phút thì DB CPU 100%, trang danh sách môn thi timeout. Hit ratio Redis từ 95% rơi về 10%.

Nguyên nhân: script deploy có php artisan cache:clear (avalanche kiểu 2), cộng thêm flexible không chống cold start (bẫy 7). 3.000 sinh viên vào cùng lúc → 3.000 query nặng.

Xử lý từng bước:

  1. Chặn máu: bật trang tĩnh "đang tải" cho màn nặng nhất, hoặc bật tạm Redis::funnel giới hạn 20 query nặng cùng lúc.
  2. Warm-up tay các key nóng nhất (php artisan exam:warm-cache) — một process, tuần tự.
  3. Sau sự cố: xoá cache:clear khỏi deploy, dùng version trong tên key; thêm bước warm-up trước khi chuyển traffic (blue-green: warm pool mới rồi mới đổi upstream).
  4. Cấm deploy trong khung 1 giờ trước giờ thi.

Case 2 — Bot quét /courses/{id} ngẫu nhiên

Triệu chứng: 3.000 req/s vào /courses/*, DB có hàng nghìn query/giây trả 0 dòng, evicted_keys tăng, hit ratio các trang khác cũng giảm.

Xử lý:

  1. Rate limit theo IP/user ở Nginx hoặc RateLimiter của Laravel — chặn tại cửa.
  2. Kiểm tra code: nếu đang remember(... fn () => Course::find($id)) thì null không được cache (bẫy 1) → đổi sang ?? false, TTL 60 giây.
  3. Xem INFO stats: evicted_keys tăng nghĩa là key rỗng đang đẩy key thật ra (bẫy 2). Giảm TTL key rỗng.
  4. Chỉ khi tập id rất lớn và vẫn bị đánh sau các bước trên thì mới thêm bloom filter.

Case 3 — Giảng viên sửa điểm, sinh viên vẫn thấy điểm cũ 10 phút

Triệu chứng: không phải lần nào cũng bị, chỉ vài lần một tuần, và thường vào giờ cao điểm.

Nguyên nhân có thể: (a) Cache::forget trong observer chạy trước commit (bẫy 13); (b) request nạp lại cache đọc từ replica đang lag (bẫy 14); (c) race ở 1.7.

Xử lý:

  1. Tìm chỗ xoá key điểm, kiểm tra có nằm trong transaction không. Đổi sang DB::afterCommit.
  2. Kiểm tra connection đọc: nếu có replica, nạp lại key điểm bằng useWritePdo().
  3. Điểm là dữ liệu nhạy cảm: thêm version (cột updated_version) và CAS khi ghi cache.
  4. Giảm TTL key điểm để nếu vẫn lọt thì chỉ sai trong thời gian ngắn.

Case 4 — 8:00 Redis một core 100%, các core khác rảnh

Nguyên nhân: hot key exam:today:config — 8.000 sinh viên, mỗi trang đọc 5 lần.

Xử lý:

  1. Xác nhận: CONFIG SET maxmemory-policy allkeys-lfu (nếu đang là LRU), rồi redis-cli --hotkeys.
  2. Nhanh nhất: Cache::memo() để mỗi request chỉ đọc 1 lần thay vì 5.
  3. Tiếp theo: L1 APCu TTL 5 giây cho key đó — cấu hình kỳ thi không đổi trong ngày.
  4. Dài hạn: dữ liệu công khai thì đẩy ra CDN / HTTP cache.

Case 5 — Thống kê dashboard "đứng hình", không bao giờ cập nhật

Triệu chứng: số liệu dashboard chỉ đổi mỗi 15 phút một lần (đúng bằng $ttl[1]), trong khi cấu hình flexible([300, 900]) lẽ ra phải làm mới sau 5 phút.

Nguyên nhân: một lần deploy giết process đang làm mới → lock illuminate:cache:flexible:lock:... kẹt với TTL -1 (bẫy 8).

Xử lý: redis-cli --scan --pattern '*flexible:lock*' → kiểm tra TTL → xoá key kẹt → sửa code, truyền ['seconds' => 30] ở mọi chỗ gọi flexible.

7. Câu hỏi phụ interviewer hay hỏi tiếp

HỏiTrả lời ngắn
Penetration, breakdown, avalanche khác nhau thế nào?Penetration: key không tồn tại. Breakdown/stampede: một key nóng hết hạn. Avalanche: nhiều key hoặc cả cache mất cùng lúc
Bloom filter có false negative không?Không, nếu phần tử đã được thêm trước khi hỏi. Có false positive, tỉ lệ do mình đặt (đo được 0,47% khi đặt 1%). Không xoá được phần tử — cần xoá thì dùng Cuckoo filter
Vì sao không cache null được trong Laravel?remember coi null là miss (Repository.php:560). Trả false hoặc sentinel
Lock hay stale-while-revalidate?Lock: dữ liệu luôn mới, nhưng người dùng xếp hàng (đo được đuôi 2,7 s). SWR: không ai đợi, nhưng nhận bản cũ. Trang người dùng → SWR. Job nền cần số chính xác → lock
Cache::flexible có chống stampede hoàn toàn không?Không. Cold start vẫn 50/50 query (đo thật). Và lock của nó không TTL nếu không truyền seconds
Xoá cache hay cập nhật cache khi ghi?Xoá, sau commit. Cập nhật thì hai người ghi có thể đè nhau theo thứ tự sai
Xoá sau commit rồi thì còn lệch không?Còn, cửa sổ hẹp (đã chứng minh ở 1.7). Chữa bằng double delete có trễ hoặc version CAS
Hot key xử lý thế nào ở Redis Cluster?Thêm node không giúp vì một key ở một slot. Dùng L1 cache, nhân bản key ra nhiều slot, hoặc CDN
Làm sao biết key nào nóng?redis-cli --hotkeys — chỉ chạy với policy LFU, bộ đếm là logarit. Muốn số thật thì đếm ở app
Redis chết thì app làm gì?Cache là tối ưu, không phải nguồn sự thật → fail open: bỏ qua cache, đọc DB, nhưng giới hạn số query đồng thời để DB không sập theo. Laravel 12 có store failover
Cache làm hệ thống khó hơn ở đâu?Có hai nguồn sự thật. Mỗi bug dữ liệu đều phải hỏi "cache hay DB?". Chi phí thật nằm ở lúc debug, không phải lúc viết

Tóm tắt

  1. Penetration: validate id, cache false (Laravel không cache được null), TTL ngắn. Bloom filter chỉ khi tập key rất lớn.
  2. Stampede: remember = 50 query; lock = 1 query nhưng đuôi 2,7 s; flexible = 1 query và không ai đợi, trừ lúc cold start. Luôn truyền ['seconds' => N].
  3. Avalanche: jitter cho key tạo cùng lúc; không bao giờ flush khi deploy; giới hạn tải xuống DB.
  4. Hot key / big key: một key = một core, một node. L1 cache, chia nhỏ, UNLINK.
  5. Lệch dữ liệu: xoá sau commit (DB::afterCommit), cẩn thận replica; dữ liệu nhạy cảm dùng version CAS.

Đọc thêm: Redis — những thứ cần biết, Idempotency key ở Redis (vì sao state không được ở chung Redis với cache), Cache khi rollback blue-green.