Java Redis “底层结构” 面试清单(含超通俗生活案例与深度理解)

一、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语言字符串的"坑",我们用"学生记笔记"这个日常场景来类比:

  1. 获取长度:从"翻遍笔记本"到"看封面标签"

• C语言字符串像"没写总页数的笔记本":比如你记了一学期的课堂笔记,想知道总共写了多少页,只能从第一页开始翻,一页页数到最后一页(遍历整个字符串,时间复杂度O(n))。如果笔记有1000页,你得翻1000次才能知道页数,不仅费时间,还容易数错。

• SDS像"写了总页数的笔记本":笔记本封面直接贴了标签"共328页",不管你想知道页数还是想翻到最后一页,看一眼标签就够了(直接读取len属性,时间复杂度O(1))。哪怕笔记有10000页,也不用翻一页纸,效率直接拉满,还不会出错。

  1. 缓冲区溢出:从"水杯漏水"到"自动扩容保温杯"

• C语言字符串像"没标最大容量的敞口水杯":你不知道这个杯子能装多少水,只知道现在装了半杯。如果朋友帮你倒开水,没注意容量,倒多了就会洒出来(缓冲区溢出)------比如你定义了一个能存5个字符的C字符串"abcde"(实际占6个字节,最后一个是结束符\0),想把它改成"abcdefg",C语言不会检查容量是否足够,直接往后面写,就会覆盖后面的内存数据,导致程序崩溃,就像水洒出来弄湿了桌面。

• SDS像"会自动变大的保温杯":杯子上标了"当前装300ml,最大能装500ml",当你倒开水到450ml时,杯子会自动膨胀,把最大容量改成1000ml(这就是SDS的空间预分配机制),永远不会洒出来。比如你要给SDS追加内容,SDS会先检查当前剩余空间(alloc - len)够不够,如果不够,先自动扩容到足够的大小,再追加内容,完全杜绝溢出问题,就像保温杯总能装下你倒的水。

  1. 内存分配次数:从"每次换衣服都买新的"到"一次多买两件+旧的先留着"

• C语言字符串改长度,像"每次穿衣服都要重新买一件":比如你有一件M码的衣服(对应C字符串长度5),长胖了要穿L码(长度8),得重新买一件L码的(内存重新分配);后来瘦了又要穿M码(长度5),又得重新买一件M码的(再次内存分配)------每次改长度都要"买新衣服",频繁跑服装店(操作系统内存管理),不仅麻烦,还浪费钱(效率低)。

• SDS用"空间预分配+惰性空间释放",像"聪明的购物方式":

◦ 空间预分配:你买衣服时,本来穿M码,直接买L码和XL码两件(分配比实际需要更多的空间),下次长胖了直接穿L码,不用再买(下次追加内容时,若剩余空间够,不用重新分配);

◦ 惰性空间释放:你瘦了穿回M码,L码的衣服先不扔(SDS缩短时,不立即释放多出来的空间),下次再长胖还能穿(下次追加内容时,直接用预留的空间)。

这样一来,你不用频繁跑服装店,内存分配次数大大减少,效率自然更高。

  1. 二进制安全:从"只能装水的杯子"到"万能收纳箱"

• 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保证高可用性的重要设计之一。

通俗理解+例子

  1. 字典的底层实现:小区的"智能快递柜"

我们可以把Redis的字典类比成小区门口的"智能快递柜",这个场景很贴近生活,容易理解:

• 数组就是快递柜的"格子":每个格子有一个编号(对应哈希表的索引),比如1号格、2号格、3号格......每个格子最多放一个快递盒(对应哈希表节点),快递盒里装的是"快递单(key)+ 包裹(value)"------key就是用户要查找的"取件码对应的标识",value就是要存储的数据。

• 链表就是"格子里叠放的快递":当两个快递的"取件码哈希后"都对应同一个格子(这就是哈希冲突),比如快递A的取件码哈希后是3,快递B的取件码哈希后也是3,这时候不能把两个快递挤在一个格子里,就把它们叠成一摞(形成链表),放在3号格里。取快递时,先找到对应的格子,再顺着叠放的顺序找自己的快递(遍历链表),就像你去快递柜取件,先找到格子,再从叠放的快递里找自己的那一个。

• 哈希计算的过程:就像快递员计算"取件码对应哪个格子"------比如快递柜有16个格子,取件码是"20240520",用取件码除以16取余数(这就是简单的哈希算法),得到余数3,就把快递放在3号格。Redis里就是用key的哈希值,对哈希表的大小取模,得到对应的数组索引,确定key要存在哪个"格子"里。

  1. 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。

  1. 渐进式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选择跳跃表的两个关键原因------性能适配高并发、实现难度低,这也是面试中常被追问的点。

通俗理解+例子

  1. 跳跃表的实现:城市的"分层地铁线路"

我们先想一个问题:如果有一个有序链表,里面存了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"站):

  1. 从"起点站"(表头)的最高层(3层)开始,沿着3层的轨道走,发现下一站是10站(正好是目标),直接到10站,就像坐直达线一步到位。

  2. 如果找的是"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层。就像地铁里,一半的站点只有普通线,四分之一的站点有快线,八分之一的站点有直达线,这样既能保证查找速度,又不会浪费太多"轨道"(内存),避免出现"很多轨道但没几趟车"的浪费情况。

  1. Redis为什么不用红黑树?------"立交桥"和"分层地铁"的选择

红黑树也是一种有序数据结构,查找、插入、删除的时间复杂度也是O(logn),和跳跃表差不多,但Redis最终选了跳跃表,核心原因有两个:

• 第一:高并发下的性能更稳定。红黑树像"复杂的立交桥",插入或删除节点时,可能需要"调整整个桥的车道"(比如左旋、右旋、变色,维护红黑树的平衡),这个过程会涉及多个节点,在高并发场景下,容易出现"服务卡顿",就像立交桥维修时要封多条车道,导致堵车。而跳跃表像"分层地铁",插入或删除一个站点(节点)时,只需要修改该站点周围的"轨道"(前进指针),比如在5站和10站之间加一个8站,只需要改5站2层的指针指向8站,8站2层的指针指向10站,不会影响其他站点,性能更稳定,就像地铁加一站只需要接两段轨道,不影响其他线路。

• 第二:实现难度低,维护成本小。红黑树的逻辑非常复杂,比如要判断节点的颜色、父节点的颜色、叔叔节点的颜色,还要处理各种边界情况(比如根节点、叶子节点),代码量很大,容易出bug,就像设计立交桥需要考虑很多复杂的交通流向,稍微错一点就会堵车。而跳跃表的逻辑很直观------就是"分层链表+随机层数",查找、插入、删除的逻辑都很简单,代码量少,后续维护起来也方便,就像设计地铁线路,只要确定站点和线路,后续调整也容易。Redis的开发者更倾向于"简单且高效"的实现,所以选了跳跃表。

关键提醒

跳跃表在Redis里主要用于两个场景:一是有序集合(zset)的底层实现(当zset的元素多或元素大时,用跳跃表+字典的组合,字典存"成员→分值"的映射,方便快速通过成员找分值;跳跃表存"分值→成员"的有序结构,方便排序和范围查找);二是集群模式下的"节点槽位映射"(用跳跃表管理节点的槽位范围,快速查找某个槽位属于哪个节点)。

五、压缩列表是什么?它的设计目的是什么?

核心考点

理解压缩列表"连续内存+紧凑编码"的结构特点,明确其"节约内存"的核心设计目的,以及适用场景(小数据量、小元素),这是Redis优化内存占用的重要手段。

通俗理解+例子

  1. 压缩列表:"一体成型的小零件收纳盒"

我们先想一个问题:如果要存10个小螺丝(对应Redis里的小整数或短字符串),用普通的链表(每个节点有prev、next指针)会怎么样?每个节点的指针就要占16字节(64位系统),10个节点就是160字节,而螺丝本身可能只占10字节------指针占的空间比数据还多,太浪费了,就像用很大的盒子装很小的螺丝,盒子占的地方比螺丝还大。

压缩列表就是为了解决"小数据浪费内存"的问题,它像"一体成型的小零件收纳盒":

• 普通链表像"用绳子串起来的10个小纸盒":每个纸盒装一个螺丝,纸盒之间用绳子(指针)连接,绳子占的地方比纸盒还大,还容易有缝隙(内存碎片),就像10个小纸盒散在桌子上,用绳子串起来后,绳子和缝隙占了很多空间。

• 压缩列表像"一个长方形的铁盒,里面分成10个小格子":铁盒是"连续的内存块",没有缝隙,每个小格子紧挨着,格子里装螺丝(元素),不用绳子连接------既省空间,又能快速找到每个格子(因为内存连续,能通过计算偏移量定位),就像收纳盒里的格子紧挨着,没有浪费空间,还能一眼找到要的螺丝。

  1. 压缩列表的组成:收纳盒的"标签和结构"

一个压缩列表就像一个标注清晰的收纳盒,从左到右分为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),就像收纳盒的底部有个"结束"标识,不会找过界。

  1. 设计目的:"能省一点是一点"------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类型性能的关键改进。

通俗理解+例子

  1. 先回顾:早期Redis的list类型"痛点"

在Redis 3.2版本之前,list类型的底层实现是"二选一",就像有两种收纳方式,但各有缺点:

• 当元素少且小时(比如少于512个元素,每个元素少于64字节),用压缩列表(像"一体收纳盒")------优点是省内存,缺点是元素多了之后,插入删除慢(要挪动元素),就像收纳盒里的零件多了,中间加一个零件要把后面的都往后推,很麻烦。

• 当元素多或大时,用双向链表(像"绳子串多个纸盒")------优点是插入删除快(改指针就行),缺点是浪费内存:每个节点的prev和next指针各占8字节(64位系统),1000个节点就是16000字节,还会产生内存碎片(每个节点的内存是单独分配的,像散落在桌子上的纸盒,中间有空隙),就像用绳子串1000个纸盒,绳子和空隙占的地方比纸盒里的东西还多。

比如你用list存"用户的聊天记录":

• 刚开始只有10条记录(短字符串),用压缩列表,占100字节,很省空间,就像用小收纳盒装10条记录。

• 后来记录多到1000条,自动转成双向链表,光指针就占16000字节,比数据本身还大,太浪费,就像用绳子串1000个纸盒,绳子占了很多空间。

• 而且链表的节点内存是分散的,像1000个纸盒散在桌子上,操作系统管理这些内存时效率低(内存碎片多),就像桌子上东西乱,整理起来麻烦。

  1. 快速列表:"串起来的小收纳盒"------兼顾省空间和高效率

快速列表的设计思路很简单:"把压缩列表切成小块,用链表串起来"------就像把一个大收纳盒,分成10个小收纳盒,每个小收纳盒里装100条聊天记录,再用绳子把10个小收纳盒串起来。这样既保留了压缩列表的"省空间",又有链表的"高效率",完美结合了两者的优点。

具体来说,快速列表的结构有两个核心部分:

• 快速列表节点(quicklistNode):就是"小收纳盒",每个节点里存的是一个压缩列表(ziplist),比如每个压缩列表里装50条聊天记录(节点大小可配置,默认是8192字节)。每个快速列表节点有prev和next指针,用来和其他节点串成链表------但因为每个节点里装了50条记录,所以1000条记录只需要20个节点,指针只占20×16=320字节,比原来的16000字节节省了很多,就像10个小收纳盒串起来,绳子占的地方比1000个纸盒串起来少很多。

• 快速列表(quicklist):就是"串起来的小收纳盒",里面存的是快速列表节点的表头和表尾指针,以及节点总数、元素总数等信息------比如记录"总共有20个小收纳盒,1000条聊天记录",方便快速获取整体信息,就像记录收纳盒的总数和里面的零件数,不用一个个数。

  1. 解决了早期list的3个核心问题

快速列表通过"链表+压缩列表"的混合结构,完美解决了早期list的痛点,就像用"串起来的小收纳盒"解决了"大收纳盒难调整"和"散纸盒浪费空间"的问题:

• 问题1:内存浪费严重→解决。早期链表1000个节点占16000字节指针,快速列表20个节点只占320字节指针,还因为每个节点是压缩列表,内部元素紧凑存储,比链表的分散节点省更多内存------相当于把1000个散纸盒,装进20个小收纳盒再串起来,桌子上的空隙少了,内存碎片也少了,空间利用率大大提高。

• 问题2:元素多了之后插入删除慢→解决。早期压缩列表1000个元素,中间插入一条要挪动999个元素;快速列表里,每个小收纳盒只装50个元素,中间插入一条最多挪动49个元素,效率提升20倍。如果要在两个小收纳盒之间插入元素,直接加一个新的小收纳盒串进去,不用动其他盒子,和链表一样快,就像在两个小收纳盒之间加一个新的,不用动里面的零件。

• 问题3:内存管理效率低→解决。早期链表的节点内存分散,操作系统要管理1000个独立的内存块;快速列表只需要管理20个内存块(每个节点一个压缩列表),内存块数量减少50倍,操作系统的内存管理效率大大提升,就像桌子上只有20个收纳盒,比1000个纸盒好整理多了。

  1. 快速列表的"细节优化"------更智能的小收纳盒

为了更省内存,快速列表还做了两个细节优化,让"小收纳盒"更智能:

• 节点大小自适应:每个小收纳盒(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万个,该怎么找?这时候选对方法很关键,选错了会影响线上服务。

  1. 错误选择:用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数多,就会阻塞服务,造成严重影响。

  1. 正确选择:用scan指令------"营业中找书",不阻塞服务

scan指令的工作方式,像"图书馆正常营业时找书",既不影响读者,又能慢慢找完:

• 图书馆不闭馆,读者正常借书还书(Redis正常处理其他命令)。工作人员每次只找一个"区域"的书(比如每次遍历100个key),找完这个区域,把找到的带"user:"的书记下来,然后休息一下(返回结果给客户端);下次读者借书时,再找下一个区域的书------分多次找,直到把所有区域都找完,就像工作人员利用空闲时间找书,不影响读者。

• 具体来说,scan指令有3个核心特点,这些特点让它适合线上使用:

  1. 渐进式遍历:不一次性遍历所有key,而是每次遍历"一部分key"(数量可通过COUNT参数控制,默认10),分多次完成全量遍历。比如1亿个key,每次遍历1000个,分1000次遍历,每次耗时10毫秒,总耗时10秒,期间不影响读者(线上服务),就像分1000次找书,每次找1000本,不会占用太多时间。

  2. 无阻塞:scan指令是"非阻塞"的,每次遍历一部分key后,就把线程还给Redis,让Redis处理其他命令;下次再继续遍历,不会占用线程不放------相当于工作人员找完一个区域的书,就去帮读者借书,下次有空再找下一个区域,不会影响读者借书。

  3. 可能重复:因为scan是基于"游标(cursor)"遍历的(每次返回下一个游标的位置),在遍历过程中,如果有key被插入或删除,可能会导致某个key被重复遍历到------比如工作人员刚找完A区域的书,又有一本"user:30000"的书被放到A区域,下次遍历A区域时,会再找到这本书。不过没关系,客户端只需要把拿到的key做一次去重(比如用Set集合存key,自动去重),就能得到唯一的10万个key,就像找到重复的书,去掉一本就行,不影响结果。

  4. 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本快很多。

  1. 两者对比:为什么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的正常服务。

相关推荐
绝无仅有4 小时前
面试真实经历某商银行大厂Java问题和答案总结(三)
后端·面试·github
绝无仅有4 小时前
面试真实经历某商银行大厂Java问题和答案总结(五)
后端·面试·github
Chris.Yuan7704 小时前
泛型学习——看透通配符?与PECS 法则
java·学习
岑梓铭5 小时前
考研408《计算机组成原理》复习笔记,第七章(1)——I/O接口
笔记·考研·408·计算机组成原理·计组
这周也會开心5 小时前
云服务器安装JDK、Tomcat、MySQL
java·服务器·tomcat
hrrrrb6 小时前
【Spring Security】Spring Security 概念
java·数据库·spring
小信丶6 小时前
Spring 中解决 “Could not autowire. There is more than one bean of type“ 错误
java·spring
周杰伦_Jay7 小时前
【Java虚拟机(JVM)全面解析】从原理到面试实战、JVM故障处理、类加载、内存区域、垃圾回收
java·jvm
摇滚侠8 小时前
Spring Boot 3零基础教程,IOC容器中组件的注册,笔记08
spring boot·笔记·后端