跳到主内容

一文搞懂 Redis 八大数据结构选型决策树 ​

为什么 Redis 选型不能只凭经验 ​

很多团队把 Redis 当成“更快的 HashMap”:对象塞 JSON 用 String,队列用 List,排行榜用 ZSet,能跑就上线。短期看开发很快,长期却容易出现三类问题:大 key 拖慢单次操作、无效字段占用大量内存、业务需要二次查询却被迫全量反序列化。

Redis 数据结构选型的本质,不是背八种类型,而是先识别访问模式:

  • 读多写少,还是读写都频繁;
  • 按整个对象读写,还是只更新单个字段;
  • 是否需要去重、排序、范围查询、集合交并差;
  • 是否允许一定误差,以换取极低内存;
  • 数据生命周期是缓存、消息、状态位,还是长期索引。

下面这张决策树把常见判断路径压缩成一张图,适合在方案评审时快速定位。

识别核心读写模式单值/整对象字段级更新去重/排序消息/状态StringHashSet / ZSetStream / Bitmap

八种结构的适用边界与成本特征 ​

String:最简单,但不是万能结构 ​

String 适合缓存完整 JSON、计数器、分布式锁、短文本配置。它的优点是命令简单、序列化方式由应用控制;缺点是字段更新必须读出完整对象、修改后再写回,容易产生竞争和额外网络开销。

bash
# 缓存完整用户资料,适合整体读取
SET user:1001 '{"name":"Alice","level":7,"city":"Shanghai"}'

# 原子计数,避免应用层读改写
INCR article:2001:views
INCRBY article:2001:likes 1

# 设置带过期时间的缓存
SETEX captcha:839201 300 '483920'

如果一个 value 超过 10 KB,且业务经常只改其中一个字段,就不应继续使用 String。此时 JSON 反序列化和回写成本会明显升高。

Hash:对象字段级读写的首选 ​

Hash 适合存储具有固定字段集合的对象,例如用户画像、商品快照、配置项。它支持 HGET、HSET、HINCRBY,只访问必要字段,字段较少时还能使用紧凑编码降低内存。

bash
# 写入商品部分字段
HSET product:3001 title "机械键盘" price 399 stock 28

# 只更新库存,不读取整个对象
HINCRBY product:3001 stock -1

# 读取单个字段和多个字段
HGET product:3001 price
HMGET product:3001 title stock

# 查看字段数与全部内容
HLEN product:3001
HGETALL product:3001

注意,Hash 不适合在 value 中继续嵌套大对象,也不适合对字段名做任意条件检索。它解决的是“对象内字段访问”,不是二级索引。

List:有序序列,但队列能力有限 ​

List 适合简单的时间线、最新记录、轻量任务队列。LPUSH + BRPOP 可以实现阻塞消费,但普通 List 没有消费组、确认机制和消息历史,不适合可靠消息系统。

bash
# 写入最近操作,保留最新 20 条
LPUSH recent:user:1001 "view:product:3001"
LTRIM recent:user:1001 0 19

# 查看最新 10 条
LRANGE recent:user:1001 0 9

# 简单队列:左侧生产,右侧阻塞消费
LPUSH task:queue '{"job":"send_email","id":1}'
BRPOP task:queue 5

如果任务不能丢、需要多消费者组、失败后重试或追踪投递位置,应选择 Stream。

Set:去重与集合运算 ​

Set 适合标签、好友关系、中奖用户去重、黑白名单。它天然去重,并支持交集、并集、差集。

bash
# 给用户打标签
SADD user:1001:tags "redis" "backend" "cache"
SADD user:1002:tags "mysql" "backend" "cache"

# 求两个用户共同标签
SINTER user:1001:tags user:1002:tags

# 判断用户是否拥有某标签
SISMEMBER user:1001:tags redis

# 随机抽取奖品候选用户
SRANDMEMBER lottery:users 3

Set 的内存成本通常高于只保存唯一 ID 的紧凑字符串,因为它要维护成员唯一性。成员量很大时,应关注 SMEMBERS 全量返回带来的阻塞风险。

ZSet:排序索引与排行榜 ​

ZSet 的每个成员都带 score,适合排行榜、延迟队列、按权重排序的 Feed。它既去重又可按分数范围检索,是查询能力很强的结构。

bash
# 玩家分数排行榜
ZADD leaderboard:stage1 1200 alice 980 bob 1350 carol

# 查询前三名及分数,带 WITHSCORE 应使用 WITHSCORES
ZREVRANGE leaderboard:stage1 0 2 WITHSCORES

# 查询 Alice 排名与分数
ZREVRANK leaderboard:stage1 alice
ZSCORE leaderboard:stage1 alice

# 查询 1000 分以上玩家
ZRANGEBYSCORE leaderboard:stage1 1000 +inf

ZSet 的代价是同时维护哈希表和跳表,写入和内存成本都高于 Set。若只需要去重,不需要排序,不要为了“以后可能用得上”而选择 ZSet。

Stream:可追踪的消息流 ​

Stream 适合业务事件、订单状态变更、异步任务流。它支持按 ID 追加、范围读取、消费组、待确认列表(PEL),比 List 更接近可靠消息队列。

bash
# 追加订单事件,* 表示由 Redis 自动生成 ID
XADD orders.stream * event created orderId 9001 amount 19900
XADD orders.stream * event paid orderId 9001 amount 19900

# 消费组读取新消息,> 表示只读取未投递过的消息
XGROUP CREATE orders.stream order-group 0 MKSTREAM
XREADGROUP GROUP order-group consumer-1 COUNT 10 BLOCK 5000 STREAMS orders.stream >

# 查看流长度与某个范围事件
XLEN orders.stream
XRANGE orders.stream - +

# 处理成功后确认消息
XACK orders.stream order-group 1700000000000-0

Stream 不会无限自动收缩。若不设置 MAXLEN 或执行 XTRIM,历史消息会持续占用内存。

Bitmap:布尔状态的极致压缩 ​

Bitmap 适合签到、在线状态、布尔特征、日活去重的辅助统计。它用 bit 位表示状态,偏移量需要稳定映射到整数用户 ID。

bash
# 记录用户 1001 在 2026 年第 90 天签到
SETBIT sign:20260331 1001 1

# 判断是否签到
GETBIT sign:20260331 1001

# 统计当天签到人数
BITCOUNT sign:20260331

# 统计连续两天都签到的用户
BITOP AND sign:both sign:20260330 sign:20260331
BITCOUNT sign:both

Bitmap 的内存取决于最大 offset,而不是已设置为 1 的数量。若用户 ID 稀疏且跨度极大,比如随机 UUID 映射出的超大整数,Bitmap 可能反而浪费内存。

HyperLogLog 与 Geo:专项能力不要滥用 ​

HyperLogLog 用于 UV 这类可接受约 0.81% 标准误差的基数统计,优点是固定占用约 12 KB;缺点是不能取出具体成员,也不能精确判断某人是否存在。Geo 本质上基于 ZSet,适合附近位置查询,不适合复杂地理空间分析。

bash
# HyperLogLog 统计页面 UV
PFADD page:home:uv user1 user2 user3
PFCOUNT page:home:uv

# Geo 存储门店坐标并查询附近门店
GEOADD shops 121.4737 31.2304 "人民广场店"
GEORADIUS shops 121.47 31.23 5 km WITHDIST

按场景做最终决策 ​

可以按下面的顺序判断:

  1. 整体读写对象:优先 String;
  2. 频繁修改单个字段:优先 Hash;
  3. 只需去重和集合运算:选 Set;
  4. 需要排序、排名、分数范围查询:选 ZSet;
  5. 可靠消费、多消费组、消息追踪:选 Stream;
  6. 海量布尔状态且 ID 连续或可控:选 Bitmap;
  7. 只要近似去重计数:选 HyperLogLog;
  8. 附近的人和地点:选 Geo。
对象缓存标签关系排行榜String / HashSetZSet

避坑总结:上线前必须检查的细节 ​

1. 避免大 key 和全量拉取 ​

常见错误现象是使用 HGETALL、SMEMBERS、ZRANGE key 0 -1 一次拉取数十万成员,导致 Redis 单线程被单个命令长时间占用,应用端还可能因结果过大超时。修正方法是使用 HSCAN、SSCAN、ZSCAN 分批扫描,并在业务层限制分页大小。对超大对象,应按维度拆分 key,例如把一个包含所有城市数据的 Hash 拆成 city:{cityId}。

2. String 存对象不要高频字段级更新 ​

错误写法是每次 GET JSON → 反序列化 → 修改一个字段 → SET 全量 JSON。高并发下会出现更新覆盖,网络和 CPU 成本也高。应改为 Hash,使用 HSET 或 HINCRBY 完成原子更新。只有在对象总是整体展示、字段不会单独修改时,String JSON 才更合适。

3. ZSet 命令参数容易写错 ​

常见错误是执行 ZREVRANGE key 0 9 WITHSCORE,Redis 会提示语法错误;正确参数是复数形式 WITHSCORES。另一个错误是把排名当成分数:ZREVRANK 返回从 0 开始的排名,ZSCORE 才返回分值。展示“第几名”时通常要对排名加一。

4. Bitmap 要控制 offset 设计 ​

错误现象是直接使用稀疏雪花 ID 或随机长整数作为 offset,导致 Redis 申请超大连续内存,实际设置的用户却很少。正确做法是维护用户 ID 到紧凑连续编号的映射,或确认业务 ID 范围可控。删除 Bitmap 后也要确认相关 BITOP 生成的临时 key 是否设置过期时间。

5. Stream 必须有积压治理 ​

很多服务只 XADD 不 XACK,也不限制长度,最终 PEL 与历史消息双膨胀。修正方法是追加时使用近似裁剪:XADD orders.stream MAXLEN ~ 100000 * event created,消费者处理成功后及时 XACK,并通过 XPENDING 监控卡死消息。是否需要死信队列,应在消费失败策略中提前定义。

选型时建议把“当前确定需求”和“未来可能需求”分开评估。Redis 的优势来自明确的数据访问模式,而不是用一个复杂结构覆盖所有想象中的查询。先用决策树确定主路径,再用内存估算、命令复杂度和过期策略校验,才能得到可长期维护的方案。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。