Redis 缓存实战:一个真实的接口加速案例
上周运营搞活动,首页商品列表接口 RT 从 50ms 飙到 800ms,数据库 CPU 90%。紧急上了 Redis 缓存把接口压回 20ms。之前写过缓存三大异常的理论,这篇补上实战篇:一个接口从裸奔到带缓存的完整过程。
事故现场
出问题的接口长这样(伪代码):
// GET /api/home/products
app.get('/api/home/products', async (req, res) => {
const products = await db.query(`
SELECT p.*, c.name AS category_name, s.stock
FROM products p
JOIN categories c ON p.category_id = c.id
JOIN stocks s ON s.product_id = p.id
WHERE p.status = 1
ORDER BY p.sort DESC
LIMIT 20
`)
res.json(products)
})
三表 JOIN + 排序,平时量小没事,活动流量一上来直接打满。
第一步:定 key 和 value 结构
缓存设计的第一课是 key 的命名规范。我的规则:业务前缀:模块:标识,用冒号分层:
shop:home:products # 首页商品列表
shop:product:1001 # 单个商品详情
shop:user:cart:42 # 用户 42 的购物车
好处:可视化工具里自动按冒号折叠成树、批量清理时 SCAN shop:home:* 方便、看名字就知道是谁的缓存。
value 直接 JSON 序列化整个查询结果:
const cached = JSON.stringify(products)
第二步:Cache Aside 标准流程
最经典的缓存模式:先读缓存,没有再查库,查完回填。
const CACHE_KEY = 'shop:home:products'
const CACHE_TTL = 300 // 5 分钟
app.get('/api/home/products', async (req, res) => {
// 1. 读缓存
const cached = await redis.get(CACHE_KEY)
if (cached) {
return res.json(JSON.parse(cached))
}
// 2. 缓存没有,查库
const products = await db.query('...那个三表 JOIN...')
// 3. 回填缓存,带上过期时间
await redis.set(CACHE_KEY, JSON.stringify(products), 'EX', CACHE_TTL)
res.json(products)
})
上线后效果立竿见影:99% 的请求直接命中缓存,数据库 QPS 从 2000 降到个位数。
第三步:过期时间怎么定
TTL 不是拍脑袋。我的决策顺序:
- 业务能容忍多久的延迟? 首页商品运营改完 5 分钟内生效可接受 → TTL 300s
- 加随机抖动:
300 + random(0, 60)。防止大批量缓存同一时刻过期(这就是雪崩预防,理论篇讲过) - 实在不能容忍延迟的数据(库存)→ 不缓存或极短 TTL + 更新时主动删
const ttl = 300 + Math.floor(Math.random() * 60)
await redis.set(CACHE_KEY, data, 'EX', ttl)
第四步:数据更新怎么办
商品改了价格,缓存里还是旧的——这是缓存永远的痛。Cache Aside 的标准答案:更新数据库后,删除缓存(不是更新缓存):
app.put('/api/admin/products/:id', async (req, res) => {
await db.query('UPDATE products SET ... WHERE id = ?', [req.params.id])
// 删缓存,下次读自然回填最新值
await redis.del('shop:home:products')
res.json({ ok: true })
})
为什么是删而不是更新?
- 更新缓存要重新算整个 JOIN 结果,可能根本没人读,白算
- 删除是惰性策略:下次有请求来再回填,不为没流量的数据浪费资源
注意顺序:先更新数据库,再删缓存。反过来有个并发窗口:删完缓存的瞬间另一个请求进来读了旧库值回填,缓存就脏了。先更新库再删缓存虽也有极小窗口,但概率低得多,业务上可接受。
第五步:防穿透——缓存空值
压测时发现有人拿不存在的商品 id 刷接口,每次缓存都是 miss,请求全打到数据库——这就是穿透。
解法:查不到也缓存,存个空值短 TTL:
const product = await db.query('SELECT * FROM products WHERE id = ?', [id])
if (!product) {
// 空值也缓存 60 秒,挡住恶意刷接口
await redis.set(key, 'NULL', 'EX', 60)
return res.status(404).json({ message: '商品不存在' })
}
读取侧遇到 'NULL' 直接返回 404。流量再大,数据库也只被真实存在的商品打到。
上线后的监控清单
缓存不是上了就完事,这几个指标要盯:
- 命中率:
info stats的 keyspace_hits / (hits + misses),健康值 > 90%。突然掉下去说明 key 设计或 TTL 有问题 - 内存占用:maxmemory 快满时确认淘汰策略(allkeys-lru 最常用)
- 慢查询:
SLOWLOG GET 10,大 key(一个 value 几百 KB)是性能杀手
小结
- key 用冒号分层命名:业务:模块:标识
- 读流程:先缓存 → miss 查库 → 回填(Cache Aside)
- TTL = 业务容忍度 + 随机抖动防雪崩
- 更新:先更新库,再删缓存(不更新缓存)
- 查不到的空值也缓存短 TTL,防穿透
- 上线后盯命中率、内存、慢查询
这一套下来,首页接口稳如老狗。缓存的理论大家都会背,但 key 怎么设计、空值要不要存、更新先动谁——这些实战细节才是真正拉开差距的地方。
看到这里了?如果这篇文章帮到过你
请我喝瓶可乐就好 🥤

评论0 条
还没有评论,来抢沙发~