写这篇东西之前,我想先聊聊动机。做后端开发这些年,Redis几乎是每个项目躲不开的组件。缓存、计数器、分布式锁、限流、消息队列……哪儿都有它。但说实话,很多人对Redis的使用停留在“会用命令、能读缓存”的层面,一旦业务量上来,单机Redis扛不住高并发、主从切换丢数据、扣库存超卖这些问题就会集中爆发。我经历过那段时间:凌晨两点被线上告警吵醒,订单系统在促销活动中出现了库存扣成负数,锁在集群模式下突然失效,缓存雪崩直接把数据库打穿。从那以后,我对Redis的定位就变了——它不是一个缓存工具,而是一个需要认真设计的分布式基础设施。
这篇文章不聊那些烂大街的基础命令,也不会停留在“加个缓存就完事”的层面。我把我这些年做Redis架构升级时踩过的坑、验证过的方案、还有那些网络上很少说清楚的原理细节,从头到尾梳理一遍。你会看到:主从、哨兵、Cluster三种高可用方案怎么选,为什么要选它;分布式锁到底怎么实现才算是可靠的;用Lua脚本保证原子性的正确姿势是什么;以及那些最容易翻车的缓存一致性、序列化、内存问题怎么排查。适合已经在用Redis、但想把架构做扎实的朋友阅读,也适合准备面试时想把Redis讲出深度的同学。
1. 高可用集群:别让Redis成为整个系统的单点
先说高可用。很多人觉得高可用是“搞个集群就好了”,但实际上,Redis的集群方案有好几种,主从复制、哨兵模式、Cluster集群,它们解决的问题不同,适用场景也完全不同。选错了方案,就等于把隐患埋在了架构最底层。
1.1 主从复制:高可用的地基,但不要指望它能自动故障转移
主从复制是Redis高可用的基础。原理不复杂:主节点(Master)负责处理写请求,从节点(Slave)通过复制主节点的数据来提供读能力。复制过程可以简单理解为三步走:
- 从节点启动后,向主节点发送
PSYNC同步命令,如果是从节点第一次连接或者主从数据差距太大,主节点会执行一次BGSAVE生成RDB快照,把快照文件发给从节点。 - 从节点加载RDB文件,把数据恢复到本地。
- 快照同步完成之后,主节点会把复制期间新产生的写命令,通过输出缓冲区持续推送给从节点,这个过程叫增量复制。
主从部署起来很简单,最精简的配置就是给从节点加一条replicaof <master-ip> <master-port>(老版本叫slaveof)。
但主从复制有一个非常明显的短板:从节点不会自动升级为主节点。如果主节点挂了,整个系统就只能读不能写。有人可能会想,那我手动把从节点切换成主节点不就行了?问题是,生产环境里主节点宕机往往发生在凌晨或者业务高峰期,等运维人员发现再手动切换,这段时间的服务不可用直接就是业务损失。
主从复制还有一个隐藏问题:复制延迟。因为主从之间是异步复制,从节点的数据永远存在延迟,极端情况下主节点刚写完就宕机,这部分数据还没来得及同步到从节点,就会直接丢失。
所以我的建议是:主从复制可以作为高可用架构的一部分,但绝不能作为高可用方案的全部。它解决的是数据容量和读压力扩展的问题,而不是故障自动恢复的问题。
1.2 哨兵模式:第一个真正意义上的“高可用”方案
哨兵模式(Sentinel)是在主从复制之上加了一层故障检测和自动切换机制。你可以把Sentinel理解成一个“监工”,它持续监控主节点和从节点的健康状态,一旦发现主节点不可用,就会自动从从节点中选举一个新的主节点,并把其他从节点切换到新的主节点上。
哨兵模式有几个核心概念必须搞清楚:
心跳检测机制。每个哨兵节点每隔1秒(默认)向自己监控的Redis节点发送PING命令。如果Redis节点在配置的时间窗口内没有响应,哨兵会先把这个节点标记为“主观下线”(SDOWN),意思是“我觉得它下线了”。
但单个哨兵说了不算。为了防止网络抖动造成的误判,哨兵之间会互相协商:当多个哨兵都确认同一个主节点不可达时,才会把它标记为“客观下线”(ODOWN)。这个“多个哨兵”的阈值就是配置里的quorum参数。
故障转移(Failover)是怎么发生的。一旦主节点被确认为客观下线,哨兵集群会选举一个Leader哨兵来执行故障转移。Leader哨兵会挑一个从节点作为新的主节点,挑选的优先级是:
- 配置了高优先级(
slave-priority)的从节点优先; - 如果优先级相同,复制偏移量最大的从节点优先(说明它数据最完整);
- 如果复制偏移量也相同,选择进程ID最小的从节点。
然后Leader哨兵会向新主节点发送SLAVEOF NO ONE命令,让新主节点停止复制并变为可写。其他从节点则被告知去复制新的主节点。整个过程中,客户端是无感知的,只要客户端支持哨兵协议,就能自动发现新的主节点地址。
关于quorum这个参数,我在生产环境里踩过一个坑。第一次部署时我把quorum设成了1,想着“只要有一个哨兵发现主节点挂了就切换”,结果某次网络抖动导致主节点和哨兵之间的连接短暂中断,触发了误切换,而切换期间新的主节点数据还不完整,直接造成了一小段时间的数据不一致。后来我把哨兵节点数量增加到三个,把quorum设为2,误判的概率才降下来。凡是涉及决策的地方,都不能只让一个节点说了算。
哨兵模式已经能解决主节点宕机后自动恢复的问题,但它有个局限:整个集群只有一个主节点,所以写性能和存储容量受限于单台机器。当业务量继续增长,单机写能力成为瓶颈时,就要考虑Cluster了。
1.3 Cluster集群:数据分片才能真正突破容量和吞吐瓶颈
Redis Cluster是Redis官方提供的高可用方案,从3.0版本开始正式发布。它的核心设计是“分片”:把整个数据集分成16384个哈希槽(Hash Slot),每个节点负责一部分槽。写入数据时,Redis会对key做CRC16算法,将得到的结果对16384取模,算出这个key应该落在哪个槽上,再由负责该槽的节点处理。
这里有个问题我经常被问到:为什么哈希槽是16384个,而不是65536个或者更多?这个数值是有讲究的。
16384个槽位对应的CRC16计算结果范围是0到16383。如果槽位数量是65536,节点之间做心跳信息同步时,消息头中用来描述槽位信息的位图需要8KB(65536bit / 8bit = 8KB)。而16384个槽位只需要2KB。Redis节点之间通过Gossip协议通信,网络包会很频繁,控制报文越小,对网络带宽的占用越少。
另外,Redis主节点的数量一般不会超过1000个,16384个槽位足够均匀分配给每个节点,设计冗余度足够。所以16384是在通信效率和数据分布均匀度之间权衡后的结果,不是随便定的。
Cluster模式下,客户端访问的流程跟单机完全不一样。如果客户端请求的key对应的槽不在当前节点上,Redis不会直接转发请求,而是返回一个MOVED错误,告诉客户端:“这个key在哪个IP:端口上,请你去那里访问”。更复杂的场景是数据迁移过程中,部分槽正在从A节点迁移到B节点,这时客户端请求会收到ASK错误,提示客户端去目标节点做一次ASKING后重新执行命令。
这些细节我建议用redis-cli -c集群模式连接登录后,手动执行几个跨节点的操作,实际看看返回信息,比死记硬背强得多。
Cluster集群很好地解决了容量和吞吐的扩展问题,但带来了新的妥协:
- 不支持多key操作。因为key可能散落在不同节点上,跨节点的
MGET、事务、Lua脚本就不再可靠了。解决方案是用hash tag机制,把需要一起操作的key放在同一个槽里,比如{user100}.profile和{user100}.cart,Redis只会对{}内的字符串做哈希计算,所以这两个key一定落在同一个节点上。 - 主从切换是反客为主的。Cluster中的每个主节点可以搭配多个从节点,当主节点挂了,它下面的从节点会被提升为主节点。这一点跟哨兵模式自动切换的逻辑类似。
1.4 高可用方案选型对比:三板斧到底怎么选
为了让你能直接做决策,我这里把三种方案的差异和适用场景整理成一个表格:
| 方案 | 数据分片 | 自动故障转移 | 写扩展性 | 适用场景 |
|---|---|---|---|---|
| 主从复制 | 否 | 否 | 不扩展 | 数据量小、允许手动运维的早期场景 |
| 哨兵模式 | 否 | 是 | 不扩展 | 单机写能力够用、读量大、需要自动恢复 |
| Cluster集群 | 是 | 是 | 水平扩展 | 数据量大、写压力高、需要线性扩展 |
这个表格是我当年做技术选型时的判断依据。说实话,现在的业务系统只要有点规模,我都建议直接上Cluster,因为从主从复制迁移到Cluster成本很高,早期就选用对方案能省很多事。
2. 实操部署:用Docker搭建一套可靠的Redis高可用架构
方案聊再多,不落地都是纸上谈兵。这一节我带大家完整走一遍基于Docker的哨兵模式高可用架构搭建过程。网络上Docker安装Redis主从的教程一搜一大把,但很多关键细节没人讲清楚,比如认证怎么配置、哨兵怎么安全地穿透Docker网络、故障转移完旧主节点怎么处理。这些我都会讲到。
2.1 环境准备与网络规划
先规划好结构。我用三台机器(或三个容器)搭一主两从的模式,这样故障转移时才有备选从节点。所有Redis实例全部跑Docker容器,做一套私有网络,保证节点间通信安全。
# 创建Docker网络 docker network create --subnet=172.20.0.0/16 redis-net # 约定主从节点地址(可按实际情况调整) # Master: 172.20.0.10:6379 # Slave-1: 172.20.0.11:6379 # Slave-2: 172.20.0.12:6379 # Sentinel-1: 172.20.0.20:26379 # Sentinel-2: 172.20.0.21:26379 # Sentinel-3: 172.20.0.22:26379很多人在这一步会图省事,直接用--link或者默认bridge网络。我建议不要偷懒,单独建一个网络,因为Redis主从复制的流量会经过网络传输,网络隔离做得不好,别的容器随便连进来,数据就容易暴露。
2.2 部署主从节点并验证复制链路
首先挂载三个目录,分别存放三个节点的数据:
mkdir -p /opt/redis/master/data mkdir -p /opt/redis/slave1/data mkdir -p /opt/redis/slave2/data主节点配置。
# master.conf port 6379 bind 0.0.0.0 protected-mode yes requirepass RedisMaster@2024 masterauth RedisMaster@2024 appendonly yes appendfsync everysec注意这里的masterauth,很多教程会忽略它。当从节点需要连接主节点同步数据时,如果主节点配置了requirepass,从节点就必须用masterauth中配置的密码去认证。这一步遗漏的后果是,主从复制一直建立不起来,日志中会出现“NOAUTH Authentication required”这样的报错。
两个从节点的配置基本一样,唯一不同的是增加replicaof:
# slave.conf port 6379 bind 0.0.0.0 protected-mode yes requirepass RedisMaster@2024 masterauth RedisMaster@2024 appendonly yes appendfsync everysec # 这里的IP/端口指向主节点 replicaof 172.20.0.10 6379启动容器:
docker run -d --name redis-master \ --network redis-net --ip 172.20.0.10 \ -v /opt/redis/master/data:/data \ -p 6379:6379 \ redis:7.2 redis-server /data/master.conf docker run -d --name redis-slave1 \ --network redis-net --ip 172.20.0.11 \ -v /opt/redis/slave1/data:/data \ -p 6380:6379 \ redis:7.2 redis-server /data/slave1.conf docker run -d --name redis-slave2 \ --network redis-net --ip 172.20.0.12 \ -v /opt/redis/slave2/data:/data \ -p 6381:6379 \ redis:7.2 redis-server /data/slave2.conf启动后,在主节点上检查复制状态:
docker exec -it redis-master redis-cli -a RedisMaster@2024 info replication如果输出中connected_slaves显示为2,而且slave0和slave1的状态都是online,说明复制链路已经通了。这时候我又习惯会写几个测试key,再到从节点上查一下,确认数据同步是实时的。
2.3 配置哨兵集群并进行故障演练
主从复制是安静的状态,哨兵才是那个时刻瞪大眼睛的角色。
每个哨兵节点的配置都差不多:
# sentinel.conf port 26379 daemonize no protected-mode no # 监控主节点,quorum设为2表示至少2个哨兵投票才判定主节点下线 sentinel monitor mymaster 172.20.0.10 6379 2 sentinel auth-pass mymaster RedisMaster@2024 sentinel down-after-milliseconds mymaster 5000 sentinel failover-timeout mymaster 60000 sentinel parallel-syncs mymaster 1有几个参数是我反复调过的:
down-after-milliseconds:指的是哨兵在多少时间内没收到主节点的响应,就判断主节点主观下线。设置太短,网络抖动就能触发误判;设置太长,真实宕机时恢复时间也变长。我一般在正常网络环境下设为3000到5000毫秒。parallel-syncs:故障转移完成后,同时有几个从节点可以向新主节点发起复制。如果这个值太大,新主节点要同时给多个从节点发送RDB快照,瞬间压力会很大。我通常设为1,让从节点一个一个重新同步,最稳妥。failover-timeout:故障转移的超时时间。如果主从切换过程中出现问题,超过这个时间就放弃这次转移,重新选举决策。
启动三个哨兵容器:
docker run -d --name redis-sentinel1 \ --network redis-net --ip 172.20.0.20 \ -v /opt/redis/sentinel1:/sentinel \ redis:7.2 redis-sentinel /sentinel/sentinel.conf # 其余两个哨兵类似,IP分别改为172.20.0.21、172.20.0.22启动完所有哨兵后,通过redis-cli -p 26379 sentinel masters能够看到当前监测的主节点信息。如果一切正常,num-other-sentinels应该显示为2,说明另外两个哨兵也已经加入监控。
现在就来做故障演练。执行:
docker stop redis-master等待十几秒后,观察哨兵日志:
docker logs redis-sentinel1 --tail 50日志里会依次出现+sdown(主观下线)、+odown(客观下线)、+vote-for-leader(投票选举Leader)、+switch-master(切换主节点)等状态记录。整个过程自动完成,不需要人工干预。
切换完成后很重要的一件事是:旧的Master节点在恢复后,会不会自动变成新Master的从节点?答案是会的。哨兵在发现旧主节点重新在线后,会自动向它发出REPLICAOF命令,让它去复制新的主节点。所以这块不需要额外处理,但前提是旧主节点的配置里得配置了正确的masterauth,否则它还是无法认证新主节点。
2.4 核心参数计算公式与容量评估
部署高可用集群前,还得学会估算自己的容量需求。我提供一套简化但实用的估算方式:
假设每秒钟写入的Redis请求量为W次,每个key的平均大小为K(KB),业务的写入峰值时长为T(秒)。那么高峰期新增数据量约为:
新增数据量 ≈ 主节点RDB落地频率 × K × W × (T + 缓冲时间)举个例子:假设RDB每60秒生成一次,key平均大小5KB,最高每秒写入10000个key,连续写入2小时。高峰期生成的数据量:
5KB × 10000 × 7200 ≈ 360GB这时候哨兵模式的主节点单机内存至少得预留峰值数据量的1.5倍左右,因为Redis的数据在内存中占据的物理空间往往比逻辑键值大小要大,加上碎片和复制缓冲区,预留少了很容易触发OOM(内存耗尽)。如果是Cluster,每个主节点承担一部分槽,容量可以均摊,比如3分片每个节点承担120GB的活跃数据,压力就小很多。
3. 原子性保障:Redis不是只能做“缓存”
光把集群搭建起来只是第一步。等你的系统真正进入高并发阶段,你会发现Redis的“原子性”才是躲不开的硬骨头。你可能会在面试里被问到“Redis为什么快”,答案是单线程模型——事件循环里所有命令按顺序执行,天然具备原子性。但在真实业务里,事情远没有那么简单。
3.1 什么时候Redis自己就能保证原子性
如果一条命令本身就是原子的,如果它涉及到的某几个命令操作合在一起,但整个过程不需要在中间插入其他命令,那Redis的单线程执行模型就能保证原子性。
举个例子:
SET user:1001:cnt 100 INCR user:1001:cntINCR是原子的,因为Redis内部对单个命令的执行不会被其他命令打断。
SETNX lock:order 1 EXPIRE lock:order 30这两条命令分开写,就不是原子的了。如果执行完SETNX之后,Redis进程突然崩溃了,EXPIRE就没有机会执行,这把锁就永远没有人释放了。所以Redis能保证的原子性仅限于单条命令或Redis事务的范围内,业务操作往往需要自己去构造原子性。
3.2 用Lua脚本构建复合操作的原子性
Redis从2.6版本开始支持Lua脚本,这是我说“原子性有救”的关键。Lua脚本在Redis内的执行方式是:先把一段脚本整个加载到Redis中解释执行,脚本期间Redis不会处理任何其他命令,这就相当于一条超大命令,天然具备原子性。
拿最典型的“扣减库存”场景举例。假设库存key为stock:sku:1001,你要在判断库存充足的前提下把库存减一:
-- 库存获取与扣减脚本 local key = KEYS[1] local delta = tonumber(ARGV[1]) local stock = tonumber(redis.call('GET', key)) if stock >= delta then redis.call('DECRBY', key, delta) return 1 else return 0 end这里有个官方推荐的做法必须强调:Lua脚本的key操作要用KEYS和ARGV动态传入,不能写死在脚本里。
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 user:name zhangsan写死的键名会导致两个问题:一是在集群模式下,脚本中的key可能不属于当前节点,执行会直接报错;二是缓存脚本(EVALSHA)时哈希值无法跟动态key正确对应。
另一个被很多人忽略的坑是:Lua脚本里不要使用会导致结果不确定的函数,比如TIME、RANDOMKEY、SRANDMEMBER这类命令。因为Redis Cluster在做主从复制时,从节点需要重放脚本的执行结果来保证一致性。如果脚本内容不确定,主从节点数据就会不一致。Redis会直接拒绝执行这类脚本,或者禁止写入数据。
脚本写好后,用EVALSHA代替EVAL可以节省带宽。先执行SCRIPT LOAD把脚本加载到Redis中,得到一个SHA摘要,之后每次都携带这个SHA调用脚本即可。
3.3 分布式锁的正确打开方式:从SETNX到Redisson
分布式锁是讨论Redis原子性时绕不开的内容,也是面试高频考点。它好写,但非常容易写错。市面上各种不严谨的姿势我见得太多了。
早期的做法是:
SETNX lock:order 1 EXPIRE lock:order 30问题前面已经说过,非原子。一旦SETNX成功但EXPIRE失败,锁就无法自动释放。后来有人改成在SETNX之前先DEL锁再加过期时间,但这完全没有理解问题的根源。
Redis 2.8版本起就提供了原子性的SET命令:
SET lock:order 1 EX 30 NX这条命令同时做到了三件事:
NX:只有键不存在时才设置成功;EX 30:为key设置30秒过期时间;- 整个操作是原子的。
这才是一个分布式锁“加锁”的正确姿势。不过,光有正确的加锁还不够,“释放锁”同样存在原子性问题。正确的解锁逻辑必须校验持有者:如果直接DEL,有可能把别人刚获取到的锁删掉。场景是这样的:
- 线程A获取锁,设置锁的value为唯一标识uuid-001;
- 线程A处理业务,但业务执行时间超过了锁的过期时间,锁自动过期释放;
- 线程B获取到锁,value是uuid-002;
- 线程A处理完毕,执行
DEL lock:order,把线程B的锁删了。
所以解锁必须用Lua脚本校验持有者,这个校验和删除也必须作为一个原子操作执行:
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end你在生产环境用Redisson或者Lettuce的锁时,它们内部几乎都是这套逻辑,没有必要自己重复造轮子。
说到Redisson,它的“看门狗”机制值得单独拎出来讲。Redisson在获取分布式锁后,并不依赖设置一个固定的过期时间,而是默认启动一个后台线程,每10秒(锁有效期默认30秒)为当前有效期的锁自动续期10秒。只要业务线程还活着,锁就不会过期失效。这个设计解决的正是“业务执行时间超过锁过期时间”的老大难问题。
从我的经验来看,只要业务还在跑,就不要迷信“自定义过期时间”。设置太短,锁提前失效导致并发访问风险;设置太长,一旦持有锁的节点挂掉,锁要很久才能释放。Redisson的看门狗虽然也会在持有锁的节点崩溃后停止续期,但它在正常工作时几乎是唯一能兼顾“业务时长不确定”和“锁自动释放”的成熟方案。
3.4 RedLock到底能不能用,我谈谈真实看法
关于分布式锁,还有一个特别有争议的话题:RedLock。它由Redis之父antirez提出,核心思路是同时向多个独立部署的Redis节点请求加锁,只有当超过半数的节点加锁成功后,才算真正获取到锁。
RedLock在理论层面遭受的质疑不少,最著名的反对者是Martin Kleppmann(《数据密集型应用系统设计》的作者),他主要攻击的是:RedLock依赖各个Redis节点的时间同步,一旦某节点发生时钟跳跃,锁的过期时间会被错误地延长或缩短,从而破坏互斥性。
我在生产环境并没有很依赖RedLock,因为日常业务对锁的严格性要求没有达到“绝对互斥”的程度。如果只是做防重、限流、定时任务抢占,单节点的Redis分布式锁加上看门狗机制已经完全够用。但如果是金融支付或者涉及账户资金流转的强一致场景,我根本不会选择Redis来做锁,而是会用ZooKeeper或etcd这类带有持久性和强一致特性的组件。Redis分布式锁解决的是“可用性”问题,不是“绝对正确性”问题。这两者的界限务必要分清楚。
4. 高可用和原子性结合:缓存治理中的经典难题
高可用集群部署完成后,你以为就稳了吗?如果你的业务系统还面对这“缓存穿透、击穿、雪崩”这三座大山,那原子性和高可用一样不能少。这一章我把它们串起来讲,因为它们无一例外都跟“重新构建缓存”时的原子操作有关。
4.1 缓存击穿与互斥锁重建的正确姿势
缓存击穿是指一个热点key在并发量极大的情况下突然过期了,导致大量请求同时穿透到数据库。处理思路有两个方向:要么让热点key永不过期(定期异步更新),要么在重建缓存时加锁,保证只有一个线程去数据库查询。
用Redis分布式锁来实现互斥重建,伪代码如下:
String value = redis.get(key); if (value != null) { return value; } // 只有拿到锁的线程才允许查数据库 String lockKey = "lock:" + key; String lockValue = UUID.randomUUID().toString(); boolean locked = redis.set(lockKey, lockValue, 30, TimeUnit.SECONDS, "NX"); if (locked) { try { value = db.query(key); redis.set(key, value, 10, 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"; redis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(lockValue)); } } else { // 拿不到锁的线程等待100ms后重新读取缓存 Thread.sleep(100); value = redis.get(key); } return value;这种写法在高并发时能兜底,但接口时延会有所增加。如果是热点数据更新的频率不是那么极端,也可以考虑“逻辑过期”方案:不设置物理过期时间,而是在value中额外存一个过期时间戳。读的时候如果发现逻辑过期,就异步发起重建任务,由唯一执行标记控制并发。
4.2 缓存雪崩时,原子性怎么帮助你“躲”过去
缓存雪崩是指大量缓存同时失效,数据库压力瞬间飙升。常见应对方案是给不同key的过期时间加一个随机扰动,比如设置成10分钟加上0到60秒的随机数。这是“错峰”思维,简单有效。
但如果雪崩已经发生了呢?此时高可用集群的作用就来了:只要有备份节点可以用来分摊读请求,数据库的压力就能大大缓解。在Cluster集群下,热点key可以分布在多个主节点上,配合读写分流,雪崩的冲击波就会被削弱很多。另外,对更新频率高的缓存数据,用分布式锁控制重建的原子性,能防止多个实例同时对数据库发起重复查询。
4.3 缓存与数据库双写一致性:原子性不是全部答案
做了多年的缓存的同行,肯定被问过“先更新数据库还是先更新缓存”的问题。我的回答一般是:不要试图更新缓存,只做删除缓存。
标准套路是:
- 更新数据库;
- 删除缓存;
- 后续请求发现缓存未命中,回源数据库并重建缓存。
这套流程能保证最终一致性。但有一个隐藏的坑:如果在第2步删除缓存时,Redis刚好不可用,删除失败怎么办?这里就需要一个保证删除操作的机制——可以投递一个删除消息到消息队列,由消费端重试删除,直到成功为止。这个过程也可以由本地消息表来做事务性保证,数据库事务提交成功后,同时将删除任务写入消息表,异步发送给MQ去消费。
双写一致性有一个本质问题:你用再多的原子操作也无法消除并发时序下的窗口期。比如线程A先更新了数据库,线程B读到旧数据并写回缓存,一会儿线程A才删除缓存,结果缓存里又被B写回了旧数据,导致缓存与数据库不一致。解决这类问题不能只靠Redis本身,还得靠版本号、时间戳或者binlog订阅方案来判定数据的新旧,这是一个系统工程,不是简单的“先删缓存再更新”或“先更新再删缓存”能搞定的。
4.4 集群模式下原子性的局限与规避
前面说的Lua脚本和事务,在单机Redis下都能很好工作。但在Cluster集群模式下,有几个不可回避的限制你要心里有数:
- 跨slot的多key操作无法使用事务和Lua脚本。因为数据分布在不同的节点上,Redis事务不支持跨节点回滚,Lua脚本也无法在多个节点上同时执行。
MULTI/EXEC在Cluster模式下只能作用于同一个节点。如果你要让多个key同时被修改,只能靠hash tag把key钳制在同一个槽位。- 分布式锁如果跨节点使用RedLock的变体版本,复杂度会急剧上升。每增加一个Redis主节点,就等于增加一个故障点,锁的管理难度成倍增加。
我自己的项目里有一条铁律:凡是需要原子性操作的多key,必须用hash tag让它们落同一个slot。在数据建模阶段就确定好哪些key需要捆绑,不要等上线之后再靠哈希巧合,因为哈希算法是确定的,没有指定的tag,两个不同的key落在同一槽的概率只有1/16384,这种概率在线上几乎等于零。
5. 原子性保障的实战场景:分布式定时任务与限流
高可用与原子性的价值,在具体业务场景中体会最深。我挑两个非常有代表性的场景展开讲:分布式定时任务怎么保证只有一个实例执行,以及限流器在高并发下怎么做才不会被击穿。这两个场景都能让你把前面学的原子性概念真正用起来。
5.1 基于Redis原子操作的分布式定时任务抢占
微服务架构下,一个定时任务往往部署在多个实例上。如果每个实例都执行一遍,数据就会被重复处理。这就是典型的分布式定时任务问题:怎么只让一个实例执行一次。
最轻量级的方案是采用Redis的SET命令获取分布式锁,那么同一时刻只有一个实例能抢到定时任务的执行权。伪代码如下:
String taskKey = "task:scheduler:order"; String lockValue = UUID.randomUUID().toString(); boolean ok = redis.set(taskKey, lockValue, 60, TimeUnit.SECONDS, "NX"); if (!ok) { log.info("另一实例已获取定时任务执行权限,本实例跳过"); return; } try { // 执行业务逻辑:处理超时订单、生成对账单、发送通知等 } finally { String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end"; redis.eval(script, Collections.singletonList(taskKey), Collections.singletonList(lockValue)); }这里的锁有效期要结合你任务的最长执行时间。如果你用的是Redisson,看门狗自动续期就能解决“任务跑一半锁过期”的问题。我实际处理过一种极端情况:某张报表任务每天凌晨3点跑,正常半小时能跑完,但某次数据量暴涨,跑了将近两个小时。如果锁的过期时间设成了60秒,任务执行到一半锁就释放了,其他实例也会陆续冲进来抢锁,同一份数据被处理了好几遍。后来我引入了Redisson,没有再出过这种问题。
5.2 基于Redis的分布式限流器:原子性如何保护你的接口
缓解突发流量,限流是基本功。Redis+Lua实现一个固定窗口或滑动窗口限流器,操作非常方便。
滑动窗口的实现可以用ZSet数据结构。每次请求到达时,把当前时间戳写入ZSet,然后删除窗口之外的旧记录,最后统计窗口内的请求数量。
-- 滑动窗口限流脚本 local key = KEYS[1] local window = tonumber(ARGV[1]) -- 时间窗口,单位毫秒 local limit = tonumber(ARGV[2]) -- 窗口内最大请求数 local current = redis.call('TIME')[1] * 1000 + math.floor(redis.call('TIME')[2] / 1000) redis.call('ZREMRANGEBYSCORE', key, 0, current - window) local count = redis.call('ZCARD', key) if count < limit then redis.call('ZADD', key, current, current .. '-' .. math.random(10000)) redis.call('PEXPIRE', key, window) return 1 else return 0 end注意我用的是redis.call('TIME')来获取当前时间,并没有用客户端传入的时间。为什么?因为多个实例的服务器时钟可能不同步,如果都用本地时间,那这个限流就会出现偏差。而TIME命令返回的是Redis节点的时间,在同一集群环境下相对可靠。不过前面说过,TIME会引入不确定性,这段脚本就不适合直接用到主从复制下的Cluster集群中,因为它会导致从节点的数据跟主节点不一致。解决思路是让业务把时间戳作为参数传进来,保证脚本确定性,而时钟同步的问题交给服务器NTP去处理。
限流器本身对结果一致性要求没有那么苛刻,不过一旦做坏了,接口在高峰期被放过的请求数量远超预期,数据库一样会被压垮。
5.3 缓存治理与原子性思维的融合
缓存治理的核心思路是“在性能、一致性、可用性之间找到平衡点”。原子性操作更多是帮你减少一致性风险,而不是彻底解决一致性问题。
我经常对项目组同事说一句话:“能不用缓存就不要用缓存,不得不用缓存时,必须想清楚缓存失效后谁来回源、回源有什么保护、回源失败怎么办。”这三个问题想清楚了,缓存治理就已经成功了一半。剩下的几个坑——比如key序列化、内存淘汰策略、大key排查——其实跟高可用和原子性关系不大,但同样值得警惕。
StringRedisTemplate和RedisTemplate混用导致key带上莫名前缀、increment报“not integer or out of range”这类问题,绝大多数都是序列化器配置不一致导致的。我把一次排查increment报错的例子记录下来:客户端用RedisTemplate写入了一个Long值,但配置的Serializer是JDK默认序列化,导致实际存储到Redis里的是一个带Java类信息的二进制流;后续再用StringRedisTemplate的increment去操作这个key时,Redis发现value不是整数,直接抛出异常。解决方法是让写入端和读取端使用相同的序列化方案,不要一个用JDK序列化,一个用String序列化,否则改数据比改代码还麻烦。
6. 常见问题与排查:生产环境中的血泪教训
最后一章,我把这些年生产环境中最常遇到的、也最有借鉴价值的问题整理成一张速查表。很多问题看起来是Redis的事,实际上一查你会发现是客户端使用姿势的问题。
6.1 问题速查表
| 现象 | 根因 | 排查思路 |
|---|---|---|
| 主从复制一直连不上 | 未配置masterauth | 检查主节点requirepass是否带了认证信息 |
| 主节点切换后新主节点一直掉线 | 旧主节点配置中的masterauth错误 | 纠正旧主节点的masterauth并重启 |
集群模式执行MGET报错 | key分散在不同slot | 为相关key加同一个hash tag |
increment操作报 not integer | 序列化器不一致或value非数值 | 用TYPE查看类型,用GET查看value |
| Redis内存突然飙升 | key过期时间设置不合理或大key未拆分 | 用redis-cli --bigkeys扫描大key |
| 缓存雪崩导致数据库压力陡增 | 大量key同时过期 | 给过期时间加随机扰动,并对缓存重建加锁 |
| 锁无法正常释放 | 客户端崩溃未执行DEL加上无过期时间 | 加锁时必须设置过期时间 |
| 哨兵模式频繁误切换 | quorum设置过小或心跳超时太短 | 提高quorum,适当调大down-after-milliseconds |
| Cluster扩容/缩容期间访问异常 | 槽迁移过程中客户端未处理ASK重定向 | 客户端需支持自动重试ASK重定向,或在低峰期操作 |
这个表格看起来简单,但每一条背后都是一次线上事故。
6.2 一年内遇到过的几个典型案例
第一个案例是哨兵模式下的误切换。当时系统还在用比较老的内存机型,机器负载偶尔飙高,导致Redis进程响应变慢。由于哨兵的心跳超时设置的是1秒,瞬间判断主节点主观下线,再加上quorum配置成1,单哨兵就能触发切换。结果是主备切换期间大量写请求失败,恢复后数据也有短暂不一致。后来我们把down-after-milliseconds调大到3秒,quorum调到2,同时优化了Redis实例所在机器的负载监控,再也没出现过误切换。
第二个案例是Cluster模式下的批量操作不可用。项目早期用了大量的MGET和管道操作,全部基于单机Redis开发。后来数据量增长,不得不迁移到Cluster。代码一上线就发现线上异常激增,一直报CROSSSLOT错误。最后通过给同一用户的多个key统一加user:{userid}:前缀作为hash tag,才把这个问题解决。这个教训记住一点:从单机Redis迁移到Cluster前,一定要在一开始的数据建模阶段就想清楚hash tag的设计,否则代码改动量非常巨大。
第三个案例是有一次线上扣库存“扣成负数”。当时的实现是先用GET拿到库存,判断大于0后执行DECR。高并发一上来,十几个线程同时读到库存为1,然后同时执行DECR,最后库存变成-10。后来我用文章前面提到的Lua脚本把判断和扣减操作合并起来,问题就再也没有出现过。很多人觉得这个场景太基础,但线上真实如此,混乱的代码到处都是。
6.3 内存治理与故障恢复的经验
最后再说说内存这块吧。Redis高可用的前提是它必须活着。如果内存不够用,什么主从切换、原子性都是空谈。Redis的内存淘汰策略有noeviction、allkeys-lru、allkeys-lfu、volatile-lru等几种。如果选错了策略,可能出现缓存淘汰掉了不该淘汰的数据。
我通常的建议是:
- 纯缓存场景:使用
allkeys-lru。让Redis自己按照最近最少使用的原则淘汰冷数据。 - 有业务关键key的场景:为每个key设置合理的
TTL,使用allkeys-lru但把没有过期时间的key排除在外,避免错误淘汰。 - 数据不能丢失的业务场景:给Redis加上AOF和RDB双层持久化,并保证对关键操作开启
appendfsync always(每秒或每次写都同步),但这时性能会有所下降——性能和持久性是一个取舍。
大key排查也很重要。一个value达到几百KB甚至几MB的key,可能在网络传输时占用大量带宽,也可能导致Redis在处理它时阻塞其他命令。用redis-cli --bigkeys扫描出来后,对大型的hash、set、list,要拆分存储或使用单独的key集合,这在架构层面比单纯加机器更管用。
写在最后
从主从复制、哨兵模式到Cluster集群,从单条命令的原子性到Lua脚本的复合操作原子性,再到分布式锁、缓存击穿、雪崩防护、定时任务抢占、限流器,这一路下来其实你会发现,Redis的高可用和原子性从来不是两个独立的话题,它们是一体两面的。没有可靠的高可用集群做底座,原子性操作在故障切换面前照样会崩塌;没有原子性保障,再高可用的集群也只能保证“不挂”,保证不了“不错”。
我个人的体会是,做Redis架构升级千万不能一步到位,也不能盲目追新。最稳妥的路径是先记录清楚业务真实的读写模型、数据量、延迟要求,然后从少量节点开始,逐步验证数据同步、故障转移、内存容量评估是否合理。每一步都做足演练,再敢谈上线。毕竟架构这件事,翻车一次的成本,往往比多花几个晚上做设计要高得多。
希望这篇总结能让你少踩几个坑。如果你也在做相关的架构升级,欢迎在实践后回来聊聊你的取舍和发现。