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 / miss | Tìm thấy trong cache / không thấy, phải xuống DB |
| cache penetration | Xuyê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 avalanche | Tuyế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 key | Mộ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 key | Một key quá to (value vài MB, hash hàng triệu field) |
| bloom filter | Cấ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 positive | Báo "có thể có" nhưng thật ra không có |
| jitter | Cộ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 cache | L1: 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 đụngchiennn_rediscủa project), Laravel 12.64.0 + phpredis 6.3.0 + PHP 8.3.33 (imagelaravel-app, mount code read-only), và PostgreSQL 18.4 (my-test-pgdb). Máy dev chạy quadocker 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ỏng | Mũi tên bị hỏng | Dấu hiệu trên dashboard | Cách chữa chính |
|---|---|---|---|
| Penetration | miss → DB không có → không SET → lần sau lại miss | Hit ratio thấp bất thường, DB có nhiều query trả 0 dòng | Validate input, cache giá trị rỗng, bloom filter |
| Stampede | Một key hết hạn → nhiều request cùng đi nhánh miss | DB CPU nhảy vọt đúng chu kỳ TTL | Lock, Cache::flexible, làm mới trước bằng scheduler |
| Avalanche | Nhiều key cùng đi nhánh miss | DB sập ngay sau deploy / sau Redis restart | Jitter, không flush cache khi deploy, giới hạn tải xuống DB |
| Hot key | Nhánh hit quá nhiều cho một key | Redis 1 core 100%, các core khác rảnh | L1 cache trong process, nhân bản key |
| Big key | GET/DEL một key quá to | Latency Redis giật từng đợt, băng thông mạng cao | Chia nhỏ key, UNLINK |
| Lệch dữ liệu | Query DB (đọc bản cũ) chen giữa COMMIT và DEL | Khô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ách | Số lần query DB | Latency min / median / max | Trả về |
|---|---|---|---|
Cache::remember, cache trống | 50 | 513 / 593 / 666 ms | 50 bản mới |
Cache::lock()->block(5) + remember, cache trống | 1 | 564 / 1374 / 2685 ms | 50 bản mới |
Cache::flexible, cache trống (cold start) | 50 | 512 / 549 / 696 ms | 50 bản mới |
Cache::flexible, đã có bản cũ quá hạn tươi | 1 | 6 / 86 / 144 ms | 50 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:
remember: 50 request = 50 query cùng lúc. Query thật 2 giây, 2.000 sinh viên mở trang đúng 8:00 → 2.000 query nặng cùng lúc → DB cạn connection.lock: DB chỉ bị hỏi 1 lần, nhưng đuôi latency tệ gấp 4 lần. Lý do nằm ở source:Lock::$sleepMilliseconds = 250(Illuminate/Cache/Lock.php:42) — con đang đợi cứ 250 ms thử lại một lần. Và 49 con đợi phải lần lượt lấy lock rồi mới đọc thấy cache. Trong 2,7 giây đó mỗi con giữ một PHP-FPM worker → xem bài Scale nginx + PHP-FPM: hếtpm.max_childrenlà 502.flexiblekhi cache trống: vẫn 50 query. Đây là chỗ hay hiểu sai nhất.flexiblekhi đã có bản cũ: 1 query, mọi người nhận bản cũ ngay trong <150 ms. Đây là thứflexiblethật sự làm được.
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:
- 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.
- Mất cả cache: Redis restart không có persistence,
php artisan cache:cleartrong 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:
- Không bao giờ flush cache khi deploy. Đổi format value thì bump version trong tên key
(
course:v2:) để key cũ tự chết theo TTL. - Bật persistence cho Redis cache nếu warm-up tốn kém (RDB là đủ, mất vài phút dữ liệu cache không sao).
- Giới hạn số request được xuống DB cùng lúc khi miss — ví dụ
Redis::funnel('db-miss')->limit(20). Request thứ 21 đợi hoặc nhận trang rút gọn, thay vì đè chết DB. - L1 cache (APCu trên từng node) sống sót khi Redis chết vài giây.
- Laravel 12 có store
failover(CacheManager::createFailoverDriver, nhận danh sáchstores): Redis lỗi thì chuyển sang store kế tiếp. Nó chống Redis chết, không chống cache trống — chưa test hành vi chuyển store trong bài này.
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:
- CPU một core: Đo thật một key với
redis-benchmark -c 20 GET key:42trên máy dev: 25.474 req/s, p50 0,263 ms. Server thật chịu được cao hơn nhiều. Nhưng trần của một key là trần của một core, dù máy có bao nhiêu core. - Băng thông mạng thường chạm trước CPU: value 50 KB × 20.000 req/s = 1 GB/s ≈ 8 Gbps — card mạng 10 Gbps gần cạn (phép tính minh hoạ).
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ách | Chạy thế nào | Giá 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ây | Cá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 key | Ghi N bản exam:config:#0..#9, đọc ngẫu nhiên một bản → ở Cluster tải chia ra nhiều node | Ghi và xoá phải làm N lần. Có lúc các bản lệch nhau |
| Đẩy lên CDN / HTTP cache | Dữ liệu công khai giống nhau cho mọi người → không chạm server | Chỉ 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ách | Chạy thế nào | Chỗ gãy |
|---|---|---|
| Ghi đè cache sau khi ghi DB | UPDATE xong thì SET cache giá trị mới | Hai 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 DB | DEL rồi mới UPDATE | Trong 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 DEL | Cò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 CAS | Cache lưu kèm version, chỉ nhận bản có version mới hơn | Cầ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
- 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 implementShouldHandleEventsAfterCommit. - 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ật | Mua được gì (số đo trong bài) | Đáng tiền khi |
|---|---|---|
Cache false thay null | Từ 3/3 xuống 1/3 lần xuống DB cho id không tồn tại | Luôn luôn — gần như miễn phí |
| Bloom filter | Chặn 99,5% id rác, RAM nhỏ hơn SET 37 lần | Tập key hàng chục triệu, có tấn công thật |
| Lock khi miss | 50 query → 1 query | Query cực nặng, DB là chỗ nghẽn, người dùng đợi thêm được |
Cache::flexible | 1 query, mọi người nhận bản cũ trong <150 ms | Dữ 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ước | Không còn miss trên đường request | Tập key nóng nhỏ và biết trước |
| Jitter | 10.000 key rải trong 12 phút thay vì 1 giây | Nhiều key được tạo cùng lúc (warm-up, import) |
| L1 cache | 20.000 lệnh/giây → vài lệnh/giây vào Redis | Hot key, dữ liệu chấp nhận cũ vài giây |
| Version CAS | Cache 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ẫy | Hậu quả | Cách tránh |
|---|---|---|---|
| 1 | remember không cache được null | Penetration vẫn còn dù "đã có cache" | Trả false / sentinel, TTL ngắn |
| 2 | Cache key rỗng cho mọi id | Bot làm đầy RAM, key thật bị đuổi | TTL 30–60 giây, rate limit, validate id |
| 3 | Bloom 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 |
| 4 | Thêm vào bloom sau khi insert DB | Khoá học vừa tạo bị 404 trong khoảng hở | Thêm vào bloom trước khi commit insert |
| 5 | Bloom mất khi Redis mất dữ liệu | Mọi id thật đều 404 | Filter trống thì fail open (bỏ qua bloom) cho tới khi rebuild xong |
| 6 | Lock 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 |
| 7 | flexible không chống cold start | 50 query cùng lúc ngay sau deploy | Warm-up trước khi nhận traffic, hoặc lock riêng cho lần nạp đầu |
| 8 | Lock của flexible không TTL | Process chết → không bao giờ làm mới ngầm nữa | Luôn truyền ['seconds' => N] |
| 9 | flexible 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 |
| 10 | Jitter không chữa mất cả cache | Sau Redis restart DB vẫn sập | Không flush khi deploy, funnel giới hạn tải DB |
| 11 | L1 cache | Các node lệch nhau vài giây, không xoá được từ xa | Chỉ cho dữ liệu chấp nhận cũ; TTL L1 rất ngắn |
| 12 | Nhân bản key | Ghi/xoá N lần; các bản lệch nhau | Chỉ cho key đọc rất nhiều, ghi rất ít |
| 13 | Xoá cache trong transaction | Lệch dữ liệu tới hết TTL | DB::afterCommit |
| 14 | Nạp lại cache từ replica | Lệch dù đã xoá đúng chỗ | Đọc primary khi nạp lại |
| 15 | Cache model Eloquent đã serialize | Deploy đổi class → unserialize lỗi hoặc __PHP_Incomplete_Class | Lưu array; bump version key — xem bài rollback |
| 16 | rememberForever cho dữ liệu nghiệp vụ | Quên xoá một chỗ = sai vĩnh viễn | Luô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ùng | Nâng lên khi | Đừng dùng khi |
|---|---|---|---|
| Penetration | Validate + cache false TTL ngắn | Bloom filter khi tập key > vài triệu và có tấn công | Bloom cho vài nghìn key — thêm hạ tầng không lý do |
| Stampede | flexible (có seconds) + warm-up | Scheduler làm mới cho key biết trước | Lock blocking cho trang người dùng đang chờ |
| Avalanche | Jitter + không flush khi deploy | Funnel giới hạn tải DB, L1 cache | — |
| Hot key | CDN nếu dữ liệu công khai | L1 APCu, rồi nhân bản key | Nhân bản key cho dữ liệu hay đổi |
| Lệch dữ liệu | Xoá sau commit (afterCommit) | Double delete khi có replica; CAS cho dữ liệu nhạy cảm | Ghi đè cache sau khi ghi DB |
Tiêu chí chọn
- 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. - 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.
- Chỗ nghẽn là DB hay Redis? DB → lock/flexible/funnel. Redis → L1, nhân bản key.
- Độ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 key | Redis 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ẹt | redis-cli --scan --pattern '*flexible:lock*' rồi TTL. Thấy -1 là kẹt (bẫy 8) |
| Đặt tên key | Thố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ười | Junior 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?" |
| Test | Stampede 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:
- 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::funnelgiới hạn 20 query nặng cùng lúc. - Warm-up tay các key nóng nhất (
php artisan exam:warm-cache) — một process, tuần tự. - Sau sự cố: xoá
cache:clearkhỏ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). - 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ý:
- Rate limit theo IP/user ở Nginx hoặc
RateLimitercủa Laravel — chặn tại cửa. - 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. - Xem
INFO stats:evicted_keystăng nghĩa là key rỗng đang đẩy key thật ra (bẫy 2). Giảm TTL key rỗng. - 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ý:
- Tìm chỗ xoá key điểm, kiểm tra có nằm trong transaction không. Đổi sang
DB::afterCommit. - Kiểm tra connection đọc: nếu có replica, nạp lại key điểm bằng
useWritePdo(). - Điểm là dữ liệu nhạy cảm: thêm version (cột
updated_version) và CAS khi ghi cache. - 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ý:
- Xác nhận:
CONFIG SET maxmemory-policy allkeys-lfu(nếu đang là LRU), rồiredis-cli --hotkeys. - Nhanh nhất:
Cache::memo()để mỗi request chỉ đọc 1 lần thay vì 5. - Tiếp theo: L1 APCu TTL 5 giây cho key đó — cấu hình kỳ thi không đổi trong ngày.
- 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ỏi | Trả 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
- Penetration: validate id, cache
false(Laravel không cache đượcnull), TTL ngắn. Bloom filter chỉ khi tập key rất lớn. - 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]. - 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.
- Hot key / big key: một key = một core, một node. L1 cache, chia nhỏ,
UNLINK. - 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.