一、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多路复用器封装了
epoll(Linux)、kqueue(BSD)或select(通用),编译时自动选择最优方案 - 事件分派器根据
aeEventLoop中的文件事件表,将可读/可写事件路由到对应处理器 - 一次完整的命令周期:
aeApiPoll等待事件 →readQueryFromClient读取 →processCommand执行 →addReply写入缓冲区 → 在下一次beforeSleep中批量写回
1.3 Pipeline与批量操作的本质
Pipeline不是原子操作,只是把多个命令打包发送、批量接收。真正能减少RTT的是:
- 客户端将命令缓存在本地buffer
- 一次
write系统调用发送所有命令 - 服务端顺序执行,结果也打包返回
这跟MGET、MSET的区别在于:后者是原子操作,Pipeline不是。
二、数据类型深度剖析
2.1 String——不仅仅是字符串
String的底层是SDS(Simple 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——字典的渐进式rehash
Hash底层也是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)范围查询 }为什么两者都要?字典查分快但无序,跳表有序但按值查慢。两者互补。
跳表的层高生成:每次插入时随机生成层高,概率为p=0.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){Stringvalue=redis.get(key);if(value!=null)returnvalue.equals("NULL")?null:value;value=db.query(key);if(value==null){redis.setex(key,60,"NULL");// 缓存空值,短过期时间}else{redis.setex(key,3600,value);}returnvalue;}注意:空值缓存时间要短,否则会占用内存且掩盖数据恢复。
方案二:布隆过滤器
在Redis前面加一层布隆过滤器,将所有可能存在的key提前加载。请求先过布隆过滤器,不存在直接返回。
缺点:有误判率(说有不代表一定有),需要提前加载所有key 优点:内存占用极低(10亿数据约1.5GB)Redis 4.0提供BF.ADD/BF.EXISTS等命令(需要加载RedisBloom模块)。
4.2 缓存击穿——热点保护的两种思路
场景:秒杀商品的缓存刚好过期,瞬间万级并发打到数据库。
方案一:互斥锁(简单暴力)
publicStringgetData(Stringkey){Stringvalue=redis.get(key);if(value!=null)returnvalue;// 抢锁,只让一个线程去查DBStringlockKey="lock:"+key;if(redis.setnx(lockKey,"1")){try{redis.expire(lockKey,10);value=db.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/PSYNC):
Slave → 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 tag
MOVED与ASK重定向:
Client → NodeA: GET {user:1001} NodeA: 计算得slot=6023,但此槽在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 代理模式
当客户端不想改造时,可以在前面加代理层:
- Twemproxy:Twitter开源,轻量,但不支持动态扩缩容
- 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为什么必须是Lua?GET和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秒。这解决了"业务超时锁自动释放"的问题。
RLocklock=redisson.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 性能优化——减少锁竞争
缩小锁粒度:
// 不好:库存扣减,所有商品共用一个锁StringlockKey="stock_lock";// 好:每个商品独立锁StringlockKey="stock_lock:"+productId;分段锁:把一个大热点Key拆成多个,请求路由到不同Key:
intsegment=hash(userId)%10;StringlockKey="seckill_lock:"+segment;6.5 ZooKeeper分布式锁对比
| 维度 | Redis | ZooKeeper |
|---|---|---|
| 实现 | SET NX + Lua | 临时顺序节点 |
| 释放 | 主动删除+超时 | 主动删除+会话断开自动删除 |
| 性能 | 高(内存操作) | 较低(磁盘+共识) |
| 一致性 | AP(可能丢失) | CP(ZAB协议) |
| 适用 | 高性能、允许短暂不一致 | 强一致性要求 |
ZK的实现:创建临时顺序节点,序号最小的获得锁,前一个节点设置Watcher,释放时ZooKeeper通知下一个。
七、持久化——数据安全的最后防线
7.1 RDB——快照的代价
触发方式:
SAVE:主进程阻塞执行,线上禁用BGSAVE:fork子进程,主进程继续服务- 配置
save m n:m秒内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.0+):
aof-use-rdb-preambleyes重写后的AOF文件前半部分是RDB格式,后半部分是追加的AOF命令。兼顾恢复速度和数据安全。
7.3 生产恢复策略
| 场景 | RDB | AOF | 混合 |
|---|---|---|---|
| 最大丢失 | 分钟级 | 最多1秒 | 最多1秒 |
| 恢复速度 | 快 | 慢 | 快 |
| 文件大小 | 小 | 大 | 中 |
推荐:混合持久化 + 每小时RDB备份到异地。
八、内存淘汰策略选型
当内存到达maxmemory时,写入命令触发淘汰:
8.1 策略分类
全局键空间(无视TTL):
noeviction:不淘汰,写入返回OOM错误allkeys-lru:LRU淘汰(推荐)allkeys-lfu:LFU淘汰(4.0+)allkeys-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引入的LFU(Least 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秒支付,回调到达时订单刚被取消
解决——状态机+乐观锁:
-- 取消订单时加状态条件UPDATEorderSETstatus='CANCELLED'WHEREid=12345ANDstatus='UNPAID';-- 支付回调时UPDATEorderSETstatus='PAID',pay_time=NOW()WHEREid=12345ANDstatus='UNPAID';-- 双方中谁影响行数为0,就执行补偿逻辑如果支付回调的更新返回0,说明订单已被取消,此时触发退款+记录异常流程。
Redis的作用:用Zset实现延迟队列,ZADD delay_queue timestamp orderId,定时任务ZRANGEBYSCORE获取到期订单。
9.4 用户Token缓存——快与安全
// 登录Stringtoken=UUID.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的系统性认知。