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

发布时间:2026/7/18 5:40:07
Redis 缓存学习笔记(一):缓存基础与底层数据结构 缓存是什么缓存的本质是用“可接受的不一致”和“额外存储空间”换取更低的访问成本。什么场景适合缓存缓存本质是一次权衡我愿意引入数据的不一致性和额外的存储空间这两项成本来换取更低的访问成本。要不要缓存就从「成本」和「收益」两边看。一、从成本端看我付出了什么引入了数据不一致性缓存里的数据可能不是最新所以先问自己几个问题业务能不能容忍短暂不一致不一致会造成多严重的后果只用于展示还是拿来做最终判断不一致窗口大概持续多久缓存失效后能不能从数据库重新重建由此可以推出适合的场景能接受短期不一致的场景商品详情、文章内容、首页配置等晚几秒一般无伤大雅。写入较少的场景如果写很频繁缓存就要不断删除 / 重建不一致窗口会变长还要额外承担数据变更成本收益就被吃掉了。⚠️ 一个关键边界像余额、库存扣减、支付状态这种「拿旧数据做最终决策会出事」的数据缓存只能辅助展示不能当唯一依据。花费了额外存储空间缓存要占内存所以要看「体积 × 命中率」是否划算体积大、访问又少的数据占内存却没收益不适合。但反过来如果一个大对象访问极其频繁命中率很高它也可能值得缓存。所以不是「大数据一定不能缓存」而是看存储花得值不值。二、从收益端看我换来了什么收益来自两种「贵」访问成本高复杂 SQL、多表聚合、远程接口、重计算每次省一点就很值。访问很频繁热点数据很多人反复看同一份命中率才高收益才大。一句话总结适合缓存的数据是「不一致和存储成本能接受、且能换来明显访问收益」的数据——通常是读多写少、有热点、允许短暂不一致、原始查询较重、且能从权威数据源重建的数据。Redis 常见数据结构从key到value到底是如何映射的整个 Redis 数据库本质就是一张 MapSDS, RedisObjectstructsdshdr{//简化版intlen;// 已使用长度intfree;// 剩余空间旧版本字段charbuf[];// 真正的数据}sds的好处二进制安全C原生字符串以’/0’作为结尾符号导致存储一些二进制数据时可能会有某个字节被解析未’/0’从而被误以为读完了计算字符串长度是O(1)而不是O(n)可以通过额外申请一些长度为后续可能的字符串扩容预留空间这样能兜住一些相对短的字符串扩容请求反过来讲可以通过惰性缩容偷偷只修改len但不释放空间预留一些空间供后续也许会发生的扩容用structredisObject{type;// Redis 类型string/list/hash/set/zsetencoding;// 底层编码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。Stringint类型当 value 是个小整数时比如 SET count 100redisObject ├─ type:string ├─ encoding:int└─ ptr:[100]← 整数直接塞这里ptr 不当指针用注意这时候根本没有 SDS 结构。ptr 那块 8 字节空间被直接拿来存整数指针本身占 8 字节存的是 long long8 字节正好塞得下embstr 编码当存储的字符串空间占用小于等于44字节时会优先使用embstr来存储embstr编码的最大特点是数据区和redisObject的存储空间是连续在一起的┌──────────────────────────────┬──────────────────────────────┐ │ redisObject │ SDS │ │ ├─type:string │ ├─len│ │ ├─ encoding:embstr │ ├─ alloc │ │ └─ ptr ─────────────────────→│ └─ buf[...]│ └──────────────────────────────┴──────────────────────────────┘ ┌──────────────┬────────────┬──────────┐ │ redisObject │ SDS 头部 │Tom\0│ │(16字节)│(3字节)│ 数据区 │ └──────────────┴────────────┴──────────┘ ←────── 一次 malloc连续一块内存 ──────→44字节是怎么来的64-16-3-1(数据区的’\0’结束符)44embstr编码的小坑embstr在设计上就是只读的如果要修改会将embstr单向转化为raw为什么不设置成新写入的较短的字符串能复用数据区空间个人感觉就是为了保持embstr的简单性减少内存碎片否则其实在写入的字符串是比原字符串短的情况下是完全可以复用空间的raw 编码当存储的字符串空间占用大于44字节或embstr字符串被修改时就会以raw编码的形式存储redisObject(第1次malloc)SDS(第2次malloc)├─type:string ├─len├─ encoding:raw ├─ alloc └─ ptr ────────────────────────────→└─ buf[...]HashHash的设计延续了小数据使用连续空间节省malloc次数和内存开销大数据通过额外的内存开销和引入复杂的数据结构来保障大数据量下读写性能本质上是以空间换时间的权衡如果 Hash 的 field-value 对数量 512并且每个 field/value 的长度都 64 字节→ 用 listpack只要满足任一条件field-value 对数量 512某个 field 或 value 长度 64 字节→ 转成 hashtableString: embstr(小,连续) ←→ raw(大,分离)Hash: listpack(小,连续) ←→ hashtable(大,结构化)listpacklistpack 更像是顺序表(不支持O(1)随机读写的数组)┌────────┬─────────┬───────┬────────┬───────┬────────┬─────┐ │ 总字节 │ 元素数 │ 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 的字节数encodingdata 之和不含 backlen 自己方便反向遍历顺序表;正向遍历直接用encoding中的长度推导出要跳几个字节}listpack_entry;优点省内存空间数据都存在一块连续的内存内缓存友好创建时只malloc一次相对而言不容易产生内存碎片劣势O(n)的查找和插入/删除但是当数据量很小时这个缺点其实可以忽略不计HashTabletypedefstructdict{dictht ht[2];// 两张哈希表longrehashidx;// rehash 进度-1 表示没在 rehash}dict;单张哈希表typedefstructdictht{dictEntry**table;// 桶数组每个位置是 dictEntry* 链表头unsignedlongsize;// 桶数组长度必须是 2 的幂unsignedlongsizemask;// size - 1用来快速取下标unsignedlongused;// 当前元素数量}dictht;每个键值对节点typedefstructdictEntry{void*key;//SDSvoid*val;//SDSstructdictEntry*next;}dictEntry;为什么一个hashtable要存两张哈希表因为redis的哈希表扩容后的迁移不是一次性的而是渐进式的所以需要额外保存一张表就像搬家的时候如果你不是一次性把家具都搬到新家那你的老房子就不能解约因为你还未搬迁的家具得有地方放。rehashidx就代表目前的搬家进度hashtable的sizemask是用来干嘛的hashtable并不是采用常见的取模法来计算哈希而是通过 的方式这种方式的好处是大大加快的运算速度位运算要比取模快的多坏处是这种优化的前提条件是模数必须为2的幂次方。因此hashtable的size必须是2的幂次什么时候会触发rehash?负载因子 ht[0].used / ht[0].size 已存的元素数 / 桶的数量常规情况下负载因子达到1则触发扩容。如果redis正在写RDB快照或者整理AOF那么阈值会拉到5才触发扩容因为此时频繁修改内存会触发 操作系统的 COW写时复制 技术导致内存占用大幅上升负载因子 0.1 时会触发缩容省内存TODO学完RDB和AOF再回来看看rehash期间读写怎么办rehash 期间旧数据可能分布在 ht[0] 和 ht[1] 两张表中所以查询、修改、删除都要查两张表但新增 key 只写入 ht[1]保证 ht[0] 只减不增最终被渐进式搬空。要注意的是由于搬迁过程中搬迁的是链表节点所以实际上不会有内存的主副本复制直接给把节点挂到新表的头节点上就行了所以不必担心并发导致的主副本数据不一致问题而且搬迁本身也是和读写操作串行的为什么用拉链不用开放寻址1.拉链法理论无限容量方便工程中做延迟扩容2.拉链法迁移简单基于链表的设计使得其重新算一遍哈希就能O(1)迁移开放寻址法寻址需要反复探测找空位相对慢很多Zset满足任一条件就从 listpack 升级为 skiplistdict· 元素个数 128 zset-max-listpack-entries· 任一元素长度 64 字节zset-max-listpack-valuetypedef struct zskiplistNode{sds ele;//member成员名 double score;//分数排序依据 struct zskiplistNode*backward;//后退指针反向遍历ZREVRANGE 用 struct zskiplistLevel{struct zskiplistNode*forward;//前进指针这一层指向谁 unsignedlongspan;//跨度跳过多少个节点← ZRANK 靠它}level[];//柔性数组层数每个节点不同}zskiplistNode;为什么要用跳表而不是红黑树实现简单易懂易调试范围查找性能优于红黑树虽然时间复杂度是同一个数量级(log(n)m),其实在logn中时间开销差不多但是后面那个m跳表具有很大的优势;找中序后继的逻辑要比直接访问跳表的下一个元素的逻辑更复杂常数更大为什么要额外保存一个backward而不是正向遍历之之后反转一下Redis支持反向遍历limit有limt的情况下正向遍历反转的算法时空间复杂度会从O(lognlimit)劣化到O(logn(max-min))Listquicklist本质块状链表实现的双端队列StreamStream 不像List是“取走就删”的队列而是一个追加型日志mystream: 1718950000000-0 {name: a, action: pay} 1718950000000-0 {name: b, action: order} 17189500000001-0 {name: c, action: refund}每条消息都有一个唯一 ID1718950000000-0前半段保证大致按时间排后半段解决同一毫秒内多条消息的问题。rax本质上是压缩版的trierax的特点是会把只有一个孩子的连续路径压成一段字符串写入新消息时1.生成新 ID2.找到 Stream 尾部 listpack3.判断尾部 listpack 是否还能容纳4.能容纳追加到这个 listpack5.不能容纳创建新的 listpack并用新消息 ID 作为新的 rax keyrax里的一个字符串对应一个listpack一个listpack中包含多条消息为了节省空间listpack中的每条消息不会存完整的ID而是存ID的两段Delta以listpack的首条消息ID有也就是rax key为基准比如基准 ID1000-0当前 ID1000-3差值ms_delta 0seq_delta 3字段重复字段名尽量通过 master fields 复用字段也会被压缩Stream 的 listpack 里不只是压缩 ID字段名也会尽量复用。比如很多消息都是user 1 product book price 99user 2 product pen price 10user 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的PELACK机制可以很大程度上避免这个问题而Stream消费完后之后删除当前消费者组的PEL中的pending状态不会直接删除stream中存储的消息方便审计和消息回放2.没有消费者组这个概念如果需要 ”组间广播组内竞争“的话要自己实现3.Redis Stream的 delivery count XCLAIM相当于在 Redis 这一层先做了一个 轻量级的“inbox 过滤”从消息角度过滤了重复的消息避免大量重复消息穿透到 DB。(依旧需要DB使用幂等键兜底)和专业MQ的边界优势1.stream不用引入额外的运维成本轻量成本低劣势1.分布式部署不友好Redis Cluster 的分片单位是 key所以如果redis整体的负载高且需要通过集群来均摊成本那stream很有可能会成为性能瓶颈因为单个stream的负载会集在一个slot内无法达到预期中通过集群来均摊stream带来的负载的初衷这种情况下要自己实现分片逻辑心智成本很高因此如果stream带来的负载对于单个redis节点来说不能承受那还不如使用正统的消息队列——复杂度不会消失只会转移既然复杂度都得存在不如让它待在专业的地方而不是塞进业务代码里2.缺少死信队列延迟重试最大重试次数等能力3.写入确认机制更弱写入时往往只意味着主节点内存写入成功没有Kafka或者RabbitMQ那种 和多节点复制/消息落盘强相关 的确认机制什么是slotkey 是快递slot 是格子Redis 节点是仓库管理员。一个快递只进一个格子一个格子只归一个管理员管但一个管理员要管很多格子。