Redis 缓存学习笔记(一):缓存基础与底层数据结构

缓存是什么

缓存的本质,是用"可接受的不一致"和"额外存储空间",换取更低的访问成本。

什么场景适合缓存

缓存本质是一次权衡:我愿意引入数据的不一致性和额外的存储空间这两项成本,来换取更低的访问成本。

要不要缓存,就从「成本」和「收益」两边看。

一、从成本端看(我付出了什么)

  1. 引入了数据不一致性

缓存里的数据可能不是最新,所以先问自己几个问题:

  • 业务能不能容忍短暂不一致?
  • 不一致会造成多严重的后果?(只用于展示,还是拿来做最终判断)
  • 不一致窗口大概持续多久?
  • 缓存失效后,能不能从数据库重新重建?

由此可以推出适合的场景:

  • 能接受短期不一致的场景:商品详情、文章内容、首页配置等,晚几秒一般无伤大雅。
  • 写入较少的场景:如果写很频繁,缓存就要不断删除 / 重建,不一致窗口会变长,还要额外承担数据变更成本,收益就被吃掉了。

⚠️ 一个关键边界:像余额、库存扣减、支付状态这种「拿旧数据做最终决策会出事」的数据,缓存只能辅助展示,不能当唯一依据。

  1. 花费了额外存储空间
  • 缓存要占内存,所以要看「体积 × 命中率」是否划算:
  • 体积大、访问又少的数据,占内存却没收益,不适合。
  • 但反过来,如果一个大对象访问极其频繁(命中率很高),它也可能值得缓存。
  • 所以不是「大数据一定不能缓存」,而是看存储花得值不值。

二、从收益端看(我换来了什么)

收益来自两种「贵」:

  • 访问成本高:复杂 SQL、多表聚合、远程接口、重计算,每次省一点就很值。
  • 访问很频繁:热点数据,很多人反复看同一份,命中率才高,收益才大。

一句话总结

适合缓存的数据,是「不一致和存储成本能接受、且能换来明显访问收益」的数据------通常是读多写少、有热点、允许短暂不一致、原始查询较重、且能从权威数据源重建的数据。

Redis 常见数据结构

从key到value到底是如何映射的

整个 Redis 数据库,本质就是一张 Map<SDS, RedisObject>

c 复制代码
struct sdshdr { //简化版
    int len;   // 已使用长度
    int free;  // 剩余空间,旧版本字段
    char buf[]; // 真正的数据
}

sds的好处:

  • 二进制安全,C原生字符串以'/0'作为结尾符号,导致存储一些二进制数据时,可能会有某个字节被解析未'/0'从而被误以为读完了
  • 计算字符串长度是O(1)而不是O(n)
  • 可以通过额外申请一些长度,为后续可能的字符串扩容预留空间,这样能兜住一些相对短的字符串扩容请求
  • 反过来讲,可以通过惰性缩容,偷偷只修改len,但不释放空间,预留一些空间,供后续也许会发生的扩容用
c 复制代码
struct redisObject {
    type;      // Redis 类型:string/list/hash/set/zset
    encoding;  // 底层编码:int/embstr/raw/quicklist/listpack/skiplist...
    void *ptr; // 指向真实数据
}

type对encoding的映射表

复制代码
string -> int / embstr / raw
list   -> quicklist
hash   -> listpack / hashtable
set    -> intset / listpack / hashtable
zset   -> listpack / skiplist
stream -> stream

注:set 在 Redis 7.2+ 当元素少且不全为整数时会用 listpack 编码(全整数小集合走 intset,大集合走 hashtable)。

String

int类型

当 value 是个小整数时,比如 SET count 100:

c 复制代码
redisObject
├─ type:     string
├─ encoding: int
└─ ptr:      [ 100 ]   ← 整数直接塞这里,ptr 不当指针用

注意:这时候根本没有 SDS 结构。ptr 那块 8 字节空间被直接拿来存整数(指针本身占 8 字节,存的是 long long,8 字节正好塞得下)

embstr 编码

当存储的字符串空间占用小于等于44字节时,会优先使用embstr来存储

embstr编码的最大特点是:数据区和redisObject的存储空间是连续在一起的

python 复制代码
┌──────────────────────────────┬──────────────────────────────┐
│ redisObject                  │ SDS                          │
│ ├─ type: string              │ ├─ len                       │
│ ├─ encoding: embstr          │ ├─ alloc                     │
│ └─ ptr ─────────────────────→│ └─ buf[...]                  │
└──────────────────────────────┴──────────────────────────────┘

┌──────────────┬────────────┬──────────┐
│ redisObject  │  SDS 头部   │ "Tom\0"  │
│  (16字节)    │  (3字节)    │  数据区  │
└──────────────┴────────────┴──────────┘
←────── 一次 malloc,连续一块内存 ──────→

44字节是怎么来的?

64-16-3-1(数据区的'\0'结束符)=44

embstr编码的小坑:embstr在设计上就是只读的,如果要修改,会将embstr单向转化为raw

为什么不设置成新写入的较短的字符串能复用数据区空间?

个人感觉就是为了保持embstr的简单性+减少内存碎片,否则其实在写入的字符串是比原字符串短的情况下,是完全可以复用空间的

raw 编码

当存储的字符串空间占用大于44字节或embstr字符串被修改时,就会以raw编码的形式存储

python 复制代码
redisObject (第1次malloc)          SDS (第2次malloc)
├─ type: string                    ├─ len
├─ encoding: raw                   ├─ alloc
└─ ptr ────────────────────────────→└─ buf[...]

Hash

Hash的设计延续了小数据使用连续空间,节省malloc次数和内存开销;大数据通过额外的内存开销和引入复杂的数据结构来保障大数据量下读写性能,本质上是以空间换时间的权衡

如果 Hash 的 field-value 对数量 <= 512

并且每个 field/value 的长度都 <= 64 字节

→ 用 listpack

只要满足任一条件:

  1. field-value 对数量 > 512
  2. 某个 field 或 value 长度 > 64 字节
    → 转成 hashtable

String: embstr(小,连续) ←→ raw(大,分离)

Hash: listpack(小,连续) ←→ hashtable(大,结构化)

listpack

listpack 更像是顺序表(不支持O(1)随机读写的数组)

python 复制代码
┌────────┬─────────┬───────┬────────┬───────┬────────┬─────┐
│ 总字节  │ 元素数   │ field1│ value1 │ field2│ value2│ end │
└────────┴─────────┴───────┴────────┴───────┴────────┴─────┘


// 单个 listpack entry(变长,连续紧凑) 即可能代表field,也可能代表value
typedef struct listpack_entry {
    uint8_t encoding[];   // ① 编码:标识类型(整数 or 字符串)+ 长度
    uint8_t data[];       // ② 实际数据:整数直存 or 字符串内容 uint8_t  xxx[] 代表单位为1字节的连续内存空间
    uint8_t backlen[];    // ③ 本 entry 的字节数(encoding+data 之和,不含 backlen 自己),方便反向遍历顺序表;正向遍历直接用encoding中的长度推导出要跳几个字节
} listpack_entry;

优点:

  • 省内存空间
  • 数据都存在一块连续的内存内,缓存友好;创建时只malloc一次,相对而言不容易产生内存碎片

劣势:

  • O(n)的查找和插入/删除,但是当数据量很小时这个缺点其实可以忽略不计

HashTable

c 复制代码
typedef struct dict {
    dictht ht[2];      // 两张哈希表
    long rehashidx;   // rehash 进度,-1 表示没在 rehash
} dict;

单张哈希表:
typedef struct dictht {
    dictEntry **table; // 桶数组,每个位置是 dictEntry* 链表头
    unsigned long size;      // 桶数组长度,必须是 2 的幂
    unsigned long sizemask;  // size - 1,用来快速取下标
    unsigned long used;      // 当前元素数量
} dictht;

每个键值对节点:
typedef struct dictEntry {
    void *key; //SDS
    void *val; //SDS
    struct dictEntry *next;
} dictEntry;

为什么一个hashtable要存两张哈希表?

因为redis的哈希表扩容后的迁移不是一次性的,而是渐进式的,所以需要额外保存一张表;就像搬家的时候,如果你不是一次性把家具都搬到新家,那你的老房子就不能解约,因为你还未搬迁的家具得有地方放。rehashidx就代表目前的搬家进度

hashtable的sizemask是用来干嘛的?

hashtable并不是采用常见的取模法来计算哈希,而是通过 & 的方式,这种方式的好处是大大加快的运算速度,位运算要比取模快的多,坏处是这种优化的前提条件是模数必须为2的幂次方。因此hashtable的size必须是2的幂次

什么时候会触发rehash?

负载因子 = ht0.used / ht0.size = 已存的元素数 / 桶的数量

常规情况下负载因子达到1则触发扩容。如果redis正在写RDB快照或者整理AOF,那么阈值会拉到5才触发扩容,因为此时频繁修改内存会触发 操作系统的 COW(写时复制) 技术,导致内存占用大幅上升

负载因子 <0.1 时会触发缩容省内存

TODO:学完RDB和AOF再回来看看

rehash期间,读写怎么办?

rehash 期间,旧数据可能分布在 ht0 和 ht1 两张表中,所以查询、修改、删除都要查两张表;但新增 key 只写入 ht1,保证 ht0 只减不增,最终被渐进式搬空。

要注意的是,由于搬迁过程中搬迁的是链表节点,所以实际上不会有内存的主副本复制,直接给把节点挂到新表的头节点上就行了,所以不必担心并发导致的主副本数据不一致问题;而且搬迁本身也是和读写操作串行的

为什么用拉链,不用开放寻址?

1.拉链法理论无限容量,方便工程中做延迟扩容

2.拉链法迁移简单,基于链表的设计使得其重新算一遍哈希就能O(1)迁移,开放寻址法寻址需要反复探测找空位,相对慢很多

Zset

满足任一条件就从 listpack 升级为 skiplist+dict:

· 元素个数 > 128 (zset-max-listpack-entries)

· 任一元素长度 > 64 字节(zset-max-listpack-value)

python 复制代码
typedef struct zskiplistNode {
    sds ele;                          // member(成员名)
    double score;                     // 分数(排序依据)
    struct zskiplistNode *backward;   // 后退指针(反向遍历,ZREVRANGE 用)
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前进指针(这一层指向谁)
        unsigned long span;            // 跨度(跳过多少个节点)← ZRANK 靠它!
    } level[];                        // 柔性数组,层数每个节点不同
} zskiplistNode;

为什么要用跳表而不是红黑树?

  • 实现简单易懂易调试
  • 范围查找性能优于红黑树:虽然时间复杂度是同一个数量级(log(n)+m),其实在logn中时间开销差不多,但是后面那个m跳表具有很大的优势;找中序后继的逻辑要比直接访问跳表的下一个元素的逻辑更复杂,常数更大

为什么要额外保存一个backward?而不是正向遍历之之后反转一下?

Redis支持反向遍历+limit;有limt的情况下,正向遍历+反转的算法时空间复杂度会从O(logn+limit)劣化到O(logn+(max-min))

List

quicklist

本质块状链表实现的双端队列

Stream

Stream 不像List是"取走就删"的队列,而是一个追加型日志:

复制代码
mystream:
1718950000000-0  {name: a, action: pay}
1718950000000-0  {name: b, action: order}
17189500000001-0  {name: c, action: refund}

每条消息都有一个唯一 ID:

1718950000000-0

前半段保证大致按时间排,后半段解决同一毫秒内多条消息的问题。

rax

本质上是压缩版的trie,rax的特点是会把只有一个孩子的连续路径压成一段字符串

写入新消息时:

python 复制代码
1. 生成新 ID
2. 找到 Stream 尾部 listpack
3. 判断尾部 listpack 是否还能容纳
4. 能容纳:追加到这个 listpack
5. 不能容纳:创建新的 listpack,并用新消息 ID 作为新的 rax key

rax里的一个字符串,对应一个listpack,一个listpack中包含多条消息;为了节省空间,listpack中的每条消息不会存完整的ID,而是存ID的两段Delta(以listpack的首条消息ID有也就是rax key为基准),比如

基准 ID:1000-0

当前 ID:1000-3

差值:ms_delta = 0,seq_delta = 3

字段:重复字段名尽量通过 master fields 复用

字段也会被压缩

Stream 的 listpack 里不只是压缩 ID,字段名也会尽量复用。

比如很多消息都是:

user 1 product book price 99

user 2 product pen price 10

user 3 product cup price 20

字段名 user/product/price 重复出现很多次,那redis就会在listpack头部将这一系列相同的字段存起来作为 master field,后续的消息的flag字段可以标记为复用主字段,如果有标记的话就只存字段的V即可,K的存储空间就可以省略(要全部字段内容于顺序都匹配才行,不能只匹配其中的某几个)

listpack 遍历原理见listpack章

为什么用Stream而不是List作为消息队列?

因为List本质就是个块状链表实现的双端队列,如果只是很简单的场景其实也未尝不可。但如果场景稍微生产一点,用list就要自己写很多东西

1.List 是"取走即删除",缺乏ACK机制,没有PEL(待决队列)如果消息取走后,有神秘小故障导致消费者挂掉,那么这条消息就丢了;stream的PEL+ACK机制可以很大程度上避免这个问题;而Stream消费完后之后删除当前消费者组的PEL中的pending状态,不会直接删除stream中存储的消息,方便审计和消息回放

2.没有消费者组这个概念,如果需要 "组间广播,组内竞争"的话要自己实现

和专业MQ的边界

优势:

1.stream不用引入额外的运维成本,轻量,成本低

劣势:

1.分布式部署不友好;Redis Cluster 的分片单位是 key,所以如果redis整体的负载高且需要通过集群来均摊成本,那stream很有可能会成为性能瓶颈,因为单个stream的负载会集在一个slot内,无法达到预期中通过集群来均摊stream带来的负载的初衷;这种情况下要自己实现分片逻辑,心智成本很高;因此如果stream带来的负载对于单个redis节点来说不能承受,那还不如使用正统的消息队列------复杂度不会消失,只会转移,既然复杂度都得存在,不如让它待在专业的地方,而不是塞进业务代码里

2.缺少死信队列,延迟重试,最大重试次数等能力

3.写入确认机制更弱,写入时往往只意味着主节点内存写入成功,没有Kafka或者RabbitMQ那种 和多节点复制/消息落盘强相关 的确认机制

什么是slot

key 是快递,slot 是格子,Redis 节点是仓库管理员。一个快递只进一个格子,一个格子只归一个管理员管,但一个管理员要管很多格子。