Redis存储原理与数据模型
为什么Redis命令处理为单线程① 加锁太复杂粒度难控Redis 有 string、list、hash、set、zset同一对象底层还可能对应多种编码/结构。若命令执行多线程并发改这些结构就要大量加锁锁粒度太粗 → 几乎退化成单线程还多了锁开销锁粒度太细 → 实现极复杂还容易死锁、难维护单线程执行命令天然无命令间数据竞争模型简单可靠。② 频繁上下文切换不划算Redis 操作大多是纯内存、微秒级。多线程带来的切换、调度、缓存失效未必赚得回来很多场景下单线程 多路复用反而更稳、更快。单线程本身有明显局限单线程最怕两件事类型后果耗时 CPU 运算一条命令算太久后面所有请求都卡住阻塞 I/O主线程一堵整个服务像停摆所以 Redis 的设计前提是主线程里不能塞重计算、不能做阻塞 I/O。Redis 真有 IO / CPU 密集活吗有但不塞进命令线程IO 密集磁盘 IO持久化RDB / AOF 重写fork子进程去做刷盘异步刷避免主线程卡在fsync网络 IO多客户端用 Reactor I/O 多路复用一个主线程盯很多连接大请求/大响应的读写Redis 6 还能开 I/O 多线程注意I/O 可并行命令执行仍在主线程CPU 密集不能真在主线程里硬算很久于是用分治大操作拆小步数据结构切换如 ziplist / listpack 与 hashtable 之间转换渐进式迁移 / 渐进式删除一次只处理一部分把耗时摊开目的只有一个别让单条命令长期霸占主线程。总结Redis 把命令处理做成单线程是为了避免复杂加锁和线程切换开销保证原子性与实现简单单线程怕阻塞所以把磁盘持久化交给子进程/异步把网络压力交给 Reactor以及可选 I/O 线程把重活做成渐进式从而在「单线程执行命令」的前提下依然高吞吐。单线程为什么还那么快1. 采用了哪些机制① 内存数据库数据主要在内存里读写比落盘数据库快几个数量级。单线程再串行单条命令也常常是微秒级。② 数据组织高效hashtable 等核心访问目标是 O(1)字典用 hashtable支持扩容/缩容扩缩容时做 渐进式 rehash避免一次性搬迁把主线程卡死③ 数据结构本身做了取舍在「执行效率」和「内存占用」之间平衡并且会 按数据量切换底层结构比如小数据用更省内存的编码变大再切换。类型虽多string/list/hash/set/zset但每种都尽量选最合适的实现。④ 高效的 Reactor 网络模型单线程 I/O 多路复用一个线程同时盯很多连接谁就绪处理谁避免「一连接一线程」的切换和锁成本。2. 做了哪些优化保证单线程不被拖慢① 分治重活拆小步以 rehash 为例把搬迁摊到每次增删查改里做一点就走定时任务里按步长推进比如最多跑约 1ms这样 CPU 耗时运算还在但不会长时间独占主线程。② 阻塞/耗时操作挪走真正容易堵的事大 key 释放、部分磁盘相关工作等放到 其他线程/子进程主线程继续跑命令。③ 对象用不同数据结构实现不是所有场景都用同一种重结构小对象用紧凑编码大了再切换。既省内存也减少无谓的 CPU 开销。3. 串起来看维度做法效果存储内存单次操作极快查找hashtable 等目标 O(1)命令本身轻网络Reactor 多路复用单线程扛高并发连接结构多种编码可切换快且省重活渐进式 / 异步 / 其他线程主线程尽量不堵如何rehashRedis 字典dict底层是哈希表冲突用 链表法一般是 头插用 负载因子 衡量拥挤程度负载因子≈键数量哈希表桶数组大小负载因子≈哈希表桶数组大小键数量​情况典型条件理解用目的扩容负载因子 1还有是否有子进程等细节减轻冲突查改更快缩容负载因子 0.1省内存扩容/缩容会换更大或更小的桶数组哈希映射位置变了旧数据必须重新计算位置并搬过去——这就是 rehash。2. 为什么必须「渐进式」如果一次性把整张大表搬完Redis 命令执行在主线程大 key 空间可能搬很久 → 阻塞所有请求所以 Redis 用 渐进式 rehash一次只搬一点把总耗时摊开。3. 核心结构同时准备两张表rehash 期间字典里有两个哈希表表角色ht[0]旧表rehash 前的数据还在这里ht[1]新表新写入的数据、以及从旧表搬过来的数据平时不用 rehash主要用 ht[0]ht[1] 为空开始 rehash分配好 ht[1]然后一点一点把 ht[0] 往 ht[1] 搬全部搬完释放旧表ht[1] 变成新的 ht[0]4. 每次搬多少渐进式的基本单位是一个数组槽位bucket。取出 ht[0] 某个下标上的整条冲突链表逐个节点按新规则算位置挂到 ht[1]再移动索引处理下一个槽位不是“一次搬一个 key 就算完”而是 按桶推进一个桶上可能挂多个冲突节点。5. 什么时候推进分治的两条路① 命令触发做业务时顺带搬对这个 dict 做增删改查时顺便执行一步或几步rehash。请求越频繁搬得往往越快。② 定时任务后台再推一把serverCron这类周期任务里继续搬按步长推进图里常见说法步长约 100 个槽单次最多大概花 1ms到时间就停避免主线程卡太久所以进度来自日常命令摊一点 定时任务再推一点6. rehash 期间读写怎么做这是面试常问的点查先查 ht[0]找不到再查 ht[1]或按实现先看是否已搬迁总之两张表都可能要看增新数据直接写到 ht[1]避免新数据再进旧表减少重复搬运删/改可能在 ht[0] 或 ht[1]两处都要按规则找到再操作这样保证搬迁过程中服务仍可用只是短暂处于「双表」状态。1. 柔性数组是什么C 语法结构体最后一个成员写成char buf[];长度为 0 的占位真正长度在malloc时一起分配struct sdshdr8 { uint8_t len; uint8_t alloc; unsigned char flags; char buf[]; // 柔性数组不占 sizeof 的“假尾巴” };规则要点必须在结构体末尾前面至少还有其他成员sizeof(struct sdshdr8)只算到 flags不算buf分配malloc(sizeof(header) 实际字符数 1)释放一次free整块内存2. Redis 源码里怎么定义Redis/redis-6.2.2/src/sds.hstruct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; /* 3 lsb of type, and 5 msb of string length */ char buf[]; }; 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[]; }; struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; uint16_t alloc; unsigned char flags; char buf[]; }; // ... sdshdr32 / sdshdr64 同理末尾都是 char buf[];buf[]就是柔性数组字符串内容紧挨着 header 后面存。__packed__是为了避免编译器对齐填充让内存更紧凑。3. 一次分配header 内容 \0创建字符串时_sdsnewlenassert(initlen hdrlen 1 initlen); /* Catch size_t overflow */ sh trymalloc? s_trymalloc_usable(hdrleninitlen1, usable) : s_malloc_usable(hdrleninitlen1, usable); // ... s (char*)shhdrlen; // 对外返回的指针指向 buf 开头 // ... if (initlen init) memcpy(s, init, initlen); s[initlen] \0; return s;一次 malloc 出来的整块内存关键点malloc大小 hdrlen initlen 1对外的sds不是指向结构体开头而是指向buf所以 SDS 能当char*用直接读字符串内容4. 为什么能从s找回 header因为flags就在buf前 1 个字节sds.hLines 83-84 #define SDS_HDR_VAR(T,s) struct sdshdr##T *sh (void*)((s)-(sizeof(struct sdshdr##T))); #define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))sdslen也是先看s[-1]flags再回退到对应 header 读lenstatic inline size_t sdslen(const sds s) { unsigned char flags s[-1]; switch(flagsSDS_TYPE_MASK) { case SDS_TYPE_5: return SDS_TYPE_5_LEN(flags); case SDS_TYPE_8: return SDS_HDR(8,s)-len; // ... } }柔性数组让「元数据 字符串」连在一起指针回退就能找到长度不必另存一份指针。5. 对比不用柔性数组会怎样不用柔性数组常见两种烂写法写法 A结构体里放指针struct bad { int len; char *buf; // 还要再 malloc 一次 };两次分配、两次释放header 和内容可能不连续缓存不友好写法 B写死固定数组struct bad2 { int len; char buf[64]; };短串浪费长串又不够柔性数组刚好一次 malloc / 一次 free长度可变内存连续6. 和对象编码的关系embstrRedis Object 和 SDS 尽量连续分配短串raw对象和 SDS 分开无论哪种SDS 内部都靠char buf[]把「长度信息 字符内容」拼在一块所以柔性数组不是业务概念而是 SDS 能又省又快的 C 级基础。为什么 Redis 字符串以 64 字节为分界Redis 字符串以 64 字节为分界是一个对内存和性能进行“锱铢必较”的精妙设计。这个阈值决定了短字符串应该使用哪种内存布局以追求最佳效率。这个选择主要基于两个层面的考量内存分配的“潜规则”Redis 默认使用的内存分配器如 jemalloc为了减少内存碎片会按2的幂次来分配内存例如 32B、64B、128B 等。64 字节是其中一个非常关键且高效的分配粒度。缓存行的“高速通道”现代 CPU 从内存读取数据时是以64 字节为单位的“缓存行”Cache Line进行的。如果能将 Redis 对象的元数据和字符串数据本身放进同一个缓存行里CPU 就能一次加载完成访问速度会快得多。因此将64 字节设定为分界是一个在内存利用率和访问效率之间的最佳平衡点既能把内存分配和缓存读取的潜在好处都利用上又不会造成浪费。为什么实际阈值是 44 字节既然分界是 64 字节为什么实际能存的字符串长度只有44 字节这多出来的 20 个字节是被“元数据”占用了。一个Redis 的字符串对象除了纯数据外还包括以下固定开销RedisObject 结构体robj16 字节。用于记录类型、引用计数、LRU 信息等元数据。SDS 头部sdshdr83 字节。在 Redis 3.2 版本后对于短字符串使用sdshdr8类型其中len、alloc和flags各占 1 字节。字符串结束符\01 字节。为了兼容 C 语言字符串函数而保留。总的固定开销就是16 3 1 20 字节。那么在 64 字节的总空间里留给实际字符串内容的最大长度就是64 - 20 44 字节。两种编码模式的区别embstr (嵌入式字符串)当字符串长度 ≤ 44 字节时使用。RedisObject、SDS 头部和数据被分配在一块连续的内存空间中一次分配即可完成且能充分利用 CPU 缓存访问效率极高。raw (原始字符串)当字符串长度 44 字节时使用。RedisObject和 SDS 数据被分配在两块不连续的内存空间中需要两次内存分配且可能无法完全放入同一个缓存行访问效率相对较低。所以这 44 字节的阈值并非随意设定而是 Redis 基于 64 字节的内存分配与缓存行大小扣除了必要的元数据开销后精心计算出的效率最优解。Redis io 多线程工作原理主线程在做什么主线程还是核心跑事件循环接受连接、发现哪些客户端可读/可写把待处理客户端放进clients_pending_read/clients_pending_write轮询分配到io_threads_list[0..n]其中[0]是自己自己也承担一部分 socket 读写请求读完后解析并执行命令改数据只在这里发生生成响应后再组织写回所以主线程 调度者 部分 I/O 工人 唯一的命令执行者。其他 I/O 线程在做什么io_threads_list[1]、[2]、[3]… 这些线程更单纯按分配到的客户端列表并行做 read读入请求数据或并行做 write写出响应数据不执行SET/GET等业务命令不直接修改 Redis 键值数据它们就是主线程请来分担网络瓶颈的。Redis 持久化RDB 与 AOF 到底在干什么Redis 把数据放在内存里所以很快进程挂了、机器重启内存也会空。持久化就是在磁盘上留一份备份重启后把数据找回来。Redis 常用两种方式RDBRedis Database某一时刻的数据快照AOFAppend Only File每一条写命令的流水账生产里常常两个都开。下面分开讲再对比最后把 RDB 后台快照依赖的fork和写时复制说清楚。一、RDB给内存拍一张快照RDB 的全称是 Redis Database。它做的事很单纯把 某一时刻内存里的全部数据 写成一份二进制文件一般叫dump.rdb。可以把它想成「定时给内存拍快照」。重启时把这张照片加载回来数据就恢复了。文件里已经是每个 key 的最终样子不是中间改过多少次。怎么生成方式行为save主线程自己写盘会卡住 Redis生产几乎不用bgsavefork()出子进程在后台写主进程继续对外服务生产里说的 RDB基本都是bgsave。也可以按配置自动触发例如「15 分钟内至少改了 1 次」就自动拍一张。特点文件是压缩过的二进制通常比较小恢复快直接按快照还原对象两次快照之间的新写入不在旧文件里。例如 5 分钟拍一次第 4 分钟崩溃这 4 分钟可能丢适合缓存为主、丢几分钟能接受、要定期拷文件做备份。二、AOF把每一次写入记到账本上AOF 的全称是 Append Only File只追加文件。每次增删改都会往文件末尾追加一条命令例如SET、INCR、DEL。文件一般叫appendonly.aof。重启时从头把命令重放一遍内存就会回到关机前的状态。它记的是过程不是某一时刻的完整照片。刷盘策略数据能有多安全写到 AOF 文件后还要看什么时候真正刷到磁盘策略含义安全性always每条写立刻刷盘几乎不丢最慢everysec每秒刷一次常用大约最多丢 1 秒no交给操作系统决定可能丢更多文件会胀所以要重写账本只追加、不改前面文件会越来越大。Redis 可以用 AOF Rewrite 压缩根据当前内存生成一份更精简的新 AOF。日常追加记账不用 fork重写时会 fork 子进程做法和bgsave很像。适合希望尽量少丢数据能接受文件更大、恢复稍慢。三、RDB 和 AOF 的区别对比项RDBAOF全称Redis DatabaseAppend Only File记什么某一时刻的全部数据每一条写命令文件大小二进制、可压缩通常 更小文本命令日志通常 更大恢复速度快直接加载最终状态较慢要重放历史命令数据安全两次快照之间可能丢一段常用everysec大约最多丢 1 秒运行时开销平时轻fork时可能抖一下每次写都记日志开销更持续forkbgsave会 fork日常记账不 fork重写才 fork文件大小为什么 RDB 更小一个计数器INCR一百万次RDB 里可能只存一个最终数字AOF 里可能有一百万条INCR中间被改过又删掉的 keyRDB 里已经不存在AOF 仍可能先写创建、再写删除。所以同样一份数据RDB 文件通常更小。恢复速度为什么 RDB 更快RDB 加载 ≈ 读一个较小文件 按 key 重建对象。AOF 恢复 ≈ 把历史上每一条写命令再执行一遍。历史越长AOF 越慢。数据安全谁更好一般认为 AOF 更安全因为它更接近「实时记账」。RDB 的安全取决于快照间隔间隔越大潜在丢失窗口越大。如果业务几乎不能丢应开 AOF常用everysec而不是只靠 RDB。很多团队两个都开AOF 保安全RDB 方便备份和快速全量恢复。Redis 4.0 以后还有 混合持久化AOF 文件开头先放一份 RDB后面再跟增量命令。重启先加载快照再重放少量命令兼顾少丢和恢复快。四、fork 是什么什么时候发生fork 是操作系统提供的能力让当前进程再分出一个 子进程。父进程原来的 Redis 主进程继续处理客户端子进程新分出来的拿着 这一瞬间 的内存去写文件为什么不另开一个空程序因为新程序看不到 Redis 内存里的数据。fork 出来的子进程几乎是「此刻这个 Redis 的副本」。fork 出现在哪场景会不会 forkRDB 后台快照bgsave会RDB 同步快照save不会主线程自己写会卡住AOF 日常追加不会AOF 重写会平时说 Redis 的 fork多半指 RDB 的bgsave。五、fork 的流程以及写时复制核心就一句话fork 时不把整块内存再拷一份先复印「页表」并共享物理内存谁改某一页才把那一页真正拷开。 这叫 写时复制Copy-On-WriteCOW。页表可以理解成地址翻译表进程以为的地址虚拟内存→ 内存条上的真实位置物理内存。像快递柜对照表程序只认「3 号柜」页表告诉 CPU 它其实在仓库第 108 格。1. 调用 fork 时发生在内核里立刻做完这时 就会复制父进程的页表等不到子进程去写 RDB也等不到父进程执行SET创建子进程复制父进程的页表复印目录不复印整仓货物把相关页表项改成 只读物理内存先不拷父子指向同一块 RAMfork返回父进程拿到子进程 PID子进程拿到 0然后分道扬镳所以 fork 通常很快实例特别大时复制这份页表仍可能让 Redis 卡一小下。fork 刚结束父进程页表 ──┐├──► 同一块物理内存只读子进程页表 ──┘2. 之后写时复制共享页是只读的。父进程继续处理SET/DEL要改某一页时CPU 发现页只读 → 写保护中断内核只把 这一页物理内存 复制一份通常 4KB修改方的页表改指新页另一方仍指旧页这一对页改回可读可写父进程改了某一页之后父 ──► 新页改后的数据继续对外服务子 ──► 旧页fork 那一刻的数据继续写 RDB整份页表只在 fork 时拷一次。后面不会再复印整张表只拷被改到的物理页并改对应的那一条映射。