ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis高可用与原子性实战:从主从复制到分布式锁的架构升级

Redis高可用与原子性实战:从主从复制到分布式锁的架构升级 写这篇东西之前我想先聊聊动机。做后端开发这些年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节点之间做心跳信息同步时消息头中用来描述槽位信息的位图需要8KB65536bit / 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}.cartRedis只会对{}内的字符串做哈希计算所以这两个key一定落在同一个节点上。主从切换是反客为主的。Cluster中的每个主节点可以搭配多个从节点当主节点挂了它下面的从节点会被提升为主节点。这一点跟哨兵模式自动切换的逻辑类似。1.4 高可用方案选型对比三板斧到底怎么选为了让你能直接做决策我这里把三种方案的差异和适用场景整理成一个表格方案数据分片自动故障转移写扩展性适用场景主从复制否否不扩展数据量小、允许手动运维的早期场景哨兵模式否是不扩展单机写能力够用、读量大、需要自动恢复Cluster集群是是水平扩展数据量大、写压力高、需要线性扩展这个表格是我当年做技术选型时的判断依据。说实话现在的业务系统只要有点规模我都建议直接上Cluster因为从主从复制迁移到Cluster成本很高早期就选用对方案能省很多事。2. 实操部署用Docker搭建一套可靠的Redis高可用架构方案聊再多不落地都是纸上谈兵。这一节我带大家完整走一遍基于Docker的哨兵模式高可用架构搭建过程。网络上Docker安装Redis主从的教程一搜一大把但很多关键细节没人讲清楚比如认证怎么配置、哨兵怎么安全地穿透Docker网络、故障转移完旧主节点怎么处理。这些我都会讲到。2.1 环境准备与网络规划先规划好结构。我用三台机器或三个容器搭一主两从的模式这样故障转移时才有备选从节点。所有Redis实例全部跑Docker容器做一套私有网络保证节点间通信安全。# 创建Docker网络 docker network create --subnet172.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 RedisMaster2024 masterauth RedisMaster2024 appendonly yes appendfsync everysec注意这里的masterauth很多教程会忽略它。当从节点需要连接主节点同步数据时如果主节点配置了requirepass从节点就必须用masterauth中配置的密码去认证。这一步遗漏的后果是主从复制一直建立不起来日志中会出现“NOAUTH Authentication required”这样的报错。两个从节点的配置基本一样唯一不同的是增加replicaof# slave.conf port 6379 bind 0.0.0.0 protected-mode yes requirepass RedisMaster2024 masterauth RedisMaster2024 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 RedisMaster2024 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 RedisMaster2024 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的平均大小为KKB业务的写入峰值时长为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的分布式限流器原子性如何保护你的接口缓解突发流量限流是基本功。RedisLua实现一个固定窗口或滑动窗口限流器操作非常方便。滑动窗口的实现可以用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 tagincrement操作报 not integer序列化器不一致或value非数值用TYPE查看类型用GET查看valueRedis内存突然飙升key过期时间设置不合理或大key未拆分用redis-cli --bigkeys扫描大key缓存雪崩导致数据库压力陡增大量key同时过期给过期时间加随机扰动并对缓存重建加锁锁无法正常释放客户端崩溃未执行DEL加上无过期时间加锁时必须设置过期时间哨兵模式频繁误切换quorum设置过小或心跳超时太短提高quorum适当调大down-after-millisecondsCluster扩容/缩容期间访问异常槽迁移过程中客户端未处理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架构升级千万不能一步到位也不能盲目追新。最稳妥的路径是先记录清楚业务真实的读写模型、数据量、延迟要求然后从少量节点开始逐步验证数据同步、故障转移、内存容量评估是否合理。每一步都做足演练再敢谈上线。毕竟架构这件事翻车一次的成本往往比多花几个晚上做设计要高得多。希望这篇总结能让你少踩几个坑。如果你也在做相关的架构升级欢迎在实践后回来聊聊你的取舍和发现。
返回列表