Redis 数据结构实战指南:从缓存、点赞、排行榜到消息队列

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

传统查询流程:

  1. 前端请求后端。
  2. 后端查询 MySQL。
  3. MySQL 返回用户数据。
  4. 后端返回响应。

如果用户信息访问频繁,每次查询数据库就会产生不必要的压力。

我们可以引入 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(事务发件箱)模式:

  1. 在同一个数据库事务中保存答卷和待发布事件。
  2. 后台任务读取待发布事件。
  3. 发布到 Redis Stream。
  4. 成功后标记事件已发布。
  5. 消费端通过幂等处理防止重复阅卷。

这就是从"会使用 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 就不再只是项目中的缓存组件,而会成为你设计高性能后端系统的重要工具。

相关推荐
新鲜势力呀3 小时前
PHP 消息队列实战:从同步接口阻塞到 RabbitMQ + Redis队列 + 异步任务架构完整优化方案
redis·rabbitmq·php
for_ever_love__4 小时前
Redis 持久化讲透:RDB、AOF 与混合持久化怎么选
java·数据库·redis·持久化·aof·区别·rdb
拾贰_C6 小时前
【English | conversation 】call | 抖音AI短句情景:打电话---come over
数据库·redis·缓存
ShineWinsu9 小时前
对于Redis:缓存的解析
java·redis·缓存·缓存穿透·缓存击穿·缓存雪崩·缓存预热
害人终害己9 小时前
redis修改密码的地方在哪里
数据库·redis·缓存
我不会起名字32210 小时前
Redis 入门(一):Redis 是什么,为什么后端都离不开它
linux·redis·docker·安装·cli
for_ever_love__10 小时前
缓存穿透、击穿、雪崩:三个经典问题与完整解决方案
java·数据库·redis·缓存·哈希算法·布隆过滤器·雪崩
hweiyu0020 小时前
Redis命令:HSTRLEN
redis·缓存
字节渡客21 小时前
Redis键明明过期了,业务还在读到旧数据
数据库·redis·spring