目录
(一)旁路缓存模式(Cache Aside Pattern)
(二)透写和回写(Write Through/Behind)
一、什么是Redis?
Redis是一款开源的高性能键值存储数据库,以其内存存储、多样化数据结构和丰富的应用场景成为现代系统的核心组件之一。
| 特性 | Redis | 传统关系型数据库(如MySQL) |
|---|---|---|
| 数据模型 | 键值对、文档、时序等非结构化数据 | 表结构,严格的关系模型 |
| 性能 | 微秒级响应,适用于高频读/写 | 毫秒级,复杂查询可能较慢 |
| 持久化 | 可选(RDB/AOF),数据可能丢失 | ACID事务保障,强持久性 |
| 适用场景 | 缓存、实时处理、分布式协调 | 事务处理、复杂查询、历史数据存储 |
二、数据结构
(一)String
最基本的数据类型,Key-Value 结构。
Redis使用SDS结构进行存储:
struct sdshdr { int len; // 实际数据长度 int alloc; // 总分配空间 char buf[]; // 存储实际数据};优点:
- O(1)获取长度:
len字段直接记录长度,无需遍历字符串 - 二进制安全:
buf可存储任意二进制数据(含'\0'),靠len而非'\0'判断结束。 - 预分配机制:新增数据时,若空间不足则自动扩容,不会发生数据溢出的问题。
使用场景
- 缓存对象(JSON序列化)
- 分布式锁(SETNX)
- 计数器(INCR/DECR)
(二)Hash
键值对集合。
在元素数量或值大小超过阈值的情况下会使用字典(dict)作为底层数据结构,而此时可能触发哈希表扩容机制。
什么是哈希表扩容机制?
当数据大小逐渐增多时,为了预防溢出,会触发rehash操作: 原本一个字典就会引用两个哈希表,其中哈希表2是空白的,还没有分配内存空间触发rehash后会给哈希表2分配内存空间,其大小是哈希表1的两倍将哈希表1的数据迁移到哈希表2迁移完成后,释放哈希表1的内存空间,并将原本对哈希表1的引用改为哈希表2,然后在原本哈希表2的引用上创建一个新的空白哈希表备用下次rehash 但如果在数据量特别大的时候,迁移时间开销就会大大增加,而且这个过程中redis是阻塞状态,会影响此期间的其他请求。 因此Redis采用的是渐进式rehash操作,也就是在对该字典执行增删改查操作的时候会顺带执行迁移操作。 具体来说,增操作只会在哈希表2进行,删只会在哈希表1进行,而改和查会先查找哈希表1在查找哈希表2。 这样的话随着操作的次数增多,最终全部迁移完成。
使用场景
- 对象属性存储
- 购物车
(三)List
双向链表,支持左右插入/删除,因此增删快,随机访问慢。
使用场景
- 消息队列
- 最新消息排行
(四)Set
无序唯一元素集合,基于哈希表实现。
使用场景
- 标签系统
- 唯一性校验
(五)Zset
元素关联分数(Score),按分数排序。
Redis3.2之前底层是由压缩列表(ZipList)或跳表(SkipList)实现的:
- 如果元素个数小于128个,且每个元素大小均小于64kb时使用压缩列表
- 反之则使用跳表
什么是压缩列表? 压缩列表的目的是为了节约内存,这是由连续的内存块组成的顺序数据结构,与数组很相似。
表头存在三个字段: zlbytes:记录链表总字节大小zltail:记录链表尾节点偏移量zllen:记录链表总节点数量 可以看出压缩列表对首尾节点的操作是很高效的,是继承了普通链表的特性。 对于每个entry节点,也存在三个字段: prevlen:记录前一个节点的长度encoding:表示数据类型以及长度,分为字符串和整数。data:实际数据 将原列表的指针存储更换成立偏移量存储,而且整数以二进制存储而非字符串,大大节省了内存占用,而且也能实现逆向遍历。 存储场景普通链表占用压缩链表占用节省效果存储整数524字节(listNode)1字节(0xF5)节省23字节存储字符串”abc”24+3=27字节2字节(编码+数据)节省25字节10个元素的列表头240字节≈30字节内存减少87.5% 这就是压缩列表最大的好处,但是普通链表存在的随机访问效率O(n)的缺点仍旧存在;而且遍历时是需要解码prevlen和encoding的,因此也会有一定的时间开销,这是典型的时间换空间的思想。 因此如果元素个数大于128就会更换为跳表进行存储。
那为什么还要限制压缩列表的元素大小? 这是因为压缩列表存在连锁更新的问题。 每个节点通过prevlen字段记录前驱节点的长度,其空间占用根据前驱节点大小动态变化: 1字节:前驱节点长度 < 254字节5字节:前驱节点长度 ≥ 254字节 这个机制虽然完美替换掉了指针空间,但也存在隐患。
倘若存在这样一条压缩列表,所有节点的prevlen字段都只有1字节。 此时在表头添加了一个长度大于254字节的节点,那其后继节点的prevlen大小就需要变化,后继节点的后继节点同理,逐个向后传递。 最坏的时间复杂度是O(n^2),n个节点会导致n次内存重分配。 因此redis限制了元素的大小来规避此问题,但在极端情况下仍旧可能发生。
那跳表又是什么?
跳表的目的是解决普通链表随机访问效率低下的问题。 通过在节点上建立多层索引来实现快速跳转,每层索引都有一个头指针进行维护。 如图所示,原本要查询节点4时所需要遍历的次数是4,需要从头遍历;但是如果使用体哦啊表,用L2指针可以先跳转到3索引,再往后遍历一次即可,因此总遍历次数为2。 可以看到时间复杂度从原本的O(n)变为了O(logN)。
跳表是如何实现多层节点的? 每个节点的数据是由一个level数组进行维护的,索引n的元素对于第n+1层级。 而层级实际上是在创建节点的时候随机生成的:在创建节点的时候会生成0-1的一个随机数,如果小于0.25则增加一层并继续生成下一个随机数,直到结果大于0.25才算创建结束。 这保证增加一层的概率小于25%,而且越增加概率越低,最大层高为64。
既然提升随机访问的性能,那Redis为什么不用B+树? 1.使用场景差异 B+树的目的是优化磁盘IO,通过降低层数来减少磁盘寻道的次数。但是Redis的数据都存储在内存中,无需考虑磁盘IO,而且跳表的层级索引也更加符合内存访问模式。 2.写入性能差异 B+树新增节点时可能会引发连锁节点分裂,涉及范围大,锁竞争激烈。 而跳表新增节点仅更新指针,而且涉及范围小,可以实现细粒度锁甚至无锁,写入性能更高。 3.内存占用差异 B+树的非叶子节点仅做索引作用,会占用额外内存,而且需要预分配页空间,容易产生碎片。 跳表节点仅存储必要指针与数据,而且节点动态生成无需预分配页空间。
因此跳表的写入高效、实现简单、内存友好三大优势,显然远远优于B+树。
为了解决压缩列表连锁更新的缺陷,Redis3.2时引入快速列表(QuickList)来替代压缩列表。
什么是快速列表? 快速列表使用的是双向链表 + 分片 ziplist,形成“链表嵌套数组”的层次结构。 每个节点分别维护一个压缩列表,这样做虽然连锁更新的概率和范围缩小,但是依旧存在发生的风险。
因此为了彻底解决该问题,Redis7.0时使用紧凑列表(ListPack)全面替代了压缩列表。
什么是紧凑列表?
可以看到紧凑列表还是继承了压缩列表的很多优点的,但是针对于连锁更新的问题,紧凑列表进行了改进: 对于entry的字段来说,移除了prevlen,每个元素记录自身长度len。 因此每个节点仅管理本身数据,消除了对前节点的依赖,从而彻底避免了连锁更新的问题。
使用场景
- 排行榜
- 延迟队列
(六)GEO
基于Zset实现,存储经纬度,并使用Geohash编码将坐标转为可排序字符串,因此可以进行高性能地理计算。
使用场景
- 地理位置查询以及汇总
(七)BitMap
基于String的二进制位操作,每个位表示状态(0/1),支持位运算,因此内存占用极低。
使用场景
- 用户签到
- 布隆过滤器
(八)HyperLogLog
这是一种概率数据结构,用于基数统计,可以以超低内存计算海量数据。
使用场景
- 网站UV统计
- 大规模去重计数
三、缓存更新策略
(一)旁路缓存模式(Cache Aside Pattern)
应用程序主动管理缓存与数据库的同步,缓存作为数据库的“旁路”而非代理。
1. 读策略
- 读请求先查询缓存,命中则直接返回。
- 若未命中,则从数据库读取数据,写入缓存后返回。
2. 写策略
- 先更新数据库,再删除缓存。
- 异常场景:
- 若删缓存失败,通过重试机制确保最终一致。
- 高并发下可能短暂不一致(线程2在线程1删除缓存之前读到脏数据)。
这种思想类似于懒加载,程序不主动缓存数据,只有当需要的时候由用户线程触发。
能够避免无效冗余的缓存数据,适用于读多写少的场景。
(二)透写和回写(Write Through/Behind)
1. 透写
同步更新缓存与数据库,确保双方都写入成功没有抛出异常后才给用户返回结果。
但是延迟就相对来说比较高,适用于需强一致但低并发的场景。
2. 回写
数据更新时仅更新缓存,异步更新数据库。
需要手动标记为脏数据并维护,且存在脏数据丢失风险,但是相对的性能很高。
适用于仅需最终一致且容忍数据丢失的场景。
(三)双删策略
这个策略是为了解决旁路缓存模式中写策略在高并发情况下的短暂不一致问题的。
- 删除缓存
- 更新数据库
- 挂起线程(500ms)
- 再次删除缓存
可以看到双删策略是先删除缓存确保其他线程不会读到脏数据,然后挂起线程是为了确保数据库更新完成,随后再次删除缓存清理并发读可能写入的脏数据。
因为存在延迟操作且多了一次网络IO,所以该策略并不适用于高并发场景。
但是可以通过将最后两步异步进行来取消用户线程的阻塞。
不过该策略在极端情况下仍旧可能造成脏读,比如说线程2在线程1删除缓存之前读取了缓存并挂起,在线程1再次删除缓存之后开始执行,此时就读到了脏数据。
适用于最终一致的低并发场景。
(四)Binlog异步监听刷新缓存
Bin log是在对数据库(如MySQL)进行更改的事务结束后会写入到磁盘的二进制文件,也就是说这个文件只要产生了,就代表数据库操作成功了,所以我们可以利用这个机制。
通过中间件(如Canal)订阅数据库的Bin log,然后将事件投递到MQ中异步进行缓存更新。
此策略彻底与用户线程解耦,适用于高并发且最终一致场景。
策略一致性性能复杂度适用场景旁路缓存最终一致高中读多写少透写强一致低高强一致场景回写最终一致极高高高频写Binlog 监听最终一致中高高跨系统数据同步双删最终一致中低低频写
四、缓存三大经典问题:雪崩、击穿与穿透
(一)雪崩
1. 定义
大量缓存数据同时失效或缓存服务宕机,导致瞬时所有请求直接访问数据库,引发数据库过载甚至崩溃。
2. 特征
- 大规模缓存失效
- 数据库QPS(每秒查询率)突增
- 系统整体响应时间飙升或服务不可用
3. 核心原因分析
- 缓存集体失效:
- 大量缓存设置相同TTL
- 定时任务批量更新缓存导致同时过期
- 缓存服务故障:
- Redis集群全量宕机
- 分布式缓存未做高可用设计
4. 解决方案
- 过期时间不固定,而是在一个范围内随机生成,如25-30min
- 数据不设置过期时间,通过异步线程对数据进行更新
- 设置多级缓存架构,不仅是DB和Redis,在客户端或应用本地也加上缓存数据
- 限流降级,设置接口访问阈值,超限线程采用降级方案(重试或返回空数据让其手动刷新)
(二)击穿
1. 定义
某个热点数据缓存过期时,大量并发请求直接穿透到数据库,导致数据库瞬时压力过载。
2. 特征
- 针对某一特定热点数据
- 高并发量
- 缓存重建期间数据库被重复查询
3. 核心原因分析
通常是在重建缓存成功之前大量请求未命中缓存直接打到DB。
4.解决方案
- 将重建缓存操作加锁,保证只有一个线程能够访问DB
- 不设置过期时间,设置逻辑过期,数据带上时间戳字段,访问数据时进行过期性检查,若国企了则返回旧数据,异步进行缓存重建
- 热点数据永不过期,使用监控线程识别热点数据并定时进行更新
| 方案 | 一致性 | 性能 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 互斥锁 | 强一致 | 中 | 中 | 金融交易、库存扣减 |
| 逻辑过期 | 最终 | 高 | 高 | 社交媒体、新闻资讯 |
| 热点永不过期 | 最终 | 极高 | 低 | 已知明确热点(如秒杀) |
(三)穿透
1. 定义
大量请求查询缓存和数据库中都不存在的数据,导致请求直接穿透到数据库。
2. 特征
- 请求的是不存在的数据
- 高并发量
- 数据库压力骤增
3. 核心原因分析
通常是外部使用非法ID进行的恶意网络攻击。
4.解决方案
- 在DB查不到的情况下一般就是网络攻击,直接缓存空对象,以便下次相同非法ID攻击不会访问DB
- 设置请求校验拦截,合法ID给予通过
- 设置布隆过滤器
| 方案 | 拦截精度 | 内存消耗 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 缓存空对象 | 低 | 高 | 简单 | 数据维度少且离散 |
| 布隆过滤器 | 中 | 极低 | 中等 | 海量数据且维度集中 |
| 请求校验 | 高 | 无 | 简单 | 有明确格式约束的场景 |
什么是布隆过滤器?
布隆过滤器由一个初始值均为0的位数组和k个哈希函数组成。 当对数据库新增操作时,其主键会被k个哈希函数分别计算出k个哈希值,然后将这k个哈希值分别对位数组长度取模,对结果索引位上置1. 既然是比较哈希值那一定会存在哈希冲突,因此布隆过滤器判断存在的ID不一定存在,但是判断不存在的ID一定不存在。 想要进一步减小布隆过滤器的误差,就只能通过增大位数组长度或者增加哈希函数的个数,一个牺牲空间一个牺牲性能,所以业务开发时得根据实际情况做取舍。
五、大Key问题与热Key问题
(一)大Key问题
1. 定义
- 字符串类型:Value > 10KB(如大文本/图片)
- 集合类型:元素数 > 5,000(如超长用户列表)
- 内存占比:单 Key 占内存 > 1MB
这些数值的前提是高并发场景,如果是低并发场景的话判定范围会更大一些,比如说字符串类型可能只要不超过100kb就不算大Key。
2. 影响
- 挤占其他 Key 空间,触发内存淘汰策略
DEL删除大 Key 引发毫秒级阻塞- 读取 1MB Key × 1,000 QPS = 1GB/s 流量,压垮带宽
- 集群模式下,数据分片不均导致部分节点内存爆满
3. 解决方案
- 数据拆分:
- 垂直拆分:大 Hash 拆分为多个子
- 水平拆分:List 按元素分桶存储,类似与水平分表
- 异步删除:用
UNLINK替代DEL(后台线程释放内存) - 数据压缩:对文本/JSON 使用 Gzip/Snappy 压缩
- 过期控制:设置TTL防止长期占用内存
(二)热Key问题
1. 定义
- 高频访问:单 Key QPS 远超均值(如集群总 QPS 10K,某 Key 占 3K)
- 单点瓶颈:流量集中于某分片
2. 影响
- 性能瓶颈:单实例 CPU 飙升至 100%,拖累同分片其他 Key
- 缓存击穿:热 Key 失效瞬间,海量请求穿透到数据库
- 集群倾斜:部分节点过载而其他节点闲置,失去集群意义
3. 解决方案
- **多级缓存:**构建多级缓存架构,确保不发生击穿问题
- **副本分压:**在热Key的基础上再加一层作为副本层,分散到各个节点
- **读写分离:**读写节点分离分散压力
- **限流熔断:**限制接口并发数,采取降级
- **预加载机制:**提前将热点数据缓存,避免缓存失效
六、分布式锁
为什么需要分布式锁? 因为synchronized或ReentrantLock是本地锁,其底层都是基于AQS框架,因此只能在单JVM进程内有效,集群之间无法共享,此时就需要一个能集群共享互斥量的锁机制,也就是分布式锁。
(一)SETNX基础锁
这是使用Redis的原生命令SETNX操作的分布式锁,原理是添加锁键值对,若不存在则直接添加视为拿到锁,若存在则返回添加失败视为获取锁失败。
SET key unique_value NX EX 30其中NX代表互斥,既不可重复添加或覆盖,EX代表存在超时时间防止死锁。
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 误删锁 | 线程A阻塞导致锁超时释放,B获锁后A删除B的锁 | value中存储客户端唯一ID,删前校验 |
| 非原子操作 | 校验ID与删除分两步执行 | Lua脚本保证原子性 |
| 锁续期不可控 | 业务超时但锁自动过期 | 无原生支持,需自建“看门狗”线程 |
(二)Redisson
Redisson解决了SETNX基础锁原生方案的三个缺陷,核心是可重入、看门狗机制和高效锁竞争。
1. 实现原理
(1)锁结构
Redisson的锁状态存储使用的是Redis的Hash结构:
my_lock: { "f801d3d0-9e1a-4b7c-b65d-6a9b7d8c9e0a:thread-1": 3, "expiration": 1672531200000}其中my_lock是全局共享唯一key,实现了锁的互斥性。
第一个Field是UUID + 线程ID合并后的唯一ID,是在释放锁时用来校验的字段,防止SETNX方案的误删锁问题的出现;其value是计数器,代表重入锁次数,实现了锁的可重入性。
第二个Field代表锁过期时间。
(2)加锁流程
if redis.call('exists', KEYS[1]) == 0 then -- 创建锁:初始化计数器=1,设置过期时间 redis.call('hincrby', KEYS[1], ARGV[2], 1) redis.call('pexpire', KEYS[1], ARGV[1]) return nilend
if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then -- 重入锁:计数器+1,刷新过期时间 redis.call('hincrby', KEYS[1], ARGV[2], 1) redis.call('pexpire', KEYS[1], ARGV[1]) return nilend
-- 返回锁剩余时间(其他线程持有)return redis.call('pttl', KEYS[1]))其加锁流程是封装在Lua脚本里的,解决了SETNX方案的非原子性问题。
(3)看门狗机制
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]) -- 重置TTL return 1endreturn 0当加锁成功后会启动一条守护线程进行监控,每10秒检查一次锁状态和业务执行进度,若锁已过期但是业务尚未执行完成就会自动续期。
(4)高效锁竞争
-- 解锁操作if redis.call('hexists', KEYS[1], ARGV[3]) == 0 then -- 非锁持有者操作 return nilend
-- 重入计数器-1local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1)
if counter > 0 then -- 仍有重入锁,更新过期时间 redis.call('pexpire', KEYS[1], ARGV[2]) return 0else -- 删除锁并发布解锁通知 redis.call('del', KEYS[1]) redis.call('publish', KEYS[2], ARGV[1]) return 1end当锁被占用时,BLOCKING状态的线程并不会轮询竞争,而是通过Pub/Sub模型订阅redis频道,当锁被释放时该频道会触发通知,收到通知的线程才会被唤醒竞争锁。
这样一来可以减少大部分无效的网络IO请求,是非阻塞竞争。
(三)RedLock
RedLock主要是为了解决Redis集群主从复制中,若主节点宕机从节点升级为主节点时,会丢失掉未同步的锁的问题。
要求部署N个(最好为奇数)独立的Redis节点,相互之间完全隔离。
其核心是通过一个算法来保障分布式锁机制的:
- 加锁时先获取客户端当前的时间戳T1
- 客户端向所有节点发送SETNX命令进行加锁(Key为自定义字符串 + 随机值),同时设置网络超时时间,若超时则直接跳过该节点
- 当收到所有命令的响应结果时,记录当前时间戳T2,总耗时t = T2 - T1
- 如果返回的结果个数 ≥ N/2 + 1,且t < 锁TTL,则代表加锁成功,锁的实际有效时间为TTL - t - ClockDrift(时钟偏移修正值,通常为TTL的1%)
- 释放锁时则向所有节点(包括超时节点)发送Lua脚本命令释放锁
RedLock是如何解决分布式锁可能存在的问题的? 问题风险场景应对措施网络延迟部分节点响应慢,导致 Δt 超限。设置严格网络超时,超时即放弃该节点。进程暂停客户端 GC 停顿导致锁过期,恢复后误删他人锁。依赖唯一 random_value 标识持有者。时钟漂移节点间时钟不同步,导致锁提前/延迟失效。引入 ClockDrift 修正值,节点强制同步 NTP 协议。
RedLock存在的争议以及局限性
- 争议 争议点观点反驳进程暂停锁过期后客户端无法感知,导致数据冲突 锁获取阶段可检测超时;锁持有后GC导致发生资源共享的问题所有方案均存在 时钟漂移时钟跳跃常见,破坏锁安全性运维可控,误差在 TTL 容忍范围内
- 局限性 性能损耗:多节点通信延迟显著高于单节点,并发性能下降 30%-50%。部署成本:需维护多个独立 Redis 实例,资源消耗倍增。复杂度:需处理节点故障、时钟同步、延迟重启等边界场景。
所以目前RedLock已经被废弃掉了,因为Redis Cluster主从切换概率低,而且RedLock的开销过大。因此主流方案采用 Redis Cluster + Redisson的看门狗机制。
七、消息队列
(一)List方案
1. 模型原理
- 数据结构: 双向链表结构,通过
LPUSH/RPOP或RPUSH/LPOP组合实现先进先出队列。 - 阻塞机制:
- 超时参数设为
0表示无限等待。 - 消费者使用
BRPOP/BLPOP命令,在队列为空时阻塞等待新消息。
- 超时参数设为
- 消息可靠性:
- 通过
RPOPLPUSH原子命令将消息移到备份队列。 - 业务处理完成后删除备份队列中的消息;失败时从备份队列重试。
- 通过
2. 优缺点
- 优点:
- 简单易用,延迟低
- 先进先出保证消息顺序消费
- 缺点:
- 无法配置多消费者
- 无原生ACK机制,需自行配置备份队列
- 消息容易积压
(二)Pub/Sub模型
1. 模型原理
-
角色划分:
- 发布者:
PUBLISH channel message向频道发送消息。 - 订阅者:
SUBSCRIBE channel或PSUBSCRIBE pattern*(模式匹配订阅)。
- 发布者:
-
消息传递:
- 消息实时广播至所有在线订阅者,无存储设计。
2. 优缺点
- 优点:
- 灵活性高,可模糊路由
- 延迟低
- 支持多消费者
- 缺点:
- 无消息持久化机制
- 无ACK机制,消息易丢失
- 消息生产速度大于消费速度,导致缓冲区溢出
(三)基于Stream流
1. 模型原理
- 数据结构:基于日志追加顺序写,每条消息含全局唯一ID。
- 消费者组:组内多个消费者竞争消费同一消息流,提升吞吐量(类似Kafka)。
- ACK 机制:消费者确认消费完消息可向生产者发送ACK确认。
- 容错设计:消费者崩溃后,未 ACK 的消息会重新分配给组内其他消费者。
2. 优缺点
- 优点:
- 消息可持久化
- 支持多消费者
- 支持消息回溯和重复消费
- 支持消息顺序消费
- 有ACK机制,确保消息不丢失
这个方案基本没有什么缺点,比较可靠。
但如果要使用MQ的话还是推荐使用较为完善的主流MQ:RabbitMQ、RocketMQ、Kafka等。
八、线程模型
1. 处理命令流程
- 客户端发起连接请求,主线程通过epoll监听到对应端口接收请求,创造socket连接放入全局等待队列。
- 主线程通过轮询策略将socket分配给IO线程组。
- 主线程分发任务后继续处理其他事件,IO线程异步完成数据解析并写入命令队列。
- IO读取解析完成,主线程获取命令队列。
- 主线程执行命令:
- 读事件同步执行: 1. 将结果写入客户端专属输出缓冲区 2. 不立即发送响应,而是将客户端标记为待写状态,并注册其写就绪事件到Reactor监听队列 3. SubReactor监听到后立刻发放给Handler,让其立刻从输出缓冲区读取数据到发送给客户端
- 写事件异步执行: 1. 主线程执行完命令后,将待响应的客户端加入写任务队列,并向Reactor注册该socket的写就绪事件 2. 当内核通知socket可写时,SubReactor线程从写任务队列取出客户端,发给Hanlder异步发送其输出缓冲区的数据到内核区。若数据未一次发完,继续等待下次写就绪事件
- 主线程清除全局就绪事件队列,等待下一请求。
2. 核心机制
(1)IO多路复用
A. 传统网络IO的性能瓶颈
- BIO:大量线程消耗内存和CPU资源,且频繁上下文切换性能开销大
- NIO:当事件少且端口多的时候,会造成空轮询
关于传统IO模型的详细讲解请阅读以下博客:带你轻松学习BIO、NIO和AIO三大模型-CSDN博客https://blog.csdn.net/2401_88959292/article/details/149206033?spm=1001.2014.3001.5501
B. 解决方案
- 每个新连接的socket都会注册到epoll,绑定FD(一种文件描述符,可以理解为定位socket的唯一标识)并关联回调函数
- 由内核监控FD是否准备就绪
- 网卡接收数据后通过DMA写入内存,触发CPU硬中断
- 内核线程ksoftirqd处理数据包,根据端口号找到对应socket接收队列
- socket就绪后通过回调函数将FD加入就绪队列
- 主线程直接获取FD就绪数组并分发给Handler,时间复杂度为O(1)
核心就是将原本需要线程主动轮询监控的FD,现在全部交给内核使用回调函数来监听事件,若监听到了事件将数据封装到FD然后加入就绪链表等待主线程分发。
这样就实现了多个端口请求复用同一个线程,也就是IO多路复用。
(2)Reactor模式
该模式一共经历了两次优化:
1. 单线程模式
Reactor负责建立连接、监听事件以及事件的分发,并创建新的Handler;
Handler接收到事件后开始解析数据并传输到命令队列里。
整个流程均在单线程上串行执行,因此在执行期间会阻塞其他所有的请求,所以这种模式并不适用于高并发。
2. 多线程模型
单线程模式的性能瓶颈主要是在Handler的阻塞处理业务逻辑上,因此多线程模型中Handler使用了线程池与Reactor隔离开,也就是Reactor只需将事件交给Handler后就可以去处理下一次连接的请求了。
但此模式依然存在性能瓶颈,客户端数量一旦几何式增长,Reactor单线程无法一下子处理这么多连接请求,造成积压。
3. 主从多线程模型
这个模式的优化思想就跟处理MQ消息积压一样,增加消费者数量即可。
因此该模型将Reactor分为了主线程和从线程池,主线程负责监听连接请求,从线程池负责监听事件以及事件的分发。
该模型能完美应对高并发场景,被广泛使用。
既然两次优化都是由于单线程引起的性能瓶颈,那为何在主从多线程模型中仍然保持单线程监听连接请求? 监听连接请求本本身就只是轻量级操作,TCP的三次握手全部都已经由内核处理完毕了,主线程要做的只是出队取出FD即可使用多线程会导致同一个连接被多个线程共享,因此得加锁,但一旦上锁了就会带来锁竞争的性能开销,会导致惊群效应(即多个线程被唤醒,但只有一个线程的请求会成功)多线程的频繁上下文切换会带来庞大的性能开销
既然多线程开销这么大,那为什么还要引入两个线程池? 可别忘了,线程池的使用可是要根据实际情况来决定的,监听连接请求之所以只用单线程是因为这既不是IO密集型也不是CPU密集型操作,这只是单纯的出队操作而已。 而从线程池是处理网络IO事件的,Handler是解析数据并传输的,既有CPU也有IO。 所以比起开销方面,引入线程池其带来的并行性能的提升更为显著。
那都说redis是单线程了,这不是只有监听连接请求是单线程,其他不还照样是多线程吗? 请注意IO多路复用 + Reactor模型的整个流程下来就只是接收客户端请求、解析并放到命令队列里罢了,真正执行命令的还是主线程,因此Redis是引入了多线程作为辅助,但是核心的命令执行仍旧是单线程。
那既然单线程执行命令,为什么多条redis命令执行的时候最好使用Lua脚本? 这就是一个线程安全的判断问题,虽然redis单线程保证了不会出现并发问题,可以保证原子性,但是多条原子性操作的组合可不是原子性的,依旧可能出现类似指令交错的现象导致并发问题或者出现死锁问题(redis执行完加锁命令后还未执行TTL命令就宕机)。
九、原子性保障
(一)事务
事务通过 MULTI、EXEC、WATCH、DISCARD 命令组合实现批量操作的原子性执行。
1. 核心流程
- 开启事务
- MULTI:标记事务开始,后续命令进入队列缓存,不立即执行。
- 命令入队
- 客户端发送的命令被序列化存储在事务队列中。
- 提交或放弃
- EXEC:按顺序执行队列所有命令,返回值列表。
- DISCARD :清空队列并退出事务状态。
- 乐观锁(WATCH)
- WATCH:监视键值变化;若被其他客户端修改,则事务中止。
2. 缺点
倘若命令在EXEC执行中出错导致执行失败,此时Redis也并不会回滚,因为Redis没有回滚机制,所以此时原子性失效。
(二)Lua脚本
Lua 脚本是Redis原生事务更强大的原子性方案,脚本内所有命令作为单操作整体执行,天然隔离且支持复杂逻辑。
| 维度 | 事务 | Lua 脚本 |
|---|---|---|
| 原子性 | 部分原子性 | 严格原子性 |
| 灵活性 | 仅支持命令序列,无分支/循环 | 支持条件判断、循环、局部变量等复杂逻辑 |
| 性能 | 多次网络往返 | 单次传输脚本,网络开销更低 |
| 错误处理 | 依赖客户端解析 EXEC 返回结果列表 | 脚本内可自定义错误处理逻辑 |
| 适用场景 | 简单批量操作、无分支乐观锁 | 分布式锁、带条件更新、复合操作 |
十、数据持久化
(一)AOF方案
AOF 以日志形式记录所有写操作命令,重启时重放命令恢复数据。默认文件为appendonly.aof。
1. 工作流程
- 命令追加
- 写操作执行后,命令协议文本追加到
aof_buf缓冲区。
- 文件写入与同步
- 缓冲区数据写入内核
page cache。 - 同步策略:
always:每次写操作后同步磁盘(数据零丢失,性能最低)。everysec(默认):每秒同步一次(平衡安全与性能,最多丢失 1 秒数据)。no:由操作系统决定同步时机(性能最优,宕机可能丢失大量数据)。
- 文件重写
- 目的:去除冗余命令(如多次修改同一 key),压缩 AOF 文件体积。
- 流程:
fork子进程扫描内存数据生成新 AOF 临时文件。- 重写期间新写操作同时记录到
aof_rewrite_buf缓冲区。 - 子进程完成后,将缓冲区数据追加至临时文件并原子替换旧文件。
2. 优缺点
- 优点
- 高数据安全性:根据策略可接近零丢失。
- 可读性强:文本文件便于人工检查或修复。
- 实时性好:支持秒级持久化。
- 缺点
- 文件体积大:日志累积导致文件膨胀。
- 恢复速度慢:重放所有命令耗时较长(尤其大文件)。
- 写入负载高:高并发写入时,命令追加可能阻塞主线程。
3. 常用配置
appendonly yes # 开启 AOFappendfilename "appendonly.aof" # AOF 文件名appendfsync everysec # 同步策略auto-aof-rewrite-percentage 100 # 文件体积超过上次 100% 时触发重写auto-aof-rewrite-min-size 64mb # 最小重写文件阈值no-appendfsync-on-rewrite yes # 重写期间阻塞aof-use-rdb-preamble yes # 开启混合持久化(二)RDB方案
RDB的目的是为了解决AOF方案中存储文件大、恢复速度慢的问题。
RDB 通过生成某一时刻内存数据的二进制快照实现持久化。快照文件经过压缩,默认存储名为dump.rdb。
1. 工作流程
- 触发机制
- 手动触发:
SAVE:主进程同步阻塞生成快照,期间拒绝所有请求。BGSAVE:主进程fork子进程异步生成快照,主进程正常响应请求。
- 自动触发: 配置文件设置触发条件(如
save 60 10000表示 60 秒内 10000 次写操作触发BGSAVE)。
- 子进程生成快照
- 子进程遍历内存数据,写入临时 RDB 文件。
- 写入完成后替换旧文件(原子操作)。
- 写时复制
fork子进程时,父子进程共享内存页。- 主进程修改数据时触发页拷贝,子进程保留原始数据快照,确保数据一致性。
2. 优缺点
- 优点
- 高效恢复:二进制文件紧凑,加载速度极快。
- 低资源占用:
BGSAVE异步执行,主进程阻塞仅发生在fork阶段。 - 备份便捷:文件可直接迁移或备份。
- 缺点
- 数据丢失风险:两次快照间宕机会丢失最后一次快照后的数据。
- 大内存
fork成本高:数据量过大时,fork可能导致主进程短暂阻塞。 - 实时性弱:无法实现秒级持久化。
3. 常用配置
dbfilename dump.rdb # RDB 文件名dir ./ # 存储路径rdbcompression yes # 启用压缩rdbchecksum yes # 启用校验和stop-writes-on-bgsave-error yes # 后台保存出错时停止写入十一、过期删除策略与内存淘汰策略
(一)过期删除策略
Redis 通过惰性删除 + 定期删除组合管理过期键,确保内存高效回收且避免过度消耗 CPU。
1. 惰性删除

客户端对该Key进行操作时检查该Key是否已经过期,如果过期可以通过配置选择同步或异步删除。
2. 定期删除

默认每隔10秒进行一次检查,每次检查20个Key(间隔时间可以自定义,但是检查Key的个数无法改变)。
单轮操作的最大执行时间为25ms,避免阻塞主线程。
为什么不过期就立即删除数据? 当过期Key数量比较多的时候,删除操作可能会占用CPU不少时间,这会对服务器的吞吐量以及响应速度造成影响。
(二)八种内存淘汰策略
当内存达到 maxmemory 阈值时触发淘汰机制,策略分为三类:不淘汰、仅淘汰过期键、淘汰所有键。
| 策略类型 | 策略名称 | 运作机制 | 适用场景 |
|---|---|---|---|
| 不淘汰数据 | noeviction (默认策略) | 内存满时拒绝写入命令,返回错误。 | 数据绝对不可丢失的场景。 |
| 仅淘汰过期键 | volatile-random | 随机删除任意设置了过期时间的键。 | 过期键分布均匀且无热点数据时。 |
| volatile-ttl | 优先删除TTL最小的键。 | 需快速清理低价值临时数据。 | |
| volatile-lru | 淘汰最近最久未使用的过期键(基于近似 LRU 算法)。 | 关注热点数据保留的缓存场景。 | |
| volatile-lfu | 淘汰使用频率最低的过期键(LFU 算法)。 | 避免冷数据长期占用内存。 | |
| 淘汰所有键 | allkeys-random | 随机删除任意键(无论是否过期)。 | 数据无明确访问规律时。 |
| allkeys-lru | 淘汰整个键空间中最近最久未使用的键(近似 LRU)。 | 通用缓存场景。 | |
| allkeys-lfu | 淘汰整个键空间中使用频率最低的键(LFU 算法)。 | 需精准保留高频访问数据的场景。 |
什么是近似LRU算法? 让我们先了解一下传统LRU算法: 优先淘汰最久未被访问的数据,基于”最近访问的数据更可能再次被访问”的时序规律。 基于双向链表和哈希表实现: 访问数据时: 若存在,将其移到链表头部(最近使用)若不存在,加载数据并插入链表头部 淘汰数据时:删除链表尾部节点(最久未使用) 缺陷: 缓存污染:突发大量冷数据访问时,会挤出热点数据低性能:每次访问需调整链表结构 而Redis为了解决LRU的性能问题,在此基础上做了优化: 去掉了哈希表和双向链表,数据结构改为了键值对。 每个键值对的内置lru字段,记录最后一次访问时间戳。 淘汰流程: 随机抽取 N 个键淘汰其中lru值最小的键若内存仍不足,重复步骤1
该方案不需要维护全局链表,可以节省大部分内存空间,而且访问性能大大提高。 但是依旧无法解决缓存污染问题。什么是LFU算法? 优先淘汰访问频率最低的数据,基于”高频访问数据更可能再次被访问”的频率规律。 该算法与redis优化的近似LRU算法类似,只不过将记录时间戳的lru字段改成了记录访问次数的logc字段。 但为了防止历史权重过高,还增加了记录上次访问时间的ldt字段。 访问时不会立刻增加次数,而是通过计算来概率递增(访问次数越多、上次访问时间越早的概率越小)。 维度LRU近似LRULFU淘汰依据访问时间(时序)采样键的访问时间访问频率(计数)内存开销高(需链表+哈希表)极低(仅键值对)极低(仅键值键)CPU开销高低中抗突发流量弱弱强识别长期热点中等中等强典型场景传统缓存系统Redis 默认策略热点数据敏感系统
十二、集群模式
(一)主从复制

通过异步复制实现数据同步,主节点(Master)负责写操作,从节点(Slave)负责读操作,实现读写分离。
1. 工作流程
- 建立连接
- 从节点发送命令,与主节点建立连接 。
- 全量同步(首次同步)
- 主节点执行
BGSAVE生成RDB快照文件,发送给从节点; - 从节点清空旧数据并加载RDB文件;
- 同步期间新写操作记录到缓冲区,RDB传输完成后发送给从节点。
- 增量同步(断连恢复)
- 主节点将断连期间的写命令写入环形缓冲区;
- 重连后,从节点通过
offset定位断点位置,主节点发送增量数据。
当从节点offset在环形缓冲区无法定位到数据时,此时即使是断连恢复也要使用全量同步,因为这时代表主节点的进度已经远超从节点了,环形缓冲区的数据已经被完全覆盖了。 因此为了避免全量同步的额外性能开销,我们可以把环形缓冲区的大小设置得稍微大一点(默认为1M)。
2. 优缺点
- 优点:
- 读性能提升
- 数据可靠性增强,不易丢失数据
- 从节点水平扩展成本低
- 缺点:
- 主从点宕机则无法执行写入操作
- 主从点压力无法分散
- 异步复制可能导致短暂的数据不一致
(二)哨兵模式

在主从复制基础上引入哨兵节点(Sentinel),实现自动故障监控、选举与切换。
1. 关键机制
- 监控
- 哨兵每秒向所有节点发送PING,未响应则标记为主观下线。
- 判定客观下线
- 若多数哨兵认定主节点主观下线,则标记为客观下线。
- 领导者选举与故障转移
- 哨兵集群通过Raft算法选举Leader哨兵;
- Leader从从节点中选新主节点
哨兵模式是如何选出新主节点的? 哨兵每秒向所有节点发送PING当哨兵节点未收到响应时,会标记该节点为主观下线当某一哨兵节点你标记为主观下线时检测到超过quorum个哨兵节点也认为其主观下线,则将该节点标记为客观下线如果被标记为客观下线的节点是主节点,则需要重新选举一个节点为主节点首先需要从哨兵集群中选出一个Leader,每个哨兵节点会主动请求其他节点投票给自己,如果该节点没有投票给其他节点则会投给这个节点一票如果一个节点票数达到了quorum(或者N/2 + 1,N为哨兵节点个数),倘若未出现Leader节点则需要重新选举Leader需要从从节点中选举一个新主节点 过滤掉故障节点是否有优先级设置,若有则选优先级最大的,否则继续往下轮询每个从节点的offset,优先选最大的,若不存在则继续往下选择启动时生成的ID最小的为主节点
配置哨兵节点个数至少为3个(奇数),配置quorum为N/2 + 1,预防脑裂问题。
什么是脑裂? 分布式系统中因网络分区导致集群分裂成多个独立子集群,每个子集群都认为自己是唯一的主控方,从而引发数据冲突、服务混乱的现象。
哨兵模式为什么会出现脑裂问题呢? 网络分区发生 主节点与部分哨兵/从节点断开连接集群被分割为两个区域: 区域A:主节点 + 少量客户端区域B:多数哨兵 + 从节点 双重主节点诞生 区域B:哨兵检测到主节点失联,选举出新主节点并接管服务区域A:原主节点未感知降级,仍在接受写请求 数据冲突与丢失 区域写入操作后果区域A(原主)SET key1 “A区写入”数据写入原主节点区域B(新主)SET key1 “B区写入”数据写入新主节点 网络恢复后: 区域A期间的写入数据永久丢失原主节点的数据被清空,全量同步新主节点数据原主节点被哨兵强制降级为从节点
因此需要增加哨兵节点个数而且为奇数个,预防断开连接后某一区域不存在哨兵节点,从而无法及时让原主节点开启自我保护机制拒绝写入操作。
2. 优缺点
- 优点:
- 自动化故障转移,高可用性
- 局限:
- 未解决写性能瓶颈
- 扩容复杂
(三)Cluster分片
哈希取模数据分片 + 多主多从架构,解决海量数据存储与读写问题。
1. 核心机制
- 虚拟哈希槽分片
- 所有键映射到16384个槽
- 每个节点负责部分槽
- 节点通信与高可用
- 节点间通过Gossip协议交换状态信息;
- 每个主节点有1-N个从节点,主故障时从节点自动顶替。
- 客户端请求路由
- 若请求的键不在当前节点,返回目标节点地址
- 数据迁移期间临时重定向
2. 集群扩容缩容流程
- 扩容:
- 新节点加入集群
- 从现有节点抽取槽位数据分批迁移
- 广播集群进行新槽位分配
- 缩容:
- 将数据迁移至其他节点
- 清空待下线节点的槽位
- 广播集群忘记该节点
3. 优缺点
- 优点:
- 数据分布式存储,突破单机内存限制
- 水平扩展成本低
- 自动故障转移和负载均衡保证高可用
- 缺点:
- Lua脚本需要保证操作的Key在同一节点
| 维度 | 主从复制 | 哨兵模式 | Cluster分片集群 |
|---|---|---|---|
| 核心目标 | 数据冗余与读写分离 | 主节点高可用 | 数据分片与水平扩展 |
| 数据一致性 | 异步复制(弱一致) | 异步复制(弱一致) | 分区一致性 |
| 故障转移 | 手动切换 | 自动切换 | 自动切换 |
| 写性能扩展 | 不支持 | 不支持 | 支持 |
| 适用场景 | 中小规模缓存、读写分离 | 高可用且数据量中等 | 海量数据、高并发读写 |
码文不易,留个赞再走吧
原文链接: 带你轻松学习Redis 作者: Yilena
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时










