3、分布式缓存基石:Redis 核心数据结构及适用场景
说到缓存,Redis 几乎是绕不开的名字。很多人觉得它就是个“快一点的数据库”,存个 key-value 拉倒。其实不然。Redis 之所以能成为分布式缓存的基石,很大程度上归功于它那五种核心数据结构——每一种都不是白给的,背后对应着不同的业务痛点。
我个人习惯把 Redis 的数据结构比作“工具箱里的五把扳手”。你用对了,事半功倍;用错了,代码写得再花哨也白搭。今天咱们就挨个捋一遍,看看它们到底能解决什么问题。
核心观点:选对数据结构,比优化代码更重要。Redis 的每一种结构都针对特定场景做了极致优化,用错了就是拿大炮打蚊子。
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%。