Redis 不只是缓存。它是一把瑞士军刀——凭借五种基础数据结构和若干扩展模块,Redis 能解决从分布式锁到社交网络、从排行榜到消息队列的几乎所有高频系统设计问题。

本文的目标是:建立一套从业务问题到 Redis 数据结构的映射思维。对于每个用例,我们都会回答三个问题:

  1. 业务问题是什么? 需求的本质是什么操作?
  2. 为什么选 Redis? 相比 MySQL、MQ 等方案,Redis 的优势和代价是什么?
  3. 怎么设计? 用哪种数据结构,Key 怎么命名,核心命令是什么?
mindmap
  root((Redis 用例全景))
    基础存储
      缓存策略
        Cache-Aside
        穿透/击穿/雪崩
      分布式会话
        Session 共享
        验证码/短链接
      分布式 ID
        INCR 自增
        日期分段
    并发控制
      分布式锁
        SET NX
        Redlock
        看门狗续期
      计数器与限流
        固定窗口
        滑动窗口
        令牌桶
    有序数据
      排行榜
        实时排名
        多维度榜单
      延时队列
        Score=到期时间
        Lua 原子消费
    关系与集合
      社交关系
        关注/粉丝
        共同好友
        集合运算
      评论系统
        实体+索引分离
        二级评论树
    消息与事件
      消息队列
        List 简单队列
        Pub/Sub 广播
        Stream 可靠队列
    地理与统计
      地理位置
        GEO 附近的人
        GeoHash 编码
      UV 统计
        HyperLogLog
        12KB 统计 2^64
      布隆过滤器
        穿透防护
        概率换空间
    二值状态
      签到系统
        Bitmap
        BITCOUNT/BITOP

Redis 数据结构速查与设计思维框架

数据结构总览

在开始任何设计之前,先建立对 Redis 数据结构能力的直觉:

数据结构 本质 核心能力 典型映射场景
String 二进制安全的字节串 GET/SET/INCR/DECR,原子操作,支持 TTL 缓存、计数器、分布式锁、会话
Hash 字段-值映射表 HSET/HGET/HGETALL,单个 Key 下的结构化存储 对象存储、用户 Profile、购物车
List 双向链表 LPUSH/RPOP/BRPOP,阻塞弹出 消息队列、最新动态、操作日志
Set 无序唯一集合 SADD/SISMEMBER/SINTER/SUNION/SDIFF 标签、共同好友、去重、抽奖
Sorted Set (ZSet) 带分数的有序唯一集合 ZADD/ZRANGE/ZRANK/ZRANGEBYSCORE 排行榜、延时队列、时间线、滑动窗口
HyperLogLog 概率基数统计 PFADD/PFCOUNT/PFMERGE,误差 ≤ 0.81% UV 统计、独立访客计数
Bitmap 位数组 SETBIT/GETBIT/BITCOUNT/BITOP 签到、在线状态、布隆过滤器
GEO 地理坐标索引(底层是 ZSet) GEOADD/GEODIST/GEORADIUS 附近的人、门店搜索
Stream 持久化消息日志 XADD/XREAD/XREADGROUP/XACK 消息队列、事件溯源

设计思维框架:三步映射法

面对任何业务问题,可以按以下三步将其转化为 Redis 方案:

flowchart TD
    A["业务问题"] --> B["Step 1: 抽象操作语义"]
    B --> C{"需要什么操作?"}
    C -->|"读写单值"| D["String"]
    C -->|"结构化对象"| E["Hash"]
    C -->|"有序队列/栈"| F["List"]
    C -->|"集合运算"| G["Set"]
    C -->|"排序 + 范围查询"| H["Sorted Set"]
    C -->|"基数统计"| I["HyperLogLog"]
    C -->|"位运算"| J["Bitmap"]
    C -->|"地理距离"| K["GEO"]
    C -->|"消息流"| L["Stream"]
    
    D --> M["Step 2: 设计 Key Schema"]
    E --> M
    F --> M
    G --> M
    H --> M
    I --> M
    J --> M
    K --> M
    L --> M
    
    M --> N["Step 3: 评估取舍"]
    N --> O{"数据丢失可接受?"}
    O -->|"是"| P["纯 Redis 方案"]
    O -->|"否"| Q["Redis + 持久化存储"]

Step 1 — 抽象操作语义:把业务需求翻译成数据操作。"用户签到"本质是"标记某天某用户为已签到"→ 位操作 → Bitmap。"排行榜"本质是"按分数排序并查询排名"→ 排序 + 排名查询 → Sorted Set。

Step 2 — 设计 Key Schema:Key 的命名决定了数据的组织方式。通用模式是 {业务域}:{实体类型}:{实体ID}:{子资源},例如 post:123:commentsuser:456:followers

Step 3 — 评估取舍:Redis 是内存数据库,天然面临容量和持久性的约束。每个方案都需要回答:数据丢了怎么办?内存不够怎么办?需要事务一致性吗?

七大可迁移模式(先记住模式,再看用例)

在深入 14 个用例之前,先建立模式意识。下面这七个模式覆盖了 Redis 解决问题的所有核心套路。每个用例都是某个模式的具体实例——记住模式,就能自己推导出新场景的方案

# 模式名称 一句话口诀 覆盖用例
1 KV + TTL 万能临时状态 任何"有生命周期的状态"都能用 String + 过期时间表达 缓存、Session、验证码、短链接
2 原子占位 SET NX = “谁先到谁占坑”,解决一切"只能有一个"的问题 分布式锁、幂等控制、缓存击穿互斥锁
3 Score 即时间轴 把时间戳塞进 ZSet 的 Score,就得到了一条可查询的时间线 延时队列、排行榜、滑动窗口限流、时间线 Feed
4 双向关系 + 集合运算 一个关系写两个 Set,交并差运算解决所有"共同/推荐/互相"问题 社交关系、标签系统、权限交集
5 实体 + 索引分离 Hash 存内容,ZSet/Set 存索引——读写分离的 Redis 版 评论系统、商品列表、搜索结果缓存
6 概率换空间 用可控的误差换取数量级的内存节省 HyperLogLog UV 统计、布隆过滤器
7 Lua 原子胶水 多个命令需要"要么全做要么不做"时,用 Lua 脚本粘合 限流、延时队列消费、锁释放、令牌桶
flowchart LR
    subgraph "记住这七个模式"
        P1["① KV+TTL<br/>临时状态"]
        P2["② 原子占位<br/>SET NX"]
        P3["③ Score=时间戳<br/>时间轴"]
        P4["④ 双Set<br/>集合运算"]
        P5["⑤ Hash+ZSet<br/>实体+索引"]
        P6["⑥ 概率结构<br/>换空间"]
        P7["⑦ Lua 脚本<br/>原子胶水"]
    end
    
    P1 --> U1["缓存/Session/验证码"]
    P2 --> U2["分布式锁/幂等/击穿"]
    P3 --> U3["排行榜/延时队列/限流"]
    P4 --> U4["社交关系/标签/权限"]
    P5 --> U5["评论/商品列表"]
    P6 --> U6["UV统计/穿透防护"]
    P7 --> U7["贯穿所有需要原子性的场景"]

阅读建议:后文每个用例结束后,都会有一个 🔑 模式提炼 段落,把该用例抽象为可迁移的模式,并列出"换个参数就能解决"的同类场景。读完全文后,回到这张表,你会发现所有用例都是这七个模式的排列组合。


用例一:缓存策略

业务问题

数据库查询慢,热点数据被反复读取,如何降低数据库压力并提升响应速度?

为什么选 Redis

维度 Redis 本地缓存 (Guava/Caffeine) CDN
延迟 亚毫秒(网络开销) 纳秒级 毫秒级
一致性 多实例共享,单点更新 每个实例独立,一致性差 适合静态资源
容量 GB 级 受 JVM 堆限制 无限(但成本高)
适用场景 动态热点数据 极热点、变化少的数据 静态资源

设计方案

Cache-Aside(旁路缓存)

这是最常用的缓存模式。应用层同时管理缓存和数据库。

sequenceDiagram
    participant Client
    participant App
    participant Redis
    participant DB

    Client->>App: 查询数据
    App->>Redis: GET cache:user:123
    alt 缓存命中
        Redis-->>App: 返回数据
        App-->>Client: 返回数据
    else 缓存未命中
        Redis-->>App: nil
        App->>DB: SELECT * FROM users WHERE id=123
        DB-->>App: 用户数据
        App->>Redis: SET cache:user:123 {data} EX 3600
        App-->>Client: 返回数据
    end

写操作:先更新数据库,再删除缓存(而非更新缓存)。删除比更新更安全,因为避免了并发写导致的脏数据。

1
2
3
4
5
# 读
GET cache:user:123

# 写(先更新 DB,再删缓存)
DEL cache:user:123

缓存三大问题及应对

flowchart LR
    subgraph 缓存穿透
        A1["查询不存在的数据"] --> A2["每次都打到 DB"]
        A2 --> A3["解决:布隆过滤器<br/>或缓存空值"]
    end
    
    subgraph 缓存击穿
        B1["热点 Key 过期"] --> B2["大量请求同时打到 DB"]
        B2 --> B3["解决:互斥锁重建<br/>或永不过期+异步更新"]
    end
    
    subgraph 缓存雪崩
        C1["大量 Key 同时过期"] --> C2["DB 瞬间压力暴增"]
        C2 --> C3["解决:过期时间加随机值<br/>或多级缓存"]
    end

缓存穿透:恶意或错误请求查询不存在的数据,缓存永远不会命中。

1
2
3
4
5
# 方案1:缓存空值(短 TTL)
SET cache:user:999999 "" EX 60

# 方案2:布隆过滤器(见后文"布隆过滤器"章节)
BF.EXISTS user_filter 999999

缓存击穿:某个热点 Key 恰好过期,瞬间大量请求涌入数据库。

1
2
3
# 方案:分布式锁保护重建过程
# 只有拿到锁的请求去查 DB 并重建缓存,其他请求等待或返回旧值
SET lock:rebuild:user:123 1 NX EX 10

缓存雪崩:大量 Key 在同一时刻过期。

1
2
# 方案:过期时间 = 基础时间 + 随机偏移
# 伪代码:SET cache:user:{id} {data} EX (3600 + random(0, 600))

取舍分析

  • 优势:读性能提升 10-100 倍,显著降低 DB 压力
  • 代价:数据一致性是最终一致(有短暂的不一致窗口);增加了系统复杂度(缓存更新策略、异常处理)
  • 不适合:对一致性要求极高的场景(如金融交易余额),或数据量极大且无明显热点的场景

用例二:分布式会话(Session)

业务问题

Web 应用部署多个实例,用户登录后的 Session 如何在多实例间共享?

为什么选 Redis

维度 Redis Sticky Session DB 存储 Session
性能 亚毫秒 无额外开销 毫秒级
可用性 实例宕机不影响 实例宕机丢 Session
扩展性 水平扩展 受限于单实例 受限于 DB
运维 需维护 Redis 负载均衡配置复杂 DB 压力大

设计方案

flowchart LR
    User["用户"] --> LB["负载均衡器"]
    LB --> App1["App 实例 1"]
    LB --> App2["App 实例 2"]
    LB --> App3["App 实例 3"]
    App1 --> Redis["Redis<br/>session:token123 → user_data"]
    App2 --> Redis
    App3 --> Redis
1
2
3
4
5
6
7
8
9
10
11
# 用户登录成功后,生成 Session
SET session:abc123def456 '{"user_id":"123","role":"admin","login_time":1700000000}' EX 1800

# 每次请求验证 Session
GET session:abc123def456

# 续期(用户活跃时)
EXPIRE session:abc123def456 1800

# 用户登出
DEL session:abc123def456

Key Schemasession:{session_token},Token 由服务端生成(UUID 或 JWT 的 jti),通过 Cookie 或 Header 传递给客户端。

取舍分析

  • 优势:无状态部署,实例可随意扩缩容;Session 天然支持 TTL 自动过期
  • 代价:每次请求多一次 Redis 网络往返;Redis 宕机影响所有用户登录态
  • 缓解:Redis Sentinel/Cluster 保证高可用;本地缓存 Session 减少网络调用

🔑 模式提炼:KV + TTL 万能临时状态

回顾用例一(缓存)和用例二(Session),它们的底层结构完全相同:一个 String Key 存一段数据,配一个过期时间。区别仅在于 Key 的命名和 TTL 的长短。

模式公式SET {业务域}:{标识符} {状态数据} EX {生命周期秒数}

这个模式可以直接迁移到以下场景——只需要换参数:

场景 Key Value TTL 说明
缓存 cache:user:123 序列化的用户数据 3600s 降低 DB 压力
Session session:token 用户登录态 JSON 1800s 多实例共享
短信验证码 sms:code:13800138000 "384729" 300s 5 分钟有效
邮箱验证链接 verify:email:uuid "user_123" 86400s 24 小时有效
短链接 short:abc123 "https://..." 永不过期或 30 天 302 跳转
接口幂等 idempotent:order:req_uuid "1" 600s 防重复提交
分布式限流标记 blocked:ip:1.2.3.4 "1" 3600s IP 封禁 1 小时

核心洞察:任何"有生命周期的临时状态",都可以用这个模式一行命令解决。你不需要为每种场景设计不同的方案——它们本质上是同一个东西,只是 Key 的命名空间和 TTL 不同。


用例三:分布式锁

业务问题

多个服务实例并发操作同一资源(如扣减库存),如何保证互斥?

为什么选 Redis

维度 Redis ZooKeeper 数据库行锁
性能 极高(万级 QPS) 中等
可靠性 需要额外机制防死锁 临时节点天然防死锁
复杂度 高(需 ZK 集群)
适用场景 高性能互斥 强一致性场景 已有 DB 的简单场景

设计方案

基础版:SETNX + 过期时间

sequenceDiagram
    participant Client1
    participant Client2
    participant Redis

    Client1->>Redis: SET lock:order:123 uuid1 NX EX 30
    Redis-->>Client1: OK(获锁成功)
    
    Client2->>Redis: SET lock:order:123 uuid2 NX EX 30
    Redis-->>Client2: nil(获锁失败)
    
    Note over Client1: 执行业务逻辑...
    
    Client1->>Redis: Lua: if GET == uuid1 then DEL
    Redis-->>Client1: 释放成功
1
2
3
4
5
6
7
8
# 加锁:SET key value NX EX seconds
# NX = 不存在才设置(互斥语义)
# EX = 过期时间(防死锁)
# value = 唯一标识(防误删别人的锁)
SET lock:order:123 "uuid-abc-123" NX EX 30

# 释放锁:必须用 Lua 脚本保证原子性(先比较再删除)
# 如果直接 DEL,可能删掉别人的锁
1
2
3
4
5
6
-- 释放锁的 Lua 脚本
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end

进阶:Redlock 算法

当 Redis 是单点时,主节点宕机可能导致锁丢失。Redlock 使用 N 个独立的 Redis 实例(通常 5 个),客户端需要在多数节点(≥ N/2 + 1)上成功加锁才算获锁。

flowchart TD
    Client["客户端"] --> R1["Redis 1: SET lock NX"]
    Client --> R2["Redis 2: SET lock NX"]
    Client --> R3["Redis 3: SET lock NX"]
    Client --> R4["Redis 4: SET lock NX"]
    Client --> R5["Redis 5: SET lock NX"]
    
    R1 -->|"OK"| Check["获锁成功 ≥ 3/5?"]
    R2 -->|"OK"| Check
    R3 -->|"OK"| Check
    R4 -->|"nil"| Check
    R5 -->|"OK"| Check
    
    Check -->|"是 (4/5)"| Success["获锁成功<br/>有效时间 = TTL - 获锁耗时"]
    Check -->|"否"| Fail["获锁失败<br/>释放所有已获取的锁"]

看门狗机制(锁续期)

业务执行时间可能超过锁的过期时间。Redisson 等客户端库实现了"看门狗"机制:后台线程定期检查锁是否仍被持有,如果是则自动续期。

sequenceDiagram
    participant Client
    participant Watchdog as 看门狗线程
    participant Redis

    Client->>Redis: SET lock:res uuid1 NX EX 30
    Redis-->>Client: OK
    
    Note over Client: 开始执行业务(可能耗时 > 30s)
    
    loop 每 10 秒检查一次
        Watchdog->>Redis: if GET lock:res == uuid1 then EXPIRE 30
        Redis-->>Watchdog: OK(续期成功)
    end
    
    Client->>Redis: Lua: if GET == uuid1 then DEL
    Note over Watchdog: 停止续期

取舍分析

  • 优势:性能极高,实现简单,适合大多数互斥场景
  • 代价:Redis 主从切换时可能短暂丢锁;Redlock 有争议(Martin Kleppmann vs Antirez 的经典论战)
  • 建议:对锁的正确性要求不是"生死攸关"时用 Redis 锁;金融级场景考虑 ZooKeeper 或数据库乐观锁

🔑 模式提炼:原子占位(SET NX)

分布式锁的本质不是"锁",而是**“原子占位”**——在一个共享命名空间中,谁先到谁占坑,后来者被拒绝。SET key value NX 这五个字母,解决了一整类"只能有一个"的问题。

模式公式SET {命名空间}:{资源标识} {持有者标识} NX EX {超时秒数}

场景 Key Value 含义
分布式锁 lock:order:123 uuid 同一时刻只有一个实例处理该订单
幂等控制 idempotent:pay:req_abc "1" 同一个支付请求只处理一次
缓存击穿互斥 lock:rebuild:user:123 "1" 只有一个请求去重建缓存
定时任务防重 cron:daily_report:20250728 instance_id 今天的日报只由一个实例生成
抢购/秒杀资格 seckill:item:789:user:456 "1" 每人限购一次

核心洞察:当你听到"只能有一个"、“不能重复”、"谁先谁得"这类需求时,脑中应该立刻浮现 SET NX。它们都是同一个模式的不同参数化实例。

进阶认知:原子占位模式有三个关键细节,无论应用在哪个场景都适用:

  1. 必须有 TTL(防死锁/防泄漏)
  2. Value 必须唯一(防误释放别人的占位)
  3. 释放必须原子(Lua 脚本:先比较再删除)

用例四:计数器与限流器

业务问题

计数器

统计文章阅读量、点赞数、库存数量等,要求高并发下的原子递增/递减。

限流器

防止 API 被恶意调用或突发流量打垮服务,需要对请求频率进行限制。

为什么选 Redis

  • 原子性INCR/DECR 是原子操作,天然线程安全
  • 性能:单实例 10 万+ QPS,远超数据库
  • TTL:天然支持过期,适合滑动窗口等时间相关的计数

设计方案

简单计数器

1
2
3
4
5
6
7
8
9
10
11
12
13
# 文章阅读量 +1
INCR article:123:views
# 返回: (integer) 42

# 获取阅读量
GET article:123:views
# 返回: "42"

# 点赞(原子递增)
INCR post:456:likes

# 取消点赞(原子递减)
DECR post:456:likes

固定窗口限流

最简单的限流:在固定时间窗口内限制请求次数。

flowchart LR
    subgraph "固定窗口 (1分钟)"
        A["12:00:00 - 12:00:59<br/>限制 100 次"]
        B["12:01:00 - 12:01:59<br/>限制 100 次"]
    end
    C["⚠️ 临界问题:<br/>12:00:50-12:01:10 的 20 秒内<br/>可能通过 200 次请求"]
1
2
3
4
5
6
7
8
# 固定窗口限流
# Key 包含时间窗口标识(分钟级)
INCR rate:user:123:202307281200
EXPIRE rate:user:123:202307281200 60

# 检查是否超限
GET rate:user:123:202307281200
# 如果 > 100,拒绝请求

滑动窗口限流(推荐)

使用 Sorted Set 实现真正的滑动窗口,解决固定窗口的临界问题。

flowchart TD
    A["请求到达"] --> B["ZREMRANGEBYSCORE<br/>移除窗口外的旧请求"]
    B --> C["ZCARD<br/>统计窗口内请求数"]
    C --> D{"请求数 < 限制?"}
    D -->|"是"| E["ZADD 记录本次请求<br/>EXPIRE 设置 Key 过期"]
    D -->|"否"| F["拒绝请求"]
1
2
3
4
5
6
7
8
9
10
11
12
# 滑动窗口限流(每分钟最多 100 次)
# current_timestamp = 1700000000000(当前时间戳,毫秒)

# 1. 移除 1 分钟前的旧记录
ZREMRANGEBYSCORE rate:user:123 0 1699999940000

# 2. 统计当前窗口内的请求数
ZCARD rate:user:123

# 3. 如果未超限,记录本次请求(member 需唯一,用时间戳+随机数)
ZADD rate:user:123 1700000000000 "1700000000000-384729"
EXPIRE rate:user:123 60

为了保证原子性,应使用 Lua 脚本将上述步骤合并:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 滑动窗口限流 Lua 脚本
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)

if count < limit then
redis.call('ZADD', key, now, now .. '-' .. math.random(1000000))
redis.call('EXPIRE', key, math.ceil(window / 1000))
return 1 -- 允许
else
return 0 -- 拒绝
end

令牌桶限流

令牌桶允许一定程度的突发流量,比滑动窗口更灵活。核心思想:令牌以固定速率放入桶中,每个请求消耗一个令牌,桶满则丢弃新令牌。

flowchart LR
    A["令牌以固定速率<br/>放入桶中"] --> B["桶 (容量上限)"]
    B --> C["每个请求<br/>消耗一个令牌"]
    C --> D{"桶中有令牌?"}
    D -->|"有"| E["放行"]
    D -->|"无"| F["拒绝/等待"]

使用 Hash 存储令牌桶状态,Lua 脚本保证原子性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
-- 令牌桶 Lua 脚本
local key = KEYS[1]
local rate = tonumber(ARGV[1]) -- 每秒生成的令牌数
local capacity = tonumber(ARGV[2]) -- 桶容量
local now = tonumber(ARGV[3]) -- 当前时间戳(秒)
local requested = tonumber(ARGV[4]) -- 请求的令牌数

local last_time = tonumber(redis.call('HGET', key, 'last_time') or now)
local tokens = tonumber(redis.call('HGET', key, 'tokens') or capacity)

-- 计算从上次到现在新生成的令牌
local elapsed = math.max(0, now - last_time)
tokens = math.min(capacity, tokens + elapsed * rate)

if tokens >= requested then
tokens = tokens - requested
redis.call('HSET', key, 'last_time', now)
redis.call('HSET', key, 'tokens', tokens)
redis.call('EXPIRE', key, math.ceil(capacity / rate) * 2)
return 1 -- 允许
else
redis.call('HSET', key, 'last_time', now)
redis.call('HSET', key, 'tokens', tokens)
return 0 -- 拒绝
end

取舍分析

  • 优势:原子操作天然线程安全;性能远超数据库方案;TTL 自动清理过期数据
  • 代价:计数器数据在内存中,Redis 宕机可能丢失(可通过持久化缓解);滑动窗口的 ZSet 在高并发下内存占用较大
  • 不适合:需要精确持久化的计数(如账户余额),应使用数据库

用例五:排行榜

业务问题

游戏积分排名、热门文章排行、销量排行等,需要实时更新分数并快速查询排名。

为什么选 Redis

排行榜的核心操作是"按分数排序 + 查询排名",这恰好是 Sorted Set 的原生能力。用 MySQL 实现需要 ORDER BY score DESC LIMIT N,在数据量大时性能急剧下降。

操作 Redis ZSet MySQL
更新分数 O(log N) O(log N) + 写磁盘
查询排名 O(log N) O(N log N) 全表排序
Top N O(log N + N) O(N log N)
查询周围排名 O(log N) 复杂子查询

设计方案

flowchart TD
    subgraph "Sorted Set: leaderboard:global"
        direction LR
        A["player_2: 2100"] 
        B["player_1: 1800"]
        C["player_3: 1000"]
    end
    
    D["ZADD 更新分数"] --> A
    E["ZREVRANK 查排名"] --> A
    F["ZREVRANGE 0 N Top N"] --> A
    G["ZINCRBY 加分"] --> A

核心命令速查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 更新/添加用户分数(ZADD 自动覆盖已有 member 的 score)
ZADD leaderboard:global 1500 player_1
ZADD leaderboard:global 2100 player_2

# 只在新分数更高时更新(Redis 6.2+)
ZADD leaderboard:global GT 1800 player_1

# 增加分数(原子操作,比 ZSCORE + ZADD 更好)
ZINCRBY leaderboard:global 50 player_3

# 查询排名(0-based,0 = 第一名)
ZREVRANK leaderboard:global player_2
# 返回: (integer) 0

# 查询分数(O(1) 哈希查找,非常快)
ZSCORE leaderboard:global player_1
# 返回: "1800"

# 获取 Top 3(从高到低)
ZREVRANGE leaderboard:global 0 2 WITHSCORES
# 返回: player_2, 2100, player_1, 1800, player_3, 1000

# 获取用户周围的排名(前后各 2 名)
# 先获取排名
ZREVRANK leaderboard:global player_1 # 假设返回 1
# 再获取范围 [max(0, 1-2), 1+2] = [0, 3]
ZREVRANGE leaderboard:global 0 3 WITHSCORES

# 分页:第 2 页,每页 10 条(页码从 0 开始)
ZREVRANGE leaderboard:global 10 19 WITHSCORES

多维度排行榜(日榜/周榜/月榜)

1
2
3
4
5
6
7
8
9
10
11
12
13
# 日榜(每天一个 Key,设置次日过期)
ZINCRBY leaderboard:daily:20250728 10 player_1
EXPIREAT leaderboard:daily:20250728 1690588800 # 次日 0 点的时间戳

# 周榜(合并 7 天的日榜,ZUNIONSTORE 默认对相同 member 的 score 求和)
ZUNIONSTORE leaderboard:weekly:2025W30 7 \
leaderboard:daily:20250722 \
leaderboard:daily:20250723 \
leaderboard:daily:20250724 \
leaderboard:daily:20250725 \
leaderboard:daily:20250726 \
leaderboard:daily:20250727 \
leaderboard:daily:20250728

ZSet 命令分类记忆

类别 命令 说明 时间复杂度
写入 ZADD 添加/更新成员,支持 NX/XX/GT/LT 选项 O(log N)
增量 ZINCRBY 原子加分 O(log N)
排名 ZRANK / ZREVRANK 升序/降序排名(0-based) O(log N)
分数 ZSCORE 查询单个成员的分数 O(1)
范围-按排名 ZRANGE / ZREVRANGE 按排名范围获取成员 O(log N + M)
范围-按分数 ZRANGEBYSCORE / ZREVRANGEBYSCORE 按分数区间获取成员 O(log N + M)
删除 ZREM 移除成员 O(log N)
集合运算 ZUNIONSTORE / ZINTERSTORE 合并多个 ZSet O(N)
计数 ZCARD / ZCOUNT 总数 / 分数区间内的数量 O(1) / O(log N)

性能提示ZSCORE 是 O(1) 的哈希查找,而 ZRANGEBYSCORE 是 O(log N + M) 的范围查询(M 为结果集大小)。查单个成员的分数永远用 ZSCORE。区间查找即使在 Redis 里也应该加上 LIMIT,避免返回过多数据。

Range 索引规则

Redis 的 ZRANGE/ZREVRANGE/LRANGE 等命令的索引规则:

  • 索引从 0 开始,第一个元素索引为 0
  • 支持负数索引:-1 表示最后一个元素,-2 表示倒数第二个
  • start 和 stop 都是闭区间(包含两端),这与 Python 切片(左闭右开)不同
  • 常用组合0 0 = 第一个元素,0 -1 = 所有元素,0 9 = 前 10 个,-5 -1 = 最后 5 个

取舍分析

  • 优势:实时排名,O(log N) 的更新和查询;天然支持分页和范围查询
  • 代价:全量数据在内存中,百万级用户的排行榜约占几十 MB;不支持复杂的多条件排序
  • 不适合:需要按多个维度联合排序的场景(如"先按等级排,等级相同按经验排"),需要额外设计 Score 编码(如将两个维度编码到一个 double 中)

用例六:延时队列

业务问题

订单 30 分钟未支付自动取消、邮件定时发送、定时任务调度等——需要在未来某个时间点触发某个操作。

为什么选 Redis

维度 Redis ZSet RabbitMQ 延时插件 数据库轮询 时间轮 (HashedWheelTimer)
精度 秒级 秒级 取决于轮询间隔 毫秒级
可靠性 需要额外保证 高(消息确认机制) 进程内,宕机丢失
复杂度 中(需要 MQ 基础设施)
吞吐量

设计方案

核心思想:将消息的到期时间戳作为 ZSet 的 Score,Worker 定期查询 Score ≤ 当前时间的消息并处理。

sequenceDiagram
    participant Producer as 生产者
    participant Redis as Redis ZSet
    participant Worker as Worker 消费者

    Producer->>Redis: ZADD delayed_queue:order_timeout<br/>1678886700 '{"order_id":"123","action":"cancel"}'
    
    Note over Redis: Score = 到期时间戳<br/>Member = 消息体 (JSON)

    loop 每秒轮询
        Worker->>Redis: ZRANGEBYSCORE delayed_queue:order_timeout<br/>0 {current_timestamp} LIMIT 0 10
        alt 有到期消息
            Redis-->>Worker: 返回到期消息列表
            Worker->>Worker: 执行业务逻辑(取消订单)
            Worker->>Redis: ZREM delayed_queue:order_timeout {message}
        else 无到期消息
            Redis-->>Worker: 空列表
            Worker->>Worker: sleep(1)
        end
    end

核心命令

1
2
3
4
5
6
7
8
9
# 生产者:添加延时消息
# Score = 当前时间 + 延迟时间(如 30 分钟 = 1800 秒)
ZADD delayed_queue:order_timeout 1678886700 '{"order_id":"12345","action":"cancel"}'

# 消费者:查询到期消息(Score ≤ 当前时间,升序取最早到期的)
ZRANGEBYSCORE delayed_queue:order_timeout 0 1678886800 LIMIT 0 10

# 消费者:处理完成后移除(ZRANGEBYSCORE 不会自动移除元素!)
ZREM delayed_queue:order_timeout '{"order_id":"12345","action":"cancel"}'

关键点ZRANGEBYSCORE 只是查询,不会移除元素。必须通过 ZREM 显式移除已处理的消息。

原子性保证:Lua 脚本

在多 Worker 场景下,"查询"和"移除"必须是原子操作,否则同一条消息可能被多个 Worker 重复处理。

1
2
3
4
5
6
7
8
-- 原子性地获取并移除一条到期消息
local expired = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 1)
if #expired > 0 then
redis.call('ZREM', KEYS[1], expired[1])
return expired[1]
else
return nil
end

消费者伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
lua_script = redis_client.register_script("""
local expired = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 1)
if #expired > 0 then
redis.call('ZREM', KEYS[1], expired[1])
return expired[1]
else
return nil
end
""")

while True:
current_timestamp = get_current_unix_timestamp()
message = lua_script(keys=['delayed_queue:order_timeout'], args=[current_timestamp])
if message:
task_data = json.loads(message)
process_message(task_data) # 执行业务逻辑(必须幂等)
else:
time.sleep(1) # 避免空轮询

设计要点

  1. 队列 Key 命名delayed_queue:{业务类型},如 delayed_queue:order_timeoutdelayed_queue:email_reminders
  2. Member 是消息体本身(JSON 字符串),而不是指向 Hash 的引用
  3. 消息处理必须幂等:即使因故障重复处理,结果也应该一致
  4. 批量消费时的分页:如果一次取多条,取完后以本批最大 Score 作为下一次查询的起始值

取舍分析

  • 优势:实现简单,无需额外中间件;精度可达秒级;支持任意延迟时间
  • 代价:轮询有固有延迟(通常 1 秒);消息可靠性不如专业 MQ(无 ACK 机制,需自行实现);大量延时消息占用内存
  • 建议:轻量级延时任务用 Redis;对可靠性要求高的场景用 RabbitMQ/RocketMQ 的延时消息功能

🔑 模式提炼:Score 即时间轴

回顾用例四的滑动窗口限流、用例五的排行榜、用例六的延时队列,它们用的都是 ZSet,但 Score 的含义不同:

用例 Score 的含义 Member 的含义 核心查询
排行榜 分数/积分 玩家 ID ZREVRANGE(按分数从高到低)
延时队列 到期时间戳 消息体 JSON ZRANGEBYSCORE 0 now(到期的消息)
滑动窗口限流 请求时间戳 请求唯一标识 ZCARD(窗口内请求数)
时间线 Feed 发布时间戳 帖子 ID ZREVRANGE(最新的帖子)
优先级队列 优先级数值 任务 ID ZRANGEBYSCORE(最高优先级)

模式公式ZADD {队列名} {时间戳或分数} {元素},然后用 ZRANGEBYSCOREZREVRANGE 做范围查询。

核心洞察:ZSet 的本质是一条可查询的数轴。Score 可以是任何有序的数值——时间戳、分数、优先级、权重、距离。只要你的业务需要"按某个数值排序并做范围查询",就应该想到 ZSet。

迁移清单——把 Score 换成不同含义,就得到不同的系统:

1
2
3
4
5
6
7
Score = 到期时间  → 延时队列、定时任务
Score = 积分 → 排行榜、热度排序
Score = 请求时间 → 滑动窗口限流
Score = 发布时间 → 时间线、最新动态
Score = 优先级 → 优先级队列
Score = 价格 → 价格区间筛选
Score = 距离 → 附近的人(GEO 底层就是这么做的)

用例七:社交关系(关注/粉丝/共同好友)

业务问题

社交网络中的关注、粉丝、共同好友、推荐好友等功能,核心是集合的交集、并集、差集运算

为什么选 Redis

关系型数据库处理"共同好友"需要两次 JOIN 或子查询,性能随数据量增长急剧下降。Redis 的 Set 提供了 O(N) 的集合运算,且全在内存中完成。

设计方案

flowchart TD
    subgraph "用户 A 的关系"
        A1["following:A = {B, C, D}"]
        A2["followers:A = {B, E}"]
    end
    
    subgraph "用户 B 的关系"
        B1["following:B = {A, C, E}"]
        B2["followers:B = {A, D}"]
    end
    
    A1 --- |"SINTER following:A following:B"| Common["共同关注: {C}"]
    A1 --- |"SDIFF following:B following:A"| Recommend["推荐关注: {E}<br/>(B 关注了但 A 没关注)"]

数据结构

每个用户维护两个 Set:

1
2
3
4
5
# 用户的关注列表
# Key: following:{user_id},Member: 被关注用户的 ID

# 用户的粉丝列表
# Key: followers:{user_id},Member: 粉丝用户的 ID

核心操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# === 关注操作(双向写入) ===
SADD following:A B # A 的关注列表加入 B
SADD followers:B A # B 的粉丝列表加入 A

# === 取消关注 ===
SREM following:A B
SREM followers:B A

# === 查询操作 ===
SMEMBERS following:A # A 的关注列表
SCARD followers:A # A 的粉丝数
SISMEMBER following:A B # A 是否关注了 B?返回 1 或 0

# === 集合运算 ===
SINTER following:A following:B # A 和 B 的共同关注
SINTER followers:A followers:B # A 和 B 的共同粉丝
SDIFF following:B following:A # 推荐关注:B 关注了但 A 没关注的人
SINTER following:A followers:A # A 的互相关注(好友)

互相关注判断优化

判断两个用户是否互相关注,可以维护一个额外的"好友"Set:

1
2
3
4
5
# A 关注 B 时,检查 B 是否已关注 A
SISMEMBER following:B A
# 如果返回 1,说明互相关注,加入好友列表
SADD friends:A B
SADD friends:B A

取舍分析

  • 优势:集合运算是 Redis 的原生能力,性能极高;SISMEMBER O(1) 判断关系
  • 代价:大 V 用户的粉丝列表可能有千万级成员,单个 Set 过大会影响性能和内存;双向写入需要保证一致性
  • 缓解:大 V 粉丝列表可以只存计数,不存完整列表;使用 Pipeline 或 Lua 脚本保证双向写入的原子性
  • 不适合:需要复杂查询的关系(如"关注了 A 且同时关注了 B 的用户中,最近 7 天活跃的"),应使用图数据库(如 Neo4j)

🔑 模式提炼:双向关系 + 集合运算

社交关系的核心不是"关注"这个动作,而是**“一个关系写两个 Set,然后用集合运算回答所有关系问题”**。这个模式可以迁移到任何存在双向关系的场景。

模式公式

  • 建立关系:SADD {关系}:{主体} {客体} + SADD {反向关系}:{客体} {主体}
  • 查询关系:SINTER / SUNION / SDIFF
场景 Set A Set B SINTER SDIFF
社交关注 following:A following:B 共同关注 推荐关注
标签系统 tag:java的文章 tag:redis的文章 同时有两个标签的文章 只有 java 没有 redis 的文章
权限系统 role:admin的权限 role:editor的权限 两个角色共有的权限 admin 独有的权限
商品属性 brand:nike的商品 color:red的商品 红色的 Nike 商品 Nike 非红色商品
课程选修 student:A的课程 student:B的课程 共同选修的课程 A 选了 B 没选的课程

核心洞察:当你看到"共同的 XX"、“推荐 XX”、"互相 XX"这类需求时,本质上都是集合的交集、差集运算。一个关系建两个 Set,三种运算回答所有问题

注意边界:当单个 Set 的成员数超过万级时(如大 V 的粉丝列表),SINTER/SDIFF 的 O(N) 复杂度会成为瓶颈。此时应考虑:只存计数不存完整列表,或将大 Set 拆分到多个 Key。


用例八:二级评论系统

业务问题

文章/帖子下的评论系统,支持一级评论和对评论的回复(二级评论),需要按时间排序和分页。

为什么选 Redis

评论系统的读远多于写,且需要按时间排序和分页——这正是 ZSet 的强项。Redis 可以作为评论系统的读缓存层,配合数据库做持久化。

设计方案

三层存储设计

flowchart TD
    subgraph "数据模型"
        H1["Hash: comment:1001<br/>content, user_id, post_id,<br/>parent_id=0, timestamp, status"]
        H2["Hash: comment:1002<br/>content, user_id, post_id,<br/>parent_id=1001, timestamp, status"]
        
        Z1["ZSet: post:123:comments<br/>member=1001, score=timestamp"]
        Z2["ZSet: comment:1001:replies<br/>member=1002, score=timestamp"]
    end
    
    Z1 -->|"ZREVRANGE"| H1
    Z2 -->|"ZREVRANGE"| H2
    H2 -->|"parent_id"| H1
  1. 评论内容(Hash):每条评论一个 Hash,存储完整信息
  2. 文章的一级评论列表(ZSet):Score 为时间戳,Member 为评论 ID
  3. 评论的回复列表(ZSet):Score 为时间戳,Member 为回复 ID
1
2
3
4
5
6
7
8
9
10
11
12
# === 评论内容 Hash ===
# Key: comment:{comment_id}
HSET comment:1001 content "Great article!" user_id "user_a" \
post_id "post_123" parent_id "0" timestamp 1700000000 status "active"

# === 文章的一级评论列表 ZSet ===
# Key: post:{post_id}:comments
ZADD post:post_123:comments 1700000000 1001

# === 评论的回复列表 ZSet ===
# Key: comment:{parent_id}:replies
ZADD comment:1001:replies 1700000050 1002

核心操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# === 发表一级评论 ===
INCR global:comment_id_counter # 生成唯一 ID,假设得到 1001
HSET comment:1001 content "Great article!" user_id "user_a" \
post_id "post_123" parent_id "0" timestamp 1700000000 status "active"
ZADD post:post_123:comments 1700000000 1001

# === 发表二级评论(回复) ===
INCR global:comment_id_counter # 假设得到 1002
HSET comment:1002 content "Thanks!" user_id "user_b" \
post_id "post_123" parent_id "1001" timestamp 1700000050 status "active"
# 注意:回复只加入父评论的 replies 列表,不加入文章的 comments 列表
ZADD comment:1001:replies 1700000050 1002

# === 读取文章评论(分页 + 回复) ===
# Step 1: 获取一级评论 ID(最新 10 条)
ZREVRANGE post:post_123:comments 0 9

# Step 2: 批量获取评论内容(使用 Pipeline 减少网络往返)
HGETALL comment:1001
HGETALL comment:1003

# Step 3: 获取每条评论的前 3 条回复
ZREVRANGE comment:1001:replies 0 2
HGETALL comment:1002

# === 逻辑删除(推荐) ===
HSET comment:1001 status "deleted"
# 读取时过滤 status="deleted" 的评论

# === 物理删除(复杂,需要清理多处) ===
ZREM post:post_123:comments 1001 # 从文章评论列表移除
DEL comment:1001:replies # 删除回复列表
DEL comment:1001 # 删除评论内容

读取流程

sequenceDiagram
    participant Client
    participant App
    participant Redis

    Client->>App: GET /post/123/comments?page=1
    App->>Redis: ZREVRANGE post:123:comments 0 9
    Redis-->>App: [1001, 1003, 1005, ...]
    
    Note over App: 使用 Pipeline 批量获取
    
    App->>Redis: Pipeline: HGETALL comment:1001,<br/>HGETALL comment:1003, ...
    Redis-->>App: 所有评论内容
    
    App->>Redis: Pipeline: ZREVRANGE comment:1001:replies 0 2,<br/>ZREVRANGE comment:1003:replies 0 2, ...
    Redis-->>App: 所有回复 ID 列表
    
    App->>Redis: Pipeline: HGETALL comment:{reply_ids}...
    Redis-->>App: 所有回复内容
    
    App-->>Client: 组装后的评论树

设计要点:回复只加入父评论的 replies 列表,不加入文章的 comments 列表——这是"主表持有关联表主键控制权,从表通过 parent_id 寻找父节点"的设计模式。使用 Pipeline 批量发送命令,将多次网络往返合并为一次。

取舍分析

  • 优势:读性能极高,天然支持按时间排序和分页
  • 代价:数据冗余(Hash + ZSet 双写);不支持复杂查询(如"搜索包含某关键词的评论");需要配合数据库做持久化
  • 建议:Redis 作为读缓存层,数据库作为持久化层;写操作先写 DB 再同步到 Redis

用例九:消息队列

业务问题

服务间异步通信、事件驱动架构、削峰填谷等场景需要消息队列。

Redis 提供了三种消息队列方案

flowchart TD
    A["Redis 消息队列方案"] --> B["List<br/>LPUSH/BRPOP"]
    A --> C["Pub/Sub<br/>PUBLISH/SUBSCRIBE"]
    A --> D["Stream<br/>XADD/XREADGROUP"]
    
    B --> B1["✅ 简单可靠<br/>❌ 不支持多消费者组<br/>❌ 无 ACK 机制"]
    C --> C1["✅ 广播模式<br/>❌ 消息不持久化<br/>❌ 离线消费者丢消息"]
    D --> D1["✅ 消费者组<br/>✅ 消息持久化<br/>✅ ACK 机制<br/>✅ 最接近专业 MQ"]

方案一:List 队列

最简单的消息队列,使用 List 的 LPUSH/BRPOP 实现。

1
2
3
4
5
6
# 生产者:推入消息
LPUSH queue:emails '{"to":"user@example.com","subject":"Welcome"}'

# 消费者:阻塞弹出(等待最多 30 秒)
BRPOP queue:emails 30
# 返回: 1) "queue:emails" 2) '{"to":"user@example.com","subject":"Welcome"}'

BRPOP vs RPOPBRPOP 是阻塞版本,队列为空时会等待而非返回 nil,避免了空轮询消耗 CPU。

方案二:Pub/Sub 发布订阅

适合实时广播场景(如聊天室、实时通知),但消息不持久化

1
2
3
4
5
6
7
8
# 订阅者(先启动)
SUBSCRIBE channel:notifications

# 发布者
PUBLISH channel:notifications '{"type":"new_order","order_id":"123"}'

# 模式订阅(支持通配符)
PSUBSCRIBE channel:*

致命缺陷:如果订阅者不在线,消息会永久丢失。Pub/Sub 没有消息积压能力,不适合需要可靠投递的场景。

方案三:Stream(Redis 5.0+,推荐)

Stream 是最接近专业消息队列的方案,支持消费者组、消息确认、消息回溯。

sequenceDiagram
    participant Producer
    participant Stream as Redis Stream
    participant C1 as Consumer 1
    participant C2 as Consumer 2

    Producer->>Stream: XADD mystream * key value
    Note over Stream: 消息 ID: 1678886400000-0
    
    C1->>Stream: XREADGROUP GROUP mygroup consumer1<br/>COUNT 1 BLOCK 5000 STREAMS mystream >
    Stream-->>C1: 消息分配给 Consumer 1
    
    C2->>Stream: XREADGROUP GROUP mygroup consumer2<br/>COUNT 1 BLOCK 5000 STREAMS mystream >
    Stream-->>C2: 下一条消息分配给 Consumer 2
    
    C1->>Stream: XACK mystream mygroup 1678886400000-0
    Note over C1: 确认消息已处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 创建消费者组($ 表示从最新消息开始消费,MKSTREAM 自动创建 Stream)
XGROUP CREATE mystream mygroup $ MKSTREAM

# 生产者:添加消息(* 表示自动生成 ID,格式为 时间戳-序号)
XADD mystream * order_id 123 action "process"
# 返回: "1678886400000-0"

# 消费者:从消费者组读取(> 表示读取未分配的新消息)
XREADGROUP GROUP mygroup consumer1 COUNT 1 BLOCK 5000 STREAMS mystream >

# 确认消息已处理
XACK mystream mygroup "1678886400000-0"

# 查看未确认的消息(Pending 列表,用于监控和故障恢复)
XPENDING mystream mygroup

# 转移超时未确认的消息给其他消费者(故障转移,60000ms = 1分钟超时)
XCLAIM mystream mygroup consumer2 60000 "1678886400000-0"

三种方案对比

特性 List Pub/Sub Stream
消息持久化
消费者组
消息确认 (ACK)
阻塞读取 ✅ (BRPOP) ✅ (SUBSCRIBE) ✅ (BLOCK)
消息回溯
广播 ✅(多消费者组)
适用场景 简单任务队列 实时通知/广播 可靠消息队列

取舍分析

  • 优势:无需额外中间件,Stream 功能已接近 Kafka/RabbitMQ 的基础能力
  • 代价:消息堆积受内存限制;不支持事务消息、延时消息等高级特性;集群模式下 Stream 不支持跨分片
  • 建议:轻量级异步任务用 Redis Stream;高可靠、高吞吐场景用 Kafka/RocketMQ

🔑 模式提炼:实体 + 索引分离

回顾用例八(评论系统)和用例九(消息队列),它们都使用了同一个架构模式:Hash 存实体内容,ZSet/Set/List 存索引。这是 Redis 中最重要的复合数据建模模式。

模式公式

  • 实体存储:HSET {实体类型}:{ID} field1 value1 field2 value2 ...
  • 索引维护:ZADD {父实体}:{父ID}:{子实体列表} {排序分数} {子实体ID}
  • 读取流程:先查索引拿 ID 列表 → 再用 Pipeline 批量查实体内容
flowchart LR
    subgraph "通用模式"
        Index["索引层<br/>ZSet/Set/List<br/>存 ID + 排序信息"] -->|"Pipeline 批量查"| Entity["实体层<br/>Hash<br/>存完整内容"]
    end
场景 实体 (Hash) 索引 (ZSet) Score
评论系统 comment:{id} → 评论内容 post:{id}:comments → 评论 ID 列表 时间戳
商品列表 product:{id} → 商品详情 category:{id}:products → 商品 ID 列表 销量/价格
用户动态 post:{id} → 帖子内容 user:{id}:timeline → 帖子 ID 列表 发布时间
搜索缓存 doc:{id} → 文档内容 search:{keyword}:results → 文档 ID 列表 相关度
订单列表 order:{id} → 订单详情 user:{id}:orders → 订单 ID 列表 下单时间

核心洞察:这个模式本质上是关系型数据库的"主表 + 索引"在 Redis 中的映射。Hash 相当于数据行,ZSet 相当于索引。区别在于:索引需要你手动维护(写入时双写),但读取性能远超数据库。

关键技巧

  1. Pipeline 是必须的——先查索引拿到 N 个 ID,再用 Pipeline 一次性发 N 个 HGETALL,将 N 次网络往返压缩为 1 次
  2. 索引和实体的一致性——写入时必须同时写 Hash 和 ZSet(用 Pipeline 或 Lua 保证)
  3. 删除要清理两处——删实体时别忘了从索引中移除

用例十:地理位置服务(附近的人)

业务问题

“附近的人”、“附近的餐厅”、“3 公里内的门店”——基于经纬度的距离计算和范围查询。

为什么选 Redis

Redis 的 GEO 命令底层使用 GeoHash 编码 + Sorted Set,将二维坐标映射为一维分数,利用 ZSet 的范围查询能力实现地理搜索。

维度 Redis GEO MySQL 空间索引 ElasticSearch
性能 极高(内存计算) 中等(磁盘 I/O) 中等
功能 距离计算、圆形范围搜索 完整空间查询(多边形等) 全文搜索 + 地理混合
精度 米级
适用场景 简单的"附近"查询 复杂空间关系 搜索 + 地理混合查询

设计方案

flowchart LR
    subgraph "GEO 底层原理"
        A["经纬度 (lng, lat)"] --> B["GeoHash 编码"]
        B --> C["52位整数"]
        C --> D["作为 ZSet 的 Score 存储"]
    end
    
    subgraph "范围查询"
        E["中心点 + 半径"] --> F["计算 GeoHash 范围"]
        F --> G["ZRANGEBYSCORE 范围查询"]
        G --> H["过滤 + 精确距离计算"]
    end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 添加地理位置
GEOADD stores:city:hangzhou 120.1551 30.2741 "store_001"
GEOADD stores:city:hangzhou 120.1612 30.2590 "store_002"
GEOADD stores:city:hangzhou 120.2100 30.2100 "store_003"

# 计算两点距离(支持 m/km/mi/ft 单位)
GEODIST stores:city:hangzhou store_001 store_002 km
# 返回: "1.8432"

# 查询某点附近 5km 内的门店(Redis 6.2+ 推荐 GEOSEARCH)
GEOSEARCH stores:city:hangzhou \
FROMLONLAT 120.1600 30.2700 \
BYRADIUS 5 km \
WITHCOORD WITHDIST COUNT 10 ASC

# 查询某个成员附近的其他成员
GEOSEARCH stores:city:hangzhou \
FROMMEMBER store_001 \
BYRADIUS 3 km \
ASC COUNT 5

# 获取成员的经纬度
GEOPOS stores:city:hangzhou store_001

# 旧版命令(Redis < 6.2)
GEORADIUS stores:city:hangzhou 120.1600 30.2700 5 km \
WITHCOORD WITHDIST COUNT 10 ASC

取舍分析

  • 优势:开箱即用,无需额外地理索引;性能极高(内存计算);API 简洁
  • 代价:只支持圆形范围查询(不支持多边形围栏);不支持复杂空间关系(如"在某条路线上的");数据量大时内存占用高
  • 不适合:需要多边形围栏、路径规划等复杂地理计算的场景,应使用 PostGIS 或 ElasticSearch

用例十一:UV 统计(HyperLogLog)

业务问题

统计网站的独立访客数(UV)、独立 IP 数等,要求去重计数。如果用 Set 存储每个访客 ID,百万级 UV 需要几十 MB 内存。

为什么选 Redis

HyperLogLog 是一种概率数据结构,用 固定 12 KB 内存 就能统计 2^64 个不同元素的基数,误差率仅 0.81%。

方案 内存占用(100 万 UV) 精确度 支持合并
Set ~40 MB 精确 ✅ (SUNION)
Bitmap ~125 KB(ID 为连续整数时) 精确 ✅ (BITOP OR)
HyperLogLog 12 KB 误差 ≤ 0.81% ✅ (PFMERGE)

设计方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 记录访客(重复添加不会增加计数)
PFADD uv:page:homepage:20250728 "user_123"
PFADD uv:page:homepage:20250728 "user_456"
PFADD uv:page:homepage:20250728 "user_123" # 重复,不计

# 查询 UV
PFCOUNT uv:page:homepage:20250728
# 返回: (integer) 2

# 合并多天的 UV(去重后的总 UV,而非简单求和)
PFMERGE uv:page:homepage:week \
uv:page:homepage:20250722 \
uv:page:homepage:20250723 \
uv:page:homepage:20250724 \
uv:page:homepage:20250725 \
uv:page:homepage:20250726 \
uv:page:homepage:20250727 \
uv:page:homepage:20250728

PFCOUNT uv:page:homepage:week
# 返回周 UV(已去重)

取舍分析

  • 优势:极低内存占用(12 KB/Key,无论元素数量多少);支持合并统计;O(1) 的添加和查询
  • 代价:有 0.81% 的误差;不能查询"某个元素是否存在";不能获取元素列表
  • 适用:UV、独立 IP 等对精确度要求不高的大规模去重计数
  • 不适用:需要精确计数或需要知道具体元素的场景

用例十二:布隆过滤器

业务问题

判断一个元素是否可能存在于一个大集合中。典型场景:缓存穿透防护、垃圾邮件过滤、推荐系统去重(“用户是否已经看过这篇文章”)。

为什么选 Redis

Redis 4.0+ 通过 RedisBloom 模块 提供了布隆过滤器。也可以用 Bitmap 手动实现。

flowchart TD
    subgraph "布隆过滤器原理"
        A["元素 X"] --> B["Hash 函数 1 → 位置 3"]
        A --> C["Hash 函数 2 → 位置 7"]
        A --> D["Hash 函数 3 → 位置 11"]
        B --> E["位数组: 将位置 3 设为 1"]
        C --> F["位数组: 将位置 7 设为 1"]
        D --> G["位数组: 将位置 11 设为 1"]
    end
    
    subgraph "查询"
        H["查询元素 Y"] --> I["计算 3 个 Hash 值"]
        I --> J{"所有对应位都是 1?"}
        J -->|"是"| K["可能存在<br/>(有假阳性概率)"]
        J -->|"否"| L["一定不存在<br/>(100% 准确)"]
    end

核心特性

  • 说"不存在"→ 一定不存在(零假阴性)
  • 说"存在"→ 可能存在(有假阳性概率,可通过参数控制)

设计方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 使用 RedisBloom 模块
# 创建布隆过滤器(预期 100 万元素,误判率 0.01%)
BF.RESERVE user_filter 0.0001 1000000

# 添加元素
BF.ADD user_filter "user_123"
BF.ADD user_filter "user_456"

# 查询元素是否存在
BF.EXISTS user_filter "user_123"
# 返回: (integer) 1(可能存在)

BF.EXISTS user_filter "user_999"
# 返回: (integer) 0(一定不存在)

# 批量操作
BF.MADD user_filter "user_789" "user_012"
BF.MEXISTS user_filter "user_789" "user_999"

缓存穿透防护实战

sequenceDiagram
    participant Client
    participant App
    participant BloomFilter as 布隆过滤器
    participant Cache as Redis Cache
    participant DB

    Client->>App: 查询 user_id=999999
    App->>BloomFilter: BF.EXISTS user_filter 999999
    alt 布隆过滤器说"不存在"
        BloomFilter-->>App: 0(一定不存在)
        App-->>Client: 返回空(不查 DB,拦截穿透)
    else 布隆过滤器说"可能存在"
        BloomFilter-->>App: 1
        App->>Cache: GET cache:user:999999
        alt 缓存命中
            Cache-->>App: 数据
        else 缓存未命中
            App->>DB: SELECT ...
            DB-->>App: 数据或空
            App->>Cache: SET cache:user:999999 ...
        end
        App-->>Client: 返回结果
    end

取舍分析

  • 优势:极低内存占用(比 Set 小一个数量级);O(k) 的查询(k 为哈希函数数量,通常 3-7)
  • 代价:有假阳性概率(可通过增大位数组和哈希函数数量降低);不支持删除元素(删除会影响其他元素的判断,因为多个元素可能共享同一个位)
  • 变体:Cuckoo Filter(CF.* 命令)支持删除操作,但内存占用略高

🔑 模式提炼:概率换空间

HyperLogLog 和布隆过滤器看似是两个独立的数据结构,但它们背后是同一个设计哲学:用可控的误差换取数量级的内存节省

模式公式:当精确方案的内存成本不可接受时,问自己——“我能容忍多大的误差?”

维度 精确方案 (Set) HyperLogLog 布隆过滤器
回答的问题 “有哪些元素?有多少个?” “大约有多少个不同元素?” “这个元素是否可能存在?”
100 万元素内存 ~40 MB 12 KB ~1.2 MB (0.1% 误判率)
节省倍数 基准 3300 倍 33 倍
误差 0 ≤ 0.81% 可配置(越低越耗内存)
能否枚举元素
能否删除元素 ❌(Cuckoo Filter 可以)

决策树——什么时候用概率结构:

flowchart TD
    A["需要去重/计数"] --> B{"需要知道具体是哪些元素?"}
    B -->|"是"| C["用 Set(精确)"]
    B -->|"否"| D{"需要知道精确数量?"}
    D -->|"是"| C
    D -->|"否"| E{"只需要判断存在性?"}
    E -->|"是"| F["布隆过滤器"]
    E -->|"否"| G["HyperLogLog"]

可迁移场景

场景 概率结构 替代的精确方案 节省
UV 统计 HyperLogLog Set 存所有用户 ID 3000x
缓存穿透防护 布隆过滤器 Set 存所有合法 ID 30x
推荐去重(“用户看过这篇文章吗”) 布隆过滤器 Set 存用户已读列表 30x
垃圾邮件过滤 布隆过滤器 Set 存所有垃圾邮件特征 30x
数据库行存在性预检 布隆过滤器 每次查 DB 减少无效 I/O

核心洞察:在大规模系统中,"精确"往往是一种奢侈。当数据量达到百万、千万级时,先问自己:“我真的需要精确答案吗?” 如果 0.81% 的误差可以接受,你就能用 12 KB 替代 40 MB。


用例十三:分布式 ID 生成

业务问题

分布式系统中需要全局唯一、趋势递增的 ID,用于数据库主键、订单号等。

为什么选 Redis

Redis 的 INCR 命令是原子操作,天然保证唯一性和严格递增性。

方案 性能 趋势递增 全局唯一 依赖
UUID 极高(本地生成) ❌ 无序
数据库自增 低(磁盘 I/O) DB
Redis INCR 高(内存操作) Redis
Snowflake 极高(本地生成) 时钟同步

设计方案

1
2
3
4
5
6
7
8
9
10
11
# 简单自增 ID
INCR global:order_id
# 返回: (integer) 10001

# 按天分段(便于按日期查询和归档)
INCR order_id:20250728
# 应用层组合为: 20250728-00001

# 批量获取(减少网络往返,应用层使用 10001-10100 这 100 个 ID)
INCRBY global:order_id 100
# 返回: (integer) 10100

带业务前缀的 ID 生成(Lua 脚本)

1
2
3
4
5
6
7
8
-- 生成带日期前缀的订单号
local date_key = 'order_seq:' .. ARGV[1] -- ARGV[1] = "20250728"
local seq = redis.call('INCR', date_key)
if seq == 1 then
redis.call('EXPIRE', date_key, 86400 * 2) -- 首次创建时设置 2 天后过期
end
return seq
-- 应用层组合为: ORD-20250728-000001

取舍分析

  • 优势:简单可靠,性能高(10 万+ QPS);严格递增
  • 代价:依赖 Redis 可用性(Redis 宕机则无法生成 ID);单点瓶颈(可通过批量获取缓解);ID 连续可能暴露业务量
  • 建议:中小规模系统用 Redis INCR;大规模系统用 Snowflake 算法(无中心依赖,本地生成)

用例十四:签到系统(Bitmap)

业务问题

用户每日签到、连续签到天数统计、月度签到日历等。

为什么选 Redis

Bitmap 用 1 bit 表示一天的签到状态,一个用户一年的签到数据只需 365 bits ≈ 46 bytes。百万用户一年的签到数据约 46 MB。

设计方案

flowchart LR
    subgraph "Bitmap: sign:user:123:202507"
        direction LR
        B0["bit0=1<br/>7/1 ✅"] 
        B1["bit1=1<br/>7/2 ✅"]
        B2["bit2=0<br/>7/3 ❌"]
        B3["bit3=1<br/>7/4 ✅"]
        B4["..."]
        B30["bit30=1<br/>7/31 ✅"]
    end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 签到(7月28日 = 第 27 位,0-based)
SETBIT sign:user:123:202507 27 1

# 查询某天是否签到
GETBIT sign:user:123:202507 27
# 返回: (integer) 1

# 统计本月签到天数
BITCOUNT sign:user:123:202507
# 返回: (integer) 15

# 获取本月签到位图(用于计算连续签到天数)
# BITFIELD 可以一次性获取多个位的值
BITFIELD sign:user:123:202507 GET u31 0
# 返回一个整数,应用层通过位运算计算末尾连续 1 的个数

# === 全站签到统计 ===
# 每天一个 Bitmap,每个用户对应一个 bit 位(用户 ID 作为 offset)
SETBIT daily_sign:20250728 123 1 # 用户 123 签到
SETBIT daily_sign:20250728 456 1 # 用户 456 签到
BITCOUNT daily_sign:20250728 # 当天签到总人数

# 统计连续 3 天都签到的用户(BITOP AND = 位与运算)
BITOP AND sign_3days daily_sign:20250726 daily_sign:20250727 daily_sign:20250728
BITCOUNT sign_3days # 连续 3 天签到的用户数

取舍分析

  • 优势:极低内存占用(1 bit/天/用户);BITCOUNT 高效统计;BITOP 支持跨天聚合分析
  • 代价:只能存储 0/1 二值状态;用户 ID 作为 offset 时必须是非负整数(且最好连续,否则稀疏 ID 会浪费空间)
  • 适用:签到、在线状态、功能开关、A/B 测试分组等二值状态场景

全景对比:Redis 用例选型决策

数据结构 → 用例映射总览

mindmap
  root((Redis 用例全景))
    String
      缓存
      会话管理
      分布式锁
      计数器
      分布式 ID
    Hash
      对象缓存
      购物车
      评论内容存储
      令牌桶状态
    List
      简单消息队列
      最新动态流
      操作日志
    Set
      社交关系
      标签系统
      抽奖去重
    Sorted Set
      排行榜
      延时队列
      滑动窗口限流
      时间线
      评论排序列表
    HyperLogLog
      UV 统计
      独立 IP 计数
    Bitmap
      签到打卡
      在线状态
      布隆过滤器
    GEO
      附近的人
      门店搜索
    Stream
      可靠消息队列
      事件溯源

Redis vs 替代方案决策矩阵

场景 首选 Redis 的条件 考虑替代方案的条件 推荐替代方案
缓存 动态热点数据,多实例共享 极热点数据,单实例内使用 本地缓存 (Caffeine)
分布式锁 高性能互斥,容忍极端情况丢锁 强一致性要求,不容忍任何丢锁 ZooKeeper / etcd
消息队列 轻量级异步,消息量不大 高可靠、高吞吐、需要事务消息 Kafka / RocketMQ
排行榜 实时排名,单维度排序 复杂多维排序,全文搜索 ElasticSearch
社交关系 简单关系运算(共同好友等) 复杂图遍历(六度分隔等) Neo4j / JanusGraph
地理位置 简单圆形范围查询 多边形围栏、路径规划 PostGIS / ElasticSearch
计数/统计 实时计数,允许宕机丢失 精确持久化计数(如余额) 关系型数据库
全文搜索 ❌ 不适合 任何搜索场景 ElasticSearch / Solr
ACID 事务 ❌ 不适合 需要跨表事务一致性 关系型数据库

Redis 的通用局限性

在选择 Redis 方案时,始终需要考虑以下约束:

  1. 内存限制:所有数据在内存中,成本高于磁盘存储。需要评估数据量和增长趋势,设置 maxmemory 和淘汰策略。
  2. 持久性风险:即使开启 RDB/AOF,极端情况下仍可能丢失少量数据(RDB 丢失最后一次快照后的数据,AOF 在 everysec 模式下最多丢 1 秒)。关键数据必须有数据库兜底。
  3. 单线程模型:Redis 6.0 前命令执行是单线程的(6.0 后 I/O 多线程,但命令执行仍是单线程)。单个慢命令(如 KEYS *、大 Key 的 HGETALLSMEMBERS)会阻塞所有请求。
  4. 数据结构限制:不支持 JOIN、聚合、全文搜索等复杂查询。
  5. 一致性模型:Redis 主从复制是异步的,主节点宕机可能丢失未同步到从节点的数据。

Key 命名最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
通用模式:{业务域}:{实体类型}:{实体ID}:{子资源}

示例:
cache:user:123 → 用户缓存
session:abc123def456 → 会话
lock:order:123 → 分布式锁
rate:user:123 → 限流计数
leaderboard:global → 全局排行榜
leaderboard:daily:20250728 → 日排行榜
delayed_queue:order_timeout → 延时队列
following:user_a → 关注列表
followers:user_a → 粉丝列表
friends:user_a → 好友列表(互相关注)
post:123:comments → 文章一级评论列表
comment:1001:replies → 评论回复列表
comment:1001 → 评论内容
uv:page:homepage:20250728 → UV 统计
sign:user:123:202507 → 签到记录
stores:city:hangzhou → 门店地理位置
mystream → 消息流

命名规则

  • 使用冒号 : 分隔层级(Redis 社区惯例)
  • 使用小写字母和下划线
  • Key 名要有业务含义,避免无意义缩写
  • 控制 Key 长度(过长浪费内存和网络带宽,过短难以理解)

通用设计原则

  1. 先抽象操作,再选数据结构:不要先想"我要用 Redis 的什么命令",而是先想"我的业务本质上是什么操作"——排序?去重?计数?集合运算?
  2. Redis 是加速层,不是持久层:除非数据丢失完全可以接受(如缓存、限流计数),否则 Redis 应该配合数据库使用。
  3. 避免大 Key:单个 Key 的 Value 不要超过 10 MB。大 Set/ZSet/Hash 考虑分片(如按用户 ID 取模分到多个 Key)。
  4. 善用 Pipeline 和 Lua:Pipeline 减少网络往返(批量读取),Lua 脚本保证多命令的原子性(如"查询+删除"、“比较+设置”)。
  5. 设置 TTL:所有缓存类 Key 都应该有过期时间,防止内存泄漏。业务数据的 Key 也应该有合理的过期策略。
  6. 监控和告警:关注 used_memory(内存使用率)、keyspace_hits/misses(缓存命中率)、slowlog(慢查询日志)、connected_clients(连接数)。

模式速查表:从问题到方案的快速映射

读完全文,你应该能记住这张表。遇到任何新问题时,先在左列找到匹配的"问题特征",右列就是你的起点方案。

听到什么关键词 → 想到什么模式

你听到的需求关键词 对应模式 Redis 方案 一句话口诀
“缓存”、“加速”、“热点” KV + TTL SET key data EX ttl 有生命周期的状态 = String + TTL
“Session”、“验证码”、“临时令牌” KV + TTL SET key data EX ttl 同上,换个 Key 和 TTL
“锁”、“互斥”、“只能一个”、“幂等” 原子占位 SET key uuid NX EX ttl 谁先到谁占坑
“排行榜”、“Top N”、“排名” Score 即时间轴 ZADD + ZREVRANGE Score = 分数,ZSet = 数轴
“延时”、“定时”、“30分钟后” Score 即时间轴 ZADD + ZRANGEBYSCORE Score = 到期时间戳
“限流”、“频率控制”、“滑动窗口” Score 即时间轴 + Lua ZADD + ZCARD + Lua Score = 请求时间戳
“共同好友”、“推荐”、“交集” 双向关系 + 集合运算 SADD + SINTER/SDIFF 一个关系两个 Set
“评论列表”、“商品列表”、“动态流” 实体 + 索引分离 Hash + ZSet + Pipeline Hash 存内容,ZSet 存索引
“UV”、“独立访客”、“去重计数” 概率换空间 PFADD + PFCOUNT 12 KB 统计 2^64 个元素
“是否存在”、“穿透防护”、“预检” 概率换空间 BF.ADD + BF.EXISTS 说不在就一定不在
“消息队列”、“异步”、“事件” 生产者-消费者 Stream XADD/XREADGROUP 轻量级用 Redis,重量级用 Kafka
“附近的人”、“距离”、“范围搜索” Score 即时间轴(GEO 变体) GEOADD + GEOSEARCH GEO 底层就是 ZSet
“签到”、“在线状态”、“开关” Bitmap 二值状态 SETBIT + BITCOUNT 1 bit 表示一个布尔值
“全局 ID”、“自增序号” 原子递增 INCR / INCRBY 原子操作天然唯一

七大模式的组合使用

实际系统中,一个功能往往是多个模式的组合:

flowchart TD
    subgraph "电商秒杀系统"
        A["模式②原子占位<br/>SET NX 抢购资格"] --> B["模式⑦Lua原子胶水<br/>扣减库存"]
        B --> C["模式③Score时间轴<br/>延时队列:未支付自动取消"]
        C --> D["模式①KV+TTL<br/>订单缓存"]
    end
    
    subgraph "社交 Feed 系统"
        E["模式④双Set集合运算<br/>关注/粉丝关系"] --> F["模式⑤实体+索引分离<br/>帖子内容+时间线索引"]
        F --> G["模式③Score时间轴<br/>按时间排序的 Feed"]
        G --> H["模式⑥概率换空间<br/>布隆过滤器去重已读"]
    end

最后的建议:不要试图记住 14 个用例的所有细节。记住七个模式这张速查表就够了。遇到新问题时,先识别它属于哪个模式,再查对应的命令和 Key 设计——这就是从"一个个问题"到"一类类问题"的思维跃迁。