Redis 数据结构场景应用——什么时候用什么 / Choosing Redis Data Structures for Real Applications
📅 创建时间:2026-06-02 🏷️ 标签:#Redis #数据结构 #SDS #跳表 #Hash #List #Set #ZSet #场景决策 📚 前置知识:[[00-redis-overview]](Redis 全景) [[01-redis-architecture]](架构分析) [[/03-web/06-databases-and-data-access/05-redis]](Redis 命令速查) 📚 相关知识:[[04-redis-distributed-locks]](分布式锁) [[05-redis-advanced-features]](HyperLogLog / Bitmap / Stream)
场景:同样存用户信息,为什么有人用 String,有人用 Hash
┌─────────────────────────────────────────────────────────────┐
│ │
│ 工程师 A:HSET user:1001 name "张三" age "28" │
│ 工程师 B:SET user:1001 "{\"name\":\"张三\",\"age\":\"28\"}" │
│ │
│ 两个方案都能工作,但: │
│ • Hash 可以只取 name,不用解析整个 JSON │
│ • String 在批量读取所有字段时只有 1 次 IO │
│ • Hash 支持 field 级别的过期吗?(不支持) │
│ │
│ 不同的数据结构有不同的使用边界, │
│ 选错了会导致代码难维护或者性能差。 │
│ │
└─────────────────────────────────────────────────────────────┘第1节:五种核心数据结构的场景决策
String——什么时候用它
┌─────────────────────────────────────────────────────────────┐
│ String 的最佳使用场景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ String(SDS)最适合的场景: │
│ │
│ 1. 简单 KV:缓存单个值 │
│ product:1001 = '{"id":1001,"name":"iPhone","price":6999}'
│ → 直接序列化/反序列化,不需要 field 级操作 │
│ │
│ 2. 计数器 │
│ INCR order:count # 原子 +1 │
│ INCRBY product:1001:views 100 # 批量增加浏览量 │
│ DECRBY stock:1001 1 # 原子 -1,扣库存 │
│ → 原子操作,不用担心并发问题 │
│ │
│ 3. 分布式锁值 │
│ SET lock:order:123 "worker_001" NX EX 30 │
│ → value 存的是持有者的唯一标识(UUID) │
│ │
│ 4. Session Token │
│ SET session:abc123 {user_id:1001} EX 7200 │
│ → 简单会话存储,不需要 field 级别操作 │
│ │
│ String 不适合的场景: │
│ ❌ 需要按字段查询:user.name,而不是整个 user 对象 │
│ ❌ 字段经常部分更新:只改 age,不重序列化整个对象 │
│ │
└─────────────────────────────────────────────────────────────┘Hash——什么时候用它
┌─────────────────────────────────────────────────────────────┐
│ Hash 的最佳使用场景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Hash 适合存"对象",特别是需要按字段读写的场景: │
│ │
│ 1. 用户/商品等业务对象(字段经常部分更新) │
│ HSET user:1001 name "张三" age "28" city "北京" │
│ HSET user:1001 age "29" # 只改 age,不动其他 │
│ HGET user:1001 name # 只读 name,一次 IO │
│ → 相比 String(序列化 JSON),field 级操作更灵活 │
│ │
│ 2. 配置缓存 │
│ HSET config:system max_connections 1000 │
│ HINCRBY config:system online_users 1 │
│ HGETALL config:system │
│ → 配置项按 key 分组,方便管理 │
│ │
│ 3. 购物车 │
│ HSET cart:1001 product:001 '{"name":"iPhone","num":1}'│
│ HSET cart:1001 product:002 '{"name":"Mac","num":1}' │
│ HINCRBY cart:1001 product:001 1 # 加一件 iPhone │
│ → 每个商品独立字段,增量更新方便 │
│ │
│ ⚠️ Hash 的注意事项: │
│ • 过期时间对整个 key 生效,不支持单 field 过期 │
│ • field 数量过多(超过 500)时考虑拆分成多个 Hash │
│ • 小数据量时用 ziplist 编码(节省内存),大数据用 hashtable│
│ │
└─────────────────────────────────────────────────────────────┘List——什么时候用它
┌─────────────────────────────────────────────────────────────┐
│ List 的最佳使用场景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ List(双向链表)适合"有序、可重复、有进有出"的场景: │
│ │
│ 1. 消息队列(简单场景,不需要消费者组) │
│ LPUSH task:queue '{"job_id":1,"type":"send_email"}' │
│ BRPOP task:queue 0 │
│ → LPUSH 生产,BRPOP 消费(FIFO 队列) │
│ → BRPOP 阻塞等待,有新消息才返回 │
│ → 比 Kafka 轻量,适合小规模异步任务 │
│ │
│ 2. 最新消息列表(时间线) │
│ LPUSH timeline:1001 "用户 1001 发布了一条动态..." │
│ LTRIM timeline:1001 0 99 # 只保留最新 100 条 │
│ LRANGE timeline:1001 0 19 # 取最新 20 条 │
│ → 微博/Twitter 时间线就是这样的结构 │
│ │
│ 3. 最新浏览记录 │
│ LPUSH history:1001 "product:001" │
│ LTRIM history:1001 0 49 # 只保留 50 条 │
│ LRANGE history:1001 0 9 # 显示最近 10 条 │
│ → 每个用户独立的浏览历史 │
│ │
│ ⚠️ List 的注意事项: │
│ • BRPOP 多个 key:BRPOP key1 key2 key3 0(哪个有消息读哪个)│
│ • 消费者崩溃:消息被消费但未处理 → 丢失 │
│ 解法:用 Stream 的 ACK 机制 │
│ │
└─────────────────────────────────────────────────────────────┘Set——什么时候用它
┌─────────────────────────────────────────────────────────────┐
│ Set 的最佳使用场景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Set(无序集合,自动去重)适合"不重复、无序、快速判断存在":│
│ │
│ 1. 标签系统 │
│ SADD product:001:tags "手机" "5G" "拍照" │
│ SISMEMBER product:001:tags "5G" # → 1(存在) │
│ SINTER product:tags:手机 product:tags:5G │
│ → 找同时有"手机"和"5G"标签的商品 │
│ → 集合交集运算 │
│ │
│ 2. 关注关系/好友列表 │
│ SADD user:1001:followings 2001 2002 2003 │
│ SADD user:2001:followers 1001 │
│ SINTER user:1001:followings user:2001:followings │
│ → 互相关注(共同关注) │
│ │
│ 3. UV 统计(去重计数) │
│ SADD page:view:2026-06-02 "user_001" "user_002" │
│ SCARD page:view:2026-06-02 # → 2(独立访客数) │
│ → 缺点:用户量大了内存开销大(用 HyperLogLog 替代) │
│ │
│ 4. 黑名单/白名单 │
│ SADD blacklist:ip "1.2.3.4" "5.6.7.8" │
│ SISMEMBER blacklist:ip "1.2.3.4" # → 1(在黑名单) │
│ → O(1) 判断是否存在,比数据库快很多 │
│ │
│ ⚠️ Set vs Hash: │
│ • Set 存的是 member 本身,Hash 存的是 field-value 对 │
│ • 需要交集/并集/差集 → 用 Set │
│ • 需要按字段读写 → 用 Hash │
│ │
└─────────────────────────────────────────────────────────────┘ZSet——什么时候用它
┌─────────────────────────────────────────────────────────────┐
│ ZSet 的最佳使用场景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ZSet(有序集合,自动按 score 排序)适合"带权重的排行榜": │
│ │
│ 1. 实时排行榜 │
│ ZADD leaderboard:posts 150 "post_001" │
│ ZADD leaderboard:posts 89 "post_002" │
│ ZREVRANGE leaderboard:posts 0 9 WITHSCORES │
│ → 查 Top 10,按分数降序 │
│ ZINCRBY leaderboard:posts 1 "post_001" │
│ → 增量更新分数,不用重新计算所有排名 │
│ │
│ 2. 延迟队列 │
│ ZADD delay:queue {now+30s} "order:123:timeout" │
│ ZRANGEBYSCORE delay:queue 0 {now} WITHSCORES │
│ → 取所有已到期的延迟任务 │
│ ZREMRANGEBYSCORE delay:queue 0 {now} │
│ → 删除已处理的任务 │
│ → 订单 30 分钟未支付 → 自动取消 │
│ │
│ 3. 带权重的优先级队列 │
│ ZADD priority:tasks 10 "task:001" # 优先级 10 高 │
│ ZADD priority:tasks 5 "task:002" # 优先级 5 低 │
│ ZRANGE priority:tasks 0 0 # 取最高优先级 │
│ → 任务调度系统,高优先级任务先执行 │
│ │
│ 4. 滑动窗口限流 │
│ ZADD ratelimit:user:1001 {timestamp} {request_id} │
│ ZREMRANGEBYSCORE ratelimit:user:1001 0 {now-60s} │
│ ZCARD ratelimit:user:1001 │
│ → 60 秒内超过阈值 → 限流 │
│ → 比 String 计数器更精确(可精确到毫秒级时间窗口) │
│ │
│ ZSet vs List: │
│ • List:按插入顺序排序,时间顺序 │
│ • ZSet:按 score(分数)排序,业务权重 │
│ │
└─────────────────────────────────────────────────────────────┘第2节:五种数据结构的决策矩阵
┌─────────────────────────────────────────────────────────────┐
│ 数据结构选择决策矩阵 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┬────────────┬───────────┬─────────────────┐ │
│ │ 数据结构 │ 场景 │ 优势 │ 忌讳场景 │ │
│ ├──────────────┼────────────┼───────────┼─────────────────┤ │
│ │ String │ KV、计数器 │ 原子操作 │ 频繁按字段更新 │ │
│ │ │ 锁值 │ 简单直接 │ │ │
│ ├──────────────┼────────────┼───────────┼─────────────────┤ │
│ │ Hash │ 业务对象 │ field 级 │ 集合运算(交并差)│ │
│ │ │ 配置缓存 │ 读写 │ 大集合计数 │ │
│ ├──────────────┼────────────┼───────────┼─────────────────┤ │
│ │ List │ 队列 │ FIFO 有序 │ 需要消费者组/ACK │ │
│ │ │ 时间线 │ 头尾操作 │ 需要随机访问 │ │
│ ├──────────────┼────────────┼───────────┼─────────────────┤ │
│ │ Set │ 标签 │ 交并差运算 │ 需要按 score 排序 │ │
│ │ │ 去重计数 │ O(1) 存在 │ 有序集合 │ │
│ ├──────────────┼────────────┼───────────┼─────────────────┤ │
│ │ ZSet │ 排行榜 │ 按权重排序 │ 只需要去重,不需排序│
│ │ │ 延迟队列 │ 范围查询 │ │ │
│ │ │ 限流 │ O(log N) │ │ │
│ └──────────────┴────────────┴───────────┴─────────────────┘ │
│ │
│ 决策口诀: │
│ 简单值 → String │
│ 对象字段 → Hash │
│ 有序列表 → List(按时间)/ ZSet(按权重) │
│ 快速查找 → Set(存在判断)/ ZSet(按权重范围) │
│ │
└─────────────────────────────────────────────────────────────┘第3节:进阶数据结构的场景应用
HyperLogLog——海量 UV 统计
┌─────────────────────────────────────────────────────────────┐
│ HyperLogLog:用 12KB 统计百万 UV │
├─────────────────────────────────────────────────────────────┤
│ │
│ 场景:统计每天有多少独立用户访问了你的网站 │
│ │
│ 方案 A:Set(SADD + SCARD) │
│ • 100 万用户 = 100 万个 member → 占用 ~100MB 内存 │
│ • 精确计数 │
│ │
│ 方案 B:HyperLogLog │
│ • 固定 12KB 内存 │
│ • 标准误差 ~0.81% │
│ • 适合统计"大概有多少个"而非精确数字 │
│ │
│ 适用场景: │
│ • DAU(日活跃用户):每天一个 HyperLogLog key │
│ • 页面 UV:每个页面每天一个 key │
│ • 接口调用量(去重): │
│ PFADD api:hits:2026-06-02 "user:1001" "user:1002" │
│ PFCOUNT api:hits:2026-06-02 │
│ │
│ 不适用场景: │
│ ❌ 需要精确去重(如订单数、支付数)→ 用 String 计数器 │
│ ❌ 用户量 < 1 万 → 直接用 Set 更精确 │
│ │
│ 合并统计:PFMERGE week:uv day1 day2 day3 → 合并多天 UV │
│ │
└─────────────────────────────────────────────────────────────┘Bitmap——位图操作
┌─────────────────────────────────────────────────────────────┐
│ Bitmap:用 bit 位标记状态 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 原理:每个 bit 可以存 0 或 1,1 个字节 = 8 个 bit │
│ │
│ 场景 1:用户签到(一年 = 365 bits = 46 bytes) │
│ │
│ # 2026 年用户 1001 的签到情况(偏移 = 月-1 × 31 + 日-1)│
│ SETBIT user:1001:signin:2026 $((6-1)*31+15) 1 # 6月15日│
│ GETBIT user:1001:signin:2026 $((6-1)*31+15) # → 1 │
│ BITCOUNT user:1001:signin:2026 # 总签到天数│
│ BITOP AND last7days day1 day2 day3 ... day7 # 近7天全签到│
│ │
│ 场景 2:在线状态(1 亿用户 = 100MB) │
│ │
│ # bit 位表示用户 ID(用户 3 在线) │
│ SETBIT online:users 3 1 │
│ # 统计当前在线人数 │
│ BITCOUNT online:users │
│ # 用户 3 是否在线 │
│ GETBIT online:users 3 │
│ │
│ 场景 3:权限位图 │
│ │
│ # 8 个 bit 表示 8 种权限,第 0 位=读,第 1 位=写... │
│ SET user:1001:permissions 5 # 0101 = 有读+权限管理权限│
│ GETBIT user:1001:permissions 1 # → 0(无写权限) │
│ │
│ 一句话:需要"大量布尔状态 + 空间敏感" → Bitmap │
│ │
└─────────────────────────────────────────────────────────────┘Stream——真正可靠的消息队列
┌─────────────────────────────────────────────────────────────┐
│ Stream:Redis 原生消息队列 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 为什么需要 Stream?List 做队列有致命缺陷: │
│ • 消费者崩溃:消息被 BRPOP 拿走,但没处理完 → 消息丢失 │
│ • 多个消费者:无法让多个消费者各自拿到完整消息 │
│ │
│ Stream 的改进: │
│ 1. 消息 ID(自动递增),可追踪 │
│ 2. 消费者组:多个消费者协作,每条消息只被一个消费者处理 │
│ 3. ACK 确认:消息处理完后显式确认,未确认会重投 │
│ 4. 持久化:存在 Redis 中,重启不丢 │
│ │
│ 场景:订单超时处理 │
│ │
│ # 1. 创建消费组(一次性) │
│ XGROUP CREATE orders:pending mygroup $ MKSTREAM │
│ │
│ # 2. 生产者:创建订单时写入 Stream │
│ XADD orders:pending * order_id "12345" amount "6999" │
│ │
│ # 3. 消费者:抢单 │
│ XREADGROUP GROUP mygroup consumer1 STREAMS orders:pending ">"│
│ # → 返回新消息,">" 表示只取未分配的消息 │
│ │
│ # 4. 消费者:处理完后 ACK │
│ XACK orders:pending mygroup 1700000000000-0 │
│ │
│ # 5. 监控:查看未确认消息(超时未 ACK → 重投) │
│ XPENDING orders:pending mygroup │
│ │
│ 适用场景:轻量级异步任务(不需要 Kafka 的吞吐量时) │
│ 不适用:需要极高吞吐量(100 万+/s)或消息持久化到磁盘 │
│ │
└─────────────────────────────────────────────────────────────┘第4节:编码转换——Redis 怎么自动优化存储
┌─────────────────────────────────────────────────────────────┐
│ Redis 内部编码转换 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Redis 会根据数据量自动选择最优编码,不需要手动干预: │
│ │
│ String: │
│ • embstr(< 39 字节):连续的内存块,1 次分配 │
│ • raw(≥ 39 字节):SDS 单独分配 │
│ • int:纯整数,用 8 字节存储 │
│ │
│ Hash / List / Set / ZSet: │
│ • 小数据量:ziplist / intset(紧凑结构,节省内存) │
│ • 大数据量:hashtable / skiplist / quicklist(高效操作) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ziplist → hashtable 的触发条件(Hash): │ │
│ │ │ │
│ │ hash-max-ziplist-entries = 512 │ │
│ │ → field 数量超过 512 → 转为 hashtable │ │
│ │ hash-max-ziplist-value = 64 │ │
│ │ → 单个 field value 超过 64 字节 → 转为 hashtable│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ⚠️ 注意:ziplist 查询是 O(N),hashtable 是 O(1) │
│ 频繁按 field 查询的场景,避免 Hash 退化成 ziplist │
│ (field 数量过多时反而影响性能) │
│ │
│ 查看编码:OBJECT ENCODING user:1001 │
│ 查看内部信息:DEBUG OBJECT user:1001 │
│ │
└─────────────────────────────────────────────────────────────┘升华:数据结构选择的本质
┌─────────────────────────────────────────────────────────────┐
│ 选择数据结构的本质:根据你的操作选结构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 核心问题只有三个: │
│ │
│ 1. 数据要不要排序? │
│ 不排序 → Set │
│ 按权重/分数排序 → ZSet │
│ 按插入顺序 → List │
│ │
│ 2. 数据要不要按字段读写? │
│ 整存整取(JSON) → String │
│ 按字段读写 → Hash │
│ │
│ 3. 操作是什么类型? │
│ 交并差运算 → Set │
│ 原子计数 → String(INCR/DECR) │
│ 时间序列 → List │
│ 排行榜/权重队列 → ZSet │
│ │
│ 反面教材: │
│ ❌ 用 ZSet 存用户对象(只为了按字段排序) │
│ ❌ 用 String 存大型 JSON,每改一个字段要反序列化+序列化 │
│ ❌ 用 List 做排行榜(每次要LRANGE 全部再排序) │
│ │
│ 正确做法: │
│ 先想清楚"我要对这个数据做什么操作", │
│ 再根据操作类型选择最合适的数据结构。 │
│ 数据结构选对了,代码自然简单,性能自然好。 │
│ │
└─────────────────────────────────────────────────────────────┘"AI 可查 vs 必须理解"清单
AI 可查:
✅ Redis 各数据结构的命令速查
✅ ziplist / intset / quicklist 的编码阈值配置
✅ DEBUG OBJECT / OBJECT ENCODING 的具体用法
必须理解:
🔴 什么时候用 String vs Hash(整存 vs field 级操作)
🔴 什么时候用 List vs ZSet(时间顺序 vs 权重排序)
🔴 HyperLogLog 的适用场景(海量 UV 统计)和不适用场景(精确计数)
🔴 Bitmap 的核心优势(位级操作,空间极省)和典型场景(签到/在线状态)
🔴 Stream 和 List 作为队列的区别(ACK 确认 vs 无确认)
🔴 Redis 内部编码转换的触发条件(entries 数量 / value 大小)学习状态:🟡 开始学习