拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis五大数据类型实战详解:原理、场景选型与排障

Redis五大数据类型实战详解:原理、场景选型与排障

1. 为什么先把数据类型想清楚,比背命令更重要

做后端开发的,几乎没有人能绕开 Redis。缓存、分布式锁、排行榜、消息队列、计数统计,样样都有它的身影。但我在面试和带团队时发现一个很有意思的现象:很多人提到 Redis,能熟练说出 String、Hash、List、Set、ZSet 这五种数据类型,可一旦落到具体业务场景,选型和命令就乱了套——有人用 String 存了一个巨大的 JSON,有人用 List 做高可靠消息队列,还有人把排行榜硬生生做成了在数据库里 ORDER BY。这些问题的根源,不是命令不熟,而是对数据类型的底层定位和适用边界缺乏直觉。

这篇内容我准备把这五种数据类型彻底拆开讲透。不光是"SADD 是加集合、ZADD 是加有序集合"这种流于表面的操作符背诵,而是每个类型解决什么问题、底层结构如何影响性能、什么场景选它会自找麻烦、什么样的 key 设计才算合理。你如果是刚接触 Redis 的新手,这篇文章可以作为完整的入门路线;你如果已经用 Redis 写过业务代码,我相信里面关于大 key、编码转换、原子性误区和排障经验的段落,也能帮你补上一些平时没太注意的盲区。

我自己的习惯是:拿到一个业务需求,先不动手写命令,先画数据模型,再对着五种类型做映射。这一步做完,整个方案基本就成型了。接下来我们把每个类型挨个过一遍,从原理讲到实战,再讲讲这些年我踩过的坑。

2. String:最基础但最容易被用错的类型

2.1 String 的底层结构和三种编码形态

String 是 Redis 最基础的数据类型,一个 key 对应一个 value,value 最大能到 512MB。很多人以为 String 就是"存字符串",实际它的底层远不止字符串这么简单。Redis 为了在不同场景下做出性能最优的选择,给 String 设计了三种编码方式:

编码触发条件底层结构
intvalue 是整数且能用 long 表示直接存整数值,不自带 redisObject 的 sds 头部
embstrvalue 长度小于等于 44 字节一次性分配连续的 redisObject 和 sds 内存
rawvalue 长度大于 44 字节分两次分配,redisObject 和 sds 独立内存

这个 44 字节的临界值,很多资料提过但没讲透。它其实是 Redis 内存分配策略和缓存行对齐共同作用的结果:jemalloc(Redis 默认内存分配器)在 64 字节以下会走 size class 分配,redisObject 结构本身占 16 字节,SDS 头部在 Redis 3.2 之后占 3 字节,再加上结束符,算下来能留给数据的空间就是 44 字节。超过这个值,embstr 的"连续内存一次分配"优势就保不住了,转为 raw 后读写需要两次内存分配,性能会略降。

我在实际项目中很少直接干预编码切换,但我会用 OBJECT ENCODING 命令检查线上 key 的编码状态。以前排查过一个诡异问题:某个存储手机号的 String key,平时写入都正常,偶尔一次写入性能暴跌,后来发现是因为某个手机号后面多了一个空格,长度从 11 位变成 12 位,编码从 embstr 跳到 raw,触发了一次额外的内存分配。这种问题很难从业务代码层面察觉,但只要你理解编码机制,排查方向就会清晰很多。

2.2 单值缓存以外:String 的经典命令组合

String 最常见的用法当然是缓存单值,比如用户昵称、商品库存、接口返回的 JSON 片段。但 String 的价值不止于 SET/GET 这一对,我给你列几个我日常最高频的命令组合,每一个都有明确的场景指向。

SETNX 是分布式锁的最小实现。它的全称是 SET if Not eXists,只有在 key 不存在时才设置成功。配合 EXPIRE 设置过期时间,就成了一版最简分布式锁:

> SETNX lock:order:1001 1 (integer) 1 > EXPIRE lock:order:1001 30 (integer) 1

当然真实生产环境里,建议直接用 SET 命令带 NX 和 EX 两个参数一次性完成:

> SET lock:order:1001 1 NX EX 30 OK

为什么推荐用 SET 带 NX EX 而不是先 SETNX 再 EXPIRE?因为两条命令组合不具备原子性,中间如果 Redis 崩溃或网络抖动,锁就没有过期时间了,直接变成死锁。用一条 SET 命令就能把"加锁"和"设置过期"合并成一个原子操作,这个细节我在代码评审里几乎每次都会提。

MSET/MGET 是批量读写的利器。一次网络往返完成多个 key 的读写,在高并发场景下能显著降低 RTT 的影响:

> MSET user:1001:name "zhangsan" user:1001:age "28" OK > MGET user:1001:name user:1001:age 1) "zhangsan" 2) "28"

这里要注意,MSET/MGET 虽然是批量操作,但它并不是原子的——中间某个 key 写失败不会回滚其他 key。如果你需要"要么全成功,要么全失败"的事务语义,得用 MULTI/EXEC 包裹,但那样又会带来性能开销,所以常规缓存场景用 MSET/MGET 就够了,别指望它承载事务。

2.3 为什么容器类型嵌套的 String 不一定安全

Redis 的 Hash、List、Set、ZSet 里存的值,本质上也都是 String。于是有一种常见的错误认知:既然容器里的元素是 String,那我是不是可以用同样的 String 命令去处理它们?我可以直接说结论:不行。容器内部的 String 不具备独立 key 的性质,它没有自己的 TTL、没有自己的过期时间、不能被单独设置 EXPIRE,也不能被单独设置访问权限。

为什么这样说?因为 Redis 的过期机制是以 key 为单位的。你给一个 Hash 设置了 EXPIRE,整个 Hash 到点会被删除,但 Hash 里某个 field 想要单独维持更长时间的存留,普通版本 Redis 根本做不到。Redis 7.4 开始支持部分 Hash field 的过期特性,但在 7.4 之前,所有"给某个 field 单独设 TTL"的方案都是自己用另外的 key 去模拟。

这个特性我在设计缓存方案时吃过亏。当时给一个用户维度的 Hash 设置了 1 小时过期,其中某个 field 存的是特殊标记,需要保留 24 小时。我天真地以为可以单独给那个 field 续期,后来发现根本没这个命令,最后只能拆出一个独立的 String key 来存这个标记。这个教训让我养成一个习惯:凡是生命周期不一致的数据,千万别塞进同一个容器 key,要么拆 key,要么接受整体过期的约束。

2.4 String 适合做的几类"小任务"

除了缓存和锁,String 还有几个被低估的用法。

第一类是计数器。INCR 和 DECR 是原子操作,天然适合做访问量、点赞数、库存扣减。很多新人会用"先 GET 再 SET"来实现计数,这在并发下会丢数,正确姿势永远是直接 INCR/DECR,让 Redis 保证原子性。

第二类是 Session 共享。分布式系统里,用户登录状态通常放在 Redis,用 SETEX 设置会话 ID 和过期时间,用户每次请求都去 GET 一下。这个场景的关键是过期时间的粒度要匹配业务:纯接口鉴权可以设短一点(比如 30 分钟),购物车这种需要长时间保留的状态可以设长一些(比如 7 天)。

第三类是分布式 ID 生成。INCR 一个全局 key,就能拿到一个趋势递增的 ID。但要注意,这个 ID 不能拿去当数据库主键,因为 Redis 重启后 INCR 会从持久化后的值继续,如果持久化策略是 RDB 快照,重启后可能丢掉最近几次自增记录,会造成主键冲突。所以分布式 ID 建议用专门的雪花算法或者在数据库层用号段模式,Redis 的 INCR 只适合对顺序不敏感的场景。

3. Hash:对象缓存和等值查询的性价比之王

3.1 为什么对象型数据我首选 Hash

String 类型存对象时,有两种常见但又各有局限的做法:一是把整个对象 JSON 序列化后塞进一个 key,查询时整体反序列化,哪怕只想取其中一个字段也要全量解析;二是按对象属性拆成多个 key,多出大量 key 的维护成本。Hash 的思路则完全不同——它天生就是一个"小对象容器",一个 key 下面可以挂多个 field-value 对,结构上和对象几乎一一对应:

> HSET user:1001 name "zhangsan" age 28 city "hangzhou" (integer) 3 > HGET user:1001 name "zhangsan" > HGETALL user:1001 1) "name" 2) "zhangsan" 3) "age" 4) "28" 5) "city" 6) "hangzhou"

当业务方接口只返回用户昵称时,HGET 一次到位;当丢给前端做详情展示时,HGETALL 一把梭哈,非常灵活。和 String 方案相比,Hash 最大的三个优势是:单字段读写、批量字段读写、字段级逻辑控制。这些优势在缓存化改造中特别明显——把老的 MySQL 行记录映射成一个 Hash 时,字段名和列名几乎可以一一对齐,改造成本极低。

我用 Hash 做对象缓存的习惯是:key 保持高扇出、可枚举,field 用稳定的业务标识。典型 key 设计是 user:1001、order:20231001 这种,后面跟业务 ID。如果业务 ID 本身可能被重建或变化,那就尽量避免把所有逻辑都挂在同一个 key 上,因为 Hash 的过期是整体过期的,一个 field 想单独续期会变得很别扭。这一点在缓存与数据库一致性方案里经常被忽略——很多人以为 Hash 可以做字段级过期,实际上官方在普通 Hash 上并不提供这个能力,除非你用的是 7.4 以上版本且启用了 hash field expiration 特性。

3.2 HGETALL 的不当使用与渐进式遍历

HGETALL 是个好命令,但也很容易成为性能陷阱。当一个 Hash 里有几千个 field 时,HGETALL 会一条命令把所有 KV 全部拉回来,这个 O(N) 操作在大 key 场景下比预想中更危险。我之前接手过一个跑了两年的老项目,一个商品信息 Hash 因为运营不断往里面加自定义属性,慢慢膨胀到几万个 field,高峰期一个 HGETALL 就能把带宽和 GC 拉爆。排查时我先执行 HLEN 发现 field 数量已经上万,然后立刻让线上读按需取字段,只读接口用多个 HGET 精准命中,管理后台才放行 HGETALL,才把慢查询压下去。

如果确实要全量读或做遍历,推荐渐进式命令 HSCAN,它和 SCAN 的思路一致,可以分多次迭代取回全部 field-value,每次返回一个游标,下次接着遍历。HSCAN 返回的游标并不是简单的偏移量,而是哈希桶的索引位置,所以哪怕遍历过程中有数据变化,也能保证不错的起始点位:

> HSCAN user:1001 0 MATCH name* 1) "0" 2) 1) "name" 2) "zhangsan"

这种做法适合做数据统计、批量迁移或后台任务扫描,但不适合高频线上读链路。线上读服务永远是精确命中,离散取字段,而不是拿着大铁锹去挖金矿。

3.3 Hash 的内存结构与编码转换细节

Hash 在元素较少时使用 ziplist(紧凑列表)编码,当元素数量超过 hash-max-ziplist-entries 或单个 field 的 value 长度超过 hash-max-ziplist-value 时,会转换为 hashtable 编码。这个转换是透明的,但会影响内存占用和操作复杂度。我做过一次内存实测:100 万个 field 的小值 Hash,在 ziplist 编码下内存占用比 hashtable 编码大概省 30% 到 40%,读取耗时差别也在毫秒级内,真正的差异主要发生在写入时的 rehash 和内存碎片上。

实际操作中,我会在配置里关注 hash-max-ziplist-entries 和 hash-max-ziplist-value 两个参数。如果业务明确知道某个 Hash 的 field 数量上限很低,可以把阈值调高一点,尽量保持 ziplist 编码以节省内存;但也要小心,ziplist 编码下的更新操作是 O(N),如果频繁修改中间位置的数据,性能反而会下降。我的经验是一个 Hash 的 field 数控制在几百到几千、单个 value 控制在几百字节内是比较舒服的范围,超过这个量级就要考虑是不是数据结构选错了。

3.4 用 Hash 做短链映射、字典表与多指标计数

Hash 的应用场景里,我最常用的是三类。

第一是短链映射。短链服务通常需要一个"短码到完整 URL"的映射,直接用 Hash 存,一个短链库就是一个大 Hash,访问时 HGET 一下,生成时 HSET 一下,天然支持批量导入。

第二是字典表。电商后台会有一堆枚举值,状态码、城市码、类目码,这种配置型数据按业务模块拆成多个 Hash,例如 config:city 存所有城市编码和名称,config:category 存类目树。后台修改配置时 HSET 某个字段,不影响到其他字段,也不用为每个配置单独建一个 String key,key 数量大幅下降。

第三是多指标计数。比如记录某篇文章的点赞、收藏、评论数,用 Hash 的 field 分别保存 post:1001:stat 的 like、fav、comment,每次更新用 HINCRBY 原子累加,读取时 HMGET 一次拿多个指标,不用为每个指标单独建 key。这一招在实时数据大屏、运营看板里非常常见,逻辑简单但非常稳。

Hash 还有一个被低估的优势是批量写入和批量读取的对称性。业务方要批量初始化一批对象时,可以先用 HM?SE T一次写入多个 field,再在查询时用 HMGET 一次性取多个 field。这种对称设计在接口层很容易和前端表单字段对齐,我写缓存服务时特别偏爱这种对齐感——数据模型和接口模型一致,代码可读性极高,排查问题时一眼就能看清数据流向。

4. List:时间线、队列与操作记录的一把好手

4.1 List 在 Redis 里的真实定位

很多人一提到 List 就下意识说"消息队列",这个印象不奇怪,LPUSH 加 BRPOP 的组合确实可以做出一个简单的队列。但 List 在 Redis 五大类型里其实更偏"线性结构"的存在:它既是栈又是队列,既可以左进右出,也可以左进左出,完全取决于你怎么用。而它真正的实力,在于顺序性、长度控制和时间窗口记录。

List 的内部存储是一个双向链表结构,所以头尾插入删除都是 O(1),但中间位置的随机访问则是 O(N)。Redis 的 List 在早期实现里直接用 linkedlist,后来为了节省内存又引入了 quicklist(一种以 ziplist 为节点的双向链表)。我自己的理解是,List 适合所有"按时间顺序追加、按批次消费"的场景,不适合高频随机查找。如果你要按索引下标取值,比如取第 1000 个元素,List 会慢到你想骂人,这时候应该考虑用其他结构,而不是硬扛。

4.2 LPUSH 加 LPOP / RPOP:从队列到栈的完整组合

List 的命令很多,核心就几组。先看最常用的队列语义:

> LPUSH task:queue "job1" "job2" "job3" (integer) 3 > RPOP task:queue "job1" > RPOP task:queue "job2"

LPUSH 从左侧写入,RPOP 从右侧弹出,先进先出,这就是一个标准 FIFO 队列。反过来,LPUSH 加 LPOP 就是 LIFO 栈。如果想让消费端在队列为空时阻塞等待,可以用 BRPOP,这样消费端不会高频空转轮询,能大幅降低无谓的 Redis 访问压力:

> BRPOP task:queue 5 1) "task:queue" 2) "job3"

这个 5 表示阻塞 5 秒,超时返回空。我在做任务调度时经常用 BRPOP 配合超时时间,消费线程挂在那里,队列一来就立刻被唤醒,队列空闲时也不会反复产生请求。有一个值得注意的点:BRPOP 返回的是"key 和 value"两部分,因为你可能同时阻塞监听多个 key,所以返回值里第一项是哪个 key 有数据,第二项才是具体数据,代码里别取错。

4.3 用 List 做时间线和操作记录:LTRIM 的妙用

List 的另一个高频场景是"最新动态"和"操作记录"。比如用户的最近浏览记录、系统的最近告警、文章的最新评论,都有同一个共性:只关心最新的 N 条,历史数据要么归档要么丢弃。实现思路很经典:

> LPUSH user:1001:recent_posts "post:9001" "post:9002" "post:9003" (integer) 3 > LTRIM user:1001:recent_posts 0 2 OK > LRANGE user:1001:recent_posts 0 -1 1) "post:9003" 2) "post:9002" 3) "post:9001"

LPUSH 之后立刻 LTRIM 只保留前 N 条,就构成了一个容量固定的滑动窗口。你在微博、抖音时间线里看到的那种"只保留最近一屏"的列表,底层很多就是这种思路。LTRIM 是 Range 保持的精髓,它把超出窗口的内容一次性剪掉,不写代码、不跑定时任务,一条命令就把 List 的容量控制住了。

这里有个坑我必须多提一次:LRANGE 返回的顺序是从左到右。你 LPUSH 进去的数据在左侧,所以 LRANGE 0 -1 返回的第一条是最新写入的。很多新人在这一点上栽过跟头——他们以为 List 和数据库查询结果一样,顺序按插入时间正序,结果一测发现最新的在最前面,日志里和预想不符,排查半天。我建议写代码前先在命令行里手动 push 三条数据感受一下顺序,再进代码写逻辑,别靠想象写。

4.4 List 作为消息队列的痛点与替代方案

虽然 LPUSH 加 BRPOP 能做队列,但如果你想用 List 在生产环境做重逻辑的消息队列,我劝你先想清楚几个限制。

消息丢失风险。BRPOP 弹出并返回数据后,如果消费端在处理消息时崩溃,这条消息就再也取不回来了。相比之下,专业的消息队列比如 RabbitMQ、Kafka 都有消费确认机制,Redis Stream 则提供了消费者组和 PEL(待处理条目列表)来保证消息可追踪。

消息积压时内存膨胀。List 是一个纯内存结构,消息堆积多了,Redis 内存直接开始报警。消息队列有磁盘缓冲能力,但 Redis List 没有。如果消息量预估很大,要么考虑 Stream,要么考虑外部的消息队列中间件。

重复消费问题。List 消费端拿走了消息,消息就没了,所以也不存在重复消费和幂等设计的余地。如果你需要的是"每个消息被多个消费者各自处理一次",List 根本实现不了,这是 Stream 的消费者组才有的能力。

我对 List 做队列的态度很简单:适合轻量级任务、低频打点、单消费者场景;不适合多消费者、高可靠、大量积压的场景。后者的合理选择是 Redis Stream,如果连 Stream 都满足不了可靠性要求,那就直接用专业消息队列,别硬凑。

5. Set:标签、去重与随机抽样的标准答案

5.1 Set 的底层与去重逻辑

Set 是一个无序、不重复的集合。它的底层实现有两种编码:元素全是整数且数量少时用 intset(整数集合),元素是字符串或数量较大时用 hashtable。无论哪种编码,Set 都能保证"添加重复元素时静默忽略、不重复存储"。这一条命令实测就能证明:

> SADD tag:1001 "java" "redis" "java" (integer) 2 > SMEMBERS tag:1001 1) "java" 2) "redis"

SADD 返回 2,说明第二个 "java" 被忽略了。这个去重能力是天然的,不需要你在业务代码里维护一个"曾见过的清单"。我在做去重逻辑时,最省事的方案就是直接用一个 Set 的 key 存储所有已处理的 ID,每次新数据进来用 SISMEMBER 或 SADD 来判断。SISMEMBER 返回 1 说明存在,返回 0 说明不存在;如果你想把"判断加加入"合成一步,那就看 SADD 的返回值——返回 1 说明添加成功(原本不存在),返回 0 说明原本就存在,加了个寂寞。

5.2 SADD/SREM/SISMEMBER:集合操作与用户标签实战

Set 最常见的场景是给用户打标签。内容平台要给用户打上"科技爱好者""数码达人""篮球迷"等标签,用 Set 来做就是:

> SADD user:1001:tags "科技" "数码" "篮球" (integer) 3 > SREM user:1001:tags "科技" (integer) 1 > SISMEMBER user:1001:tags "数码" (integer) 1

标签的增删查改都变成了集合操作,非常自然。更妙的是,当需要知道"同时打了 A 标签和 B 标签的用户"时,可以直接用 SINTER 求交集,不用在业务代码里做双重循环。举一个实际场景:运营想筛选"既关注数码话题,又活跃的用户",维护两个 Set,一个存关注数码话题的用户 ID,一个存活跃用户 ID,然后 SINTER 取交集,一次拿到人群包。这种基于 Set 的交并集运算在推荐系统的人群圈选里是标配。

5.3 随机类场景:SPOP 和 SRANDMEMBER 的区别

抽奖是 Set 的经典玩法,但 SPOP 和 SRANDMEMBER 两个命令的分工经常被搞混。SPOP 会从集合中随机移除一个元素并返回它;SRANDMEMBER 则只是随机返回一个或多个元素,不会移除。对应到业务上:

  • 抽奖发奖,奖品发放后不能重复发,用 SPOP
  • 随机展示推荐位、随机抽一个用户做调查但不改变用户池,用 SRANDMEMBER
> SADD lottery:20240101 "u1" "u2" "u3" "u4" (integer) 4 > SPOP lottery:20240101 "u2" > SRANDMEMBER lottery:20240101 2 1) "u3" 2) "u4"

我见过一个活动组的同学在抽奖接口里用 SRANDMEMBER 抽奖,结果一个用户被抽中两次,因为元素还在集合里。后来改成 SPOP,中奖用户立刻出池,问题就没了。另一个细节是 SPOP 支持 count 参数:SPOP lottery:20240101 3 可以一次抽出 3 个不同的元素,保证互不重复,这在批量抽奖里非常实用,不需要在代码里循环多次调 SPOP。

5.4 Set 做存在性判断:从黑名单到布隆过滤器的取舍

Set 还可以承担一部分"布隆过滤器"的职责,判断一个元素是否存在时用 SISMEMBER,时间复杂度 O(1)。比如黑名单、白名单、已读列表、已发放权益列表,都可以用 Set 来存。和布隆过滤器相比,Set 的优点是精确、无误差;缺点是内存占用较高——每个元素都要真实存储在内存中。

我在实战中的做法是:如果集合规模小(几万到几十万),直接用 Set 做存在性判断,省心省力;如果规模到了千万级甚至亿级,Set 内存顶不住,才考虑布隆过滤器。这里有一个折中的技巧:可以把 Set 的 key 按业务维度切片,比如 user:100x:blacklist,把一个巨大的集合拆散到多个 key 上,分担单 key 的压力,同时让 SISMEMBER 的命中路径更短。切片的粒度要结合实际查询模式,别切出几十万个 key 把管理搞瘫痪。

6. Sorted Set:排行榜、延迟队列与范围查询的关键武器

6.1 Score 的含义与排序规则

Sorted Set(ZSet)是 Redis 五种类型里最特别的一个,它给每个元素关联了一个 double 类型的 score,元素按 score 从小到大排序。如果你有复杂排序需求且同时要求插入性能,ZSet 几乎是唯一选择。它的排序是稳定的:score 相同时,按元素的字典序(member 的字符串顺序)排列。

> ZADD ranking:game 100 "playerA" 200 "playerB" 150 "playerC" (integer) 3 > ZRANGE ranking:game 0 -1 WITHSCORES 1) "playerA" 2) "100" 3) "playerC" 4) "150" 5) "playerB" 6) "200"

注意 ZRANGE 默认是从小到大取,也就是升序。如果你要排行榜从高到低,可以用 ZREVRANGE(从大到小)或者 ZRANGE 加 REV 参数。很多面试题里会问"ZSet 底层是什么",答案是跳跃表加哈希表:跳表负责排序和范围查询,哈希表负责 O(1) 查找元素对应的 score。理解这一点,你就明白了为什么 ZSet 能做"按 score 取排名""按排名取元素""按 score 区间取元素"三种维度的查询,而且效率都很高。

6.2 ZADD/ZSCORE/ZRANK:排行榜的完整实现路径

写一个排行榜功能是 ZSet 的入门实操,步骤并不复杂。

第一步,数据写入。用 ZADD 把玩家 ID 和分数写入:

> ZADD ranking:202401 1000 "playerA" (integer) 1 > ZADD ranking:202401 1200 "playerB" (integer) 1 > ZADD ranking:202401 800 "playerC" (integer) 1

第二步,查询 TopN。用 ZREVRANGE 拿到榜单前三:

> ZREVRANGE ranking:202401 0 2 WITHSCORES 1) "playerB" 2) "1200" 3) "playerA" 4) "1000" 5) "playerC" 6) "800"

第三步,给某个玩家加分。用 ZINCRBY 在现有 score 上累加:

> ZINCRBY ranking:202401 100 "playerC" "900"

第四步,查某个玩家的排名。ZRANK 返回的是从 0 开始的升序排名,ZREVRANK 返回降序排名:

> ZREVRANK ranking:202401 "playerB" (integer) 0

有了这四步,一个带排名、加分、名次查询的完整排行榜就通了。我在做排行榜时还会注意一个细节:如果数据量很大,ZRANGE 全量取出会导致网络传输压力很大,所以接口层必须要分页。很多新手直接 ZREVRANGE 0 -1 把整个排行榜拖回内存,再在内存里分页,这是非常典型的性能反模式。正确做法是在 Redis 端就用 ZREVRANGE 的 start 和 stop 参数做分页,只拿当前页需要的数据。

6.3 ZSet 做延迟队列的两种思路与同分陷阱

延迟队列是 ZSet 的隐藏神技。思路很简单:把任务的执行时间戳作为 score,消费者用 ZRANGEBYSCORE 去拿"到点该执行"的任务。我常用的实现方式有两种。

方式一:轮询扫描

> ZADD delay:queue 1735689600 "task:1001" > ZRANGEBYSCORE delay:queue 0 1735689600 WITHSCORES LIMIT 0 10

用一个后台线程定时执行 ZRANGEBYSCORE,把 score 小于当前时间的任务取出来,处理完再 ZREM 删除。这个方案简单、可控,适合任务量不大的场景。

方式二:阻塞型,使用 BZPOPMIN 可以阻塞等待"score 最小且已被加入到集合中"的元素弹出。但要注意,BZPOPMIN 弹出的是整个 ZSet 中 score 最小的元素,不是"小于某个阈值的所有元素"。所以如果你想实现严格的延迟队列,更稳妥的还是轮询方式。

我再补充一个坑:ZSet 的 score 是 double 类型,用毫秒时间戳做 score 时精度完全够;但如果你用秒级时间戳,同一秒内大量任务会落到同一个 score,ZRANGEBYSCORE 的范围取法就要额外考虑同秒任务的顺序,否则"同一秒的任务"你可能只取到一部分。做法是把时间戳精确到毫秒,或者在任务里附带一个自增序号来打破同分竞争。

6.4 范围查询与冷热数据的分级处理

ZSet 里还有一个非常强大的能力,就是按 score 范围做切片查询:ZRANGEBYSCORE min max。做冷热数据分级时,可以把数据的"最近活跃时间"作为 score,然后按时间窗口切出"近期活跃"和"沉睡用户"。

> ZADD active:users 1735689600 "u1" > ZADD active:users 1735603200 "u2" > ZRANGEBYSCORE active:users 1735603200 1735689600 1) "u2" 2) "u1"

这个操作直接回答了"哪些用户在过去 24 小时内有活跃行为"。相比用时间戳字段去数据库走索引,ZSet 的优势是全内存、O(log N) 的范围查找、天然按时间排序。在一些用户分层、营销人群圈选的系统里,这种用法非常常见。同样要提醒:ZSet 是内存结构,存几十万上百万元素还好,千万别把全量用户都塞进一个 ZSet,内存和写入压力扛不住。要按业务域拆 key,例如 active:202401、active:202402,每个月一个新 key。

6.5 ZSet 与 List、Set 的选择取舍

最后把决策问题讲清楚。如果你还不知道某个场景该用 List、Set 还是 ZSet,可以按这个思路判断:

  • 需要先进先出、按容量剪裁,优先 List
  • 需要唯一性、快速求交并集、随机抽取,优先 Set
  • 需要按分数排序、按范围取 TopN、带权重取元素,优先 ZSet

一个很好的类比是:List 像排队打饭的队伍,Set 像一个去重后的集合,ZSet 更像一个带分数标签的有序排行榜。它们之间的转换也经常出现:比如你先用 List 收集了一批待处理任务,在去重环节转成 Set,最终需要按优先级执行时再转成 ZSet。Redis 类型不是死的,关键是每种类型擅长解决什么问题。

7. 内部编码、内存优化与命令选型的经验总结

7.1 五种类型的内部编码对照

很多面试题里会问 Redis 的 encoding,但实际调优中它也是最有用的信息,因为直接决定了内存和性能。不同数据类型在不同条件下走不同编码:

数据类型小数据量编码大数据量编码触发转换的关键配置
Stringint / embstrraw字符串长度超过 44 字节走 raw
Hashziplisthashtablehash-max-ziplist-entries / hash-max-ziplist-value
Listquicklist(内含 ziplist)quicklistlist-max-ziplist-size / list-compress-depth
Setintsethashtableset-max-intset-entries
ZSetziplistskiplist + dictzset-max-ziplist-entries / zset-max-ziplist-value

我并不是建议你去背这些配置,而是建议你在内存水位异常时,用 OBJECT ENCODING key 或 DEBUG OBJECT key 去看一个 key 当前的编码:

> OBJECT ENCODING user:1001 "ziplist" > DEBUG OBJECT user:1001 Value at:0x7f9f1c44b920 refcount:1 encoding:ziplist serializedlength:83 lru:4823112 lru_seconds_idle:120

如果发现原本预期走紧凑编码的 key 变成了 hashtable 或 raw,往往是因为某个 value 太大或字段太多触发了转换。这时候你该做的不是盲目调大阈值,而是审视数据模型是否合理。比如一个 Hash 的 field 涨到上万个,那不该调高 ziplist 阈值,而是应该把大 Hash 拆成多个小 Hash,按业务分桶。

7.2 内存优化:从 key 命名到紧凑编码的三个层次

Redis 内存优化的话题很容易被忽视,因为很多人只有当内存报警时才想起看,但那时已经晚了。我给出的优化思路有三个层次。

第一层:控制 key 的数量与长度。一个 key 的名称哪怕只多 20 个字节,1000 万个 key 就多出 200MB 内存,这还不算哈希表本身的膨胀。所以 key 命名要短而可读,比如 user:1001 而不是 full_user_name:1001:profile_cache:001。我看到过长 key 把内存占到 30% 以上的真实案例,教训是命名的长度直接影响内存成本。

第二层:优选紧凑编码。在前面编码对照表里,ziplist、intset、embstr 都是紧凑编码,在数据量小时内存效率远高于 hashtable 和 raw。合理设置阈值,尽量让小数据保持紧凑形态。需要提醒的是,紧凑编码下的读性能未必差,只是写路径更脆,因为写入可能触发编码转换。所以更建议"根据业务规模预估"来设置阈值,而不是拍脑袋把参数调大。

第三层:利用 key 聚合减少元数据开销。Redis 每个 key 都要维护元数据(类型、编码、LRU、引用计数等),key 数量越多,元数据开销越大。把业务上相关联的子数据聚合到一个 Hash/ZSet/Set 里,能显著减少 key 的数量。比如点赞、收藏、评论三个计数,用一个 Hash 存三个 field,比用三个 key 存三个值更省内存。但聚合也要有度,单 key 过大又会导致读写放大,所以"聚合"和"拆分"要根据实际访问模式来平衡。

7.3 O(N) 命令的红线:KEYS、HGETALL、SMEMBERS、LRANGE

Redis 在单线程模型下,一个慢命令会阻塞所有其他命令。常用的 O(N) 命令里,有几个经典红线是必须记住的:

  • KEYS:生产环境等于自杀,千万别用,用 SCAN 代替
  • HGETALL:大 Hash 下会阻塞,用 HSCAN 或按需 HGET
  • SMEMBERS:大 Set 下的全量输出同理,用 SSCAN
  • LRANGE:大 List 下全量输出同理,按需 LRANGE start stop
  • ZRANGE:大 ZSet 下全量输出同理,分页取

这里多说一句:Redis 会为慢命令记录 slowlog,配置 slowlog-log-slower-than 可以设置阈值。实际排障时,我经常用 SLOWLOG GET 定位那些拖垮实例的"罪魁祸首"。建议上线前就把慢日志打开,阈值设在 10 毫秒左右,生产环境一旦出现慢查询就知道谁在捣鬼:

> SLOWLOG GET 10 1) 1) (integer) 102 2) (integer) 1735689600 3) (integer) 2300 4) "SLOWLOG"

7.4 类型选择决策表:遇到业务需求先查这张表

我把常见业务需求直接映射到对应的数据类型,方便你直接抄作业。这份经验汇总不保证覆盖所有场景,但能覆盖八成常见需求:

业务需求推荐数据类型关键命令
缓存单值、分布式锁StringSET / GET / SETNX / SETEX
缓存对象、按字段读写HashHSET / HGET / HGETALL
会话、验证码短期存储StringSETEX / GETDEL
最新动态、时间线ListLPUSH / LTRIM / LRANGE
轻量消息队列ListLPUSH / BRPOP
可靠消息队列Stream(或外部 MQ)XADD / XREADGROUP
用户标签、去重SetSADD / SISMEMBER / SINTER
随机抽奖SetSPOP / SRANDMEMBER
排行榜、TopNZSetZADD / ZINCRBY / ZREVRANGE
延迟队列ZSetZADD / ZRANGEBYSCORE / ZREM
全局唯一计数StringINCR / DECR
大规模存在性判断RedisBloom(布隆过滤器)BF.ADD / BF.EXISTS

这张表是我做技术选型时的第一反应表。它不解决"这个业务到底要不要用 Redis"的问题,只解决"确定了用 Redis 之后该用哪个类型"的问题。接下来再想清楚 key 生命周期、过期策略、与数据库的一致性,整个方案就已经成型了一半。

7.5 从五大类型到 Stream、Bitmaps、HyperLogLog 的延伸

写完五大类型,必须提一句 Redis 不止这五种。官方容易被忽视的还有 Bitmaps、HyperLogLog、Stream、地理空间类型 Geo。它们各有绝活:Bitmaps 做在线状态统计极省内存;HyperLogLog 用 12KB 就能做千万级基数统计;Geo 直接支持附近的人或店铺查询;Stream 则弥补了 List 做可靠消息队列的短板。我平时的习惯是:先用五大类型解决 80% 的需求,再根据场景引入这些专项类型。它们不是越多越好,而是搞清楚每种类型在天平上的位置——内存、准确度、实时性、可靠性,四个维度分别取舍。

8. 实际部署中的几个"翻车现场"与抢救记录

8.1 大 Value 引发的阻塞与拆分

真实翻车案例一:某个老项目里用一个 String key 存储了十几 MB 的 JSON 数据,启动时一次性读取,平时基本不更新。结果某天业务方大量请求这个 key,Redis 响应突然飙到几百毫秒。我用 STRLEN 检查发现 value 已经很大,又用 SLOWLOG 确认 GET 操作本身就是慢查询。之所以慢,不完全是网络传输问题,而是这个 key 的数据在 Redis 内部要经历序列化、内存拷贝、网络输出三个阶段,每个阶段都被放大。后来我把这个 String 拆成多个小 key 按字段存储,或者改用 Hash 按字段读取,问题迎刃而解。这里的关键教训是:Redis 不适合存大对象,超过几百 KB 就要拆。真遇到不能拆的(比如整存整取的外系统接口数据),那就做好缓存失效策略和网络超时,并接受延迟上升的事实。

8.2 过期策略与内存淘汰的"组合拳"

很多人在一个 Redis 实例里同时设置 TTL,又开启了 allkeys-lru 淘汰,结果发现数据早早就被淘汰了,总是丢失。这里有两个机制的名字只差一字但行为截然不同:过期机制(expire)是主动把过期数据删除;内存淘汰(eviction)是在内存达到 maxmemory 时,按策略挑选 key 删除。如果实例内存很小,又开了 allkeys-lru,那么即使 key 还没到 TTL,也可能被 LRU 策略提前淘汰。解决办法依据业务特点来定:缓存型数据可以接受淘汰,用 volatile-lru 或者 allkeys-lru 都行;但像分布式锁、限流计数这种状态型数据,绝不能依赖淘汰策略,一定要设置合理的 TTL 并考虑持久化。

另一个相关的坑是"过期键在从库上不会主动删除"。Redis 主从架构里,过期键的处理是主库负责删除,然后向从库发送 DEL。如果从库被提升为主库,在旧主库发送删除命令之前,从库上的这些数据可能仍然可读——这是主从切换后偶尔出现脏数据的一个原因。要减少这个窗口,需要关注 repl-disable-tcp-nodelay 和同步延迟,或者使用 Redis 7 的 WAITAOF 等机制提升数据一致性。

8.3 用 MEMORY 和 OBJECT 命令做常规体检

真正的排障高手不会等故障发生才动手,而是定期给 Redis 做"体检"。我最常用的三个命令:

第一个是 MEMORY USAGE key,查一个 key 实际占用内存:

> MEMORY USAGE user:1001 (integer) 136

第二个是 INFO memory,查整体内存分布、碎片率 mem_fragmentation_ratio 等:

> INFO memory # Memory used_memory:826032 used_memory_human:806.67K used_memory_rss:2396160 mem_fragmentation_ratio:2.90

碎片率长期大于 1.5 或小于 1,都要关注。大于 1.5 说明内存碎片严重,可以考虑启用 activedefrag 或通过主从切换来整理;小于 1 说明发生了 swap,Redis 开始用磁盘,性能会断崖式下跌。第三个是 OBJECT ENCODING key,查编码是否符合预期。这三个命令组合起来,一个 key 大约 10 秒内就能完成"内存占用、碎片情况、编码状态"的全面体检。

8.4 连接数打满与命令超时的排查链路

还有一次,业务反馈所有接口都变慢,我排查后发现 Redis 连接数暴涨。连接数打满的常见原因有几个:服务端连接池配置过大、客户端没有正确归还连接、慢查询导致单个连接占用时间过长、空闲连接没有及时回收。这些原因的排查顺序我从实际经验里总结成一条链路:先看 deferred 和 rejected 连接数,再用 CLIENT LIST 看连接来源分布,再用 SLOWLOG 慢日志看是否有慢命令,最后看客户端连接池的 maxTotal 和 maxIdle 配置。

这里特别提一个容易被忽略的点:很多客户端连接池的"最大连接数"默认值是大几千,但 Redis 服务端默认 maxclients 只有 10000。当服务实例多、每个实例又用尽连接池时,很容易打满。解决办法是统一规划连接池大小,建议每个服务实例的连接数控制在几百以内,并在客户端配置合理的 minIdle 和 maxIdle,避免连接被无意义地占用。另一个维度是给所有 Redis 命令设置合理的超时时间——连接超时设 200ms,读超时设 500ms,一旦超时立即熔断降级,而不是无限等待,把故障的影响控制在调用方。

9. 写给新手的几条实战心法

9.1 先画数据模型,再写命令

我见过很多新人一上来就敲命令,敲着敲着发现类型选错了,又要重构。正确顺序是:先画出业务数据模型——哪些是单值、哪些是对象、哪些是列表、哪些是集合、哪些需要排序,然后对着五大类型做映射。这个过程不花 10 分钟,但能省下一天的重构时间。

画数据模型时,我的习惯是随手写一个"数据字典",格式很简单:

key: user:1001 类型: Hash field: name / age / city / reg_time 用途: 用户详情缓存,按字段读取 TTL: 1 小时

每个 key 都按这个格式在项目文档里登记,维护成本非常低,但团队协作时能避免很多"这个 key 到底存的啥"的口头沟通。

9.2 一套命令走天下,还是按需组合

Redis 的命令看起来多,但常用的命令其实就是二十来个。我对新手的建议是:别试图背全命令,但要把核心组合用熟。

  • 单值缓存组合:SET + GET + DEL + SETNX + SETEX + EXPIRE
  • 对象缓存组合:HSET + HGET + HGETALL + HDEL + HINCRBY
  • 列表组合:LPUSH + RPOP + LRANGE + LTRIM + LLEN
  • 集合组合:SADD + SREM + SISMEMBER + SINTER + SPOP
  • 有序集合组合:ZADD + ZINCRBY + ZRANGE + ZREVRANGE + ZRANGEBYSCORE

每个组合都有自己的"惯用法",比如"LPUSH + LTRIM 做最新列表""ZADD + ZREVRANGE 做排行榜""SADD 返回值做去重判断"。把惯用法记熟,再配合 SCAN、OBJECT、MEMORY 等运维命令,基本就能应对 95% 的开发场景。

9.3 养成三个好习惯:TTL、命名规范、监控

最后聊聊习惯,技术选型再对,习惯不好也会翻车。我总结成三条,缺一不可。

第一,能设 TTL 一定要设 TTL。缓存型数据如果忘了设过期时间,一旦数据源变化,Redis 里的旧数据就会一直存在,成为"僵尸缓存"。我见过一个项目所有 key 都没设置过期时间,后来数据不一致了,排查了一星期,最后发现是缓存没失效。所有能设 TTL 的 key,都应该设 TTL,哪怕只是 10 分钟。

第二,key 命名要有统一规范。一个可读的命名体系,能让你在排障时不用凭空猜。我常用的规范是"业务域:实体:ID:属性",例如 user:1001:profile:name。这种命名方式的好处是:可以通过前缀用 SCAN 扫描出某个业务域的所有 key;监控平台也能按前缀聚合统计。但注意千万不要用 KEYS 命令去扫,生产环境要用 SCAN。

第三,监控必须前置。至少要有三个指标:命中率(hit_rate)、内存使用率、慢查询数。命中率低了说明缓存设计有问题;内存涨了要提前扩容;慢查询多了要立刻排查。对接 Prometheus 加 Grafana 的 redis_exporter 是社区标配,上线前就接好,别等出了事故再装。

这三个习惯是"零成本,高回报"的典型。很多人把注意力放在选型和命令上,却忽视了这些基础工程能力,最终在真实故障里付出代价。可以说,Redis 用得好不好,一大半取决于这三点有没有做到。

我自己的体会是,把这五种类型全部用熟只是第一层,真正的高手会把它们当作乐高积木一样自然地组合:用 String 存令牌、用 Hash 存对象、用 List 做时间线、用 Set 做去重、用 ZSet 做排序,然后在更复杂的场景里叠加 Stream、HyperLogLog 这些进阶结构。掌握每个结构最擅长解决的那个问题,比死记一百条命令有用得多。

最后再分享一个小技巧:遇到不确定该用哪种类型的方案,先在命令行里用真实数据规模的小样本跑一遍,观察内存和耗时表现,再做决定。这种"先试验后落地"的习惯,能帮你避免很多"以为能用、一上线就出问题"的大型返工。

返回列表