年初的时候帮朋友的公司调整过一套 Redis 缓存架构,当时他们最头疼的问题就是:缓存经常失效、主库一挂整个业务跟着停。聊到最后,问题基本都落在两个词上:Redis哨兵和缓存。哨兵负责让 Redis 主从集群在故障时自动切换,缓存负责把热点数据挡在数据库前面。这两个东西单独拎出来都不难,难的是把它们组合成一套能抗住线上压力的方案。这篇文章我就从实际落地的角度,把哨兵原理、缓存一致性、集群部署和排查过程从头到尾捋一遍,适合正在做 Redis 高可用改造,或者被缓存雪崩、故障转移坑过的同学。
1. 先想明白:缓存和哨兵分别解决什么痛点
1.1 缓存不是万能的,它是把双刃剑
很多人对 Redis 的第一印象是“快”,但缓存之所以能快,是因为它把数据放在内存里,避免了每次请求都打到磁盘或者数据库。举个生活化的例子,你去超市买东西,收银台旁边如果有个小货架,放着你常买的几样商品,你就没必要每次都绕到仓库区去翻。缓存就是这个收银台小货架,数据库是仓库。
不过货架也有货架的问题。第一是容量有限,内存比磁盘贵,不可能把所有数据都放进去;第二是货架上的东西可能过期、可能和仓库不一致,顾客刚拿走一包标着“特价”的薯片,结账时系统里价格已经改了。对应到系统里,就是缓存命中率、缓存过期、缓存与数据库一致性这几个老生常谈的问题。再加上并发场景,还有缓存穿透、缓存击穿、缓存雪崩,每一个都能让线上接口从几十毫秒变成几十秒。
所以聊缓存,不能只聊“快”,还得聊“怎么保证缓存层本身稳定可靠”。这也是哨兵会出现的原因。
1.2 哨兵是给 Redis 主从加“自动切换”能力
Redis 本身支持主从复制,一个 master 负责写,多个 slave 负责读,数据通过异步复制同步到从库。但主从复制解决不了高可用:如果 master 机器宕机、进程崩溃、或者网络分区,这时候客户端仍然把写请求发给 master,结果就是写入失败,整个服务不可用。没有哨兵的情况下,只能人工登录服务器,手动执行 replicaof 之类的操作,把某个从库提升为主库,再通知客户端改地址。这个操作在凌晨三点发生的时候,非常痛苦。
哨兵(Sentinel)就是来解决这个问题的。它是一组独立的 Redis 进程,专门盯着主库和从库的状态。核心职责有四块:监控、通知、自动故障转移、配置提供。监控是不断检查主从节点是否活着;通知是发现节点异常时告诉其他哨兵和客户端;自动故障转移是发现 master 挂了之后,自动从从库中选一个新 master,并把其他从库切换到新 master 上;配置提供是让客户端可以通过哨兵获取当前真正的 master 地址,这样就算 master 换了,客户端也不需要改配置。
你可以把哨兵理解成小区物业的安保团队,业主(客户端)不需要知道每个保安是谁,只需要知道找物业就能问到“今天该找哪个管家办事”。物业团队自己会轮岗、会换人,但服务入口不变。
1.3 为什么缓存架构里几乎必带哨兵
如果你的 Redis 只是单机,挂在它前面的是一层纯缓存,那么 Redis 一挂,所有缓存请求都会穿透到数据库。平时可能数据库能扛,但大促或者流量高峰时,这一下穿透基本等于雪崩:数据库连接池被打满,应用层跟着超时,最后整个链路一起挂掉。所以越是把 Redis 当缓存用,越要给 Redis 加高可用保障。
如果把缓存架构设计成“一主多从 + 哨兵”,master 负责写,slave 负责读,哨兵负责故障转移,那么即使 master 挂了,哨兵会在几十秒内切换到新的 master,客户端这边只需短暂抖动,不会出现长时间不可用。这个组合几乎是互联网后端缓存层的标准姿势。接下来的内容,我就围绕这套标准姿势展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 哨兵的工作原理与关键参数,一次说透
2.1 从 PING 到客观下线,故障是怎么被发现的
哨兵与 Redis 节点之间通过一条命令频道和一条发布订阅频道通信。每个哨兵默认每秒向它监控的所有实例发送一次 PING,如果实例在 down-after-milliseconds 指定的时间内没有有效回复,这个哨兵就会把这个实例标记为“主观下线”(Subjectively Down,简写 sdown)。
注意“主观”这个词,它表示只是这一个哨兵自己觉得对方挂了,不一定真的挂了。比如网络抖动、GC停顿、服务器负载过高,都可能导致 PING 超时。如果只有一个哨兵,它误判了就会引发误切换,所以生产环境不会只部署一个哨兵。
当某个哨兵认为 master 主观下线之后,它会通过发布订阅把这件事广播给其他哨兵。其他哨兵收到消息后,会去确认自己对 master 的连通性。当确认 master 主观下线的哨兵数量达到配置里的 quorum 数值时,大家就会把 master 标记为“客观下线”(Objectively Down,简写 odown)。简单说,sdown 是单个哨兵的判断,odown 是多数派哨兵的一致意见。
这里有个很容易踩坑的点:quorum 不是越大越好,也不是越小越好。它只影响“何时认为 master 真的挂了”,不影响“谁来执行切换”。quorum 设置为 2,意思是最少需要两个哨兵都认为 master 不可用,才会进入故障转移流程。如果设成 1,等于任何一个哨兵只要自己觉得有问题就可以发起切换,误判概率会明显增加。一般三哨兵集群我习惯配 quorum=2,五哨兵集群配 quorum=3,保持“多数派”原则。
2.2 故障转移过程:选主、切换、从库同步
一旦进入客观下线状态,哨兵们之间就要选出一个 Leader 来负责执行故障转移。这个选举机制跟 Raft 类似:每个哨兵都可以投自己一票,先到先得,得票超过半数(准确说是超过哨兵总数的一半)的哨兵成为 Leader。当选后,Leader 会从健康的从库中选一个作为新 master,然后把其他从库的复制目标重新指向新 master,最后通知所有客户端。
选新 master 不是随机选。优先级上,哨兵会先看从库的优先级配置 replica-priority,数值越小越优先;优先级相同再看复制偏移量,谁同步的数据越新,谁越有可能被选上;如果还相同,再比较 runid,小的优先。整个选择逻辑其实就是为了一个目标:尽量选一个数据最完整、最不可能丢数据的从库来顶替原 master。
故障转移完成后,原 master 如果恢复了,哨兵不会把它自动设回 master,而是把它降级成新 master 的从库。这也是很多人第一次看日志会困惑的地方:为什么老 master 回来了,却变成 slave?因为系统要保证“只有唯一 master”这个原则,避免出现两个 master 同时写数据的情况。
2.3 sentinel.conf 里的参数不是随便填的
我把常用的几个参数拿出来逐个说。
sentinel monitor mymaster 127.0.0.1 6379 2 这条配置里,mymaster 是逻辑名称,可以自己定;127.0.0.1 6379 是初始 master 地址;最后的 2 就是 quorum。注意哨兵第一次启动时,必须通过这个配置找到 master,后续它会自动发现从库和其他哨兵。
down-after-milliseconds 表示多少毫秒无响应就认为主观下线。这个值设太短,网络抖动就会误判;设太长,故障恢复时间就变长。我给内部网络一般设 5000ms,跨机房场景会调到 10000ms 左右。
failover-timeout 表示故障转移超时时间,包括选举哨兵 Leader、选新 master、从库同步等阶段的总超时。设太短,可能切换到一半就放弃了;设太长,在极端情况下会拖慢恢复。默认 180000ms,我一般保持默认,只有遇到切换卡住的问题时才调大。
parallel-syncs 表示故障转移后,同时允许多少个从库去同步新 master。值越大,从库同步越快,但新 master 的负载也越高。设置成 1 最稳,避免多个从库同时全量同步把新 master 打崩。
还有一个容易被忽略的是 sentinel auth-pass。如果 Redis 开启了 requirepass,那么哨兵与 Redis 之间通信也必须认证,否则哨兵会一直报 NOAUTH,无法监控节点。很多新手搭哨兵集群,明明配置看起来没问题,就是一直不切换,多数是这里没配。
3. 哨兵 + 缓存的架构设计与代码落地
3.1 一主两从三哨兵拓扑怎么选
缓存场景下,读写比例通常远高于普通业务,比如一个商品详情接口,读请求可能是写请求的几十倍。所以合理的拓扑是:一个 master 负责写,两个 slave 负责读,三个哨兵负责监控和故障转移。为什么是三个哨兵而不是一个或两个?因为哨兵本身也要高可用,一个哨兵挂了就没人能执行切换;两个哨兵虽然能组成“一票多数”,但如果其中一个挂了,剩下的一个哨兵无法满足多数派条件。三个哨兵可以容忍一个哨兵挂掉,是兼顾成本和可靠性的最小方案。
在这个架构下,应用层的读写策略是:读操作优先走 slave,写操作必须走 master。如果代码里所有请求都直接打 master,slave 就白部署了。但引入读写分离后,又会遇到复制延迟导致的数据不一致:写主库后立刻读从库,可能读到旧数据。我的处理方式是对一致性要求高的场景,做一个“读后写”标识,比如在本地线程变量里记录刚才写过,短时间内的读取强制走 master,或者直接用 Cache Aside 模式让缓存来兜底,尽量减少直接读从库的延迟影响。
3.2 Spring Boot 连接哨兵的配置与代码片段
让应用连接哨兵集群,和连接普通 Redis 最大的区别是:客户端不再是直连某个固定 IP,而是先连哨兵,通过哨兵拿到当前 master 地址。Spring Boot 中使用 Lettuce 或 Jedis 都支持这个能力。我以 Spring Boot + Lettuce 为例,配置大概是这样。
yaml复制spring:
redis:
sentinel:
master: mymaster
nodes:
- 10.0.0.1:26379
- 10.0.0.2:26379
- 10.0.0.3:26379
password: yourpassword
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 2
max-wait: 3000ms
这里有个细节必须注意:sentinel.nodes 填的是哨兵进程的地址,不是 Redis 主从节点的地址。应用通过任意一个哨兵都能获取到当前 master 的信息,所以这里三个哨兵地址都填上,即使其中一两个哨兵不可用,客户端还能通过其他哨兵继续工作。
代码层面,用 Spring 封装的 RedisTemplate 就行,不需要额外感知哨兵切换,因为底层连接工厂会周期性从哨兵刷新 master 地址。示例:
java复制@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
StringRedisSerializer keySerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer();
template.setKeySerializer(keySerializer);
template.setHashKeySerializer(keySerializer);
template.setValueSerializer(valueSerializer);
template.setHashValueSerializer(valueSerializer);
template.afterPropertiesSet();
return template;
}
}
操作缓存的代码反而最简单,无非是读写、设置过期时间。真正要花心思的是缓存策略。
3.3 缓存一致性:更新策略、穿透、击穿、雪崩
缓存领域最常见的四类问题我在前面提过,这里展开讲一下应对套路。
先看缓存与数据库的一致性问题。我推荐 Cache Aside 模式:读的时候先读缓存,读不到就读数据库,然后回填缓存;写的时候先更新数据库,再删除缓存,等下次读请求再回填。为什么不更新缓存而是删除缓存?因为更新缓存在并发写场景下容易出现旧值覆盖新值。删除缓存虽然多了一次缓存 miss,但至少不会把脏数据长期留在内存里。
单纯“更新数据库后删除缓存”仍然有并发漏洞:比如线程 A 读缓存未命中,读到数据库旧值;线程 B 更新数据库为新值,并删除缓存;线程 A 这时把旧值写回缓存。于是缓存里又变成旧值。比较常用的补救手段是延迟双删:先删除缓存,更新数据库,等几百毫秒再删除一次缓存。这个“几百毫秒”要大于读请求把旧值写回缓存的时间窗口。还有一种更稳的思路是订阅数据库 binlog,异步删除缓存,这个对团队基建要求高一些,不是所有项目都有条件做。
再看缓存穿透,指的是查询一个不存在的数据,缓存和数据库都没有,请求直接打到数据库。有人恶意用随机 key 刷接口时,数据库压力会瞬间飙升。最简单的办法是对空结果也做缓存,设置较短的过期时间,比如 60 秒;更专业的做法是在缓存前面加一层布隆过滤器,把所有存在的 key 都过一遍布隆过滤器,不在集合里的直接拦截。
缓存击穿,指的是某一个热点 key 刚好在过期瞬间,大量请求同时打到数据库。解决办法是加互斥锁,让只有一个请求去数据库查询,其他请求等待;或者把热点 key 的过期时间延长,并且在返回给客户端时做“逻辑过期”,后台异步刷新真实数据。
缓存雪崩,指的是大量 key 在同一时间过期,或者缓存层整体不可用。前者很好解决,给 key 的过期时间加一个随机量,把过期时间打散;后者需要多级缓存、熔断降级,以及我前面说的哨兵高可用来兜底。我在之前的项目里,还会对缓存增加本地缓存层(比如 Caffeine),即使 Redis 短暂不可用,本地缓存还能扛住一部分热点请求。
4. 实操:用 Docker Compose 从零搭一套 Redis 哨兵集群
4.1 编排文件与哨兵配置
纸上谈兵没意思,我用 Docker Compose 给你搭一套最小可用的“一主两从三哨兵”环境。为了演示方便,这里用 host 网络模式,避免容器间 IP 和端口映射的干扰。实际生产环境如果用了容器,需要额外关注哨兵向外宣告的 IP 地址问题,我后面会专门提。
先创建一个目录,里面放三个哨兵配置文件。以 sentinel1.conf 为例:
conf复制port 26379
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 180000
sentinel parallel-syncs mymaster 1
另外两个哨兵的配置基本一样,只需要把 port 改成 26380、26381,并且指定不同的 pid 文件和日志文件,避免冲突。如果 Redis 设置了密码,每个哨兵配置里都要加 sentinel auth-pass mymaster 你的密码。
docker-compose.yml 大概这样写:
yaml复制version: "3.8"
services:
redis-master:
image: redis:7.0
container_name: redis-master
network_mode: host
command: ["redis-server", "--port", "6379", "--appendonly", "yes"]
redis-slave1:
image: redis:7.0
container_name: redis-slave1
network_mode: host
depends_on:
- redis-master
command: ["redis-server", "--port", "6380", "--replicaof", "127.0.0.1", "6379"]
redis-slave2:
image: redis:7.0
container_name: redis-slave2
network_mode: host
depends_on:
- redis-master
command: ["redis-server", "--port", "6381", "--replicaof", "127.0.0.1", "6379"]
sentinel1:
image: redis:7.0
container_name: sentinel1
network_mode: host
volumes:
- ./sentinel1.conf:/etc/redis/sentinel.conf
command: ["redis-sentinel", "/etc/redis/sentinel.conf"]
sentinel2:
image: redis:7.0
container_name: sentinel2
network_mode: host
volumes:
- ./sentinel2.conf:/etc/redis/sentinel.conf
command: ["redis-sentinel", "/etc/redis/sentinel.conf"]
sentinel3:
image: redis:7.0
container_name: sentinel3
network_mode: host
volumes:
- ./sentinel3.conf:/etc/redis/sentinel.conf
command: ["redis-sentinel", "/etc/redis/sentinel.conf"]
执行 docker compose up -d 后,分别看下三个容器日志,确认哨兵已经发现了 master 和从库。用 docker exec redis-master redis-cli -p 6379 info replication 能看到主从状态;用 docker exec sentinel1 redis-cli -p 26379 sentinel master mymaster 能看到当前真正的 master 地址。
这里有个容易忽略的细节:哨兵配置文件里我写的初始 master 地址是 127.0.0.1:6379,这在 host 网络模式下没问题。如果换成 bridge 网络,哨兵里的 master 地址应该写成 redis-master:6379 这种容器服务名,否则哨兵连不上 master。
4.2 启动验证与故障演练
环境起来以后,不要急着直接杀 master,先做几项检查。第一,往 master 写入一个测试 key,比如 set cache:demo hello,再从两个从库分别读出来,确认复制正常。第二,用 redis-cli -p 26379 sentinel get-master-addr-by-name mymaster,确认哨兵返回的 master 地址是 127.0.0.1:6379。第三,执行 info sentinel,确认哨兵之间已经互相感知。
确认没问题后,开始故障演练。我先把 master 容器停掉,也就是模拟 master 机器宕机:docker stop redis-master。然后持续观察哨兵日志,正常情况下,几秒后会出现 sdown,接着是 odown,然后哨兵选举 leader,并把某个从库切换成新 master。整个过程一般在十几秒内完成。
切换完成后,再用 redis-cli -p 26379 sentinel get-master-addr-by-name mymaster,你会看到返回的地址已经变成 127.0.0.1:6380 或 6381 中的一个。这时候再往新 master 写入数据,然后重新把原来的 master 容器启动起来,它会自动作为新 master 的从库重新同步数据。
4.3 提醒:容器环境下的连接问题
如果你没有用 host 网络,而是把 Redis 的端口映射到宿主机,再让应用从宿主机访问,这套架构很容易踩到一个坑:哨兵向客户端返回的 master 地址是容器内部 IP,客户端在容器外访问不通。解决办法是在哨兵配置里增加 sentinel announce-ip 和 sentinel announce-port,把哨兵对客户端宣告的地址改成宿主机 IP 和映射后的端口,Redis 主从节点也类似,主从之间互相宣告地址时要注意使用可路由的地址。
这个坑我在第一次容器化部署的时候被卡了半天,现象是哨兵一切正常,但应用一直报连接超时。后来发现客户端拿到的地址是 172.17.0.x,宿主机根本路由不到。所以用容器部署哨兵,务必先想清楚“谁在跟谁通信”:哨兵和 Redis 之间、哨兵和哨兵之间、客户端和哨兵之间、客户端和 Redis 之间,四类通信的地址是否都是可达的。
5. 实战中的坑和排查清单
5.1 哨兵不切换的排查实录
有一次线上环境,master 进程已经被 kill,但等了五分钟哨兵都没有切换。我去查哨兵日志,发现一直在刷 sdown,但始终没进入 odown。打开配置文件一看,quorum 被配置成了 3,但整个环境只有两个哨兵节点。这种配置本质上是不可能完成切换的,因为第二个哨兵怎么都凑不齐三个“主观下线”的投票。这类坑很常见,配置哨兵数量时,quorum 的值不能超过哨兵节点总数。
第二个常见问题是哨兵之间无法通信。三台哨兵分布在不同的机器上,安全组只放行了 Redis 主从的 6379、6380、6381 端口,忘了放行 26379、26380、26381。结果每个哨兵都能连 master,但哨兵之间互不认识,master 挂了一台哨兵上报“主观下线”,其他哨兵收不到广播,也就无法确认“客观下线”。排查的时候,可以用 redis-cli -p 26379 sentinel sentinels mymaster 看一下当前哨兵集群里能看到几个哨兵节点,如果只有自己,那就是节点发现有问题。
第三个坑是主从配置了密码,哨兵却没配 auth-pass。Redis 开启 requirepass 之后,哨兵执行 PING 需要先认证,否则会得到 NOAUTH 错误,对应节点状态一直是 sdown。这个问题在日志里非常明显,看到 Authentication required 基本就是它了。还有一种隐蔽情况:master 有密码,slave 也有自己的密码,哨兵里需要分别配置对 master 和 slave 的认证信息,新版 Redis 和 Redis Sentinel 对 auth 的处理方式略有不同,建议直接升级到较新版本,少踩很多兼容性的坑。
5.2 缓存治理中的高频坑
第一个坑是 key 过期时间设置得整整齐齐,下一秒缓存全没了。我见过一个运营活动,把所有缓存 key 的 TTL 都设置了 24 小时,结果第二天活动开始前整点一到,大批 key 同时过期,数据库被打挂了。后面改成“固定过期时间 + 随机 0-300 秒扰动”,把雪崩概率降了下来。
第二个坑是缓存 value 的序列化方式。Spring Boot 默认可能使用 JDK 序列化,存进 Redis 的 value 是一串二进制,Redis Desktop Manager 这类可视化工具里看到的全是乱码。而且 JDK 序列化后的体积明显偏大,浪费内存。换成 Jackson 或 fastjson2 之后,可读性提升了,缓存大小也降下来了。这个改动顺手还能解决一部分“缓存反序列化失败”的告警。
第三个坑是 bigkey 和热 key。我遇到过一个 String 类型的 key,value 有 3MB 多,每次读取这个 key 都会让单个 Redis 实例的网络 I/O 飙升,甚至阻塞其他 key 的读写。对于这类大 value,要么拆分,要么压缩,实在不行就别放 Redis。热 key 则是另一个方向,单个 key 的访问量太大,可能把一个 Redis 分片的 CPU 打满。常用手段是给热点 key 加随机后缀,拆成多个 key 分散到不同分片,或者用本地缓存挡掉一部分请求。
第四个坑是 Redis 分布式锁。很多人用 SETNX 实现分布式锁,却忘了设置过期时间,结果持有锁的线程崩溃,锁永远不释放。正确做法是 SET key value NX EX 5,并且把 value 设置成一个唯一标识,释放锁时用 Lua 脚本保证“先比较再删除”的原子性。这个和缓存治理属于两个方向,但既然做 Redis 中间件,迟早会碰到。
5.3 常见问题速查表
我把上面提到的坑整理成一张表,方便排查时直接对照。
| 现象 | 可能原因 | 排查方式 | 解决办法 |
|---|---|---|---|
| 哨兵一直显示 sdown,不进入 odown | quorum 设置过大,或哨兵间网络不通 | 查看哨兵日志,执行 sentinel sentinels mymaster | 调整 quorum,放行哨兵端口 |
| 故障转移后客户端连不上 Redis | 容器或跨网段地址不可达 | 用 sentinel get-master-addr-by-name 看返回地址 | 配置 announce-ip 和 announce-port |
| master 挂掉后长时间不切换 | 哨兵未配置 auth-pass | 日志中搜索 NOAUTH | 为哨兵配置正确的认证信息 |
| 缓存 key 同时大量失效 | TTL 设置相同 | 查询 key 的过期时间分布 | 过期时间加随机扰动 |
| 缓存里出现乱码 | 序列化器用的是 JDK 默认 | 用可视化工具查看 key 的 value | 改成 JSON 序列化器 |
| 单个 key 读取导致 Redis 卡顿 | bigkey 或热 key | 使用 redis-cli --bigkeys 分析 | 拆分、压缩、加本地缓存 |
| 集群切换后应用仍写旧地址 | 客户端没有刷新拓扑 | 查看应用日志的主从地址 | 开启 Lettuce 拓扑刷新,或重启连接池 |
5.4 几个值得记住的运维习惯
最后再分享几个我长期在用的运维习惯。监控一定要看哨兵日志,很多问题在哨兵日志里都有明确线索,不用瞎猜。故障演练要定期做,至少每季度一次,否则真到故障发生时,你会发现切换流程和想象中的完全不一样。备份不能只靠主从复制,哨兵的故障转移只能保证可用性,不能保证数据零丢失,主从复制是异步的,master 宕机时可能有部分数据没同步到从库。对于缓存场景,这点数据丢失通常可以接受,因为缓存本来就可以从数据库回填;但如果 Redis 存储的是强一致数据,就得考虑其他方案了。
关于缓存本身,我也养成了一套固定习惯:所有缓存 key 都加上业务前缀和版本号,比如 user:info:v1:123,方便做灰度发布时整体切缓存;所有过期时间都比业务要求的最小时间多留一点余量;所有写操作尽量走主库,读操作尽量走从库或本地缓存;上线大促前,一定要做缓存预热,别等用户流量把冷数据焊热。
我在实际运维中体会最深的一点是,哨兵和缓存虽然概念上分开,但线上问题往往是一起爆发的:缓存穿透打垮数据库,接着 Redis 主库负载飙升,哨兵误判主观下线,甚至引发切换。所以不要只盯着某一层,要从“数据库 - 缓存 - Redis 集群 - 哨兵”整条链路去看压力和数据流向。搭建好了之后,一定要自己亲手杀一次 master,亲手看一次切换,踩过这个坑,你才对线上环境真正有底。
