ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis 数据结构与单线程模型深度解析:对外接口与底层编码的双重智慧

Redis 数据结构与单线程模型深度解析:对外接口与底层编码的双重智慧 一、前言为什么 Redis 能扛 10 万 QPS面试时几乎 100% 会被问“Redis 为什么这么快” 标准答案里有两条内存操作比磁盘快 10 万倍单线程 I/O 多路复用避免上下文切换但这只是冰山一角。真正支撑 Redis 高性能的是它精心设计的数据结构——对外暴露 5 种基础类型对内却用 9 种底层编码实现每种编码都针对特定场景做了极致优化。本文从外到内、从应用到原理逐层剥开 Redis 的双重设计智慧。二、对外5 种基础数据类型类型常用操作典型场景内部编码3 种StringGET/SET/INCR/APPEND缓存、计数器、分布式锁int / embstr / rawListLPUSH/RPUSH/LPOP/LRANGE消息队列、最新列表listpack(quicklist)HashHSET/HGET/HGETALL对象存储、用户画像listpack / hashtableSetSADD/SMEMBERS/SINTER标签、共同好友、抽奖intset / hashtable / listpackZSetZADD/ZRANGEBYSCORE排行榜、延迟队列listpack / skiplisthashtable还有 4 种高级类型Bitmap、HyperLogLog、GEO、Stream本文聚焦基础 5 种。三、对内9 种底层编码全景图渲染错误:Mermaid 渲染失败: No diagram type detected matching given configuration for text: ┌────────────────────────────────────────────────────────────┐ │ Redis Object │ │ (type: String/List/Hash/Set/ZSet, encoding: 编码类型) │ └────────────────────────────────────────────────────────────┘ │ ┌─────────────────────┼─────────────────────┐ │ │ │ String 类型 容器类型 特殊编码 ┌──────┐ ┌──────────┐ ┌──────────┐ │ int │ │ listpack │ │ intset │ │embstr│ │ │ │(整数集合) │ │ raw │ └──────────┘ └──────────┘ └──────┘ ↑ ┌──────┴──────┐ │ quicklist │ │(List专用) │ └─────────────┘ ↓ ┌──────────────────┐ │ skiplist dict │ (ZSet 大数据) │ hashtable │ └──────────────────┘3.1 编码转换阈值Redis 7.0 默认类型编码升级条件元素变多时降级条件Stringint → embstr → raw长度 44 字节不能降级Listlistpack(quicklist)不升级不降级Hashlistpack → hashtable元素 128或value 总长 64 字节不降级Set (整数)intset → hashtable元素 512或出现非整数不降级Set (非整数)hashtable不升级不降级ZSetlistpack → skiplist元素 128或member 长度 64 字节不降级阈值可通过redis.conf配置hash-max-listpack-entries等参数调整。四、逐个拆解5 种数据结构的底层实现4.1 String三种编码的玄机// redisObject 伪代码structredisObject{unsignedtype:4;// 类型字符串、列表、哈希...unsignedencoding:4;// 编码void*ptr;// 指向底层实现intrefcount;// 引用计数unsignedlru:24;// LRU 时间戳};4.1.1 int 编码SET counter100OBJECT ENCODING counter# int存的是 64 位整数ptr 直接指向值不是指针省一次解引用。4.1.2 embstrembedded string编码长度 ≤ 44 字节时字符串与 redisObject 一起分配在一块连续内存[redisObject (16 字节) | sdshdr (3 字节) | buf (≤44 字节)] 总共一次 malloc缓存友好4.1.3 raw 编码长度 44 字节字符串单独分配内存redisObject.ptr 指向它。4.1.4 验证SET s1helloOBJECT ENCODING s1# embstrSET s2$(python3-cprint(x*45))OBJECT ENCODING s2# raw实战建议能用整数就别用字符串省内存能用短字符串就别用长字符串走 embstr。4.2 Listquicklist双向链表 listpackRedis 3.2 之前用 ziplist linkedlist之后统一改成quicklist——一个双向链表每个节点是 listpackquicklist ├── listpack (entry1, entry2, ...) │ ↓ ├── listpack (entry3, entry4, ...) │ ↓ └── listpack (entry5, ...)4.2.1 listpack 替代 ziplist 的原因ziplist 的级联更新问题entry A 增大 → 后续 entry 偏移量变化 → 可能触发连锁 realloc。listpack 用“每个 entry 自带长度”解决了这个问题单 entry 变化不影响其他 entry。4.2.2 验证RPUSH list a b c d e OBJECT ENCODING list# listpackLRANGE list0-1# 1) a 2) b 3) c 4) d 5) e4.3 Hashlistpack vs hashtableHSET user nameAliceage30OBJECT ENCODING user# listpack小数据时# 写入超过 128 个字段后# hashtablelistpack 存小 Hash 更快连续内存、CPU 缓存命中高。hashtable 存大 Hash 更快O(1) 查询不需遍历。4.4 Setintset 装整数SADD nums12345OBJECT ENCODING nums# intsetSADD numshelloOBJECT ENCODING nums# hashtable只要混入非整数就升级intset 底层是有序数组二分查找 O(log n)比 hashtable 还省内存。4.5 ZSetskiplist hashtable 双剑合璧ZSet 既要按 score 排序又要按 member 查 score单一结构搞不定。Redis 的解法是同时维护两个结构ZSet Object ├── hashtable: member → score (O(1) 查分) └── skiplist: 按 score 排序 (O(log n) 范围查)这两份数据共享 member 指针不重复存储。4.5.1 为什么用跳表不用红黑树Redis 作者 antirez 亲自答过跳表实现简单调试容易范围查询天然支持红黑树要中序遍历并发场景下跳表局部锁更友好性能差距不大10w 级数据没区别4.5.2 跳表结构示意Level 3: head ------------------------------ 50 ---------------- nil Level 2: head -------- 20 ---------------- 50 ---------------- nil Level 1: head - 10 - 20 - 30 - 40 - 50 - 60 - 70 - 80 - nil每层都是有序链表查找时从最高层开始最坏 O(log n)。五、单线程模型误解与真相5.1 三大常见误解误解真相❌ Redis 只有一个线程Redis 6.0 在网络 I/O层用了多线程IO_THREADS命令执行仍是单线程❌ 单线程 慢单线程避免了锁竞争和上下文切换CPU 利用率反而更高❌ 单线程 只能用 1 个 CPU 核心通过多实例端口不同可以吃满多核5.2 单线程的核心I/O 多路复用Redis 用epollLinux/kqueuemacOS实现一个线程处理多个客户端连接┌──────────────────┐ │ epoll 监听器 │ │ (单线程事件循环) │ └────────┬─────────┘ │ ┌────────────┼────────────┐ ↓ ↓ ↓ 客户端 A 客户端 B 客户端 C (fd5) (fd6) (fd7)事件循环伪代码while(1){// 1. epoll_wait 阻塞等待任意 fd 就绪intnepoll_wait(epfd,events,MAX_EVENTS,-1);// 2. 遍历就绪事件for(inti0;in;i){if(events[i].data.fdlisten_fd){// 新连接 → acceptacceptClient();}elseif(events[i].eventsEPOLLIN){// 可读 → 读命令 解析 执行readQueryAndExecute();}elseif(events[i].eventsEPOLLOUT){// 可写 → 写响应writeReply();}}}5.3 为什么单线程能跑出 10 万 QPS内存操作100ns 级1 秒可执行 1000 万次非阻塞 I/Oepoll_wait 不会被单条慢请求阻塞命令执行快大多数命令是 O(1) 或 O(log n)避免锁开销单线程天然无锁5.4 6.0 多线程演进redis.conf中io-threads 4 io-threads-do-reads yes职责划分操作是否多线程线程接收连接、读取请求、发送响应✅ 多线程IO_THREADSI/O 线程池解析命令、执行命令、访问数据结构❌ 单线程主线程多线程只解决了网络读写瓶颈命令执行还是单线程——这是 Redis 的设计哲学把最热的路径保持单线程避免引入锁。六、避坑指南使用 Redis 的 7 个反模式6.1 严禁 keys *# ❌ 千万别用KEYS pattern*# ✅ 用 SCAN 迭代SCAN0MATCH user:* COUNT100KEYS 会阻塞主线程遍历所有 keyO(N) 复杂度。生产环境一条 KEYS 就可能把 Redis 打挂。6.2 大 Key 治理redis-cli--bigkeys类型大 Key 阈值String 10 KBHash/List/Set/ZSet元素数 5000大 Key 导致DEL 时阻塞Redis 4.0 后用 UNLINK 异步删除传输慢、网络带宽占满集群环境下 slot 迁移困难6.3 慢命令命令复杂度风险KEYSO(N)阻塞HGETALL大数据O(N)阻塞SMEMBERS大数据O(N)阻塞ZUNIONSTORE大数据集O(NM)阻塞FLUSHDBO(N)阻塞6.4 合理使用数据结构场景错误选择正确选择排行榜List要遍历ZSet共同好友Set用 SINTER 慢Set 提前算好文章标签Hash要 HGETALL用 Tag ID 数组 单独 tag 表计数器StringString INCR原子6.5 避免 O(N) 命令套 N 层# ❌ 灾难LRANGE list0-1|xargs-I{}GET{}# ✅ 用 pipeline 一次性发pipelineEOF LRANGE list 0 -1 EOF6.6 持久化与 forkBGSAVE 会 fork 子进程fork 瞬间主线程阻塞复制页表。内存越大阻塞越久GB 级内存可能阻塞 10-100ms。# 建议 save appendonly yes appendfsync everysec6.7 内存淘汰策略maxmemory 4gb maxmemory-policy allkeys-lru常用策略对比策略适用noeviction不淘汰写报错默认allkeys-lru通用缓存volatile-lru区分冷热重要数据设 TTLallkeys-lfu4.0更智能的淘汰推荐七、面试高频问答速记Q1Redis 是单线程还是多线程A网络 I/O 层多线程6.0命令执行层始终单线程。Q2Redis 为什么用跳表不用红黑树A实现简单、范围查询友好、并发局部锁更友好、性能差距不大。Q3embstr 和 raw 的区别Aembstr 一次 malloc≤44 字节连续内存缓存友好raw 两次 malloc44 字节。Q4ZSet 为什么同时用跳表和字典A跳表做范围查询 O(log n)字典做单点查询 O(1)两者互补。Q5Redis 6.0 多线程一定更快吗A不一定。如果瓶颈在命令执行如大量 LRANGE多线程没用。瓶颈在网络带宽如大 key 传输时才有帮助。Q6Redis 单线程如何利用多核A多实例端口不同部署在同一台机器上集群化扩展。八、总结双重智慧对外5 种基础类型String/List/Hash/Set/ZSet 4 种高级类型Bitmap/HyperLogLog/GEO/Stream 对内9 种底层编码 - 连续内存结构listpack/intset装小数据缓存友好 - 散列结构hashtable/skiplist装大数据O(1)/O(log n) 执行单线程命令执行 I/O 多路复用 性能根源内存 无锁 epoll 高效数据结构设计哲学Redis 把热的路径做到极致单线程命令把凉的复杂度用数据结构消化编码自动转换。写在最后下次面试被问Redis 为什么快别只答内存 单线程了——从数据结构、底层编码、epoll 三个角度展开说能让面试官眼前一亮。
返回列表