Redis 为什么这么快?(内存、IO 多路复用、单线程)
一句话原理:Redis 基于纯内存操作规避磁盘 I/O 瓶颈,利用 epoll 机制实现 IO 多路复用,在单线程事件循环中串行处理命令,彻底避免了多线程上下文切换与锁竞争的开销。
一句话源码:核心逻辑位于 aeProcessEvents 函数,通过 aeApiPoll 监听就绪 Socket,将网络读写与命令执行封装为 FileEvent 事件,在主线程中由 aeMain 循环按顺序无锁调度执行。
一句话项目/场景:在 Redis 6/7 版本的高并发场景下,针对网络 I/O 瓶颈引入多线程进行数据协议的读写解析,但核心命令执行仍保持单线程,既利用多核提升吞吐,又规避了事务与 Lua 脚本的并发原子性问题。
Redis 的单线程模型如何理解?为什么 6.0 引入多线程?
一句话原理:Redis 单线程模型指主线程通过事件循环串行执行命令以保障原子性与无锁化,而 6.0 引入多线程仅为了并行处理网络 I/O 的读写与协议解析,将耗时的数据搬运工作从主线程剥离以突破网络吞吐瓶颈。
一句话源码:在 networking.c 中,6.0 版本新增 io_threads_list 数据结构,通过 IOReadPhase 和 IOWritePhase 阶段性标记,让主线程等待多个 I/O 线程完成 Socket 数据的读取或写出后,再由主线程安全地执行命令逻辑。
一句话项目/场景:在千兆或万兆网络环境下的高并发压测中,开启 io-threads 配置可显著降低主线程 CPU 负载并提升 QPS 上限,但在实际维护中需注意设置 io-threads-do-reads yes 并避免线程数超过 CPU 核心数,防止因线程竞争反而导致性能衰退。
如何优化 Redis 的内存使用?(内存淘汰策略、压缩、数据结构选择)
内存淘汰策略:主动释放空间
一句话原理:Redis通过内存淘汰策略在内存达到maxmemory上限时主动清理数据,提供8种淘汰策略(LRU/LFU/TTL/随机等),根据业务特点选择合适的策略,在内存和命中率间取得平衡。
一句话源码:
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
| /**
* 内存淘汰策略源码解析
*/
// 内存淘汰策略配置
// maxmemory 1gb # 最大内存限制
// maxmemory-policy volatile-lru # 淘汰策略
// Redis 8种淘汰策略
#define MAXMEMORY_FLAG_LRU (1<<0) // LRU(最近最少使用)
#define MAXMEMORY_FLAG_LFU (1<<1) // LFU(最不经常使用)
#define MAXMEMORY_FLAG_ALLKEYS (1<<2) // 所有键
#define MAXMEMORY_FLAG_NOEVICTION (1<<3) // 不淘汰
// 策略对应关系
// noeviction: 内存满返回错误(默认)
// allkeys-lru: 所有键中淘汰LRU
// volatile-lru: 设置过期时间的键中淘汰LRU
// allkeys-random: 所有键中随机淘汰
// volatile-random: 设置过期时间的键中随机淘汰
// volatile-ttl: 淘汰剩余时间最短的键
// allkeys-lfu: 所有键中淘汰LFU(4.0+)
// volatile-lfu: 设置过期时间的键中淘汰LFU(4.0+)
// 淘汰键的选择(LRU近似实现)
struct redisObject {
unsigned lru:24; // LRU时间戳(精确到秒)或LFU计数
// ...
};
// LRU近似算法:采样5个键,淘汰最旧的
void evictionPoolPopulate(dict *sampledict, ...) {
// 从数据库随机采样maxmemory-samples个键(默认5)
for (i = 0; i < server.maxmemory_samples; i++) {
de = dictGetRandomKey(sampledict);
// 计算空闲时间(当前时间 - lru)
idle = estimateObjectIdleTime(o);
// 放入淘汰池(按空闲时间排序)
pool_insert(evictionPool, de, idle);
}
}
// 执行淘汰
int freeMemoryIfNeeded(void) {
while (mem_reported > server.maxmemory) {
// 选择最佳淘汰键
bestkey = evictionPoolPopulate();
if (bestkey) {
dbDelete(db, bestkey); // 删除键
mem_reported -= zmalloc_used_memory_diff();
}
}
}
|
项目场景:在电商商品缓存中,采用allkeys-lru策略,最近访问多的热销商品保留,冷门商品自动淘汰。大促期间即使内存紧张,热销商品依然在缓存中,保证了核心业务的响应速度。监控显示命中率保持在95%以上。
数据压缩与编码优化
一句话原理:Redis对小对象采用压缩编码(ziplist/intset/quicklist),对长字符串使用LZF压缩,通过时间换空间,在数据量小时大幅降低内存占用,最高可节省70%内存。
一句话源码:
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| /**
* 压缩编码配置与源码
*/
// ============ 1. 压缩编码配置 ============
// redis.conf 配置
hash-max-ziplist-entries 512 // hash最大元素数
hash-max-ziplist-value 64 // hash元素最大长度
list-max-ziplist-size -2 // list每个节点最大8KB
set-max-intset-entries 512 // set整数元素最大数
zset-max-ziplist-entries 128 // zset最大元素数
zset-max-ziplist-value 64 // zset元素最大长度
// ============ 2. Hash编码选择 ============
robj *hashTypeLookupWriteOrCreate(client *c, robj *key) {
robj *o = lookupKeyWrite(c->db, key);
if (checkType(c, o, OBJ_HASH)) return NULL;
if (!o) {
// 创建时使用压缩列表
o = createHashObject(); // 默认用ziplist
dbAdd(c->db, key, o);
} else {
// 检查是否需要转换编码
hashTypeTryConversion(o, &argv, 2, 3);
}
return o;
}
// 检查并转换编码
void hashTypeTryConversion(robj *o, robj **argv, int start, int end) {
if (o->encoding != OBJ_ENCODING_ZIPLIST) return;
for (int i = start; i <= end; i++) {
// 如果元素长度超过阈值,转为hashtable
if (sdsEncodedObject(argv[i]) &&
sdslen(argv[i]->ptr) > server.hash_max_ziplist_value) {
hashTypeConvert(o, OBJ_ENCODING_HT);
break;
}
}
}
// ============ 3. LZF字符串压缩 ============
robj *tryObjectEncoding(robj *o) {
// 长字符串尝试压缩
if (len > OBJ_ENCODING_EMBSTR_SIZE_LIMIT) {
// 尝试LZF压缩
robj *compressed = createCompressedStringObject(o->ptr, len);
if (compressed) {
decrRefCount(o);
return compressed; // 返回压缩对象
}
}
return o;
}
|
项目场景:在用户画像系统中,每个用户有数十个属性。通过ziplist存储,当用户数1000万时,相比hashtable节省内存2.3GB。同时配置hash-max-ziplist-entries 256,确保中等规模用户仍享受压缩优势。
数据结构优化选择
一句话原理:根据业务场景选择最合适的数据结构,如用Hash存储对象(替代String+JSON)、用intset存储整数集合、用Bitmap存储布尔状态,可减少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
34
35
36
37
38
| /**
* 数据结构选择对比
*/
// ============ 1. Hash vs String存储对象 ============
// 用户信息:JSON String方式
SET user:1001 "{\"name\":\"tom\",\"age\":25,\"city\":\"bj\"}" // 约50字节
// Hash方式(字段独立)
HSET user:1001 name "tom" age 25 city "bj" // 约30字节(省去JSON结构)
// ============ 2. intset vs hashtable ============
// 存储整数ID集合
SADD friend:1001 1002 1003 1004 // 所有元素都是整数,用intset编码
> OBJECT ENCODING friend:1001
"intset" // 节省内存(有序数组,无指针开销)
// 插入非整数后自动转换
SADD friend:1001 "abc" // 转为hashtable
> OBJECT ENCODING friend:1001
"hashtable"
// ============ 3. Bitmap存储状态 ============
// 用户签到(1000万用户)
// Set方式:每个用户一个key,内存巨大
// String方式:每个用户一个字节,10MB
SETBIT sign:20230101 1000000 1 // 仅用125KB存储1000万用户签到状态
// ============ 4. HyperLogLog基数统计 ============
// UV统计(1000万独立访客)
// Set方式:存储所有ID,内存 > 100MB
// HyperLogLog方式:固定12KB,误差0.81%
PFADD uv:20230101 user:1001 user:1002 ...
PFCOUNT uv:20230101 // 返回近似UV
// ============ 5. GEO地理位置 ============
// 存储地理位置(使用geohash编码)
GEOADD location 116.40 39.90 "beijing" // 底层用zset存储
GEORADIUS location 116.40 39.90 100 km // 附近查询
|
项目场景:在亿级用户签到系统中,用SETBIT存储每月签到状态,内存从10GB降到125MB;在日活统计中,用HyperLogLog将内存从100MB降到12KB;在附近的人功能中,用GEO替代手动计算,开发效率提升10倍。
实战内存优化清单
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
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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
| /**
* Redis内存优化完整指南
*/
public class RedisMemoryOptimization {
/**
* 1. 优化手段速查表
*/
public class OptimizationChecklist {
// 手段 配置/命令 内存节省
// 淘汰策略 maxmemory-policy 动态平衡
// 压缩编码 hash-max-ziplist-entries 30-70%
// 数据分片 用Hash替代String+JSON 30-50%
// Bitmap SETBIT 90%+
// HyperLogLog PFADD 99%+
// 短结构 合理设置阈值 避免转换
// 过期时间 EXPIRE 及时清理
// 内存碎片整理 MEMORY PURGE 10-30%
}
/**
* 2. 监控命令
*/
public class MonitorCommands {
// 查看内存使用
> INFO memory
// used_memory: 实际使用内存
// used_memory_rss: 系统分配内存(包括碎片)
// mem_fragmentation_ratio: 碎片率
// 查看键内存占用
> MEMORY USAGE user:1001
(integer) 1024
// 查看编码
> OBJECT ENCODING user:1001
"ziplist"
// 内存碎片整理
> MEMORY PURGE
}
/**
* 3. 优化实战案例
*/
public class OptimizationCases {
// 案例1:缓存1亿用户信息
// 优化前:String+JSON,内存12GB
// 优化后:Hash分片(user:hash:1..1000),内存4.5GB
// 案例2:存储10亿设备在线状态
// 优化前:Set存储,内存40GB
// 优化后:Bitmap(online:20230101),内存125MB
// 案例3:统计1000万PV的UV
// 优化前:Set存储用户ID,内存200MB
// 优化后:HyperLogLog,内存12KB
// 案例4:用户签到30天
// 优化前:30个key/用户,内存300GB
// 优化后:按月Bitmap,内存3GB
}
/**
* 4. 面试必备
*/
public class InterviewQA {
// Q: 内存淘汰策略怎么选?
// A: 缓存用allkeys-lru,会话用volatile-ttl
// Q: 压缩编码阈值设多少合适?
// A: 根据平均元素大小和数量测试决定
// Q: 碎片率高了怎么办?
// A: MEMORY PURGE或重启(主从切换)
// Q: 1亿用户签到怎么设计?
// A: Bitmap按月分片
}
}
/**
* 内存优化总结表
*
* 优化方向 具体措施 收益
* 淘汰策略 选择合适的maxmemory-policy 控制最大内存
* 编码优化 调整压缩阈值 内存降30-70%
* 结构优化 Hash/Bitmap/HLL 内存降90%+
* 分片优化 大key拆小 均衡负载
* 过期清理 合理设置TTL 及时释放
* 碎片整理 MEMORY PURGE 整理碎片
*
* 最佳实践:
* 1. 先监控,后优化
* 2. 小对象用压缩编码
* 3. 大集合用分片
* 4. 状态用Bitmap
* 5. 统计用HLL
*/
// 面试金句
// "Redis内存优化就像'居家收纳'的艺术:
// 淘汰策略是'断舍离'(定期扔掉不用的东西),
// 压缩编码是'真空压缩袋'(把衣服压扁省空间),
// 数据结构选择是'收纳盒分类'(用Hash/Bitmap替代乱放),
// 分片是'柜子分区'(避免一个抽屉塞太满)。
// 在签到系统中,我们用Bitmap把10GB数据压到125MB,
// 用HyperLogLog把UV统计从100MB降到12KB,
// 用Hash分片把用户信息从12GB优化到4.5GB。
// 理解这些技巧,就能让Redis在有限内存中发挥最大价值。"
|
Redis 的持久化机制有哪些?RDB 和 AOF 的区别?如何选择?
Redis 提供三种持久化机制:RDB(快照)、AOF(日志)及 RDB-AOF 混合持久化。
1. RDB 与 AOF 的核心区别:
- 实现原理:RDB 是将某一时刻的内存数据生成二进制快照文件(全量备份);AOF 则是将所有写命令以日志形式追加记录(增量备份)。
- 数据安全:RDB 安全性较低,故障时可能丢失最后一次快照后的所有数据;AOF 安全性较高,根据策略(如
everysec)最多丢失 1 秒数据。 - 文件与恢复:RDB 文件紧凑、体积小,恢复速度快;AOF 文件体积大(需 Rewrite 重写机制压缩),恢复速度相对较慢。
- 系统开销:RDB 在 fork 子进程生成快照时会消耗 CPU 和内存,但平时无磁盘压力;AOF 持续写入磁盘,对性能有一定影响但较为平稳。
2. 选择建议:
- 推荐方案(混合持久化):Redis 4.0 以后版本首选。结合两者优势,AOF 重写时以 RDB 格式记录全量数据,后续追加 AOF 增量日志。恢复时先加载 RDB 快速恢复基线,再回放 AOF 日志,兼顾了恢复速度与数据安全。
- 单选场景:若对数据丢失不敏感(如纯缓存场景),追求高性能与快速恢复,首选 RDB;若对数据完整性要求极高(如金融交易、订单系统),且能接受一定的性能损耗,首选 AOF。
大 Key 问题是什么?有什么危害?如何发现和解决?
一句话原理:大 Key 指单个 Key 的 Value 过大(如 String 超过 10KB)或包含元素过多(如集合超 5000 个),导致内存分布不均、阻塞 Redis 单线程主循环及引发网络拥塞。
一句话源码:在 db.c 的删除逻辑中,大 Key 会长时间占用主线程释放内存,且 networking.c 在输出缓冲区发送大 Key 数据时会瞬间占用大量带宽。
一句话项目/场景:生产环境中应禁止将大文件二进制存入 Redis,对于聚合数据采用 Hash Tag 分片拆分,治理时使用 UNLINK 异步删除代替 DEL 以防止服务阻塞。
详细解析
什么是大 Key?
大 Key 并非指 Key 本身很长,而是指 Key 对应的 Value 占用内存过大或元素过多。通常的判定标准如下:
- String 类型:Value 值超过 10KB(甚至 1MB)。
- 集合类型:List、Set、Hash、ZSet 等元素数量超过 5000 个,或所有元素的总内存占用过大。
大 Key 的危害
- 阻塞主线程:Redis 为单线程模型,操作大 Key(读取、删除、序列化)耗时长,会阻塞后续请求,导致服务响应变慢甚至超时。
- 内存不均:在集群模式下,大 Key 导致特定节点内存占用过高,引发数据倾斜。
- 网络拥塞:大 Key 在传输时占用大量带宽,可能导致网卡打满,影响该实例上其他请求的正常通信。
- 主从同步延迟:大 Key 会增加主从复制的压力,导致同步延迟或缓冲区溢出。
如何发现大 Key?
- 命令行工具:使用
redis-cli --bigkeys 扫描并输出每种数据类型最大的 Key(注意:该命令只能找到各类型的 Top 1,且可能阻塞,建议在从节点或低峰期执行)。 - 实时命令:使用
MEMORY USAGE <key> 获取指定 Key 的内存占用字节数;或使用 SCAN 命令遍历结合 STRLEN/LLEN 等进行排查。 - 离线分析:使用 Redis RDB Tools 解析 RDB 快照文件,分析内存分布情况。
- 监控指标:关注 Redis 的慢查询日志以及
blocked_clients 指标。
如何解决大 Key 问题?
- 预防为主:
- 拆分:将大 Key 拆分为多个小 Key(如按业务维度或 Hash Tag 分片)。
- 压缩:对 String 类型的大文本进行压缩(如 GZIP、Protobuf)。
- 本地缓存:将不常变的大对象存放在本地内存或分布式对象存储(OSS)中,Redis 仅存储引用 ID。
- 治理方案:
- 安全删除:严禁使用
DEL 命令直接删除大 Key,应使用 UNLINK 命令,它会在后台异步释放内存,不阻塞主线程。 - 渐进式删除:对于 Hash、Set 等集合,使用
HSCAN、SSCAN 分批删除元素,最后删除空 Key。 - 序列化优化:在业务层优化序列化协议,减少数据体积。