2 giờ sáng, database đứng im, alert đổ tới tấp
Đó là câu chuyện thật của mình. Response time từ 200ms leo lên 8 giây, database CPU 100%, và cái duy nhất mình nghĩ được lúc đó là: “Tại sao mình chưa cache cái này?”
Vấn đề không phải là thiếu cache — mình đã có Redis. Nhưng mỗi request vẫn phải đi qua mạng để gọi Redis, cộng thêm latency không đáng có khi data hoàn toàn có thể nằm trong memory của chính process đó. Multi-level caching là thứ mình ước biết sớm hơn 1 năm.
Quick Start: Chạy trong 5 phút
Cài hai package cần thiết:
npm install ioredis node-cache
Tạo file cache-manager.js với 2 tầng cache:
const NodeCache = require('node-cache');
const Redis = require('ioredis');
const L1 = new NodeCache({ stdTTL: 60, checkperiod: 120 }); // In-memory, TTL 60s
const L2 = new Redis({ host: 'localhost', port: 6379 });
async function get(key) {
// Tầng 1: In-memory cache (~0.01ms)
const l1Hit = L1.get(key);
if (l1Hit !== undefined) return l1Hit;
// Tầng 2: Redis (~1ms)
const l2Hit = await L2.get(key);
if (l2Hit) {
const parsed = JSON.parse(l2Hit);
L1.set(key, parsed); // Backfill L1 cho request tiếp theo
return parsed;
}
return null;
}
async function set(key, value, ttl = 300) {
L1.set(key, value, 60); // L1: 60s
await L2.setex(key, ttl, JSON.stringify(value)); // L2: 300s
}
async function del(key) {
L1.del(key);
await L2.del(key);
}
module.exports = { get, set, del };
Dùng ngay trong route handler:
const cache = require('./cache-manager');
app.get('/products/:id', async (req, res) => {
const key = `product:${req.params.id}`;
let product = await cache.get(key);
if (!product) {
product = await db.query('SELECT * FROM products WHERE id = ?', [req.params.id]);
await cache.set(key, product, 600); // Cache 10 phút
}
res.json(product);
});
Xong. Request đầu tiên hit DB, lần tiếp theo về từ Redis (L2), lần sau nữa về thẳng từ in-memory (L1) — không cần đụng đến mạng hay disk.
Tại sao cần đến 2 tầng?
Chỉ Redis thôi chưa đủ
Redis rất nhanh, nhưng vẫn là network call. Trên cùng một datacenter, một lần gọi Redis mất khoảng 0.5–2ms. Nghe nhỏ, nhưng một API handler gọi Redis 10 lần thì đó là 5–20ms “chết” mỗi request — chưa kể lúc Redis bị overload hoặc có network glitch.
In-memory cache (L1) nằm ngay trong process của Node.js: truy cập mất 0.01–0.1ms, nhanh hơn 10–100 lần. Data được đọc lặp lại trong vài chục giây? L1 trả về gần như tức thì.
Chỉ in-memory cũng không ổn
In-memory cache bị giới hạn bởi RAM của process, không chia sẻ được giữa nhiều instance khi scale horizontally, và mất sạch khi restart. Redis giải quyết cả 3 vấn đề. Kết hợp cả hai mới đủ.
Triển khai nâng cao
Cache invalidation đúng cách — đừng để sau
Mình từng mất 2 tiếng debug lý do tại sao giá sản phẩm vẫn hiển thị sai dù database đã cập nhật — thủ phạm là L1 cache còn 55 giây TTL. Học xong rồi: strategy invalidation phải định nghĩa trước khi viết business logic, không phải sau khi mọi thứ đã cháy.
// Dùng Redis SCAN thay vì KEYS — an toàn trên production
async function invalidateByPattern(pattern) {
// Xóa L1
const l1Keys = L1.keys().filter(k => k.startsWith(pattern));
l1Keys.forEach(k => L1.del(k));
// Xóa L2 bằng SCAN (không block Redis như KEYS)
let cursor = '0';
do {
const [nextCursor, keys] = await L2.scan(cursor, 'MATCH', `${pattern}*`, 'COUNT', 100);
cursor = nextCursor;
if (keys.length > 0) await L2.del(...keys);
} while (cursor !== '0');
}
// Gọi khi update dữ liệu
app.put('/products/:id', async (req, res) => {
await db.query('UPDATE products SET ? WHERE id = ?', [req.body, req.params.id]);
await invalidateByPattern(`product:${req.params.id}`);
res.json({ success: true });
});
Circuit breaker khi Redis down
Redis đôi khi cũng có sự cố. Không có fallback, app down theo:
let redisAvailable = true;
L2.on('error', () => { redisAvailable = false; });
L2.on('connect', () => { redisAvailable = true; });
async function get(key) {
const l1Hit = L1.get(key);
if (l1Hit !== undefined) return l1Hit;
if (!redisAvailable) return null; // Skip Redis nếu đang down
try {
const l2Hit = await Promise.race([
L2.get(key),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 100))
]);
if (l2Hit) {
const parsed = JSON.parse(l2Hit);
L1.set(key, parsed);
return parsed;
}
} catch (err) {
console.error('Redis error, fallback to DB:', err.message);
}
return null;
}
Chống cache stampede bằng mutex
Sau khi restart, L1 sạch hoàn toàn. Traffic 500 req/s mà cùng miss L1 là 500 query DB đồng thời — đủ để kill một database đang khỏe mạnh chỉ trong vài giây. Người ta gọi đây là thundering herd. Giải pháp là dùng in-process mutex:
const pending = new Map();
async function getWithMutex(key, fetchFn, ttl = 300) {
const cached = await cache.get(key);
if (cached) return cached;
// Nếu đã có promise đang xử lý cho key này, chờ nó
if (pending.has(key)) return pending.get(key);
const promise = fetchFn()
.then(async data => {
await cache.set(key, data, ttl);
pending.delete(key);
return data;
})
.catch(err => { pending.delete(key); throw err; });
pending.set(key, promise);
return promise;
}
Tips thực tế từ production
1. TTL L1 phải ngắn hơn TTL L2
L1 không chia sẻ giữa các instance, nên TTL dài sẽ khiến các Node.js instance trả về data lệch nhau. Tỷ lệ an toàn: TTL L1 = 1/5 đến 1/10 TTL L2. Ví dụ: L1 30s, L2 300s — ngắn gọn vậy thôi.
2. Monitor hit rate — nếu dưới 80% thì cần xem lại
let stats = { l1Hit: 0, l2Hit: 0, miss: 0 };
// Nhúng vào hàm get() ở mỗi nhánh rẽ
// ...
app.get('/health/cache', (req, res) => {
const total = stats.l1Hit + stats.l2Hit + stats.miss;
res.json({
...stats,
hitRate: total > 0 ? ((stats.l1Hit + stats.l2Hit) / total * 100).toFixed(1) + '%' : 'N/A'
});
});
3. Key naming: dùng format entity:id:variant
Ví dụ: product:123:detail, user:456:profile, category:electronics:list:page2. Nhất quán từ đầu, về sau debug lỗi stale data sẽ đỡ đau đầu hơn nhiều.
4. Đừng cache tất cả mọi thứ
Cache chỉ có giá trị khi hit rate cao. Nên cache: data đọc nhiều, ít thay đổi, tốn tài nguyên để tạo. Không nên cache: số dư tài khoản, trạng thái đơn hàng real-time, bất kỳ thứ gì yêu cầu consistency cao theo giây.
Lần sau mà 2 giờ sáng alert kêu, mình hy vọng bạn đã có L1 + L2 cache sẵn rồi — chứ không phải mới bắt đầu implement lúc đó như mình hồi xưa.

