一、Redis底层数据结构有哪些?为什么要基于这些结构构建对象系统?
核心考点
准确掌握Redis的6种底层基础结构,理解"底层结构≠直接使用"的设计逻辑------底层结构是"基础组件",对象系统是"定制化产品",适配不同业务场景的key-value存储需求。
通俗理解+例子
我们可以把Redis的底层结构和对象系统,类比成"家具工厂的原材料与成品家具":
• 底层结构就是"原材料":动态字符串(SDS)像木板、链表像金属连接件、字典像合页、跳跃表像滑轨、整数集合像小五金件、压缩列表像布艺面料------这些原材料单独拿出来没用,比如一块木板不能直接当衣柜用,一根金属连接件也不能直接当椅子用,必须经过组合加工才能变成可用的家具。
• 对象系统就是"成品家具":Redis不会把木板(SDS)直接给用户用,而是用木板+金属连接件+合页,做成衣柜(对应string类型,存用户昵称、商品标题)、书桌(对应hash类型,存用户信息:姓名、年龄、地址)、抽屉柜(对应zset类型,存商品销量排名)。比如你要存"用户A的昵称是小明",用的是string对象,而这个string对象的底层,就是用SDS(木板)做的"柜体";你要存"用户A的收货地址列表",用的是list对象,底层是快速列表(木板+金属连接件的组合,后续会详细说明)。
• 为什么要这么设计?就像家具工厂不会只卖原材料------用户需要的是能直接用的衣柜、书桌,而不是自己拼原材料。Redis的对象系统就是帮用户"拼好成品",比如同样用SDS,既能做存短文本的string对象,也能做hash对象里的"字段名"和"字段值",不用用户自己去组合底层结构,还能根据数据量自动调整(比如hash对象小的时候用压缩列表,数据量大了就切换成字典)。
关键提醒
6种底层结构是Redis所有key-value类型的"基石",具体对应关系可简单记忆:string底层是SDS;list底层是快速列表(3.2+版本);hash底层是压缩列表(小数据场景)或字典(大数据场景);zset底层是压缩列表(小数据场景)或跳跃表+字典(大数据场景);set底层是整数集合(纯整数小数据场景)或字典(非整数/大数据场景)。
二、Redis的SDS和C语言字符串比,优势在哪?
核心考点
对比C语言字符串的4个核心缺陷,理解SDS针对这些缺陷的优化思路------本质是"用少量额外空间,换效率、安全和功能扩展",这也是Redis能高效处理字符串的关键。
通俗理解+例子
要搞懂SDS的优势,先得知道C语言字符串的"坑",我们用"学生记笔记"这个日常场景来类比:
- 获取长度:从"翻遍笔记本"到"看封面标签"
• C语言字符串像"没写总页数的笔记本":比如你记了一学期的课堂笔记,想知道总共写了多少页,只能从第一页开始翻,一页页数到最后一页(遍历整个字符串,时间复杂度O(n))。如果笔记有1000页,你得翻1000次才能知道页数,不仅费时间,还容易数错。
• SDS像"写了总页数的笔记本":笔记本封面直接贴了标签"共328页",不管你想知道页数还是想翻到最后一页,看一眼标签就够了(直接读取len属性,时间复杂度O(1))。哪怕笔记有10000页,也不用翻一页纸,效率直接拉满,还不会出错。
- 缓冲区溢出:从"水杯漏水"到"自动扩容保温杯"
• C语言字符串像"没标最大容量的敞口水杯":你不知道这个杯子能装多少水,只知道现在装了半杯。如果朋友帮你倒开水,没注意容量,倒多了就会洒出来(缓冲区溢出)------比如你定义了一个能存5个字符的C字符串"abcde"(实际占6个字节,最后一个是结束符\0),想把它改成"abcdefg",C语言不会检查容量是否足够,直接往后面写,就会覆盖后面的内存数据,导致程序崩溃,就像水洒出来弄湿了桌面。
• SDS像"会自动变大的保温杯":杯子上标了"当前装300ml,最大能装500ml",当你倒开水到450ml时,杯子会自动膨胀,把最大容量改成1000ml(这就是SDS的空间预分配机制),永远不会洒出来。比如你要给SDS追加内容,SDS会先检查当前剩余空间(alloc - len)够不够,如果不够,先自动扩容到足够的大小,再追加内容,完全杜绝溢出问题,就像保温杯总能装下你倒的水。
- 内存分配次数:从"每次换衣服都买新的"到"一次多买两件+旧的先留着"
• C语言字符串改长度,像"每次穿衣服都要重新买一件":比如你有一件M码的衣服(对应C字符串长度5),长胖了要穿L码(长度8),得重新买一件L码的(内存重新分配);后来瘦了又要穿M码(长度5),又得重新买一件M码的(再次内存分配)------每次改长度都要"买新衣服",频繁跑服装店(操作系统内存管理),不仅麻烦,还浪费钱(效率低)。
• SDS用"空间预分配+惰性空间释放",像"聪明的购物方式":
◦ 空间预分配:你买衣服时,本来穿M码,直接买L码和XL码两件(分配比实际需要更多的空间),下次长胖了直接穿L码,不用再买(下次追加内容时,若剩余空间够,不用重新分配);
◦ 惰性空间释放:你瘦了穿回M码,L码的衣服先不扔(SDS缩短时,不立即释放多出来的空间),下次再长胖还能穿(下次追加内容时,直接用预留的空间)。
这样一来,你不用频繁跑服装店,内存分配次数大大减少,效率自然更高。
- 二进制安全:从"只能装水的杯子"到"万能收纳箱"
• C语言字符串像"只能装纯净水的杯子":如果装的是果汁(带果肉),果肉会堵住杯口(C语言会把\0当成字符串结束符,比如二进制数据里的\0会被误认为"字符串结束",导致数据截断)。比如你想存一张用户头像的二进制数据(里面有很多\0),C语言会只存到第一个\0就停止,后面的头像数据全丢了,相当于果汁只装了半杯,果肉全没了,头像显示不完整。
• SDS像"带密封盖的万能收纳箱":不管你装水、装果汁、装小石子(对应任意二进制数据,比如图片、音频、视频),都能完整装下,取出来的时候和放进去的一模一样,不会丢任何东西。SDS是按"字节"来存储和读取的,不管里面有没有\0,都按len属性记录的长度来处理,完全实现二进制安全,就像收纳箱能完好保存所有物品。
关键提醒
SDS不仅是Redis字符串类型(string)的底层实现,还是其他所有复合类型(hash、list、zset、set)中"字符串数据"的存储基础------比如hash的"字段名"和"字段值"、list里的每个元素、zset的"成员",本质都是SDS,可见SDS在Redis中的核心地位。
三、Redis字典是怎么实现的?什么是渐进式Rehash?
核心考点
理解字典"数组+链表"的底层结构(解决哈希冲突的关键),掌握Rehash的触发条件,重点搞懂"渐进式Rehash"如何避免服务阻塞------这是Redis保证高可用性的重要设计之一。
通俗理解+例子
- 字典的底层实现:小区的"智能快递柜"
我们可以把Redis的字典类比成小区门口的"智能快递柜",这个场景很贴近生活,容易理解:
• 数组就是快递柜的"格子":每个格子有一个编号(对应哈希表的索引),比如1号格、2号格、3号格......每个格子最多放一个快递盒(对应哈希表节点),快递盒里装的是"快递单(key)+ 包裹(value)"------key就是用户要查找的"取件码对应的标识",value就是要存储的数据。
• 链表就是"格子里叠放的快递":当两个快递的"取件码哈希后"都对应同一个格子(这就是哈希冲突),比如快递A的取件码哈希后是3,快递B的取件码哈希后也是3,这时候不能把两个快递挤在一个格子里,就把它们叠成一摞(形成链表),放在3号格里。取快递时,先找到对应的格子,再顺着叠放的顺序找自己的快递(遍历链表),就像你去快递柜取件,先找到格子,再从叠放的快递里找自己的那一个。
• 哈希计算的过程:就像快递员计算"取件码对应哪个格子"------比如快递柜有16个格子,取件码是"20240520",用取件码除以16取余数(这就是简单的哈希算法),得到余数3,就把快递放在3号格。Redis里就是用key的哈希值,对哈希表的大小取模,得到对应的数组索引,确定key要存在哪个"格子"里。
- Rehash:快递柜"扩容/缩容"
当小区里的快递越来越多,16个格子的快递柜全满了(对应哈希表负载因子超过1,负载因子=哈希表已用节点数/哈希表大小),再放快递就没地方了------这时候物业要换一个更大的快递柜(比如32个格子,对应哈希表扩容),这个"换柜子"的过程就是Rehash:
• 第一步:新建一个更大的快递柜(新哈希表ht[1]),比如原来16格,新的32格;如果快递很少,比如32格的柜子只放了3个快递(负载因子低于0.1),就换一个小柜子(16格,对应哈希表缩容)。
• 第二步:把旧柜子(旧哈希表ht[0])里的所有快递,全部搬到新柜子里------搬的时候要重新算每个快递的格子(用新哈希表大小取模),比如原来3号格的快递,可能会搬到6号格,因为新柜子的大小变了,取模结果也会变。
• 第三步:所有快递搬完后,删掉旧柜子,让新柜子(ht[1])变成"默认快递柜",原来的ht[1]变成新的ht[0],等待下一次Rehash。
- 渐进式Rehash:"不闭馆的快递柜搬迁"
如果直接一次性把旧柜子的所有快递搬到新柜子,会出现什么问题?比如旧柜子里有10万个快递,搬完需要10分钟------这10分钟里,快递柜不能用(Redis线程阻塞),业主取不了快递,快递员也放不了快递,线上服务直接瘫痪,就像快递柜贴了"暂停使用"的通知,大家只能等着。
Redis的"渐进式Rehash"就是为了解决这个问题,它像"不闭馆的搬迁",完全不影响用户使用:
• 搬迁不一次性完成,而是"见缝插针":每次有业主取快递(Redis执行查询操作)、快递员放快递(Redis执行插入操作)时,顺便把旧柜子里的1-2个快递搬到新柜子里。比如业主小明取3号格的快递时,物业顺便把3号格里的另一个快递搬到新柜子的6号格;快递员放新快递时,也会顺手搬一个旧快递,这样搬迁就在"空闲时间"悄悄进行。
• 新旧柜子同时可用:搬迁期间,新快递会直接放进新柜子(ht[1]),取快递时先查新柜子,新柜子没有再查旧柜子(ht[0])------业主完全感觉不到搬迁,正常取放快递,就像快递柜一直正常运行,没人知道后台在换柜子。
• 搬迁结束:当旧柜子里的快递全搬完了,物业就删掉旧柜子,搬迁正式结束,整个过程没有影响过业主的使用。
这样一来,搬迁过程不会阻塞快递柜的使用(Redis服务),既完成了扩容/缩容,又保证了线上服务的可用性,这就是渐进式Rehash的巧妙之处。
关键提醒
字典是Redis的"核心数据结构":除了hash类型的底层实现,Redis的全局key-value存储(所有的key和对应的value)、带过期时间的key存储(过期字典)、集群模式下的节点映射,用的都是字典结构,可见字典在Redis中的重要性。
四、跳跃表是怎么实现的?Redis为什么用跳跃表而不用红黑树?
核心考点
掌握跳跃表"分层加速查找"的核心原理,理解Redis选择跳跃表的两个关键原因------性能适配高并发、实现难度低,这也是面试中常被追问的点。
通俗理解+例子
- 跳跃表的实现:城市的"分层地铁线路"
我们先想一个问题:如果有一个有序链表,里面存了1000个数字(1→2→3→...→1000),要找"500"这个数字,得从1开始一个个往后数,要数500次(时间复杂度O(n)),特别慢,就像坐公交一站站停,到目的地要很久。
跳跃表的出现,就是为了"少停几站"------它像城市里的"分层地铁线路",普通链表是"只停每一站的普通线",跳跃表多了"快线"和"直达线",通过"跳着走"减少查找次数,就像坐地铁选快线,比普通线快很多。
• 先看跳跃表的结构组成(对应地铁线路):
◦ 节点:就是地铁的"站点",每个站点存3个核心信息------"分值(站号,比如1、5、10)"、"成员(站名,比如火车站、市中心、汽车站)"、"多层前进指针(对应不同线路的轨道)"。每个节点就像一个地铁站,有自己的编号和名称,还有不同线路的轨道连接其他站点。
◦ 层:就是地铁的"线路",比如1层是普通线、2层是快线、3层是直达线(Redis里层的数量是1-32之间的随机数,像不是所有站点都有快线和直达线,比如郊区的小站可能只有普通线)。
◦ 前进指针:就是"轨道",比如1层的前进指针连接相邻的站点(1→2→3→...),2层的前进指针连接间隔几个站的站点(1→5→10→...),3层的前进指针连接间隔更远的站点(1→10→20→...)。轨道的作用是让地铁能从一个站到另一个站,前进指针的作用是让跳跃表能从一个节点找到下一个节点。
◦ 跨度:就是"两个站点之间的站数",比如1层1→2的跨度是1,2层1→5的跨度是4,3层1→10的跨度是9------跨度用来算"站点的排名",比如从1站出发,走3层到10站,跨度累计9,说明10站是第10名(1+9=10),就像你从火车站坐直达线到汽车站,中间隔了9站,所以汽车站是第10个站。
• 再看查找过程(找"10"站):
-
从"起点站"(表头)的最高层(3层)开始,沿着3层的轨道走,发现下一站是10站(正好是目标),直接到10站,就像坐直达线一步到位。
-
如果找的是"8"站:从3层出发,下一站是10站(比8大,不能往前走),就降到2层;2层的下一站是5站(比8小,可以往前走),走到5站;再从5站的2层出发,下一站是10站(比8大,不能往前走),降到1层;1层从5站走到6→7→8站,找到目标。整个过程只走了5步(3层1步→2层1步→1层3步),比普通链表的8步快多了,时间复杂度降到了O(logn),就像坐快线转普通线,比全程坐普通线快很多。
• 节点的"层数"怎么定?Redis用"幂次定律"随机生成------比如50%的节点是1层,25%的节点是2层,12.5%的节点是3层,以此类推,最高32层。就像地铁里,一半的站点只有普通线,四分之一的站点有快线,八分之一的站点有直达线,这样既能保证查找速度,又不会浪费太多"轨道"(内存),避免出现"很多轨道但没几趟车"的浪费情况。
- Redis为什么不用红黑树?------"立交桥"和"分层地铁"的选择
红黑树也是一种有序数据结构,查找、插入、删除的时间复杂度也是O(logn),和跳跃表差不多,但Redis最终选了跳跃表,核心原因有两个:
• 第一:高并发下的性能更稳定。红黑树像"复杂的立交桥",插入或删除节点时,可能需要"调整整个桥的车道"(比如左旋、右旋、变色,维护红黑树的平衡),这个过程会涉及多个节点,在高并发场景下,容易出现"服务卡顿",就像立交桥维修时要封多条车道,导致堵车。而跳跃表像"分层地铁",插入或删除一个站点(节点)时,只需要修改该站点周围的"轨道"(前进指针),比如在5站和10站之间加一个8站,只需要改5站2层的指针指向8站,8站2层的指针指向10站,不会影响其他站点,性能更稳定,就像地铁加一站只需要接两段轨道,不影响其他线路。
• 第二:实现难度低,维护成本小。红黑树的逻辑非常复杂,比如要判断节点的颜色、父节点的颜色、叔叔节点的颜色,还要处理各种边界情况(比如根节点、叶子节点),代码量很大,容易出bug,就像设计立交桥需要考虑很多复杂的交通流向,稍微错一点就会堵车。而跳跃表的逻辑很直观------就是"分层链表+随机层数",查找、插入、删除的逻辑都很简单,代码量少,后续维护起来也方便,就像设计地铁线路,只要确定站点和线路,后续调整也容易。Redis的开发者更倾向于"简单且高效"的实现,所以选了跳跃表。
关键提醒
跳跃表在Redis里主要用于两个场景:一是有序集合(zset)的底层实现(当zset的元素多或元素大时,用跳跃表+字典的组合,字典存"成员→分值"的映射,方便快速通过成员找分值;跳跃表存"分值→成员"的有序结构,方便排序和范围查找);二是集群模式下的"节点槽位映射"(用跳跃表管理节点的槽位范围,快速查找某个槽位属于哪个节点)。
五、压缩列表是什么?它的设计目的是什么?
核心考点
理解压缩列表"连续内存+紧凑编码"的结构特点,明确其"节约内存"的核心设计目的,以及适用场景(小数据量、小元素),这是Redis优化内存占用的重要手段。
通俗理解+例子
- 压缩列表:"一体成型的小零件收纳盒"
我们先想一个问题:如果要存10个小螺丝(对应Redis里的小整数或短字符串),用普通的链表(每个节点有prev、next指针)会怎么样?每个节点的指针就要占16字节(64位系统),10个节点就是160字节,而螺丝本身可能只占10字节------指针占的空间比数据还多,太浪费了,就像用很大的盒子装很小的螺丝,盒子占的地方比螺丝还大。
压缩列表就是为了解决"小数据浪费内存"的问题,它像"一体成型的小零件收纳盒":
• 普通链表像"用绳子串起来的10个小纸盒":每个纸盒装一个螺丝,纸盒之间用绳子(指针)连接,绳子占的地方比纸盒还大,还容易有缝隙(内存碎片),就像10个小纸盒散在桌子上,用绳子串起来后,绳子和缝隙占了很多空间。
• 压缩列表像"一个长方形的铁盒,里面分成10个小格子":铁盒是"连续的内存块",没有缝隙,每个小格子紧挨着,格子里装螺丝(元素),不用绳子连接------既省空间,又能快速找到每个格子(因为内存连续,能通过计算偏移量定位),就像收纳盒里的格子紧挨着,没有浪费空间,还能一眼找到要的螺丝。
- 压缩列表的组成:收纳盒的"标签和结构"
一个压缩列表就像一个标注清晰的收纳盒,从左到右分为5个部分,每个部分都有明确的作用,就像收纳盒上的各种标签和结构:
• zlbyttes:收纳盒的"总长度标签"------比如铁盒的总长度是50毫米,记录这个值是为了快速知道"整个收纳盒占多少空间",销毁压缩列表时能直接释放对应大小的内存,不用一点点算,就像收纳盒的包装上写着"尺寸50×10×5mm",一眼知道它占多大地方。
• zltail:收纳盒的"尾端偏移标签"------比如最后一个格子(尾节点)距离铁盒开口(压缩列表起始地址)的距离是45毫米,想找最后一个格子时,不用从第一个格子一个个往后数,直接用"起始地址+45毫米"就能定位到,时间复杂度O(1),就像收纳盒上标了"最后一格在距离开口45mm处",不用翻遍所有格子。
• zllen:收纳盒的"格子数量标签"------比如铁盒里有10个小格子(10个节点),直接记录这个数,不用数格子。但要注意:如果格子数量超过65535,这个标签会记为65535,实际数量需要遍历所有格子才能知道(不过压缩列表本来就用于小数据,很少会超过这个数),就像收纳盒标签写着"10格",不用自己数。
• entryX:收纳盒的"小格子"------每个格子存一个具体的元素,比如一个小螺丝(整数123)、一个小垫片(短字符串"abc")。每个格子的编码很紧凑,会根据元素的类型(整数/字符串)和大小,用最短的字节数存储,比如存整数123,只用1字节,而不是4字节,就像小格子会根据零件大小调整,不会浪费空间。
• zlend:收纳盒的"底部标记"------比如铁盒的底部贴了一张"end"的贴纸,告诉使用者"到这里就结束了,后面没有格子了",对应的二进制是"0xff"(255),就像收纳盒的底部有个"结束"标识,不会找过界。
- 设计目的:"能省一点是一点"------Redis的内存优化思路
Redis是内存数据库,内存成本很高,所以对"小数据"的存储特别"抠"------能少用1字节就少用1字节。压缩列表的设计目的就是"极致节约内存",主要适用于两种场景:
• 场景1:小数据量的集合类型。比如hash类型存用户的"收货地址"(字段是"addr1",值是"北京市朝阳区",短字符串),当字段数少(默认少于512个,可通过hash-max-ziplist-entries配置)、字段和值都小时(默认少于64字节,可通过hash-max-ziplist-value配置),底层用压缩列表,而不是字典(字典的数组和链表会浪费内存),就像用小收纳盒装少量小零件,不用大箱子。
• 场景2:小元素的有序集合。比如zset类型存"用户的签到天数排名"(成员是用户名,短字符串;分值是签到天数,小整数),当元素数少(默认少于128个,可通过zset-max-ziplist-entries配置)、成员和分值都小时(默认少于64字节,可通过zset-max-ziplist-value配置),底层用压缩列表,而不是跳跃表(跳跃表的多层指针会浪费内存)。
但压缩列表也有缺点:当元素变大或变多时,插入、删除元素会很麻烦------因为内存是连续的,插入一个元素需要"挪动后面所有元素的位置",像在收纳盒的中间加一个格子,要把后面的格子都往后推,时间复杂度O(n)。所以当数据量超过阈值时,Redis会自动把压缩列表转换成其他结构,比如hash转成字典,zset转成跳跃表+字典,就像小收纳盒装不下了,换成大箱子。
关键提醒
压缩列表是Redis"小数据优化"的典型结构,但在3.2+版本后,list类型已经不用压缩列表了(改用快速列表),但hash、zset、set(纯整数小数据场景)仍然会用压缩列表作为底层结构,直到数据量超过配置的阈值,这是Redis根据数据大小动态调整结构的体现。
六、快速列表(quicklist)是什么?它解决了什么问题?
核心考点
理解快速列表"链表+压缩列表"的混合结构,掌握它对早期list类型(链表+压缩列表)的优化点------兼顾"内存紧凑"和"操作高效",这是Redis优化list类型性能的关键改进。
通俗理解+例子
- 先回顾:早期Redis的list类型"痛点"
在Redis 3.2版本之前,list类型的底层实现是"二选一",就像有两种收纳方式,但各有缺点:
• 当元素少且小时(比如少于512个元素,每个元素少于64字节),用压缩列表(像"一体收纳盒")------优点是省内存,缺点是元素多了之后,插入删除慢(要挪动元素),就像收纳盒里的零件多了,中间加一个零件要把后面的都往后推,很麻烦。
• 当元素多或大时,用双向链表(像"绳子串多个纸盒")------优点是插入删除快(改指针就行),缺点是浪费内存:每个节点的prev和next指针各占8字节(64位系统),1000个节点就是16000字节,还会产生内存碎片(每个节点的内存是单独分配的,像散落在桌子上的纸盒,中间有空隙),就像用绳子串1000个纸盒,绳子和空隙占的地方比纸盒里的东西还多。
比如你用list存"用户的聊天记录":
• 刚开始只有10条记录(短字符串),用压缩列表,占100字节,很省空间,就像用小收纳盒装10条记录。
• 后来记录多到1000条,自动转成双向链表,光指针就占16000字节,比数据本身还大,太浪费,就像用绳子串1000个纸盒,绳子占了很多空间。
• 而且链表的节点内存是分散的,像1000个纸盒散在桌子上,操作系统管理这些内存时效率低(内存碎片多),就像桌子上东西乱,整理起来麻烦。
- 快速列表:"串起来的小收纳盒"------兼顾省空间和高效率
快速列表的设计思路很简单:"把压缩列表切成小块,用链表串起来"------就像把一个大收纳盒,分成10个小收纳盒,每个小收纳盒里装100条聊天记录,再用绳子把10个小收纳盒串起来。这样既保留了压缩列表的"省空间",又有链表的"高效率",完美结合了两者的优点。
具体来说,快速列表的结构有两个核心部分:
• 快速列表节点(quicklistNode):就是"小收纳盒",每个节点里存的是一个压缩列表(ziplist),比如每个压缩列表里装50条聊天记录(节点大小可配置,默认是8192字节)。每个快速列表节点有prev和next指针,用来和其他节点串成链表------但因为每个节点里装了50条记录,所以1000条记录只需要20个节点,指针只占20×16=320字节,比原来的16000字节节省了很多,就像10个小收纳盒串起来,绳子占的地方比1000个纸盒串起来少很多。
• 快速列表(quicklist):就是"串起来的小收纳盒",里面存的是快速列表节点的表头和表尾指针,以及节点总数、元素总数等信息------比如记录"总共有20个小收纳盒,1000条聊天记录",方便快速获取整体信息,就像记录收纳盒的总数和里面的零件数,不用一个个数。
- 解决了早期list的3个核心问题
快速列表通过"链表+压缩列表"的混合结构,完美解决了早期list的痛点,就像用"串起来的小收纳盒"解决了"大收纳盒难调整"和"散纸盒浪费空间"的问题:
• 问题1:内存浪费严重→解决。早期链表1000个节点占16000字节指针,快速列表20个节点只占320字节指针,还因为每个节点是压缩列表,内部元素紧凑存储,比链表的分散节点省更多内存------相当于把1000个散纸盒,装进20个小收纳盒再串起来,桌子上的空隙少了,内存碎片也少了,空间利用率大大提高。
• 问题2:元素多了之后插入删除慢→解决。早期压缩列表1000个元素,中间插入一条要挪动999个元素;快速列表里,每个小收纳盒只装50个元素,中间插入一条最多挪动49个元素,效率提升20倍。如果要在两个小收纳盒之间插入元素,直接加一个新的小收纳盒串进去,不用动其他盒子,和链表一样快,就像在两个小收纳盒之间加一个新的,不用动里面的零件。
• 问题3:内存管理效率低→解决。早期链表的节点内存分散,操作系统要管理1000个独立的内存块;快速列表只需要管理20个内存块(每个节点一个压缩列表),内存块数量减少50倍,操作系统的内存管理效率大大提升,就像桌子上只有20个收纳盒,比1000个纸盒好整理多了。
- 快速列表的"细节优化"------更智能的小收纳盒
为了更省内存,快速列表还做了两个细节优化,让"小收纳盒"更智能:
• 节点大小自适应:每个小收纳盒(quicklistNode)里的压缩列表,大小不是固定的------如果元素是短字符串(比如聊天记录里的"你好"),一个压缩列表能装100条;如果元素是长一点的字符串(比如"今天去吃了火锅,味道很好"),一个压缩列表可能只装20条,避免单个节点太大,影响插入删除效率,就像根据零件大小调整收纳盒格子的大小。
• 空节点自动删除:如果一个小收纳盒里的元素全被删光了(比如聊天记录全删了),快速列表会自动把这个空节点删掉,不会留着空盒子占内存------就像把空的收纳盒扔掉,只留装了东西的盒子,不浪费空间。
关键提醒
Redis 3.2版本后,快速列表完全替代了"链表+压缩列表"的组合,成为list类型的唯一底层实现。不管list里的元素多还是少、大还是小,都用快速列表存储------你在Redis里用LPUSH、LPOP、LRANGE等命令操作list时,底层都是在操作快速列表,这是Redis对list类型的重要优化,让list的内存占用和操作效率都有了很大提升。
七、1亿个key中,如何高效找出10万个固定前缀的key?
核心考点
明确keys指令的"阻塞风险",掌握scan指令的"无阻塞+渐进式遍历"特性,理解高并发场景下的正确选择逻辑------这是线上Redis运维的常见问题,也是面试高频考点。
通俗理解+例子
先假设一个场景:你的Redis里存了1亿个key,都是用户相关的,比如"user:10001""user:10002"......"order:20001""order:20002"......现在要找出所有以"user:"为前缀的key,大概有10万个,该怎么找?这时候选对方法很关键,选错了会影响线上服务。
- 错误选择:用keys指令------"闭馆找书",阻塞服务
keys指令的工作方式,像"图书馆闭馆找书",虽然能找到书,但会影响正常使用:
• 图书馆里有1亿本书(对应1亿个key),你要找所有书名带"user:"的书(对应10万个前缀key)。用keys指令,相当于让图书馆闭馆,所有读者不能进(Redis线程阻塞),工作人员全员上阵,从第一本书开始,一本本翻书名(遍历所有key),把带"user:"的书挑出来------这个过程要多久?如果1亿本书,每本翻1毫秒,就要10万秒(约28小时),期间图书馆完全不能用(线上服务瘫痪),读者只能等着,体验极差。
• keys指令的核心问题:一是"全量遍历",不管key有没有符合前缀,所有key都要过一遍,效率极低,就像找10本特定的书,却要翻遍整个图书馆;二是"阻塞线程",Redis是单线程模型,keys指令会占用线程直到执行完,期间其他命令(GET、SET、LPUSH等)全被卡住,线上业务直接不可用,就像图书馆闭馆期间,没人能借书还书。
所以,线上环境绝对不能用keys指令,哪怕是找100个key,只要总key数多,就会阻塞服务,造成严重影响。
- 正确选择:用scan指令------"营业中找书",不阻塞服务
scan指令的工作方式,像"图书馆正常营业时找书",既不影响读者,又能慢慢找完:
• 图书馆不闭馆,读者正常借书还书(Redis正常处理其他命令)。工作人员每次只找一个"区域"的书(比如每次遍历100个key),找完这个区域,把找到的带"user:"的书记下来,然后休息一下(返回结果给客户端);下次读者借书时,再找下一个区域的书------分多次找,直到把所有区域都找完,就像工作人员利用空闲时间找书,不影响读者。
• 具体来说,scan指令有3个核心特点,这些特点让它适合线上使用:
-
渐进式遍历:不一次性遍历所有key,而是每次遍历"一部分key"(数量可通过COUNT参数控制,默认10),分多次完成全量遍历。比如1亿个key,每次遍历1000个,分1000次遍历,每次耗时10毫秒,总耗时10秒,期间不影响读者(线上服务),就像分1000次找书,每次找1000本,不会占用太多时间。
-
无阻塞:scan指令是"非阻塞"的,每次遍历一部分key后,就把线程还给Redis,让Redis处理其他命令;下次再继续遍历,不会占用线程不放------相当于工作人员找完一个区域的书,就去帮读者借书,下次有空再找下一个区域,不会影响读者借书。
-
可能重复:因为scan是基于"游标(cursor)"遍历的(每次返回下一个游标的位置),在遍历过程中,如果有key被插入或删除,可能会导致某个key被重复遍历到------比如工作人员刚找完A区域的书,又有一本"user:30000"的书被放到A区域,下次遍历A区域时,会再找到这本书。不过没关系,客户端只需要把拿到的key做一次去重(比如用Set集合存key,自动去重),就能得到唯一的10万个key,就像找到重复的书,去掉一本就行,不影响结果。
-
scan指令的使用细节------"怎么找更高效"
用scan指令找前缀key时,还要注意两个细节,能让找书的效率更高:
• 配合MATCH参数:指定前缀模式,比如执行"scan 0 MATCH user:* COUNT 1000"------意思是"从游标0开始,找前缀是user:的key,每次遍历1000个key"。这样scan只会挑出带前缀的key,不用把所有key都返回给客户端,减少网络传输量,就像工作人员只找带"user:"的书,不用把所有书都记下来。
• 合理设置COUNT参数:COUNT参数不是"返回的key数量",而是"每次遍历的key数量"。如果设置COUNT=10,每次可能只返回1-2个带前缀的key,要分10万次才能找完;设置COUNT=1000,每次可能返回100个带前缀的key,分1000次就能找完------根据总key数和前缀key的比例,合理设置COUNT(比如1000-10000),能大幅提高效率,就像每次找1000本比每次找10本快很多。
- 两者对比:为什么scan是线上首选?
从遍历方式来看,keys指令是全量遍历,不管key是否符合前缀要求,都会逐个检查所有key;而scan指令是渐进式遍历,每次只处理一部分key,分多次完成整个遍历过程。从是否阻塞服务来看,keys指令会完全阻塞Redis线程,期间所有其他命令都无法执行,线上业务会瘫痪;scan指令则是非阻塞的,每次处理完一部分key就释放线程,Redis能正常处理其他业务命令,不影响线上服务。从效率来看,处理1亿个key中的10万个前缀key时,keys指令可能需要几十小时,效率极低;scan指令只需要十几秒,效率适中。从结果重复问题来看,keys指令不会有重复的key,因为是一次性全量遍历;scan指令可能会有重复,但客户端简单去重就能解决。从线上可用性来看,keys指令完全不可用,会导致服务阻塞;scan指令完全可用,不影响业务,所以scan指令是线上环境的唯一正确选择。
关键提醒
除了scan,Redis还有针对特定类型的遍历指令,比如hscan(遍历hash的field)、sscan(遍历set的成员)、zscan(遍历zset的成员)------它们的原理和scan一样,都是渐进式无阻塞遍历,适合在高并发场景下使用。比如要遍历一个大hash的所有field,用hscan比hkeys(类似keys,全量遍历阻塞)更安全,不会影响Redis的正常服务。