3、分布式缓存基石:Redis 核心数据结构及适用场景

说到缓存,Redis 几乎是绕不开的名字。很多人觉得它就是个“快一点的数据库”,存个 key-value 拉倒。其实不然。Redis 之所以能成为分布式缓存的基石,很大程度上归功于它那五种核心数据结构——每一种都不是白给的,背后对应着不同的业务痛点。

我个人习惯把 Redis 的数据结构比作“工具箱里的五把扳手”。你用对了,事半功倍;用错了,代码写得再花哨也白搭。今天咱们就挨个捋一遍,看看它们到底能解决什么问题。

核心观点:选对数据结构,比优化代码更重要。Redis 的每一种结构都针对特定场景做了极致优化,用错了就是拿大炮打蚊子。

Redis 五种核心数据结构全景 Redis 数据结构 String Hash List Set Sorted Set 缓存、计数器 分布式锁、Session 对象存储、用户 属性、购物车 消息队列、最新 动态、时间线 标签、去重、 好友关系 排行榜、延时 队列、限流

3.1 String:最基础,也最容易被低估

String 是 Redis 里最简单的结构,key 对 value,value 最大能到 512MB。但你别因为它简单就小看它——我见过太多人把复杂业务逻辑硬塞进 String,结果搞得一团糟。

适用场景:

  • 缓存单个值:比如用户 、配置项、HTML 片段。说白了就是“查一次,存起来,下次直接用”。
  • 计数器:INCR/DECR 命令是原子操作,做点赞数、访问量、库存扣减再合适不过。
  • 分布式锁:SETNX + EXPIRE 组合拳,虽然现在有 Redlock,但基础原理还是 String。
  • Session 共享:多台 Web 服务器共享登录态,String 一把梭。

我的经验:做计数器时,记得用 INCRBY 而不是 GET + SET。前者是原子操作,后者在高并发下会丢数据。我曾经在一个秒杀项目里看到有人用 GET + SET 做库存扣减,压测直接超卖——嗯,后来改成了 INCRBY 就稳了。

# 缓存用户信息
SET user:1001: "abc123xyz"
GET user:1001:

# 计数器
INCR article:42:views
INCRBY article:42:likes 5

# 分布式锁
SET lock:order:1001 "thread-1" NX EX 30
DEL lock:order:1001

3.2 Hash:对象存储的天然选择

Hash 相当于一个“小型的 map”,一个 key 下面可以挂多个 field-value 对。你想想看,如果存一个用户对象,用 String 你得序列化整个对象,改一个字段就得全量读写。用 Hash 呢?改哪个字段就操作哪个字段,省带宽、省内存。

适用场景:

  • 用户信息/商品信息:name、age、email 各占一个 field,按需存取。
  • 购物车:用户 ID 做 key,商品 ID 做 field,数量做 value。
  • 配置中心:一个应用的所有配置项塞进一个 Hash,HGETALL 一把拉。

注意:Hash 的 field 数量不要太多,超过 1000 个时内存开销会明显上升。Redis 内部对小 Hash 做了压缩(ziplist),但 field 一多就会转成 hashtable,内存翻倍。我建议单个 Hash 的 field 控制在 500 以内。

# 存储用户信息
HSET user:1001 name "张三" age 28 email "zhangsan@example.com"
HGET user:1001 name
HGETALL user:1001

# 购物车
HSET cart:user_1001 product_42 2
HINCRBY cart:user_1001 product_42 1

3.3 List:消息队列的轻量方案

List 是一个双向链表,支持从两端 push/pop。很多人拿它当消息队列用——其实 Redis 官方后来出了 Stream,但 List 在简单场景下依然能打。

适用场景:

  • 消息队列:LPUSH + BRPOP 实现生产者-消费者模式。
  • 最新动态/时间线:LPUSH 塞入最新内容,LRANGE 取前 N 条。
  • 日志收集:多个生产者往 List 里写,一个消费者慢慢消费。

避坑指南:我曾经用 List 做消息队列,消费者挂了之后消息全丢了。后来加了 BRPOPLPUSH(或现在的 LMOVE),消费时先把消息移到备份 List,处理完再删。这样即使消费者崩溃,消息也不会丢。

# 消息队列 - 生产者
LPUSH task:queue "task-1" "task-2" "task-3"

# 消息队列 - 消费者(阻塞读取)
BRPOP task:queue 0

# 最新动态
LPUSH user:1001:feed "post-42" "post-43"
LRANGE user:1001:feed 0 9

3.4 Set:去重与关系运算的利器

Set 是无序集合,元素唯一。它的杀手锏是集合运算——交集、并集、差集,一个命令搞定,不用在应用层写循环。

适用场景:

  • 标签系统:一篇文章有哪些标签,一个用户关注了哪些话题。
  • 好友关系:SINTER 求共同好友,SDIFF 求可能认识的人。
  • 去重统计:今天访问过页面的所有用户 ID,SCARD 直接出人数。

实战案例:我之前做一个社交推荐系统,用 Set 存用户的兴趣标签。推荐时直接 SINTER 求交集,找出“和你兴趣最像的人”,再 SDIFF 找出“他们有你没有的标签”。一个命令搞定,比在 MySQL 里 JOIN 快了两个数量级。

# 文章标签
SADD article:42:tags "redis" "cache" "performance"

# 共同好友
SADD user:1001:friends "user_2001" "user_2002"
SADD user:1002:friends "user_2001" "user_2003"
SINTER user:1001:friends user:1002:friends
# 结果:user_2001

# 去重统计
SADD page:home:visitors "ip_192.168.1.1" "ip_192.168.1.2"
SCARD page:home:visitors

3.5 Sorted Set:带权重的有序集合

Sorted Set 是 Redis 里我最喜欢的数据结构。它在 Set 的基础上给每个元素加了一个 score(分数),集合按 score 排序。说白了,就是“自带排序功能的 Set”。

适用场景:

  • 排行榜:玩家分数做 score,ZREVRANGE 取前十名。
  • 延时队列:任务执行时间戳做 score,ZRANGEBYSCORE 取到期的任务。
  • 滑动窗口限流:时间戳做 score,ZREMRANGEBYSCORE 清理过期记录,ZCARD 统计当前窗口请求数。
  • 自动补全/搜索建议:搜索频率做 score,ZREVRANGE 取热门搜索词。

注意:Sorted Set 的 ZADD 时间复杂度是 O(log N),N 是集合大小。如果你的排行榜有上千万人,每次插入都会有点慢。我建议对超大集合做分片,比如按分数段拆成多个 Sorted Set。

# 游戏排行榜
ZADD leaderboard:game1 1000 "player_42"
ZADD leaderboard:game1 850 "player_18"
ZADD leaderboard:game1 1200 "player_7"
ZREVRANGE leaderboard:game1 0 2 WITHSCORES
# 结果:player_7(1200), player_42(1000), player_18(850)

# 延时队列
ZADD task:delay 1700000000 "task-1"
ZADD task:delay 1700000100 "task-2"
ZRANGEBYSCORE task:delay 0 1700000050
# 结果:task-1(已到期)

3.6 如何选择?一张表说清楚

很多新手会纠结“这个场景该用 Hash 还是 String?” 我直接给你一张对比表,照着选就行。

数据结构 核心特性 时间复杂度 典型场景 内存效率
String 简单 key-value O(1) 缓存、计数器、锁 高(小值)
Hash field-value 映射 O(1) 单 field 对象存储、购物车 中(field 多时膨胀)
List 双向链表 O(1) 两端操作 消息队列、时间线 中
Set 无序唯一 O(1) 增删查 标签、去重、关系 中
Sorted Set 有序唯一 O(log N) 增删 排行榜、延时队列 低(维护跳表)

我的选择原则:能不用 String 存对象就别用。对象用 Hash,列表用 List,去重用 Set,排序用 Sorted Set。String 只干三件事——缓存简单值、计数器、分布式锁。记住这个原则,你的 Redis 内存至少省 30%。