Redis源码与实战剖析小结
# 写在文章开头
Redis 作为面试和实战中的高频技术栈,涵盖数据结构、持久化、内存管理、网络模型等多个维度。本文梳理了 Redis 核心知识点,适合准备面试或快速回顾知识体系。
本文覆盖内容:
- 基础原理:单线程设计、IO 多路复用、Pipeline
- 数据结构:SDS、Zset、GEO、Stream、布隆过滤器
- 可靠性:持久化机制、内存淘汰策略、原子性保证
- 实战应用:分布式锁、消息队列、限流实现
你好,我是 SharkChili ,Java Guide 核心维护者之一,对 Redis、Nightingale 等知名开源项目有深度源码研究经验。熟悉 Java、Go、C 等多语言技术栈,现任某知名黑厂高级开发工程师,专注于高并发系统架构设计与性能优化。
🌟 开源项目贡献
- mini-redis:教学级 Redis 精简实现,助力分布式缓存原理学习
🔗 https://github.com/shark-ctrl/mini-redis (opens new window)(欢迎 Star & Contribute)
📚 公众号价值 分享企业级架构设计、性能优化、源码解析等核心技术干货,涵盖分布式系统、微服务治理、大数据处理等实战领域,并探索面向AI的vibe coding等现代开发范式。
👥 加入技术社群 关注公众号,回复 【加群】 获取联系方式,与众多技术爱好者交流分布式架构、微服务等前沿技术!
# 详解redis基础知识点
# 为什么Redis被设计成是单线程的
Redis 采用单线程设计,主要基于以下原因:
- 内存操作,CPU 不是瓶颈:Redis 数据完全存于内存,内存操作耗时约 100 纳秒(ns),属于极轻量级操作
- 避免锁竞争:如果引入多线程并发操作,必须加锁保护,而锁的引入会导致线程阻塞等待,反而降低吞吐量
- 简化实现:单线程模型避免了复杂的并发控制,代码更简洁,bug 更少
核心结论:内存操作极快,多线程带来的计算能力提升对 Redis 意义不大,反而徒增复杂度
更多关于源码的解释可以参考笔者写的这篇文章:
# 为什么 Redis 单线程也能这么快
- IO 多路复用:通过 epoll/select/kqueue 等机制,单线程可同时处理海量并发连接,避免为每个连接创建线程的开销
- 数据结构极致优化:
- SDS:O(1) 获取长度,二进制安全
- ziplist / skiplist:按数据量自动切换编码,平衡内存与性能
- 整数集合、压缩列表等紧凑编码,减少内存开销
- 纯内存操作:数据存于内存,耗时约 100ns,无磁盘 I/O 阻塞
- 无锁模型:单线程串行执行命令,无需加锁,无死锁风险,无同步原语开销
补充:Redis 6.0 引入多线程后,网络 I/O 和协议解析也由多线程处理,进一步释放了单线程的数据处理能力
更多关于源码的解释可以参考笔者写的这两篇文章:
- 详解redis单线程设计思路: https://mp.weixin.qq.com/s/v8LJqvtPMukv2g6CHr948Q (opens new window)
- 如何理解redis是单线程的:https://mp.weixin.qq.com/s/8BHPnoPAbx2eIAOeZCzPJw (opens new window)
# Redis 管道 Pipeline
Pipeline 是redis客户端内置的一个优化设计,只需通过换行符分隔键入多指令,即可批量发送多条指令,减少网络往返次数(RTT)。

当我们键入回车批量发送指令后,其指令流程为:
- 客户端将多条命令打包,一次性发送到服务端
- 服务端依次执行,缓存结果
- 最后一次性返回所有执行结果

对应性能优势为:
| 优化点 | 无 Pipeline | 有 Pipeline |
|---|---|---|
| RTT | N 条命令 = N 次 RTT | N 条命令 = 1 次 RTT |
| 系统调用 | N × 2 次(读+写) | 1 × 2 次 |
注意事项:
- Pipeline 不保证原子性,如果需要原子性,应使用 Lua 脚本
- 与事务(MULTI/EXEC)的区别:事务保证原子性(但不自动回滚),Pipeline 只优化网络
对于pipeline实现感兴趣的读者,可以查看redis-cli.c中的相关代码:
/* Pipeline 核心流程 */
while(repeat--) {
// 1. 命令追加到客户端缓冲区(此时无网络 I/O)
redisAppendCommandArgv(context, argc, (const char**)argv, argvlen);
// 2. 读取响应时自动发送所有缓冲命令
if (cliReadReply(output_raw) != REDIS_OK) {
return REDIS_ERR;
}
}
2
3
4
5
6
7
8
9
10
关键点:
redisAppendCommandArgv():仅将命令追加到context->obuf(sds 动态字符串),不触发网络发送cliReadReply()→redisGetReply():首次调用时通过redisBufferWrite()一次性发送所有缓冲命令- 多个命令共享一次网络往返,大幅减少 RTT
# Redis 如何保证命令原子性
Redis 保证原子性的方式主要有三种:
1. 原子命令
Redis 提供了一系列单指令原子操作,将 RMW(Read-Modify-Write)合并为一条命令,无需额外加锁。例如:
INCR/DECR:原子递增/递减SETNX:仅当 key 不存在时设置(相当于"检查再写入")
这是最简单也是最安全的原子性保证方式。
2. 单线程串行执行
由于 Redis 采用单线程模型处理所有客户端请求,每个命令都是串行执行的,不存在并发竞争。这意味着:
- 当客户端 A 执行某个命令时,客户端 B 的请求必须等待
- 从结果上看,命令执行是互斥的,天然保证了原子性
3. 分布式锁
当业务逻辑无法用单条原子命令完成时,需要借助分布式锁保护临界资源。
基本流程是:先用 SETNX 尝试获取锁,成功后执行业务逻辑,最后释放锁。代码示例如下:
// 获取锁(使用唯一值作为 value,便于后续校验)
String uniqueValue = UUID.randomUUID().toString();
String lockKey = "distribute:lock:order";
String result = jedis.set(lockKey, uniqueValue, "NX", "PX", 30000);
if ("OK".equals(result)) {
try {
// 业务逻辑
doSomething();
} finally {
// 释放锁(必须校验 value,避免误删他人的锁)
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
jedis.eval(script, 1, lockKey, uniqueValue);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
需要特别注意的是,分布式锁必须解决两个问题:
第一,死锁问题:如果持有锁的进程崩溃或网络中断,锁将永远无法释放。因此必须给锁设置超时时间(TTL),让锁自动过期。
第二,误删问题:假设客户端 A 持有锁,客户端 B 因为超时或网络原因无法完成业务,此时锁自动释放了,客户端 C 获取了锁。但客户端 A 业务完成后仍然会执行 DEL,可能误删客户端 C 的锁。解决方案是:在释放锁时先校验 value,只有值匹配才执行删除。
4. Lua 脚本
如果业务需要执行多条 Redis 命令,可以将它们写在 Lua 脚本中。Redis 会把整个 Lua 脚本作为原子操作执行,执行过程中不会被其他命令打断:
local current = redis.call("incr", KEYS[1])
if tonumber(current) == 1 then
redis.call("expire", KEYS[1], 60)
end
return current
2
3
4
5
生产环境中推荐使用 Redisson 等成熟库。以 Redisson 为例,它内部封装了完整的分布式锁实现:
- Lua 脚本:获取锁、释放锁等核心操作都通过 Lua 脚本保证原子性,无需手写
- 看门狗机制:如果业务执行时间超过锁 TTL,Redisson 会启动定时任务自动续期,避免锁提前过期导致业务失败
- 唯一值校验:每次加锁都会设置唯一标识,释放锁时自动校验,避免误删他人持有的锁
相比手写分布式锁,使用 Redisson 等成熟库更安全、更可靠。
# Redis 使用什么协议进行通信
Redis 采用 RESP(REdis Serialization Protocol)协议进行通信,这是 Redis 自己设计的一种文本协议,用于客户端与服务端之间的请求响应。
RESP 协议具有以下特点:
简单:协议基于 ASCII 编码,易于阅读和调试,例如用 telnet 就能直接与 Redis 交互。
高效:协议设计简洁,解析速度快,序列化/反序列化开销低。
易于解析:客户端只需按规则读取首字节判断数据类型即可。
二进制安全:使用长度前缀而非特殊字符(如 \0)标识边界,可以处理包含任意字节的数据。
RESP2 支持的数据类型包括:
| 类型标识 | 说明 | 示例 |
|---|---|---|
| + | 简单字符串 | +OK\r\n |
| - | 错误信息 | -ERR unknown command\r\n |
| : | 整数 | :1000\r\n |
| $ | 批量字符串 | $5\r\nhello\r\n |
| * | 数组 | *3\r\n:1\r\n:2\r\n:3\r\n |
实际使用中,客户端库(如 Jedis、Lettuce)会封装这些细节,开发者通常无需直接处理协议解析。
# Redis 支持哪几种数据类型
Redis 的数据类型可以分为两大类:
基本数据类型(5 种):
- String(字符串):最基础的数据类型,可存储字符串、整数或浮点数
- List(列表):按插入顺序存储的字符串列表,支持从两端操作
- Set(集合):无序且不重复的字符串集合,支持交集、并集等集合运算
- ZSet(有序集合):每个元素关联一个分数,按分数排序,适用于排行榜场景
- Hash(哈希):键值对集合,适合存储对象
特殊数据类型(4 种):
- Bitmap(位图):按位操作,适合做签到、用户在线状态统计
- Geo(地理位置):存储经纬度坐标,支持距离计算、范围查询
- HyperLogLog:用于基数统计,占用空间极小,适合 UV 统计
- Stream(流):5.0 引入的消息队列,支持持久化和消费组
# Redis 为什么要自己定义 SDS
SDS(Simple Dynamic Strings)是 Redis 自定义的字符串数据结构,相比 C 字符串有以下优势:
1. 二进制安全
C 字符串以 \0 结尾,如果存储二进制数据(如序列化对象、图片),中间可能包含 \0,strlen() 等函数会在第一个 \0 处截断。SDS 使用长度字段而非 \0 判断边界,可以存储任意字节。
2. O(1) 获取长度
C 字符串获取长度需要遍历到 \0,时间复杂度 O(n)。SDS 在结构体中维护 len 字段,读取长度为 O(1)。
以 SDS 8 为例,结构体定义如下:
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* 已使用的字节数,读取长度直接返回此字段 → O(1) */
...
char buf[]; /* 柔性数组,存放实际字符串内容 */
};
2
3
4
5
可以看到,len 字段紧邻 buf[],获取字符串长度只需读取 len 字段,无需遍历,strlen() 的调用被替换为直接读取结构体中的 len 字段。
3. 空间预分配与惰性释放
C 字符串拼接时,如果目标缓冲区空间不足,会溢出。SDS 会先检查空间,不够时自动扩容,安全可靠。更进一步,SDS 在扩容时会多分配一些空间(空间预分配),避免每次拼接都扩容;对于缩短操作,不会立即释放多余空间(惰性释放),方便下次复用,从而提升字符串处理效率。
以 sdscatlen 为例,可以看到扩容的核心逻辑:
sds sdscatlen(sds s, const void *t, size_t len) {
size_t curlen = sdslen(s);
/* 关键:空间不够时自动扩容 ⭐ */
s = sdsMakeRoomFor(s, len);
if (s == NULL) return NULL;
memcpy(s+curlen, t, len); /* 复制数据 */
sdssetlen(s, curlen+len); /* 更新长度 */
s[curlen+len] = '\0'; /* 添加结束符 */
return s;
}
2
3
4
5
6
7
8
9
10
11
12
再看惰性释放的实现,sdsclear 只是将 len 置为 0,不释放内存:
void sdsclear(sds s) {
sdssetlen(s, 0); /* 只重置长度,不释放内存! */
s[0] = '\0'; /* 字符串结束符 */
}
2
3
4
这样设计的好处是:下次 sdscatlen 追加数据时,可以直接复用已分配的缓冲区,避免频繁 malloc/free。
# Redis 中的 Zset 是怎么实现的
Zset(有序集合)的底层实现根据数据量大小分为两种编码:
小数据量:使用 listpack(3.2+)或 ziplist(3.2 之前),紧凑存储,节省内存。
大数据量:使用 skiplist + dict 组合结构:
- dict(哈希表):存储 member → score 映射,用于 O(1) 查找某成员的分数
- skiplist(跳表):按分数有序存储 member,用于 O(logN) 的范围查询和排序
为什么要同时用 dict 和 skiplist?因为:
- 单独用 skiplist:按分数查 member 高效,但按 member 查分数需要 O(n)
- 单独用 dict:按 member 查分数高效,但无法保证有序
- 两者组合:各取所长,既能 O(1) 查分数,又能 O(logN) 范围查询
对应源码结构体:
typedef struct zset {
dict *dict; /* member → score 映射,实现 O(1) 查找分数 */
zskiplist *zsl; /* 跳表,按分数有序存储,实现 O(logN) 范围查询 */
} zset;
2
3
4
7.0 之后完全移除了 ziplist/listpack,Zset 只使用 skiplist + dict 实现
# 为什么Redis 6.0引入了多线程
Redis 处理能力(QPS)约 8w-10w,对于高并发场景,瓶颈并非数据处理,而是网络 I/O:
| 操作类型 | 典型耗时 | 量级 |
|---|---|---|
| 内存操作 | ~100 纳秒 (ns) | 10⁻⁷ 秒 |
| 跨机房网络 RTT | ~0.5-1 毫秒 (ms) | 10⁻³ 秒 |
差距高达 1000-10000 倍,真正的瓶颈在于网络等待,而非数据处理。
虽说 Redis 采用 epoll 等多路复用技术,但 epoll 本质还是同步阻塞 IO,线程在等待网络 I/O 时无法处理其他任务。因此 Redis 6.0 引入多线程,专门处理网络 I/O 和协议解析,让主线程专注于纯内存的数据操作:

核心结论:多线程解决的是「等」的问题,而非「算」的问题,核心执行命令依然是单线程
# 为什么 Lua 脚本可以保证原子性
Redis 在执行 Lua 脚本时,会将整个脚本作为单个原子操作执行。执行过程中:
- 不会处理其他客户端命令
- 脚本中的所有命令串行执行,不会被中断
因此,Lua 脚本中的多条命令可以保证原子性执行。如果脚本执行失败,Redis 不会自动回滚已执行的修改,需要业务方自行处理。
以下是 evalGenericCommand 的核心源码,印证了 Lua 脚本的执行过程:
/* 将错误处理器和 Lua 函数压栈 */
lua_getglobal(lua, "__redis__err__handler");
lua_getglobal(lua, funcname);
/* 通过 lua_pcall 执行 Lua 函数 ⭐ */
err = lua_pcall(lua, 0, 1, -2); /* 参数:0个入参,1个返回值,错误处理函数在栈顶-2 */
2
3
4
5
6
关键点在于 lua_pcall:它会调用 Lua 函数,函数内部通过 redis.call() 执行 Redis 命令。
底层执行流程:
redis.call("DBSIZE")在 Lua 中被解析- Redis 服务端通过伪客户端(fake client)模拟执行命令
- 伪客户端调用实际的 Redis 命令函数,如
DBSIZE→dbsizeCommand() - 命令执行结果通过 Lua 虚拟机栈返回给 Lua 脚本
整个执行过程中,只有当 lua_pcall 返回后,Redis 主线程才会继续处理其他客户端请求,从而保证了 Lua 脚本的原子性。

# setnx 命令为什么是原子性的
SETNX key value 是原子操作,原因有两点:
- 单条指令:SETNX 是单条 Redis 指令,检查(key 是否存在)和插入在服务端一次性完成,无需客户端参与
- 单线程执行:Redis 单线程串行处理所有命令,天然保证互斥执行
因此,SETNX 本身就是原子操作,无需额外加锁。
# Redis 5.0中的 Stream是什么
5.0版本新增的数据结构,主要用于处理有序且可追朔的消息流,每个消息都有唯一的id,按照添加顺序进行排序,并且开发人员可以从中添加、读取和删除消息,同时它还是支持让多个消费者并发的处理消息流。 在5.0之前redis通过使用发布订阅模型实现消息队列,但缺点是不支持持久化,如果出现网络断开、redis宕机等情况,就会造成消息丢失。 而stream提供了消息持久化和主从复制功能保证消息不丢失,保证客户端可以访问任何时刻的数据,并且还能记住访问位置。
总的来说,stream有几个几个优点:
- 有序性
- 多消费者支持
- 持久化
- 支持消息分组
以下是 Jedis 操作 Stream 的完整示例:
@Test
public void testStreamProduceAndConsume() {
try (JedisPool jedisPool = new JedisPool(REDIS_HOST, REDIS_PORT);
Jedis jedis = jedisPool.getResource()) {
// ========== XGROUP CREATE ==========
// 对应指令:XGROUP CREATE stream-key group-name id|$|
// 作用:创建消费者组,id 指定从哪条消息开始消费
// - "0-0":从第一条消息开始
// - "$":只消费新消息(已有消息不消费)
try {
jedis.xgroupCreate(STREAM_KEY, GROUP_NAME, new StreamEntryID("0-0"), true);
} catch (Exception e) {
// 消费者组已存在,忽略异常
}
// ========== XADD ==========
// 对应指令:XADD stream-key * field1 value1 field2 value2 ...
// 作用:追加消息到流,返回自动生成的 Message ID(格式:时间戳-序号)
// StreamEntryID.NEW_ENTRY 表示自动生成 ID
for (int i = 1; i <= 3; i++) {
Map<String, String> message = new HashMap<>();
message.put("orderId", "1000" + i);
message.put("amount", String.valueOf(100 * i));
StreamEntryID messageId = jedis.xadd(STREAM_KEY, StreamEntryID.NEW_ENTRY, message);
System.out.println("XADD - 消息 ID: " + messageId);
}
// ========== XREADGROUP ==========
// 对应指令:XREADGROUP GROUP group-name consumer-name COUNT n BLOCK ms STREAMS stream-key >
// 作用:从消费组读取消息
// - ">" 表示只读未过分配的消息(NEW MESSAGES)
// - 不带 ">" 则读取 PENDING 列表中的消息(OLD MESSAGES,用于故障恢复)
Map<String, StreamEntryID> streams = new HashMap<>();
streams.put(STREAM_KEY, StreamEntryID.UNRECEIVED_ENTRY); // UNRECEIVED_ENTRY = ">"
XReadGroupParams params = new XReadGroupParams().count(10).block(3000);
List<Map.Entry<String, List<StreamEntry>>> messages = jedis.xreadGroup(
GROUP_NAME, CONSUMER_NAME, params, streams);
if (messages != null) {
for (Map.Entry<String, List<StreamEntry>> streamEntry : messages) {
for (StreamEntry entry : streamEntry.getValue()) {
// ========== XACK ==========
// 对应指令:XACK stream-key group-name message-id
// 作用:确认消息已成功处理,从 PENDING 列表移除
System.out.println("XREADGROUP - 收到消息: " + entry.getFields());
jedis.xack(STREAM_KEY, GROUP_NAME, entry.getID());
System.out.println("XACK - 确认消息: " + entry.getID());
}
}
}
// ========== XINFO ==========
// 对应指令:XINFO STREAM stream-key
// 作用:查看 Stream 元信息(长度、最后消息 ID、消费组信息等)
StreamInfo streamInfo = jedis.xinfoStream(STREAM_KEY);
System.out.println("XINFO - Stream 长度: " + streamInfo.getLength());
System.out.println("XINFO - 首条消息: " + streamInfo.getFirstEntry());
System.out.println("XINFO - 末条消息: " + streamInfo.getLastEntry());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# Redis 的持久化机制是怎样的
Redis 支持两种持久化方式:RDB 和 AOF,可以同时开启。
1. RDB(Redis Database)
RDB 通过定时快照的方式,将内存数据以二进制格式写入磁盘。特点是:
- 文件小:紧凑的二进制格式,适合备份和灾难恢复
- 恢复快:加载速度比 AOF 快
- 可能丢数据:如果快照间隔内宕机,会丢失最近的数据
触发方式有两种:手动(SAVE/BGSAVE)或自动(满足配置条件)。
2. AOF(Append Only File)
AOF 通过记录每次写命令来恢复数据。特点是:
- 数据更完整:根据同步策略,可实现最多丢失 1 秒的数据
- 文件较大:存储所有写命令,会有冗余
AOF 有三种同步策略:
| 策略 | 说明 | 可靠性 | 性能 |
|---|---|---|---|
| always | 每次写命令都同步 | 最高 | 最低 |
| everysec | 每秒同步一次 | 中等 | 较高 |
| no | 由操作系统决定 | 最低 | 最高 |
3. 加载顺序
如果同时开启了 RDB 和 AOF,Redis 启动时优先加载 AOF,因为 AOF 数据更完整。
# Redis 的事务机制是怎样的
掌握 Redis 事务,提升数据处理效率的必备秘籍:https://mp.weixin.qq.com/s/ryNFuk3YDJ4j2M5xuogaUA (opens new window)
# Redis的定期内存淘汰策略是怎么样的
redis通过定期删除和惰性删除处理过期key:
- 定期删除:redis的serverCron函数会每个100ms随机抽检一些key查看是否过期,如果过期则将这些key删除,通过随机抽检保证单线程执行不会阻塞。
- 惰性删除:当用户查询某个key的时候,redis函数会检查该key是否会过期,如果过期则将其删除并返回nil。
Redis 的内存淘汰策略用于在内存不足时决定如何移除数据,以确保 Redis 可以继续正常运行。以下是 Redis 支持的主要内存淘汰策略:
- noeviction:默认策略,当达到最大内存限制时,任何写入操作都会返回错误(读取操作仍然可以进行)。
- allkeys-lru:从所有键中使用最近最少使用的算法来驱逐键。
- volatile-lru:仅从设置了过期时间的键中使用最近最少使用的算法来驱逐键。
- allkeys-random:从所有键中随机选择键来驱逐。
- volatile-random:仅从设置了过期时间的键中随机选择键来驱逐。
- volatile-ttl:优先根据剩余生存时间(TTL)来驱逐键,即 TTL 较短的键会被优先驱逐。
这些策略可以在 Redis 配置文件 redis.conf 中通过 maxmemory-policy 参数设置。选择合适的淘汰策略取决于具体的应用场景和需求。例如,如果希望尽可能保留热点数据,可以选择 allkeys-lru 或 volatile-lru;如果希望更公平地处理所有数据,则可以选择 allkeys-random 或 volatile-random。
关于缓存淘汰策略,感兴趣的读者可以参考这篇文章:https://mp.weixin.qq.com/s/1XY1_WPZ68FoQMXNw3JAYA (opens new window)
# Redis如何实现发布/订阅
redis发布订阅是通过pub和sub指令实现的,如果客户端对某个事件感兴趣可以通过sub订阅,这些客户端就会存储到主题的channel中的链表,一旦有发送者用pub消息,channel就会遍历订阅者通知消息。关于pub/sub更多知识,可以参考笔者这篇文章:https://mp.weixin.qq.com/s?__biz=MzkwODYyNTM2MQ==&mid=2247485446&idx=1&sn=6ba38a384d834d0c54c1078cde94021f&chksm=c0c65cb8f7b1d5aec856e575116a00c50a18f8fdbc8a6882eef82b1af500d6e5455b00c61db1#rd (opens new window)
当然随着stream的出现,可能更多的企业会考虑使用更可靠的stream实现发布订阅。
# 为什么ZSet 既能支持高效的范围查询,还能以 O(1) 复杂度获取元素权重值?
底层数据结构由字典和调表构成,两者共同维护持有元素指针,当进行键定位时通过字典的哈希算法完成O(1)级别的定位,当需要有序的范围查询时,又可以通过跳表完成O(logN)级别的范围检索定位,这一点可以参考笔者的这篇文章:
# 什么是Redis的渐进式rehash
redis底层字典本质上是通过数组+哈希算法和拉链法解决冲突,随着时间推移可能会重现大量的链表导致查询性能下降,又因为redis是单线程,为避免哈希表扩容耗时长导致性能下降,redis采用渐进式哈希逐步迁移数据到新表。
对于源码感兴趣的读者可以参考这篇文章:
- 聊聊redis中的字典设计与实现:https://mp.weixin.qq.com/s/tE81OSr_-GCjs7JaFCth4A (opens new window)
# Redis 中 key 过期了一定会立即删除吗
不会。Redis 采用定期删除 + 惰性删除组合策略:
- 定期删除:serverCron 每隔 100ms 随机抽取一批 key,检查过期后删除
源码印证:
/* 通过随机采样的方式清理过期 key */
if (server.active_expire_enabled && server.masterhost == NULL)
activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
2
3
可以看到,activeExpireCycle 正是执行定期删除的核心函数,它在 serverCron 中被调用(仅主节点执行,从节点由主节点同步 DEL)。
- 惰性删除:访问某个 key 时才检查是否过期,过期则删除并返回空
源码印证:
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
/* 先检查 key 是否过期 */
if (expireIfNeeded(db,key) == 1) {
/* key 已过期,主节点直接返回 NULL */
if (server.masterhost == NULL) return NULL;
/* 从节点只有在读命令时才能返回 NULL */
if (server.current_client->cmd->flags & CMD_READONLY) return NULL;
}
return lookupKey(db,key,flags);
}
2
3
4
5
6
7
8
9
10
可以看到,惰性删除在每次查询 key 时都会先调用 expireIfNeeded() 检查是否过期,过期则返回 NULL(相当于删除)。这样只在访问时处理,不影响 Redis 主流程。
两者配合,既避免全量扫描的性能损耗,又能在访问时及时清理过期 key。
# 批量删除 key 会造成阻塞吗
会的。Redis 单线程执行命令,如果用 DEL 命令批量删除大量 key,或者用 KEYS * 匹配大量 key,会导致主线程长时间阻塞,其他客户端请求无法处理。
解决方案:使用 UNLINK 命令替代 DEL,UNLINK 是异步删除,会将删除任务放到后台线程执行,不阻塞主线程。
// DEL:同步删除,阻塞主线程
jedis.del("key1", "key2", "key3");
// UNLINK:异步删除,不阻塞主线程
jedis.unlink("key1", "key2", "key3");
2
3
4
5
如果 key 数量很大,建议配合 SCAN 使用:
// SCAN 分批扫描 + UNLINK 异步删除
ScanParams params = new ScanParams().match("batch:*").count(100);
String cursor = "0";
do {
ScanResult<String> scanResult = jedis.scan(cursor, params);
List<String> keys = scanResult.getResult();
if (!keys.isEmpty()) {
jedis.unlink(keys.toArray(new String[0])); // 异步删除
}
cursor = scanResult.getCursor();
} while (!"0".equals(cursor));
2
3
4
5
6
7
8
9
10
11
12
原理:UNLINK 将 key 从 dict 中移除后立即返回,内存释放由后台线程完成。
# Pipeline 和事务有什么区别
Pipeline:客户端批量发送多条命令,不保证原子性,每条命令独立执行,失败不影响后续命令。
事务(MULTI/EXEC):Redis 提供的事务机制,保证组队阶段的原子性。使用方式:
MULTI
SET key1 value1
SET key2 value2
EXEC
2
3
4
两者的区别:
| 对比项 | Pipeline | 事务 |
|---|---|---|
| 原子性 | ❌ 不保证 | ✅ 组队阶段保证原子性 |
| 失败处理 | 后续继续执行 | 组队失败全部不执行;执行失败后续继续 |
| 回滚 | 不回滚 | 不自动回滚 |
| 使用场景 | 优化网络延迟 | 保证批量命令原子性 |
Pipeline 逐条执行的源码印证:
while(repeat--) {
// 每条命令追加到缓冲区,逐条执行
redisAppendCommandArgv(context, argc, (const char**)argv, argvlen);
// 读取响应时,依次处理每条命令的结果
if (cliReadReply(output_raw) != REDIS_OK) {
return REDIS_ERR;
}
}
2
3
4
5
6
7
8
可以看到,Pipeline 本质上是循环执行 redisAppendCommandArgv + cliReadReply,每条命令独立追加、独立读取结果,并不存在"批量原子执行"的概念。
事务执行源码印证:
void execCommand(client *c) {
/* 检查是否需要中止事务 */
if (c->flags & (CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC)) {
/* 组队期间有异常指令(如 WATCH 的 key 被修改),直接回滚不执行 */
addReply(c, shared.nullmultibulk);
discardTransaction(c);
goto handle_monitor;
}
/* 正常执行事务中的所有命令 */
...
}
2
3
4
5
6
7
8
9
10
11
可以看到,EXEC 执行时会检查 CLIENT_DIRTY_EXEC 标志位。如果组队期间有异常(如 WATCH 的 key 被修改),Redis 会直接返回空响应并丢弃事务,不会执行任何命令。
注意:如果需要原子性 + 自动回滚,应使用 Lua 脚本。
# Redis 的事务和 Lua 之间有哪些区别
相同点:都可以将多条命令打包执行,Redis 都会将其作为整体处理,保证组队/执行阶段的原子性。
不同点:
| 对比项 | 事务 | Lua 脚本 |
|---|---|---|
| 执行失败处理 | 组队失败全部不执行;执行失败后续继续 | 一条失败,后续不执行,整个脚本终止 |
| 回滚 | 不自动回滚 | 不自动回滚 |
| 适用场景 | 简单的批量操作 | 复杂的业务逻辑、需要原子性的多步骤操作 |
例如:
# 事务:第2条失败,第3条会继续执行
MULTI
SET k1 v1
INCR k2 # 假设 k2 是字符串,这条会报错
SET k3 v3 # 这条仍然会执行
EXEC
2
3
4
5
6
-- Lua 脚本:第2条失败,整个脚本终止,不会执行第3条
redis.call("SET", "k1", "v1")
redis.call("INCR", "k2") -- 报错
redis.call("SET", "k3", "v3") -- 不会执行
2
3
4
总结:如果需要更严格的原子性保证,推荐使用 Lua 脚本。
# 为什么 Redis 不支持回滚
首先澄清一个概念:Redis 在组队阶段发现异常时,是不执行(丢弃事务),而不是回滚(执行后撤销)。
Redis 设计者选择不支持回滚,原因如下:
1. 简单高效的设计初衷
Redis 的核心设计理念是简单、高效。如果支持回滚,需要记录每一步执行前的状态,以便撤销,这会大幅增加复杂度和性能开销。
2. 使用场景不需要
Redis 的定位是数据结构服务器和缓存,典型场景(如缓存、计数器、排行榜)即使失败也不需要回滚业务逻辑。
3. 指令错误应该提前规避
执行时出错(如类型错误:INCR 一个字符串值)通常意味着业务逻辑或数据有问题,应该在开发测试阶段发现并修复,而不是依赖事务回滚。
实际建议:如果业务需要严格的原子性保证,推荐使用 Lua 脚本,或者在应用层做补偿处理。
# 关于 Redis 中的布隆过滤器
布隆过滤器是一种概率性数据结构,用于快速判断一个元素是否存在于某个集合中。
特点:
- 空间效率高:用少量位存储大量数据的存在情况
- 允许误判:可能把不存在的数据判断为存在(假阳性)
- 不支持删除:删除操作可能导致其他元素也被"误删"
两个重要结论:
- 布隆过滤器判断数据不存在 → 100% 准确
- 布隆过滤器判断数据存在 → 可能误判(假阳性)
Redis 中的使用方式:
- RedisBloom 模块(推荐,需安装):
BF.ADD myfilter "user123" # 添加元素
BF.EXISTS myfilter "user123" # 检查是否存在
BF.ADD myfilter "user456"
2
3
// Jedis 使用示例
jedis.bfAdd("myfilter", "user123");
Boolean exists = jedis.bfExists("myfilter", "user123");
2
3
- 手写 bitmap + 哈希(不推荐,实现复杂)
典型使用场景:
- 缓存穿透防护:查询数据库前先用布隆过滤器判断
- 用户在线状态:判断用户是否登录过
- 爬虫 URL 去重:判断 URL 是否已爬取
- 推荐系统:判断内容是否已推荐过
误判率控制: 布隆过滤器的误判率与 bitmap 大小和哈希函数数量有关。数据量越大,需要的 bitmap 空间越大,才能维持较低的误判率。
# RDB 和 AOF 如何选择
根据业务对数据可靠性的要求选择:
| 场景 | 推荐方案 |
|---|---|
| 最高可靠性 | 同时开启 RDB + AOF,优先加载 AOF |
| 可接受分钟级丢失 | 只用 RDB |
| 需要快速恢复 | 只用 RDB |
| 平衡性能与可靠性 | AOF + everysec 同步 |
| 极高可靠性 | 混合持久化(4.0+) |
总结:没有绝对的最佳选择,需要根据业务场景权衡。如果数据非常重要,建议同时开启两种持久化。
# redis的数据恢复如何做到的?
AOF持久化开启且存在AOF文件时,优先加载AOF文件。AOF关闭或者AOF文件不存在时,加载RDB文件。- 加载
AOF/RDB文件成功后,Redis启动成功。 AOF/RDB文件存在错误时,Redis启动失败并打印错误信息。
补充说明:
- 当AOF文件尾部损坏时,可通过配置
aof-load-truncated yes让Redis尽可能加载后继续运行(Redis 3.0+支持) - 当AOF文件开头是
REDI字样时,表示这是混合持久化格式(RDB+增量AOF)
源码实现(Redis启动时加载RDB或AOF文件的函数):
void loadDataFromDisk(void) {
long long start = ustime();
// AOF开启时优先加载AOF文件,否则加载RDB文件
if (server.aof_state == AOF_ON) {
// 加载AOF文件(包含混合持久化格式的RDB+增量AOF)
if (loadAppendOnlyFile(server.aof_filename) == C_OK)
// 打印:"DB loaded from append only file: X.XXX seconds"
serverLog(LL_NOTICE, "DB loaded from append only file: %.3f seconds", (float)(ustime()-start)/1000000);
} else {
// AOF关闭时加载RDB文件
if (rdbLoad(server.rdb_filename) == C_OK) {
// 打印:"DB loaded from disk: X.XXX seconds"
serverLog(LL_NOTICE, "DB loaded from disk: %.3f seconds", (float)(ustime()-start)/1000000);
} else if (errno != ENOENT) {
// ENOENT表示RDB文件不存在(首次启动),否则是加载失败
// 打印:"Fatal error loading the DB: error message. Exiting."
serverLog(LL_WARNING, "Fatal error loading the DB: %s. Exiting.", strerror(errno));
exit(1); // 严重错误导致Redis无法启动
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# redis4.0的混合持久化
将RDB文件的内容和增量的AOF日志文件存在一起。当触发AOF重写时,Redis会先保存当前数据的RDB快照,然后记录从此刻到重写完成期间的增量AOF命令。这样既保证了数据完整性,又避免了AOF文件持续膨胀的问题。
# 开启混合持久化(Redis 4.0+支持)
aof-use-rdb-preamble yes
2
对应混合持久化核心代码段可参考aof.c的rewriteAppendOnlyFile函数:
int rewriteAppendOnlyFile(char *filename) {
rio aof; // AOF读写结构体
FILE *fp = NULL; // 临时文件指针
char tmpfile[256]; // 临时文件名
// 创建临时文件,文件名包含进程ID避免冲突
snprintf(tmpfile,256,"temp-rewriteaof-%d.aof", (int) getpid());
fp = fopen(tmpfile,"w"); // 打开临时文件准备写入
if (!fp) {
// 文件打开失败,记录错误日志
serverLog(LL_WARNING, "Opening the temp file for AOF rewrite in rewriteAppendOnlyFile(): %s", strerror(errno));
return C_ERR; // 返回错误码
}
// 初始化AOF读写流,关联到临时文件
rioInitWithFile(&aof,fp);
// 标记开始保存,RDBFLAGS_AOF_PREAMBLE标志混合持久化模式
startSaving(RDBFLAGS_AOF_PREAMBLE);
// 核心逻辑:根据配置决定写入格式
if (server.aof_use_rdb_preamble) {
// 混合持久化模式:写入RDB格式的数据库快照
int error;
if (rdbSaveRio(SLAVE_REQ_NONE,&aof,&error,RDBFLAGS_AOF_PREAMBLE,NULL) == C_ERR) {
errno = error;
goto werr; // 写入失败,跳转到错误处理
}
} else {
// 普通AOF重写模式:只记录写命令
if (rewriteAppendOnlyFileRio(&aof) == C_ERR) goto werr;
}
// 确保数据不停留在操作系统输出缓冲区
if (fflush(fp)) goto werr; // 刷新用户态缓冲区到内核态
if (fsync(fileno(fp))) goto werr; // 强制将内核态数据持久化到磁盘
// 关闭临时文件
if (fclose(fp)) { fp = NULL; goto werr; }
fp = NULL;
// 使用RENAME原子替换原AOF文件,确保只有生成的文件完整才替换
if (rename(tmpfile,filename) == -1) {
serverLog(LL_WARNING,"Error moving temp append only file on the final destination: %s", strerror(errno));
unlink(tmpfile); // 删除失败的临时文件
stopSaving(0); // 标记保存失败
return C_ERR;
}
stopSaving(1); // 标记保存成功
return C_OK;
werr:
// 错误处理标签:记录错误、清理资源
serverLog(LL_WARNING,"Write error writing append only file on disk: %s", strerror(errno));
if (fp) fclose(fp); // 关闭文件指针
unlink(tmpfile); // 删除临时文件
stopSaving(0); // 标记保存失败
return C_ERR;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
混合持久化执行流程解析:
- 创建临时文件:生成
temp-rewriteaof-<pid>.aof - 写入RDB快照:调用
rdbSaveRio()将整个数据库状态序列化为RDB二进制格式 - 写入增量AOF:调用
rewriteAppendOnlyFileRio()将重写期间的写命令追加到临时文件 - 刷盘同步:
fflush()+fsync()确保数据落盘 - 原子替换:
rename()用临时文件替换原AOF文件
关于混合持久化的解析,感兴趣的读者可以参考笔者这篇文章:https://mp.weixin.qq.com/s/cF2gE0wVSuvuBRXslce3ng (opens new window)
# redis场景架构设计
# 缓存击穿、缓存穿透、缓存雪崩问题以及应对策略
缓存击穿:当缓存中某个热点数据key过期时,瞬间大量并发请求直接打到数据库,导致数据库压力剧增甚至崩溃。

解决策略:
根据数据类型采用不同策略:
热点数据:设置较长的过期时间,通过访问后定期续命(如每次访问延长30分钟)保持缓存新鲜度,同时配合异步更新机制(如Canal订阅binlog)保证数据一致性。
// 访问时续命伪代码 String getHotData(String key) { String data = jedis.get(key); if (data == null) { return null; } // 每次访问延长30分钟过期时间 jedis.expire(key, 30 * 60); return data; }1
2
3
4
5
6
7
8
9
10
11非热点数据:使用Redis分布式锁(SETNX)保护数据库查询,获取锁失败时采用等待重试策略(短暂等待后再次查询缓存),避免高并发下大量请求排队等待导致系统雪崩。
// 非热点数据获取伪代码 String getNormalData(String key) { String data = jedis.get(key); if (data != null) { return data; } String lockKey = key + ":lock"; while (true) { // 尝试获取分布式锁(设置10秒过期防止死锁) Boolean acquired = jedis.set(lockKey, "1", "NX", "EX", 10); if (Boolean.TRUE.equals(acquired)) { try { // 获取锁成功后再次检查缓存 data = jedis.get(key); if (data != null) return data; // 查数据库并写入缓存 data = findFromDB(key); jedis.setex(key, 600, data); return data; } finally { jedis.del(lockKey); } } else { // 获取锁失败,短暂等待后重试 data = jedis.get(key); if (data != null) return data; Thread.sleep(50); } } }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
缓存穿透:尽管我们将数据库中某些数据加载到内存中,但若攻击者使用数据库中不存在的key进行恶意攻击,所有查询请求就会穿透缓存直接打到数据库,在高并发场景下导致数据库压力过大甚至崩溃。

解决策略:
使用布隆过滤器:将数据库所有key映射到bitmap中,利用布隆过滤器"不存在就一定不存在"的特性过滤无效请求。注意:布隆过滤器存在误判率(一般0.01),需要根据数据量合理设置bitmap大小。
缓存空结果:将从数据库查询为空的数据也缓存起来,设置较短的过期时间(如3-5分钟)避免同一key被频繁攻击。同时配合Canal订阅binlog异步更新缓存保证数据一致性。
缓存雪崩:大量缓存key同时过期或缓存服务不可用,导致大量并发请求同时打到数据库,造成数据库压力剧增甚至崩溃。
解决策略:
缓存层兜底 + 分布式锁:即使缓存过期,也要保证数据库不被击穿。使用Redis分布式锁保护数据库查询,查到数据后写入缓存,若数据库也无数据则缓存空结果,同时配合Canal订阅binlog异步更新缓存保证数据一致性。
随机过期时间:对缓存设置随机过期时间(如基础时间 ± 0~60秒),避免大量key同时过期导致雪崩。
# 缓存污染(缓存空间全满)
某些数据查询一次就被缓存在数据库中,随着时间推移,缓存空间已经满了,这时候redis就要根据缓存策略进行缓存置换。这就造成没意义的数据需要通过缓存置换策略来淘汰数据,而且还可能出现淘汰热点数据的情况。
解决方案:选定合适的缓存置换策略,而redis缓存策略主要分三类
不淘汰的
- noeviction (v4.0后默认的):不会淘汰任何过期键,满了就报错,对设置了过期时间的数据中进行淘汰
- volatile-random:随机删除过期key
- volatile-ttl:根据过期时间进行排序,越早过期的数据就优先被淘汰。
- volatile-lru:即最近最少使用算法(推荐),redis的lru缓存置换算法相比传统的算法做了一定优化,根据 maxmemory-samples从缓存中随机取出几个key值,然后进行比较在进行淘汰,这样就避免了缓存置换时需要操作一个大链表进行key值淘汰了。
- volatile-lfu:lru只知晓用户最近使用次数,而不知道该数据使用频率,所以lfu就是基于lru进一步的优化,进行淘汰时随机取出访问次数最少的数据,如果最少的数据有多个,按按照lru算法进行淘汰。但是redis只用8bit记录访问次数,超过255就无法进行自增了,所以我们可以使用
lfu-log-factor和lfu-decay-time来用户访问次数增加的频率。 - lfu-decay-time:控制访问次数衰减。LFU 策略会计算当前时间和数据最近一次访问时间的差值,并把这个差值换算成以分钟为单位。然后,LFU 策略再把这个差值除以 lfu_decay_time 值,所得的结果就是数据 counter 要衰减的值。若设置为0,则意味着每次扫描访问次数都会扣减。
- lfu-log-factor:用计数器当前的值乘以配置项 lfu_log_factor 再加 1,再取其倒数,得到一个 p 值;然后,把这个 p 值和一个取值范围在(0,1)间的随机数 r 值比大小,只有 p 值大于 r 值时,计数器才加 1。 全部数据进行淘汰
- allkeys-random:从所有键值对中使用lru淘汰
- allkeys-lru:从所有键值对中随机删除
- allkeys-lfu:从所有键值对中使用lfu随机淘汰
# 基于Redis定位亿级数据的方式
假如Redis里面有1亿个key,其中有10w个key是以某个固定的已知的前缀开头的,如何将它们全部找出来?
更好的做法是通过合理的数据结构设计避免scan操作。对于需要按前缀查询的key,可以统一用SET存储所有key的集合,例如使用SADD user:keys "user:1001" "user:1002"将所有用户key存储到集合中,查询时直接使用SMEMBERS user:keys获取,时间复杂度O(1),且不存在阻塞风险。
如果必须使用scan,可以用keys指令扫出指定模式的key列表。但要注意keys指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用scan指令,scan指令可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用keys指令长。
# 什么情况下会出现数据库和缓存不一致的问题?
主要有以下两种情况:
1. 先更新数据库再更新缓存
线程1和线程2都先更新数据再更新缓存,由于线程1网络波动导致后更新缓存,最终导致数据库和缓存不一致。先更新缓存再更新数据库同理。

2. 读场景
缓存未命中时的并发时序问题:
- 线程2查询缓存,未命中
- 线程2从数据库读取值10
- 线程1更新数据库为20
- 线程1删除缓存(或更新缓存)
- 线程2将旧值10写入缓存
自此,缓存不一致问题出现:

# 如何解决Redis和数据库的一致性问题?
延时双删:更新数据库后删除缓存,延迟一段时间再次删除缓存,确保并发请求中的旧数据被清理。
先更新数据库再删除缓存:相比先删缓存再更新数据库,这种方式更安全,因为缓存未命中时直接查数据库不会出错。
基于binlog异步删除:通过Canal等工具订阅数据库binlog,当数据变更时自动删除对应缓存,保证最终一致性。
# 为什么需要延迟双删,两次删除的原因是什么?
第一次删除:在更新数据库后立即删除缓存,确保后续请求不会读到旧缓存。
第二次删除:延迟一段时间后再删除,处理并发场景。如果读请求在更新数据库前读取了旧数据并准备写入缓存,第二次删除可以清理掉这个脏数据,保证最终一致性。
注意:延迟双删是尽力而为的方案,存在边界情况:第二次删除可能误删新写入的数据,或在延迟时间内又有新数据写入,导致最终不一致。对一致性要求高的场景建议使用binlog异步删除。
# Redis如何实现延迟消息
Keyspace Notifications:通过配置
notify-keyspace-events Ex开启过期key事件通知,程序继承KeyExpirationEventMessageListener监听过期事件。缺点是过期的key不一定会立即删除,且消息没有持久化可能丢失。ZSet延迟队列:将过期时间作为score,key作为member,程序计算过期时间差值进行休眠,到期后执行相应操作。需要注意的是如果有时效更短的key进来需要更新其score。
Redisson延迟队列:通过Redisson提交延迟任务,原理和方法2类似,但封装了并发消费等问题的处理,使用更加简单。
# 如何基于Redis实现滑动窗口限流?
滑动窗口通过有序集合保证单位时间内流量平稳,避免突发流量冲击。假设需要将接口每秒请求控制在2000,对应实现方案为:
- 将请求接口作为key。
- 请求到来时,生成唯一id作为member,时间戳作为score。
- 基于当前时间戳减去60秒,统计60秒内的请求数。
- 如果请求数小于2000则允许请求,反之拒绝。
也可以直接使用Redisson提供的RRateLimiter实现限流。
# 怎么处理热key
什么是热Key?热key是指访问频率比较高的key,比如热门新闻事件或商品,这类key通常有大流量的访问,对Redis存储是不小的压力。Redis集群部署时,热key可能会造成整体流量不均衡,个别节点OPS过大,极端情况下甚至超过Redis本身能够承受的压力。
热key处理的关键在于监控,可以从以下三个层面监控热点key:
- 客户端:在客户端设置全局字典记录key和调用次数,每次调用Redis命令时进行统计。
- 代理端:Twemproxy、Codis等代理架构中,所有请求经过代理端,可在代理层统一收集统计。
- Redis服务端:可通过monitor命令监控所有Redis执行命令,但monitor会降低Redis性能,生产环境慎用。
热key的处理方案:
- 将热key分散到不同节点,降低单节点压力。
- 加入二级缓存,提前加载热key数据到内存,若Redis宕机可走内存查询。
# 缓存预热怎么做?
所谓缓存预热,就是提前把数据库里的数据刷到缓存里,通常有这些方法:
- 直接写个缓存刷新页面或者接口,上线时手动操作
- 数据量不大,可以在项目启动的时候自动进行加载(我们目前就是执行这种操作,通过继承InitializingBean实现)
- 定时任务刷新缓存.
# 热点key重建问题了解过?你是如何解决的呢?
开发的时候一般使用“缓存+过期时间”的策略,既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。
但是有两个问题如果同时出现,可能就会出现比较大的问题:
- 当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。
- 重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的 SQL、多次IO、多个依赖等。 在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。
要解决这个问题也不是很复杂,解决问题的要点在于:
减少重建缓存的次数。
数据尽可能一致。
较少的潜在危险。 所以一般采用如下方式:
互斥锁(mutex key) 这种方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。
永远不过期 “永远不过期”包含两层意思:
从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期后产生的问题,也就是“物理”不过期,注意数据更新后要实时加锁更新。
从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。
# 详解Redis日常运维
# Redis阻塞问题如何解决
API或数据结构使用不合理:Redis执行命令速度非常快,但不合理地使用命令(如大对象上执行O(n)复杂度的命令)可能导致阻塞。高并发场景应避免这种情况。
慢查询处理:
# 发现慢查询 slowlog get {n} # 获取最近n条慢查询 # 优化方向: # 1. 改为低算法复杂度命令,如hgetall改为hmget,禁用keys、sort等命令 # 2. 调整大对象:缩减数据或拆分为多个小对象1
2
3
4
5CPU饱和问题:Redis单线程只能使用一个CPU,当单核CPU使用率接近100%时即为CPU饱和。处理步骤:
- 使用
redis-cli -h {ip} -p {port} --stat判断并发量 - 若OPS达到几万+,应做集群化水平扩展
- 若OPS只有几百几千,需排查命令和内存使用
- 使用
持久化相关阻塞:
- Fork阻塞:RDB和AOF重写时fork产生子进程,若fork耗时过长会导致主线程阻塞
- AOF刷盘阻塞:AOF每秒fsync一次,若硬盘压力过大导致fsync等待超过2秒,主线程会阻塞
- HugePage写操作阻塞:开启Transparent HugePages时,每次写操作复制内存页单位从4K变为2MB,放大512倍,拖慢写操作执行时间
# Redis大key问题
Redis使用过程中可能出现大key情况:
- 单个key的value很大,size超过10KB
- hash、set、zset、list中存储过多元素(以万为单位)
大key会造成的问题:
- 客户端耗时增加,甚至超时
- 对大key进行IO操作时,严重占用带宽和CPU
- 造成Redis集群中数据倾斜
- 主动删除、被动淘汰时可能导致阻塞
如何找到大key?
- bigkeys命令:遍历Redis实例分析所有Key,返回整体统计信息和每个数据类型中的Top1大Key
- redis-rdb-tools:Python工具分析RDB快照文件,生成JSON或报表用于分析Redis使用情况
如何处理大key?
删除大key:
- Redis 4.0+:使用
UNLINK命令以非阻塞方式逐步清理 - Redis 4.0-:使用SCAN命令增量迭代扫描,避免阻塞式KEYS命令
- Redis 4.0+:使用
压缩和拆分key:
- String类型:序列化或压缩控制大小,但序列化和反序列化会带来额外开销
- 若压缩后仍是大key:拆分为多个小key,使用multiget等操作批量读取
# Redis常见性能问题和解决方案
- Master最好不要做任何持久化工作,包括内存快照和AOF日志文件,特别是不要启用内存快照做持久化。
- 如果数据比较关键,可以让某个Slave开启AOF备份,策略为每秒同步一次。
- 为保证主从复制的速度和连接稳定性,Slave和Master最好在同一个局域网内,尽量避免在压力较大的主库上增加从库。
- Master调用BGREWRITEAOF重写AOF文件时,会占用大量CPU和内存资源,导致服务load过高,出现短暂服务暂停。
- 为保证Master稳定性,主从复制采用单向链表结构更稳定,即
Master<–Slave1<–Slave2<–Slave3…,便于解决单点故障问题,实现Slave对Master的替换。
# 如何用Redis实现乐观锁、可重入锁
Redis事务通过以下指令实现:
- watch监视一个或多个key
- get查询数据
- multi开始事务
- set执行命令
- exec提交事务
WATCH是乐观锁机制,基于CAS原理:提交时检查被监视的key是否被修改,若被修改则事务失败。
可重入锁通过setnx+incr和decr指令完成上锁和解锁逻辑。
关于各种锁的实现和源码可以参考笔者这篇文章:https://mp.weixin.qq.com/s/nrCO8GZBJrLQis98bMaRhg (opens new window)
# Redis实现分布式锁时需要考虑的问题
- 互斥
- 性能
- 误解锁
- 锁超时
- 锁续命
- 单点故障
- 锁重入
- 网络分区
- 时间漂移
# Redis如何高效安全地遍历所有key
大量key场景下,使用keys *会导致其他客户端请求阻塞。针对遍历需求,建议使用scan命令,以游标方式分批次迭代键集合:

百万级数据测试:keys *耗时约70秒,期间其他请求全部阻塞:

scan命令从游标0开始,逐批次检索遍历,解决keys阻塞问题:

注意:scan是渐进式遍历,对数据实时性把控有限,扫描区间内的修改操作可能无法感知。
# 小结
我是 sharkchili ,CSDN Java 领域博客专家,mini-redis的作者,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。
同时也非常欢迎你star我的开源项目mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。
# 参考
Redis常见面试题总结(下):https://javaguide.cn/database/redis/redis-questions-02.html#缓存雪崩 (opens new window)
Redis进阶 - 缓存问题:一致性, 穿击, 穿透, 雪崩, 污染等:https://www.pdai.tech/md/db/nosql-redis/db-redis-x-cache.html#4种相关模式 (opens new window)
Redis 缓存雪崩、缓存穿透、缓存击穿、缓存预热:https://juejin.cn/post/7059949724152889380#heading-6 (opens new window)
【Redis】如何保证原子操作:https://zhuanlan.zhihu.com/p/356277655 (opens new window)
面渣逆袭(Redis面试题八股文)必看👍:https://tobebetterjavaer.com/sidebar/sanfene/redis.html#_30-怎么处理热key (opens new window)
《redis开发与运维》