ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis数据结构深度解析:从底层编码到生产实战选型

Redis数据结构深度解析:从底层编码到生产实战选型 1. 为什么我们总在面试和实战里反复聊Redis数据结构干了这么多年后端我发现一个挺有意思的现象不管是团队内部的技术分享还是帮朋友做系统设计评审只要涉及到缓存和高并发场景Redis的数据结构几乎是一个绕不开的话题。很多人对Redis的理解停留在“不就是个key-value缓存嘛”结果一上生产环境遇到大key、热key、内存暴涨、命令阻塞这些坑才发现自己对底层数据结构的认知有多浅。Redis之所以能成为缓存和内存数据库领域的绝对主力核心原因不在于它“快”而在于它针对不同的业务场景提供了多种底层数据结构并且每种结构都有对应的编码优化策略。你写一个HSET底层可能是哈希表也可能是压缩列表你写一个ZADD底层可能是跳表加字典的组合。这些细节直接决定了你的内存占用、命令耗时、以及集群环境下的数据分布。这篇文章我打算从一线实战的角度把Redis常用的几种数据结构彻底拆一遍。不是照本宣科地念API文档而是结合我这些年踩过的坑、调过的优、排过的故障讲清楚每种结构到底适合什么场景、底层是怎么实现的、有哪些参数可以调、以及怎么避免把Redis用成性能瓶颈。不管你是刚接触Redis的新手还是已经用了几年但没深究过底层的老手应该都能从里面找到一些可以直接抄作业的东西。2. 五种基础数据结构逐一拆解与实战选型2.1 String最基础但最容易用错的结构String是Redis里最基础的数据类型也是很多人唯一会用的类型。它的底层实现其实就是一个动态字符串内部维护了长度信息和剩余空间支持追加操作。但就是这么简单的结构用错了照样出大问题。先说过期时间的设置。我见过不少项目把用户会话信息直接SET进去然后忘了设过期时间结果内存一路涨到报警。正确的做法是SET key value EX 3600或者SETEX key 3600 value把过期时间作为原子操作的一部分。如果你先SET再EXPIRE中间如果客户端崩溃或者网络抖动这个key就可能变成永不过期的垃圾数据。再说计数器场景。String支持INCR、DECR、INCRBY这些原子操作用来做文章阅读数、接口调用量统计非常合适。但要注意INCR的原子性只在单个Redis实例上成立如果你用了集群模式同一个key必须落在同一个slot上才能保证原子性。另外如果value不是数字INCR会直接报错所以初始化的时候一定要确保value是合法的数字字符串。还有一个容易被忽略的点是SETNX和SET带NX参数的区别。SETNX是独立命令而SET key value NX EX 60可以在一条命令里同时完成“不存在才设置”和“设置过期时间”两个操作。后者在分布式锁场景下更安全因为避免了设置成功但过期时间没加上导致的死锁问题。注意String类型的value最大是512MB虽然理论上支持这么大但实际生产中超过10KB的value就要警惕了大key在集群迁移和主从同步时会造成明显的延迟抖动。2.2 Hash对象存储的性价比之选Hash结构适合存储对象比如用户信息、商品详情、配置项集合。相比用String存JSON字符串Hash的优势在于可以单独更新某个字段不需要把整个对象读出来再写回去。底层实现上Hash在字段数量少且值比较小的时候会用压缩列表来存储内存非常紧凑。当字段数量超过hash-max-ziplist-entries默认512或者某个字段的值长度超过hash-max-ziplist-value默认64字节时才会转成哈希表。这个转换是不可逆的一旦转成哈希表即使你删掉字段让它重新变小也不会再变回压缩列表。这个特性带来一个很实际的优化点如果你要存的对象字段很多但每个字段的值都很小可以适当调大这两个参数让更多Hash继续用压缩列表能省下不少内存。但也不能调得太大因为压缩列表是线性结构查找复杂度是O(n)字段太多会导致单次操作变慢。我踩过的一个坑是用Hash存一个商品的SKU列表每个SKU作为一个字段结果某个爆款商品有上万个SKU直接导致这个Hash变成了大key。后来改成用Hash只存商品的基本属性SKU列表用单独的Set或者List来存问题才解决。所以Hash适合存“字段数量可控”的对象不适合存“会无限增长”的集合。2.3 List队列和栈的灵活切换List在Redis里是一个双向链表支持从两端插入和弹出。底层在元素少的时候用压缩列表元素多的时候用快速链表。这个结构最典型的应用就是消息队列和最新列表。用LPUSH加RPOP可以实现先进先出的队列用LPUSH加LPOP就是后进先出的栈。但如果你用RPOP来消费消息需要配合BRPOP做阻塞等待否则就得轮询既浪费CPU又增加延迟。BRPOP可以设置超时时间超时后返回空客户端可以继续循环等待。不过用List做消息队列有个明显的缺陷不支持消费确认。消息一旦被RPOP出来就从List里删掉了如果消费者处理失败这条消息就丢了。所以对可靠性要求高的场景还是得用专门的消息队列中间件或者用Redis的Stream结构。List还有一个常用场景是“最新N条记录”。比如展示最新的100条评论可以用LPUSH插入新评论然后用LTRIM保留前100条。这样List的长度始终是固定的不会无限增长。LTRIM的复杂度是O(n)但n是你要删除的元素数量通常很小所以性能没问题。提示LRANGE命令在List很大的时候要慎用比如LRANGE key 0 -1会返回所有元素如果List有几十万元素这一条命令就能把Redis阻塞住。正确的做法是用LRANGE key 0 99分页获取。2.4 Set去重和集合运算的利器Set是无序不重复的字符串集合底层在元素少且都是整数时用整数集合元素多或者包含字符串时用哈希表。它最直接的应用就是去重比如统计文章的独立访客、记录用户点赞过的帖子。但Set真正的威力在于集合运算。SINTER可以求交集SUNION求并集SDIFF求差集。比如做社交推荐里的“共同关注”就可以用两个用户的关注Set求交集。不过这些运算的复杂度是O(n)如果Set很大直接在Redis上跑会阻塞。更好的做法是用SINTERSTORE把结果存到一个临时Set里再慢慢取或者用SSCAN分批处理。随机取元素也是Set的常用功能。SRANDMEMBER可以随机返回一个或多个元素但不删除SPOP随机返回并删除。抽奖场景用SPOP很合适但要注意SPOP在集群模式下如果指定了count参数行为可能和单机不一致需要提前测试。我遇到过一个典型问题用Set存用户的标签结果某个用户的标签数量爆炸导致这个Set变成了大key。后来改成用Hash存标签每个标签作为一个字段值设为1这样虽然内存稍微多一点点但可以单独删除某个标签也避免了集合运算时的性能问题。2.5 ZSet排行榜和延时队列的核心ZSet是有序集合每个元素关联一个分数按分数排序。底层用跳表加字典实现跳表负责按分数范围查找字典负责按成员查分数。这个结构最经典的应用就是排行榜比如游戏积分榜、热搜榜。ZADD添加元素ZRANGE按分数升序取ZREVRANGE降序取ZRANK查排名ZSCORE查分数。这些命令的复杂度都是O(log n)非常高效。但要注意如果分数相同ZSet会按成员的字典序排列这个细节在做排行榜时可能影响展示效果。ZSet还可以用来做延时队列。把任务的执行时间戳作为分数然后用ZRANGEBYSCORE查询到期的任务。消费者轮询这个范围取到任务后删除。这种方案比用List做延时队列更灵活因为可以按时间范围批量取任务也支持动态调整任务的执行时间。不过ZSet的跳表结构在元素很多时内存占用会比较高因为每个元素除了存储成员和分数还要维护多层索引。如果只是做简单的去重加排序可以考虑用List加定期排序的方式替代但实时性会差一些。3. 底层编码与内存优化的关键参数3.1 理解Redis的编码转换机制Redis为了在内存和性能之间取得平衡对每种数据结构都设计了多种底层编码。比如String有int、embstr、raw三种编码Hash有ziplist和hashtableList有ziplist、quicklistSet有intset、hashtableZSet有ziplist、skiplist。这些编码的转换是由配置参数控制的比如hash-max-ziplist-entries、list-max-ziplist-size、set-max-intset-entries、zset-max-ziplist-entries。默认值都是经过大量测试的适合大多数场景但如果你对自己的数据特征非常了解调整这些参数能带来明显的内存收益。举个例子如果你用Hash存的对象字段数稳定在100以内每个字段值不超过100字节可以把hash-max-ziplist-entries调到128hash-max-ziplist-value调到128这样所有对象都用压缩列表存储内存能省30%以上。但如果你不确定数据规模就不要乱调否则一个超大压缩列表的查找操作会拖慢整个实例。3.2 用OBJECT ENCODING查看实际编码OBJECT ENCODING key命令可以查看某个key当前的底层编码。这个命令在排查内存问题和性能问题时非常有用。比如你发现某个Hash操作特别慢用这个命令一看发现是hashtable编码但你的数据量明明很小那就说明之前可能有过大字段导致编码转换了而且不可逆。我一般会在压测阶段用这个命令抽查一批key的编码确保它们都落在预期的编码上。如果发现异常就回头检查数据写入逻辑看看是不是有脏数据或者异常大的字段混进去了。3.3 内存碎片与淘汰策略Redis的内存碎片率可以通过INFO memory里的mem_fragmentation_ratio查看。如果这个值大于1.5说明碎片比较严重可以考虑开启activedefrag或者重启实例让操作系统回收内存。但重启是有代价的所以更好的做法是在设计阶段就控制好value的大小和数量避免频繁的扩容缩容。淘汰策略方面maxmemory-policy有八种可选。最常用的是allkeys-lru和volatile-lru。前者对所有key做LRU淘汰后者只对设置了过期时间的key做淘汰。如果你的缓存数据都设了过期时间用volatile-lru更安全因为它不会淘汰掉那些没设过期时间的持久化数据。但如果你把Redis当纯缓存用所有数据都可以丢那allkeys-lru更简单直接。注意LRU是近似算法Redis会随机采样一批key然后淘汰其中最久未使用的。采样数量由maxmemory-samples控制默认是5。调大这个值会让淘汰更精确但也会增加CPU开销。4. 生产环境实操从零搭建一个缓存层4.1 场景定义与容量估算假设我们要给一个内容社区做缓存层核心需求是缓存用户信息、文章详情、文章列表、以及文章点赞数。用户量大概100万日活10万文章量500万每天新增1万篇。先估算内存。用户信息用Hash存每个用户大概10个字段平均每个字段50字节加上key和过期时间一个用户大约1KB。100万用户就是1GB。文章详情用Hash存每篇文章20个字段平均100字节一篇大约2.5KB500万篇就是12.5GB。文章列表用List存每个列表存最新的100篇每篇存ID和标题大约5KB假设有1万个活跃列表就是50MB。点赞数用String存每个key大约50字节500万篇就是250MB。总计大约14GB。考虑到Redis的内存开销和碎片实际需要预留20GB左右。所以选一个32GB内存的实例比较稳妥或者用集群模式分片存储。4.2 键名设计与过期策略键名设计要遵循几个原则可读性、唯一性、避免冲突。我一般用业务名:对象类型:ID的格式比如user:info:1001、article:detail:2001、article:likes:2001。这样在SCAN或者监控的时候一眼就能看出是什么数据。过期策略要分层设置。用户信息可以设7天过期因为用户资料不常变但也不能永久缓存。文章详情设1天过期因为文章可能被编辑。文章列表设1小时过期保证列表的实时性。点赞数不设过期因为它是持久化数据但要用定时任务定期同步到数据库。这里有个细节不要给所有key设一样的过期时间否则会导致缓存集中失效引发缓存雪崩。可以在基础过期时间上加一个随机偏移比如用户信息设7天加减1小时的随机值。4.3 批量操作与Pipeline的使用缓存层经常需要批量读取比如一次取10个用户的信息。如果用循环逐个HGETALL10次网络往返至少需要10毫秒而用Pipeline可以压缩到1毫秒左右。Pipeline的原理是把多条命令打包发送服务端依次执行后一次性返回结果。但Pipeline不是没有代价的。如果一次打包太多命令比如上万个会占用大量服务端内存来缓存回复还可能阻塞其他请求。我一般把Pipeline的批次控制在100到500条之间根据命令的复杂度和网络状况调整。对于Hash的批量读取还有一个更优雅的方案用HMGET一次取多个字段或者用HGETALL取整个Hash。如果只需要部分字段HMGET比多次HGET高效得多。但要注意HMGET不存在的字段会返回nil客户端需要处理这种情况。4.4 缓存穿透、击穿、雪崩的应对缓存穿透是指查询一个数据库里也不存在的key导致每次请求都打到数据库。解决方案是缓存空值比如查不到用户时往Redis里写一个user:info:9999值为null的key过期时间设短一点比如5分钟。这样下次再查这个不存在的用户就直接从Redis返回空不会打到数据库。缓存击穿是指某个热点key过期瞬间大量请求同时打到数据库。解决方案是用互斥锁只让一个请求去数据库加载其他请求等待。具体做法是用SET lock:user:1001 1 NX EX 10抢锁抢到的去查数据库并回写缓存没抢到的睡一小会儿再重试。缓存雪崩是指大量key同时过期导致数据库压力骤增。解决方案就是前面说的给过期时间加随机偏移。另外还可以做二级缓存本地缓存加Redis缓存本地缓存过期时间更短但能挡住大部分热点请求。5. 常见问题排查与避坑经验实录5.1 大key的发现与处理大key是Redis运维里最常见的问题之一。一个String类型的key如果超过10KB一个Hash如果字段超过5000个一个List如果长度超过1万都可以认为是大key。大key的危害包括网络传输延迟高、主从同步慢、集群迁移卡顿、删除时阻塞。发现大key的方法有几种。一是用redis-cli --bigkeys扫描它会统计每种类型最大的key。二是用SCAN加STRLEN、HLEN、LLEN、SCARD、ZCARD逐个检查。三是通过监控慢查询日志如果某个命令耗时超过10毫秒很可能就是操作了大key。处理大key不能直接用DEL因为DEL是同步操作删除一个百万元素的Hash会阻塞好几秒。正确的做法是用UNLINK异步删除或者用HSCAN、SSCAN、ZSCAN分批删除。如果是List可以用LTRIM逐步截断。5.2 热key的识别与分散热key是指访问频率极高的key比如首页的全局配置、秒杀活动的库存。热key会导致单个Redis节点CPU跑满而其他节点很闲。识别热key可以用redis-cli --hotkeys或者通过监控命令的QPS分布来判断。分散热key的方法有几种。一是把热key复制多份比如user:info:1001:1、user:info:1001:2客户端随机选一个读。二是用本地缓存挡住大部分请求只让少量请求穿透到Redis。三是把热key拆成多个子key比如库存拆成10份每份独立扣减最后汇总。提示--hotkeys选项需要Redis开启maxmemory-policy为lfu相关策略才能生效因为它依赖LFU计数器来统计访问频率。5.3 慢查询日志的分析与调优Redis的慢查询日志通过slowlog-log-slower-than参数控制阈值默认是10000微秒也就是10毫秒。slowlog-max-len控制日志长度默认128条。生产环境建议把阈值调到1毫秒因为Redis的操作通常都在微秒级别超过1毫秒就值得关注了。分析慢查询日志时重点关注KEYS、FLUSHALL、HGETALL、LRANGE、SMEMBERS、ZRANGE这些O(n)命令。如果发现这些命令频繁出现在慢查询里说明数据结构选型或者使用方式有问题。比如用KEYS做模糊匹配应该换成SCAN用HGETALL取整个Hash应该换成HMGET只取需要的字段。5.4 内存告警的排查思路收到内存告警时先看INFO memory里的used_memory和used_memory_rss。如果两者差距很大说明碎片严重。再看mem_fragmentation_ratio大于1.5就要考虑整理碎片。然后看INFO keyspace确认每个db的key数量和过期key数量。如果过期key很多但没被删除说明淘汰策略可能有问题或者有大量key没设过期时间。接着用--bigkeys扫描大key用--hotkeys扫描热key逐步定位内存消耗的大头。最后检查是否有数据只增不减。比如用List做队列只LPUSH不RPOP用Set做去重只SADD不SREM。这些都会导致内存持续增长需要在业务逻辑上加上清理机制。6. 数据结构选型的决策框架与个人体会6.1 一张表帮你快速选型需求场景推荐结构关键命令注意事项缓存对象HashHSET/HGET/HMGET控制字段数量和值大小计数器StringINCR/INCRBY确保value是数字去重集合SetSADD/SISMEMBER大集合用SSCAN分批处理排行榜ZSetZADD/ZREVRANGE分数相同时按字典序排列消息队列ListLPUSH/BRPOP不支持消费确认可靠性有限最新列表ListLPUSH/LTRIM用LTRIM控制长度延时队列ZSetZADD/ZRANGEBYSCORE轮询频率要合理分布式锁StringSET NX EX注意锁续期和误删问题6.2 我个人的几条选型原则第一能用简单结构就不用复杂结构。String和Hash能解决的问题不要用Set和ZSet因为后者的内存开销和操作复杂度都更高。第二预估数据规模提前设置编码参数。如果知道某个Hash的字段数不会超过200就把hash-max-ziplist-entries调到256让它一直用压缩列表。第三永远给缓存设过期时间。即使你觉得这个数据不会变也要设一个较长的过期时间比如30天作为兜底。第四大key和热key要在设计阶段就避免。不要等到生产环境出问题了再想办法拆分那时候成本高得多。第五监控比调优更重要。把慢查询、内存使用、key数量、命令QPS这些指标都接入监控设置合理的告警阈值才能在问题扩大之前发现它。6.3 后续可以深入的方向如果你已经把基础数据结构用得很熟了可以进一步研究Redis的Stream结构它支持消费者组和消息确认比List更适合做消息队列。还有Redis的模块系统比如RedisJSON、RedisSearch、RedisTimeSeries这些扩展模块能让Redis支持更丰富的数据类型和查询能力。另外集群模式下的数据分片和迁移也是个大话题。不同数据结构在集群里的分布策略、跨slot操作的限制、以及迁移时的性能影响都值得单独写一篇来聊。我后面会找机会把这些内容也整理出来。在实际项目里我越来越觉得Redis用得好不好不取决于你会多少命令而取决于你对数据结构的理解有多深。同样一个需求用不同的结构实现内存占用可能差好几倍性能也可能差一个数量级。多花点时间在选型上比事后调优划算得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进