← 返回文章列表
数据库·664 次阅读

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 不是拍脑袋。我的决策顺序:

  1. 业务能容忍多久的延迟? 首页商品运营改完 5 分钟内生效可接受 → TTL 300s
  2. 加随机抖动:300 + random(0, 60)。防止大批量缓存同一时刻过期(这就是雪崩预防,理论篇讲过)
  3. 实在不能容忍延迟的数据(库存)→ 不缓存或极短 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 条

还没有评论,来抢沙发~

发表评论

评论需审核后显示

© 2026 许冬博客 · 专注 JS 技术分享

·RSS 订阅·Powered by Nuxt 4