← 返回博客

缓存策略实战:Redis 在 Web 应用中的使用模式与陷阱

缓存是提升 Web 应用性能最有效的手段,但也是引入 Bug 最快的方式。本文从缓存模式、过期策略、缓存穿透与雪崩、以及 Redis 的实际使用场景四个层面展开——适合正在或准备在 Web 应用中引入缓存层的后端开发者。

先说结论:缓存是性能利器,但也是 Bug 温床

缓存是提升 Web 应用性能最有效的手段——没有之一。一个设计良好的缓存层可以把接口响应时间从几百毫秒降到几毫秒。但缓存也是引入 Bug 最快的方式——缓存穿透、缓存雪崩、缓存和数据库不一致,每一个都能让你调试到怀疑人生。

本文从四个层面展开:缓存模式、过期策略、常见问题、Redis 实战。


1. 缓存模式

1.1 Cache-Aside(最常用)

读:应用 → 读缓存 → 命中 → 返回
                  → 未命中 → 读数据库 → 写入缓存 → 返回
写:应用 → 更新数据库 → 删除缓存
async function getUser(id: number): Promise<User> {
  // 1. 读缓存
  const cached = await redis.get(`user:${id}`);
  if (cached) return JSON.parse(cached);

  // 2. 缓存未命中,读数据库
  const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
  if (!user) return null;

  // 3. 写入缓存
  await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 3600);
  return user;
}

async function updateUser(id: number, data: Partial<User>): Promise<void> {
  // 1. 更新数据库
  await db.query('UPDATE users SET name = $1 WHERE id = $2', [data.name, id]);
  // 2. 删除缓存
  await redis.del(`user:${id}`);
}

为什么更新缓存时是删除而不是更新? 删除是幂等的——删多次和删一次效果一样。而更新缓存可能存在并发问题:两个请求同时更新数据库,后更新的反而先写入了缓存,导致缓存中是旧数据。

1.2 Read-Through(缓存即数据源)

应用只和缓存交互,缓存负责从数据库加载数据。适合读多写少的场景,但实现更复杂,需要 Redis 端的支持(如 RedisJSON 模块)。


2. 过期策略

2.1 过期时间设置

数据类型建议过期时间说明
用户信息30-60 分钟变化不频繁
文章/内容1-6 小时更新频率低
配置/参数24 小时极少变化
计数/统计5-15 分钟需要及时更新
临时数据1-5 分钟短时效

2.2 防止缓存雪崩:过期时间加随机偏移

// ❌ 所有 key 在同一时间过期
await redis.set(`article:${id}`, data, 'EX', 3600);

// ✅ 加随机偏移,避免同时过期
const ttl = 3600 + Math.floor(Math.random() * 600); // 3600-4200 秒
await redis.set(`article:${id}`, data, 'EX', ttl);

3. 常见问题

3.1 缓存穿透

查询一个不存在的数据,每次请求都穿透缓存打到数据库。

解决:缓存空值(短期过期,如 60 秒),或使用布隆过滤器。

async function getArticle(id: number) {
  const cached = await redis.get(`article:${id}`);
  if (cached !== null) {
    if (cached === 'NULL') return null; // 缓存了空值
    return JSON.parse(cached);
  }
  const article = await db.query('SELECT * FROM articles WHERE id = $1', [id]);
  // 不管有没有数据,都缓存(空值也缓存,短期过期)
  await redis.set(`article:${id}`, JSON.stringify(article) || 'NULL', 'EX', article ? 3600 : 60);
  return article;
}

3.2 缓存击穿

热点 key 过期瞬间,大量请求同时打到数据库。

解决:互斥锁,或热点 key 永不过期(后台异步更新)。

async function getHotData(id: number) {
  const cached = await redis.get(`hot:${id}`);
  if (cached) return JSON.parse(cached);

  // 尝试获取锁
  const lock = await redis.set(`lock:hot:${id}`, '1', 'NX', 'EX', 5);
  if (!lock) {
    // 没拿到锁,等 50ms 后重试
    await sleep(50);
    return getHotData(id);
  }

  // 拿到锁,查数据库
  const data = await db.query('SELECT * FROM hot_data WHERE id = $1', [id]);
  await redis.set(`hot:${id}`, JSON.stringify(data), 'EX', 3600);
  await redis.del(`lock:hot:${id}`);
  return data;
}

3.3 缓存雪崩

大量 key 在同一时间过期,数据库压力瞬间飙升。

解决:过期时间加随机偏移(见 §2.2),或设置多级缓存(本地缓存 + Redis)。


4. Redis 内存管理

# 限制最大内存(推荐设置为服务器内存的 60-70%)
maxmemory 4gb

# 淘汰策略:allkeys-lru(最近最少使用)
maxmemory-policy allkeys-lru

# 监控内存使用
redis-cli INFO memory | grep used_memory_human
# used_memory_human: 2.5G

总结

问题症状解决方案
缓存穿透查询不存在的数据打穿缓存缓存空值 / 布隆过滤器
缓存击穿热点 key 过期瞬间大量请求互斥锁 / 永不过期
缓存雪崩大量 key 同时过期随机过期时间 / 多级缓存
不一致缓存和数据库数据不同步Cache-Aside:更新数据库后删缓存

缓存不是万能的,但不用缓存是万万不能的。 一个设计良好的缓存层能让你的应用性能提升一个数量级,但前提是你理解了它的陷阱。

需要 Redis 缓存方案设计或后端性能优化?联系我们,说清你的数据访问模式与规模,24 小时内回可行性。

相关阅读

常见问题

缓存穿透、缓存击穿、缓存雪崩有什么区别?

缓存穿透:查询一个不存在的数据,缓存和数据库都没有,请求直接打到数据库。攻击者可以利用这个漏洞大量请求不存在的 key 来打垮数据库。解决:缓存空值(短期过期)或用布隆过滤器。缓存击穿:一个热点 key 在缓存过期的一瞬间,大量请求同时打到数据库。解决:互斥锁(Mutex)或设置热点 key 永不过期(后台异步更新)。缓存雪崩:大量 key 在同一时间过期,导致数据库压力瞬间飙升。解决:过期时间加随机偏移量,避免同时过期。

Redis 缓存和数据库的一致性怎么保证?

缓存和数据库之间没有完美的强一致性方案(除非你用分布式事务,但代价太高)。推荐 Cache-Aside 模式:读的时候先读缓存,缓存没有则读数据库并写入缓存;写的时候先更新数据库,然后删除缓存。为什么是删除缓存而不是更新缓存?因为删除是幂等的,而更新缓存可能存在并发写的问题。如果对一致性要求高,可以用延时双删:先删缓存,再更新数据库,等几毫秒后再删一次缓存。

Redis 内存满了怎么办?

配置 maxmemory 限制 Redis 最大内存,并设置淘汰策略。推荐 allkeys-lru(最近最少使用淘汰)或 allkeys-lfu(最不频繁使用淘汰)。不设置 maxmemory 的话,Redis 会一直增长直到耗尽服务器内存,导致 Redis 被 OOM Killer 杀掉。建议再配合监控(如 INFO memory 命令)定期检查内存使用率,超过 80% 就告警。

什么时候不应该用缓存?

三种情况:① 数据一致性要求极高(如金融交易、余额)——缓存带来的延迟提升不值得引入不一致的风险;② 数据几乎不访问(冷数据)——缓存命中率低,不如不缓存;③ 数据量极小且查询极快(如单表几百行)——没有缓存必要,直接查数据库可能还更快。

本文来自 AI Enable Harness 一线交付实践。需要同类系统或优化服务?

订阅博客更新

新文章发布后第一时间邮件通知。不定期发送,不推销。

订阅 →