
上一篇我们把 Redis 定位成"数据结构服务器":库存、用户资料、领券记录、销量排行和订单事件,虽然都能放进 Redis,却不应该塞进同一种结构。数据结构一旦选错,轻则代码绕、查询慢,重则一次全量读取或集合运算就堵住主线程。
这也是面试里的高频追问:"Redis 有哪些数据类型,各适合什么场景?"只背"String 做缓存、Hash 存对象、List 做队列"远远不够。面试官真正想听的是:为什么这个访问模式适合这种结构,换一种会付出什么代价,以及到了生产环境有哪些边界。尤其是"List 能不能做消息队列""Hash 和 JSON 字符串怎么选",答案都不是一句口诀。
这篇按"先看访问模式,再选数据结构"这条线索展开:先建立统一的选型方法,再依次拆解 String、Hash、List、Set、Sorted Set 和 Stream,最后补上 Bitmap、HyperLogLog、Geo,并用一张选型表把它们放回电商业务。
目录
- 选数据结构不是背命令,而是先看访问模式
- String:缓存、计数器与简单状态
- Hash:对象字段与局部更新
- List:有序序列与简单队列
- Set:去重、成员判断与集合运算
- [Sorted Set:排行榜、优先级与范围查询](#Sorted Set:排行榜、优先级与范围查询)
- Stream:可追踪、可确认的消息流
- [Bitmap、HyperLogLog、Geo 与最终选型表](#Bitmap、HyperLogLog、Geo 与最终选型表)
一、选数据结构不是背命令,而是先看访问模式
业务数据长什么样,只决定了一半答案;另一半取决于它会被怎样访问。同一份商品信息,可以是一段整体读写的 JSON,也可以是一组需要单独修改的字段。两者看起来都在"存对象",访问模式却完全不同。
选择前先回答五个问题:
- 读写粒度是什么? 每次整体读写,还是只读、只改某几个字段?
- 是否要求唯一? 是允许重复的序列,还是需要天然去重的集合?
- 是否要求顺序? 按插入顺序、按业务分数排序,还是根本不关心顺序?
- 主要查询是什么? 按 Key 取值、判断成员、求交集、查 Top N,还是按范围读取?
- 消费后要不要确认和恢复? 只是取走一个任务,还是必须知道消息由谁处理、哪些尚未确认?
可以先用一张极简地图建立直觉:

| 访问模式 | 优先考虑 | 典型业务 |
|---|---|---|
| 一个 Key 对应一个值,整体读写 | String | 商品详情 JSON、验证码、库存计数 |
| 一个对象包含多个可独立访问的字段 | Hash | 商品属性、用户会话 |
| 保留插入顺序,从两端操作 | List | 最新记录、简单任务队列 |
| 成员唯一,做存在判断或集合关系 | Set | 已领券用户、共同关注 |
| 成员唯一,还要按分数排序 | Sorted Set | 销量榜、优先级任务 |
| 事件持续追加,需要消费组、确认与恢复 | Stream | 订单事件流 |
这里有两个容易忽略的前提。
第一,Redis 的数据类型是 Key 对应的 Value 类型 ,Key 本身仍是字符串。第二,Redis 命令的快慢不能只看复杂度字母,还要乘上真实数据规模。HGETALL、SMEMBERS、大范围 ZRANGE 都可能随着返回元素增多而变重;一次向客户端回传几十万个成员,即使查找过程不复杂,网络和序列化成本也不会消失。
选型的核心不是"Redis 能不能做",而是让服务端原生命令直接表达最频繁的业务动作,并让单次操作的成本可控。 有了这把尺子,再看各类型就不会学成命令清单。
二、String:缓存、计数器与简单状态
String 是 Redis 最基础、覆盖面也最广的类型。它保存的是二进制安全的字节序列,所以内容既可以是普通文本、数字和 JSON,也可以是序列化后的二进制数据。名字叫 String,不代表它只能放"字符串"。
2.1 整体读写:商品详情缓存
如果商品详情总是作为一个整体读取和刷新,把它序列化成 JSON 放进 String 最直接:
redis
SET product:detail:1001 '{"name":"机械键盘","price":39900,"stock":500}' EX 600
GET product:detail:1001
这种方案的优点是应用侧模型清晰,一次 GET 就拿到完整对象;序列化格式也不受 Redis 字段模型约束。代价是只改价格时仍要读取或重新生成整个值,而且并发"读出---修改---写回"可能互相覆盖。
所以 String 适合整体读、整体写的对象缓存;如果频繁局部更新字段,Hash 通常更自然。 不过别只为了"对象"两个字就机械换成 Hash。如果每次接口都要完整返回商品,Hash 最终还是要读取全部字段,未必比一段紧凑的序列化结果更划算。
2.2 原子增减:计数器与库存
Redis 能把保存为整数形式的 String 直接做原子增减:
redis
SET product:stock:1001 500
INCRBY product:stock:1001 -1
INCR product:view:1001
INCR、INCRBY 的价值不只是少写一行代码,而是把"读取旧值、计算新值、写回"收进一条原子命令,多个客户端并发执行不会丢失更新。
但原子减一不等于完整的秒杀方案。库存为 0 时,INCRBY ... -1 仍然可以减成负数;"先 GET 判断,再 DECR"又不是一个原子整体。要保证库存不能小于 0,需要 Lua 或其他原子组合,同时还要处理订单落库、回滚和幂等。《Redis 07 · 原子操作:事务、Pipeline、Lua 与 Functions》会继续拆这个问题。
2.3 条件写入:锁、幂等与短期状态的积木
SET 可以把"仅不存在时写入"和"过期时间"组合在一条命令里:
redis
SET order:idempotent:req-8f31 1 NX EX 60
SET login:code:13800000000 482913 EX 300
第一条适合做短期幂等标记,第二条适合验证码一类短期状态。这里的关键是 写入与设置 TTL 在同一条命令中完成,避免进程恰好在两条命令之间崩溃,留下永不过期的 Key。
SET NX EX 也是实现分布式锁的基础积木,但"抢到一个带超时的 Key"不等于锁方案已经完整。锁的唯一标识、安全释放、业务超时、续期和故障切换都有额外边界,第 13 篇再展开。
生产中还要留意三件事:
- 不要把超大 JSON、图片或文件直接塞进 String;单个值即使允许很大,也会放大网络、复制、持久化和删除成本;
- 计数器要考虑整数范围、Key 生命周期和热点写入;
- 缓存值要有版本兼容和反序列化失败的处理策略。
String 解决的是"一个 Key 对应一个整体值"。当业务开始要求只读价格、只改库存、只让会话中的某个字段过期,问题就从整体读写变成了字段访问。
三、Hash:对象字段与局部更新
Hash 可以理解成 Redis Key 下面再挂一张字段表:外层 Key 定位对象,field 定位属性,value 保存属性值。
redis
HSET product:1001 name "机械键盘" price 39900 stock 500
HGET product:1001 price
HMGET product:1001 name price stock
HINCRBY product:1001 stock -1
它特别适合两个条件同时成立的场景:对象由多个字段组成,而且调用方经常只访问或修改其中一部分。 例如后台只更新商品价格,库存服务只修改库存,详情接口再读取所需字段,不必每次传输和重写整个对象。
3.1 Hash 和 JSON String 怎么选
这是一道很常见的面试题,不能简单回答"存对象就用 Hash"。
| 对比维度 | JSON String | Hash |
|---|---|---|
| 读写粒度 | 整体读写自然 | 字段读写自然 |
| 应用模型 | 一次反序列化为完整对象 | 需要处理 field/value 映射 |
| 局部更新 | 通常要重写整个值 | HSET 可直接改字段 |
| 网络传输 | 完整对象每次传输 | 可以只取需要的字段 |
| 复杂嵌套 | JSON 表达更自然 | 通常需要拆平或再次序列化 |
| 并发修改 | 整体覆盖容易丢更新 | 不同字段可独立修改 |
如果商品详情始终整块返回,JSON String 往往更省心;如果购物车里每个商品的数量要频繁独立增减,Hash 更贴合访问方式:
redis
HSET cart:user:42 product:1001 2 product:2008 1
HINCRBY cart:user:42 product:1001 1
HGET cart:user:42 product:1001
3.2 Hash 不是免费的"小表"
把 Hash 类比成一张小表有助于理解字段访问,但不要把它当关系型数据库。它没有任意条件查询、二级索引和 JOIN;你仍然要先知道外层 Key,才能访问其中字段。
HGETALL 会返回全部字段,成本会随字段数量和响应大小增长。会无限增加字段的购物车、设备状态或用户属性,不能每次都全量拉取;需要渐进遍历时使用 HSCAN,同时给对象规模设置边界。
另一个常见误区是 TTL。传统上过期时间主要挂在整个 Key 上,给 product:1001 设置 TTL 会让整张 Hash 一起过期,而不是只让 price 过期。新版本 Redis 已提供 Hash 字段级过期能力,但客户端支持、部署版本和迁移兼容必须确认,不能拿新语法默认所有生产环境都可用。
版本提示:Hash 字段级过期命令是 Redis 7.4 引入的能力;如果专栏基线内还要兼容更早的 7.x,设计时仍应按"TTL 作用于整个 Hash Key"处理,或把不同生命周期的数据拆成不同 Key。
Hash 在字段维度组织对象,List 则换了一条轴:它关心的是元素的先后顺序。
四、List:有序序列与简单队列
Redis List 是按插入顺序排列的字符串序列,可以从头尾两端推入和弹出元素。它最自然的操作不是"按下标随机访问",而是"维护两端不断变化的队列"。
redis
LPUSH user:42:recent-products 1001
LPUSH user:42:recent-products 2008
LTRIM user:42:recent-products 0 99
LRANGE user:42:recent-products 0 9
这组命令维护用户最近浏览的 100 个商品,并取出最新 10 个。LPUSH 后紧跟 LTRIM 很常见,因为 任何会持续增长的 List 都必须有容量治理;否则"最近记录"迟早变成"所有历史记录"。如果要求两条命令不可被其他操作穿插,还要用事务或脚本组合。
4.1 List 适合从两端操作,不适合任意位置查询
List 可以按下标读取,也可以取范围,但越深入中间位置,访问成本通常越高。把它当数组频繁执行大下标 LINDEX,或者一次 LRANGE 0 -1 拉出全部历史,都会偏离它的优势。
List 更适合:
- 最新 N 条记录;
- 简单的先进先出或后进先出序列;
- 生产者推入、消费者阻塞弹出的轻量任务分发。
redis
LPUSH order:queue order-90001
BRPOP order:queue 5
BRPOP 在队列为空时可以阻塞等待,避免消费者不断轮询。若任务处理需要更可靠的交接,可以用 LMOVE 把元素原子转入"处理中"列表,再由业务完成后删除;这比直接弹出后立刻从 Redis 消失多了一层补救空间。
4.2 List 能做队列,但不等于可靠消息系统
直接弹出有一个明显窗口:消费者拿到任务后、业务处理完成前宕机,任务已经离开队列,却没有处理成功。即便增加"处理中"列表,消费者身份、超时任务认领、投递次数和积压观测也要自行设计。
因此可以这样划线:只要求有序交接、失败可容忍或业务能自行补偿时,List 足够简单;要求消费组、显式确认、失败认领和待处理追踪时,优先考虑 Stream 或专业消息队列。

List 解决了顺序,却不保证元素唯一。领券名单、共同关注和标签关系更关心"成员是否存在",这正是 Set 的位置。
五、Set:去重、成员判断与集合运算
Set 是无序且成员唯一的集合。重复添加同一个成员不会产生第二份,因此它天然适合去重和存在性判断。
redis
SADD coupon:88:users 42
SADD coupon:88:users 42
SISMEMBER coupon:88:users 42
SCARD coupon:88:users
用户 42 即使重复提交,集合里仍只有一个成员。SISMEMBER 可以直接回答"是否领过券",SCARD 可以统计领取人数。这比把用户 ID 拼成一个 JSON 数组,再由应用读取、遍历、去重自然得多。
5.1 集合运算把关系计算留在服务端
Set 还能求交集、并集和差集:
redis
SINTER campaign:a:users campaign:b:users
SDIFF coupon:88:eligible coupon:88:users
第一条找出同时参加两个活动的用户,第二条找出有资格但尚未领券的用户。服务端原生命令让业务语义非常清楚,不必把两个集合都拉到应用内存再计算。
但"命令方便"不等于"规模不限"。集合运算需要读取和比较多个集合,成本随参与集合及结果规模增长;结果本身很大时,返回网络也会成为压力。线上不要对不受控的大集合随意执行 SMEMBERS、SINTER,尤其不能把它们放在高频接口里。
生产设计通常会补三条约束:
- 给 Set 的成员规模设上限并持续监控;
- 只需渐进查看成员时使用
SSCAN,不要一次取完; - 大规模离线关系计算交给更适合的计算系统,Redis 保存最终结果或热点子集。
5.2 无序是语义,不要依赖返回顺序
Set 的输出顺序不构成稳定契约。今天看起来按某种顺序返回,不代表下一次或数据规模变化后仍然如此。需要按时间、销量或权重排序时,不要额外在客户端猜顺序,而应该直接使用 Sorted Set。
六、Sorted Set:排行榜、优先级与范围查询
Sorted Set 和 Set 一样保证成员唯一,但每个成员还关联一个 score,Redis 按分数维护顺序。它解决的不是普通排序,而是 数据持续变化时仍要高效地更新名次、查 Top N 和按分数范围读取。
6.1 排行榜:成员是商品,分数是销量
redis
ZINCRBY sales:rank 1 product:1001
ZINCRBY sales:rank 3 product:2008
ZRANGE sales:rank 0 9 REV WITHSCORES
ZREVRANK sales:rank product:1001
每卖出一件商品就增加对应分数,读取榜单时直接取分数最高的前 10 名。与"把所有商品销量读出来再排序"相比,Sorted Set 把维护有序关系变成了数据结构本身的职责。
成员必须唯一。如果同一个商品需要同时进入"日榜"和"周榜",通常建立两个 Key,而不是在一个 Sorted Set 里给成员保存两份分数:
text
sales:rank:2026-08-17
sales:rank:2026-W34
分数相同时,成员会按字典序进一步排序。因此业务若要求"同销量时先达到者排名更高",不能指望 Redis 自动理解这个规则,需要把时间等信息编码进排序方案,或者接受业务层二次处理。复合分数要特别谨慎:浮点精度、数值范围和更新规则都必须可证明,不能靠拼一个"大概能排"的数字。
6.2 分数也可以表示时间
当 score 保存执行时间戳,Sorted Set 就能表达"哪些任务已经到期":
redis
ZADD order:timeout 1786939200000 order-90001
ZRANGE order:timeout -inf 1786939200000 BYSCORE LIMIT 0 100
消费者查询当前时间之前的订单,拿到需要关闭的超时任务。这是延迟任务的一种常见实现,但查询、领取和删除必须避免多个消费者重复处理,通常需要 Lua 做原子认领;消费者停机后的补偿、持久化和集群故障也要评估。
所以 Sorted Set 可以做规模可控的延迟调度,却不自动等于完整的延迟消息平台。数据结构只解决"按时间排序和范围查询",不会替业务解决可靠投递。
6.3 控制范围,别让 Top N 变成 All
Sorted Set 擅长范围查询,但返回成本仍与结果数量有关。Top 10 很轻,不代表 ZRANGE 0 -1 读取百万成员也轻。排行榜应明确窗口,历史榜单应设置保留周期,超大集合还要考虑拆分。
从 List 到 Sorted Set,我们处理的仍然是"元素集合"。订单事件则多了另一个维度:消息不仅要有顺序,还要知道消费进度和处理结果。
七、Stream:可追踪、可确认的消息流
Stream 是持续追加的消息日志。每条消息有唯一 ID 和多个 field/value,既可以按 ID 范围读取,也可以通过消费者组把消息分发给不同消费者。
redis
XADD order:events * orderId 90001 status created
XADD order:events * orderId 90001 status paid
XRANGE order:events - + COUNT 10
* 让 Redis 生成消息 ID。ID 通常体现时间顺序,但业务不应把它当订单号;订单号仍然放在消息字段里。
7.1 消费者组解决了什么
创建消费者组后,多个消费者可以分担同一条 Stream 中的新消息:
redis
XGROUP CREATE order:events fulfillment 0 MKSTREAM
XREADGROUP GROUP fulfillment worker-1 COUNT 10 BLOCK 5000 STREAMS order:events >
XACK order:events fulfillment <message-id>
消费者通过 XREADGROUP 读取消息后,消息不会因为"被读过"就彻底消失。Redis 会把已投递但尚未确认的消息记录在消费者组的 Pending Entries List(PEL,待处理条目列表) 中;业务处理完成后再用 XACK 确认。
这正是 Stream 和简单 List 队列最关键的区别:
| 能力 | List 队列 | Stream 消费者组 |
|---|---|---|
| 顺序保存 | 支持 | 支持,消息带 ID |
| 多消费者分工 | 可以弹出竞争 | 消费者组原生支持 |
| 显式确认 | 无原生 ACK | XACK |
| 未确认追踪 | 需要自建处理中列表 | PEL 原生记录 |
| 故障后认领 | 需要自行设计 | 可检查并认领超时消息 |
| 历史范围读取 | 不自然 | 可按 ID 范围读取 |
当 worker-1 读取后宕机,运维或其他消费者可以用 XPENDING 检查积压,再用 XAUTOCLAIM 认领空闲超过阈值的消息。这套机制提供了恢复抓手,但不会自动替你完成恢复流程。
7.2 有 ACK 也不等于恰好一次
考虑这样一个时序:消费者已经把订单写入数据库,但还没来得及 XACK 就宕机。消息稍后被重新认领,业务会再处理一次。因此 Stream 常见的是 至少一次投递语义,消费者仍要根据订单号或事件 ID 做幂等。
反过来,如果业务还没真正完成就提前 ACK,随后宕机,消息不会再出现在 PEL 中,可能造成丢处理。正确顺序通常是:读取消息,幂等执行业务,业务成功后 ACK;失败消息的重试次数和最终去向也要明确。
7.3 Stream 也必须限制长度
追加日志如果永不裁剪,最终一定吃满内存。生产中应根据保留时间、消息速率和故障恢复窗口设计裁剪策略,例如追加时设置近似最大长度:
redis
XADD order:events MAXLEN ~ 100000 * orderId 90002 status created
~ 表示近似裁剪,通常比精确维护长度更高效,但实际条目数可能略高于目标。裁剪前还要确认消费者是否允许落后到保留窗口之外;如果消息承担审计、跨地域传输、超长积压或严格可靠性要求,应评估 Kafka、RabbitMQ 等专业消息系统,而不是仅凭 Redis 已经在线就顺手复用。
Stream 提供的是消息 ID、消费组、PEL、ACK 和故障认领这些可靠消费积木,不是"零配置的恰好一次消息队列"。 这是面试回答里最值得说清的一层。
八、Bitmap、HyperLogLog、Geo 与最终选型表
前六种类型覆盖了大多数业务,但 Redis 还提供几类面向特定问题的结构。它们不是"高级版通用容器",而是用更强的约束换取空间或查询优势。
8.1 Bitmap:把二值状态压进每一个 bit
Bitmap 适合用户在某一天是否签到、某个功能是否开启这类只有 0/1 的状态。它本质上是在 String 上按位操作,把用户编号映射为偏移量:
redis
SETBIT attendance:2026-08-17 42 1
GETBIT attendance:2026-08-17 42
BITCOUNT attendance:2026-08-17
如果用户 ID 稠密且能稳定映射为较小的非负整数,Bitmap 非常节省空间;如果 ID 极度稀疏,例如直接拿一个很大的随机 ID 当偏移量,中间空洞也要占据地址空间,优势就没了。它只能表达 bit 状态,不能顺便保存签到时间、渠道等复杂属性。
多个日期的 Bitmap 还能通过位运算计算连续签到或活跃交集,但同样要评估位图长度和运算频率。Bitmap 适合稠密编号的二值状态,不是 Set 的无条件替代品。
8.2 HyperLogLog:用近似值换极小空间
如果只关心商品详情页"有多少独立访客",不关心访客具体是谁,可以把用户 ID 加入 HyperLogLog:
redis
PFADD product:1001:uv user:42 user:73
PFCOUNT product:1001:uv
它返回的是基数估算值,不保证绝对精确。优势是内存占用不会随唯一用户数线性膨胀,适合 UV、独立设备数等海量去重计数;代价是不能列出成员,也不能准确判断某个用户是否已经出现。
因此选择很清楚:需要精确人数、成员判断或拿回名单,用 Set;只要大规模去重后的近似数量,用 HyperLogLog。误差是否可接受是业务条件,不是技术人员替业务默认做的决定。
8.3 Geo:围绕经纬度做附近查询
Geo 用来保存经纬度并做距离、半径或边界范围查询,例如查找用户附近的门店:
redis
GEOADD store:locations 121.4737 31.2304 store:shanghai-001
GEOSEARCH store:locations FROMLONLAT 121.4800 31.2200 BYRADIUS 5 km WITHDIST ASC COUNT 20
Geo 底层建立在 Sorted Set 之上,所以同一个 Key 也遵循有序集合的成员语义。它适合"附近有哪些点、距离多远"这类查询,不负责复杂路线规划、道路通行时间和多条件地理分析;后者通常需要专业地图服务或空间数据库。
8.4 一张表完成最终选型
把整篇压缩成一张生产选型表:
| 业务需求 | 推荐结构 | 关键理由 | 主要边界 |
|---|---|---|---|
| 商品详情整体缓存 | String | 一次读写完整序列化对象 | 局部更新要重写,控制值大小 |
| 秒杀库存、浏览量 | String | 原子增减 | 条件判断需脚本,热点 Key 要治理 |
| 商品字段、购物车数量 | Hash | 字段独立读写和增减 | 防止字段无限增长,慎用 HGETALL |
| 最近浏览、简单任务队列 | List | 保序且擅长两端操作 | 随机访问弱,可靠消费能力有限 |
| 已领券用户、关系去重 | Set | 唯一成员和成员判断 | 大集合全量读取、集合运算可能很重 |
| 销量榜、到期任务 | Sorted Set | 按 score 排序和范围查询 | 控制返回范围,可靠认领需额外设计 |
| 订单事件流 | Stream | 消费组、PEL、ACK、故障认领 | 消费者仍需幂等,必须治理积压和长度 |
| 每日签到状态 | Bitmap | 稠密编号下按 bit 压缩 | 稀疏大偏移浪费,只能表达二值状态 |
| 商品 UV | HyperLogLog | 小空间估算海量基数 | 有误差,不能返回具体成员 |
| 附近门店 | Geo | 经纬度距离和范围查询 | 不适合路线规划和复杂 GIS |
最后再校准一个常见误区:数据结构不是选定后永远不变。业务早期的简单任务队列可能用 List 就够了;后来加入多消费组、失败认领和积压追踪,就该迁到 Stream。商品缓存原本整块刷新适合 String;后来价格和库存由不同服务高频更新,也可能拆成 Hash 或多个职责更单一的 Key。
**好的选型不是追求"最强的数据结构",而是让当前最重要的访问模式最直接,同时给规模增长和语义升级留出迁移边界。
这一篇没有把 Redis 数据类型写成命令手册,而是用访问模式把它们串了起来:整体值交给 String,字段访问交给 Hash,两端有序序列交给 List,唯一成员关系交给 Set,带分数的顺序交给 Sorted Set,需要追踪与确认的事件交给 Stream;Bitmap、HyperLogLog 和 Geo 则分别用更强约束解决二值状态、近似基数和地理位置问题。
带走四句话:① 先问读写粒度、唯一性、顺序、查询方式和消费语义,再选 Redis 数据结构;② String 与 Hash 的分界不在"是不是对象",而在整体读写还是字段读写;③ List 能做简单队列,但需要 ACK、待处理追踪和故障认领时,Stream 更贴合问题;④ 任何 O(1) 或范围命令都不能脱离数据规模、返回数量和网络成本谈安全。
下一篇,我们继续往下一层走------《Redis 03 · 底层原理:RedisObject 与核心编码》。同样是 Hash、List 或 Sorted Set,为什么数据少和数据多时内部实现可能不同?SDS、dict、listpack、quicklist、intset 和 skiplist 又怎样在内存、查找与更新成本之间做权衡?