Redis数据结构与底层编码:从类型选型到性能优化实战

发布时间:2026/10/8 20:22:06
Redis数据结构与底层编码:从类型选型到性能优化实战 1. 先搞清楚一件事Redis快不只是因为内存很多人刚接触Redis时会有一个误解Redis快是因为它把数据放在内存里不用走磁盘。这话对了一半。内存确实是基础但真正让Redis在数据结构操作上甩开其他内存存储几条街的是它精心设计的底层数据结构——或者说是它对外提供的那几种数据类型背后藏着一套不断进化的编码方式。想理解Redis不能停留在SET key value、GET key value这种命令背诵层面。你得先建立一张地图Redis对外只暴露了5种基本数据类型——String字符串、Hash哈希、List列表、Set集合、ZSet有序集合但从Redis 3.2之后还有个geo、hyperloglog、bitmap、stream这些扩展类型底层其实也都是基于基础类型实现的。也就是说你把5种基础类型吃透了Redis的80%就算拿下了。那为什么要有这么多种类型而不是一种通用结构因为不同的数据访问模式对内存占用、读写速度、操作粒度的要求完全不同。举个例子用户的购物车既要频繁改单个商品数量又要整体遍历微博的关注列表只需要判断我是否关注了他顺序无所谓直播间的实时热榜需要随时按分数排序。这三种需求如果用同一种结构去硬撑要么内存爆掉要么操作复杂度失控。这篇文章我打算从实际使用的角度把5种核心数据结构逐个拆开讲清楚每种结构的内部实现是什么样的、适合什么场景、有哪些坑是光看命令文档发现不了的。不是命令手册的复读是结合项目实操的复盘。后面还会聊到序列化、内存优化、过期策略这些直接影响线上稳定性的细节。如果你是准备面试或者正在用Redis做中间件但没系统性梳理过这篇应该能帮上忙。2. Redis的数据类型和底层编码是两个维度这是很多人学Redis最绕的一个弯必须单独拿出来讲清楚。你在Redis命令行里敲TYPE key返回的是string、hash、list这样的结果这是对外数据类型。但在Redis内部每一种数据类型其实对应了多种底层编码方式Redis会根据你存的数据规模、元素大小、操作特征在合适的时机自动切换编码。你感知不到切换过程但底层的存储布局和操作时间复杂度实实在在发生了变化。以String为例这是最基础也最容易被人忽略的类型。它内部其实有三种编码int如果你存的是一个整数且能放进8字节的long里Redis会直接以整数形式存储不再额外维护sds结构。embstr字符串长度小于等于44字节时Redis会把redisObject和sds分配在连续内存里一次内存分配搞定读写。raw长度超过44字节Redis才用常规方式拆成两次内存分配来存。这个44字节的临界点不是个随机数它是为了配合内存分配器的对齐策略和头结构大小设计出来的最优值。Hash类型则更典型。小数据量的时候用ziplist压缩列表元素变多或某个value过大就会升级为hashtable。List也一样少量数据用quicklist的节点里存压缩列表超过阈值则每个节点只存一个元素。Set在元素都是整数且数量不大时用intset否则退化为hashtable。ZSet在小数据量时用ziplist数据量上来就切换成skiplist hashtable的组合结构。为什么Redis要搞这么复杂全是为了一个目标在内存和CPU之间找平衡。压缩列表这类结构把多个元素紧紧挤在一起内存利用率高遍历时缓存命中率也高但插入和删除要搬移数据数据量大时性能就会下降。跳表查询是O(log n)但每个节点要维护多层指针内存开销大胜在数据量大了之后依然稳定。Redis的做法就是小数据用紧凑布局大数据用高效索引结构中间通过阈值自动切换。这里我补充一个实际案例。之前我用Hash存一批商品的属性数据误以为Redis会自动优化不需要关心数据规模结果某个店铺的商品数量到了几千每个商品的属性字段又多单个Hash的entry数量直接冲过阈值。原本ziplist下的哈希操作在小规模时是很优秀的升级成hashtable之后单个key的查询其实也还好但确实能通过DEBUG OBJECT key看出encoding字段变化了。这种升级是单向的不会降级回来。所以如果你预判某个key的数据量会长期超过阈值不如数据拆分分散到多个key反而更利于均衡分布。这个编码切换知识单纯看SET/GET命令文档是永远学不到的但它在面试里几乎是必考题在线上故障排查时也可能成为突破口。3. String与Hash对象存储该怎么选别凭感觉3.1 String的多种形态String在Redis里远远不只是存字符串。它可以是数字、JSON字符串、二进制数据甚至被拿来当计数器、限流器、分布式锁的载体。INCR、DECR、INCRBY这些原子自增操作是它最出名的特性——不是GET出来在代码里加1再SET回去而是Redis服务端直接帮你完成原子操作。先看一张String核心命令的速查表场景命令示例说明常规读写SET user:1:name tom最简单的键值带过期时间SET token:abc 12345 EX 3600一小时自动失效计数器INCR article:views:100原子1批量获取MGET user:1:name user:1:age减少网络往返分布式锁SET lock:order:1 uuid NX PX 3000不存在才设置3秒过期Bit操作SETBIT online:day1 10086 1位图记录用户上线状态需要注意的一点是String的SET带NX参数是实现分布式锁的常用方式但它只是能用不是好用。生产级分布式锁你还需要考虑锁的续期、可重入、主从切换时的锁丢失、以及value里放的唯一标识怎么在释放时校验。这些单靠Redis是做不到的通常要引入Redisson这类封装好的客户端库来解决。我自己踩过一个坑早期用SETNXEXPIRE两个命令分步执行进程刚SETNX成功还没执行EXPIRE服务宕了锁直接变成永不释放。后来改成一条SET lock value NX PX 3000才算绕开这个雷。3.2 Hash才是对象存储的正解如果需要一个对象比如用户信息包含昵称、年龄、性别、积分很多人第一反应是存一个JSON字符串到String里。这在数据量小的时候没问题但一旦你需要修改其中某个字段麻烦就来了必须把整个JSON取出来反序列化改字段再序列化整个写回去。并发高的时候还有覆盖风险。Hash就是为这种场景设计的。一个key对应一个对象对象里有多个field每个field独立存储、独立更新HSET user:1001 name tom age 25 score 100 HGET user:1001 name HINCRBY user:1001 score 10只改分数却不需要动其他字段这在String JSON的方案里做不到。但Hash也有个隐藏的坑如果你把一个Hash的所有field一次全部取出来用HGETALL数据量很大的时候会阻塞Redis单线程因为取出的数据要一次性组装成响应。我建议线上业务里能用HGET精确取字段就别用HGETALL。真要遍历用HSCAN分批游标式迭代不要让单个命令包揽太多工作。3.3 实际选型建议总结一下我的经验单个value不超过几十KB、整体读多写少用String存JSON没问题。经常要修改部分字段、需要字段级TTLHash虽然不能给单个field设过期但你可以自己在field值里加入过期时间戳做软过期、或者字段数量可控优先用Hash。大规模计数浏览量、点赞数、库存扣减用String的INCRBY不需要Hash。存储的对象字段很多且不确定建议前期就定好Hash的field命名规范上线后再迁移数据结构是很痛苦的。4. List不只是队列还有阻塞语义和内存陷阱4.1 双端操作与阻塞实现List在Redis里面向两端的链表语义LPUSH从左边推入RPUSH从右边推入LPOP、RPOP分别从两端弹出。用这两个基础操作就能拼出栈同端进同端出和队列一端进另一端出两种模型。但你真正该关注的是阻塞版本BLPOP、BRPOP。它们是实现生产者-消费者模式的关键工具。调用BRPOP key timeout时如果key里没有元素客户端连接会阻塞在服务端直到有新元素推入或者超时返回nil。这个设计让消费者不用频繁轮询能把Redis直接当轻量级MQ用。不过要提醒一句能用Redis List当MQ的场景通常是消息不用严格不丢、不用精确一次语义的场景。比如异步发通知、异步刷新缓存。真要可靠投递、消费者分组、消息回溯还是老老实实用专业的消息队列。Redis在这里的价值是轻量、零运维成本但也要承认它在消息可靠性上的天花板。我在实际用BRPOP时遇到过一个问题多个消费者同时阻塞在同一个key上时一个LPUSH进来只会唤醒一个消费者而不是广播给所有人。这是Redis阻塞队列竞争消费的默认行为如果你需要广播模型就得自己给每个消费者建一个队列或者换发布订阅。4.2 quicklist的内存权衡之前的章节提过List底层是quicklist。你可以把它理解成把多个小的ziplist串成一个双向链表——每个节点是一段压缩的连续内存节点之间用指针连接。这样既享受了ziplist的高内存密度又不像单个ziplist那样在中间插入元素需要整体搬移。这里有个配置参数值得关注list-max-ziplist-size。它决定quicklist每个节点里ziplist的最大大小默认是-2表示每个节点不超过8KB。如果单个List非常大比如几百万条消息你可以试着调大这个值提高压缩率、减少链表节点数代价是中间插入操作的耗时变长。反过来如果List经常在中间位置插入删除调小节点大小会更合适。4.3 一个常见的性能事故有一年我们做一个排行榜刷新功能用RPUSH不断往List尾部推数据再用LRANGE key 0 -1一次性读取全部数据。上线后跑了一个月都正常突然有一天某个大客户的数据量暴增LRANGE一次拉出来几十万条Redis主线程直接卡了好几秒。排查后确认LRANGE O(N)的时间复杂度在数据量小的时候不是问题但一旦数据量达到几十万甚至百万级一条命令就能拖垮整个Redis实例。这也解释了为什么Redis官方一直在强调不要在生产环境执行KEYS *和LRANGE key 0 -1这类全量命令。正确做法是分页读取LRANGE key 0 99配合LLEN获取总长度循环拉取。如果单页数据还是要5万条那建议直接把数据拆到不同key用多个list分片。5. Set与ZSet从去重判断到排行榜核心5.1 Set的杀手锏是集合运算Set在Redis里是一个无序的字符串集合元素不能重复。它最诱人的地方是支持集合间的交并差运算SINTER、SUNION、SDIFF。举个例子你要做一个今日和昨日都活跃的用户名单传统做法是昨天导一份名单、今天导一份名单写程序比对。有了Set直接两条命令SADD today:active user1 user2 user3 SADD yesterday:active user2 user3 user4 SINTER today:active yesterday:active # 返回 user2 user3更常见的场景是标签系统。文章打标签、用户打标签通过Set求交集找到同一兴趣人群或者用SPOP随机抽奖。Set的另一个常用面是去重判断SISMEMBER返回1或0复杂度O(1)比拿String再去查一次要更顺手。Set的底层编码前面提过元素都是整数且量不大时用intset它是一个有序数组内存极省。但一旦出现字符串元素或数量超阈值会转成hashtable这时元素在内存中是无序存放的。所以如果你依赖Set的遍历顺序——抱歉Set就是不保证顺序的需要顺序请往下看ZSet。5.2 ZSet为什么能稳定支撑排行榜ZSet是Redis里能力最被低估的一个。每个成员关联一个分数成员唯一分数可重复。底层用跳表加哈希表组合实现读和写都能保持在O(log n)量级这让它处理千万级排行榜不在话下。最常用的命令ZADD top:game:1 10000 userA写分数ZINCRBY top:game:1 500 userA加分原子ZREVRANGE top:game:1 0 9获取前10名ZRANK top:game:1 userA查排名ZRANGEBYSCORE按分数区间取成员使用ZSet做排行榜的场景特别多。直播间礼物榜、积分商城兑换排名、商品销量榜甚至可以做延迟队列把任务执行时间搓成时间戳作为score轮询时ZRANGEBYSCORE key -inf now取出到期的任务。这里我要讲一个实际优化点。如果排行榜的分数需要实时更新比如直播中的打赏总额榜一次ZINCRBY非常轻量但打赏是很高频的操作可能会酿成Redis操作次数过多的问题。我们的方案是引入一层内存缓冲比如每秒批量把这段时间内的增量合并成一条ZINCRBY提交到Redis而不是打一次赏就写一次。实时性损失一秒但Redis的QPS压力降了一个量级。ZSet在面试里被追问的点通常是跳表为什么能替代平衡树。我的理解是跳表实现简单、支持O(log n)查找和顺序遍历而Redis的ZSet还需要一个哈希表来实现O(1)的成员定位二者一配合既快又省心。6. 数据结构之外的性能杀手序列化、过期策略与Redis线程模型6.1 序列化选型直接决定内存占用很多团队把对象塞进Redis之前都会做序列化这就是热搜词里出现redis序列化的原因。JDK原生的Serializable序列化有严重问题对象头大、字段冗余多存同样的内容体积可能是JSON的2到3倍是二进制序列化框架如Protostuff、Kryo的5到10倍。Redis内存价格不便宜序列化选型粗糙的后果是内存翻倍、网络开销翻倍、大key出现频率增加。我建议遵循三个原则不用JDK序列化除非是极端遗留项目否则一律换掉。JSON序列化选Jackson或Fastjson2时要保证配置禁止循环引用防止对象图里出现指向自身的引用导致死循环。高并发、大数据量场景用二进制序列化字段增删的影响要做好版本号控制。还有一个细节用GenericJackson2JsonRedisSerializer存数据时会把class类型信息带上这会多出不少字节。如果你明确知道存的是某个固定类型可以自定义序列化方案去掉类型头。省下的内存极其可观。6.2 过期策略懒删除、定期删除与内存淘汰的区别Redis的key过期并不是到点了就立刻消失。它内部有两套机制惰性删除每次访问key时检查是否过期过期才删除。定期删除每隔一段时间随机抽取一批设置了过期时间的key删除其中已过期的。所以一个已经过期的key如果一直不被访问它可能还会在内存里躺很久。真正防止内存被过期key占满的是内存淘汰策略maxmemory-policy。常见的有noeviction不淘汰写不进去、allkeys-lru所有key按LRU淘汰、volatile-ttl优先淘汰快过期的。线上常用的其实是allkeys-lru或allkeys-lfuLFU比LRU更适合热数据反复访问、冷数据偶发访问的场景。6.3 Redis是单线程但慢命令才是罪魁祸首Redis是单线程这句话经常被误读。准确地说Redis的网络IO和命令执行是单线程的但持久化、过期key回收等任务有额外线程池处理。我们写业务代码时最需要担心的就是单条命令的执行时间——一旦某个命令执行太慢后面排队的命令全部阻塞。为什么KEYS *会阻塞因为它要遍历全量key本质是O(N)。为什么HGETALL大key会阻塞因为它要组装一个大响应。为什么ZRANGE key 0 -1 WITHSCORES会阻塞同理。我整理过一份团队内部慢命令排查清单禁止生产环境执行KEYS *改用SCAN游标。LRANGE、HGETALL、SMEMBERS、ZRANGE都必须带limit。单个String或Hash的value大小控制在100KB以内超过就拆分。SORT命令慎用它在高版本虽然优化过但排序量太大依然可能拖垮Redis。BGSAVE、BGREWRITEAOF虽然是异步但fork瞬间的耗时和内存复制成本也要提前评估。这些规则看起来很基础但据我观察90%的线上Redis卡顿事故根因都能在慢命令大key过期集中这三个词里找到。7. 缓存穿透、缓存击穿与缓存雪崩数据结构不是万能药7.1 三个缓存杀手的本质区别热搜词里redis 缓存穿透排得很靠前说明这是大家真正常遇到的问题。缓存穿透查询一个根本不存在的数据缓存里没有数据库里也没有。每次请求都直接打到DB缓存形同虚设。解决思路是缓存空值即使DB返回null也缓存一个短期key或者用布隆过滤器挡住不存在key的请求。缓存击穿某个热点key恰好过期同一瞬间大量请求绕过缓存直冲DB。因为只有一个key失效压力集中在数据库上。解决思路是互斥锁重建缓存或者把热点key的过期时间调长甚至不过期加定时任务主动续期。缓存雪崩大量key在同一时间集体过期或者Redis实例宕机造成请求全部打到DBDB扛不住直接崩。解决思路是给过期时间加随机扰动避免集中过期或做Redis主从加哨兵保证高可用。7.2 结合数据结构谈防控缓存穿透用布隆过滤器Redis里可以用String的bitmap自己实现一个简易布隆过滤器也可以直接用bf模块。例如BF.ADD user:filter user:1001、BF.EXISTS user:filter user:1001布隆过滤器说存在可能误判说不存在一定不存在。缓存击穿用分布式锁就是前面提到的SET lock uuid NX PX方案拿到锁的线程负责回源查DB并重建缓存其他线程先查缓存没查到就短暂等待后重试。注意这里要预防死锁锁过期时间要大于重建缓存所需时间。缓存雪崩则要落到TTL设计和集群高可用。TTL不要用固定值SET key value EX (600 random(0, 300))让过期时刻均匀散开。还要提一个容易忽略的Redis宕机时的本地兜底缓存。有些团队会在Redis没数据时查一个本地Caffeine缓存二级缓存互备。这虽然增加了代码复杂度但对核心链路来说是值得的。本地缓存的容量要用定时任务定期刷新避免本地缓存数据太旧。7.3 Redis做中间件时的链路治理Redis做中间件它只是链条上的一环。你在实际项目里更要注意的是数据一致性的最终收敛缓存更新可以利用Cache Aside Pattern先更新DB再删缓存但先删缓存再更新DB会导致一次缓存空窗期DB承压以及并发写时出现旧值覆盖的隐患。我们团队实操下来最稳妥的是延迟双删——更新DB后删除缓存隔几百毫秒再删除一次兜底删除期间可能重建的旧缓存。我觉得做缓存的人一定要有缓存只是加速器不是数据库的心态。任何时候都要想清楚如果Redis整实例挂了我的系统能不能降级能不能直接查DB能不能接受短暂的数据不一致。想清楚了就有了预案。8. 从会用数据结构到设计好Redis存储写到这里我突然想通了一个规律Redis里所有的大坑几乎都不是数据结构的错而是使用姿势的错。数据结构本身只是在做空间换时间或时间换空间的取舍真正决定一个Redis系统稳不稳的是你能不能把数据模型设计得贴合Redis的处理模型。给你一个我自己实践下来的建模思路这个方法论比具体的命令更重要第一步识别业务里的实体和关系。用户、商品是实体用户关注商品是关系。实体适合用String或Hash关系集合适合用Set有序关系用ZSet时序流用List或Stream。第二步识别访问模式。是点查HGET、范围查ZRANGEBYSCORE、还是一个key全量读不同访问模式对应不同结构。第三步预估数据量级和单个key大小。小key合并、大key拆分提前就规划好不要等线上出了慢查询再回来改。第四步考虑TTL和淘汰策略。数据有没有生命周期是永久有效、还是只保留最近一小时TTL设计直接影响内存能否循环利用。第五步定好KEY的命名规范。Redis的key是全局可见的不像关系库的表或字段天然隔离。推荐用业务名:对象名:id这种分层的命名比如order:info:10001、user:follow:u1234。网上常说的redis分布式锁其实也可以被归入设计的范畴——锁的key名、value随机串、过期时间、续期机制这些都是要提前设计完整的。对了关于redis安装教程这类热搜其实不用太焦虑。官方Docker镜像redis:7.0跑起来加一个appendonly yes再做主从复制三个容器就够当成开发环境了。生产环境再考虑哨兵或者集群模式。第0步卡在安装上的同学先把环境跑通比什么理论都强。最后分享一个我在团队里反复培训的检查习惯写完一段缓存代码后问自己三个问题——如果这个key突然没了我能接受吗如果这个key的值特别大我能接受吗如果Redis此刻卡了两秒我的服务会不会被拖垮三连问之后你的数据结构选型和缓存策略一般都会收敛到更合理的方案上。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询