4、缓存高可用设计:Redis集群模式与缓存异常应对
缓存这东西,用好了是神器,用不好就是定时炸弹。
我在做库存管理系统时,一开始就吃了大亏。单机Redis扛不住双11的流量,直接挂了。从那以后,我对缓存高可用设计就特别上心。今天咱们聊聊Redis的集群模式,以及缓存穿透、击穿、雪崩这三个“拦路虎”。
4.1 Redis集群模式:三种方案怎么选?
Redis的高可用,说白了就是“挂了能自动恢复,流量大了能横向扩展”。我个人习惯把方案分成三种:主从、哨兵、Cluster。它们各有各的适用场景。
4.1.1 主从复制:最基础的保底方案
主从模式,就是一个Master负责写,多个Slave负责读。Master挂了,你得手动把某个Slave提升为Master。嗯,这方案其实不算高可用,但它是基础。
核心配置示例:
# 从节点配置
replicaof 192.168.1.100 6379
replica-read-only yes
# 主节点配置(可选)
requirepass yourpassword
masterauth yourpassword
我在项目中遇到过一个问题:主从复制有延迟。库存扣减时,如果读从库,可能读到旧数据。解决办法?关键操作强制读主库。
避坑指南:我曾经因为主从延迟,导致库存超卖。后来加了个“写后读一致性”策略:写操作完成后,5秒内强制读主库。
4.1.2 哨兵模式:自动故障转移
哨兵模式,就是在主从基础上加了个“监控者”。哨兵会盯着Master,如果Master挂了,它会自动选一个Slave上位。你想想看,这比手动切换强多了。
哨兵至少需要3个节点(奇数个),用Raft协议选主。配置也不复杂:
# sentinel.conf
sentinel monitor mymaster 192.168.1.100 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
这里有个坑:哨兵只是解决了“高可用”,没解决“高容量”。你的数据量如果超过单机内存,哨兵模式也扛不住。
4.1.3 Cluster模式:真正的分布式
Cluster模式,才是库存系统真正需要的。它把数据分片到多个节点上,每个节点负责一部分哈希槽(总共16384个槽)。
举个例子:
# 创建集群(至少6个节点,3主3从)
redis-cli --cluster create \
192.168.1.101:6379 192.168.1.102:6379 192.168.1.103:6379 \
192.168.1.104:6379 192.168.1.105:6379 192.168.1.106:6379 \
--cluster-replicas 1
Cluster模式的好处很明显:
- 自动分片:数据均匀分布,不怕单机瓶颈
- 自动故障转移:主节点挂了,从节点自动顶上
- 线性扩展:加节点就能扩容,不停机
注意:Cluster模式不支持多key操作(比如mget、事务),除非这些key在同一个哈希槽。库存系统里,我一般用哈希标签(hash tag)把相关key强制放到同一槽。
4.2 缓存穿透、击穿、雪崩:三个“杀手”
这三个问题,我统称“缓存三连杀”。每个都可能导致系统崩溃。咱们一个一个说。
4.2.1 缓存穿透:查了个不存在的数据
缓存穿透,就是请求的数据在缓存和数据库里都不存在。每次请求都直接打到数据库。如果恶意攻击,数据库瞬间就挂了。
解决方案:
- 布隆过滤器:把所有可能的key提前存到布隆过滤器里。请求来了先判断key是否存在,不存在直接返回。
- 缓存空对象:如果数据库查不到,也缓存一个空值(比如null),设置较短的过期时间(比如30秒)。
代码示例(布隆过滤器):
// 初始化布隆过滤器(预计100万数据,误差率1%)
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
1000000, 0.01);
// 预热:把所有库存ID加入过滤器
for (String skuId : allSkuIds) {
bloomFilter.put(skuId);
}
// 查询时先判断
if (!bloomFilter.mightContain(skuId)) {
return "商品不存在";
}
我个人习惯用布隆过滤器。空对象方案虽然简单,但会占用缓存空间,而且容易被恶意构造的key刷爆。
4.2.2 缓存击穿:热点key过期
缓存击穿,就是一个热点key在过期瞬间,大量请求同时打到数据库。比如某个爆款商品的库存数据。
解决方案:
- 互斥锁:只让一个线程去查数据库,其他线程等待。
- 逻辑过期:缓存永不过期,但存一个逻辑过期时间。后台异步更新。
避坑指南:我曾经用互斥锁时,忘了设置超时时间。结果数据库挂了,所有线程都卡在锁上,系统完全瘫痪。记住:锁一定要加超时!
// 互斥锁示例(Redis分布式锁)
String lockKey = "lock:sku:" + skuId;
String lockValue = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 查数据库,更新缓存
Stock stock = stockMapper.selectBySkuId(skuId);
redisTemplate.opsForValue().set("sku:" + skuId, stock, 30, TimeUnit.MINUTES);
} finally {
// 释放锁(用Lua脚本保证原子性)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Arrays.asList(lockKey), lockValue);
}
} else {
// 等待重试
Thread.sleep(100);
return getStock(skuId); // 递归重试
}
4.2.3 缓存雪崩:大量key同时过期
缓存雪崩,就是大量key在同一时间过期,或者Redis整个挂了。所有请求直接打到数据库,数据库瞬间被压垮。
解决方案:
- 过期时间加随机值:避免大量key同时过期。
- 多级缓存:本地缓存(Caffeine)+ Redis + 数据库,层层兜底。
- 限流降级:数据库扛不住时,直接返回默认值或错误提示。
过期时间随机化示例:
// 基础过期时间30分钟,加上随机0-5分钟
int baseExpire = 1800; // 30分钟
int randomExpire = ThreadLocalRandom.current().nextInt(300); // 0-5分钟
redisTemplate.opsForValue().set(key, value, baseExpire + randomExpire, TimeUnit.SECONDS);
我记得有一次线上事故,就是因为所有缓存key的过期时间都设成了凌晨0点。结果0点一到,数据库CPU直接飙到100%。从那以后,我所有过期时间都加随机值。
4.3 整体架构图
下面这张图,是我在库存系统里实际用的缓存高可用架构。你一看就明白了:
4.4 总结一下
缓存高可用设计,说白了就是三件事:
- 选对集群模式:小项目用哨兵,大项目用Cluster。
- 防住三个“杀手”:穿透用布隆过滤器,击穿用互斥锁,雪崩用过期随机化+多级缓存。
- 做好兜底:限流降级、熔断保护,别让数据库裸奔。
嗯,这些经验都是我踩坑踩出来的。你按这个思路去设计,库存系统基本不会出大问题。