☰
Redis数据结构底层解码:从编码切换到性能优化实战
2026/10/3 2:58:42 网站建设 项目流程

做Redis开发这几年,我发现一个特别有意思的现象:很多人用Redis好几年,张口就是五种数据类型,面试也能把String、Hash、List、Set、ZSet背得滚瓜烂熟,但一问到底层怎么存、为什么这样存、多大算大Key,立刻就含糊了。这其实不怪大家,因为大多数人接触Redis都是从业务开始的,set一个值、get一个值,根本不需要关心底层。可一旦你开始做缓存治理、做性能优化、排查线上事故,脑子里没有一张“数据结构地图”,遇到问题就只能在网上翻碎片化文章,越翻越乱。

这篇文章我想换个角度,不写命令手册,也不列一堆官方文档里已有的配置项,而是把Redis五种数据结构的底层编码、设计意图、选型逻辑和实战中常见的坑串成一条线。你会发现,Redis的设计其实非常朴素——它不追求“一种结构打天下”,而是用外部API统一入口,内部根据数据规模和数据特征动态切换编码。理解了这个思路,你再看它的配置参数、内存优化手段、甚至面试题,都会通透很多。

1. Redis为什么需要五种数据结构

我们先回到一个简单的问题:为什么Redis不学Memcached,只提供一种“key-value大抽屉”?因为内存型数据库的核心优势是“快”,但如果只能存和取,很多业务需求就得绕道到客户端去计算,比如排行榜要排序、关注列表要求交集、延迟任务要阻塞读取,这些高频操作全靠客户端算一遍,性能就废掉了。Redis把数据组织成不同的结构,本质上是把一部分计算能力下沉到服务端内存里,让命令自带语义。

这个设计的关键词是“外部API与内部编码分离”。作为使用者,你只跟String、Hash这些抽象类型打交道;作为存储引擎,Redis在底层可以用完全不同的方式实现同一个类型。最典型的就是Hash:用户量少的时候,它把字段压缩成一个紧凑的字节数组,省内存;用户量大了,它切换成真正的哈希表,换时间。外部接口不变,内部悄悄换引擎,这就像你开同一款车型,市区低速时用经济模式,上了高速切成运动模式,方向盘和踏板不用换。

为什么要这样设计?答案只有一个字:内存。Redis的数据都在内存里,内存是又贵又有限的资源。同样的一个字段,用紧凑编码存可能只要几千字节,用普通哈希表存可能要翻倍。所以Redis在“性能”和“内存”之间做了非常精细的权衡:数据小就用更节省内存但操作复杂度稍高的编码,数据大就切换成操作效率高但开销也大的编码。这种思想贯穿了几乎所有类型的设计,也是Redis被称作“数据结构服务器”的真正原因。

理解了这层设计意图,很多问题就迎刃而解了。比如面试必问的“ZSet为什么用跳表而不用红黑树”,本质上是权衡了实现复杂度、范围查询效率和内存占用。再比如为什么Redis官方文档反复强调“小Hash优先用listpack”,因为大多数业务场景里,一个Hash也就几十个字段,没必要让每个小Hash都维护一张大的哈希表。这些取舍,都是围绕着“内存效率+时间复杂度”这两个指标在转。

2. 五种核心数据结构底层编码拆解

2.1 String:不只是字符串

String是Redis里最基础的砖块,但它的底层形态分成三种:int、embstr、raw。这不是闲得无聊,而是Redis对“存储空间”的极致抠门。

当你要存的value是一个整数,比如set login_count 1000,Redis不会真的存一个ASCII字符串“1000”,而是直接存一个8字节的long型整数,这就是int编码。你incr它的时候,CPU在整数上自增,速度极快,不会涉及字符串解析。这在计数器场景里是最优解。

当字符串比较短(Redis 3.2之后阈值是44字节),它会用embstr编码。embstr的全称是“embedded string”,意思是Redis把对象头和字符串的内存块一次性分配出来,紧挨在一起,这样读写时只需要一次内存分配、一次释放,缓存命中率也更高。

一旦字符串超过44字节,或者字符串被修改过导致无法用嵌入式方式存储,就会退化为raw编码。raw需要两次内存分配,一次给对象头,一次给字符串本身,但能容纳任意长度。很多人在意那个44字节的阈值,其实没必要背死,你只需要知道:短字符串用embstr,长字符串用raw,纯数字用int。内存优化时,把value控制在44字节以内,可以减少内存分配的次数,但这个优化只有在千万级小value场景下才明显。

String 还有一个容易踩的坑:`append` 或者对已存在的字符串做修改,会导致编码从 `embstr` 变成 `raw`,这是正常现象,不需要担心。真正要担心的是把一个几MB的对象直接塞进 String,这时候你不仅占了整个JVM堆外的内存,还会让 `get` 命令的阻塞时间变长。后面会专门讲大Key问题。

2.2 Hash:从小紧凑到大哈希的蜕变

Hash可能是日常开发里最实用的类型了,因为它天然适合存储“对象”。一个用户信息、一个商品详情、一个埋点事件的字段集合,都可以放在一个Hash里,不用像String那样靠JSON序列化整个对象,想改字段时还得整存整取。

Hash的底层编码经历了两个阶段。Redis 7.0之前,小Hash用ziplist,大Hash用hashtable;Redis 7.0开始,小Hash用listpack替代了ziplist。这里要解释一下为什么要替换。ziplist是个非常紧凑的双向链表,所有字段和值紧挨着排布,省内存但更新效率一般,而且在极端场景下有“级联更新”的风险。listpack也是紧凑结构,但它的设计重新考虑了节点长度记录方式,消除了级联更新的问题,更适合作为小数据量的通用编码。

触发切换的条件由配置项控制:hash-max-listpack-entries(默认128)和hash-max-listpack-value(默认64)。也就是说,当Hash里的字段数不超过128,而且每个字段名和值的长度不超过64字节时,就用紧凑编码;一旦超过任意一个阈值,整个Hash会升级成真正的哈希表。

这个阈值在5.x版本里叫hash-max-ziplist-entries、hash-max-ziplist-value,默认值其实是512和64。升级是永久的,不会降级回listpack。所以如果你预计一个Hash可能会从100字段涨到几千字段,不妨一开始就预估好,因为升成hashtable之后,内存开销会明显增加。

我实际测试过,100万个小Hash(每个Hash 10个字段、字段值大概20字节),用listpack和hashtable的内存差距能到30%左右。如果你们公司Redis内存紧张,这种细节是值得优化的。把 `hash-max-listpack-entries` 从128改成256,在字段都是小短值的场景下能省出不少内存,但代价是单个Hash字段多时读写性能会有所下降,需要结合业务权衡。

2.3 List:双向链表与紧凑块的组合

List的底层是quicklist,你可以把它理解成一个“由紧凑块组成的双向链表”。为什么不用单一的双向链表或单一的数组?因为双向链表每个节点都要维护前后指针,内存开销大;数组在中间插入删除时要移动元素,性能差。Redis取了个折中:分成很多小块,每块内部再用紧凑结构存一串元素,块之间用双向链表串起来。这样插入删除时只需要操作小块内部,如果小块满了就新建节点,内存不至于太散。

在Redis 7.0之后,quicklist里的小块从ziplist换成了listpack。外部用lpush、rpush、lpop、rpop操作,底层就是往链表的头部或尾部快速插拔元素。因为操作端在两端,quicklist的时间复杂度是O(1),但如果你想用lindex按照下标随机访问元素,复杂度就是O(N),因为它得从链表头或者尾一步步走。N是List里的总元素数。

List还有一个容易被忽视的用法:阻塞队列。blpop、brpop命令可以指定超时时间,队列空的时候客户端会阻塞等待,这是实现简单消息队列的原生方案。Redis官方其实不建议拿List当专业消息队列用,因为消息不支持ACK、不支持多消费者组,但对于轻量级延迟任务、通知推送这类业务,List完全够用,而且不引入额外中间件。如果只是用来做“最新消息列表”,用lpush+lrange 0 9取前10条,配合按时间倒序的插入方式,成本极低。

2.4 Set:整数集合与哈希表的自动切换

Set的特点是无序去重,适合做标签、好友关系、点赞记录等集合运算。它的底层编码有两种:intset和hashtable。

intset是Redis专门为“全是整数且数量不大”的集合设计的紧凑结构。所有元素在内存里排成一个有序的整数数组,查找时可以用二分查找,虽然插入涉及移动元素,但数量少的时候根本感觉不到。这个状态的触发条件是:所有元素都是整数,而且元素数量不超过set-max-intset-entries(默认512)。

一旦某个元素不是整数(比如插入一个字符串"abc"),或者元素数量超过512个,intset就会升级成hashtable。这是Redis里典型的“数据规模驱动编码切换”。除了内存优势,Set的另一个价值是提供丰富的集合运算:sinter(交集)、sunion(并集)、sdiff(差集)。比如你要算“同时关注了A和B的用户”,直接sinter两个集合就行。注意这些运算在主线程执行,如果两个集合都很大,比如几十万元素,sinter可能耗时较长,阻塞Redis,这种情况最好抽出一份小集合来反复运算,或者把结果缓存。

Set的典型业务是抽奖、加购去重、签到去重。签到场景如果一年下来要记录365天,用Set存`user:2024:sign`,每天一个日期字符串,查询某天是否签到时`sismember`,复杂度O(1),比扫全量记录快得多。但如果只是判断“是否已参与”这种单一标记,用String更省,别滥用Set。

2.5 ZSet:跳表与哈希表的黄金组合

ZSet在业务里最常见的就是排行榜,但它底层结构其实是“跳表+哈希表”的组合。跳表(skip list)负责按分数有序排列,哈希表负责按成员快速查找。这套设计很有意思。

先从跳表本身说起。Redis用跳表而不是红黑树或B+树,最重要的原因是:跳表实现简单、容易调试、范围查询非常自然。跳表是在一层层有序链表上“跳跃”查找,平均时间复杂度是O(log N)。ZSet需要频繁支持的是zrangebyscore这种范围访问——从分数低到分数高取出一个区间,跳表按顺序遍历的能力比哈希表强太多。红黑树虽然也能做到有序,但实现复杂得多,而且在内存数据库这种“操作频繁但数据量通常不超过百万级”的场景里,跳表的常数优势并不明显。

那哈希表是干什么用的?zscore key member要在O(1)时间内返回某个成员的分数。但跳表查找成员是O(log N),如果每次都走跳表,性能有点浪费,所以ZSet另外维护了一个dict,key是成员,value是分数,用来专门支撑这种“按成员查分数”的操作。每次写操作同时更新跳表和dict,两个结构协同工作。

ZSet的小体积编码也有类似Hash的机制:zset-max-listpack-entries(默认128)和zset-max-listpack-value(默认64)。当成员数不超过128且每个成员和分数的字节长度都不超过64时,用listpack存储,内存极省;超过阈值就转成跳表+哈希表。这里有个容易被忽略的点:ZSet的成员不能重复,但分数可以重复。当多个成员分数一样时,跳表会按照成员字典序排列,所以“相同分数按名字排序”是ZSet自带的行为,无需额外处理。

3. 从业务场景反推数据结构选型

3.1 缓存治理与序列化问题

大部分公司用Redis最主要的场景就是缓存。缓存数据之前,你得想清楚两件事:一是value怎么序列化,二是这个数据用哪种类型承载。

先说序列化。很多Spring项目用RedisTemplate操作Redis,默认的序列化器是JdkSerializationRedisSerializer,它会把Java对象序列化成二进制流,还会带上一大串类的描述信息,value里会有类似aced 0005这样的二进制头,不光可读性极差,还白白浪费内存。如果你打开Redis Desktop Manager,看到一堆乱码,十有八九就是没改序列化方案。

我个人的建议是:key统一用StringRedisSerializer,value用GenericJackson2JsonRedisSerializer或Jackson2JsonRedisSerializer,可读性和内存占用都比JDK序列化好很多。更极致一点的方案是直接把整个对象手动转成JSON字符串存在String里,这样最透明,也最容易排查问题,但缺点是你无法单独读写某个字段,只能整存整取。所以,如果对象经常被高频地读改写某些字段,用Hash;如果只是整体写入、整体读取,直接用String存JSON就完了。

缓存治理里还有一个关键点:要不要缓存空值,要不要给缓存加过期时间,数据更新时先更新库还是先删缓存。这些跟数据结构关系不大,但跟Redis的整体稳定性关系极大。比如一个User对象缓存1小时,如果数据库里改了昵称,你得在更新数据库后主动删掉Redis里的Key,否则用户看到的还是旧数据。如果删除失败,你还要考虑重试或者引入延迟双删策略。这些问题在实际项目中比单纯研究编码更紧急。

3.2 分布式锁为什么离不开String

Redis做分布式锁,底层依赖的就是String的set key value NX PX命令。NX表示只有当Key不存在时才设置成功,PX表示过期时间,再加上一个唯一的客户端标识作为value,就能实现一个比较基础的分布式锁。

这里的核心点是:value一定要是全局唯一的标识,不能写死一个固定字符串。释放锁时,不能简单del key,而应该先用Lua脚本比较一下value是不是自己持有的,是才删除,避免误删别人持有的锁。这个Lua脚本是原子执行的,Redis在脚本执行期间不会执行其他命令,所以不存在“检查后删锁之间被插入其他命令”的窗口期。

另外一个容易被忽略的点:锁的过期时间设多长。如果业务执行时间超过了锁的过期时间,锁会自动释放,其他线程就能拿到锁并发执行,导致临界区被破坏。这就要用到Redisson的看门狗机制,它会在锁到期前自动续期。如果你的项目没有引入Redisson,那你得自己实现一个“续期”逻辑,或者干脆把过期时间设得足够长,但这又会带来“业务宕机后锁久久不释放”的隐患。所以说,分布式锁七分靠设计,三分靠数据结构。没有String的NX PX原子操作,整套方案根基就不稳。

3.3 排行榜不能只用List

排行榜这类业务,几乎就是为ZSet量身定做的。比如一个直播间热度榜,Key设成rank:live:{liveId},成员是userId,分数可以是点赞数、送礼总额或者综合权重分。用户每产生一次互动,就zincrby给对应成员加分,查询排行榜前十名用zrevrange,查某人在榜上的名次用zrank,整个流程没有一个命令是多余的高开销操作。

有人会拿List去存“排行榜”,每产生一个变动就重新排好序再覆盖整个List。这种方案在数据量小的时候可行,但数据一上量,每次“全量重排+覆盖”都是一个O(N)级别的写操作,Race条件也多。与其自己排序,不如把排序交给了底层的跳表,这也是“数据结构服务器”真正的价值所在。

利用ZSet还可以做“时间线”,比如利用时间戳作为分数,成员存内容ID,就能用zrangebyscore按时间区间拉取文章列表、活动列表,天然带上时间排序。再比如“延迟队列”,成员存任务ID,分数存执行时间戳,消费方定时zrangebyscore取第一个到期任务,拿出来执行成功后zrem。这个用法简单可靠,比用List盲等更可控。

3.4 延迟任务和阻塞弹出的场景差异

延迟任务有两种主流做法:一种用ZSet,一种用List。它们适用的场景完全不同。

ZSet方案适合“任务到期时间已知”的场景,比如订单30分钟未支付自动关闭。下单时把订单ID存到delay:order:close,分数设为30分钟后的时间戳,后台配合一个定时任务,每隔几秒钟扫描一下当前时间点之前的所有任务,逐个处理并删除。这里的关键是ZSet天然的按分数排序,扫描区间非常高效。

List方案适合“消息到达就能被消费”的场景,比如通知推送、日志采集。生产方rpush,消费方blpop,可以多个客户端同时阻塞弹出。但这里有一个非常重要的坑:blpop在同一时刻只能被一个客户端抢到,抢到之后消息就没了。如果消费者处理消息失败,消息就真的丢了。这就是为什么很多架构里会在业务表中加一个“处理状态”字段,先消费List里的消息,再更新数据库状态,保证至少能人工补偿。

3.5 集合运算与权限标签

Set最大的独门优势就是集合运算:交集、并集、差集。经典的例子是“共同关注”,sinter得到两个用户关注列表的交集;“该用户是否有权限访问某个接口”,sismember判断用户ID是否在某权限集合里,O(1)返回。权限集合的特点是只增不减,非常适合放在Redis里做加速层。

不过要提醒一下:如果集合非常大,比如一个百万级粉丝的账号,你要关心的是SINTERSTORE、SUNIONSTORE这种“把结果写回另一个Key”的命令。因为它们是在主线程执行的,如果两个大集合做交集,耗时可能达到几百毫秒甚至更高,从而阻塞其他请求。这时候可以把数据做分片,比如按粉丝ID哈希分成若干小集合,或者用Lua脚本去分步计算,避免阻塞主线程。

4. 复杂度意识与批量操作优化

日常开发中,你不是每次操作Redis都能直观看到它内部做了什么,但你可以用复杂度表格来预判命令的代价。几个最常被误用的高复杂度命令我列一下:linsert、lrem、lindex在List里是O(N);hkeys、hvals是O(N),N是Hash里的字段数;keys *和scan整个库的遍历更是灾难,一个keys *如果库里有几百万Key,完全可以直接把Redis拖死。

我见不少团队在复盘事故时发现,线上Redis CPU飙升到100%,原因就是代码里写了一个keys *,还有人用keys *做模糊搜索。正确做法是尽量用单独的索引Key,或者用scan分页遍历。这里的关键原则是:宁可多写点逻辑,也不要把O(N)这种命令放到请求路径里。

批量操作方面,mget、mset可以把多个命令打包成一次网络往返,降低延迟。但要注意mget虽然有O(N),但多是在Redis内部顺序取多个Key,性能基本取决于Key的数量和网络。还有一种更精细的优化是管道(Pipeline),把客户端一批命令一口气发过去,再一起接收结果。Pipeline不是事务,中间可能有其他客户端的命令混入,它只是减少了网络往返。做高吞吐写入时,Pipeline能带来几十倍的吞吐提升,这是最常用也最有效的优化手段。

对于Java端的RedisTemplate,常见的倒腾方式是:读操作多时,用RedisTemplate配合@Cacheable,但不要把整个List都塞进缓存;写操作多时,用pipeline批量写,示例代码如下:

List<Object> results = stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> { for (int i = 0; i < 10000; i++) { connection.stringCommands().set(("key:" + i).getBytes(StandardCharsets.UTF_8), "value".getBytes(StandardCharsets.UTF_8)); } return null; });

这段代码把一万次写操作压缩成一次网络交互,吞吐量远比循环set高。注意executePipelined里不能依赖返回值,因为结果是在所有命令发送后才统一收集的。

5. 常见问题与排查技巧实录

5.1 大Key与大Value

大Key是Redis各种事故的头号元凶。String里存了几MB的图片二进制、List里攒了几十万个元素、Hash里堆了成千上万个字段,这些都属于大Key。大Key的危害有两个:一是读写会阻塞主线程,尤其是使用get或hgetall时,序列化和网络传输的时间会成倍上涨;二是集群模式下大Key会导致数据倾斜,一个节点的内存压力远大于其他节点,分片的负载也不均衡。

如何发现大Key?官方提供了redis-cli --bigkeys,它会扫描整个实例,找出每种类型里最大的Key。这个命令是安全的,因为它用的是scan游标方式,不会阻塞太久,但如果是生产环境,仍然建议在低峰期跑。另一种方式是用redis-cli --memkeys统计内存占用,或者干脆自己在代码里周期性地抽样检查。

处理大Key的思路取决于场景。大String可以拆成多个分片Key;大List可以按时间或ID分段;大Hash可以按字段拆到多个Hash里。在删除大Key时也要小心:直接del一个几百万元素的Key,主线程会阻塞好几秒。Redis 4.0提供了unlink命令,它会把释放内存的操作变成异步执行,删除后立刻返回,后台线程慢慢清理,这是删除大Key的首选。

5.2 热Key与缓存击穿

热Key指的是某个Key在短时间内被大量并发请求打爆。比如一个爆款商品详情,缓存3分钟,但请求量每秒几万次,Redis单节点处理不过来;更糟的是缓存过期的一瞬间,大量请求同时去查数据库,这被称为缓存击穿。常见的缓解方案有:缓存永不过期但由后台任务刷新、请求级互斥锁只允许一个线程去重建缓存、本地缓存打底挡住一部分流量。

用ZSet还能做“热点Key漂移”,比如把一份缓存复制成多份,用随机后缀分散到不同Key,读的时候随机取一个,降低单Key压力。这种手段适合读多写少且允许短时数据不一致的场景。

5.3 连接超时与Command Timed Out

很多用Spring Boot + Lettuce的项目会遇到一种报错:Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException。这不是随便一个参数就能解决的,一定要按层次排查。

第一层是网络:客户端到Redis服务器的往返延迟是否正常,跨机房调Redis尤其容易出现超时,最好能同机房部署;第二层是Redis服务器是否被大Key或慢查询拖住了。第三层是Lettuce连接池:如果maxTotal设置得太小,高并发时所有连接都被占用,新请求只能在池外等待,直接超时。需要重点检查spring.redis.lettuce.pool.max-active和max-wait,前者要至少能扛住瞬时并发,后者决定了等待时间上限。另外,Lettuce默认写的超时时间是60秒,但如果业务上认为60秒太久,可以把timeout调成1到3秒,快速失败比长时间阻塞更好。

还有一个非常隐蔽的问题:Linux系统TCP连接数不足,或者Redis实例的tcp-backlog设置过小,在连接数暴涨时,新的TCP连接会被拒绝,客户端表现为超时或拒绝连接。这种情况下,单纯调超时参数是没用的,必须到服务器上看ss -s和redis.conf里socket相关配置。

5.4 Redis Desktop Manager 使用避坑

这两个热词里的Redis Desktop Manager和Another Redis Desktop Manager常出现在初学者提问里。这类工具适合开发环境查看,但我不建议在连生产环境时高频使用,尤其是别在GUI上点“删除”“清空”,一个手滑就是事故。更危险的是,GUI默认打开树形结构时,有的版本会触发keys *命令去加载全部Key,生产库如果有几百万Key,这一个动作就可能把实例拖崩。

用管理工具时要养成两个习惯:一是连接生产环境前确认是否勾选了“只读”模式;二是尽量用命令行窗口执行scan代替keys *,并且在GUI里禁用危险命令。真正做运维指标监控时,可以用redis-cli --stat实时查看,或者接入Prometheus这类监控系统,而不是频繁靠GUI登进去“看一眼”。

我个人在实际项目里还有个体会:很多人研究Redis数据结构,是从面试题开始的,但真正让这些知识产生价值的,是你把这些结构放到业务模型里“物尽其用”的那一刻。就好比你学会了一个线性表的API,这不值钱,值钱的是你知道在什么样的数据规模下,这个结构会退化、会阻塞、会浪费内存,从而提前做出设计规避。如果读完这篇文章,你下次再看到线上Redis内存偏高、命令超时、缓存抖动时,能先想到“是不是编码切换了”“是不是大Key在作怪”“是不是序列化方案太浪费”,那这篇文章就没白写。

最后再分享一个小技巧:新项目里搭建Redis缓存层,第一件事不是写set/get,而是先根据业务数据量估算Key的数量、Value的平均大小、过期时间,再决定用哪几种数据类型。预算完内存,再去设计序列化方案和命令的复杂度,这样的顺序能让后期少踩很多坑。Redis的内容远不止五种数据结构,但把这五种结构吃透,你已经能解决绝大多数日常问题了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询