Redis 学习笔记

一份覆盖 Redis 完整知识体系的学习笔记。从基础数据类型到集群架构,从缓存设计到分布式锁,重点讲"为什么这么设计"(费曼式 📌 讲解),配 draw.io 图。

图表在 /Redis学习笔记/diagrams/ 目录。图注名即原图文件名(如图注"图 1 · Redis 单线程架构"对应 图1_Redis单线程架构.d2 / 图1_Redis单线程架构.d2.svg),改图请编辑同名 .d2 源文件后运行 scripts/build-d2.ps1 重新渲染。

怎么用这份笔记

  1. 学习/复习 → 顺序看正文,重点看 📌 类比和"为什么"
  2. 查命令 → 翻 附录 A 命令速查表
  3. 选类型 → 看 附录 B 数据类型选型指南
  4. 解问题 → 看 附录 C 常见问题 FAQ

1 · Redis 简介与单线程模型

图 1 \xb7 Redis 单线程架构

1.1 Redis 是什么

Redis(Remote Dictionary Server)是一个开源的、基于内存的、键值对存储数据库。它不是简单的缓存,而是一个数据结构服务器——值可以是字符串、哈希、列表、集合、有序集合等多种结构。

特性 说明
基于内存 数据存在内存中,读写速度极快(10万+ QPS)
单线程 命令执行单线程,避免锁竞争和上下文切换
持久化 支持 RDB 快照和 AOF 日志两种方式
丰富数据类型 String/List/Hash/Set/ZSet/Stream/Bitmap/HyperLogLog/Geo
主从复制 读写分离,数据冗余
高可用 哨兵自动故障转移
水平扩展 集群分片,16384 个槽位

1.2 为什么单线程还这么快

TIP

📌 打个比方:Redis 的单线程就像一条单车道的高速公路——虽然只有一条车道,但路上没有红绿灯(锁)、没有并线冲突(上下文切换)、路面平整(内存操作),所以车跑得比多车道还快。

多线程数据库像多车道市区道路——虽然有多个车道,但频繁红绿灯(锁)、并线(上下文切换),实际平均速度未必快。

单线程快的三个原因:

  1. 纯内存操作:内存读写纳秒级,磁盘毫秒级,差 10 万倍
  2. 无锁无竞争:单线程不需要加锁/解锁,没有死锁、没有竞争等待
  3. I/O 多路复用:epoll 单线程处理数万连接,非阻塞 I/O

1.3 单线程的局限

命令执行是单线程,但 Redis 6.0+ 的 I/O 是多线程的: ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ I/O 多线程 │ →→→ │ 命令执行 │ →→→ │ I/O 多线程 │ │ (读取请求) │ │ (单线程串行) │ │ (发送响应) │ └─────────────┘ └──────────────┘ └─────────────┘
TIP

📌 为什么命令执行不也多线程:因为 Redis 的数据操作都在内存中,速度极快,瓶颈不在 CPU 而在 I/O。把 I/O 并行化就够用了,命令执行并行化反而引入锁的复杂度,得不偿失。

1.4 适用场景

场景 说明
缓存 最常见用途,减轻数据库压力
会话存储 分布式 Session,TTL 自动过期
排行榜 ZSet 有序集合,天然排序
计数器 原子 INCR,点赞/浏览量
分布式锁 SETNX / Redlock
消息队列 List / Stream
限流 滑动窗口 + ZSet
地理位置 Geo 数据类型

2 · 数据类型详解

图 2 \xb7 数据类型思维导图

2.1 String(字符串)

最基础的类型,二进制安全,可存字符串、数字、序列化对象。最大 512MB。

# 基本操作
SET name "Redis"
GET name                    # → "Redis"

# 数字操作(原子性)
SET counter 100
INCR counter                # → 101
INCRBY counter 10           # → 111
DECR counter                # → 110

# 过期时间
SET token "abc123" EX 3600  # 3600秒后自动删除
TTL token                   # → 剩余秒数

# 批量操作
MSET k1 v1 k2 v2
MGET k1 k2                  # → v1 v2
TIP

📌 原子性是关键:INCR 是原子操作,即使 100 个客户端同时执行 INCR counter,也不会出现竞态条件。如果用 MySQL UPDATE counter SET val = val + 1,需要加锁或用事务,性能差很多。

应用场景:缓存对象、计数器、分布式锁、限流。

2.2 List(列表)

双向链表,支持头尾插入/弹出,适合队列和栈操作。

# 队列模式(FIFO)
LPUSH queue "msg1"          # 左侧入队
LPUSH queue "msg2"
RPOP queue                  # 右侧出队 → "msg1"(先入先出)

# 栈模式(LIFO)
LPUSH stack "a"
LPUSH stack "b"
LPOP stack                  # → "b"(后入先出)

# 阻塞队列
BRPOP queue 30              # 阻塞最多30秒等数据

# 范围查询
LRANGE queue 0 -1           # 查看所有元素
LTRIM queue 0 99            # 只保留前100条
TIP

📌 为什么 List 适合做消息队列:LPUSH + BRPOP 天然实现生产者-消费者模型。BRPOP 是阻塞的,消费者不会空转浪费 CPU。比用 RabbitMQ 简单很多,适合轻量级场景。但注意:List 不支持消息确认(ACK),消费失败无法重试。需要 ACK 的场景用 Stream。

应用场景:消息队列、最新消息列表、操作日志。

2.3 Hash(哈希)

键值对集合,适合存储对象。比 String 存 JSON 更节省内存。

# 存储用户对象
HSET user:1001 name "张三" age 30 email "zhangsan@example.com"
HGET user:1001 name         # → "张三"
HGETALL user:1001           # → name 张三 age 30 email zhangsan@example.com

# 修改单个字段
HINCRBY user:1001 age 1     # age → 31

# 检查字段
HEXISTS user:1001 email     # → 1
HDEL user:1001 email        # 删除字段
TIP

📌 Hash vs String 存对象:

  • String 存 JSON:SET user:1001 '{"name":"张三","age":30}' — 改一个字段要读出整个 JSON、修改、写回,3 次操作
  • Hash 存字段:HSET user:1001 age 31 — 直接改一个字段,1 次操作

所以:读多写少用 String 存 JSON(简单),字段级读写用 Hash(高效)。

应用场景:用户信息、商品详情、配置项。

2.4 Set(集合)

无序、不重复的字符串集合。支持交集、并集、差集。

# 基本操作
SADD tags "java" "redis" "mysql"
SMEMBERS tags              # → 所有成员
SISMEMBER tags "java"      # → 1(存在)
SCARD tags                 # → 3(数量)

# 集合运算
SADD user:tags:alice "java" "redis" "python"
SADD user:tags:bob "redis" "go" "java"
SINTER user:tags:alice user:tags:bob    # → 交集 "java" "redis"
SUNION user:tags:alice user:tags:bob    # → 并集 "java" "redis" "python" "go"
SDIFF user:tags:alice user:tags:bob     # → 差集 "python"
TIP

📌 Set 的集合运算是杀手锏:求"共同好友""共同标签""你认识的人也认识谁"——用 MySQL 要 JOIN + DISTINCT,数据量大时很慢。Redis Set 的 SINTER 在内存中直接计算,毫秒级返回。

应用场景:标签系统、共同好友、去重、随机抽取。

2.5 ZSet(有序集合)

每个成员关联一个 score,按 score 排序。是 Redis 最强大的数据类型之一。

# 排行榜
ZADD leaderboard 100 "Alice" 200 "Bob" 150 "Charlie"

# 按分数从高到低
ZREVRANGE leaderboard 0 2 WITHSCORES    # → Bob 200, Charlie 150, Alice 100

# 查排名
ZREVRANK leaderboard "Bob"              # → 0(第一名)
ZSCORE leaderboard "Bob"                # → 200

# 分数范围查询
ZRANGEBYSCORE leaderboard 100 200       # → Alice Charlie Bob

# 增加分数
ZINCRBY leaderboard 50 "Alice"          # Alice → 150
TIP

📌 为什么 ZSet 适合做排行榜:排行榜的核心操作是"插入+排序+查排名"。用 MySQL 要 ORDER BY 全表扫描,数据量大时慢。ZSet 内部用跳表(SkipList),插入和查询都是 O(logN),百万级数据毫秒完成。

📌 跳表是什么:跳表 = 多层链表。底层有所有节点,上层每隔一个节点抽一个,像"快速通道"。查找时从顶层开始,大了就下一层,类似二分但用链表实现。比平衡树简单,性能接近。

应用场景:排行榜、延迟队列(score=时间戳)、滑动窗口限流、带权重的标签。

2.6 Stream(流)

Redis 5.0 引入,专门为消息队列设计。支持消费者组、消息确认(ACK)、持久化。

# 生产者
XADD mystream * name "Alice" action "login"
XADD mystream * name "Bob" action "purchase"

# 消费者组
XGROUP CREATE mystream group1 0
XREADGROUP GROUP group1 consumer1 COUNT 10 STREAMS mystream >

# 确认消息
XACK mystream group1 <message-id>

# 查看未确认的消息
XPENDING mystream group1
TIP

📌 Stream vs List 做消息队列:

  • List:简单,但不支持 ACK,消息消费后即删除,失败无法重试
  • Stream:支持消费者组、ACK、消息持久化,消费失败可重新投递。功能对标 Kafka,但轻量得多

如果消息不能丢、需要重试 → Stream。如果只是简单的先进先出 → List。

2.7 特殊类型速览

类型 用途 典型场景
Bitmap 位操作 用户签到、布隆过滤器
HyperLogLog 基数估算(误差 0.81%) UV 统计,12KB 算 2^64 去重
Geo 地理坐标 附近的人、距离计算
# Bitmap — 用户签到
SETBIT user:1001:sign:202401 0 1    # 第0天(1月1日)签到
SETBIT user:1001:sign:202401 1 1    # 第1天签到
BITCOUNT user:1001:sign:202401      # → 签到天数

# HyperLogLog — UV 统计
PFADD page:uv:homepage "user1" "user2" "user3"
PFCOUNT page:uv:homepage            # → 3(去重后的数量)

# Geo — 附近的人
GEOADD locations 116.404 39.915 "天安门" 116.418 39.917 "王府井"
GEORADIUS locations 116.41 39.916 1 km  # → 1km内的地点

3 · 持久化机制

图 3 \xb7 持久化机制对比

3.1 RDB(快照)

RDB 把某个时间点的所有数据写入二进制文件(dump.rdb)。

# 配置自动快照
save 900 1      # 900秒内至少1个key变化 → 触发快照
save 300 10     # 300秒内至少10个key变化 → 触发快照
save 60 10000   # 60秒内至少10000个key变化 → 触发快照

# 手动触发
SAVE            # 阻塞式快照(生产环境禁用)
BGSAVE          # 后台快照(fork 子进程)

优点:文件小、恢复速度快、适合备份。 缺点:两次快照之间宕机会丢数据。

3.2 AOF(追加日志)

AOF 把每条写命令追加到日志文件(appendonly.aof)。

# 开启 AOF
appendonly yes

# 刷盘策略
appendfsync always      # 每条命令都刷盘(最安全,最慢)
appendfsync everysec    # 每秒刷盘(推荐,宕机最多丢1秒)
appendfsync no          # 由 OS 决定(最快,可能丢较多)

# AOF 重写(压缩日志)
auto-aof-rewrite-percentage 100   # 文件翻倍时重写
auto-aof-rewrite-min-size 64mb    # 最小重写大小
TIP

📌 AOF 重写是什么:比如你 INCR counter 100 次,AOF 文件里有 100 条 INCR 命令。重写后变成 SET counter 100 一条。重写用 fork 子进程做,不影响主线程。

📌 always vs everysec:always 每条命令都 fsync,磁盘 I/O 成为瓶颈,QPS 降到几千。everysec 每秒 fsync 一次,性能接近纯内存,宕机最多丢 1 秒数据。99% 的场景用 everysec。

3.3 RDB + AOF 混合持久化

Redis 4.0+ 支持混合模式:AOF 重写时,先以 RDB 格式写入当前数据,再追加增量 AOF 命令。

aof-use-rdb-preamble yes   # 开启混合持久化
RDB AOF 混合
数据安全 可能丢分钟级数据 最多丢1秒 最多丢1秒
恢复速度 快(二进制加载) 慢(重放命令) 快(RDB主体+少量AOF)
文件大小 小 大 中
推荐场景 纯缓存可丢数据 不能丢数据 生产环境推荐
TIP

📌 生产环境怎么选:

  • 纯缓存,丢了能从 DB 重建 → 只用 RDB
  • 不能丢数据(会话、订单) → AOF everysec
  • 又要快又要安全 → 混合持久化(4.0+) :::

4 · 主从复制

图 4 \xb7 主从复制架构

4.1 为什么需要主从复制

单 Redis 宕机 = 数据全丢、服务中断。主从复制解决三个问题:

  1. 数据冗余:从节点保存副本,主节点宕机不丢数据
  2. 读写分离:读请求分摊到从节点,提升读 QPS
  3. 故障恢复:主节点挂了,从节点可以顶上

4.2 复制原理

全量同步(首次连接): 从节点 → PSYNC ? -1 → 主节点 主节点 → BGSAVE 生成 RDB → 发送 RDB 给从节点 主节点 → 发送缓冲区中的写命令给从节点 从节点 → 加载 RDB + 重放命令 增量同步(断线重连): 从节点 → PSYNC <runid> <offset> → 主节点 主节点 → 从 offset 开始发送缺失的命令

:::tip 📌 全量同步的代价:主节点 BGSAVE 要 fork 子进程,大内存实例 fork 慢(1GB ≈ 20ms),期间主线程阻塞。而且传输 RDB 占带宽。所以要尽量避免频繁全量同步。

📌 什么时候触发全量同步:①首次连接 ②从节点 runid 不匹配(主节点重启过)③offset 不在复制积压缓冲区中(断线太久)。配置 repl-backlog-size 大一点(默认 1MB),减少全量同步概率。

4.3 配置

# 从节点配置(redis.conf)
replicaof 192.168.1.100 6379      # 主节点地址
masterauth <password>              # 主节点密码(如有)

# 从节点默认只读
replica-read-only yes

# 复制积压缓冲区大小
repl-backlog-size 16mb             # 增大可减少全量同步

4.4 读写分离的注意事项

WARNING

🚧 主从延迟问题:复制是异步的,从节点可能落后主节点几百毫秒。写主库后立即读从库,可能读不到。

解决方案:

  • 写后立即读 → 强制读主库
  • 写后延迟读 → Sleep 100ms 再读从库
  • 用 WAIT numreplicas timeout 命令等待复制完成 :::

5 · 哨兵模式

图 5 \xb7 哨兵模式架构

5.1 哨兵的作用

主从复制中,主节点宕机需要人工把从节点提升为主节点。哨兵(Sentinel)把这个过程自动化了。

三大职责:

  1. 监控:定时 ping 主/从节点,检测存活
  2. 通知:节点异常时通知管理员或客户端
  3. 自动故障转移:主节点挂了,自动选一个从节点提升为主

5.2 故障转移过程

1. 主观下线(SDOWN) 哨兵 ping 主节点,30秒内无响应 → 标记为主观下线 2. 客观下线(ODOWN) 超过半数哨兵都报告主观下线 → 确认为客观下线 3. 选举 Leader 哨兵 哨兵之间投票选出一个 Leader 来执行故障转移 4. 选择新主节点 Leader 从从节点中选一个: ① 优先级最高的(replica-priority 最小) ② 优先级相同 → offset 最大(数据最新) ③ offset 相同 → runid 字典序最小 5. 执行转移 ① 新主节点执行 SLAVEOF NO ONE(成为主节点) ② 其他从节点指向新主节点 ③ 旧主节点恢复后变为从节点

:::tip 📌 为什么需要多个哨兵:单个哨兵可能误判(网络抖动导致 ping 超时)。多个哨兵投票,超过半数才确认下线,避免误判。这就像"一个人说你有病不算数,三个医生会诊才算数"。

📌 为什么选举用 Raft 算法:哨兵 Leader 选举用 Raft——每个哨兵有随机超时时间,先超时的发起选举,获得多数票就成为 Leader。随机超时避免同时发起选举导致瓜分选票。

5.3 配置

# sentinel.conf
port 26379
sentinel monitor mymaster 192.168.1.100 6379 2
#          监控名      主节点IP      端口  判定下线的哨兵数
sentinel down-after-milliseconds mymaster 30000    # 30秒无响应=下线
sentinel parallel-syncs mymaster 1                  # 同时同步的从节点数
sentinel failover-timeout mymaster 180000           # 故障转移超时3分钟

5.4 客户端连接

客户端不直连 Redis,而是连哨兵获取主节点地址:

import redis
from redis.sentinel import Sentinel

sentinel = Sentinel([
    ('192.168.1.101', 26379),
    ('192.168.1.102', 26379),
    ('192.168.1.103', 26379),
])

master = sentinel.master_for('mymaster', socket_timeout=0.5)
slave = sentinel.slave_for('mymaster', socket_timeout=0.5)

master.set('key', 'value')    # 写主
value = slave.get('key')      # 读从

6 · 集群模式

图 6 \xb7 集群模式与槽分配

6.1 为什么需要集群

哨兵模式解决了高可用,但只有一个主节点写入,写入能力有上限。集群模式把数据分散到多个主节点,实现水平扩展。

单机 主从 哨兵 集群
高可用 否 部分 是 是
读写分离 否 是 是 是
水平扩展 否 否 否 是
自动故障转移 否 否 是 是
数据分片 否 否 否 是

6.2 槽位机制

Redis 集群有 16384 个槽位(hash slot)。每个 key 通过 CRC16 计算后对 16384 取模,决定落在哪个槽。

key → CRC16(key) mod 16384 → slot number → 负责该slot的节点
TIP

📌 为什么是 16384 而不是 65536:作者 antirez 解释——集群节点间通过 gossip 协议交换槽位信息,每次交换携带 16384 位的 bitmap(2KB)。如果是 65536,bitmap 就是 8KB,gossip 消息太大。而且 16384 个槽足够支撑 1000 个节点(实际很少超过这个规模)。

6.3 节点通信

集群节点用 Gossip 协议 通信:

  • 每秒向几个随机节点发送 PING
  • PING 携带自己已知的集群信息
  • 收到 PING 的节点更新自己的信息
  • 信息像"八卦"一样传播到全网

6.4 MOVED 与 ASK 重定向

# 客户端连节点A,但key在节点B
127.0.0.1:7000> SET user:1001 "Alice"
(error) MOVED 5474 127.0.0.1:7001
# 槽5474在节点B(7001),客户端缓存这个映射,下次直连B
MOVED ASK
含义 槽已永久迁移到新节点 槽正在迁移中,临时重定向
客户端行为 更新本地路由表 不更新路由表,只本次重定向
场景 集群稳定状态 槽迁移过程中

6.5 集群限制

WARNING

🚧 集群模式不支持跨槽操作:

# user:1001 在槽A,user:1002 在槽B
MSET user:1001 "Alice" user:1002 "Bob"   # 报错

解决方案:用 Hash Tag 强制同一槽:

# {user} 是 Hash Tag,CRC16 只算花括号内的部分
MSET {user}:1001 "Alice" {user}:1002 "Bob"  # 都在同一个槽

6.6 搭建集群

# 最少6个节点:3主3从
# 端口 7000-7005,每个节点 redis.conf:
port 7000
cluster-enabled yes
cluster-config-file nodes-7000.conf
cluster-node-timeout 5000

# 创建集群
redis-cli --cluster create \
  127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 \
  127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 \
  --cluster-replicas 1

7 · 缓存设计模式

图 7 \xb7 缓存设计模式

7.1 Cache Aside(旁路缓存)

最常用的缓存模式。

读:先查缓存 → 命中返回 → 未命中查DB → 写入缓存 → 返回 写:更新DB → 删除缓存
def get_user(user_id):
    # 1. 查缓存
    data = redis.get(f"user:{user_id}")
    if data:
        return json.loads(data)

    # 2. 查数据库
    data = db.query("SELECT * FROM users WHERE id = %s", user_id)

    # 3. 写入缓存
    redis.setex(f"user:{user_id}", 3600, json.dumps(data))

    return data

def update_user(user_id, data):
    # 1. 更新数据库
    db.update("users", data, id=user_id)
    # 2. 删除缓存(不是更新缓存!)
    redis.delete(f"user:{user_id}")
TIP

📌 为什么是删除缓存而不是更新缓存:

  • 更新缓存:每次写DB都更新缓存,但缓存可能根本没人读 → 浪费。而且并发更新时缓存和DB可能不一致
  • 删除缓存:下次读的时候再加载(懒加载),不浪费。并发问题更少

📌 为什么先更新DB再删缓存:如果先删缓存再更新DB,删完后另一个请求读缓存未命中,读旧DB数据写入缓存,然后DB更新了 → 缓存是旧数据。先更新DB再删缓存,最多短暂不一致。

7.2 缓存三大问题

缓存穿透

问题:查询不存在的数据,缓存和DB都没有,每次都打到DB。

恶意请求 → 查 user:-1 → 缓存没有 → DB没有 → 每次都打DB

解决方案:

# 方案1:缓存空值
def get_user(user_id):
    data = redis.get(f"user:{user_id}")
    if data is not None:
        return json.loads(data) if data != "NULL" else None

    data = db.query("SELECT * FROM users WHERE id = %s", user_id)
    if data:
        redis.setex(f"user:{user_id}", 3600, json.dumps(data))
    else:
        redis.setex(f"user:{user_id}", 300, "NULL")  # 缓存空值5分钟
    return data

# 方案2:布隆过滤器
# 启动时加载所有存在的key到布隆过滤器
# 查询前先过布隆过滤器,不存在直接返回

缓存击穿

问题:热点 key 过期瞬间,大量请求同时打到 DB。

解决方案:

# 方案1:互斥锁
def get_hot_key(key):
    data = redis.get(key)
    if data:
        return data

    # 加锁,只有一个请求查DB
    lock_key = f"lock:{key}"
    if redis.set(lock_key, "1", nx=True, ex=10):
        try:
            data = db.query(key)
            redis.setex(key, 3600, data)
            return data
        finally:
            redis.delete(lock_key)
    else:
        time.sleep(0.1)
        return get_hot_key(key)  # 重试

# 方案2:热点key永不过期(逻辑过期)

缓存雪崩

问题:大量 key 同时过期,或 Redis 宕机,所有请求打到 DB。

解决方案:

  • 过期时间加随机值:expire = 3600 + random(0, 300) 避免同时过期
  • 多级缓存:本地缓存 + Redis + DB
  • 限流降级:DB 压力大时返回默认值或错误页
  • Redis 高可用:哨兵/集群防宕机

8 · 分布式锁

图 8 \xb7 分布式锁实现

8.1 基本实现

# 加锁:SET key value NX EX(原子操作)
SET lock:order:1001 "uuid-xxx" NX EX 30
# NX: 不存在才设置(互斥)
# EX: 30秒过期(防死锁)
# value: 唯一标识(防误删)

8.2 释放锁的原子性

-- 释放锁必须用 Lua 脚本保证原子性
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
TIP

📌 为什么释放锁要用 Lua:

# 错误做法:先 GET 再 DEL(两步非原子)
GET lock:order:1001    # → "uuid-xxx" → 是我的锁
# ← 此时锁恰好过期,别人加锁成功
DEL lock:order:1001    # → 删了别人的锁!

Lua 脚本在 Redis 中原子执行,GET + 判断 + DEL 不会被其他命令插入。

8.3 锁过期问题

WARNING

🚧 锁过期但业务没执行完:线程A拿到锁30秒,但业务执行了40秒。第30秒锁自动过期,线程B拿到锁。第40秒线程A执行完,删除锁——删了B的锁。

解决方案:看门狗续期

  • 线程A开一个后台线程,每隔10秒检查锁是否还属于自己,是则续期到30秒
  • Redisson 框架内置了看门狗机制 :::
// Redisson 看门狗示例
RLock lock = redisson.getLock("lock:order:1001");
lock.lock();  // 默认30秒过期,看门狗每10秒续期
try {
    // 业务逻辑
} finally {
    lock.unlock();
}

8.4 Redlock 算法

单 Redis 实例的分布式锁在主从切换时可能丢失。Redlock 用多个独立 Redis 实例提高可靠性:

1. 获取当前时间 T1 2. 依次向 5 个独立 Redis 实例请求加锁 3. 获取当前时间 T2 4. 如果 ≥3 个实例加锁成功,且 T2-T1 < 锁过期时间 → 加锁成功 5. 否则向所有实例释放锁

:::tip 📌 Redlock 争议:Martin Kleppmann(DDIA 作者)批评 Redlock 在时钟漂移和网络延迟下不安全。antirez 反驳说实际场景够用。

实践建议:

  • 对正确性要求极高(金融)→ 用 ZooKeeper/etcd
  • 一般场景(订单防重)→ 单 Redis 锁 + 看门狗够用
  • Redlock 适合没有主从复制的独立多实例场景 :::

9 · 消息队列

图 9 \xb7 消息队列模式

9.1 三种实现方式对比

List Pub/Sub Stream
持久化 是 否 是
消息确认 否 否 是 (ACK)
消费者组 否 否 是
消息堆积 是 否(无缓冲) 是
历史回溯 否 否 是
复杂度 低 最低 中
适用场景 简单队列 实时广播 专业消息队列

9.2 List 模式

# 生产者
LPUSH task_queue "task1" "task2" "task3"

# 消费者(阻塞式)
BRPOP task_queue 0    # 阻塞等待,0=无限等待

优点:简单直接。 缺点:无 ACK,消费失败消息丢失;不支持多消费者竞争消费(虽然可以,但无法跟踪消费进度)。

9.3 Pub/Sub 模式

# 订阅者
SUBSCRIBE channel1 channel2

# 发布者
PUBLISH channel1 "hello everyone"

:::tip 📌 Pub/Sub 的致命缺陷:消息不持久化。如果订阅者离线,期间的消息全部丢失。而且消息堆积没有缓冲区,发布快、消费慢时消息直接丢弃。所以 Pub/Sub 只适合实时广播(聊天室、实时通知),不适合做可靠消息队列。

9.4 Stream 模式

# 创建消费者组
XGROUP CREATE task_stream task_group 0 MKSTREAM

# 消费者消费
XREADGROUP GROUP task_group consumer1 COUNT 5 BLOCK 5000 STREAMS task_stream >

# 消费成功后确认
XACK task_stream task_group <message-id>

# 消费失败后重新投递给其他消费者
XPENDING task_stream task_group - + 10
XCLAIM task_stream task_group consumer2 60000 <message-id>
TIP

📌 Stream 的消费者组是亮点:

  • 多个消费者组成一个组,组内竞争消费(负载均衡)
  • 消息被消费后不立即删除,需要 ACK 确认
  • 未 ACK 的消息可以 XCLAIM 转给其他消费者(故障恢复)
  • 可以按 ID 回溯历史消息

这基本就是轻量版 Kafka,适合不需要 Kafka 那么重的场景。

10 · 限流与计数器

10.1 固定窗口限流

# 每分钟最多100次请求
def is_rate_limited(user_id):
    key = f"rate:{user_id}:{int(time.time() / 60)}"
    count = redis.incr(key)
    if count == 1:
        redis.expire(key, 60)
    return count > 100

缺点:窗口边界问题——59秒100次 + 61秒100次 = 2秒内200次。

10.2 滑动窗口限流(ZSet)

def is_rate_limited_sliding(user_id, limit=100, window=60):
    key = f"rate:{user_id}"
    now = time.time()
    pipeline = redis.pipeline()

    # 1. 移除窗口外的记录
    pipeline.zremrangebyscore(key, 0, now - window)

    # 2. 添加当前请求
    pipeline.zadd(key, {str(now): now})

    # 3. 统计窗口内请求数
    pipeline.zcard(key)

    # 4. 设置过期时间
    pipeline.expire(key, window)

    _, _, count, _ = pipeline.execute()
    return count > limit
TIP

📌 滑动窗口 vs 固定窗口:滑动窗口用 ZSet 的 score 存时间戳,每次请求先清理过期记录再统计。没有窗口边界问题,但每次请求要操作 ZSet(O(logN)),比固定窗口的 INCR(O(1))稍慢。

📌 更高性能的滑动窗口:用 Lua 脚本把4步合成1次网络往返,减少 RTT。

10.3 令牌桶限流

-- 令牌桶:以固定速率生成令牌,桶满了丢弃,请求消耗令牌
local key = KEYS[1]
local capacity = tonumber(ARGV[1])    -- 桶容量
local rate = 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 delta = math.max(0, now - last_time)
tokens = math.min(capacity, tokens + delta * rate)

-- 消耗令牌
if tokens >= requested then
    tokens = tokens - requested
    redis.call("HMSET", key, "tokens", tokens, "last_time", now)
    redis.call("EXPIRE", key, math.ceil(capacity / rate))
    return 1  -- 允许
else
    redis.call("HMSET", key, "tokens", tokens, "last_time", now)
    return 0  -- 拒绝
end

11 · 性能优化

图 10 \xb7 性能优化全景图

11.1 内存优化

选择合适的数据类型:

场景 不推荐 推荐 节省
存对象 String 存 JSON Hash 存字段 ~30%
短字符串集合 Set 用 ziplist 编码 ~50%
小哈希 Hash (hashtable) Hash (ziplist) ~40%
# 配置 ziplist 阈值(元素少时用 ziplist 压缩编码)
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
list-max-ziplist-size -2
set-max-intset-entries 512
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
TIP

📌 ziplist 是什么:ziplist 是连续内存的紧凑编码。普通 hashtable 每个元素都有指针开销(16+字节),ziplist 把所有元素紧挨着放,没有指针。元素少时省大量内存。但元素多了 ziplist 的插入性能变差(要 realloc + memmove),所以有阈值切换。

设置过期时间:不设过期 = 内存只增不减,最终 OOM。

# 设置淘汰策略
maxmemory 4gb
maxmemory-policy allkeys-lru   # 内存满时淘汰最少使用的key

11.2 命令优化

避免慢命令:

命令 时间复杂度 替代方案
KEYS * O(N) SCAN 渐进式
SMEMBERS 大集合 O(N) SSCAN
HGETALL 大哈希 O(N) HSCAN
SORT O(N+M*logM) 用 ZSet 代替
LRANGE 0 -1 大列表 O(N) 分页 LRANGE 0 99
# KEYS 是生产环境禁用命令!
KEYS user:*      # 阻塞所有命令

# 用 SCAN 替代
SCAN 0 MATCH user:* COUNT 100   # 非阻塞,每次返回100个
WARNING

🚧 KEYS 命令的威力:100万个 key 执行 KEYS * 大约阻塞 1-2 秒。这期间 Redis 完全无法处理其他请求。生产环境绝对禁用!用 SCAN 替代,它每次只扫描一小批,不阻塞。

11.3 Pipeline 批量操作

# 无 Pipeline:10次网络往返
for i in range(10):
    redis.set(f"key{i}", f"value{i}")
# 总耗时 ≈ 10 × RTT

# Pipeline:1次网络往返
pipeline = redis.pipeline()
for i in range(10):
    pipeline.set(f"key{i}", f"value{i}")
pipeline.execute()
# 总耗时 ≈ 1 × RTT
TIP

📌 Pipeline vs MULTI:

  • Pipeline:客户端批量发送命令,减少网络往返。命令间不保证原子性
  • MULTI/EXEC:事务,命令原子执行。但还是要等所有命令发完才执行

Pipeline 是网络优化,MULTI 是原子性保证。可以组合使用:pipeline.multi() → 发命令 → pipeline.execute()。

11.4 大 Key 优化

# 发现大 key
redis-cli --bigkeys
redis-cli --mem-usage

# 大 key 的危害:
# ① 阻塞:DEL 大 key 耗时几秒
# ② 网络阻塞:GET 大 key 传输慢
# ③ 内存不均:集群中某节点内存远超其他

# 解决方案:
# ① 拆分:大 Hash 拆成多个小 Hash
# ② 异步删除:UNLINK 替代 DEL(4.0+)
UNLINK bigkey    # 后台异步删除,不阻塞
# ③ 过期策略:给大 key 设 TTL,让 Redis 逐步淘汰

12 · 安全配置

12.1 基本安全

# 1. 设置密码
requirepass "YourStrongPassword123!"

# 2. 禁用危险命令
rename-command FLUSHALL ""
rename-command FLUSHDB ""
rename-command KEYS ""
rename-command CONFIG "CONFIG_a8f3e2d1"   # 重命名

# 3. 绑定地址(不要暴露到公网!)
bind 127.0.0.1 192.168.1.100

# 4. 修改默认端口
port 6380

# 5. 禁用 CONFIG 命令远程修改
rename-command CONFIG ""
WARNING

🚧 Redis 未授权访问漏洞:默认 Redis 无密码、绑 0.0.0.0、6379 端口。攻击者可以直接连上 Redis,写 SSH 公钥到 ~/.ssh/authorized_keys,实现免密登录服务器。每年都有大量服务器因为这个被入侵。

最低限度安全配置:①设密码 ②bind 内网IP ③改端口 ④禁危险命令。

12.2 ACL(Redis 6.0+)

# 创建用户
ACL SETUSER app_readonly on +@read ~user:* >password123
#          用户名      启用  只读  只能访问user:*  密码

ACL SETUSER app_write on +@write +@read ~* >password456
#          读写权限,访问所有key

# 查看用户列表
ACL USERS

# 查看当前用户
ACL WHOAMI

12.3 TLS 加密(Redis 6.0+)

# redis.conf
tls-port 6380
tls-cert-file /path/redis.crt
tls-key-file /path/redis.key
tls-ca-cert-file /path/ca.crt

13 · 监控与排障

13.1 慢查询日志

# 配置慢查询
slowlog-log-slower-than 10000    # 超过10ms记录(微秒)
slowlog-max-len 128              # 最多保留128条

# 查看慢查询
SLOWLOG GET 10                   # 最近10条
SLOWLOG LEN                      # 当前条数
SLOWLOG RESET                    # 清空

13.2 INFO 命令

# 服务器信息
INFO server      # 版本、运行时间、配置文件路径
INFO clients     # 连接数
INFO memory      # 内存使用
INFO stats       # 命令统计、命中率
INFO replication # 主从复制状态

# 关键指标
INFO memory
# used_memory:1000000        实际使用内存
# used_memory_rss:1500000    OS分配的内存
# mem_fragmentation_ratio:1.5  碎片率(>1.5需关注)

13.3 常见排障

现象 可能原因 排查方法
响应变慢 慢命令、大key、fork SLOWLOG GET + INFO stats
内存涨不停 无过期key、内存泄漏 INFO memory + MEMORY USAGE
连接数高 连接泄漏 INFO clients + CLIENT LIST
主从断开 网络问题、全量同步 INFO replication
CPU 飙高 大量计算命令、bgsave INFO stats + top
碎片率高 频繁修改/删除 INFO memory → activedefrag yes

13.4 内存碎片整理

# Redis 4.0+ 自动碎片整理
activedefrag yes
active-defrag-ignore-bytes 100mb      # 碎片超过100MB才整理
active-defrag-threshold-lower 10      # 碎片率超过10%开始
active-defrag-threshold-upper 100     # 碎片率超过100%全力整理
active-defrag-cycle-min 1             # 最小CPU占比
active-defrag-cycle-max 25            # 最大CPU占比

附录 A · 命令速查表

String

命令 说明 示例
SET key value 设置 SET name "Redis"
GET key 获取 GET name
DEL key 删除 DEL name
INCR key 自增 INCR counter
INCRBY key n 增n INCRBY counter 10
SET key val EX sec 设过期 SET token "x" EX 3600
SET key val NX 不存在才设 SET lock "1" NX EX 30
MSET k1 v1 k2 v2 批量设 MSET a 1 b 2
MGET k1 k2 批量取 MGET a b

List

命令 说明 示例
LPUSH key v 左插入 LPUSH q "msg"
RPUSH key v 右插入 RPUSH q "msg"
LPOP key 左弹出 LPOP q
RPOP key 右弹出 RPOP q
LRANGE key s e 范围查 LRANGE q 0 -1
LLEN key 长度 LLEN q
BRPOP key t 阻塞弹出 BRPOP q 30

Hash

命令 说明 示例
HSET key f v 设字段 HSET user name "Tom"
HGET key f 取字段 HGET user name
HGETALL key 取所有 HGETALL user
HDEL key f 删字段 HDEL user email
HINCRBY key f n 字段自增 HINCRBY user age 1
HEXISTS key f 字段存在 HEXISTS user email
HKEYS key 所有字段名 HKEYS user
HVALS key 所有值 HVALS user

Set

命令 说明 示例
SADD key m 添加 SADD tags "java"
SMEMBERS key 所有成员 SMEMBERS tags
SISMEMBER key m 是否存在 SISMEMBER tags "java"
SCARD key 数量 SCARD tags
SINTER k1 k2 交集 SINTER set1 set2
SUNION k1 k2 并集 SUNION set1 set2
SDIFF k1 k2 差集 SDIFF set1 set2

ZSet

命令 说明 示例
ZADD key s m 添加 ZADD rank 100 "Alice"
ZRANGE key s e 升序范围 ZRANGE rank 0 9
ZREVRANGE key s e 降序范围 ZREVRANGE rank 0 9
ZSCORE key m 取分数 ZSCORE rank "Alice"
ZRANK key m 升序排名 ZRANK rank "Alice"
ZREVRANK key m 降序排名 ZREVRANK rank "Alice"
ZRANGEBYSCORE key min max 分数范围 ZRANGEBYSCORE rank 80 100
ZINCRBY key s m 增加分数 ZINCRBY rank 10 "Alice"

通用

命令 说明 示例
EXPIRE key sec 设过期 EXPIRE key 3600
TTL key 剩余时间 TTL key
PERSIST key 移除过期 PERSIST key
TYPE key 数据类型 TYPE key
EXISTS key 是否存在 EXISTS key
SCAN cursor 渐进扫描 SCAN 0 MATCH user:* COUNT 100
INFO [section] 服务器信息 INFO memory
DBSIZE key数量 DBSIZE
PING 连通测试 PING

附录 B · 数据类型选型指南

需求 推荐类型 理由
缓存 JSON 对象 String 简单,读多写少
缓存对象字段级操作 Hash 可单独读写字段
计数器/自增ID String INCR 原子操作
消息队列(简单) List LPUSH + BRPOP
消息队列(可靠) Stream ACK + 消费者组
排行榜 ZSet 自动排序 + 查排名
去重/标签 Set 自动去重 + 集合运算
共同好友 Set SINTER 交集
分布式锁 String SET NX EX
限流(滑动窗口) ZSet score 存时间戳
用户签到 Bitmap 位操作省空间
UV 统计 HyperLogLog 12KB 算亿级去重
附近的人 Geo 地理坐标计算
延迟队列 ZSet score = 执行时间戳

附录 C · 常见问题 FAQ

Q1: Redis 和 Memcached 的区别?

Redis Memcached
数据类型 丰富(String/List/Hash/Set/ZSet...) 仅 String
持久化 RDB + AOF 无
集群 内置集群分片 客户端分片
线程模型 单线程命令 + 多线程I/O 多线程
最大 value 512MB 1MB
适用场景 缓存+数据存储+消息队列 纯缓存

Q2: Redis 适合做主数据库吗?

不推荐。Redis 基于内存,虽然能持久化但可靠性不如 MySQL/PostgreSQL。最佳实践是 Redis 做缓存/辅助存储,DB 做主存储。Redis on Flash(企业版)可以热数据在内存、冷数据在 SSD,但开源版不支持。

Q3: 过期 key 是怎么删除的?

两种策略组合:

  1. 惰性删除:访问 key 时检查是否过期,过期则删除。优点 CPU 友好,缺点不访问的 key 永远不删。
  2. 定期删除:每秒 10 次,每次随机检查 20 个 key,过期则删。如果过期比例 > 25%,继续检查。

如果两种都没删掉,还有 maxmemory-policy 内存淘汰策略兜底。

Q4: 内存淘汰策略怎么选?

策略 说明 适用场景
noeviction 不淘汰,写入报错 数据不能丢
allkeys-lru 淘汰最少使用的 推荐,纯缓存
allkeys-lfu 淘汰最少频率使用的 访问频率差异大
volatile-lru 只淘汰有过期key的LRU 混合存储
volatile-ttl 淘汰最快过期的 优先淘汰短TTL

Q5: pipeline 和事务有什么区别?

  • Pipeline:减少网络往返,不保证原子性
  • MULTI/EXEC:事务,命令原子执行,但不回滚(某条失败不影响后续)
  • Lua 脚本:原子执行 + 条件逻辑,最接近"真正的事务"

Q6: Redis 集群为什么最少 6 个节点?

3 主 3 从。少于 3 个主节点无法构成集群(投票需要多数)。每个主节点至少 1 个从节点保证高可用。所以最少 6 个。

Q7: 如何优雅地做大 key 迁移?

# 1. 不直接 DEL(阻塞),用 UNLINK(异步删除)
UNLINK bigkey

# 2. 大 Hash 逐步迁移
HSCAN bigkey 0 COUNT 100    # 每次读100个字段
HSET newkey field1 val1 ...  # 写入新key
HDEL bigkey field1 ...       # 从旧key删除
# 循环直到 bigkey 为空
UNLINK bigkey
本页目录