本文是在阅读 JavaGuide 关于 Redis 的内容后做的个人整理;

请移步:https://javaguide.cn

核心知识点

可以看这篇文章的总结:https://juejin.cn/post/7525640395234230326#heading-4

基本数据类型8种

基本的5种

  1. String(字符串),使用SDS,即Redis自己构建的简单动态字符串来实现;
  2. List(列表),用的是双向链表,支持反向查找,可以用于实现消息队列;
  3. Hash(散列),键的值是键值对,可以用于存储对象,实现类似Java的HashMap;
  4. Set(集合),无序结构且无重复,支持交并差集的运算,可用于共同关注、粉丝等功能;用整数集合或哈希表实现(元素为整数且数量较少时用Set)
  5. Zset(有序集合),跳表实现,针对Set中每个元素增加一个权重参数score,根据这个参数有序排列;可以根据score范围获取元素列表,可以用于实现排行榜

特殊的3种

  1. Bitmap (位图)
  2. HyperLogLog(基数统计)
  3. Geospatial (地理位置)

底层数据类型8种

1. 简单动态字符串SDS

1
2
3
4
5
6
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};

image-20260217162140079

SDS 重点看四部分:len(已使用长度)、alloc(总分配空间)、flags(标识 sds 类型)以及 buf[](真实字符内容)。buf[] 末尾会额外追加 \0,以兼容 C 字符串接口。

sdshdr8/16/32/64 后面的数字,主要表示 lenalloc 的位宽;sdshdr5 是一个特例,它没有 lenalloc 字段,而是把长度信息压到 flags 这个 char 的高 5 位中;低 3 位仍然用于标识自己的 sds 类型。所以源码注释里的 5 unused bits,在 sdshdr5 上并不是真的“unused”。

动态扩容采用预分配策略:新字符串长度小于 1MB 时,通常按翻倍扩容;大于 1MB 时,则额外增加 1MB。扩容时可能发生类型升级,例如 sdshdr5 很容易变成 sdshdr8,但不会自动降级。

常见函数有:创建(_sdsnewlen,会自动选择类型)、扩容(_sdsMakeRoomFor)、追加(sdscatlen)和分割(sdssplitlen)。

2. Dict(哈希表/字典)

Redis 是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过 Dict 来实现的。

Dict 可以先按三层来理解:哈希表、哈希节点(dictEntry)和最外层的字典对象(dict)。文中的图偏旧版本实现,但不影响理解整体思路。

image-20260217162150556

dict 里的 dictType *type 用来封装不同场景下的哈希函数和辅助逻辑;void *privdata 也是给这些场景做配套用的。

这里的 used 可能大于 size,因为多个元素可能映射到同一个桶,冲突时会通过 dictEntry 里的 *next 指针串成链表。

  1. 添加键值对的哈希槽运算(这个和jdk的HashMap是一样的)

根据 key 计算出 hash 值 h,再用 h & sizemask 计算元素该落到哪个索引位置。这里可以用 & 代替取余,是因为 size = 2^nsizemask = 2^n - 1,于是有:$a % 2^n = a & (2^n - 1)$。

  1. 哈希冲突时的头插法

image-20260217162203518

上图中先插入 <k1,v1>,再插入 <k2,v2>。如果这两个键发生哈希冲突,<k2,v2> 会直接头插到 <k1,v1> 前面,这样就不需要每次都遍历到链表末尾。

再看这个 **table,它本质上指向的是 dictEntry 指针数组。新版本里这个表示方式做过调整,但理解成“哈希桶数组里放的是链表头指针”就够了。

  1. 为了支持 rehash,Dict 会同时维护两个哈希表。下图是一个旧版本的数据结构示意,此时第二个哈希表还是空的。

image-20260217162233572

rehash

扩容代码

收缩代码

上面几个代码部分浅看一下就行,不用都看得非常明白,关键是要知道扩容和收缩的条件

下面是关键的rehash操作

渐进式 rehash:设置 dict.rehashidx = 0,表示开始 rehash。

  • 将 dict.ht[0] 中的每一个 dictEntry 都 rehash 到 dict.ht[1],将 dict.ht[1] 赋值给 dict.ht[0],给 dict.ht[1] 初始化为空哈希表,释放原来的 dict.ht[0] 的内存(这样会造成主进程阻塞,不可取)
  • 每次执行新增、查询、修改、删除时,都会顺带检查 dict.rehashidx 是否大于 -1;如果是,就把 dict.ht[0].table[rehashidx] 上的链表迁移到 dict.ht[1],然后将 rehashidx++

直到 dict.ht[0] 的数据全部迁移到 dict.ht[1](判断依据是 rehashidx >= ht[0].size),再把 rehashidx 设回 -1,表示 rehash 结束。
在 rehash 过程中,新增操作会直接写入 ht[1];查询、修改和删除则会依次检查 ht[0]ht[1]。这样 ht[0] 只会越来越空,最终完成迁移。

3. Intset(整数集合):紧凑地存储多个整数

Intset 用来紧凑地存储多个整数,特点是:元素唯一且有序、支持编码升级、查询时可用二分查找。

如果Set集合是整数,且数量不超过一定值,则会用整数集合存储。

1
2
3
4
5
6
/* intset.h */
typedef struct intset {
uint32_t encoding; // 编码方式,支持存放16位、32位、64位整数
uint32_t length; // 元素个数
int8_t contents[]; // 整数数组,保存集合数据;其实更应该被看做指针,指向数组头部
} intset;
  1. 操作元素时的地址转换和计算

虽然数组头 contents 的声明类型是 int8_t[],但真实元素大小取决于 encoding,可能是 int16_tint32_tint64_t。因此读取元素时,必须结合编码做地址换算。

1
2
3
4
5
/* intset.c */
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))
// 这三个宏是intset结构体中encoding的可能赋值

所以获取元素时,本质上要做“首地址 + 下标 * 元素大小”的计算。源码里是通过类型转换来完成这件事的。

IntSet

这个地址计算在源码中是用类型转换实现的:

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
/* Return the value at pos, given an encoding. */
static int64_t _intsetGetEncoded(intset *is, int pos, uint8_t enc) {
int64_t v64;
int32_t v32;
int16_t v16;

if (enc == INTSET_ENC_INT64) {
memcpy(&v64,((int64_t*)is->contents)+pos,sizeof(v64));
memrev64ifbe(&v64);
return v64;
} else if (enc == INTSET_ENC_INT32) {
memcpy(&v32,((int32_t*)is->contents)+pos,sizeof(v32));
memrev32ifbe(&v32);
return v32;
} else {
memcpy(&v16,((int16_t*)is->contents)+pos,sizeof(v16));
memrev16ifbe(&v16);
return v16;
}
}

/* Set the value at pos, using the configured encoding. */
static void _intsetSet(intset *is, int pos, int64_t value) {
uint32_t encoding = intrev32ifbe(is->encoding);

if (encoding == INTSET_ENC_INT64) {
((int64_t*)is->contents)[pos] = value;
memrev64ifbe(((int64_t*)is->contents)+pos);
} else if (encoding == INTSET_ENC_INT32) {
((int32_t*)is->contents)[pos] = value;
memrev32ifbe(((int32_t*)is->contents)+pos);
} else {
((int16_t*)is->contents)[pos] = value;
memrev16ifbe(((int16_t*)is->contents)+pos);
}
}
  1. 插入元素时维持有序结构(必须是升序)

插入时会调用 intsetSearch(intset *is, int64_t value, uint32_t *pos) 做二分查找,*pos 表示计算出来的插入位置;如果元素明显比当前最大值更大、或比最小值更小,还会直接走边界优化。

插入前还会先判断新元素是否需要触发编码升级。如果需要升级,新元素一定只能放在首部或尾部;如果不需要升级,则继续二分搜索,找到就不插入,找不到就插到 pos,随后通过 intsetMoveTail(...) 整体搬移尾部元素。

  1. 编码升级:intset *intsetUpgradeAndAdd(intset *is, int64_t value)

编码升级时会采用 back-to-front 的方式,也就是从后往前设置元素。

新加入的 value 一定是新的最小值或最大值,否则就没有必要升级编码。若 value 为负,它会落到最左边;若为正,则落到最右边。移动旧元素时,也会据此决定是否整体再右移一个位置。

这里有个插入元素和编码升级的示例:

image-20260217165037263

4. LinkedList(双向链表)

5. SkipList(跳跃表):用于Sorted Set

6. ZipList(压缩列表)

7. QuickList(快速列表)

8. ListPack

9. RedisObject

缓存读写策略

旁路缓存模式

Cache Aside Pattern,最常见的一种。

  1. 读:先读缓存,未命中再读 DB,并把结果写回缓存;

  2. 写:先写 DB,再删除缓存。

  1. 无论“先删缓存还是先写 DB”,理论上都可能出现短暂不一致;实践里更常见的是“先写 DB,再删缓存”。
  2. 这种模式更适合读多写少的场景;如果写很多,缓存会频繁失效。

读写穿透模式

Read/Write Through Pattern,可以理解为由缓存层统一代理对底层数据库的读写。

  1. 读:先读缓存,未命中时由缓存层把数据从 DB 加载到缓存,再返回结果;

  2. 写:应用只与缓存层交互,由缓存层负责更新缓存并同步到底层 DB。

异步缓存写入

Write Behind Pattern,不常用,和读写穿透类似,但写入 DB 是异步完成的。

  1. 读:和读写穿透模式类似;
  2. 写:先写缓存,再由缓存层异步刷新到 DB。

持久化机制

Redis 常见的持久化方式可以概括为 RDB、AOF 和混合持久化。持久化的目的主要有两个:一是重启或故障后恢复数据,二是做数据同步。

RDB(快照, snapshotting)

  1. 作用:通过创建快照拿到内存数据在某个时间点上的副本;
  2. 用途:既可以用于恢复数据,也可以把快照复制到其他服务器上做数据同步;
  3. 特点:可以阻塞主线程执行,也可以通过 fork 子进程来完成。

AOF(只追加文件, append-only file)

大致流程:

  1. 先执行命令,再把命令写入 AOF 缓冲区
  2. 通过 write 系统调用写到内核缓冲区;
  3. 使用fsync系统调用,刷新系统内核缓冲区到磁盘中。

持久化方式(fsync时机)

fsync 的时机可以配置为每次写都刷盘、每秒刷一次,或者交给 OS 自己决定。

AOF重写

当 AOF 文件过大时,会通过重写来缩小体积,这个过程通常由子进程完成。

重写期间会维护一个AOF 重写缓冲区,用于记录新 AOF 生成期间执行的命令;等重写完成后,再把这些增量命令补到新文件末尾。

混合持久化

RDB 和 AOF 一般会一起开启。

  1. RDB 是压缩后的二进制快照,文件小、恢复快,但快照之间可能丢更多数据;
  2. AOF 文件通常更大,但记录更细,数据安全性更高,实际丢失范围取决于 fsync 策略。

事务

Redis 事务本质上是把命令放进一个队列中,然后按顺序依次执行。

某条命令执行失败时,后续命令通常仍会继续执行。

部署方式

主从复制 => 哨兵模式

  • 主从复制:

优点:1. 主节点负责写,从节点负责读,读写分离可以提高性能;2. 数据有副本,具备一定容灾能力。

缺点:1. 主节点单点故障风险高,不具备真正的高可用;2. 主从同步存在延迟,一致性难以严格保证;3. 故障转移如果靠人工处理,成本较高。

  • 引入哨兵:
  1. 监控 Redis 各个节点是否正常;

  2. 通知其他节点某个节点出现故障;

  3. 自动故障转移,把一个从节点提升为主节点,并让其他节点重新完成主从关系配置;

  4. 哨兵通常会部署多个节点(一般至少 3 个),通过投票选出 leader,因此哨兵本身也要高可用。

1
2
3
// 配置文件 sentinel.conf
// 最后的1表示有多少个哨兵判断挂掉,才是真的认为挂掉
sentinel monitor master 127.0.0.1 6379 1
1
2
// 启动配置文件
redis-sentinel sentinel.conf

切换过程中通常会有短暂不可用,但整体可用性已经比单纯主从复制高很多。

集群模式 Cluster

Cluster 主要解决的是单机容量和单机性能的上限问题。

  1. 如何添加新节点?节点之间会通过集群协议交换状态信息,并逐步完成握手和拓扑同步。

  2. 数据公平性

    一共 16384 个槽位,每个主节点负责其中一部分;

    数据读写时,会先对 key 做哈希计算,再映射到某个槽位,由负责该槽位的节点处理;

    如果请求到了错误的节点,节点会把正确的槽号、IP 和端口返回给客户端,让客户端重定向到目标节点。

redis-cluster

图源:https://www.bilibili.com/video/BV1ge411L7Sh

Redisson

Redis 里可以用 SETNX key value 实现最朴素的分布式锁:只有 key 不存在时才会设置成功,因此多个进程同时抢锁时,只有一个会成功。

问题:如果加锁后服务宕机,锁可能永远无法释放;如果给锁设置过期时间,又可能在业务还没执行完时提前释放。

解决方案是Redisson分布式锁:

  1. key 是锁名,value 是一个 Hash;Hash 的键是线程或进程标识,值是重入次数。如果还是同一个持有者再次加锁,就把重入次数加 1;

  2. Watch Dog 机制会在锁还被当前持有者占用时定时续约,默认是每隔 10 秒把过期时间续到 30 秒;

  3. 加锁失败时,线程不会一直空转,而是等待锁释放后再重试;

  4. 释放锁时会先校验持有者身份;如果是重入锁,就先把重入次数减 1,只有减到 0 才真正删除锁并唤醒其他等待线程。


  • Redisson获取锁的代码流程解读:
  1. 通过 tryLock 发起加锁,请求里会带上 waitTimeleaseTime:前者表示最多等多久,后者表示锁多久自动释放;

  2. tryLock 内部会通过 Lua 脚本执行抢锁逻辑,用脚本保证这组 Redis 操作的原子性;

  3. 如果锁不存在,就创建以锁名为 key 的 Hash,并写入当前持有者标识与重入次数 1;

  4. 如果锁已存在,则继续判断当前持有者是不是自己;是自己就做重入,不是自己就进入等待;

  5. 如果等待时间超过 waitTime,则返回加锁失败;

  6. leaseTime 没有显式指定为固定值时,Redisson 会通过看门狗线程按周期续约。

  • 尝试获取锁的代码:

image-20241228230519934

代码来源

  • 续约的代码:

image-20241228230444458