redis存储原理与数据模型

1、redis 是不是单线程?单线程是指什么?
具体说是命令处理或业务处理是单线程的,实际上,由于其他耗时任务如异步关闭大文件,aof持久化,内存管理,网络io读线程和写线程都是异步线程,整个redis进程不是单线程的

2、命令处理为什么使用单线程?
虽然单线程不能执行耗时任务(cpu运算、阻塞io),影响响应性能, redis里可能的耗时操作--io层面:磁盘io(方法1:fork子进程 rdb持久化;方法2:异步线程aof持久化)网络io (客观层面的 用户密集 io密集:采样reactor模型, 请求或返回的数据量大、 开启了io多线程处理大数据); cpu密集型:(分治的方法、数据结构切换、渐进式数据迁移);
此外多线程的加锁复杂,在redis不同数据结构下,加锁的颗粒度不好控制;频繁cpu上下文切换,抵消多线程异步优势;

3、单线程为什么这么快?
包括redis 的 数据组织结构、高效的数据结构、reactor网络模型、优化算法(扩容的分治,异步执行其他耗时阻塞任务、对象类型采样不同的数据结构实现)
redis固有机制,本身特性就很快:比如内存数据库 读写快,数据组织(kv) O(1) 在hashtable存储kv;采样siphash随机性较大,尽可能避免hash冲突;
关于冲突, redis负载因子为1 = used/size; table存储的元素==数组长度的; 负载因子越小,冲突越小;为了解决冲突,使用链地址法(拉链法);当多个键的哈希值映射到同一个桶时,它们会依次链接在该桶的链表上,查找时遍历链表找到目标键。
扩容(rehash)的目的是降低哈希表的负载因子,扩容规则是翻倍;相应的,当负载因子<0.1,发生缩容,规则是恰好包含used的2^n; 将 ht[0] 中的键值对重新分布到新分配的 ht[1] 中,迁移完成后交换指针,使 ht[1] 成为新的 ht[0]。 特别的,如果在扩容或缩容时 table中元素过多, 不能一次性rehash完成(耗时任务); 采用渐进式rehash;
rehash步骤:将 ht[0] 中的元素重新经过 hash 函数生成 64 位整数,再对 ht[1] 长度进行取余,从而映射到 ht[1];
渐进式规则:

  1. 分治的思想,将 rehash 分散到之后的每步增删改查的操作当中;
  2. 在定时器中,最大执行一毫秒 rehash; 每次步长 100 个数组槽位;
    面试:处于渐进式 rehash 阶段时,是否会发生扩容缩容?不会!

scan scan cursor [MATCH pattern] [COUNT count] [TYPE type] 在渐进式扩容如何查找元素;
采用高位进位加法的遍历顺序,rehash 后的槽位在遍历顺序上是相邻的;
遍历目标是:不重复,不遗漏;
会出现一种重复的情况:在 scan 过程当中,发生两次缩容的时候,会发生数据重复;关于 scan,scan 要达到的目的是从 scan 开始那刻起 redis 已经存在的数据进行遍历,不会重复和遗漏(例外是 scan 过程中两次缩容可能造成数据重复), 因为比如我 scan 已经快结束了,现在插入大量数据,这些数据肯定遍历不到;扩容和缩容造成映射算法发生改变,但是使用高位进位累加的算法,可以对 scan 那刻起已经存在数据的遍历不会出错;

此外,KV的V数据结构是高效的,如图:
image
上图中的数据结构简单如下解释:

SDS(Simple Dynamic String)​是 Redis 用来替代 C 原生 char* 的字符串结构,原生二进制不安全(遇到 \0 截断;redisObject是 Redis 用来包裹所有键值对 value​ 的统一结构体。sds中的柔性数组是定义在结构体中且在这个包含了其他成员的结构体的末尾。sizeof返回的结构体大小不会包含柔性数据大小; 一次malloc,结构体和数组连续存放,一次free,如果是指针,指针的数据还需要malloc;广泛应用于可变长度的消息字段

struct redisObject {
    unsigned type:4;        // 对象类型:String/List/Hash/Set/ZSet/Stream
    unsigned encoding:4;    // 底层编码:OBJ_ENCODING_EMBSTR/RAW/HT/ZIPLIST/INTSET/SKIPLIST...
    unsigned lru:LRU_BITS;  // LRU 时钟或 LFU 计数(用于内存淘汰)
    int refcount;            // 引用计数(内存回收)
    void *ptr;               // 指向真正的底层数据结构
};

SKIPList 跳表是一种基于有序链表的概率性数据结构,通过维护多层索引来实现近似二分查找的效率;没有概率性,那就是理想跳表,但是增删改 需要重新构建每个层级的结构;因此,我们每次插入节点,概率性随机定义层级。通过查找确定位置,然后随机决定新节点的层数,并在各层插入指针。删除:在各层找到并移除该节点。 最终的查找平均复杂度 O(log2 n),实践性的跳表,还会限制 最高层级数,并概率较低;

4、redis io 多线程工作原理?
复述reactor的作用,传统网络模型,不知道io何时就绪,因此线程需要阻塞等待;那么reactor解决了这个等待时机的问题,对就绪的io进行处理。
redis处理请求整体流程,读数据,解协议,计算业务,加密协议,写数据(发生回复)。

  • 读事件回调:readQueryFromClient:分割数据包并处理:processInputBuffer、分割数据包processInlineBuffer/processMultibulkBuffer、处理数据包processCommandAndResetClient
  • 写数据到buffer:addReply
  • 数据写到socket:handleClientsWithPendingWritesUsingThreads->writeToClient
  • 写事件回调:sendReplyToClient
    处理流程
  • readQueryFromClient
  • read->connRead
  • decode-> networking.c : processInlineBuffer/processMultibulkBuffer
  • compute-> server.c : processCommand
  • send -> clientInstallWriteHandler-> writeToClient

为什么使用多线程? 这里在很多步骤都可能存在耗时任务: 比如在最开始,读数据可能大,解析协议也可能耗时,相似的写数据可能大;
Redis 6.0 之前(以及 6.0 之后如果配置 io-threads-do-reads no),使用的是纯单线程同步模型。整个过程是同步阻塞的,主线程在事件循环中直接调用 read() 读取数据,然后解析、执行命令,最后直接调用 write() 写回结果。
image

下面是多线程异步IO方案, 这里的线程池恰好和任务偷取线程池相似,

  • 主线程epoll 发现多个fd可读, Round robin分发任务io线程;
  • 在各自io线程读取数据,解码,读入客户端的 querybuf输入缓冲区;
  • 所有 IO 线程完成读取后,主线程进行业务计算,执行结果被写入客户端的 replybuf(输出缓冲区);
  • IO 线程 加密协议,写入fd;

因此,redis多线程整个流程:io线程并发的读数据,解析协议;主线程串行执行所有业务;在并发加协议和写数据。
image

5、面试题: redis中的string为什么以64B作为数据结构不同的分割?以字符串数据结构为例,字符串<44B 是embstr编码;否则raw编码。44的来源?
在 64 位系统​ 上:redisObject 结构体占用16字节(type、encoding、lru、refcount、ptr 等)。
由于C标准字符串不安全,redis内置了SDS;SDS头部(sdshdr8)占用 3 字节(len、alloc、flags),加上字符串末尾的 \0 共 4 字节。因此,固定开销为 16 + 4 = 20 字节。
现代内存分配器(如 jemalloc、tcmalloc)cache line 通常按64字节​为单位分配小块内存。为了将整个embstr对象放入一个缓存行中,可用空间为 64 - 20 = 44 字节。

posted @ 2026-08-26 14:51  超级麋鹿  阅读(6)  评论(0)    收藏  举报