缓存策略实战: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 小时内回可行性。
相关阅读
- Docker Compose 部署实战:从开发到生产的配置演进 —— Redis 在 Docker 环境中的部署配置
- 运维自动化脚本模式:从一次性脚本到可维护工具 —— Redis 监控与运维自动化
常见问题
缓存穿透、缓存击穿、缓存雪崩有什么区别?
缓存穿透:查询一个不存在的数据,缓存和数据库都没有,请求直接打到数据库。攻击者可以利用这个漏洞大量请求不存在的 key 来打垮数据库。解决:缓存空值(短期过期)或用布隆过滤器。缓存击穿:一个热点 key 在缓存过期的一瞬间,大量请求同时打到数据库。解决:互斥锁(Mutex)或设置热点 key 永不过期(后台异步更新)。缓存雪崩:大量 key 在同一时间过期,导致数据库压力瞬间飙升。解决:过期时间加随机偏移量,避免同时过期。
Redis 缓存和数据库的一致性怎么保证?
缓存和数据库之间没有完美的强一致性方案(除非你用分布式事务,但代价太高)。推荐 Cache-Aside 模式:读的时候先读缓存,缓存没有则读数据库并写入缓存;写的时候先更新数据库,然后删除缓存。为什么是删除缓存而不是更新缓存?因为删除是幂等的,而更新缓存可能存在并发写的问题。如果对一致性要求高,可以用延时双删:先删缓存,再更新数据库,等几毫秒后再删一次缓存。
Redis 内存满了怎么办?
配置 maxmemory 限制 Redis 最大内存,并设置淘汰策略。推荐 allkeys-lru(最近最少使用淘汰)或 allkeys-lfu(最不频繁使用淘汰)。不设置 maxmemory 的话,Redis 会一直增长直到耗尽服务器内存,导致 Redis 被 OOM Killer 杀掉。建议再配合监控(如 INFO memory 命令)定期检查内存使用率,超过 80% 就告警。
什么时候不应该用缓存?
三种情况:① 数据一致性要求极高(如金融交易、余额)——缓存带来的延迟提升不值得引入不一致的风险;② 数据几乎不访问(冷数据)——缓存命中率低,不如不缓存;③ 数据量极小且查询极快(如单表几百行)——没有缓存必要,直接查数据库可能还更快。