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 次以下流程:

  1. 从设置了过期时间的 Key 中随机采样 100 个
  2. 删除其中已过期的
  3. 如果过期 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 不在当前节点时,返回 MOVEDASK 指引客户端到正确节点
  • 自动故障转移:每个 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 的理由通常是"已经在用"或"纯缓存场景下更高的内存效率"。

加载导航中...

评论