Redis 底层原理与架构
Redis 是基于内存的键值存储,常被当作缓存、消息队列、分布式锁甚至轻量数据库使用。它之所以通用,关键在于其丰富的数据结构与精巧的底层实现。本文聚焦 Redis 的"内部"——数据结构的底层编码、Lua 执行模型、过期与淘汰机制、持久化、集群架构与故障转移;命令用法与适用场景见姊妹篇《Redis 使用手册》。
全局视角
Redis 的 key 是二进制安全的字符串,value 则可以是以下几种类型之一。每种类型对应一组命令,并拥有各自的底层编码(Redis 会在元素数量、体积变化时自动切换编码以平衡内存与性能)。
| 类型 | 典型用途 | 底层编码(7.x) |
|---|---|---|
| String | 缓存、计数器、分布式锁 | int / embstr / raw |
| List | 消息队列、最新列表 | listpack / quicklist |
| Hash | 对象存储 | listpack / hashtable |
| Set | 去重、标签、抽奖 | intset / hashtable |
| ZSet | 排行榜、延迟队列 | listpack / skiplist + hashtable |
| Stream | 消息流、事件日志 | radix tree + listpack |
| Bitmap | 签到、活跃统计 | String 上的位操作 |
| HyperLogLog | 基数统计 | String 上的稀疏/密集结构 |
| Geo | 附近的人/店 | ZSet + GeoHash 编码 |
| Lua 脚本 | 原子复合操作、分布式锁释放 | 内置 Lua 解释器 + 脚本缓存 |
编码细节会随版本演进,但「小数据用紧凑结构省内存、大数据用高效结构保性能」的思路始终一致。可用
OBJECT ENCODING key查看实际编码。
数据结构底层编码
String
String 是最基础也最被低估的类型。它不仅能存文本,还能存数字、二进制(图片、序列化对象),最大 512MB。
- int:值为整数且能用
long表示时,直接存整数,不分配 SDS。 - embstr:短字符串(≤ 44 字节),
redisObject与 SDS 内存连续分配,一次malloc,缓存友好。 - raw:长字符串,
redisObject与 SDS 分开分配。
embstr 是只读的——任何修改都会使其转为 raw。
1 | |
int 编码不分配 SDS,整数值直接塞进 ptr(ptr=(void*)((long)value));embstr 把 robj 与 sdshdr8 拼成连续内存一次 malloc;raw 则两者分开分配——上表三种编码即源于此。
| 编码 | 内存形态 | 特点 |
|---|---|---|
| int | redisObject.ptr 直接保存整数值 | 不分配 SDS,适合可表示为 long 的整数 |
| embstr | redisObject 与 SDS 连续分配 | 一次 malloc,缓存友好,短字符串专用 |
| raw | redisObject 与 SDS 分开分配 | 两次分配,适合较长字符串,修改更灵活 |

List
双向链表语义的有序字符串集合,支持两端高效 push/pop。
- listpack:元素少且小时使用,紧凑连续内存。
- quicklist:以 listpack 为节点的双向链表,兼顾内存紧凑与随机访问,兼顾两者优点。每个 ziplist/listpack 节点大小可配置。
quicklist 可以理解为「链表外壳 + listpack 内核」:链表负责两端插入删除,listpack 负责把一批元素紧凑地放在连续内存里。

Hash
字段-值映射,值本身也是字符串。一个 Hash 就像一个对象。
- listpack:字段少且短时使用。
- hashtable:字段多时转为真正的哈希表,O(1) 读写但内存开销大。
| 编码 | 存储方式 | 读写特点 |
|---|---|---|
| listpack | field/value 成对写入连续内存 | 省内存,查找需要顺序扫描 |
| hashtable | bucket 数组 + dictEntry 链表 | O(1) 读写,指针与哈希桶带来额外内存开销 |

Set
无序、唯一字符串集合,支持丰富的集合运算。
- intset:全为整数且数量少时使用,紧凑有序数组。
- hashtable:含字符串或元素多时使用。

ZSet(Sorted Set)
有序集合,每个元素关联一个 score,按 score 排序且元素唯一。Redis 中表达力最强的结构。
- listpack:元素少且短时使用。
- skiplist + hashtable:元素多时使用。跳表提供按 score 的有序遍历与范围查询(O(logN)),hashtable 提供 O(1) 的元素→score 反查。两者共用元素引用,不重复存储字符串。
1 | |
每个节点按概率决定层数,span 让 ZRANK 这类按排名的查询同样保持 O(logN);ele 与哈希表共用,故 member→score 反查 O(1) 而不重复存字符串。
| 编码 | 组成 | 能力 |
|---|---|---|
| listpack | element/score 成对连续存储 | 省内存,适合小 ZSet |
| skiplist + hashtable | 跳表按 score 排序,哈希表按 member 反查 score | 范围查询 O(logN),成员查分 O(1) |

ZSet 的 score 是 double(64 位浮点),存大时间戳(毫秒)足够精确,但极端大整数会有精度损失。
Stream
5.0 引入,专为消息流设计,可看作「自带持久化、消费组、ack 的日志结构」。
Stream 是只追加的日志,每条消息有自动生成的 ID(<毫秒时间戳>-<序号>),可按 ID 或时间范围查询。底层用 rax(radix tree)按消息 ID 建索引,叶子节点指向 listpack 消息块。rax 负责范围定位,listpack 负责紧凑保存多条消息。

Lua 脚本底层实现
Redis 从 2.6 起内置 Lua 解释器,允许将多条命令打包为一段脚本原子执行——整个脚本期间 Redis 不会切换到其他客户端,天然避免了竞态条件。
Lua 环境初始化
Redis 启动时创建一个 Lua 环境(luaState),并做以下定制:
- 替换随机数生成器:用确定性的伪随机算法替换
math.random,保证主从复制结果一致。 - 禁用危险函数:
dofile、loadfile、io、os.execute等被删除或替换为空操作,防止脚本访问文件系统或执行系统命令。 - 注入
redis全局表:提供redis.call()、redis.pcall()、redis.status_reply()、redis.error_reply()、redis.log()、redis.sha1hex()等 API。 - 设置全局保护:7.0 之前脚本可以创建全局变量(易引发难以排查的 bug);7.0+ 默认禁止在脚本中创建全局变量(
lua-env-flag机制),试图赋值全局会报错。
执行模型
1 | |
关键特性:
- 原子性:脚本在 Redis 主线程中执行,期间不处理其他客户端命令。因此脚本必须快——长时间脚本会阻塞整个实例。
- 超时与 kill:
lua-time-limit(默认 5 秒)超时后,Redis 并不会主动终止脚本(因为 Lua 没有安全的中断机制),而是进入「可被 kill」状态,客户端可执行SCRIPT KILL(若脚本没写过数据)或SHUTDOWN NOSAVE(强制停机回滚)。 - 纯函数约束:为保障主从一致性,脚本中禁止调用
TIME、SRANDMEMBER、RANDOMKEY、SCAN等非确定性命令。7.0 前redis.replicate_commands()可显式声明副作用复制模式绕过此限制;7.0+ 默认采用效果复制(effect replication),不再需要显式声明。
脚本缓存
- 编译后的字节码以 SHA1 为 key 存在 server 级别的
script_cache字典中。 - 缓存不会随 RDB/AOF 持久化——重启后缓存为空,客户端需重新
EVAL或SCRIPT LOAD。 SCRIPT FLUSH清空所有缓存,FUNCTION FLUSH清空函数缓存,两者互不干扰。
复制模式演进
| 版本 | 默认复制方式 | 说明 |
|---|---|---|
| < 3.2 | 整脚本复制 | 主节点把完整脚本发到从节点重放,保证确定性 |
| 3.2–6.x | 可选效果复制 | redis.replicate_commands() 开启后,只复制写命令本身 |
| 7.0+ | 效果复制 | 默认只复制写命令的效果,脚本不再需要是纯函数 |
效果复制意味着:即使脚本里调用了 TIME 等命令,从节点也不会因结果不一致而出问题——因为只有写操作的结果被传播,而非整个脚本。
过期与淘汰
过期删除策略
为 key 设置过期时间,是 Redis 控制内存、回收冷数据的核心手段。过期时间记录在与主字典分离的 expires 字典中——key 本身不受影响,仅多一条「到期时间」。Redis 采用两种策略配合删除:
- 惰性删除:访问 key 时才检查过期,过期则删。CPU 开销分摊在访问路径,但冷 key 可能长期占内存。
- 定期删除:后台周期性从 expires 字典随机抽样删除已过期 key,并通过自适应算法调节每次抽样的时长与频率,平衡 CPU 与回收速度。
惰性删除兜底访问路径,定期删除清扫冷数据,再配合 maxmemory + 淘汰策略,构成完整的内存治理链路。
1 | |
访问路径上调用此函数实现惰性删除;从库只读不删、删除权归主库,正是前文「删除由主库主导」的代码体现。
从库过期:删除由主库主导,完成后传播
DEL给从库;3.2+ 从库读取过期 key 时也会返回不存在(惰性),但实际删除仍以主库为准,避免主从不一致。
内存淘汰策略
当内存达到 maxmemory 上限时触发淘汰:
| 策略 | 范围 | 依据 |
|---|---|---|
noeviction | — | 默认,写操作直接报错 |
allkeys-lru | 全部 key | 最近最少使用 |
allkeys-lfu | 全部 key | 最不经常使用(4.0+) |
volatile-lru | 设过期的 key | LRU |
volatile-lfu | 设过期的 key | LFU |
allkeys-random | 全部 key | 随机 |
volatile-random | 设过期的 key | 随机 |
volatile-ttl | 设过期的 key | 优先淘汰 TTL 最短的 |
LRU/LFU 均为近似算法(基于抽样而非精确链表)以节省内存。纯缓存常用
allkeys-lru/allkeys-lfu;key 均有 TTL 时volatile-ttl也合理。
1 | |
淘汰不维护全局 LRU 链表,而是随机抽 N 个 key 取最该淘汰者入小池——这就是「近似算法基于抽样」的内存取舍;调大 maxmemory-samples 提升精度但增加 CPU。
SCAN 游标机制
SCAN 系列以游标为基础分批增量遍历,是生产遍历 key 的唯一推荐方式(KEYS 一次性遍历会阻塞主线程,生产禁用)。
- 基于底层字典的渐进式遍历,cursor 是位反转后的桶索引,在 rehash 期间也能正确推进。
- 不保证完整性:遍历期间被修改的 key 可能返回多次,也可能完全不返回。语义是「尽力而为」,适合统计、清理等容忍误差的场景,不适合强一致全量快照。
- COUNT 是提示而非保证:
COUNT 10仅建议每次约返回 10 个元素,实际受桶填充度影响可能远多远少。返回空列表且 cursor 非零时须继续扫描,而非停止。
1 | |
常规 v++ 在 rehash 扩容时会漏桶;位反转递增让游标在高位推进,使得无论期间是否扩容,每个桶恰好被访问一次(元素层面「不保证完整」的语义仍成立)。
持久化(RDB / AOF)
Redis 数据在内存中,掉电即失。持久化负责把内存数据落盘,是备份与重启恢复的基础。Redis 提供两种互补机制,可单独或组合使用。
RDB(快照)
把某一时刻的内存数据以紧凑二进制形式写入磁盘文件 dump.rdb。
- 触发方式:
SAVE在主线程同步快照(会阻塞,生产禁用);BGSAVE通过fork子进程异步写盘,主线程继续服务;也可用save m n配置(save 900 1表示 900s 内至少 1 次写则触发)。 - fork 与 COW:子进程共享父进程内存页,借助操作系统的写时复制(copy-on-write)获得一致性快照,期间父进程的写操作会复制出新页。大实例 fork 本身有停顿,且写量大时内存峰值会上升。
- 优点:文件小、加载快、适合远程备份与灾难恢复。
- 缺点:两次快照之间的写入可能丢失。
AOF(追加日志)
把每条写命令追加到日志文件 appendonly.aof,恢复时按顺序回放。
- fsync 策略(
appendfsync)决定持久化强度与性能的权衡:always:每条命令都 fsync,最安全但最慢。everysec:每秒 fsync 一次(默认),最多丢 1 秒数据,性能与安全的平衡点。no:交由 OS 决定,性能最好但数据丢失风险最大。
- 重写(rewrite):日志会随写操作无限增长,
BGREWRITEAOF在后台 fork 子进程,根据当前内存状态生成最小等价命令集,压缩体积。可由auto-aof-rewrite-percentage/auto-aof-rewrite-min-size自动触发。 - 优点:数据完整性高(
everysec下最多丢 1s)。 - 缺点:文件比 RDB 大、恢复更慢。
混合持久化
aof-use-rdb-preamble(4.4 引入,5.0+ 默认开启)。AOF 重写时,文件前半段写 RDB 格式的全量快照、后半段追加增量写命令。加载时先载入 RDB 快照(快),再回放增量命令(全),兼顾恢复速度与数据完整性。
加载优先级
启动恢复时若同时存在 AOF 与 RDB,优先加载 AOF(因其数据更完整)。RDB 单独存在时才加载 RDB。
脚本缓存不随 RDB/AOF 持久化,重启后需重新
EVAL或SCRIPT LOAD。
集群架构
Redis Cluster 通过数据分片将 key 分散到多个主节点,每个主节点配若干从节点,提供横向扩容与自动故障转移。它与「主从复制 + Sentinel 哨兵」的区别在于:主从复制是单机数据全量冗余、Sentinel 负责监控选主;Cluster 则把数据切分到不同节点,写入与存储能力随节点数线性扩展。
槽位与路由
Cluster 把整个 keyspace 划分为 16384 个槽(slot),每个主节点负责其中一段。key 所属槽由哈希决定:
1 | |
1 | |
& 16383 即 mod 16384;{user:1001}.profile 与 {user:1001}.cart 只对 user:1001 哈希从而落到同一槽——这正是「多 key 同槽」「Lua/事务同槽」的底层实现。
客户端可与任意节点通信:若 key 落在该节点负责的槽,直接处理;否则返回 MOVED <slot> <host:port> 重定向,客户端据此缓存槽映射,后续直连目标节点,稳定后几乎不再重定向。槽迁移期间还会出现 ASK 临时重定向——本次请求走新节点,但不更新本地映射(迁移尚未完成)。
集群拓扑与通信
- Gossip 协议:节点间周期性交换状态(存活、槽归属),探测故障并传播配置变更。
- 主从复制:每个主节点配至少一个从节点,实时同步主节点数据,主节点宕机时从节点顶上。
- 最少 3 主 3 从:故障转移依赖多数派投票,至少 3 个主节点才能形成多数,最小可用集群为 3 主 3 从(6 节点)。
故障转移
- 主观下线(PFAIL):节点 A 在
cluster-node-timeout内未收到节点 B 的 Gossip 响应,标记 B 疑似下线。 - 客观下线(FAIL):超过半数主节点都报告 B 的 PFAIL,B 被标记为客观下线。
- 选举升主:B 的从节点发起选举,获多数主节点投票后升为新主,接管 B 的槽与流量。
整个过程自动完成,无需人工介入(Sentinel 哨兵逻辑类似,但它是独立进程、针对单机主从架构)。
集群限制
| 限制 | 说明 |
|---|---|
仅 db 0 | Cluster 不支持多 db,所有 key 都在 0 号库 |
| 多 key 命令须同槽 | MGET/MSET/SUNION 等涉及多 key 的命令要求所有 key 同槽,否则报 CROSSSLOT 错误 |
| 事务/Lua 须同槽 | MULTI/EXEC 与 EVAL 涉及的 key 必须同槽,常配合 Hash Tag |
| 在线 reshard | 逐 key 迁移,期间该槽读写走 ASK 重定向 |
| 客户端要求 | 需集群感知客户端(redis-py cluster、Jedis Cluster、Lettuce 等),普通单机客户端不可直接使用 |
数据量未达单机瓶颈、高可用可用 Sentinel 解决时,单机 + 主从 + Sentinel 更简单;真正需要水平扩展写与存储时才上 Cluster。



