Redis面试题
Redis 为什么快?内存操作、单线程模型、IO 多路复用
面试官您好!Redis 之所以能达到每秒 10 万 +的惊人 QPS,核心是它做对了这 4 件事:纯内存操作、单线程模型、IO 多路复用,再加上高效的数据结构。 下面我逐一说明:
1. 纯内存操作 ⚡
Redis 是内存数据库,所有数据都存储在 RAM 中,读写速度比磁盘快几个数量级。
| 存储介质 | 随机读取延迟 | 相对速度 | 形象比喻 |
|---|---|---|---|
| 内存 (RAM) | ~100ns | 1000000x | 😳 眨一下眼 |
| SSD 固态硬盘 | ~100μs | 1000x | ☕ 接一杯水 |
| HDD 机械硬盘 | ~10ms | 1x | 😴 打个盹 |
内存操作比磁盘快 10 万倍 以上,这是快的基石。
而且 Redis 精心设计了大量的 O(1) 数据结构(哈希表、跳跃表、压缩列表),数据定位几乎不费劲。
🧠 数据在内存 + ⚡ O(1) 数据结构 = 极速存取一句话总结:内存操作是 Redis 快的根本原因,相当于开跑车 vs 骑自行车的差距。
2. 单线程模型 🧵
很多人误以为单线程会慢,但 Redis 的单线程恰恰是它快的关键!
Redis 核心网络读写和命令执行是单线程的,很多人会疑惑:“单线程能快?多线程不是更猛吗?” 这里有个关键:
多线程不一定快,因为:
- 上下文切换有开销 🌀
- 共享数据要加锁,锁竞争会严重拖慢速度 🔒
而 Redis 是 CPU 带的货不是瓶颈,瓶颈在网络 I/O。单线程反而带来了几个巨大好处:
- 没有上下文切换,CPU 不用到处救火。
- 没有锁竞争,不用加锁、解锁,代码路径极短。
- 天然原子性,一个命令执行时不会被其他命令插队。
❌ 多线程:Thread1 🔒数据 -> 等锁 -> 执行 -> 解锁
Thread2 ⏳等锁... (内耗)
✅ Redis单线程:请求1 -> 执行 -> 请求2 -> 执行 (一气呵成)这就好比银行只有一个柜员,但业务处理极快,还不用跟后台抢印章——比三个柜员互相等章快多了 😄。
单线程的优势:
- ✅ 避免了多线程上下文切换的开销
- ✅ 不需要加锁,没有锁竞争问题
- ✅ 代码更简单,更容易维护和调试
注意:Redis 6.0 引入了多线程,但只用于处理网络 IO 和数据序列化,核心命令执行仍然是单线程的。
3. IO 多路复用 🔄
Redis 使用了epoll(Linux 下)实现 IO 多路复用,让单线程可以同时处理成千上万个客户端连接。
这是 Redis 快的网络核心。传统的 BIO 模型是来一个客户端就开一个线程,1 万个客户端就开 1 万个线程,系统直接炸掉 💥。
Redis 用了 IO 多路复用(Linux 下用 epoll),一个线程监听成百上千个 socket,只处理活跃的。
🧑🍳 传统服务(BIO):一个服务员盯一张台,上了菜就在旁边干等客人吃完。
🦸 多路复用(epoll):一个服务员盯全场,哪桌客人举手(有事件)才过去,效率爆表。核心流程:
- 红黑树 + 链表存储所有客户端 socket
- 调用
epoll_wait时,只返回就绪的 socket(有数据可读、可写) - 单线程遍历这些就绪事件,非阻塞处理
客户端A ──┐
客户端B ──┤
客户端C ──┼──> [ epoll 机制 ] ──> 单线程事件循环 ──> 命令处理 ──> 返回
客户端D ──┤ ↑ 只处理有事件的连接
客户端E ──┘这套机制保证了即使并发数超高,Redis 也能用极低的 CPU 和内存稳如泰山。
工作原理:
- 所有客户端连接都注册到 epoll 上
- epoll 监听哪些连接有数据可读 / 可写
- 单线程依次处理这些就绪的连接
- 没有连接就绪时,线程阻塞等待
一句话总结:IO 多路复用让 Redis 用一个线程搞定了一万个连接。
4. 高效的数据结构 📊
Redis 为不同的场景设计了专门的数据结构,并且做了极致的优化:
- String:简单动态字符串 (SDS),比 C 语言原生字符串更高效
- Hash:压缩列表 + 哈希表,小数据量时用压缩列表节省内存
- List:双向链表 + 压缩列表,支持快速头尾操作
- Set:整数集合 + 哈希表,整数数据时用整数集合
- ZSet:跳表 + 哈希表,实现有序集合的高效操作
面试加分项 ✨
- Redis 使用自己实现的内存分配器(jemalloc/tcmalloc),比 glibc 的 malloc 更高效
- Redis 采用写时复制 (COW) 技术,在 fork 子进程进行 RDB 持久化时,不会阻塞主线程太久
- Redis 协议是简单的文本协议,解析速度非常快
总结 🎯
Redis 的快不是单一因素决定的,而是内存 + 单线程 + IO 多路复用 + 高效数据结构共同作用的结果。它牺牲了部分功能(如事务不支持回滚),换来了极致的性能,这也是它能成为缓存之王的根本原因。
Redis 快 = 💾 内存操作(纳秒级)
+ 🧵 单线程无锁(无内耗)
+ 🌐 IO 多路复用(高并发低开销)
+ 📊 高效数据结构(极致优化)Redis 五种基本数据类型与底层数据结构
面试官您好!Redis 之所以性能这么炸裂,核心原因之一就是它为每种数据类型都设计了量身定制的底层数据结构,做到了 "好钢用在刀刃上"。下面我从实际使用和底层实现两个维度来详细说明。
🗺️ 先看全局:五种类型速览
- String 🏷️ – 字符串,能存文本、整数、二进制(如序列化对象)。
- List 📋 – 列表,有序可重复,可做队列/栈。
- Hash 🗂️ – 哈希表,存字段-值对,适合存对象。
- Set 🎯 – 无序集合,不可重复,支持交并差。
- Sorted Set (ZSet) 🏆 – 有序集合,每个成员带分数,按分排序。
划重点:我们讨论的是用户看到的“数据类型”,而在 Redis 内部,它们被映射到多种底层数据结构(内部编码),这是性能与内存博弈的关键。🧠
整体概览表 📊
Redis 会根据值的大小、元素数量自动切换内部编码(OBJECT ENCODING key 可查)。帮你整理成表格,一目了然 👇:
| 数据类型 | 常用场景 | 底层数据结构 | 编码方式 | 最大元素数 |
|---|---|---|---|---|
| String 🍬 | 缓存、计数器、分布式锁 | 简单动态字符串 (SDS) | int/embstr/raw | 512MB |
| List 📋 | 消息队列、文章列表 | 压缩列表 (ziplist) + 双向链表 (quicklist) | ziplist/quicklist | 2^32-1 |
| Set 🎯 | 去重、交集并集差集 | 整数集合 (intset) + 哈希表 (hashtable) | intset/hashtable | 2^32-1 |
| Hash 🗂️ | 对象存储、购物车 | 压缩列表 (ziplist) + 哈希表 (hashtable) | ziplist/hashtable | 2^32-1 |
| ZSet 🏆 | 排行榜、延时队列 | 压缩列表 (ziplist) + 跳表 (skiplist) | ziplist/skiplist | 2^32-1 |
配置提醒:hash-max-ziplist-entries/value、zset-max-ziplist-entries/value 等决定了转换时机。
各数据类型底层详解 🔍
1. String 🍬 - 最简单也最常用
底层核心:简单动态字符串 (SDS)
struct sdshdr {
int len; // 已使用长度
int free; // 剩余可用长度
char buf[]; // 实际存储数据的数组
}三种编码方式自动切换:
- int 编码:当值是整数且在 long 范围内时,直接用整数存储
- embstr 编码:当字符串长度≤44 字节时,SDS 和 RedisObject 在同一块连续内存
- raw 编码:当字符串长度 > 44 字节时,SDS 和 RedisObject 分开分配内存
关键点: SDS 比 C 语言原生字符串多了 len 和 free 字段,解决了 C 字符串的缓冲区溢出问题,同时获取字符串长度的时间复杂度是 O (1)。
2. List 📋 - 双向链表的进化版
底层核心:Quicklist (快速列表)
设计思想: 结合了压缩列表 (节省内存) 和双向链表 (高效头尾操作) 的优点。每个 quicklist 节点都是一个压缩列表,存储多个元素。
关键点: Redis 3.2 之后统一使用 quicklist 替代了原来的 ziplist 和双向链表。可以通过list-max-ziplist-size参数调整每个节点的压缩列表大小。
3. Set 🎯 - 无序不重复集合
底层核心:整数集合 (intset) + 哈希表 (hashtable)
编码切换条件:
- 当集合中所有元素都是整数且元素个数≤512 个时,使用 intset 编码
- 否则自动升级为 hashtable 编码
intset 结构:
struct intset {
uint32_t encoding; // 编码方式(16/32/64位整数)
uint32_t length; // 元素个数
int8_t contents[]; // 有序存储的整数数组
}关键点: intset 支持自动升级编码,但不支持降级。例如,当添加一个 64 位整数时,整个数组会升级为 64 位编码。
4. Hash 🗂️ - 键值对中的键值对
底层核心:压缩列表 (ziplist) + 哈希表 (hashtable)
编码切换条件:
- 当哈希对象的键值对个数≤512 个且所有键值的长度都≤64 字节时,使用 ziplist 编码
- 否则自动升级为 hashtable 编码
ziplist 存储方式: 键和值作为相邻的两个节点连续存储在压缩列表中
关键点: ziplist 是一块连续的内存,通过特殊的编码方式存储多个元素,极大地节省了内存开销。
5. ZSet 🏆 - 有序不重复集合
底层核心:压缩列表 (ziplist) + 跳表 (skiplist)
编码切换条件:
- 当有序集合的元素个数
≤128个且所有成员的长度都≤64字节时,使用 ziplist 编码 - 否则自动升级为 skiplist 编码
跳表结构示意图:
关键点: ZSet 同时使用跳表和哈希表两种数据结构。跳表用于范围查询,哈希表用于单点查询,两者共享元素数据,没有冗余。
🔬 拆解底层数据结构的“脾气”
1. 简单动态字符串(SDS)— 字符串的基石
- 自带长度字段,O(1) 获取长度;杜绝了 C 字符串的缓冲区溢出问题。
- 二进制安全,能存任意数据。
- 有预分配和惰性空间释放,减少内存重分配次数。
embstr是 SDS 的优化:数据与头部一起连续分配,只读不修改时极为轻量。
2. 压缩列表(ziplist)— 内存小能手,但有代价
- 一块连续内存,用变长编码存储,极省内存。
- 适合元素少、值短的场景。
- 致命弱点:插入/删除元素可能引发“连锁更新”(往后的元素都要重新分配空间),最坏复杂度 O(N²)。所以元素多时必须转为其他结构。
3. 快速列表(quicklist)— List 的终极形态
- Redis 3.2 后 List 只用 quicklist。
- 结构:多个 ziplist 用双向链表串在一起,兼顾内存连续性与插入删除效率。
- 图示一下它的结构:
[ziplist] <-> [ziplist] <-> [ziplist] ...- 既避免了大 ziplist 的连锁更新,又减少了纯链表的内存碎片和指针开销。
4. 整数集合(intset)— 全是数字才有的福利
- 底层是有序数组,可二分查找。
- 编码分 int16/int32/int64,遇到更大范围的整数会自动“升级”编码(但不降级),保证数据不丢失。
5. 字典(dict)— Hash 和 Set 的通用底座
- 拉链法解决哈希冲突,逐步 rehash(分步迁移数据),防止大 key 阻塞。
- 增删改查平均 O(1)。
- Set 存元素时只关心键,值设为 NULL。
6. 跳跃表(skiplist)— ZSet 的范围查询利器
- 多层级有序链表,查找、插入、删除均为 O(logN)。
- 最大的价值:天然支持范围查询(zrange、zrangebyscore)。
- 但是,单靠 skiplist 不能 O(1) 查某个成员的 score,所以 ZSet 会同时维护一个 dict:
成员 → score。这样修改 score 和范围查询两不误,内存多付出一份指针而已。🤝
🧩 一张图让你彻底记住编码选择逻辑
面试官最喜欢的回答,就是能把这套“编码切换逻辑”讲明白——说明你懂它为啥快、为啥省。🔍
面试必问的几个关键点 💡
✅ 面试金句总结(加分点)
- “Redis 的五种基本类型只是抽象,底层会根据数据特征在内存利用率和操作效率之间动态权衡。”
- “压缩列表是连续内存的极致,但插入有连锁更新风险,所以仅限小集合。”
- “ZSet 为什么快?因为同时用跳表做范围查询,用字典做成员 O(1) 定位,双向奔赴。”
- “List 现在都是 quicklist,本质是 ziplist 的链表,解决了内存碎片和更新效率的矛盾。”
把这些记牢,面试官再追问也只能顺着你的思路走。😏
为什么 Redis 不用平衡树而用跳表实现 ZSet?
- 跳表的实现更简单,代码可读性高
- 跳表的插入和删除操作不需要旋转,效率更高
- 跳表的范围查询性能与平衡树相当
- 跳表的并发性能更好,因为修改操作只需要修改局部节点
SDS 相比 C 字符串有哪些优势?
- 获取长度 O (1) 时间复杂度
- 自动扩容,避免缓冲区溢出
- 减少内存分配次数 (预分配 + 惰性释放)
- 兼容部分 C 字符串函数
压缩列表的缺点是什么?
- 插入和删除操作需要移动内存,时间复杂度 O (n)
- 当元素过多或过大时,性能会急剧下降
- 这也是为什么 Redis 会在达到阈值时自动切换到其他数据结构
🎯 动手查一查(立马上手)
> SET hello world
OK
> OBJECT ENCODING hello
"embstr"
> HSET user:1 name Alice age 25
> OBJECT ENCODING user:1
"ziplist"当你能根据输出判断内部编码,你就和“死记硬背”说拜拜了。🚀
Redis 高级数据类型:Bitmap、HyperLogLog、Geo、Stream
面试官您好!Redis 除了我们常用的 5 种基础数据类型(String、List、Set、Hash、ZSet),还有 4 种非常实用的高级数据类型,它们在特定场景下能极大提升性能和节省内存。下面我逐一为您介绍:
Bitmap(位图)📊
一句话:把 String 当成 bit 数组用,每一个 bit 代表一个状态,省内存到极致。
核心概念
Bitmap 本质上是String 类型的二进制位操作,将字符串的每个字符(8 位)拆分成独立的位,每个位只能是 0 或 1。
底层原理
- 基于 Redis 的 SDS(简单动态字符串)实现
- 最大支持 2^32 位(约 512MB),可以存储 42 亿个状态
- 位偏移量从 0 开始
常用命令
SETBIT key offset value # 设置指定偏移量的位值
GETBIT key offset # 获取指定偏移量的位值
BITCOUNT key [start end] # 统计指定范围内值为1的位数
BITOP operation destkey key1 [key2...] # 对多个Bitmap进行位运算举例:
SETBIT user:login:20260501 1001 1 # 用户1001在5月1号签到
GETBIT user:login:20260501 1001 # 查是否签到
BITCOUNT user:login:20260501 # 统计签到总人数
BITOP AND dest key1 key2 # 位运算:交集/并集核心优势
- 极致节省内存:1 亿个用户的签到数据仅需约 12MB
- 位运算速度极快:O (1) 时间复杂度的位操作
典型应用场景
- 用户签到 / 打卡系统
- 在线用户统计
- 用户活跃状态记录
- 布隆过滤器(基础版)
典型场景:日活/签到统计
如果用户 id 是自增数字,一个亿用户一天的签到记录只需要 1亿 bit ≈ 12MB。你用 MySQL 存一条签到记录要多少空间?百倍以上。
📊 内存对比:
| 存储方式 | 1亿用户签到 | 内存占用 |
|---|---|---|
| MySQL 记录 (uid+date) | 1亿行 | ≈ 几 GB |
| Redis Bitmap | 1亿 bit | ≈ 12 MB |
✨ 面试亮点:提一下 BITFIELD 还能一次性操作多个连续 bit,比如存用户多状态机,更灵活。
注意事项
- 偏移量过大时会导致 Redis 阻塞(首次分配大内存)
- 不适合存储稀疏数据(大部分位为 0)
HyperLogLog(基数统计)📈
一句话:用极小内存(12KB)估算海量数据的基数(去重计数),误差在 0.81% 以内,牺牲精准度换空间。
核心概念
HyperLogLog 是一种概率性数据结构,用于估算集合的基数(不重复元素的数量),牺牲了一定的准确性来换取极致的内存效率。
底层原理
- 基于概率统计的 "伯努利试验" 原理
- Redis 实现使用了 16384 个桶(2^14)
- 每个桶占用 6 位,总内存仅为 12KB
- 标准误差约为 0.81%
它不存元素本身,而是基于概率算法,通过记录元素哈希值中“前导零的最大长度”来推断基数。所以无论存多少亿用户,每个 HLL 固定只占 12KB。
常用命令
PFADD key element [element...] # 添加元素
PFCOUNT key [key...] # 计算基数
PFMERGE destkey sourcekey [sourcekey...] # 合并多个HyperLogLog举例:
PFADD uv:page1 user1 user2 user3
PFCOUNT uv:page1 # 估算独立访客数
PFMERGE uv:total uv:page1 uv:page2 # 合并多个HLL核心优势
- 内存占用固定:无论存储多少元素,始终约 12KB
- 合并操作高效:O (N) 时间复杂度,N 为桶的数量
典型应用场景
- 页面 UV 统计
- 独立访客统计
- 商品点击量去重统计
- 大规模数据的基数估算
⚠️ 禁忌:不能用它取具体元素,比如“昨天是哪几个用户访问了”——它干不了,老老实实用 Set 或 Bitmap。
📉 对比图:
| 数据结构 | 内存占用 (1亿UV) | 是否精确 | 可否查询元素 |
|---|---|---|---|
| Set | 几 GB | 精确 | ✅ |
| Bitmap | 12 MB | 精确 | ✅ (需要uid映射) |
| HyperLogLog | 12 KB | 误差 0.81% | ❌ |
面试时补一句:如果公司对数据精度要求高,可以搭配 Bloom Filter 或直接用 Set,但 HLL 在监控大盘、运营概览场景下非常香。
注意事项
- 只能统计基数,不能获取具体元素
- 存在一定的误差(0.81%),不适合精确统计场景
- 当基数小于 10000 时,误差几乎为 0
Geo(地理空间)🌍
一句话:底层用 Sorted Set + GeoHash 编码,把经纬度编码成 52bit 整数作为 score,轻松实现附近的人、距离计算。
核心概念
Geo 是 Redis 3.2 版本新增的地理空间数据类型,用于存储和查询地理位置信息(经纬度)。
底层原理
- 基于 ZSet(有序集合) 实现
- 使用 Geohash 算法 将经纬度编码为 52 位整数
- 将 Geohash 值作为 ZSet 的 score,成员名称作为 value
经度 [-180,180] 和纬度 [-90,90] 交替二分法逼近,每次逼近产生 1 bit,最终合并成 52 bit 整数作为 Zset 的 score。地理越近,score 越接近,所以 Zset 范围查询就能拿到附近元素。
📍 字符串形象化:比如 wx4g0ec1 这种 base32 编码,前缀越长越精确。Redis 内部直接用 long 整数省掉字符串开销。
⚡ 面试官可能追问:为什么不用 Geohash 库自算存 Zset? 答:Redis Geo 封装了距离计算和单位转换(米/千米/英里),直接 GEORADIUS 一条命令搞定,减少网络开销和业务逻辑。
🌐 场景图:
我的位置 (116.40, 39.91)
|
5km 半径
/ \
司机A 📍 司机B 📍常用命令
GEOADD key longitude latitude member [longitude latitude member...] # 添加地理位置
GEOPOS key member [member...] # 获取指定成员的经纬度
GEODIST key member1 member2 [unit] # 计算两个成员之间的距离
GEORADIUS key longitude latitude radius unit [WITHCOORD] [WITHDIST] [COUNT count] # 查询指定范围内的成员
GEORADIUSBYMEMBER key member radius unit [WITHCOORD] [WITHDIST] [COUNT count] # 查询指定成员周围的成员
GEOHASH key member [member...] # 获取指定成员的Geohash编码举例:
GEOADD drivers 116.38 39.90 driver:1 # 存司机位置
GEORADIUS drivers 116.40 39.91 5 km WITHDIST COUNT 10 ASC # 附近5km内司机
GEODIST drivers driver:1 driver:2 km # 两个位置距离核心优势
- 内置地理空间计算能力,无需额外数据库
- 查询速度快,基于 ZSet 的有序性实现范围查询
- 支持多种距离单位(米、千米、英里、英尺)
典型应用场景
- 附近的人 / 商家
- 外卖配送范围计算
- 地图导航
- 地理位置打卡
注意事项
- 不支持海拔高度
- 精度有限(约 0.6 米)
- 不支持复杂的地理空间查询(如多边形范围)
Stream(流)📡
核心概念
Stream 是 Redis 5.0 版本新增的消息队列数据类型,专门为消息队列场景设计,解决了之前 Redis 消息队列的诸多问题。
底层原理
- 基于 ** 基数树(Radix Tree)和列表(List)** 实现
- 每个 Stream 是一个有序的消息序列
- 每个消息有一个唯一的 ID(时间戳 + 序列号)
- 支持消费者组(Consumer Group)机制
Stream 模型(消费者组)
一个消息可以被不同消费者组独立消费(类似 Kafka 的 group),组内多个消费者分摊消息,并需要 ACK 防止丢失。
常用命令
XADD key [MAXLEN ~ length] * field value [field value...] # 添加消息
XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key...] ID [ID...] # 读取消息
XGROUP CREATE key groupname id-or-$ [MKSTREAM] # 创建消费者组
XREADGROUP GROUP groupname consumer [COUNT count] [BLOCK milliseconds] STREAMS key [key...] > # 消费者组读取消息
XACK key groupname id [id...] # 确认消息已处理
XLEN key # 获取Stream的长度
XTRIM key MAXLEN ~ length # 修剪Stream的长度举例:
XADD order_stream * action create order_id 1001 # 发消息,* 表示自动生成ID
XREAD COUNT 2 STREAMS order_stream 0 # 从头读2条
XREADGROUP GROUP mygroup consumer1 STREAMS order_stream > # 消费者组读取新消息
XACK order_stream mygroup 1526569495631-0 # 确认消息
XPENDING order_stream mygroup # 查看待确认消息核心优势
- 消息持久化:消息不会丢失
- 消息有序性:严格按照插入顺序存储
- 消费者组:支持多消费者负载均衡
- 消息确认机制:确保消息至少被处理一次
- 消息回溯:可以重新消费历史消息
典型应用场景
- 消息队列
- 事件流处理
- 日志收集
- 实时数据同步
注意事项
- 消息不会自动删除,需要手动 XTRIM 或设置 MAXLEN
- 消费者组的偏移量需要手动管理
- 不支持消息优先级
对比 Pub/Sub 和 List
| 特性 | Pub/Sub | List (BRPOP) | Stream |
|---|---|---|---|
| 消息持久化 | ❌ | ✅ (但消费即删除) | ✅ (消费后仍保留) |
| 消费者组 | ❌ | ❌ | ✅ |
| 消息确认 | ❌ | ❌ (取出就没了) | ✅ (XACK) |
| 回溯历史消息 | ❌ | ❌ | ✅ (根据ID重新消费) |
| 适用场景 | 即时通知 | 简单任务队列 | 稳健的事件流、微服务异步解耦 |
💡 注意:Stream 不能完全替代 Kafka,当数据量极大、需要长时间保留或高吞吐顺序 IO 时 Kafka 更强。但对于中小规模、需要轻量部署的业务,Stream 真香。
四种高级数据类型对比表 📋
| 数据类型 | 底层实现 | 内存占用 | 时间复杂度 | 核心用途 | 误差 |
|---|---|---|---|---|---|
| Bitmap | String | 极低(1 位 / 元素) | O(1) | 状态存储、统计 | 无 |
| HyperLogLog | 概率结构 | 固定 12KB | O(1) | 基数统计 | ~0.81% |
| Geo | ZSet | 中等 | O(logN) | 地理位置查询 | 约 0.6 米 |
| Stream | Radix Tree+List | 中等 | O(logN) | 消息队列 | 无 |
一张图总结四大金刚 🧩
✅ 如果你能在项目里合理组合它们,比如:
- 签到+UV:Bitmap 记签到,HLL 估活跃。
- 附近推送+异步:Geo 找附近设备,Stream 分发推送任务。
Redis 持久化:RDB vs AOF 对比及混合持久化
面试官您好!Redis 持久化是为了解决内存数据库断电数据丢失的问题,它提供了三种持久化方案:RDB、AOF 和混合持久化。下面我从原理、优缺点、适用场景三个维度给您详细对比说明。
RDB 持久化 📸
核心思想:在指定时间间隔内,将内存中的数据集快照写入磁盘,生成一个二进制的.rdb文件。
内存数据 ──▶ fork子进程 ──▶ 写入临时RDB文件 ──▶ 覆盖旧RDB文件工作流程
触发方式
- 自动触发:配置文件中设置
save m n(m 秒内有 n 次修改则触发) - 手动触发:
SAVE(阻塞主进程)、BGSAVE(后台异步执行)
优缺点 ✅❌
优点:
- 恢复速度极快,适合大规模数据恢复
- 单个紧凑的二进制文件,便于备份和传输
- 对 Redis 性能影响小(子进程处理,主进程不做 IO)
缺点:
- 数据安全性低,会丢失最后一次快照后的所有数据
- fork () 操作会阻塞主进程,大数据集时阻塞时间较长
AOF 持久化 📝
核心思想:以日志的形式记录 Redis 服务器执行的每一个写命令,重启时通过重新执行这些命令来恢复数据。
SET name "zhangsan" ──▶ 追加到 AOF 缓冲区 ──▶ 写入磁盘工作流程
刷盘策略
| 策略 | 配置 | 说明 | 安全性 | 性能 |
|---|---|---|---|---|
| 每秒刷盘 | appendfsync everysec | 每秒将缓冲区数据刷入磁盘 | 最多丢失 1 秒数据 | 推荐 |
| 每次写入刷盘 | appendfsync always | 每个写命令都立即刷盘 | 几乎不丢失数据 | 性能差 |
| 操作系统控制 | appendfsync no | 由操作系统决定何时刷盘 | 不可控,丢失数据多 | 性能最好 |
AOF 重写机制
当 AOF 文件过大时,Redis 会执行BGREWRITEAOF命令,生成一个新的 AOF 文件,只包含恢复当前数据所需的最小命令集合,从而大幅减小文件体积。
优缺点 ✅❌
优点:
- 数据安全性高,可配置为每秒刷盘,最多丢失 1 秒数据
- AOF 文件是文本格式,可读性好,便于手动修复
缺点:
- 相同数据集下,AOF 文件通常比 RDB 文件大
- 恢复速度比 RDB 慢,尤其是大数据集时
- 频繁的 IO 操作可能影响 Redis 性能
RDB vs AOF 核心对比 🆚
| 对比维度 | RDB | AOF |
|---|---|---|
| 持久化方式 | 全量快照 | 增量日志 |
| 数据安全性 | 低,丢失最后一次快照后的数据 | 高,最多丢失 1 秒数据 |
| 恢复速度 | 极快 | 较慢 |
| 文件大小 | 小 | 大 |
| 性能影响 | 小(fork 阻塞) | 较大(频繁 IO) |
| 适用场景 | 备份、灾难恢复、对数据丢失不敏感 | 对数据安全性要求高的场景 |
混合持久化 🤝
Redis 4.0 引入,结合了 RDB 和 AOF 的优点,是目前推荐的持久化方案。
RDB快照(全量) AOF日志(增量)
┌─────────────────┐ ┌──────────────────┐
│ 某个时间点数据 │ + │ 快照后的写命令 │
└─────────────────┘ └──────────────────┘
↘ ↙
合并写入同一个AOF文件文件结构长这样:
[RDB二进制数据][AOF增量命令]重写AOF时,先fork子进程把当前数据全量写成RDB格式,再补上后续的增量AOF命令,覆盖旧AOF文件。
工作原理
- AOF 重写时,将当前内存数据以RDB 格式写入 AOF 文件开头
- 之后的写命令继续以AOF 格式追加到文件末尾
- 这样 AOF 文件前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据
优势 🌟
- 恢复速度快:先加载 RDB 部分,再执行少量 AOF 命令
- 数据安全性高:结合了 AOF 的增量持久化特性
- 文件体积小:RDB 部分压缩了全量数据
配置方式
# 开启混合持久化
aof-use-rdb-preamble yes(Redis 5.0后默认开启)生产环境最佳实践 💡
- 推荐使用混合持久化,兼顾性能和数据安全
- 如果对数据丢失不敏感,可以只使用 RDB
- 不建议只使用 AOF,因为 RDB 更适合备份和快速恢复
- 合理配置 RDB 的 save 策略和 AOF 的刷盘策略
- 定期备份持久化文件到异地服务器
“实际项目中,我们一般会把混合持久化打开,享受RDB的快恢复和AOF的数据安全。
如果对数据极度敏感,再配上everysec,几乎完美。
纯缓存场景不在乎数据的话,直接关闭持久化,性能拉满。”
最后给你个总结小图 📊:
数据安全性
↑
AOF ● (高)
│
混合 ●──┼──● RDB (快)
└─────────→ 恢复速度Redis 过期键删除策略:惰性删除 + 定期删除
面试官您好,Redis 的过期键删除采用的是惰性删除 + 定期删除的组合策略,这是一种在CPU 性能和内存占用之间做的经典权衡设计。
🧑💻 面试官:Redis 里给 key 设了过期时间,到期后是怎么被删掉的?
🙋 候选人: 面试官好,这个问题我分成三块来答:策略总览 → 两种策略的实现和源码细节 → 为什么要这么结合。咱们边聊边画,保证听得明白。
整体策略:不是“时间一到立刻消失”
Redis 过期键删除采用的是 惰性删除 + 定期删除 的混合策略。
- 惰性删除 :访问 key 时,顺带检查过期
- 定期删除 :后台轮询,随机抽查一批 key 清理
可以这样理解:
- 惰性删除像 “懒汉” —— 你不来找我,我就不管;等你来访问了,发现过期就马上清理。
- 定期删除像 “勤劳的保洁阿姨” —— 每隔一段时间,随机抽几个房间看看,有垃圾就扫掉,但也不会把整栋楼全扫一遍,避免累死。
下面拆开细说。
核心策略总览 🔍
惰性删除(被动删除)😴
核心思想:"你不找我,我就不删",把删除操作推迟到键被访问时执行。
- 优点:对 CPU 最友好,只有在真正需要的时候才执行删除操作,不会浪费 CPU 资源在无人访问的过期键上
- 缺点:对内存最不友好,如果大量过期键永远不被访问,会一直占用内存,造成 "内存泄漏"
- 执行时机:每次执行 GET、SET、EXISTS 等命令时,都会先调用expireIfNeeded()函数检查键是否过期
🔍 原理
所有读写命令执行前,都会调用 expireIfNeeded() 函数检查当前 key 是否过期:
- 如果过期,就 立即删除,然后返回
nil(就好像 key 根本不存在) - 如果没过期,就正常处理
🧠 源码视角
expireIfNeeded() 逻辑(伪代码):
if (key 有过期时间 && 当前时间 >= 过期时间) {
删除 key;
return NULL;
}
return 正常值;因此哪怕后台线程没来得及清理,只要你再次访问到一个过期的 key,也会瞬间被干掉。这就保证了 数据一致性:客户端永远不会读到逻辑上已过期的值。
✅ 优点 / ❌ 缺点
| 优点 | 缺点 |
|---|---|
| 对 CPU 友好,不访问就不浪费算力 | 如果 key 过期后再也没人访问,会一直占着内存 |
| 实现简单,无额外线程开销 | 可能产生大量“过期垃圾”,造成内存泄露 |
所以单纯靠惰性删除不行,必须有定期删除来兜底。
定期删除(主动删除)⏰
核心思想:"每隔一段时间抽查一次",通过定时任务主动清理过期键。
执行频率:Redis 默认每秒执行 10 次(hz配置项,默认 10),每次执行时间不超过 25ms
执行流程:
- 1.从过期字典中随机抽取 20 个键
- 2.删除其中所有过期的键
- 3.如果过期键的比例超过 25%,立即重复步骤 1
- 4.直到过期键比例低于 25% 或达到 25ms 时间限制
优点:通过限制执行频率和时长,避免了对 CPU 的过度占用;同时主动清理了部分无人访问的过期键,缓解了内存压力
缺点:清理不彻底,仍然会有部分过期键残留内存中
🧹 原理
Redis 内部每隔 100ms(由 activeExpireCycle 执行)会做一次 随机抽样清理:
- 从带有过期时间的 key 集合中,随机抽取 20 个 key
- 删除其中已过期的 key
- 如果本次删除比例
> 25%,证明过期 key 比较密集,就 继续循环 再抽 20 个 - 但为了防止阻塞,整个过程有 执行时间上限(默认 25ms / 次)
这样一来,既能清理掉大部分过期垃圾,又不会像全表扫描那样把单线程卡死。
⚙️ 执行频率
- 模式1:在
serverCron周期任务中触发(每 100ms) - 模式2:在
beforeSleep事件循环中也可能会快速执行一次
📊 流程图(Mermaid)
✅ 优点 / ❌ 缺点
| 优点 | 缺点 |
|---|---|
| 能主动淘汰过期 key,防止内存泄漏 | 随机抽样,可能漏掉某些过期 key |
| 通过时间和比例双重控制,避免 CPU 尖刺 | 如果过期 key 非常集中,依然可能卡一下 |
为什么采用这种组合策略?🤔
如果只用惰性删除 → 冷数据过期后永远赖在内存里,内存越用越大。
如果只用定期删除 → 要么清理不及时(频率低),要么清理太猛(CPU 飙高,阻塞命令)。
两者结合,就像 “前台立即响应 + 后台周期性兜底”,用最低代价达成 内存与性能的平衡。
| 策略 | CPU 占用 | 内存占用 | 实时性 |
|---|---|---|---|
| 定时删除 | 高 | 低 | 最高 |
| 惰性删除 | 最低 | 高 | 最低 |
| 定期删除 | 中 | 中 | 中 |
| 惰性 + 定期 | 低 | 中 | 中 |
Redis 作为高性能内存数据库,CPU 性能是第一优先级,同时也要保证内存不会被过期键耗尽。这种组合策略完美平衡了两者:
- 惰性删除保证了 CPU 不会做无用功
- 定期删除兜底,防止内存被大量冷数据占满
补充:内存淘汰机制 🚨
即使有了惰性删除和定期删除,仍然可能出现内存不足的情况。这时 Redis 会触发内存淘汰机制(maxmemory-policy配置),根据不同的策略删除键来释放内存。
常见的淘汰策略:
noeviction:不淘汰,直接返回错误(默认)allkeys-lru:从所有键中淘汰最近最少使用的volatile-lru:从设置了过期时间的键中淘汰最近最少使用的allkeys-random:从所有键中随机淘汰volatile-random:从设置了过期时间的键中随机淘汰volatile-ttl:淘汰最早过期的键
面试加分点 ✨
- 定期删除是随机抽查不是全量扫描,这是为了避免 O (n) 的时间复杂度
- 定期删除的 25% 阈值和 25ms 时间限制是 Redis 经过大量实践得出的最优值
- 惰性删除和定期删除都只能处理设置了过期时间的键,未设置过期时间的键永远不会被这两种策略删除
- Redis 3.0 之后对 LRU 算法进行了优化,采用近似 LRU 算法,性能更好
顺便提个坑:大量 key 同时过期
假如你用同一秒给几十万个 key 设置过期时间,会出现 “缓存集中过期” 问题。
定期删除循环会被反复触发,甚至可能跑满 25ms 上限,造成 Redis 短暂卡顿。
🌱 解法:在过期时间上加一个随机偏移量,比如 EXPIRE key 300 + random(0~60),把过期时间打散。
Redis 内存淘汰策略:LRU、LFU 等八种策略对比
面试官您好!Redis 的内存淘汰策略是当 Redis 内存达到 maxmemory 上限时,如何选择数据进行删除以释放内存的机制。Redis 4.0 之后共有 8 种 淘汰策略,我从核心分类、实现原理、优缺点和适用场景几个方面为您详细说明。
🧠 先懂背景:内存是件“皇帝的新衣”
Redis 是基于内存的,内存很贵,而且有上限(maxmemory)。当数据塞满,再写新数据时,Redis 就得“忍痛割爱”扔掉一些旧数据。怎么扔?八种策略 就是八套规则。
策略本质只围绕三个维度:
- 淘汰范围:从所有 key 里选?还是只从设了过期时间的 key 里选?
- 选择算法:随机扔?按最近最少用(LRU)?按最不常用(LFU)?还是按剩余寿命(TTL)?
- 要不要赶尽杀绝:满了是否直接报错?
下面直接上硬菜 🔥。
眼尖的你肯定发现,就是 2×4 的排列组合,再加一个“躺平”的 noeviction 😎。
先搞懂:什么时候会触发淘汰? 🤔
当 Redis 内存使用量超过配置的 maxmemory 阈值时,会根据配置的 maxmemory-policy 执行淘汰策略。如果不配置淘汰策略,Redis 会直接拒绝写入操作并返回 OOM 错误。
8 种淘汰策略全景图 🗺️
Redis 淘汰策略可以分为 4 大类,每类又分为 "全量键" 和 "设置了过期时间的键" 两种:
核心策略深度解析 🔍
1. 不淘汰策略:noeviction
- 核心逻辑:不淘汰任何键,当内存满时,所有写入操作直接报错
- 优点:数据绝对安全,不会丢失任何数据
- 缺点:无法写入新数据,服务可用性下降
- 适用场景:绝对不能丢失数据的场景,或者内存足够大的情况
2. 随机淘汰策略
| 策略名称 | 核心逻辑 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| allkeys-random | 从所有键中随机选择删除 | 实现简单,性能高 | 可能删除热点数据 | 数据访问频率差不多的场景 |
| volatile-random | 只从设置了过期时间的键中随机删除 | 不会删除永久数据 | 同样可能删除热点过期数据 | 有部分永久数据需要保留的场景 |
3. LRU 淘汰策略(Least Recently Used)✨
- 核心逻辑:淘汰最近最少使用的键,基于 "最近使用过的数据未来也可能被使用" 的假设
- Redis 实现:不是严格的 LRU,而是近似 LRU。Redis 会随机抽取 N 个键(默认 5 个),然后从中淘汰最久未使用的那个
- 优点:比随机淘汰更合理,能保留热点数据
- 缺点:无法识别 "偶尔被访问一次的冷数据",比如缓存击穿后一次性访问的大量数据会把真正的热点数据挤出去
- 适用场景:大部分互联网业务,热点数据明显的场景
4. LFU 淘汰策略(Least Frequently Used)🔥
- 核心逻辑:淘汰使用频率最低的键,基于 "使用频率高的数据未来也可能被使用" 的假设
- Redis 实现:每个键维护一个计数器,记录访问次数;同时计数器会随时间衰减,避免历史热点数据一直占用内存
- 优点:比 LRU 更智能,能识别真正的热点数据,不会被一次性访问的冷数据影响
- 缺点:实现稍复杂,需要额外存储计数器
- 适用场景:数据访问频率差异大的场景,比如电商秒杀、热点新闻
5. TTL 淘汰策略:volatile-ttl
- 核心逻辑:只从设置了过期时间的键中,淘汰最早过期的键
- 优点:优先删除即将过期的数据,减少数据丢失的影响
- 缺点:只考虑过期时间,不考虑访问频率
- 适用场景:希望优先删除即将过期数据的场景
🔍 技术点深挖:LRU 与 LFU 到底咋实现的
1. 近似LRU(可不是严格的链表)
Redis 没搞双链表精准记录,太耗内存。它采用随机采样:
- 随机选 N 个 key(由
maxmemory-samples控制,默认5) - 从中淘汰最近最少被访问的那个(通过 key 对象里的 24 bits 时钟字段)
- 效果几乎等同真实 LRU,但 CPU 和内存开销极低 ⚙️。
2. 近似LFU(莫里斯计数器)
LFU 需要计数频率,Redis 用了一个巧妙的概率性计数器:
- 同样 24 bits,高16位存分钟级时间戳,低8位存对数计数器
- 访问时,计数器概率递增,同时随时间衰减(半衰期可配 lfu-decay-time)
- 这样既防高频 key 霸占,又能让长期不访问的 key 自动降级,比纯 LRU 更能适应扫描型负载。
8 种策略终极对比表 📊
| 策略名称 | 淘汰范围 | 淘汰依据 | 推荐指数 | 典型适用场景 |
|---|---|---|---|---|
| noeviction | 不淘汰 | - | ⭐⭐ | 绝对不能丢数据 |
| allkeys-random | 所有键 | 随机 | ⭐⭐ | 数据访问均匀 |
| volatile-random | 过期键 | 随机 | ⭐⭐ | 有永久数据保留 |
| allkeys-lru | 所有键 | 最近使用时间 | ⭐⭐⭐⭐ | 大部分业务场景 |
| volatile-lru | 过期键 | 最近使用时间 | ⭐⭐⭐ | 有永久数据保留 |
| allkeys-lfu | 所有键 | 使用频率 | ⭐⭐⭐⭐⭐ | 热点数据明显 |
| volatile-lfu | 过期键 | 使用频率 | ⭐⭐⭐⭐ | 有永久数据保留 |
| volatile-ttl | 过期键 | 过期时间 | ⭐⭐⭐ | 优先删即将过期 |
面试高频追问点 💡
Redis 为什么不实现严格的 LRU?
答:严格 LRU 需要维护一个双向链表,每次访问都要移动节点,性能开销大。Redis 的近似 LRU 性能接近严格 LRU,但实现简单,性能更高。
LRU 和 LFU 怎么选?
答:如果你的业务中存在 "一次性访问" 的大量数据(比如缓存击穿),优先选 LFU;如果数据访问模式比较稳定,LRU 就足够了。
如何配置 Redis 淘汰策略?
答:在 redis.conf 中配置 maxmemory-policy allkeys-lfu,然后重启 Redis 或者使用 CONFIG SET 命令动态修改。
🤔 灵魂拷问:到底选哪种?
模拟一下真实对话:
- 你:“那我线上 Redis 当缓存用,大部分 key 都设了过期,该用哪个?”
- 我:“allkeys-lru 包治百病。为什么?因为只要你的访问有冷热之分,LRU 就能自适应。如果发现热 key 被批量加载的冷数据冲掉,赶紧换 allkeys-lfu。”
- 你:“如果有些 key 是永久存储,不能丢呢?”
- 我:“那就用 volatile-lru,只淘汰设了过期时间的那些‘临时工’,永久 key 安全。如果担心没有可淘汰的 volatile key 导致 OOM,它就会触发noeviction 一样报错,所以务必保证有足够的带过期时间的 key。”
- 你:“volatile-ttl 看着很合理啊,让快过期的先走。”
- 我:“合情但不一定合理。如果大量 key 过期时间雷同,会一起被选中,造成批量失效导致的缓存雪崩⛷️。慎用。”
总结 ✅
Redis 4.0 之后推荐优先使用 allkeys-lfu 策略,它能更好地识别热点数据,提高缓存命中率。如果有永久数据需要保留,可以使用 volatile-lfu。只有在特殊场景下才考虑使用其他策略。
- “Redis 淘汰策略是在主线程内执行的,所以选择
maxmemory-samples不能太大,避免阻塞,默认5就很平衡。” - “LFU 模式解决了‘某个冷数据突然被大量访问导致热数据被 LRU 误淘汰’的问题,适合防止缓存污染。”
- “使用
volatile-*策略时,如果没有可淘汰的 volatile key,表现等同于 noeviction,会写失败。” - “云 Redis 通常默认
volatile-lru,但自己用缓存集群,allkeys-lru 是业界事实标准。”
Redis 事务:MULTI/EXEC,不支持回滚的原因
Redis 事务本质上是 "命令批处理 + 原子执行" 的组合,和传统关系型数据库的事务有很大区别,核心围绕 MULTI、EXEC、DISCARD、WATCH 四个命令实现。
其实Redis 官方态度很明确——没必要为了回滚牺牲性能。他们觉得事务里的错误分两种,绝大多数都是编译期就能发现的,根本不用回滚;少数运行时错误是程序 bug,应该在测试阶段解决,而不是靠 Redis 兜底。
Redis 事务执行流程 📋
核心命令说明
| 命令 | 作用 |
|---|---|
| MULTI | 开启事务,进入命令队列模式 |
| EXEC | 执行事务,一次性运行队列中所有命令 |
| DISCARD | 放弃事务,清空命令队列 |
| WATCH key1 key2 | 乐观锁,监视指定 key,若执行前被修改则事务失败 |
执行流程图解
客户端 Redis 服务端
| |
|------ MULTI --------> | 开启事务队列
| |
|------ SET k1 v1 ---> | 命令入队 (未执行)
| |
|------ INCR k1 ------> | 命令入队 (语法错误?Redis 直接报错!)
| |
|------ EXEC ---------> | 开始顺序执行队列中的命令
| |
|<----- 结果数组 ------- |语法错误(编译期):
比如你把 SET 写成了 SETT,在加入队列的时候 Redis 就能发现。这时 EXEC 压根不会执行,直接返回错误。既然都没执行,自然不需要回滚。就像你写 Java 代码,编译不过,根本跑不起来,谈何事务回滚呢?😏
运行期错误(类型错误):
比如你对一个字符串 key 执行 LPUSH。这种错误只有在 EXEC 真正执行时才会暴露。但 Redis 的做法是:跳过这条错误命令,继续执行后面的命令。它不会因为一条命令失败就把整个事务回滚。
graph TD
A[Redis 事务模型] --> B{命令入队时检查}
B -->|语法错误| C[❌ 直接拒绝执行, 无回滚必要]
B -->|语法正确| D[命令进入队列]
D --> E[EXEC 顺序执行]
E --> F{执行中出错?}
F -->|运行期错误| G[⚡ 跳过该命令, 继续执行]
F -->|无错| H[✅ 全部成功]
G --> I[无回滚机制]
H --> I
I --> J[理由1: 追求极致性能]
I --> K[理由2: 错误应是开发阶段 Bug]
I --> L[理由3: 保持简单, 避免膨胀]- 追求极致性能:回滚需要维护 undo log,比如记录旧值、版本号,这会严重拖慢 Redis 的单线程核心。Redis 定位就是快,它宁可不支持回滚,也不加这个负担。
- 错误是程序 Bug:官方原话说,这类运行期错误“不应该在生产环境出现”。就像你写
String类型的变量去调List方法,编译器没拦住,那是你代码质量问题。🤷 - 保持简洁:Redis 源码已经很精巧了,加上完整的回滚会引入事务隔离、并发控制等一系列复杂度,变成了“小关系型数据库”,这违背了它的初衷。
既然不支持回滚,那 DISCARD 和 WATCH 又有什么用?🤨
DISCARD是“取消”未提交的事务,相当于清空命令队列,不是“回滚”已执行命令。WATCH实现的是乐观锁,配合MULTI/EXEC做 CAS(Compare-And-Swap)。比如你监控了balance这个 key,在EXEC前如果有人改了它,整个事务会直接放弃执行。这其实是在应用层面帮我们避免并发覆盖,而不是在 Redis 内部做数据回滚。
客户端1: WATCH balance 客户端2:
balance = 100
MULTI SET balance 80 (偷偷改了)
INCRBY balance 50
EXEC → 返回 nil (事务未执行)这里 Redis 连执行都没执行,自然就没有“回滚”一说。✨
Redis 事务的 "原子性" 真相 ⚡
很多人误以为 Redis 事务是完全原子的,但实际上:
- ✅ 命令执行的原子性:一旦执行
EXEC,队列中所有命令会连续、不间断地执行,不会被其他客户端的命令打断 - ❌ 错误处理的非原子性:如果队列中某条命令执行失败,后续命令仍然会继续执行,已经执行的命令也不会回滚
举个例子:
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET name zhangsan
QUEUED
127.0.0.1:6379> INCR name # 对字符串执行自增,语法正确但运行时错误
QUEUED
127.0.0.1:6379> SET age 20
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (error) ERR value is not an integer or out of range
3) OK # 这条命令仍然执行成功了!Redis 为什么不支持回滚?🔥 这是面试重点!
Redis 官方明确表示永远不会支持事务回滚,核心有 3 个原因:
1. 性能至上的设计哲学 🚄
- Redis 是内存数据库,性能是第一优先级
- 回滚机制需要复杂的日志记录、状态保存和恢复逻辑
- 这些额外开销会严重降低 Redis 的吞吐量和响应速度
2. 错误类型的特殊性 🎯
Redis 事务中的错误只有两种:
- 语法错误:命令拼写错误、参数数量错误等,在入队时就会被检测到,整个事务直接失败
- 运行时错误:如对字符串执行 INCR、对列表执行 HSET 等,这是程序员的逻辑错误
- Redis 认为:运行时错误应该在开发阶段就被发现并修复,而不是依赖数据库的回滚机制
3. 简单性原则 🧩
- Redis 的设计理念是简单、高效、可预测
- 不支持回滚让 Redis 的内部实现变得极其简单
- 事务执行逻辑只有 "入队" 和 "执行" 两个阶段,没有复杂的回滚分支
那我需要 "回滚" 怎么办?💡
虽然 Redis 不支持原生回滚,但我们可以通过以下方式实现类似效果:
- 使用 WATCH 实现乐观锁:防止并发修改导致的数据不一致
- 业务层补偿:如果某步执行失败,手动编写反向操作代码
- Lua 脚本:将多个命令封装在一个 Lua 脚本中,Lua 脚本本身是原子执行的(推荐)
与 MySQL 事务的核心对比 🆚
| 特性 | Redis 事务 | MySQL 事务 |
|---|---|---|
| 原子性 | 仅保证命令连续执行,不支持回滚 | 完全原子性,支持回滚 |
| 隔离性 | 完全隔离,执行期间不会被打断 | 支持 4 种隔离级别 |
| 一致性 | 不保证,需要业务层控制 | 保证 |
| 持久性 | 取决于持久化配置 | 保证 |
| 性能 | 极高 | 相对较低 |
总结 ✨
Redis 事务不是传统意义上的 ACID 事务,它更像是一个 "原子批处理工具"。不支持回滚是 Redis 为了追求极致性能和简单性做出的设计取舍,而不是技术缺陷。
在实际开发中,如果需要强一致性和回滚能力,应该优先考虑关系型数据库;如果只是需要批量执行命令并保证原子性,Redis 事务完全够用。
Redis 主从复制 + 哨兵模式 + Cluster 集群
面试官你好!我会从解决什么问题→核心原理→关键流程→优缺点的逻辑来讲解这三个 Redis 高可用方案,它们其实是 Redis 高可用演进的三个阶段,层层递进解决不同的痛点。
Redis 主从复制 📚
1. 解决的核心问题
- 单机 Redis 存在单点故障问题
- 单机读能力有限,无法应对高并发读请求
- 数据只有一份,存在数据丢失风险
2. 核心架构图
3. 关键工作原理
角色划分:Master 负责写操作,Slave 只能读操作
复制方式:
- 全量复制:Slave 第一次连接 Master 时,Master 生成 RDB 文件发送给 Slave,Slave 加载 RDB 完成数据同步
- 增量复制:后续同步只发送 Master 的写命令流,通过
repl_backlog_buffer实现
复制偏移量:Master 和 Slave 各自维护复制偏移量,用于判断数据一致性
原理:主节点所有写命令记录到缓冲区,从节点连上来后,先全量同步(RDB),再增量同步(命令传播)。Redis 2.8 后支持部分重同步,靠
replication offset + backlog避免断线全量重传。作用:读写分离(扛读流量)、数据备份。
硬伤:主挂了,得人工处理。你半夜三点手动切从,运维会骂人,所以必须有哨兵。
🔧 一句话:主从复制解决的是数据副本问题,但没有解决故障自动恢复问题。
4. 优缺点
✅ 优点:实现读写分离,提升读性能;数据多副本备份
❌ 缺点:Master 单点故障,需要手动切换;无法解决写能力和存储容量瓶颈
Redis 哨兵模式(Sentinel) 🛡️
1. 解决的核心问题
主从复制中 Master 故障需要手动切换的问题,实现自动故障转移
2. 核心架构图
3. 哨兵三大核心功能
- 监控:持续监控 Master 和 Slave 是否正常运行
- 通知:当某个 Redis 节点出现问题时,通过 API 通知管理员或其他应用程序
- 自动故障转移:当 Master 故障时,自动将一个 Slave 提升为新的 Master
4. 故障转移关键流程
- 主观下线:单个 Sentinel 认为 Master 不可达
- 客观下线:超过
quorum数量的 Sentinel 都认为 Master 不可达 - 领导者选举:Sentinel 之间通过 Raft 算法选举出一个领导者
- 故障转移:领导者从 Slave 中选择最优的一个提升为新 Master,其他 Slave 切换到新 Master
⚠️ 要注意的点:
- 哨兵集群本身最少 3 个实例,保证自身高可用。
- 客户端要能处理主地址切换(JedisSentinelPool 之类)。
- 依然是 单主写,所有数据全量存每个节点。数据量一大,单机内存扛不住,就得 Cluster。
🛡️ 哨兵解决了“主挂了自动切”,但写能力、存储容量还是单机瓶颈。
5. 优缺点
✅ 优点:实现了 Master 的自动故障转移,高可用性大大提升
❌ 缺点:仍然无法解决写能力和存储容量瓶颈;主从切换期间会有短暂的服务不可用
Redis Cluster 集群 🌐
1. 解决的核心问题
- 解决写能力瓶颈:将写请求分散到多个节点
- 解决存储容量瓶颈:数据分片存储在多个节点
- 实现真正的分布式高可用
2. 核心架构图
3. 核心原理:哈希槽(Hash Slot)
- Redis Cluster 将整个数据库分为16384 个哈希槽
- 每个 Master 节点负责一部分哈希槽
- 计算 key 的槽位:
CRC16(key) % 16384 - 客户端可以连接任意节点,节点会根据 key 的槽位重定向到正确的节点
4. 关键特性
- 数据分片:数据按照哈希槽分布在不同的 Master 节点
- 高可用:每个 Master 可以有多个 Slave,Master 故障时自动提升 Slave
- 去中心化:没有中心节点,每个节点都保存完整的槽位映射关系
- 水平扩展:可以动态添加 / 删除节点,自动迁移哈希槽
5. 优缺点
✅ 优点:解决了写能力和存储容量瓶颈;真正的分布式高可用;支持水平扩展
❌ 缺点:不支持多键操作(如 MSET、MGET);不支持事务;数据迁移复杂
三者对比总结 📊
| 特性 | 主从复制 | 哨兵模式 | Cluster 集群 |
|---|---|---|---|
| 解决的核心问题 | 读写分离、数据备份 | 自动故障转移 | 水平扩展、分布式存储 |
| 高可用性 | 低(手动切换) | 中(自动切换) | 高(分片级高可用) |
| 写能力 | 单机 | 单机 | 多机分布式 |
| 存储容量 | 单机 | 单机 | 多机总和 |
| 复杂度 | 低 | 中 | 高 |
| 适用场景 | 读多写少、数据量小 | 读多写少、需要高可用 | 数据量大、写并发高 |
🎯 我常说的选择逻辑:
- 小项目、数据量小就用哨兵,够用、运维简单。
- 一旦预计数据规模上 TB,或写 TPS 要上万,直接上 Cluster。
- Cluster 并非完美,批量操作、事务、Lua 脚本都受跨槽限制,所以要合理设计键和 hash tag。
😊 这样捋下来,你心里应该有张很清晰的进化路线图了:
主从复制(数据副本)→ 哨兵(自动故障转移)→ Cluster(分片 + 自动故障转移)。
面试官常追问的 3 个问题 💡
Q:主从复制中,Master 和 Slave 网络断开后重连会发生什么?
- A:如果 Slave 断开时间较短,Master 的
repl_backlog_buffer中还保留着断开期间的写命令,会进行增量复制;如果断开时间较长,repl_backlog_buffer已经被覆盖,则会进行全量复制。
- A:如果 Slave 断开时间较短,Master 的
Q:哨兵模式中,为什么需要至少 3 个 Sentinel 节点?
- A:为了防止脑裂问题。如果只有 2 个 Sentinel 节点,当网络分区时,每个 Sentinel 都认为自己是唯一的,会各自进行故障转移,导致出现两个 Master。3 个节点可以保证超过半数(2 个)达成一致。
Q:Redis Cluster 为什么是 16384 个哈希槽?
- A:主要是为了节省网络带宽。Redis 节点之间需要定期交换槽位信息,16384 个槽位用 2KB(16384 bits)就可以表示,而如果是 65536 个槽位则需要 8KB。同时,Redis 作者认为一般不会有超过 1000 个 Master 节点,16384 个槽位足够分配。
缓存穿透、击穿、雪崩原因及解决方案
(面试官视角:这道题是缓存必考题,能看出你有没有实际线上踩坑经验,回答时一定要先讲现象→再讲原因→最后给落地解决方案,别上来就堆名词)
核心概念速览表 📊
| 问题类型 | 核心现象 | 根本原因 | 数据库影响 |
|---|---|---|---|
| 缓存穿透 | 查询不存在的数据 | 缓存和数据库都没有这条记录 | 每次请求都直接打数据库 |
| 缓存击穿 | 单个热点 key过期 | 高并发下热点 key 同时失效 | 瞬间大量请求打数据库 |
| 缓存雪崩 | 大量 key同时过期或缓存泵机 | 大面积缓存失效 / 服务不可用 | 数据库压力骤增甚至泵机 |
记好这张图,面试时先画图再说话,效果加倍 ✍️。
逐个击破详解 🔨
1. 缓存穿透 🕳️
现象:黑客恶意攻击,查询一些根本不存在的用户 ID(如id=-1、id=999999),缓存查不到,每次都去查数据库。
- 解决方案(按优先级排序):
- ✅ 布隆过滤器(Bloom Filter):把所有存在的 key 预先存入布隆过滤器,请求先过过滤器,不存在直接返回。这是最常用的工业级方案
- ✅ 缓存空值 / 默认值:数据库查不到也缓存一个空对象,设置较短过期时间(如 5 分钟)
- ❌ 不推荐:接口层增加参数校验(只能防简单攻击,防不了恶意构造的参数)
1️⃣ 布隆过滤器(Bloom Filter)🪄
用很小的内存空间,快速判断一个 key 是否一定不存在。如果布隆过滤器说“没有”,直接返回空,根本不去查数据库。
- 优点:内存极省,拦截在缓存之前。
- 缺点:存在小概率误判(会放过少量合法请求),删除数据麻烦。
Java 里可以用 Guava 的 BloomFilter 或 Redisson 的 RBloomFilter。
// 伪代码示例
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("userFilter");
bloomFilter.tryInit(1000000L, 0.03); // 预估容量,误判率 3%
public User getUser(String id) {
if (!bloomFilter.contains(id)) {
return null; // 🚫 一定不存在,直接返回
}
// 可能存在,继续查缓存->DB
}💡 面试官追问:布隆过滤器有什么缺点?
答:存在误判率,只能说 "可能存在",不能说 "一定存在"。但对于穿透场景完全够用,因为误判的 key 本来就不存在,最多多查一次数据库。
2️⃣ 缓存空值 🕳️
即使数据库查出来是 null,也往缓存里存一个“空对象”,设置较短的过期时间(如 1~5 分钟)。这样下次同样请求直接走缓存返回 null,不再击穿到 DB。
- 优点:实现简单。
- 缺点:如果 key 很多且不重复,会占用一些内存;必须设置短过期来保证一致性。
最佳实践:两种可以结合:布隆过滤器前置过滤 + 缓存空值兜底。
2. 缓存击穿 ⚡
现象:比如双 11 零点,某个爆款商品的缓存刚好过期,瞬间几十万请求同时打到数据库。
- 解决方案:
- ✅ 热点 key 永不过期:物理上不设置过期时间,后台异步更新缓存
- ✅ 互斥锁(Mutex):第一个请求获取锁去查数据库并更新缓存,其他请求等待缓存更新完成。推荐使用 Redis 的 SETNX 命令实现
- ✅ 提前预热:活动开始前,手动把热点数据加载到缓存中
💡 面试官追问:互斥锁有什么问题?答:如果第一个请求挂了,锁会一直持有,导致所有请求都阻塞。所以一定要给锁设置过期时间。
1️⃣ 互斥锁(Mutex)🔒
当缓存未命中时,不是所有请求都去数据库查,而是让抢到锁的那个请求去查 DB 并重建缓存,其他请求等待重试或返回空。
Java 里可以用 Redis 的 SETNX 命令,或者直接使用 Redisson 的分布式锁。
public String getHotData(String key) {
String value = redis.get(key);
if (value == null) {
// 尝试加锁,锁粒度最好是 key 级别
RLock lock = redisson.getLock("lock:" + key);
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
try {
// 双重检查
value = redis.get(key);
if (value == null) {
value = db.query(key);
redis.set(key, value, 30, TimeUnit.MINUTES);
}
} finally {
lock.unlock();
}
} else {
// 没拿到锁,稍等重试或返回降级数据
Thread.sleep(50);
return getHotData(key); // 重试
}
}
return value;
}2️⃣ 逻辑过期(不设物理过期)🧠
热点 key 不设置 Redis 的 TTL(永久有效),而在 value 里放一个 expireTime 字段表示逻辑过期时间。请求发现逻辑过期后,也是单一线程去重建缓存,其他线程直接返回旧值,互不阻塞。
- 优点:不会因为过期引发突然的数据库压力,体验丝滑。
- 缺点:实现稍复杂,需要维护逻辑时间和异步更新线程。
3. 缓存雪崩 🌋
现象:最严重的缓存问题!比如 Redis 集群挂了,或者大量 key 在同一时间过期,数据库瞬间被打垮,然后引发连锁反应,整个系统崩溃。
两种诱因:
- 大批量 key 设置了相同的过期时间;
- Redis 集群整体挂掉。
解决方案(多管齐下):
- ✅ 过期时间加随机值:给每个 key 的过期时间加上一个随机偏移量(如 1-5 分钟),避免同时过期
- ✅ 缓存集群高可用:搭建 Redis 主从 + 哨兵集群,或者 Redis Cluster,防止单点故障
- ✅ 服务熔断与降级:使用 Hystrix/Sentinel,当数据库压力过大时,熔断非核心接口,返回默认值
- ✅ 多级缓存:本地缓存(Caffeine)+ Redis 缓存,即使 Redis 挂了,本地缓存还能顶一阵
主从 + 哨兵或 Redis Cluster,保证即使个别节点崩溃也能快速自动切换,避免整体不可用。
一句话总结 📝
- 穿透:查不存在的
→布隆过滤器拦着 - 击穿:单个热点挂了
→加锁或者永不过期 - 雪崩:一大片都挂了
→错开过期时间 + 集群高可用
你可以这样组织语言:
“面试官,这三个问题都源于缓存层无法正常挡住请求。
- 穿透是数据本就不存在,咱们可以用布隆过滤器做前置拦截,再用缓存空值兜底。
- 击穿是热点数据卡在过期的瞬间,必须保证只有一个线程去重建缓存,用分布式锁或逻辑过期把重建压力降到最低。
- 雪崩是大面积失效,一方面靠过期时间加随机值打散,另一方面必须做多级缓存和熔断降级保证整体高可用。
实际落地时,Redisson + Caffeine + Sentinel 这一套组合拳基本能覆盖多数场景。”
分布式锁实现:SETNX、Redisson、RedLock
面试官您好,关于分布式锁的这三种实现方式,我会从原理、优缺点、踩坑点和适用场景四个维度给您详细说明。
Redis 原生 SETNX 实现 🔑
很多同学一上来就说“用 Redis 的 SETNX 就行”,结果上线就翻车 😱。咱们从最基础的开始,一层层看是怎么演进的。
核心原理
最原始的分布式锁实现,利用 Redis 的SETNX(SET if Not eXists)命令:
# 正确写法(原子操作)
SET lock_key unique_value NX EX 30NX:只有 key 不存在时才设置成功(获取锁)EX 30:设置 30 秒过期时间(防止死锁)unique_value:每个客户端生成的唯一标识(防止误删别人的锁)
释放锁(必须用 Lua 脚本保证原子性)
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end优缺点总结
| 优点 ✅ | 缺点 ❌ |
|---|---|
| 简单粗暴,原生支持,无第三方依赖 | 不可重入(同一个线程多次获取会死锁) |
| 性能高,Redis 单线程原子性保证 | 无自动续期(业务执行超时会导致锁提前释放) |
| 理解成本低,排查问题方便 | 单点故障(Redis 主节点挂了,锁会丢失) |
| 释放锁必须用 Lua 脚本,否则有并发问题 |
面试踩坑点 💣
- ❌ 错误写法 1:
SETNX + EXPIRE分开写(非原子操作,中间 Redis 挂了会死锁) - ❌ 错误写法 2:释放锁时不判断 value(会误删其他客户端的锁)
- ❌ 错误写法 3:过期时间设置过短(业务没执行完锁就释放了)
Redisson 分布式锁 🏆
核心原理
Redisson 是目前最成熟的 Java 分布式锁框架,它解决了 SETNX 的所有痛点:
- 可重入锁:基于 Hash 结构实现,记录线程 ID 和重入次数
- 看门狗机制:自动续期,业务没执行完锁不会释放
- 原子操作:所有命令都用 Lua 脚本保证原子性
RLock lock = redisson.getLock("myLock");
lock.lock(); // 就是这么简单可重入锁实现流程图
看门狗机制详解 🐶
- 默认过期时间:30 秒
- 续期间隔:10 秒(过期时间的 1/3)
- 触发条件:只要线程还持有锁,就会自动续期
- 停止条件:锁被释放 或 客户端宕机
💡 一个小坑:如果你自己指定了 leaseTime,看门狗就睡觉去了,不会再自动续期,需要自己把控业务超时。
❌ 主从切换丢锁问题
Redisson 默认只用单节点 Redis。如果主节点刚写入了锁,还没来得及同步给从节点就宕机了,从节点被提为主,此时另一个客户端就能拿到同一把锁——锁互斥性被打破 🔓。
优缺点总结
| 优点 ✅ | 缺点 ❌ |
|---|---|
| 完美解决 SETNX 的所有问题 | 依赖 Redis 主从架构的一致性 |
| 支持可重入、公平锁、读写锁、联锁等 | 主节点挂了,从节点还没同步锁数据,会导致锁丢失 |
| 自动续期,不用关心过期时间 | 比原生 SETNX 性能稍差(但足够用) |
| API 简单易用,一行代码搞定 | 有一定的学习成本 |
面试高频问题 🎯
Q:Redisson 的看门狗是怎么实现的?
- A:通过 Netty 的 HashedWheelTimer(时间轮)实现定时任务,每 10 秒执行一次续期。
Q:如果客户端宕机了,看门狗还会续期吗?
- A:不会,客户端宕机后,Redis 连接会断开,看门狗线程也会停止,锁会在 30 秒后自动释放。
RedLock 红锁算法 🔴
RedLock:红锁算法,为苛刻场景而生 🏰
核心原理
RedLock 是 Redis 作者 Antirez 提出的分布式锁算法,专门解决Redis 主从架构下的锁丢失问题。核心思路:别只用一个 Redis 实例,用 N 个完全独立的 Master 节点(通常是 5 个),过半加锁成功才算成功。
算法步骤:
- 获取当前时间戳 T1
- 依次向 N 个独立的 Redis 节点申请锁(使用相同的 key 和 value)
- 如果在小于锁过期时间内,成功获取了 ** 超过半数(N/2+1)** 节点的锁,则获取锁成功
- 计算获取锁花费的时间 T2 = 当前时间 - T1
- 锁的实际有效时间 = 过期时间 - T2
- 如果获取锁失败,向所有节点释放锁
为什么有效? 即便半数以内的节点宕机,剩下的节点也能保证同一时间只有一个客户端获得多数派支持,维持互斥。Redisson 已经内置了 RedissonRedLock,可以直接使用。
不过,RedLock 也引发过激烈的争论 🧠:
- 时钟漂移:严重依赖系统时钟,一旦发生跳跃可能导致锁失效。
- GC 停顿:客户端 GC 暂停可能让锁实际超时而不自知。
- 运维复杂:部署多个独立 Redis 实例,成本与故障率都高。
很多时候,用 集群版的 Redisson 配合合理配置 已经能满足绝大多数业务需求,RedLock 更适合对一致性要求极其苛刻的金融交易等场景,且需要谨慎评估。
为什么需要 N 个独立节点?
- 解决单点故障问题:只要不是超过半数的节点同时挂掉,锁就不会丢失
- 解决主从同步问题:每个节点都是独立的,没有主从关系
优缺点总结
| 优点 ✅ | 缺点 ❌ |
|---|---|
| 解决了 Redis 单点故障问题 | 实现复杂,运维成本高 |
| 理论上安全性更高 | 性能比单节点 Redisson 锁差很多 |
| 被 Redis 官方推荐 | 存在争议(Martin Kleppmann 曾提出质疑) |
| 实际生产中很少使用 |
面试争议点 ⚠️
Martin Kleppmann 在《How to do distributed locking》一文中指出 RedLock 存在以下问题:
- 依赖系统时钟的正确性,如果节点时钟发生偏移,会导致锁提前释放
- 网络延迟会影响锁的有效性
- 无法解决 "脑裂" 问题
三者对比与选型建议 📊
| 特性 | SETNX | Redisson 单节点锁 | RedLock 红锁 |
|---|---|---|---|
| 可重入 | ❌ | ✅ | ✅ |
| 自动续期 | ❌ | ✅ | ✅ |
| 原子性 | 部分保证 | ✅ | ✅ |
| 单点故障 | ❌ | ❌ | ✅ |
| 性能 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| 复杂度 | ⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| 生产推荐度 | ⭐ | ⭐⭐⭐⭐⭐ | ⭐ |
🎯 接地气的选择建议:先上 Redisson,把看门狗和可重入锁用好。如果真的发现了主从切换导致的锁冲突,再考虑升级成 RedLock 或直接上 Zookeeper/etcd 这类 CP 系统。
总结成一句话就是:“SETNX 是砖块,Redisson 是成品套房,RedLock 是防震堡垒。” 按需选用,切忌过度设计 🏗️。
最终选型建议
- 99% 的场景:使用 Redisson 单节点锁就够了,性能好,实现简单,足够稳定
- 对数据一致性要求极高:可以考虑使用 ZooKeeper 分布式锁(虽然性能差,但一致性更好)
- 不推荐:原生 SETNX 实现(除非是非常简单的场景)和 RedLock(太复杂,收益不大)
缓存与数据库双写一致性方案
面试官您好,关于缓存与数据库双写一致性这个问题,我从问题本质、错误方案、正确方案、大厂落地四个层面来回答。
问题本质 🔍
缓存与数据库是两个独立的存储系统,无法做到原子性更新,只要有先后顺序,就必然存在时间窗口的不一致问题。
这个问题的本质是 两个数据源(Redis + MySQL)的写入不是原子操作,任何中间步骤出问题或出现并发,就会数据错乱。
最常见的场景就是 更新数据库 + 更新缓存 或 删除缓存。围绕它,业界沉淀了几套成熟方案,我按演进路线来说。”
绝对不能用的错误方案 ❌
1. 先更缓存,再更数据库
Client → 更新缓存 → 更新DB致命问题:缓存更新成功,数据库更新失败,导致缓存永久脏数据。
结论:❌ 一般不推荐直接「更新」缓存,而是「删除」缓存,让读请求来重建。
并发之坑:先删缓存,再更新数据库
(1) 删除缓存 → (2) 更新DB乍一看没问题,但考虑 读写并发:
所以 先删缓存再更新DB 有短暂的“真空期”,可能被读请求把旧数据刷回缓存。
🔑 改进:延迟双删 —— 写完DB后,sleep一会儿,再删一次缓存。
缺点:sleep时长难把控,且增加复杂度,不优雅。
2. 先更数据库,再更缓存
问题:并发场景下会出现 "写覆盖",导致数据错乱。
基础正确方案 ✅
方案 1:先更数据库,再删缓存(推荐)
核心思想:删除缓存比更新缓存更安全,下次读取时自动重建。
优点:实现简单,绝大多数场景够用缺点:极端并发下仍有短暂不一致
为什么这样更安全?
假设并发,即便发生下面的极端情况:
写操作“删缓存”在先,读操作“写回缓存”在后,但因为读的是新DB,缓存最终也是新值, 数据一致 ✅。
只有一种极端理论情况会导致脏数据:
- 读先miss,读到旧DB,然后写更新DB+删缓存,最后读再把旧值写回缓存。
- 但此场景要求读的DB发生在写之前,且写入缓存发生在删除之后,概率极低。可以通过设置 缓存过期时间 兜底。
删缓存失败了怎么办?——保证最终一致 🚀
上面方案的最大软肋:删缓存失败 → 脏数据。补救办法:
方案A:重试机制(消息队列)
更新DB → 删缓存失败 → 发送删除消息到MQ → 消费者重试删除简单但引入MQ复杂度。
方案B:订阅数据库Binlog(推荐)
- 优点:
- 业务代码无侵入,只需要关注DB本身。
- 用binlog保证“最终一致性”,即使缓存删除失败也能不断重试。
- 适合微服务架构。
强一致需求怎么玩?
如果需要 强一致性(比如金融),就不能用Cache Aside,要 读写都穿透到数据库:
- Read/Write Through:缓存层代理DB,同步更新。
- 写时通过分布式锁 让缓存更新和DB更新串行化。
- 或者干脆 不用缓存,或者使用支持事务的分布式缓存(如Tair、Aerospike某些模式)。
方案 2:延迟双删
解决 "先读缓存失败,再读数据库,此时缓存被删除,导致旧数据写入缓存" 的问题。
// 伪代码
public void updateData(Long id, Object newData) {
// 1. 更新数据库
db.update(id, newData);
// 2. 第一次删除缓存
redis.del(key);
// 3. 延迟500ms-1s后再次删除
threadPool.schedule(() -> redis.del(key), 500, TimeUnit.MILLISECONDS);
}为什么延迟? 等待读请求完成旧数据的写入,然后再删除一次。
进阶强一致性方案 🚀
方案 1:基于 Canal 的异步更新
原理:监听数据库 binlog,异步删除 / 更新缓存。
- 优点:
- 解耦业务代码
- 保证最终一致性
- 适合高并发场景
方案 2:分布式锁
原理:读写操作都加分布式锁,保证同一时间只有一个线程操作。
优点:强一致性
缺点:性能差,不适合高并发读场景
大厂实际落地经验 💡
| 方案 | 一致性级别 | 性能 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 先更库再删缓存 | 最终一致 | 高 | 低 | 绝大多数业务 |
| 延迟双删 | 最终一致 | 高 | 低 | 并发较高的业务 |
| Canal+MQ | 最终一致 | 高 | 中 | 高并发、大流量 |
| 分布式锁 | 强一致 | 低 | 高 | 金融、支付等强一致性要求 |
面试加分项 ⭐
- 缓存过期时间兜底:无论什么方案,都要给缓存设置合理的过期时间,作为最终兜底。
- 重试机制:删除缓存失败时,要有重试机制(如 MQ 重试)。
- 读写分离场景:主从同步延迟会导致不一致,建议 Canal 监听从库 binlog。
- 大 key 问题:删除大 key 时要注意性能问题,避免阻塞 Redis。
- 不要更新缓存,要删除缓存,避免双写顺序问题。
- 标准答案 Cache Aside:先更DB,后删缓存,配合过期时间,能扛住99%场景。
- 兜底方案 Binlog异步删缓存,实现最终一致性,解耦业务。
总结 📝
- 没有绝对完美的方案,只有最适合业务的方案
- 99% 的场景用 "先更数据库,再删缓存 + 过期时间" 就够了
- 高并发场景用 Canal+MQ 异步删除
- 强一致性场景才考虑分布式锁
🔥 一图胜千言:Cache Aside 标准写流程
Redis 6.0/7.0 新特性:多线程 IO、ACL、Redis Functions
面试官您好!我来梳理一下 Redis 6.0 和 7.0 中最核心的三个新特性:多线程 IO、ACL 权限控制和 Redis Functions。这三个特性分别解决了 Redis 在性能瓶颈、安全管控和业务逻辑原子性方面的核心痛点。
先定个调:这三个特性不是独立的炫技,而是分别瞄准了性能、安全、可编程性的短板,下面逐个拆解。
Redis 6.0 多线程 IO 🧵
核心解决的问题
Redis 单线程模型在处理高并发网络 IO时成为性能瓶颈,CPU 单核跑满但多核闲置。
很多人一听“多线程”就以为 Redis 变成多线程执行命令了,大错特错。
Redis 6.0 的多线程只用来处理网络读写,命令执行永远是单线程。
为什么需要?
旧版本里网络读写也全压在单线程上,当 QPS 到 10w+ 时,CPU 都在忙着 read/write 系统调用,挤压了真正执行命令的时间。多线程 IO 把协议解析、回复写套接字这些重活分摊出去,主线程只跑核心命令。
关键配置(redis.conf):
io-threads 4 # IO 线程数,建议设成 CPU 核数 * 0.75
io-threads-do-reads yes # 开启读多线程,默认只多线程写⚠️ 注意:线程数不是越多越好,超过 4~6 个后上下文切换成本会反噬性能。
实现原理(面试必考点)
关键点:
- ✅ 命令执行仍然是单线程(保证原子性,无需加锁)
- ✅ 仅将网络 IO 读写和协议解析交给多线程处理
- ✅ 可通过
io-threads配置线程数,建议设置为 CPU 核心数的一半 - ✅ 性能提升:在高并发场景下,QPS 可提升2-3 倍
常见面试追问
- 为什么不把命令执行也做成多线程?
→避免复杂的锁机制,保持 Redis 简单高效的设计哲学 - 多线程 IO 在什么场景下效果最明显?
→大 value 读写、高并发短连接场景
Redis 6.0 ACL 权限控制 🔐
核心解决的问题
之前 Redis 只有一个全局密码,无法实现细粒度的权限控制,存在严重的安全隐患。
ACL(访问控制列表) 让你可以定义多个用户 + 不同权限,直接对标数据库的 RBAC。
核心能力对比
| 特性 | **Redis 5.x 及以前 ** | Redis 6.0+ ACL |
|---|---|---|
| 用户管理 | 仅一个默认用户 | 支持创建多个独立用户 |
| 权限粒度 | 全有或全无 | 按命令、按 key、按数据库控制 |
| 认证方式 | 单一密码 | 用户名 + 密码 |
| 安全风险 | 极高 | 可控 |
常用命令示例
# 创建用户并设置权限
ACL SETUSER alice on >password123 ~cache:* +@read +@write -@dangerous
# 查看用户权限
ACL LIST
# 删除用户
ACL DELUSER alice# 实战示例:创建一个只读的监控用户
ACL SETUSER monitor on >readonly ~* +@read +INFO +SLOWLOG这样 monitor 用户只能读数据、看 INFO 和慢日志,删库?门都没有。🚫
对 Java 开发的影响:
Spring Data Redis / Lettuce 客户端天然支持 RedisURI 传入用户名密码,不用再裸传 requirepass 了。
关键点:
- ✅ 权限语法:~表示 key 前缀,+@表示允许命令类别,-@表示禁止命令类别
- ✅ 支持将权限配置持久化到users.acl文件
- ✅ 生产环境必须禁用默认用户,创建最小权限用户
Redis 7.0 Redis Functions 📦
核心解决的问题
Lua 脚本存在无法持久化、无法版本管理、集群环境下执行复杂等问题。
如果 Lua 脚本是手写纸条,那 Functions 就是装订成册的函数库。
7.0 引入 Functions,本质是有名字、可持久化、可版本管理的服务端脚本。
为什么干掉 EVAL?
- EVAL 脚本是匿名字符串,每次执行都要传输脚本体,浪费带宽
- 副本传播靠复制脚本本身,不是复制写命令,主从不一致风险高
- 函数写成库文件,用
FUNCTION LOAD加载,持久化到 AOF/ RDB,重启还在 - 函数有
FCALL调用,只需传函数名和参数,轻量 - 支持
no-writes标记,明确只读函数可以放在只读副本上执行
架构对比一目了然:
| 特性 | Lua EVAL | Redis Functions |
|---|---|---|
| 名称 | 无,匿名脚本 | 有名函数(库.函数) |
| 持久化 | 不持久,重启丢失 | 自动持久化到 AOF/RDB |
| 传输成本 | 每次发送脚本体 | 仅发送调用参数 |
| 副本复制 | 复制脚本本身 | 复制实际写命令,一致性高 |
| 只读安全 | 无标记 | no-writes 标记可安全在只读副本跑 |
创建一个函数库:
#!lua name=mylib
redis.register_function('myincr', function(key, amount)
return redis.call('INCRBY', key, amount)
end)加载后调用:
FUNCTION LOAD "$(cat mylib.lua)"
FCALL myincr 1 counter 10Java 侧使用:
Lettuce 6.3+ 提供了 RedisFunctionsCommands,可以直接 fcall,代码更干净。
Lua 脚本 vs Redis Functions 对比
简单示例
-- 定义一个函数库
#!lua name=mylib
redis.register_function('get_with_ttl', function(keys, args)
local key = keys[1]
local value = redis.call('GET', key)
local ttl = redis.call('TTL', key)
return {value, ttl}
end)# 加载函数库
FUNCTION LOAD REPLACE "上面的Lua代码"
# 调用函数
FCALL get_with_ttl 1 mykey关键点:
✅ 函数库持久化到 RDB 和 AOF 文件,重启后自动加载
✅ 集群模式下,函数库会自动同步到所有主节点
✅ 支持函数版本管理,可通过FUNCTION LIST查看
✅ 性能比 Lua 脚本略高,因为无需每次传输和编译
多线程 IO:不是多线程执行,是网络层帮手,默认只开写线程,要开读线程需配置。
ACL:安全粒度从大门升级到门禁卡,可限制命令、Key模式、频道,权限最小化。
Redis Functions:有身份、有户口、能继承(持久化),比 EVAL 更符合生产级脚本治理。
Redis 6.0 Redis 7.0
| |
多线程 IO + ACL 新增 Functions
解决性能与安全 解决脚本可维护性
| |
+----------+----------+
|
企业级特性闭环面试加分总结 ✨
这三个特性代表了 Redis 从 "简单缓存" 向 "企业级数据存储" 演进的关键步骤:
- 多线程 IO解决了性能天花板问题
- ACL解决了企业级安全管控问题
- Redis Functions解决了复杂业务逻辑在 Redis 端的原子性执行问题
在实际项目中,我会根据业务场景合理使用这些特性,比如在高并发系统中开启多线程 IO,在多团队共用的 Redis 集群中严格使用 ACL 权限控制,在需要原子性执行多个 Redis 命令的场景下优先使用 Redis Functions 替代 Lua 脚本。
