1. Redis缓存实战:三大核心问题与更新策略深度解析
Redis作为当前最流行的内存数据库,在高并发系统中承担着至关重要的缓存角色。但实际应用中我们常会遇到三个致命问题:缓存穿透、雪崩和击穿。去年我们电商系统在大促期间就曾因缓存雪崩导致数据库连接池耗尽,直接损失了300多万订单。今天我就结合这个真实案例,带大家彻底搞懂这些问题的本质和解决方案。
2. 缓存穿透:当查询命中不存在的数据
2.1 穿透现象的本质
想象你在图书馆查一本根本不存在的书,管理员每次都要翻遍整个书库才能告诉你"没有"——这就是缓存穿透。具体表现为:
- 请求的key在Redis中不存在
- 数据库中也不存在该数据
- 导致每次请求都直达数据库
我们曾遭遇恶意攻击者用脚本随机生成商品ID发起请求,瞬间导致数据库CPU飙升至100%。这种攻击成本极低但破坏力极强。
2.2 布隆过滤器实战方案
2.2.1 实现原理
我们在商品服务前部署了布隆过滤器,核心代码如下:
// 初始化布隆过滤器 BloomFilter<String> bloomFilter = BloomFilter.create( Funnels.stringFunnel(Charset.defaultCharset()), 1000000, // 预期元素数量 0.01); // 误判率 // 预热数据 List<Product> products = productDao.getAll(); products.forEach(p -> bloomFilter.put(p.getId())); // 查询拦截 if(!bloomFilter.mightContain(productId)) { return null; // 直接拦截 }关键点:误判率需要根据业务容忍度调整,我们电商场景设0.01意味着100次拦截可能有1次误杀
2.2.2 性能对比测试
| 方案 | QPS | 数据库负载 | 内存占用 |
|---|---|---|---|
| 无防护 | 1200 | 100% CPU | 0 |
| 空对象缓存 | 8500 | 15% CPU | 2GB |
| 布隆过滤器 | 15000 | 5% CPU | 50MB |
2.3 空对象缓存策略
对于少量穿透请求,我们采用缓存空对象方案:
SET product:999999 "NULL" EX 300 # 设置5分钟过期注意两个坑:
- 必须设置过期时间,否则会内存泄漏
- 当真实数据产生时需要双写更新
3. 缓存击穿:热点key的突然死亡
3.1 现象特征
某爆款商品缓存过期瞬间,上万请求直接打穿到数据库。我们监控到有个商品页面的QPS从2000突然飙升到15000。
3.2 互斥锁解决方案
3.2.1 单机锁实现
public Product getProduct(String id) { Product product = redis.get(id); if (product == null) { synchronized (this) { // 细粒度锁 product = redis.get(id); if (product == null) { product = db.get(id); redis.setex(id, 3600, product); } } } return product; }3.2.2 分布式锁优化
采用Redisson实现分布式锁:
RLock lock = redisson.getLock("product:" + productId); try { lock.lock(5, TimeUnit.SECONDS); // 二次检查缓存 // 数据库查询 // 写回缓存 } finally { lock.unlock(); }3.3 逻辑过期方案
对于超级热点数据,我们采用value中存储过期时间的方案:
{ "value": "真实数据", "expire": 1672531200 }后台线程定期扫描这些key,在过期前主动重建缓存。
4. 缓存雪崩:集体失效的灾难
4.1 事故复盘
我们系统曾因所有商品缓存设置相同过期时间,在凌晨2点同时失效,导致数据库连接池瞬间被打满。监控显示:
- Redis QPS从5万骤降到200
- MySQL连接数从20飙升到1000(最大值)
- 响应时间从50ms恶化到15s
4.2 分层缓存架构
最终我们采用多级缓存方案:
- 本地缓存(Caffeine):存储极热数据,过期时间随机
- Redis集群:主缓存,采用"基础过期时间+随机偏移量"
- 备份Redis:永不过期的数据副本
// 两级缓存查询示例 public Product getProduct(String id) { // 第一级:本地缓存 Product product = caffeineCache.get(id); if (product != null) return product; // 第二级:Redis product = redis.get(id); if (product != null) { caffeineCache.put(id, product); return product; } // 第三级:数据库 return loadFromDbAndCache(id); }4.3 过期时间优化算法
def get_cache_ttl(): base_ttl = 3600 # 基础1小时 random_ttl = random.randint(-600, 600) # ±10分钟随机 return base_ttl + random_ttl5. 缓存更新策略深度对比
5.1 四种常见模式
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Cache Aside | 实现简单 | 存在不一致窗口 | 读多写少 |
| Read/Write Through | 一致性较好 | 实现复杂 | 写频繁 |
| Write Behind | 性能最好 | 可能丢数据 | 允许延迟 |
| Refresh Ahead | 无过期抖动 | 占用更多资源 | 热点数据 |
5.2 我们采用的混合策略
对于商品系统:
- 基础信息:Cache Aside + 异步刷新
- 库存数据:Write Through + 本地缓存
- 价格数据:Write Behind + 版本控制
6. 面试高频问题解析
6.1 经典问题清单
- 布隆过滤器为什么不能删除元素?
- 如何设计一个分布式环境下的热点key探测系统?
- 缓存与数据库不一致如何解决?
- Redis持久化对缓存策略的影响?
- 如何评估缓存系统的效果?
6.2 问题3的深度解答
我们采用"延迟双删"策略解决不一致问题:
public void updateProduct(Product product) { // 第一次删除 redis.delete(product.getId()); // 更新数据库 db.update(product); // 异步延迟删除 threadPool.schedule(() -> { redis.delete(product.getId()); }, 1, TimeUnit.SECONDS); }7. 实战中的血泪教训
监控缺失:没有设置缓存命中率报警,等发现时已故障
- 解决方案:监控Redis的keyspace_hits/misses指标
缓存污染:爬虫遍历不存在的ID导致缓存被空对象占满
- 解决方案:对空对象设置更短的TTL(如30秒)
雪崩误判:把数据库慢查询当成缓存问题
- 解决方案:建立完整的调用链监控
锁失效:分布式锁未设置合理的过期时间
- 最佳实践:锁的过期时间要大于业务执行时间
这套方案实施后,在今年618大促期间,我们的系统在QPS峰值12万的情况下,Redis命中率保持在98.7%,数据库负载始终低于40%。缓存问题就像牙疼——平时不注意,发作时要人命。建议每个系统都要建立完整的缓存治理方案,包括预防、监控、应急三个维度。