Redis 不只是一个缓存数据库。理解它的数据结构,实际上是在学习如何针对不同业务场景设计高效的数据存储方案。
在日常后端开发中,我们经常会遇到这些需求:
- 用户登录后,需要缓存用户信息。
- 一篇文章需要记录点赞人数,并且不能重复点赞。
- 游戏需要实时更新玩家排行榜。
- 电商系统需要实现购物车。
- 用户每天签到,需要统计连续签到天数。
- 网站每天有百万访问,需要统计独立访客数量。
- 订单创建后,需要异步通知其他服务。
这些场景都可以使用 Redis 实现,但并不是所有场景都适合使用 String。
Redis 提供了多种数据结构,每种数据结构都有自己的优势。选择合适的数据结构,不仅可以减少代码复杂度,还能显著改善性能和内存使用效率。
一、Redis 数据结构总览
首先建立整体认识:
| 数据结构 | 核心特点 | 典型场景 |
|---|---|---|
| String | 单值存储、原子计数 | 缓存、验证码、计数器、分布式锁 |
| Hash | 字段和值的映射 | 用户信息、购物车、对象缓存 |
| List | 有序、可重复、双端操作 | 最新消息、简单任务队列 |
| Set | 无序、不重复、集合运算 | 点赞、共同好友、抽奖 |
| ZSet | 不重复、按 score 排序 | 排行榜、热度榜、延迟任务 |
| Bitmap | 位操作、节省空间 | 签到、活跃状态 |
| HyperLogLog | 近似去重计数 | 网站 UV、独立设备统计 |
| GEO | 地理位置查询 | 附近门店、附近骑手 |
| Stream | 消息流、消费者组 | 异步任务、事件处理 |
下面从真实业务出发,逐个分析。
二、String:缓存、计数器、验证码与分布式锁
String 是 Redis 最基础、使用频率最高的数据结构。
虽然名字叫 String,但它可以保存字符串、整数、序列化后的 JSON 等数据。
场景一:缓存用户信息
假设我们有一个用户查询接口:
bash
GET /api/users/1001
传统查询流程:
- 前端请求后端。
- 后端查询 MySQL。
- MySQL 返回用户数据。
- 后端返回响应。
如果用户信息访问频繁,每次查询数据库就会产生不必要的压力。
我们可以引入 Redis:
markdown
客户端
↓
后端服务
↓
Redis 查询
├── 命中 → 直接返回
└── 未命中 → MySQL 查询
↓
写入 Redis
↓
返回结果
代码示例:
py
import json
async def get_user(user_id: int):
key = f"user:{user_id}"
# 1. 查询缓存
cached = await redis.get(key)
if cached is not None:
return json.loads(cached)
# 2. 查询数据库
user = await db.get_user(user_id)
if user is None:
return None
# 3. 写入缓存,设置过期时间
await redis.set(
key,
json.dumps(user),
ex=3600
)
return user
这就是经典的 Cache Aside(旁路缓存)模式。
需要注意:修改用户信息时,还要处理缓存一致性。常见做法是先更新数据库,再删除对应缓存,并针对并发读写设计额外保护。
场景二:接口访问次数统计
例如统计文章浏览量:
less
SET article:1001:views 0
INCR article:1001:views
GET article:1001:views
INCR 是 Redis 的原子操作。
这意味着多个请求同时执行自增时,不会像普通的"读取 → 加一 → 写回"操作那样发生丢失更新。
例如:
css
请求 A ── INCR ── 101
请求 B ── INCR ── 102
请求 C ── INCR ── 103
这比应用层自己维护一个计数变量更适合多实例部署的场景。
不过,如果需要将浏览量永久保存到 MySQL,还需要考虑异步同步、数据丢失风险以及最终一致性。
场景三:验证码
sql
SET verification:email:123456 "839201" EX 300
表示验证码有效期为 300 秒。
验证时:
sql
GET verification:email:123456
实际业务中还需要限制发送频率、验证失败次数,并在验证成功后删除验证码。
场景四:分布式锁
假设系统部署了三个后端实例,都可能处理同一个订单。
为了避免重复执行某些互斥操作,可以使用:
sql
SET lock:order:1001 unique-request-id NX PX 10000
参数含义:
NX:只有 key 不存在时才能设置成功。PX 10000:10 秒后自动过期。unique-request-id:锁的唯一持有者标识。
但是获取锁只是第一步。
释放锁时,必须判断锁是否仍属于当前请求,并且将"比较 + 删除"作为原子操作执行。
此外,还需要考虑锁过期后任务仍在运行的问题。对于严格一致性的场景,可能需要 fencing token 或数据库层面的并发保护。
小结:String 适合保存一个整体值,以及需要原子数值操作的业务。
三、Hash:用户对象与购物车
Hash 可以理解为一个 Redis Key 对应多个字段。
例如:
yaml
user:1001
├── name: Tom
├── age: 20
└── email: tom@example.com
场景一:用户资料缓存
sql
HSET user:1001 name Tom age 20 email tom@example.com
读取单个字段:
sql
HGET user:1001 name
修改年龄:
sql
HSET user:1001 age 21
获取全部字段:
sql
HGETALL user:1001
与 String 存 JSON 相比,Hash 的优势是可以单独修改字段,而不需要重新序列化整个对象。
但并不意味着所有对象都应该用 Hash。
如果业务经常整体读取、整体更新,String + JSON 也很合理。
场景二:电商购物车
假设用户购物车中有三件商品:
makefile
cart:user:1001
├── product:101 → 2
├── product:102 → 1
└── product:103 → 5
可以使用 Hash:
sql
HSET cart:user:1001 product:101 2
HSET cart:user:1001 product:102 1
HSET cart:user:1001 product:103 5
用户点击商品数量加一:
sql
HINCRBY cart:user:1001 product:101 1
获取购物车:
sql
HGETALL cart:user:1001
删除商品:
sql
HDEL cart:user:1001 product:102
这里的好处是,我们不需要每次都读取整个购物车对象,然后修改 JSON 再写回。
注意: 购物车的价格、库存等关键数据不能仅依赖缓存,提交订单时仍然需要根据权威数据重新校验。
四、List:最新消息列表与简单任务队列
List 是有序、允许重复的集合,支持从两端插入和弹出数据。
常见命令:
LPUSH
RPUSH
LPOP
RPOP
LRANGE
LTRIM
场景一:保存用户最近 100 条操作记录
例如用户最近的搜索关键词:
sql
LPUSH search:user:1001 "Redis"
LPUSH search:user:1001 "MySQL"
LPUSH search:user:1001 "LangGraph"
获取最近 10 条:
sql
LRANGE search:user:1001 0 9
为了防止列表无限增长:
sql
LTRIM search:user:1001 0 99
只保留最新的 100 条。
这个场景非常适合 List,因为我们只关心顺序和最近的数据。
但如果需要复杂筛选、长期保存和分页,通常还需要数据库支持。
场景二:简单任务队列
假设用户上传文件后,需要异步处理:
上传服务
↓
Redis List
↓
后台 Worker
↓
文件处理
生产者:
arduino
LPUSH file:tasks "task-1001"
消费者可以阻塞等待:
BRPOP file:tasks 0
当队列为空时,消费者不需要持续轮询。
不过,普通 BRPOP 弹出后,如果消费者突然崩溃,任务就可能丢失。
因此 List 更适合简单、允许一定失败处理成本的任务场景。需要消息确认、失败重试、消费者组时,应该考虑 Stream。
五、Set:点赞、共同好友和抽奖
Set 最大的特点是:
元素不重复,并且支持交集、并集、差集。
场景一:文章点赞
例如:
less
SADD article:1001:likes user:1
SADD article:1001:likes user:2
SADD article:1001:likes user:1
虽然 user:1 执行了两次添加,但集合中只有一份。
查询点赞人数:
css
SCARD article:1001:likes
判断某用户是否点赞:
css
SISMEMBER article:1001:likes user:1
取消点赞:
css
SREM article:1001:likes user:1
如果产品只要求"当前是否点赞",Set 就很合适。
如果还需要记录点赞时间、点赞来源等详细信息,可以配合数据库或其他数据结构。
场景二:共同好友
假设:
css
用户 A 的好友:
{张三, 李四, 王五}
用户 B 的好友:
{李四, 王五, 赵六}
Redis 可以直接计算交集:
less
SINTER friends:A friends:B
结果:
李四
王五
同样:
less
SUNION friends:A friends:B
得到并集。
less
SDIFF friends:A friends:B
得到 A 有而 B 没有的好友。
这也是为什么 Set 很适合社交关系、标签系统和兴趣匹配。
场景三:抽奖
假设某活动所有符合条件的用户 ID 都存在 Set 中:
css
SADD lottery:participants user1 user2 user3
随机抽取一名用户并从集合中移除:
css
SPOP lottery:participants
这样可以避免同一个用户被重复抽中。
但正式抽奖系统仍需要考虑参与资格、审计记录、并发流程及公平性要求。
六、ZSet:排行榜、热度榜与延迟队列
ZSet(Sorted Set)可以理解为:
Set + Score(排序分值)。
每个成员都关联一个 score,Redis 可以根据 score 排序。
场景一:游戏排行榜
例如:
yaml
ZADD game:ranking 1500 userA
ZADD game:ranking 2800 userB
ZADD game:ranking 1900 userC
查询分数最高的前三名:
ZREVRANGE game:ranking 0 2 WITHSCORES
增加分数:
ZINCRBY game:ranking 100 userA
查询某个用户的排名:
ZREVRANK game:ranking userA
排名从 0 开始。
这种方案特别适合需要频繁更新分数和查询 Top N 的业务。
场景二:文章热度榜
可以将文章的点赞、评论、浏览等行为转换为热度分数:
markdown
热度 = 浏览数 × 1
+ 点赞数 × 5
+ 评论数 × 10
然后使用:
less
ZADD article:hot 350 article:1001
查询热门文章:
css
ZREVRANGE article:hot 0 9 WITHSCORES
实际业务中通常还会加入时间衰减,避免老文章长期占据排行榜。
场景三:延迟任务
假设订单创建 30 分钟后仍未支付,需要自动取消。
可以使用时间戳作为 score:
less
order:1001 → 到期时间戳
order:1002 → 到期时间戳
order:1003 → 到期时间戳
写入:
sql
ZADD order:delay 1760000000 order:1001
Worker 定期获取已到期任务:
arduino
ZRANGEBYSCORE order:delay -inf 1760000000
但这里只是读取,不会自动删除任务。
如果多个 Worker 同时执行,就可能重复处理同一个订单。
因此需要通过 Lua 脚本或其他原子领取机制来解决竞争问题,同时设计任务确认、重试与恢复逻辑。
对于复杂可靠的延迟消息业务,也可以选择专业消息队列。
七、Bitmap:签到系统
Bitmap 特别适合存储大量只有两种状态的数据。
例如:
签到:1
未签到:0
假设用户一个月有 31 天签到记录。
如果每一天单独存一个 Redis Key,会产生较多额外开销。
Bitmap 可以把这些状态压缩到一个字符串的不同 bit 位上。
场景:用户每日签到
使用:
r
SETBIT sign:user:1001:202610 0 1
表示用户在 10 月 1 日签到。
第二天:
r
SETBIT sign:user:1001:202610 1 1
检查某天是否签到:
r
GETBIT sign:user:1001:202610 1
统计本月签到次数:
r
BITCOUNT sign:user:1001:202610
因为一个 bit 就可以保存一个签到状态,所以非常节省空间。
不过,如果业务还需要签到地点、奖励、签到时间等信息,Bitmap 就不足以保存全部数据,需要搭配其他存储方式。
八、HyperLogLog:百万用户 UV 统计
假设一个网站每天有 100 万独立访客。
我们希望统计:
今天有多少个不同用户访问过网站?
最直观的方案是 Set:
SADD visitors:today user1
SADD visitors:today user2
然后:
SCARD visitors:today
但 Set 需要保存所有不同的用户 ID,数据量越大,占用内存越多。
HyperLogLog 解决的就是这个问题。
场景:每日 UV 统计
PFADD uv:20261008 user1
PFADD uv:20261008 user2
PFADD uv:20261008 user1
统计:
PFCOUNT uv:20261008
HyperLogLog 不保存完整的用户集合,而是通过概率算法估算基数。
它的优势是空间占用很小,单个结构通常约 12 KB,标准误差约为 0.81%。
代价是结果并非完全精确,也无法列出所有访问用户。
所以:
- 要准确统计、判断成员是否存在:Set。
- 只需要估算海量独立用户数量:HyperLogLog。
九、GEO:附近门店和骑手定位
GEO 适合处理经纬度数据。
例如外卖平台:
markdown
用户当前位置
↓
查询 3km 范围内的门店
↓
返回距离最近的门店
添加门店:
GEOADD stores 116.397 39.908 storeA
GEOADD stores 116.407 39.918 storeB
查询某个位置附近 3km 内的门店:
sql
GEOSEARCH stores
FROMLONLAT 116.400 39.910
BYRADIUS 3 km
ASC
这里的命令需要作为一条完整指令执行。
GEO 非常适合附近的人、附近门店、骑手位置等场景。
但它不是完整的 GIS 系统。
如果业务需要复杂地理围栏、多边形查询、真实道路距离和路线规划,可能需要 PostGIS 或专业地图服务。
十、Stream:订单异步处理与消费者组
Stream 是 Redis 专门面向消息流的结构。
相比 List,它提供了更完整的消息处理能力,包括:
- 消息 ID。
- 消费者组。
- 消息确认。
- Pending 消息管理。
- 消息历史记录。
场景:订单创建后的异步处理
假设订单创建成功后,需要执行:
arduino
创建订单
↓
发送消息
↓
Redis Stream
↓
消费者处理
├── 发放积分
├── 发送通知
└── 其他异步操作
创建消息:
matlab
XADD order:events * order_id 1001 type created
创建消费者组:
sql
XGROUP CREATE order:events order-workers 0 MKSTREAM
消费者读取消息:
scss
XREADGROUP GROUP order-workers worker1
COUNT 10
BLOCK 5000
STREAMS order:events >
业务处理成功后:
sql
XACK order:events order-workers 1760000000000-0
这里的 ID 只是示例,实际使用读取消息返回的 ID。
如果消费者读取消息后崩溃,没有执行 XACK,消息会保留在消费者组的 Pending Entries List 中。
其他消费者可以通过 XAUTOCLAIM 等机制接管长时间未确认的消息。
但要注意:Redis Stream 不代表消息绝对不会丢失。
它的可靠性仍然受到 Redis 持久化、主从故障切换、消息清理策略等因素影响。
另外,消费者也可能重复收到消息,因此业务处理应该具备幂等性。
特别提醒: 同一个消费者组中的消息会分配给组内消费者,而不是自动广播给每一个消费者。如果积分服务和通知服务都需要收到同一条事件,可以为不同业务建立独立消费者组。
十一、真实项目应该如何组合使用?
实际上,一个成熟项目不会只使用一种 Redis 数据结构。
以在线学习考试平台为例:
| 业务需求 | 推荐结构 | 原因 |
|---|---|---|
| 缓存课程详情 | String | JSON 整体缓存 |
| 缓存学员资料 | Hash | 可以按字段修改 |
| 考试剩余时间相关状态 | String | 适合保存数值和时间戳 |
| 防止考试重复提交 | String + 数据库约束 | Redis 辅助幂等控制 |
| 学员每日签到 | Bitmap | 节省空间 |
| 课程点赞用户 | Set | 防止重复点赞 |
| 学员积分排行榜 | ZSet | 按分数排序 |
| 最近浏览课程 | List | 保留最近 N 条 |
| 每日独立学习人数 | HyperLogLog | 大规模近似去重 |
| 考试提交后异步阅卷 | Stream | 异步消费、确认、重试 |
例如考试提交后异步阅卷,可以设计为:
arduino
React 前端
↓
Go / Python 后端
↓
数据库事务保存答卷
↓
可靠地发布阅卷事件
↓
Redis Stream
↓
阅卷 Worker
↓
保存阅卷结果
↓
通知前端
这里有一个重要的工程问题:
数据库事务成功,不代表 Redis 消息一定发送成功。
如果要求可靠投递,可以考虑 Transactional Outbox(事务发件箱)模式:
- 在同一个数据库事务中保存答卷和待发布事件。
- 后台任务读取待发布事件。
- 发布到 Redis Stream。
- 成功后标记事件已发布。
- 消费端通过幂等处理防止重复阅卷。
这就是从"会使用 Redis 命令"走向"能够设计可靠后端系统"的关键。
十二、Redis 数据结构背后的底层实现
面试时,还需要区分两个概念:
Redis 对外提供的数据类型,不等于底层实际使用的数据结构。
例如:
| Redis 类型 | 常见底层实现 |
|---|---|
| String | SDS(简单动态字符串)等 |
| Hash | listpack 或哈希表 |
| List | quicklist |
| Set | 整数集合、哈希表等编码 |
| ZSet | listpack 或跳表结合哈希表 |
| Bitmap | 基于 String 的位操作 |
| HyperLogLog | 稀疏或稠密编码 |
| GEO | 基于 Sorted Set 的地理空间索引 |
| Stream | radix tree、listpack 等 |
具体编码与 Redis 版本、元素数量和配置有关。
比如 ZSet 为什么适合排行榜?
因为在常见的大规模编码下,它使用跳表维护有序关系,支持高效的插入、删除和范围查询,同时通过哈希结构辅助成员定位。
再比如 List 为什么适合双端操作?
因为 Redis 的 quicklist 结构适合在两端高效插入、删除元素。
理解底层结构之后,就更容易判断某种操作是否适合高并发、大数据量场景。
十三、生产环境还需要注意什么?
选择合适的数据结构只是第一步,真正上线时还需要关注以下问题。
1. 不要无限制地增长 Key
例如:
makefile
article:likes
user:history
order:events
如果这些数据一直增长而没有限制,最终可能造成 Redis 内存压力。
应该根据业务设计 TTL、容量限制、归档与清理策略。
2. 不要轻易查询超大集合的全部元素
例如:
lua
HGETALL huge:hash
SMEMBERS huge:set
LRANGE huge:list 0 -1
这些命令可能一次返回大量数据。
实际开发中应考虑分页、SCAN 系列命令或调整数据模型。
3. Redis 原子命令不等于业务事务
例如:
INCR inventory:1001
单条命令具有原子性。
但是:
markdown
检查库存
↓
判断是否充足
↓
扣减库存
这是多个操作,不能仅凭 Redis 单命令原子性就认为整个业务安全。
需要使用 Lua 脚本、Redis 事务或其他并发控制方案,并根据业务决定最终一致性由谁保障。
4. Redis 不一定是最终数据源
对于订单、支付、考试成绩等关键业务数据,通常应该明确持久化数据库作为权威数据源。
Redis 可以承担缓存、计数、排队、加速查询等职责,但不能因为访问速度快就忽略可靠性与数据一致性。
十四、总结:根据需求选择,而不是根据熟悉程度选择
Redis 数据结构的选择,可以归纳为一套简单的判断方法:
第一步:判断数据本身的特点。
- 单个值?String。
- 多字段对象?Hash。
- 有序列表?List。
- 需要去重?Set。
- 需要排序?ZSet。
第二步:判断是否存在特殊需求。
- 大量布尔状态?Bitmap。
- 海量去重计数?HyperLogLog。
- 地理位置查询?GEO。
- 需要消息确认和消费者组?Stream。
第三步:考虑工程约束。
- 是否需要持久化?
- 是否存在高并发写入?
- 是否要求强一致性?
- 数据是否会无限增长?
- 消费者失败后如何恢复?
- 是否需要保证幂等性?
最终,选择 Redis 数据结构的核心原则不是"哪个结构性能最好",而是:
根据业务访问模式选择合适的数据结构,再根据并发、一致性、可靠性和内存成本设计完整的解决方案。
掌握这些内容后,Redis 就不再只是项目中的缓存组件,而会成为你设计高性能后端系统的重要工具。