在没有真正经历过主从切换之前,我一直觉得Redis哨兵是个“用起来很简单,讲起来很绕”的东西。直到线上某天凌晨,主节点所在机器内存告警,Sentinel自动完成了故障转移,应用却因为连接池里的旧地址没刷新,在缓存击穿的边缘疯狂试探。那次之后我才算把哨兵和缓存这两件事真正串起来:哨兵保证的是Redis服务本身不躺平,缓存策略保证的是后端数据库不被流量打垮。两者合在一起,才是线上缓存体系最基础的骨架。
这篇文章就把哨兵的机制、缓存层的三板斧,以及把它们部署到一起时的实操要点和坑,完整拆一遍。不管你是刚接触Redis的初级开发,还是已经维护过一段时间缓存服务的运维,我相信都能从中找到一点能直接抄作业的东西。
1. 为什么要用哨兵:单点故障与高可用设计
1.1 哨兵到底在解决什么问题
很多团队一开始用Redis,就是一个单实例往那一放,应用连一个IP一个端口,跑得也挺好。但一旦流量上来,或者数据变得重要,单实例的问题就非常突出:磁盘坏了怎么办?内存不够了怎么办?Redis进程挂了,业务直接不可用,因为所有缓存请求都打不进去,紧接着流量就会穿透到数据库,数据库跟着扛不住,整个系统就雪崩了。
哨兵(Sentinel)不是缓存本身,而是给Redis实例做高可用监护的“值班人员”。它会持续监视主节点和从节点的健康状况,在主节点挂掉的时候,自动从从节点里选出一个新的主节点,然后把客户端请求指向新的主节点。这个过程中,应用完全不需要感知,只要客户端接入的是哨兵而不是具体的Redis实例地址,就能在几十秒内自动恢复读写能力。
我见过不少新同学把Sentinel和Cluster搞混。简单说:Cluster解决的是数据分片和水平扩展问题,把数据分散到多个主节点上;Sentinel解决的是主节点挂了谁来接替问题,不解决分片。一个主节点下面挂多个从节点,再由Sentinel盯梢,这种架构在很多中等规模项目里已经足够。
1.2 哨兵机制里的三个关键角色:监视、通知、自动故障转移
哨兵本身也是一个Redis进程,但它干的事比较特殊。至少三个哨兵节点组成一个Sentinel集群,它们互相通信,并共同对Redis主从节点进行监控。
第一个职责是监视。哨兵会不断向Redis实例发送PING命令,检查它们是否响应。如果某个实例在配置的持续时间(比如30秒)内没有响应,哨兵会认为这个实例“主观下线”。只有多个哨兵都认为它主观下线,才判定为“客观下线”。这一步是为了避免单个哨兵误判,毕竟网络抖动很常见。
第二个职责是通知。当主节点状态变化时,哨兵需要把变化告诉其他哨兵,同时客户端如果订阅了Sentinel的频道,也会收到事件通知。比如+switch-master事件,就是主节点切换完成后对外广播的消息。
第三个职责是自动故障转移。一旦主节点被确认客观下线,Sentinel集群就要开始选举。逻辑并不复杂:先根据配置的优先级(slave-priority)从从节点里选候选者,再比较复制偏移量,偏移量越大说明从节点数据越新,越有可能被选中。选出来后,哨兵发送SLAVEOF NO ONE命令让这个从节点变成主节点,然后再让其他从节点去复制新的主节点。
整个过程里,有一个叫做quorum的参数很关键。比如sentinel monitor mymaster 127.0.0.1 6379 2里的“2”,就表示至少需要2个哨兵同意,才能判定主节点客观下线。quorum不宜配得太小,太小时单个哨兵故障可能引起误切换;也不宜配得太大,比如5个哨兵配4,那必须等足够多哨兵确认,故障转移时间会变长。
1.3 脑裂和数据丢失是怎么发生的
哨兵机制看起来完美,但它不是银弹。最典型的风险是网络分区带来的脑裂。
假设主节点在A机房,从节点和哨兵在B机房,因为交换机故障,A和B之间网络中断。此时B机房的哨兵联系不上主节点,判定主节点客观下线,于是从节点提升为新主节点。可实际上A机房的主节点并没有死,它还在继续接收应用写入的请求。这就出现了两个主节点同时对外提供服务,数据各自增长。
等到网络恢复,原来的主节点如果重新加入集群,哨兵会强制它变成从节点,并且丢弃掉分区期间多出来的写入数据。这些数据就丢了,而且没有提示。要缓解这个问题,可以给主节点配置min-replicas-to-write参数,比如设置成1,要求主节点至少有一个从节点在线才接受写入。当网络分区后,旧主节点发现从节点都失联了,就会拒绝写入,从而减少数据丢失。
但这里也有个权衡:如果主节点刚启动时没有从节点,这个参数可能导致短暂拒绝写入。所以生产环境需要根据业务容忍度去调。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存层的三板斧:穿透、击穿、雪崩
2.1 缓存穿透:空值缓存与布隆过滤器
缓存穿透这个词,指的是查询一个数据库里根本不存在的数据。因为缓存里没有,请求会直接打到数据库,如果一直被恶意请求或大量无效查询打同一个key,数据库就会承受无谓的读压力。
最简单的处理办法是空值缓存。查询结果如果为空,也把空值写入缓存,设置一个比较短的过期时间,比如60秒。这样同一个不存在的key在60秒内不会再打到数据库。但要注意,如果恶意请求不断换新的key,空值缓存也扛不住,因为key是无限的。
这时候就要考虑布隆过滤器。在请求到达Redis之前,先用一个布隆过滤器判断这个key是否可能存在。布隆过滤器是一个很省内存的数据结构,它允许误判(认为存在但实际不存在),但不允许漏判(认为不存在但实际存在)。所以它适合做前置拦截:如果过滤器说key不存在,直接返回空,根本不用访问Redis和数据库。
我自己的做法是:对于可以枚举的数据范围(比如用户ID、订单ID),在缓存层前面加一个布隆过滤器;对于无法枚举的范围,用空值缓存加上限流兜底。这算是一个比较务实的组合。
2.2 缓存击穿:互斥锁与逻辑过期
击穿和穿透听起来像,但本质不同。击穿针对的是某个热点key,它的缓存到点了,同一时间有大量请求来查这个key,结果全部穿透到数据库。
最常用的两种方案是互斥锁和逻辑过期。
互斥锁的思路:当缓存失效时,只让一个线程去数据库查询并重建缓存,其他线程先等待,或者直接返回旧的缓存值。在Redis里可以用SETNX实现一个简单的分布式锁。伪代码大致是:
java复制String key = "hot:item:1001";
String lockKey = "lock:" + key;
String requestId = UUID.randomUUID().toString();
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, Duration.ofSeconds(5));
if (locked) {
try {
Object value = queryFromDb(key);
redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(30));
return value;
} finally {
if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) {
redisTemplate.delete(lockKey);
}
}
} else {
// 线程没有抢到锁,可以先查一次缓存,如果还没有,短暂sleep后重试
}
互斥锁的缺点很明显:如果热点key非常多,每个key失效瞬间都有一次锁竞争,可能增加延迟。而且一旦某线程持锁时间过长,其他线程等待的延迟也会变长。
逻辑过期是我个人更喜欢的方案。做法是:缓存里不设置Redis过期时间,但存的值里包含一个“逻辑过期时间”。当读取时发现逻辑过期,不会立刻让请求穿透,而是返回旧数据,同时异步线程去更新缓存。这样就保证了在缓存更新期间,请求依然能拿到旧数据,不会打穿数据库。
java复制// 缓存结构
class CacheObj {
private LocalDateTime expireAt;
private Object value;
}
这个方案对一致性要求没那么高的场景非常合适,比如商品详情、列表页数据。缺点是如果异步线程没来得及更新,旧数据的有效期不能太长,否则用户看到的永远是过期信息。
2.3 缓存雪崩:过期时间打散与降级
雪崩和击穿的区别在于范围。击穿是单个热点key失效,雪崩是大批量key在同一时间段失效,导致流量整体压向数据库。
最常见的场景是:晚上零点定时活动开始,你给所有相关缓存设置的过期时间都是凌晨00:00,到了那一刻,缓存集体失效,数据库直接被打满。避免雪崩的办法有几个层面。
第一是过期时间打散。可以在基础过期时间上增加一个随机值,比如基础30分钟,再加0到5分钟的随机偏移,让key的过期时间分布开而不是聚在一起。这个做法很简单,但对平滑流量非常有效。
第二是多级缓存。本地缓存(比如Caffeine)加上Redis缓存,就算Redis缓存大面积失效,本地缓存还能扛住一部分流量,给数据库留出喘息时间。不过引入本地缓存要面对数据一致性问题,所以通常只适合那种更新频率低、容忍轻微不一致的数据。
第三是服务降级和限流。如果数据库确实扛不住降级后的流量,就得在缓存层前面加Sentinel限流或者接口层面的熔断,宁可返回一些降级数据(比如白名单里的默认品类),也不能让数据库被打挂。
3. 哨兵部署与缓存接入实操
3.1 主从复制和哨兵配置核心参数
部署一套带哨兵的Redis环境,比想象中简单,但配置细节很容易踩坑。假设我们有三个Redis节点:一个主节点(6379)、两个从节点(6380、6381),另外部署三个哨兵进程(26379、26380、26381)。
Redis主从配置,主要是从节点上设置replicaof:
redis复制# 6380.conf
port 6380
daemonize yes
replicaof 127.0.0.1 6379
哨兵配置的核心项是sentinel monitor,它告诉哨兵要监控哪个主节点,以及quorum是多少:
redis复制# sentinel_26379.conf
port 26379
daemonize yes
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 30000
sentinel failover-timeout mymaster 180000
sentinel parallel-syncs mymaster 1
down-after-milliseconds表示如果主节点在30秒内没有响应,就判定为主观下线。failover-timeout是故障转移的总体超时时间。parallel-syncs控制故障切换后,同时允许几个从节点复制新主节点,设置为1意思是逐个同步,避免同时同步造成新主节点压力过大。
这里有个很容易忽略的细节:哨兵配置文件里的mymaster只是一个逻辑名字,同一个集群的所有哨兵必须使用相同的名字。如果各哨兵配置的master名字不一样,它们之间虽然能互相通信,但会各自维护不同的主节点,无法形成一致判定。
3.2 客户端如何通过哨兵获得主节点
以前很多项目是在配置里写死Redis地址,主从切换后必须手动改配置重启应用。这种做法在哨兵架构下是完全错误的。正确的方式是接入哨兵地址,让客户端自己监听主节点变化。
以Spring Boot + Lettuce为例,spring.redis.sentinel.master和spring.redis.sentinel.nodes要这样配:
yaml复制spring:
redis:
sentinel:
master: mymaster
nodes: 10.0.0.1:26379,10.0.0.2:26380,10.0.0.3:26381
Lettuce在启动时会向哨兵节点询问当前主节点的地址,并建立连接。切换发生时,Lettuce通过订阅Sentinel的频道感知到+switch-master事件,然后自动更新连接。
如果你是Java老项目用的Jedis,也有对应的JedisSentinelPool。核心作用是一样的,都是通过Sentinel拿到主节点地址。
但光靠客户端自动感知还不够。很多线上问题出在连接池长期持有旧连接,或者DNS缓存。比如有些框架在启动时把主节点地址解析后缓存到本地,切换后不会刷新。遇到这种情况,可以在应用里加一个定时任务,定期轮询当前主节点信息,但一般不建议这么做,最好是升级客户端版本并正确配置。
3.3 缓存更新策略:Cache Aside与延迟双删
有了高可用的Redis,接下来要处理的是缓存与数据库之间的数据一致性。没有哪种方案能做到强一致,我们只能在“性能”和“一致性”之间做取舍。
最常用的是Cache Aside模式。读的时候先读缓存,没命中就读数据库再回填缓存。写的时候先更新数据库,然后删除缓存。为什么是删除缓存而不是更新缓存?因为更新缓存容易产生并发竞争:两个线程同时更新数据,后更新的反而把旧值写进缓存,导致数据不一致。删除缓存则简单得多,下次读请求会重新加载数据库值。
但Cache Aside也有坑。如果一个线程更新数据库后,删缓存失败,缓存里还是旧数据。解决思路是延迟双删:先删除缓存,再更新数据库,稍等几百毫秒后再删除一次缓存。这个“稍等”的时间需要根据业务场景调整,本质上是给可能出现的并发读线程一个窗口去重新写入旧的缓存值,等它写完后再删一次。
java复制// 延迟双删
redisTemplate.delete(key);
updateDatabase(data);
Thread.sleep(500);
redisTemplate.delete(key);
需要说明的是,延迟双删并不能100%保证一致,只是把不一致的时间窗口缩小了。更严格的做法是用Canal订阅数据库binlog,再异步删除对应缓存。这种方案适合对一致性要求很高的场景,比如订单状态、库存等。
4. 线上问题排查与经验总结
4.1 哨兵切换后缓存未失效的问题
我遇到过一次非常典型的情况:主节点切换完成后,应用读写Redis都正常,但部分缓存key始终没有更新。排查后发现问题不在Redis,而在应用程序内部有一个本地缓存层,把Redis的key又缓存了一层,而且本地缓存的过期时间是10分钟。也就是说,Redis主从切换后,应用虽然拿到了新的主节点地址,但本地缓存里的数据还是旧的,业务读到的也是旧数据。
这个案例给我们的教训是:引入本地缓存时,必须考虑它的失效机制是否能够被外部事件触发。可以订阅Redis的key过期事件或者Sentinel的切换事件,在事件发生时主动清理本地缓存。不能用单纯的过期时间,因为过期时间在“缓存雪崩”时本身就不可靠。
4.2 连接池超时:RedisCommandTimeoutException
很多团队在Redis集群压力大时,会看到这样一段异常:Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException。一看到超时,很多人第一反应是Redis性能不行,但实际排查下来,经常是客户端连接池配置不合理。
默认连接池的maxTotal如果太小,比如只有8,而应用峰值并发超过这个数,请求就会排队等待连接。一旦等待时间超过命令超时时间,就报超时异常。这种情况下,Redis本身CPU和内存都很正常,但请求已经堆积在客户端了。
排查思路很简单:先看Redis的INFO命令输出,确认命中率、ops、内存都没有异常,然后把连接池的maxTotal调大一点。根据我的经验,单实例Redis的maxTotal可以设为50到200之间,具体要看机器文件描述符和业务并发量。同时要设置合理的maxIdle,避免空闲连接过多白白占用内存。
4.3 缓存一致性踩坑实录
有一个项目做过一次“缓存过期时间统一设置”的“优化”。当时想着所有key统一30分钟过期,方便管理。上线后第二天,数据库慢查询数量暴涨。原因就是大量key在同一时刻失效,形成了缓存雪崩。后来我们把过期时间改成“30分钟 + 随机0到5分钟”,数据库压力立刻下来了。
还有一次,有人为了提升性能,把缓存写成功的返回值给忽略掉了。结果Redis因为内存淘汰策略(比如allkeys-lru)把一些重要key淘汰了,应用还一直以为缓存存在,不到数据库查,返回了空数据。排查了很久才发现是缓存主动淘汰导致的。所以重要数据必须设置合理的过期时间和内存淘汰策略,不能全部依赖Redis帮忙保存。
4.4 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 主从切换很慢 | quorum过大、down-after-milliseconds过长 | 调整哨兵参数,避免误判和慢切换 |
| 切换后客户端连不上新主 | 客户端未启用Sentinel模式 | 修改为通过Sentinel获取主节点 |
| 缓存全部失效,数据库压力大 | 过期时间未加随机,配置了统一过期 | 过期时间打散,引入本地缓存 |
| 单key热点导致Redis实例倾斜 | key设计不合理,未做热key拆分 | 将热key拆成多份,hash分散 |
| 缓存和数据库不一致 | 删缓存失败或并发更新 | 使用延迟双删,或接入binlog订阅 |
| 连接池报command timed out | maxTotal太小或等待超时 | 调大连接池参数,合理设置超时 |
5. 个人实操体会
做了一个多学期的缓存治理之后,我最大的感受是:哨兵和缓存的组合,不是一个“配置完就万事大吉”的方案,而是一个需要持续调优和监控的体系。哨兵能帮你自动切换主节点,但它不能同时解决数据一致性、热点key、慢查询这些应用层问题;缓存能帮你抵挡大量读请求,但如果穿透、击穿、雪崩不提前考虑,高并发下也可能反过来成为雪崩的起点。
每当线上出问题的时候,我都会先打开监控面板,看三个指标:Redis命中率、主从切换事件、客户端连接池活跃连接数。这三个指标基本能覆盖绝大多数缓存相关的故障。如果命中率突然掉下来,先查是不是有大量key被淘汰或者过期;如果出现主从切换事件,再看切换前后client端的日志;如果活跃连接数打满,再查是不是有慢查询或者连接泄漏。
另外还有一个建议:不管业务规模多大,都要把Redis的连接密码、超时时间、连接池参数、哨兵配置这些信息,统一收进配置中心和运维平台。否则一旦出现故障,好几个人各查各的配置,排查效率会非常低。像我们后来把配置全部yaml化,每次变更都有审计记录,出现问题时可以秒级定位到哪台实例、哪个参数引起的。
最后分享一个小技巧:在主从切换后,如果应用使用的是旧的主节点地址做写入,很多框架会在日志里打出READONLY You can't write against a read only replica。看到这个异常,不要急着在代码里catch掉,它其实是哨兵切换后客户端没有及时感知到新主节点的最强信号。正确做法是检查你的客户端配置,确认Sentinel的nodes节点没有写错,同时确认Sentinel进程本身没有发生脑裂。把这个异常当成一次自动体检,远比把它吞掉有用得多。
