一、Redis是什么——重新理解它的定位很多人把Redis简单定义为缓存这其实低估了它。Redis本质上是一个基于内存的、单线程事件驱动的键值存储系统更准确地说它是一个数据结构服务器。为什么单线程还这么快我们需要拆解它的线程模型。1.1 单线程的真相Redis的单线程指的是网络请求解析、数据读写、响应返回由同一个线程完成。但这不代表它内部没有并发——Redis 4.0之后引入了后台线程处理UNLINK、FLUSHDB ASYNC等耗时操作。单线程的核心优势没有锁竞争数据结构操作天然线程安全没有上下文切换开销CPU缓存命中率高代码简洁可维护复杂命令如ZUNIONSTORE不需要考虑并发问题瓶颈也很明显CPU密集型操作会阻塞整个服务。所以生产环境要禁用KEYS *慎用SORT在大集合上。1.2 事件驱动模型四个核心组件Redis基于Reactor模式构建四个组件环环相扣Client1 ──┐ Client2 ──┤ Client3 ──┼──► IO多路复用器 ──► 事件分派器 ──► 事件处理器 Client4 ──┘ (epoll/kqueue) │ │ ┌─────┴──────┐ │ 连接应答处理器 │ 命令请求处理器 │ 命令回复处理器 └────────────┘关键细节IO多路复用器封装了epollLinux、kqueueBSD或select通用编译时自动选择最优方案事件分派器根据aeEventLoop中的文件事件表将可读/可写事件路由到对应处理器一次完整的命令周期aeApiPoll等待事件 →readQueryFromClient读取 →processCommand执行 →addReply写入缓冲区 → 在下一次beforeSleep中批量写回1.3 Pipeline与批量操作的本质Pipeline不是原子操作只是把多个命令打包发送、批量接收。真正能减少RTT的是客户端将命令缓存在本地buffer一次write系统调用发送所有命令服务端顺序执行结果也打包返回这跟MGET、MSET的区别在于后者是原子操作Pipeline不是。二、数据类型深度剖析2.1 String——不仅仅是字符串String的底层是SDSSimple Dynamic String不是C原生字符串structsdshdr{intlen;// 已用长度intfree;// 剩余空间charbuf[];// 柔性数组};设计亮点O(1)获取长度C字符串要O(n)预分配空间减少内存重分配次数二进制安全能存图片、序列化对象\0不会截断惰性空间释放缩短后不立即回收内存int编码优化当value能转为整数且在LONG_MIN到LONG_MAX之间时Redis用int编码存储内存占用极低。当对其进行APPEND操作时会自动转为raw编码。应用场景分布式锁SET key value NX PX 30000计数器INCR天然原子不用加锁缓存对象可序列化JSON但推荐用Hash部分更新更友好2.2 List——快慢结合的双向链表List在3.2版本之前用ziplist压缩列表和linkedlist双向链表混合实现。3.2之后统一为quicklist——由ziplist作为节点的双向链表。quicklist: [ziplist1] ←→ [ziplist2] ←→ [ziplist3] ↑ ↑ ↑ 多个元素 多个元素 多个元素这样设计的精妙之处解决了linkedlist的指针空间开销大、内存碎片问题解决了纯ziplist的连锁更新风险每个ziplist节点大小可配置list-max-ziplist-size在空间和时间之间取得平衡阻塞队列的底层BLPOP/BRPOP在list为空时不立即返回而是将客户端信息放入blocking_keys字典数据到达后唤醒。超时机制通过时间轮实现。2.3 Set——哈希表与intset的切换Set有两个底层实现intset整数集合当元素全是整数且数量小于set-max-intset-entries默认512时使用。有序数组二分查找。hashtable哈希表元素较多或出现非整数时转换。intset的升级机制新元素插入时如果位宽不够如int16遇到int32元素整个intset会扩容升级不可逆。2.4 Hash——字典的渐进式rehashHash底层也是ziplist和hashtable的切换阈值hash-max-ziplist-entries和hash-max-ziplist-value。渐进式rehash的核心Redis的字典扩容不是一次性完成的而是维持两个哈希表ht[0]和ht[1]在每次CRUD操作时顺带迁移一部分数据rehashidx记录进度。这样做避免了大规模迁移导致的延迟抖动。扩容触发条件负载因子 used/size - 无BGSAVE/BGWRITEAOF时负载因子 1 - 有BGSAVE/BGWRITEAOF时负载因子 5考虑写时复制 缩容触发负载因子 0.12.5 Sorted Set——跳表与压缩列表的协奏Zset在元素数量少时用ziplist元素多时用skiplist dict组合zset { dict: {element → score} // O(1)按值查分 skiplist: 按score排序 // O(logN)范围查询 }为什么两者都要字典查分快但无序跳表有序但按值查慢。两者互补。跳表的层高生成每次插入时随机生成层高概率为p0.25即每升一层概率为25%。层高上限64。这种概率分布使得跳表的期望查询复杂度为O(log N)。Level 3: 1 ──────────────────► 15 Level 2: 1 ────► 5 ──────────► 15 Level 1: 1 → 3 → 5 → 9 → 12 → 15 → 20score相同时的排序按member的字典序排序。这保证了Zset的排序是严格全序的。三、过期键删除——内存与CPU的博弈Redis的键过期机制分两部分过期键的存储和过期键的删除。3.1 过期字典每个Redis DB维护一个expires字典键是指针指向键空间的键对象值是毫秒精度的过期时间戳。PEXPIRE和EXPIRE都是在这个字典中添加/更新记录PERSIST删除记录。3.2 三种删除策略的权衡惰性删除在访问键时检查过期则删除并返回空。实现入口在expireIfNeeded()几乎所有读写命令前都会调用。intexpireIfNeeded(redisDb*db,robj*key){if(!keyIsExpired(db,key))return0;// 从节点不主动删除等待主节点DEL命令同步if(server.masterhost!NULL)return1;// 删除键并通知AOF/从节点deleteKey(db,key);server.stat_expiredkeys;return1;}定期删除由activeExpireCycle()实现在beforeSleep()和serverCron()中调用。每次随机抽取20个键检查如果过期比例超过25%则继续但不超过执行时间上限默认1ms可配。为什么不选定时删除如果每个过期键都创建一个定时器百万级别的定时器会压垮CPU。极端场景大量键在同一秒过期如当天0点过期的活动缓存定期删除可能来不及处理。触发点是过期键比例超过25%此时Redis会持续扫描阻塞请求。解决过期时间加随机值。四、缓存三大经典问题与生产级方案4.1 缓存穿透——空值屏障本质查询不存在的数据缓存层永远无效。方案一缓存空值publicStringgetData(Stringkey){Stringvalueredis.get(key);if(value!null)returnvalue.equals(NULL)?null:value;valuedb.query(key);if(valuenull){redis.setex(key,60,NULL);// 缓存空值短过期时间}else{redis.setex(key,3600,value);}returnvalue;}注意空值缓存时间要短否则会占用内存且掩盖数据恢复。方案二布隆过滤器在Redis前面加一层布隆过滤器将所有可能存在的key提前加载。请求先过布隆过滤器不存在直接返回。缺点有误判率说有不代表一定有需要提前加载所有key 优点内存占用极低10亿数据约1.5GBRedis 4.0提供BF.ADD/BF.EXISTS等命令需要加载RedisBloom模块。4.2 缓存击穿——热点保护的两种思路场景秒杀商品的缓存刚好过期瞬间万级并发打到数据库。方案一互斥锁简单暴力publicStringgetData(Stringkey){Stringvalueredis.get(key);if(value!null)returnvalue;// 抢锁只让一个线程去查DBStringlockKeylock:key;if(redis.setnx(lockKey,1)){try{redis.expire(lockKey,10);valuedb.query(key);redis.setex(key,3600,value);}finally{redis.del(lockKey);}}else{Thread.sleep(50);returngetData(key);// 重试}returnvalue;}方案二逻辑过期不设TTL缓存永不过期但value中包含一个逻辑过期时间。读取时判断是否逻辑过期是则开异步线程去更新当前请求直接返回旧值。对比互斥锁保证一致性但可能阻塞请求逻辑过期高可用但返回旧数据4.3 缓存雪崩——多级防御成因大量key同时过期Redis集群宕机防御过期时间加随机值expireTime base random(0, 300)打散过期时间多级缓存本地缓存(Caffeine) → Redis → DB限流降级当DB压力过大时直接返回降级响应或空值高可用架构哨兵/集群保证Redis层不宕五、高可用架构详解5.1 主从复制——异步复制全过程全量同步SYNC/PSYNCSlave → Master: PSYNC ? -1 Master: 执行BGSAVE生成RDB Master: 发送RDB给Slave Master: 缓冲区记录期间的写命令 Slave: 加载RDB Master: 发送缓冲区命令 Slave: 执行缓冲区命令追上主库部分同步PSYNC断线重连时如果偏移量在复制积压缓冲区范围内只发送缺失部分。关键参数repl-backlog-size复制积压缓冲区大小影响部分同步成功率repl-diskless-sync无盘复制数据直接通过网络发送不落盘复制风暴一主多从时如果所有从库同时请求全量同步主库IO压力会瞬间飙升。5.2 哨兵——故障转移的细节哨兵的本质是一个分布式监控系统它解决的核心问题是主库宕机后谁来决策、谁来完成切换。主观下线SDOWN与客观下线ODOWN单个Sentinel: ping超时 → SDOWN自己的判断 多个Sentinel: 超过quorum个确认SDOWN → ODOWN集群共识选主逻辑过滤掉不健康的从库断线、响应慢优先级slave-priority配置值小的优先复制偏移量最接近主库的从库Run ID字典序最小的保证确定性哨兵集群的通信通过Redis的Pub/Sub机制哨兵节点间通过__sentinel__:hello频道交换信息。5.3 Cluster——数据分片的艺术哈希槽Hash Slot总共16384个槽均匀分配到各节点路由公式slot CRC16(key) % 16384只有key中有{}的部分参与计算支持hash tagMOVED与ASK重定向Client → NodeA: GET {user:1001} NodeA: 计算得slot6023但此槽在NodeB NodeA → Client: MOVED 6023 NodeB_IP:NodeB_PORT Client → NodeB: GET {user:1001} NodeB → Client: valueMOVED是永久重定向ASK是临时重定向槽迁移过程中。槽迁移过程源节点设置目标节点slot为importing状态目标节点设置源节点slot为migrating状态逐个迁移slot中的key迁移完成更新槽位信息并广播客户端Smart模式JedisCluster/JedisPool自带槽位缓存一次MOVED后更新本地路由表后续请求直达。5.4 代理模式当客户端不想改造时可以在前面加代理层TwemproxyTwitter开源轻量但不支持动态扩缩容Codis豌豆荚开源支持动态扩缩容带Dashboard管理Redis Enterprise商业方案本质都是在代理层维护槽位映射对客户端透明。六、分布式锁——从能用到可靠6.1 锁的本质与基本实现一把合格的分布式锁需要满足互斥任意时刻只有一个客户端持有锁防死锁即使持有者崩溃锁也能自动释放解铃还须系铃人谁加的锁谁来解最简版本# 加锁值用唯一标识防误删SET lock_key unique_id NX PX30000# 解锁用Lua保证原子性ifredis.call(GET, KEYS[1])ARGV[1]thenreturnredis.call(DEL, KEYS[1])elsereturn0end为什么必须是LuaGET和DEL是两个命令不用Lua就有并发问题A判断是自己的锁 → 此时锁过期 → B抢到锁 → A执行DEL删了B的锁。6.2 Redisson——自动续期与可重入Redisson的RLock基于Redis的Hash结构实现了可重入锁lock_key: { thread_id_1: 2 // 重入次数 }Watch Dog机制默认锁租约30秒每10秒检查一次如果业务还在执行就续期到30秒。这解决了业务超时锁自动释放的问题。RLocklockredisson.getLock(order_lock);lock.lock();// 默认30秒Watch Dog自动续期try{// 业务逻辑}finally{lock.unlock();}注意Watch Dog只在未显式指定leaseTime时生效。如果指定了时间到期自动释放不续期。6.3 主从锁丢失与RedLock问题客户端A在主节点拿到锁 → 主节点宕机锁数据未同步到从节点 → 哨兵将从节点提升为新主 → 客户端B在新主上拿到同一把锁 → 互斥被破坏。RedLock算法Redisson已实现假设有5个独立Redis节点获取锁的步骤 1. 获取当前时间戳 t1 2. 依次向5个节点尝试获取锁SET NX超时时间很短 3. 计算总耗时 t2 - t1 4. 如果超过半数(3个)节点成功 总耗时 锁有效期 → 获取成功 5. 锁实际有效期 锁有效期 - 总耗时争议Martin Kleppmann《数据密集型应用》作者认为RedLock不安全时钟跳跃会导致问题Redis作者Antirez反驳认为实践上足够可靠工程建议如果不是金融级场景单节点合理超时业务幂等已经足够6.4 性能优化——减少锁竞争缩小锁粒度// 不好库存扣减所有商品共用一个锁StringlockKeystock_lock;// 好每个商品独立锁StringlockKeystock_lock:productId;分段锁把一个大热点Key拆成多个请求路由到不同Keyintsegmenthash(userId)%10;StringlockKeyseckill_lock:segment;6.5 ZooKeeper分布式锁对比维度RedisZooKeeper实现SET NX Lua临时顺序节点释放主动删除超时主动删除会话断开自动删除性能高内存操作较低磁盘共识一致性AP可能丢失CPZAB协议适用高性能、允许短暂不一致强一致性要求ZK的实现创建临时顺序节点序号最小的获得锁前一个节点设置Watcher释放时ZooKeeper通知下一个。七、持久化——数据安全的最后防线7.1 RDB——快照的代价触发方式SAVE主进程阻塞执行线上禁用BGSAVEfork子进程主进程继续服务配置save m nm秒内n次修改触发写时复制COW的坑fork子进程时父子进程共享内存页标记为只读。主进程写操作时触发缺页中断复制该页。如果写操作很多内存可能翻倍。调优# 关闭COW大页减少fork延迟echonever/sys/kernel/mm/transparent_hugepage/enabled7.2 AOF——命令日志的演进刷盘策略appendfsync always # 每条命令fsync最安全但最慢 appendfsync everysec # 每秒批量fsync折中方案推荐 appendfsync no # 交给OS性能最好但可能丢数据AOF重写当AOF文件过大时BGREWRITEAOF触发重写用最简命令重建数据集原AOF可能有6条RPUSH命令 → 重写后变一条RPUSH list 1 2 3 4 5 6混合持久化4.0aof-use-rdb-preambleyes重写后的AOF文件前半部分是RDB格式后半部分是追加的AOF命令。兼顾恢复速度和数据安全。7.3 生产恢复策略场景RDBAOF混合最大丢失分钟级最多1秒最多1秒恢复速度快慢快文件大小小大中推荐混合持久化 每小时RDB备份到异地。八、内存淘汰策略选型当内存到达maxmemory时写入命令触发淘汰8.1 策略分类全局键空间无视TTLnoeviction不淘汰写入返回OOM错误allkeys-lruLRU淘汰推荐allkeys-lfuLFU淘汰4.0allkeys-random随机淘汰带过期时间键空间volatile-lru/volatile-lfu/volatile-random同上但只看有过期时间的键volatile-ttl淘汰TTL最短的键8.2 近似LRU算法Redis的LRU不是精确的而是采样近似1. 随机采样N个键maxmemory-samples默认5 2. 比较它们的空闲时间lru字段 3. 淘汰最久未使用的那个样本量越大越精确但CPU开销也越大。默认5是平衡点。8.3 LFU——更聪明的淘汰4.0引入的LFULeast Frequently Used不是简单的计数而是考虑了访问频率的衰减lru字段分成两部分 - 高16位最后访问时间分钟级 - 低8位访问计数器0-255概率递增定期衰减这样能避免曾经热门但早已冷下来的key长期霸占内存。九、工程实战案例9.1 延迟双删——缓存一致性的一次范式场景更新数据库后删除缓存但在主从延迟期间读请求可能读到旧数据并写回缓存。方案publicvoidupdateData(Datadata){// 1. 更新数据库db.update(data);// 2. 第一次删缓存redis.del(data:data.getId());// 3. 延迟后第二次删缓存覆盖主从延迟窗口delayQueue.offer(newDelayTask(()-{redis.del(data:data.getId());},1000));// 延迟1秒}进阶使用Canal监听binlog在确认主从同步完成后删缓存比固定延迟更可靠。9.2 防止订单重复提交——Token机制下单流程 1. 进入下单页 → 后端生成token → 存Redis 返回前端 2. 用户提交订单 → 前端带token 3. 后端用Lua原子校验并删除token if redis.call(GET, tokenKey) token then return redis.call(DEL, tokenKey) else return 0 end 4. 返回1表示首次提交0表示重复提交前端防抖只是锦上添花后端幂等才是必须。9.3 支付回调与订单超时的并发问题订单30分钟未支付自动取消定时任务/延迟队列用户在29分59秒支付回调到达时订单刚被取消解决——状态机乐观锁-- 取消订单时加状态条件UPDATEorderSETstatusCANCELLEDWHEREid12345ANDstatusUNPAID;-- 支付回调时UPDATEorderSETstatusPAID,pay_timeNOW()WHEREid12345ANDstatusUNPAID;-- 双方中谁影响行数为0就执行补偿逻辑如果支付回调的更新返回0说明订单已被取消此时触发退款记录异常流程。Redis的作用用Zset实现延迟队列ZADD delay_queue timestamp orderId定时任务ZRANGEBYSCORE获取到期订单。9.4 用户Token缓存——快与安全// 登录StringtokenUUID.randomUUID().toString();redis.setex(token:token,7200,userId);// 2小时过期// 续期策略每次请求刷新一半过期时间if(redis.ttl(token:token)3600){redis.expire(token:token,7200);}注意Token应该是无状态的Redis做黑名单而不是存储全部Token更轻量。十、总结Redis入门容易精通难。从SDS的二进制安全到跳表的概率平衡从主从复制的部分重同步到Cluster的槽位迁移从SETNX的原子加锁到RedLock的多数派共识——每个特性背后都藏着精妙的设计决策。一些建议不要在生产环境用KEYS和FLUSHALL大Key要拆分否则迁移和删除会阻塞单个实例内存控制在10GB以内方便主从同步和fork持久化策略要结合实际容忍的数据丢失窗口希望这篇文章能帮你建立起对Redis的系统性认知。
Redis深度解析:从底层原理到生产级架构实践
一、Redis是什么——重新理解它的定位很多人把Redis简单定义为缓存这其实低估了它。Redis本质上是一个基于内存的、单线程事件驱动的键值存储系统更准确地说它是一个数据结构服务器。为什么单线程还这么快我们需要拆解它的线程模型。1.1 单线程的真相Redis的单线程指的是网络请求解析、数据读写、响应返回由同一个线程完成。但这不代表它内部没有并发——Redis 4.0之后引入了后台线程处理UNLINK、FLUSHDB ASYNC等耗时操作。单线程的核心优势没有锁竞争数据结构操作天然线程安全没有上下文切换开销CPU缓存命中率高代码简洁可维护复杂命令如ZUNIONSTORE不需要考虑并发问题瓶颈也很明显CPU密集型操作会阻塞整个服务。所以生产环境要禁用KEYS *慎用SORT在大集合上。1.2 事件驱动模型四个核心组件Redis基于Reactor模式构建四个组件环环相扣Client1 ──┐ Client2 ──┤ Client3 ──┼──► IO多路复用器 ──► 事件分派器 ──► 事件处理器 Client4 ──┘ (epoll/kqueue) │ │ ┌─────┴──────┐ │ 连接应答处理器 │ 命令请求处理器 │ 命令回复处理器 └────────────┘关键细节IO多路复用器封装了epollLinux、kqueueBSD或select通用编译时自动选择最优方案事件分派器根据aeEventLoop中的文件事件表将可读/可写事件路由到对应处理器一次完整的命令周期aeApiPoll等待事件 →readQueryFromClient读取 →processCommand执行 →addReply写入缓冲区 → 在下一次beforeSleep中批量写回1.3 Pipeline与批量操作的本质Pipeline不是原子操作只是把多个命令打包发送、批量接收。真正能减少RTT的是客户端将命令缓存在本地buffer一次write系统调用发送所有命令服务端顺序执行结果也打包返回这跟MGET、MSET的区别在于后者是原子操作Pipeline不是。二、数据类型深度剖析2.1 String——不仅仅是字符串String的底层是SDSSimple Dynamic String不是C原生字符串structsdshdr{intlen;// 已用长度intfree;// 剩余空间charbuf[];// 柔性数组};设计亮点O(1)获取长度C字符串要O(n)预分配空间减少内存重分配次数二进制安全能存图片、序列化对象\0不会截断惰性空间释放缩短后不立即回收内存int编码优化当value能转为整数且在LONG_MIN到LONG_MAX之间时Redis用int编码存储内存占用极低。当对其进行APPEND操作时会自动转为raw编码。应用场景分布式锁SET key value NX PX 30000计数器INCR天然原子不用加锁缓存对象可序列化JSON但推荐用Hash部分更新更友好2.2 List——快慢结合的双向链表List在3.2版本之前用ziplist压缩列表和linkedlist双向链表混合实现。3.2之后统一为quicklist——由ziplist作为节点的双向链表。quicklist: [ziplist1] ←→ [ziplist2] ←→ [ziplist3] ↑ ↑ ↑ 多个元素 多个元素 多个元素这样设计的精妙之处解决了linkedlist的指针空间开销大、内存碎片问题解决了纯ziplist的连锁更新风险每个ziplist节点大小可配置list-max-ziplist-size在空间和时间之间取得平衡阻塞队列的底层BLPOP/BRPOP在list为空时不立即返回而是将客户端信息放入blocking_keys字典数据到达后唤醒。超时机制通过时间轮实现。2.3 Set——哈希表与intset的切换Set有两个底层实现intset整数集合当元素全是整数且数量小于set-max-intset-entries默认512时使用。有序数组二分查找。hashtable哈希表元素较多或出现非整数时转换。intset的升级机制新元素插入时如果位宽不够如int16遇到int32元素整个intset会扩容升级不可逆。2.4 Hash——字典的渐进式rehashHash底层也是ziplist和hashtable的切换阈值hash-max-ziplist-entries和hash-max-ziplist-value。渐进式rehash的核心Redis的字典扩容不是一次性完成的而是维持两个哈希表ht[0]和ht[1]在每次CRUD操作时顺带迁移一部分数据rehashidx记录进度。这样做避免了大规模迁移导致的延迟抖动。扩容触发条件负载因子 used/size - 无BGSAVE/BGWRITEAOF时负载因子 1 - 有BGSAVE/BGWRITEAOF时负载因子 5考虑写时复制 缩容触发负载因子 0.12.5 Sorted Set——跳表与压缩列表的协奏Zset在元素数量少时用ziplist元素多时用skiplist dict组合zset { dict: {element → score} // O(1)按值查分 skiplist: 按score排序 // O(logN)范围查询 }为什么两者都要字典查分快但无序跳表有序但按值查慢。两者互补。跳表的层高生成每次插入时随机生成层高概率为p0.25即每升一层概率为25%。层高上限64。这种概率分布使得跳表的期望查询复杂度为O(log N)。Level 3: 1 ──────────────────► 15 Level 2: 1 ────► 5 ──────────► 15 Level 1: 1 → 3 → 5 → 9 → 12 → 15 → 20score相同时的排序按member的字典序排序。这保证了Zset的排序是严格全序的。三、过期键删除——内存与CPU的博弈Redis的键过期机制分两部分过期键的存储和过期键的删除。3.1 过期字典每个Redis DB维护一个expires字典键是指针指向键空间的键对象值是毫秒精度的过期时间戳。PEXPIRE和EXPIRE都是在这个字典中添加/更新记录PERSIST删除记录。3.2 三种删除策略的权衡惰性删除在访问键时检查过期则删除并返回空。实现入口在expireIfNeeded()几乎所有读写命令前都会调用。intexpireIfNeeded(redisDb*db,robj*key){if(!keyIsExpired(db,key))return0;// 从节点不主动删除等待主节点DEL命令同步if(server.masterhost!NULL)return1;// 删除键并通知AOF/从节点deleteKey(db,key);server.stat_expiredkeys;return1;}定期删除由activeExpireCycle()实现在beforeSleep()和serverCron()中调用。每次随机抽取20个键检查如果过期比例超过25%则继续但不超过执行时间上限默认1ms可配。为什么不选定时删除如果每个过期键都创建一个定时器百万级别的定时器会压垮CPU。极端场景大量键在同一秒过期如当天0点过期的活动缓存定期删除可能来不及处理。触发点是过期键比例超过25%此时Redis会持续扫描阻塞请求。解决过期时间加随机值。四、缓存三大经典问题与生产级方案4.1 缓存穿透——空值屏障本质查询不存在的数据缓存层永远无效。方案一缓存空值publicStringgetData(Stringkey){Stringvalueredis.get(key);if(value!null)returnvalue.equals(NULL)?null:value;valuedb.query(key);if(valuenull){redis.setex(key,60,NULL);// 缓存空值短过期时间}else{redis.setex(key,3600,value);}returnvalue;}注意空值缓存时间要短否则会占用内存且掩盖数据恢复。方案二布隆过滤器在Redis前面加一层布隆过滤器将所有可能存在的key提前加载。请求先过布隆过滤器不存在直接返回。缺点有误判率说有不代表一定有需要提前加载所有key 优点内存占用极低10亿数据约1.5GBRedis 4.0提供BF.ADD/BF.EXISTS等命令需要加载RedisBloom模块。4.2 缓存击穿——热点保护的两种思路场景秒杀商品的缓存刚好过期瞬间万级并发打到数据库。方案一互斥锁简单暴力publicStringgetData(Stringkey){Stringvalueredis.get(key);if(value!null)returnvalue;// 抢锁只让一个线程去查DBStringlockKeylock:key;if(redis.setnx(lockKey,1)){try{redis.expire(lockKey,10);valuedb.query(key);redis.setex(key,3600,value);}finally{redis.del(lockKey);}}else{Thread.sleep(50);returngetData(key);// 重试}returnvalue;}方案二逻辑过期不设TTL缓存永不过期但value中包含一个逻辑过期时间。读取时判断是否逻辑过期是则开异步线程去更新当前请求直接返回旧值。对比互斥锁保证一致性但可能阻塞请求逻辑过期高可用但返回旧数据4.3 缓存雪崩——多级防御成因大量key同时过期Redis集群宕机防御过期时间加随机值expireTime base random(0, 300)打散过期时间多级缓存本地缓存(Caffeine) → Redis → DB限流降级当DB压力过大时直接返回降级响应或空值高可用架构哨兵/集群保证Redis层不宕五、高可用架构详解5.1 主从复制——异步复制全过程全量同步SYNC/PSYNCSlave → Master: PSYNC ? -1 Master: 执行BGSAVE生成RDB Master: 发送RDB给Slave Master: 缓冲区记录期间的写命令 Slave: 加载RDB Master: 发送缓冲区命令 Slave: 执行缓冲区命令追上主库部分同步PSYNC断线重连时如果偏移量在复制积压缓冲区范围内只发送缺失部分。关键参数repl-backlog-size复制积压缓冲区大小影响部分同步成功率repl-diskless-sync无盘复制数据直接通过网络发送不落盘复制风暴一主多从时如果所有从库同时请求全量同步主库IO压力会瞬间飙升。5.2 哨兵——故障转移的细节哨兵的本质是一个分布式监控系统它解决的核心问题是主库宕机后谁来决策、谁来完成切换。主观下线SDOWN与客观下线ODOWN单个Sentinel: ping超时 → SDOWN自己的判断 多个Sentinel: 超过quorum个确认SDOWN → ODOWN集群共识选主逻辑过滤掉不健康的从库断线、响应慢优先级slave-priority配置值小的优先复制偏移量最接近主库的从库Run ID字典序最小的保证确定性哨兵集群的通信通过Redis的Pub/Sub机制哨兵节点间通过__sentinel__:hello频道交换信息。5.3 Cluster——数据分片的艺术哈希槽Hash Slot总共16384个槽均匀分配到各节点路由公式slot CRC16(key) % 16384只有key中有{}的部分参与计算支持hash tagMOVED与ASK重定向Client → NodeA: GET {user:1001} NodeA: 计算得slot6023但此槽在NodeB NodeA → Client: MOVED 6023 NodeB_IP:NodeB_PORT Client → NodeB: GET {user:1001} NodeB → Client: valueMOVED是永久重定向ASK是临时重定向槽迁移过程中。槽迁移过程源节点设置目标节点slot为importing状态目标节点设置源节点slot为migrating状态逐个迁移slot中的key迁移完成更新槽位信息并广播客户端Smart模式JedisCluster/JedisPool自带槽位缓存一次MOVED后更新本地路由表后续请求直达。5.4 代理模式当客户端不想改造时可以在前面加代理层TwemproxyTwitter开源轻量但不支持动态扩缩容Codis豌豆荚开源支持动态扩缩容带Dashboard管理Redis Enterprise商业方案本质都是在代理层维护槽位映射对客户端透明。六、分布式锁——从能用到可靠6.1 锁的本质与基本实现一把合格的分布式锁需要满足互斥任意时刻只有一个客户端持有锁防死锁即使持有者崩溃锁也能自动释放解铃还须系铃人谁加的锁谁来解最简版本# 加锁值用唯一标识防误删SET lock_key unique_id NX PX30000# 解锁用Lua保证原子性ifredis.call(GET, KEYS[1])ARGV[1]thenreturnredis.call(DEL, KEYS[1])elsereturn0end为什么必须是LuaGET和DEL是两个命令不用Lua就有并发问题A判断是自己的锁 → 此时锁过期 → B抢到锁 → A执行DEL删了B的锁。6.2 Redisson——自动续期与可重入Redisson的RLock基于Redis的Hash结构实现了可重入锁lock_key: { thread_id_1: 2 // 重入次数 }Watch Dog机制默认锁租约30秒每10秒检查一次如果业务还在执行就续期到30秒。这解决了业务超时锁自动释放的问题。RLocklockredisson.getLock(order_lock);lock.lock();// 默认30秒Watch Dog自动续期try{// 业务逻辑}finally{lock.unlock();}注意Watch Dog只在未显式指定leaseTime时生效。如果指定了时间到期自动释放不续期。6.3 主从锁丢失与RedLock问题客户端A在主节点拿到锁 → 主节点宕机锁数据未同步到从节点 → 哨兵将从节点提升为新主 → 客户端B在新主上拿到同一把锁 → 互斥被破坏。RedLock算法Redisson已实现假设有5个独立Redis节点获取锁的步骤 1. 获取当前时间戳 t1 2. 依次向5个节点尝试获取锁SET NX超时时间很短 3. 计算总耗时 t2 - t1 4. 如果超过半数(3个)节点成功 总耗时 锁有效期 → 获取成功 5. 锁实际有效期 锁有效期 - 总耗时争议Martin Kleppmann《数据密集型应用》作者认为RedLock不安全时钟跳跃会导致问题Redis作者Antirez反驳认为实践上足够可靠工程建议如果不是金融级场景单节点合理超时业务幂等已经足够6.4 性能优化——减少锁竞争缩小锁粒度// 不好库存扣减所有商品共用一个锁StringlockKeystock_lock;// 好每个商品独立锁StringlockKeystock_lock:productId;分段锁把一个大热点Key拆成多个请求路由到不同Keyintsegmenthash(userId)%10;StringlockKeyseckill_lock:segment;6.5 ZooKeeper分布式锁对比维度RedisZooKeeper实现SET NX Lua临时顺序节点释放主动删除超时主动删除会话断开自动删除性能高内存操作较低磁盘共识一致性AP可能丢失CPZAB协议适用高性能、允许短暂不一致强一致性要求ZK的实现创建临时顺序节点序号最小的获得锁前一个节点设置Watcher释放时ZooKeeper通知下一个。七、持久化——数据安全的最后防线7.1 RDB——快照的代价触发方式SAVE主进程阻塞执行线上禁用BGSAVEfork子进程主进程继续服务配置save m nm秒内n次修改触发写时复制COW的坑fork子进程时父子进程共享内存页标记为只读。主进程写操作时触发缺页中断复制该页。如果写操作很多内存可能翻倍。调优# 关闭COW大页减少fork延迟echonever/sys/kernel/mm/transparent_hugepage/enabled7.2 AOF——命令日志的演进刷盘策略appendfsync always # 每条命令fsync最安全但最慢 appendfsync everysec # 每秒批量fsync折中方案推荐 appendfsync no # 交给OS性能最好但可能丢数据AOF重写当AOF文件过大时BGREWRITEAOF触发重写用最简命令重建数据集原AOF可能有6条RPUSH命令 → 重写后变一条RPUSH list 1 2 3 4 5 6混合持久化4.0aof-use-rdb-preambleyes重写后的AOF文件前半部分是RDB格式后半部分是追加的AOF命令。兼顾恢复速度和数据安全。7.3 生产恢复策略场景RDBAOF混合最大丢失分钟级最多1秒最多1秒恢复速度快慢快文件大小小大中推荐混合持久化 每小时RDB备份到异地。八、内存淘汰策略选型当内存到达maxmemory时写入命令触发淘汰8.1 策略分类全局键空间无视TTLnoeviction不淘汰写入返回OOM错误allkeys-lruLRU淘汰推荐allkeys-lfuLFU淘汰4.0allkeys-random随机淘汰带过期时间键空间volatile-lru/volatile-lfu/volatile-random同上但只看有过期时间的键volatile-ttl淘汰TTL最短的键8.2 近似LRU算法Redis的LRU不是精确的而是采样近似1. 随机采样N个键maxmemory-samples默认5 2. 比较它们的空闲时间lru字段 3. 淘汰最久未使用的那个样本量越大越精确但CPU开销也越大。默认5是平衡点。8.3 LFU——更聪明的淘汰4.0引入的LFULeast Frequently Used不是简单的计数而是考虑了访问频率的衰减lru字段分成两部分 - 高16位最后访问时间分钟级 - 低8位访问计数器0-255概率递增定期衰减这样能避免曾经热门但早已冷下来的key长期霸占内存。九、工程实战案例9.1 延迟双删——缓存一致性的一次范式场景更新数据库后删除缓存但在主从延迟期间读请求可能读到旧数据并写回缓存。方案publicvoidupdateData(Datadata){// 1. 更新数据库db.update(data);// 2. 第一次删缓存redis.del(data:data.getId());// 3. 延迟后第二次删缓存覆盖主从延迟窗口delayQueue.offer(newDelayTask(()-{redis.del(data:data.getId());},1000));// 延迟1秒}进阶使用Canal监听binlog在确认主从同步完成后删缓存比固定延迟更可靠。9.2 防止订单重复提交——Token机制下单流程 1. 进入下单页 → 后端生成token → 存Redis 返回前端 2. 用户提交订单 → 前端带token 3. 后端用Lua原子校验并删除token if redis.call(GET, tokenKey) token then return redis.call(DEL, tokenKey) else return 0 end 4. 返回1表示首次提交0表示重复提交前端防抖只是锦上添花后端幂等才是必须。9.3 支付回调与订单超时的并发问题订单30分钟未支付自动取消定时任务/延迟队列用户在29分59秒支付回调到达时订单刚被取消解决——状态机乐观锁-- 取消订单时加状态条件UPDATEorderSETstatusCANCELLEDWHEREid12345ANDstatusUNPAID;-- 支付回调时UPDATEorderSETstatusPAID,pay_timeNOW()WHEREid12345ANDstatusUNPAID;-- 双方中谁影响行数为0就执行补偿逻辑如果支付回调的更新返回0说明订单已被取消此时触发退款记录异常流程。Redis的作用用Zset实现延迟队列ZADD delay_queue timestamp orderId定时任务ZRANGEBYSCORE获取到期订单。9.4 用户Token缓存——快与安全// 登录StringtokenUUID.randomUUID().toString();redis.setex(token:token,7200,userId);// 2小时过期// 续期策略每次请求刷新一半过期时间if(redis.ttl(token:token)3600){redis.expire(token:token,7200);}注意Token应该是无状态的Redis做黑名单而不是存储全部Token更轻量。十、总结Redis入门容易精通难。从SDS的二进制安全到跳表的概率平衡从主从复制的部分重同步到Cluster的槽位迁移从SETNX的原子加锁到RedLock的多数派共识——每个特性背后都藏着精妙的设计决策。一些建议不要在生产环境用KEYS和FLUSHALL大Key要拆分否则迁移和删除会阻塞单个实例内存控制在10GB以内方便主从同步和fork持久化策略要结合实际容忍的数据丢失窗口希望这篇文章能帮你建立起对Redis的系统性认知。