Redis 学习笔记
一份覆盖 Redis 完整知识体系的学习笔记。从基础数据类型到集群架构,从缓存设计到分布式锁,重点讲"为什么这么设计"(费曼式 📌 讲解),配 draw.io 图。
图表在 /Redis学习笔记/diagrams/ 目录。图注名即原图文件名(如图注"图 1 · Redis 单线程架构"对应 图1_Redis单线程架构.d2 / 图1_Redis单线程架构.d2.svg),改图请编辑同名 .d2 源文件后运行 scripts/build-d2.ps1 重新渲染。
怎么用这份笔记
- 学习/复习 → 顺序看正文,重点看 📌 类比和"为什么"
- 查命令 → 翻 附录 A 命令速查表
- 选类型 → 看 附录 B 数据类型选型指南
- 解问题 → 看 附录 C 常见问题 FAQ
1 · 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 的单线程就像一条单车道的高速公路——虽然只有一条车道,但路上没有红绿灯(锁)、没有并线冲突(上下文切换)、路面平整(内存操作),所以车跑得比多车道还快。
多线程数据库像多车道市区道路——虽然有多个车道,但频繁红绿灯(锁)、并线(上下文切换),实际平均速度未必快。
单线程快的三个原因:
- 纯内存操作:内存读写纳秒级,磁盘毫秒级,差 10 万倍
- 无锁无竞争:单线程不需要加锁/解锁,没有死锁、没有竞争等待
- 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.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.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.1 为什么需要主从复制
单 Redis 宕机 = 数据全丢、服务中断。主从复制解决三个问题:
- 数据冗余:从节点保存副本,主节点宕机不丢数据
- 读写分离:读请求分摊到从节点,提升读 QPS
- 故障恢复:主节点挂了,从节点可以顶上
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.1 哨兵的作用
主从复制中,主节点宕机需要人工把从节点提升为主节点。哨兵(Sentinel)把这个过程自动化了。
三大职责:
- 监控:定时 ping 主/从节点,检测存活
- 通知:节点异常时通知管理员或客户端
- 自动故障转移:主节点挂了,自动选一个从节点提升为主
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.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.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.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.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 · 性能优化

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 是怎么删除的?
两种策略组合:
- 惰性删除:访问 key 时检查是否过期,过期则删除。优点 CPU 友好,缺点不访问的 key 永远不删。
- 定期删除:每秒 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