Redis 核心机制与工程实践:从数据结构选型到持久化的设计权衡
一、Redis 为什么快:不只是"内存"
"Redis 快是因为数据在内存里。"——这句话对但不够。如果只是内存操作快,那任何 HashMap 都够快了。Redis 的性能来自多个设计决策的叠加效应。
单线程模型
Redis 用单线程处理所有客户端请求。这不是技术限制,而是刻意的设计选择:
- 无锁竞争:所有操作天然串行化,不需要任何锁机制
- 无上下文切换:没有线程调度开销
- 数据结构可以更简单:不用考虑并发安全,实现更紧凑高效
Redis 的瓶颈从来不是 CPU——单线程下 CPU 利用率很难跑满。真正的瓶颈在内存带宽和网络 I/O。
I/O 多路复用
单线程不意味着一次只能处理一个连接。Redis 使用 epoll/kqueue 等 I/O 多路复用技术,单线程也能同时监听成千上万个连接。当有数据可读时才去处理,避免空等。
基准性能
| 场景 | 吞吐量 / 延迟 |
|---|---|
| 本地 Unix Socket,INCR 命令 | 100K+ TPS |
| LAN 网络,简单 GET/SET | ~1ms 延迟 |
| Pipeline 批量操作 | 吞吐提升 5~10 倍 |
SLOWLOG:发现真正的慢操作
SLOWLOG GET 10 -- 获取最近 10 条慢查询
Redis 默认记录执行时间超过 10ms 的命令(可配置 slowlog-log-slower-than)。注意:SLOWLOG 不包含网络 I/O 时间,只记录命令本身的执行耗时。如果 SLOWLOG 里出现了简单命令(如 GET),通常说明内存不足导致了 swap。
二、五种数据类型与内部编码
Redis 不是一个简单的 Key-Value 存储,它的五种数据类型各有针对性的内部实现,选对类型是性能优化的起点。
2.1 String:不只是字符串
内部结构:SDS(Simple Dynamic String)
struct sdshdr {
long len; // 已使用长度
long free; // 剩余可用空间
char buf[]; // 实际数据(二进制安全)
};
与 C 字符串相比,SDS 的优势:
- 二进制安全:可以存储任意二进制数据(图片、序列化对象),不受
\0截断 - O(1) 获取长度:直接读
len字段 - 空间预分配:
free字段减少内存重分配次数
核心操作与适用场景:
| 操作 | 命令 | 适用场景 |
|---|---|---|
| 原子计数 | INCR / INCRBY | 计数器、限流、ID 生成 |
| 设置过期 | SET key value EX seconds | 缓存、分布式锁 |
| 批量读写 | MGET / MSET | 减少网络往返 |
内存开销:一个 String 类型的 Key-Value 约有 90 字节 的元数据开销。如果你存储大量短小的值(如用户 ID → 用户名映射),考虑改用 Hash 类型来降低开销。
最大值大小:1GB。
2.2 List:双向链表
底层实现为双向链表,O(1) 的头尾操作,O(N) 的随机访问。最大长度 2³²-1。
核心操作:
LPUSH / RPUSH -- 头部/尾部插入
LPOP / RPOP -- 头部/尾部弹出
LRANGE 0 -1 -- 获取全部元素
LINDEX 3 -- 按下标访问(O(N),慎用)
LTRIM 0 99 -- 只保留前 100 个元素
阻塞操作(消息队列语义):
BLPOP key 30 -- 阻塞弹出,最多等 30 秒
BRPOP key 0 -- 阻塞弹出,永久等待
RPOPLPUSH src dst -- 原子地从 src 尾部弹出,推入 dst 头部
RPOPLPUSH 可以实现可靠队列:消费者从工作队列弹出任务的同时推入备份队列,处理完成后再从备份队列删除。如果消费者崩溃,备份队列中的任务不会丢失。
适用场景:消息队列、最新动态(Timeline)、任务队列。
2.3 Hash:对象存储的正确方式
内部编码演变:
| 条件 | 编码 | 性能 | 内存 |
|---|---|---|---|
| 字段数 ≤ 64 且每个值 ≤ 512B | zipmap(紧凑编码) | O(N) 但对少量字段很快 | 极省 |
| 超过阈值 | hashtable | O(1) | 正常 |
阈值可通过 hash-max-zipmap-entries(默认 64)和 hash-max-zipmap-value(默认 512)配置。
为什么用 Hash 而不是多个 String?
假设存储 100 万个用户的 3 个属性(name, age, email):
| 方案 | Key 数量 | 内存开销 |
|---|---|---|
3 个 String:user:1:name, user:1:age, user:1:email |
300 万 | 每个 Key 90 字节开销 × 300 万 |
1 个 Hash:user:1 → {name, age, email} |
100 万 | 共享一份 Key 元数据 |
Hash 方案的内存节省通常在 50%~70%。
核心命令:
HSET user:1 name "张三" age 28 -- 设置字段
HGET user:1 name -- 获取单个字段
HMGET user:1 name age email -- 批量获取
HINCRBY user:1 age 1 -- 原子递增
HGETALL user:1 -- 获取所有字段(大 Hash 慎用)
适用场景:用户信息、配置项、购物车、任何"对象"型数据。
2.4 Set:集合运算
Hash Table 实现,O(1) 的增删查。最大元素数 2³²-1。
独特能力:集合运算:
SINTER set1 set2 -- 交集:共同好友
SUNION set1 set2 -- 并集:合并标签
SDIFF set1 set2 -- 差集:可能认识的人
SRANDMEMBER set 3 -- 随机取 3 个元素(抽奖)
SPOP set -- 随机弹出 1 个元素
适用场景:标签系统、共同好友、去重、抽奖。
2.5 Sorted Set:有序集合
内部实现:Skip List + Hash Table 混合结构。
┌──────────────────────────────────────────┐
│ Hash Table: element → score O(1) 查分 │
│ Skip List: 按 score 排序 O(log N) 范围 │
└──────────────────────────────────────────┘
两种数据结构各取所长:Hash Table 提供 O(1) 的分数查询,Skip List 提供 O(log N) 的排序和范围查询。Skip List 是双向链表式的,支持正向和反向遍历。
核心命令:
ZADD board 1000 "user:1" -- 添加/更新分数
ZINCRBY board 10 "user:1" -- 分数原子递增
ZREVRANGE board 0 9 WITHSCORES -- 排行榜前 10(降序)
ZRANGEBYSCORE board 90 100 -- 分数在 90~100 的元素
ZRANK board "user:1" -- 排名(升序)
ZREVRANK board "user:1" -- 排名(降序)
适用场景:排行榜、延迟队列(score 存时间戳)、带权重的优先级队列。
选型决策表
| 场景 | 推荐类型 | 关键命令 | 说明 |
|---|---|---|---|
| 缓存、计数器、分布式锁 | String | GET/SET/INCR | 最简单,最常用 |
| 消息队列、最新列表 | List | LPUSH/BRPOP | 阻塞弹出实现可靠消费 |
| 对象属性存储 | Hash | HSET/HGET/HMGET | 比多个 String 省内存 50%+ |
| 标签、去重、集合运算 | Set | SADD/SINTER/SDIFF | 交并差运算 |
| 排行榜、延迟队列 | Sorted Set | ZADD/ZREVRANGE | O(log N) 范围查询 |
三、过期策略:惰性删除 + 主动采样
Redis 不会为每个设置了过期时间的 Key 启动一个定时器——那样百万个 Key 就需要百万个定时器。它采用两种策略配合。
被动过期(Lazy Expiration)
访问一个 Key 时,Redis 先检查它是否过期。如果过期了,删除并返回空。
问题:如果一个 Key 设了过期时间但再也没有被访问,它就永远不会被删除,一直占着内存。
主动过期(Active Expiration)
Redis 每秒执行 10 次以下流程:
- 从设置了过期时间的 Key 中随机采样 100 个
- 删除其中已过期的
- 如果过期 Key 超过 25%,回到步骤 1 继续
这是一个概率性的清理策略——不保证所有过期 Key 都被及时清理,但保证过期 Key 不会大量累积。
主从一致性
过期删除只在 Master 上执行。Master 删除一个过期 Key 后,向 Slave 发送 DEL 命令。Slave 自己不会主动删除过期 Key——在收到 Master 的 DEL 之前,Slave 上的过期 Key 仍然可读。
内存淘汰策略
当内存使用达到 maxmemory 上限时,Redis 需要决定淘汰哪些 Key:
| 策略 | 淘汰范围 | 说明 |
|---|---|---|
| volatile-lru | 设了过期时间的 Key | 淘汰最近最少使用的(默认) |
| volatile-ttl | 设了过期时间的 Key | 淘汰剩余 TTL 最短的 |
| allkeys-lru | 所有 Key | 淘汰最近最少使用的 |
| noeviction | 不淘汰 | 写入报错(OOM) |
工程建议:生产环境必须设置
maxmemory。如果不设置,Redis 内存持续增长,最终触发 OS swap,性能断崖式下降——从微秒级变成毫秒级甚至秒级。
四、持久化:RDB 与 AOF 的设计权衡
Redis 是内存数据库,但它不是"重启就丢数据"的玩具。持久化机制让 Redis 在进程崩溃甚至断电后仍能恢复数据。
4.1 写操作的五步管线
要理解持久化的数据安全性,需要先理解一次写操作在操作系统层面经历的五个阶段:
Client Redis Server OS Kernel Disk
│ │ │ │
│── write request ──→ │ │ │
│ ①存入内存 │ │
│ │── write() ──→ │ │
│ │ ②写入内核缓冲区 │
│ │ │── transfer ──→ │
│ │ │ ③到达磁盘控制器缓存
│ │ │ │
│ │ │ ④写入物理介质
数据安全性分析:
| 故障类型 | 数据安全时机 | 说明 |
|---|---|---|
| Redis 进程崩溃 | 第 ② 步之后 | 内核缓冲区还在,OS 会最终刷盘 |
| 操作系统崩溃 | 第 ③ 步之后 | 磁盘控制器缓存有电容保护(企业级) |
| 断电 | 第 ④ 步之后 | 只有写入物理介质才绝对安全 |
fsync 的作用:强制将内核缓冲区的数据推到磁盘控制器(乃至物理介质),消除第 ②→④ 之间的不确定性。
4.2 RDB 快照
RDB 在指定的时间间隔内生成内存数据的全量快照。
触发条件(可配置):
save 900 1 # 900 秒内至少 1 次写入
save 300 10 # 300 秒内至少 10 次写入
save 60 10000 # 60 秒内至少 10000 次写入
实现机制:
1. Redis Fork 子进程
2. 子进程遍历内存,将数据写入临时文件(RDB 格式)
3. 写完后,原子 rename 替换旧 RDB 文件
4. 父进程继续处理请求(通过 COW 机制共享内存页)
COW(Copy-On-Write):Fork 之后父子进程共享内存页。只有当父进程修改某个内存页时,OS 才会复制这个页。如果写入量不大,Fork 的额外内存开销很小。
性能数据:
| 指标 | 数据 |
|---|---|
| Fork 耗时 | ~10ms / GB 内存 |
| 1.5GB 快照 → 200MB 文件 | ~8 秒(PC 级机器) |
| RDB 加载速度 | 极快(直接映射到内存结构) |
原子性保证:先写临时文件,再 rename 替换。如果写入过程中进程崩溃,旧 RDB 文件不受影响——数据永远不会损坏,最多丢失最后一次快照之后的写入。
局限:两次快照之间的数据可能丢失。按默认配置,最坏情况下可能丢失 最近 5 分钟 的数据。
4.3 AOF 日志
AOF(Append Only File)记录每一条写命令,格式是 Redis 协议文本(人类可读)。
三种 fsync 策略:
| 策略 | 行为 | 最大数据丢失 | 性能影响 |
|---|---|---|---|
appendfsync no |
不主动 fsync,交给 OS | ~30 秒(Linux 默认) | 最小 |
appendfsync everysec |
每秒 fsync 一次 | 最多 2 秒 | 小(推荐) |
appendfsync always |
每条命令都 fsync | 不丢数据 | 大 |
为什么 everysec 最多丢 2 秒而不是 1 秒?因为如果上一次 fsync 超过 1 秒还没完成,Redis 会延迟当前的 fsync(避免阻塞主线程),最终可能累积两秒的数据。
AOF 重写:
AOF 文件随写入不断增长。Redis 通过重写来压缩文件:Fork 子进程遍历内存,生成等价的最小命令集。比如一个 Key 被 SET 了 100 次,重写后只保留最后一次的 SET 命令。
性能数据:1.4GB AOF 文件加载约 13 秒。
4.4 RDB vs AOF 决策
| 维度 | RDB | AOF |
|---|---|---|
| 数据安全性 | 可能丢数分钟 | 最多丢 1~2 秒(everysec) |
| 文件大小 | 紧凑(二进制) | 较大(文本命令,需定期重写) |
| 启动恢复速度 | 极快(直接加载) | 较慢(逐条重放命令) |
| 写性能影响 | Fork 时有短暂阻塞 | 每秒 fsync 影响小 |
| 文件可读性 | 不可读 | 可读(Redis 协议文本) |
| 适合做备份 | 是(完整快照,可远程传输) | 不适合(文件持续增长) |
工程建议:
- 生产环境两者都开:RDB 做定期冷备(便于传输和恢复),AOF 做热恢复(丢数据少)
- 如果只能选一个:选 AOF(数据安全性更高)
- 内存安全边界:预留 2 倍 已用内存给 Fork 的 COW 开销。1GB 数据 → 至少准备 2GB 可用内存
五、复制与高可用
5.1 主从复制
Redis 的复制是异步的,Slave 最终一致。
全量同步(首次连接或数据差异过大时):
Slave 发送 SLAVEOF master_ip port
↓
Master 执行 BGSAVE,生成 RDB 文件
↓
Master 将 RDB 传输给 Slave
↓
Slave 丢弃旧数据,加载 RDB
↓
Master 将 BGSAVE 期间的写命令发送给 Slave
↓
进入增量同步状态
增量同步:Master 将每条写命令实时发送给 Slave 重放。
PSYNC(Redis 2.8+):部分重同步。Master 维护一个复制积压缓冲区(replication backlog),如果 Slave 断连后再连上,且断连期间的数据还在缓冲区内,就只发送缺失的命令,避免全量复制。
5.2 Sentinel 哨兵
主从复制解决了数据冗余,但 Master 挂了需要人工切换。Sentinel 实现自动故障转移。
架构:
┌─────────┐ ┌─────────┐ ┌─────────┐
│Sentinel 1│ │Sentinel 2│ │Sentinel 3│ ← 独立进程,至少 3 个
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└───── 监控 ─────┼───── 监控 ────┘
│
┌───────┴───────┐
│ Master │
└───┬───────┬──┘
│ │
┌──────┘ └──────┐
│ │
┌────┴────┐ ┌────┴────┐
│ Slave 1 │ │ Slave 2 │
└─────────┘ └─────────┘
故障检测过程:
| 阶段 | 行为 | 时间 |
|---|---|---|
| 心跳 | 每个 Sentinel 每秒向 Master/Slave 发 PING | 持续 |
| 主观下线(sdown) | 某个 Sentinel 连续无响应 | 30 秒(可配置) |
| 客观下线(odown) | 多数 Sentinel(quorum,默认 2)在 5 秒内一致认为 Master 不可用 | 5 秒 |
| 故障转移 | 选举 Leader Sentinel → 选择最优 Slave → 提升为 Master → 重定向其他 Slave | 秒级 |
Sentinel 之间的发现:通过 Pub/Sub 通道,每 5 秒广播自己的信息。新加入的 Sentinel 自动被发现。
5.3 Cluster(简述)
Redis Cluster 提供数据自动分片和高可用:
- 16384 个 slot:每个 Key 通过 CRC16 哈希映射到一个 slot,每个节点负责一部分 slot
- 客户端重定向:请求的 Key 不在当前节点时,返回
MOVED或ASK指引客户端到正确节点 - 自动故障转移:每个 Master 有一个或多个 Slave,Master 挂掉后 Slave 自动提升
vs 客户端分片(如 Jedis ShardedJedis):Cluster 支持在线迁移 slot(动态扩缩容),客户端分片不支持。
六、性能调优实践
Pipeline:减少网络往返
Redis 命令的延迟大部分花在网络往返(RTT)上。Pipeline 将多个命令打包成一次网络请求:
-- 不用 Pipeline:100 个 SET → 100 次 RTT
-- 用 Pipeline:100 个 SET → 1 次 RTT
Pipeline 不是原子操作(中间可能插入其他客户端的命令),但吞吐可提升 5~10 倍。
Lua 脚本:服务端执行
EVAL "redis.call('SET', KEYS[1], ARGV[1]); redis.call('EXPIRE', KEYS[1], ARGV[2])" 1 mykey myvalue 60
Lua 脚本在 Redis 中天然是原子的——脚本执行期间不会被其他命令打断。适合需要"读-判断-写"原子性的场景(如分布式锁、限流计数器)。
大 Key 问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| DEL 大 Key 阻塞 | 释放大块内存需要时间 | 用 UNLINK(异步删除,Redis 4.0+) |
| Set 扩容阻塞 | Hash Table resize 需要写锁 | 控制 Set 大小,拆分到多个 Key |
| HGETALL 大 Hash 阻塞 | 遍历大量字段 | 用 HSCAN 分批获取 |
| 网络阻塞 | 大 Value 传输占用带宽 | Value 大小控制在 10KB 以内 |
生产禁用命令
| 命令 | 风险 | 替代方案 |
|---|---|---|
KEYS * |
O(N) 遍历所有 Key,阻塞主线程 | SCAN(游标分批遍历) |
FLUSHDB / FLUSHALL |
清空数据库 | 线上应禁用 |
SORT |
CPU 密集型排序 | 在 Slave 上执行,或应用层排序 |
连接池配置
以 Jedis 为例,默认最大连接数为 8。高并发场景下远远不够:
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(200); // 最大连接数
config.setMaxIdle(50); // 最大空闲连接
config.setMinIdle(10); // 最小空闲连接
config.setMaxWaitMillis(3000); // 获取连接超时
配置原则:连接数 ≥ 峰值并发线程数,但不要过多(Redis 单线程,连接再多也是排队)。
七、Redis vs Memcached:选型依据
两者都是内存缓存,但定位不同。
| 维度 | Redis | Memcached |
|---|---|---|
| 数据结构 | String/List/Hash/Set/Sorted Set | 纯 Key-Value |
| 持久化 | RDB + AOF | 无 |
| 高可用 | 主从复制 + Sentinel | 无内置方案 |
| 内存效率 | 有对象开销(~90 字节/Key) | 更高效的 slab 分配 |
| 单 Value 上限 | 1GB | 1MB |
| 多线程 | 单线程(6.0 起 I/O 多线程) | 多线程 |
| 集群 | Redis Cluster | 客户端一致性 Hash |
选择 Memcached 的场景:
- 纯缓存,不需要持久化
- 大 Value(> 100KB),Memcached 的 slab 内存分配更高效
- 简单 KV,不需要复杂数据结构
- 已有成熟的 Memcached 集群
选择 Redis 的场景:
- 需要持久化(缓存 + 存储双角色)
- 需要复杂数据结构(排行榜、队列、集合运算)
- 需要内置高可用(Sentinel、Cluster)
- 需要 Pub/Sub、Lua 脚本等高级特性
大多数新项目选 Redis——功能超集,生态更活跃。选 Memcached 的理由通常是"已经在用"或"纯缓存场景下更高的内存效率"。