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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// server.h —— Redis 值的统一外壳
typedef struct redisObject {
unsigned type:4; // OBJ_STRING / OBJ_LIST / OBJ_HASH ...
unsigned encoding:4; // OBJ_ENCODING_INT / EMBSTR / RAW ...
unsigned lru:LRU_BITS; // LRU 时间或 LFU 频次(24bit)
int refcount; // 引用计数;int 编码时置 INT_MAX 表示共享
void *ptr; // 指向数据:SDS / 跳表 / 哈希表,或直接存整数
} robj;

// sds.h —— 短字符串的紧凑头(embstr/raw 的真正载荷)
struct __attribute__((__packed__)) sdshdr8 {
uint8_t len; // 已使用长度
uint8_t alloc; // 分配总长(不含头与 '\0')
unsigned char flags; // 低 3 位标识头类型(sdshdr8/16/32/64)
char buf[]; // 实际字符串,柔性数组
};

int 编码不分配 SDS,整数值直接塞进 ptrptr=(void*)((long)value));embstrrobjsdshdr8 拼成连续内存一次 mallocraw 则两者分开分配——上表三种编码即源于此。

编码内存形态特点
intredisObject.ptr 直接保存整数值不分配 SDS,适合可表示为 long 的整数
embstrredisObject 与 SDS 连续分配一次 malloc,缓存友好,短字符串专用
rawredisObject 与 SDS 分开分配两次分配,适合较长字符串,修改更灵活

String 三种编码的内存布局

List

双向链表语义的有序字符串集合,支持两端高效 push/pop。

  • listpack:元素少且小时使用,紧凑连续内存。
  • quicklist:以 listpack 为节点的双向链表,兼顾内存紧凑与随机访问,兼顾两者优点。每个 ziplist/listpack 节点大小可配置。

quicklist 可以理解为「链表外壳 + listpack 内核」:链表负责两端插入删除,listpack 负责把一批元素紧凑地放在连续内存里。

quicklist 与 listpack 的关系

Hash

字段-值映射,值本身也是字符串。一个 Hash 就像一个对象。

  • listpack:字段少且短时使用。
  • hashtable:字段多时转为真正的哈希表,O(1) 读写但内存开销大。
编码存储方式读写特点
listpackfield/value 成对写入连续内存省内存,查找需要顺序扫描
hashtablebucket 数组 + dictEntry 链表O(1) 读写,指针与哈希桶带来额外内存开销

Hash 的 listpack 与 hashtable 编码

Set

无序、唯一字符串集合,支持丰富的集合运算。

  • intset:全为整数且数量少时使用,紧凑有序数组。
  • hashtable:含字符串或元素多时使用。

Set 的 intset 与 hashtable 编码

ZSet(Sorted Set)

有序集合,每个元素关联一个 score,按 score 排序且元素唯一。Redis 中表达力最强的结构。

  • listpack:元素少且短时使用。
  • skiplist + hashtable:元素多时使用。跳表提供按 score 的有序遍历与范围查询(O(logN)),hashtable 提供 O(1) 的元素→score 反查。两者共用元素引用,不重复存储字符串。
1
2
3
4
5
6
7
8
9
10
// server.h —— 跳表节点
typedef struct zskiplistNode {
sds ele; // 元素值,与旁路哈希表共用,不重复存储
double score; // 排序分数
struct zskiplistLevel {
struct zskiplistNode *forward; // 第 i 层后继
unsigned long span; // 到后继跨越的节点数,支撑按排名 O(logN) 定位
} level[]; // 柔性数组,层数按抛硬币概率生成(上限 32)
struct zskiplistNode *backward; // 前驱(仅第 0 层),支持反向遍历
} zskiplistNode;

每个节点按概率决定层数,spanZRANK 这类按排名的查询同样保持 O(logN);ele 与哈希表共用,故 member→score 反查 O(1) 而不重复存字符串。

编码组成能力
listpackelement/score 成对连续存储省内存,适合小 ZSet
skiplist + hashtable跳表按 score 排序,哈希表按 member 反查 score范围查询 O(logN),成员查分 O(1)

ZSet 的 listpack 与 skiplist hashtable 编码

ZSet 的 score 是 double(64 位浮点),存大时间戳(毫秒)足够精确,但极端大整数会有精度损失。

Stream

5.0 引入,专为消息流设计,可看作「自带持久化、消费组、ack 的日志结构」。

Stream 是只追加的日志,每条消息有自动生成的 ID(<毫秒时间戳>-<序号>),可按 ID 或时间范围查询。底层用 rax(radix tree)按消息 ID 建索引,叶子节点指向 listpack 消息块。rax 负责范围定位,listpack 负责紧凑保存多条消息。

Stream 的 rax 索引与 listpack 消息块

Lua 脚本底层实现

Redis 从 2.6 起内置 Lua 解释器,允许将多条命令打包为一段脚本原子执行——整个脚本期间 Redis 不会切换到其他客户端,天然避免了竞态条件。

Lua 环境初始化

Redis 启动时创建一个 Lua 环境(luaState),并做以下定制:

  1. 替换随机数生成器:用确定性的伪随机算法替换 math.random,保证主从复制结果一致。
  2. 禁用危险函数dofileloadfileioos.execute 等被删除或替换为空操作,防止脚本访问文件系统或执行系统命令。
  3. 注入 redis 全局表:提供 redis.call()redis.pcall()redis.status_reply()redis.error_reply()redis.log()redis.sha1hex() 等 API。
  4. 设置全局保护:7.0 之前脚本可以创建全局变量(易引发难以排查的 bug);7.0+ 默认禁止在脚本中创建全局变量(lua-env-flag 机制),试图赋值全局会报错。

执行模型

1
2
3
4
5
6
7
8
9
10
客户端 → EVAL 脚本 → Redis 主线程

├─ 1. 计算脚本 SHA1,检查缓存(已编译则跳过编译)
├─ 2. 若未缓存,luaL_loadbuffer 编译为 Lua 字节码并存入 script cache
├─ 3. 设置钩子(每执行一定指令数检查超时)
├─ 4. 绑定 KEYS/ARGV 到 Lua 全局表
├─ 5. lua_pcall 执行字节码
│ └─ redis.call() → 把命令打包为 Redis 调用 → 在同一主线程同步执行 → 返回结果
├─ 6. 将 Lua 返回值转为 Redis 回复,发送给客户端
└─ 7. 清理环境(重置全局表、移除钩子)

关键特性

  • 原子性:脚本在 Redis 主线程中执行,期间不处理其他客户端命令。因此脚本必须快——长时间脚本会阻塞整个实例。
  • 超时与 killlua-time-limit(默认 5 秒)超时后,Redis 并不会主动终止脚本(因为 Lua 没有安全的中断机制),而是进入「可被 kill」状态,客户端可执行 SCRIPT KILL(若脚本没写过数据)或 SHUTDOWN NOSAVE(强制停机回滚)。
  • 纯函数约束:为保障主从一致性,脚本中禁止调用 TIMESRANDMEMBERRANDOMKEYSCAN 等非确定性命令。7.0 前 redis.replicate_commands() 可显式声明副作用复制模式绕过此限制;7.0+ 默认采用效果复制(effect replication),不再需要显式声明。

脚本缓存

  • 编译后的字节码以 SHA1 为 key 存在 server 级别的 script_cache 字典中。
  • 缓存不会随 RDB/AOF 持久化——重启后缓存为空,客户端需重新 EVALSCRIPT 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// db.c —— 命令执行前的惰性过期检查
int expireIfNeeded(redisDb *db, robj *key) {
if (!keyIsExpired(db, key)) return 0; // 未过期,正常使用

if (server.masterhost == NULL) {
// 主库:真正删除,并把 DEL 传播给从库
propagateDeletion(db, key, server.lazyfree_lazy_expire);
server.stat_expiredkeys++;
notifyKeyspaceEvent(NOTIFY_EXPIRED, "expired", key, db->id);
return server.lazyfree_lazy_expire ? dbAsyncDelete(db, key) // 异步释放
: dbSyncDelete(db, key); // 同步释放
} else {
// 从库:不主动删,等待主库的 DEL 传播,避免主从不一致
return 1; // 仅告知调用方"已过期"
}
}

访问路径上调用此函数实现惰性删除;从库只读不删、删除权归主库,正是前文「删除由主库主导」的代码体现。

从库过期:删除由主库主导,完成后传播 DEL 给从库;3.2+ 从库读取过期 key 时也会返回不存在(惰性),但实际删除仍以主库为准,避免主从不一致。

内存淘汰策略

当内存达到 maxmemory 上限时触发淘汰:

策略范围依据
noeviction默认,写操作直接报错
allkeys-lru全部 key最近最少使用
allkeys-lfu全部 key最不经常使用(4.0+)
volatile-lru设过期的 keyLRU
volatile-lfu设过期的 keyLFU
allkeys-random全部 key随机
volatile-random设过期的 key随机
volatile-ttl设过期的 key优先淘汰 TTL 最短的

LRU/LFU 均为近似算法(基于抽样而非精确链表)以节省内存。纯缓存常用 allkeys-lru/allkeys-lfu;key 均有 TTL 时 volatile-ttl 也合理。

1
2
3
4
5
6
7
8
9
10
11
12
// evict.c —— 近似 LRU/LFU 的抽样入池(核心循环)
void evictionPoolPopulate(int dbid, dict *dbdict, ...) {
while (sampled < server.maxmemory_samples) { // 仅抽样 N 个 key(默认 5),不做全局排序
de = dictGetRandomKey(dbdict); // 随机取一个 key
...
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU)
idle = 255 - LFUDecrAndReturn(o); // LFU:访问频次越低越优先淘汰
else
idle = estimateObjectIdleTime(o); // LRU:越久未访问越优先淘汰
// 按 idle 插入大小为 EVPOOL_SIZE(16) 的淘汰池,随后取池首淘汰
}
}

淘汰不维护全局 LRU 链表,而是随机抽 N 个 key 取最该淘汰者入小池——这就是「近似算法基于抽样」的内存取舍;调大 maxmemory-samples 提升精度但增加 CPU。

SCAN 游标机制

SCAN 系列以游标为基础分批增量遍历,是生产遍历 key 的唯一推荐方式(KEYS 一次性遍历会阻塞主线程,生产禁用)。

  • 基于底层字典的渐进式遍历,cursor 是位反转后的桶索引,在 rehash 期间也能正确推进。
  • 不保证完整性:遍历期间被修改的 key 可能返回多次,也可能完全不返回。语义是「尽力而为」,适合统计、清理等容忍误差的场景,不适合强一致全量快照。
  • COUNT 是提示而非保证COUNT 10 仅建议每次约返回 10 个元素,实际受桶填充度影响可能远多远少。返回空列表且 cursor 非零时须继续扫描,而非停止。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// dict.c —— 游标推进的核心(位反转法)
unsigned long dictScan(dict *d, unsigned long v, ...) {
...
t0 = &(d->ht[0]); m0 = t0->sizemask;
de = t0->table[v & m0]; // 扫描第 v 个桶
while (de) { fn(privdata, de); de = de->next; }
...
/* 关键:对有效位取反 → 反转 → +1 → 再反转,
等价于"从高比特位进位"的递增,保证 rehash 扩容(桶数翻倍)后不重不漏 */
v |= ~m0;
v = rev(v);
v++;
v = rev(v);
return v;
}

常规 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 持久化,重启后需重新 EVALSCRIPT LOAD

集群架构

Redis Cluster 通过数据分片将 key 分散到多个主节点,每个主节点配若干从节点,提供横向扩容与自动故障转移。它与「主从复制 + Sentinel 哨兵」的区别在于:主从复制是单机数据全量冗余、Sentinel 负责监控选主;Cluster 则把数据切分到不同节点,写入与存储能力随节点数线性扩展。

槽位与路由

Cluster 把整个 keyspace 划分为 16384 个槽(slot),每个主节点负责其中一段。key 所属槽由哈希决定:

1
slot = CRC16(key) mod 16384
1
2
3
4
5
6
7
8
9
10
11
12
13
// cluster.c —— 槽号计算与 Hash Tag 处理
unsigned int keyHashSlot(char *key, int keylen) {
int s, e;
for (s = 0; s < keylen; s++)
if (key[s] == '{') break; // 找 Hash Tag 起始 '{'
if (s == keylen) // 无 '{':整串参与哈希
return crc16(key, keylen) & 16383;
for (e = s + 1; e < keylen; e++)
if (key[e] == '}') break; // 找 Hash Tag 结束 '}'
if (e == keylen || e == s + 1) // '{' 无配对 '}' 或 tag 为空:整串哈希
return crc16(key, keylen) & 16383;
return crc16(key + s + 1, e - s - 1) & 16383;// 只对 {..} 内子串哈希
}

& 16383mod 16384{user:1001}.profile{user:1001}.cart 只对 user:1001 哈希从而落到同一槽——这正是「多 key 同槽」「Lua/事务同槽」的底层实现。

客户端可与任意节点通信:若 key 落在该节点负责的槽,直接处理;否则返回 MOVED <slot> <host:port> 重定向,客户端据此缓存槽映射,后续直连目标节点,稳定后几乎不再重定向。槽迁移期间还会出现 ASK 临时重定向——本次请求走新节点,但不更新本地映射(迁移尚未完成)。

集群拓扑与通信

  • Gossip 协议:节点间周期性交换状态(存活、槽归属),探测故障并传播配置变更。
  • 主从复制:每个主节点配至少一个从节点,实时同步主节点数据,主节点宕机时从节点顶上。
  • 最少 3 主 3 从:故障转移依赖多数派投票,至少 3 个主节点才能形成多数,最小可用集群为 3 主 3 从(6 节点)。

故障转移

  1. 主观下线(PFAIL):节点 A 在 cluster-node-timeout 内未收到节点 B 的 Gossip 响应,标记 B 疑似下线。
  2. 客观下线(FAIL):超过半数主节点都报告 B 的 PFAIL,B 被标记为客观下线。
  3. 选举升主:B 的从节点发起选举,获多数主节点投票后升为新主,接管 B 的槽与流量。

整个过程自动完成,无需人工介入(Sentinel 哨兵逻辑类似,但它是独立进程、针对单机主从架构)。

集群限制

限制说明
db 0Cluster 不支持多 db,所有 key 都在 0 号库
多 key 命令须同槽MGET/MSET/SUNION 等涉及多 key 的命令要求所有 key 同槽,否则报 CROSSSLOT 错误
事务/Lua 须同槽MULTI/EXECEVAL 涉及的 key 必须同槽,常配合 Hash Tag
在线 reshard逐 key 迁移,期间该槽读写走 ASK 重定向
客户端要求需集群感知客户端(redis-py cluster、Jedis Cluster、Lettuce 等),普通单机客户端不可直接使用

数据量未达单机瓶颈、高可用可用 Sentinel 解决时,单机 + 主从 + Sentinel 更简单;真正需要水平扩展写与存储时才上 Cluster。