Redis学习

Redis 从零到生产级实战

Python、FastAPI 与分布式系统核心应用完整版教程

适读人群:会写一些 Python 和 FastAPI,知道数据库大致是什么,但没有接触过 Redis 的后端开发者。

文档定位:这不是一本只告诉你“某条命令怎么写”的速查手册,而是一条从心智模型、数据结构、命令和客户端,一直延伸到短信登录、缓存、分布式锁、阻塞队列、可靠消息队列、集群和多级缓存的完整学习路径。

代码声明:本文代码是为了讲清原理而编写的静态教学示例,经过前后一致性和逻辑审阅,但没有在你的本机安装依赖或实际运行。用于真实项目时,请根据所使用的 Redis、redis-py、FastAPI、数据库版本补充集成测试、并发测试、故障演练和安全配置。


阅读完以后,你应该具备什么能力

如果只记住命令名称,很快就会忘;如果知道每种结构解决什么问题、付出什么代价,就能自己推导答案。本文希望你最终可以:

  1. 解释 Redis 是什么、为什么快、它和数据库有什么关系。
  2. 掌握核心键命令,以及 String、Hash、List、Set、Sorted Set、Bitmap、Bitfield、HyperLogLog、GEO、Stream 的核心命令。
  3. 根据查询方式、唯一性、顺序、范围和数据量选择合适的数据结构。
  4. 使用 redis-py 的异步客户端正确接入 FastAPI,理解连接池、生命周期、序列化、超时和异常降级。
  5. 独立实现验证码登录、Token 会话、查询缓存、缓存一致性保护、分布式锁、List 阻塞队列、Stream 消费者组和秒杀异步下单。
  6. 解释缓存穿透、击穿、雪崩、热 Key、大 Key 的区别及解决方向。
  7. 理解过期删除、内存淘汰、RDB、AOF、复制、Sentinel 和 Redis Cluster。
  8. 设计 L1 进程内缓存 + L2 Redis + 数据库的多级缓存。
  9. 面对 Redis 延迟升高、内存异常、连接耗尽、缓存命中率下降和消息积压时,有一套排查方法。

全书的两条主线

知识线如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
为什么需要 Redis

键、TTL 和数据结构

原子性、事务、Pipeline、Lua

Python 与 FastAPI 工程化接入

缓存、登录、锁、队列和秒杀

持久化、高可用、集群和多级缓存

测试思路、性能、监控和排障

项目线是一套“本地生活服务平台”:

1
2
3
4
5
6
7
8
9
10
11
12
13
数据库查询商户
↓ 加入 Redis
商户查询缓存
↓ 加入认证
短信验证码登录与会话
↓ 面对高并发
缓存保护与分布式锁
↓ 异步削峰
List 队列 → Stream 可靠消息队列
↓ 综合运用
优惠券秒杀与异步订单
↓ 扩大规模
Sentinel / Cluster / 多级缓存

每一章都尽量回答五个问题:

  • 它要解决什么问题?
  • 它的精确定义是什么?
  • 对应命令和代码怎么写?
  • 哪些错误写法看起来能用,实际上会出问题?
  • 什么时候应该用它,什么时候应该换方案?

目录

第一篇:建立正确的 Redis 心智模型

  • 第 1 章:为什么后端系统需要 Redis
  • 第 2 章:安装、启动和第一次交互
  • 第 3 章:键空间、类型、TTL 与安全遍历

第二篇:数据结构决定建模方式

  • 第 4 章:String——缓存、计数器与条件写入
  • 第 5 章:Hash——对象字段与局部更新
  • 第 6 章:List——顺序、栈、队列和阻塞等待
  • 第 7 章:Set——唯一性、集合关系和抽样
  • 第 8 章:Sorted Set——排行榜、权重与时间窗口
  • 第 9 章:Bitmap 与 Bitfield——紧凑状态存储
  • 第 10 章:HyperLogLog——概率统计
  • 第 11 章:GEO——附近的人不是一张地图
  • 第 12 章:Stream——可持久化日志与消费者组
  • 第 13 章:数据结构选型与大 Key、热 Key

第三篇:原子性、批量执行与服务器端逻辑

  • 第 14 章:命令原子性、并发竞态与事务
  • 第 15 章:Pipeline、批量命令和网络往返
  • 第 16 章:Lua 脚本与 Redis Functions
  • 第 17 章:发布订阅与通知机制

第四篇:Python 与 FastAPI 工程化接入

  • 第 18 章:redis-py 异步客户端
  • 第 19 章:FastAPI 生命周期与依赖组织
  • 第 20 章:序列化、Key 设计和通用封装

第五篇:从实际模块理解 Redis 应用模式

  • 第 21 章:短信验证码登录
  • 第 22 章:Cache-Aside 查询缓存
  • 第 23 章:缓存一致性与更新策略
  • 第 24 章:缓存穿透、击穿、雪崩和热点 Key
  • 第 25 章:分布式锁从错误版本到可靠版本
  • 第 26 章:List 阻塞队列
  • 第 27 章:Stream 可靠消息队列
  • 第 28 章:秒杀与异步订单综合链路

第六篇:从单机缓存走向生产级分布式系统

  • 第 29 章:过期删除、内存淘汰和内存管理
  • 第 30 章:RDB、AOF 与数据恢复
  • 第 31 章:主从复制与读写分离
  • 第 32 章:Sentinel 高可用
  • 第 33 章:Redis Cluster
  • 第 34 章:分布式缓存架构
  • 第 35 章:多级缓存

第七篇:把系统拼起来并看懂它

  • 第 36 章:综合项目拼装
  • 第 37 章:测试策略
  • 第 38 章:性能、压测和优化
  • 第 39 章:监控与故障排查
  • 第 40 章:安全、上线检查与知识地图

附录

  • 附录 A:核心命令分类速查表
  • 附录 B:数据结构选型矩阵
  • 附录 C:缓存问题诊断矩阵
  • 附录 D:常见 Redis 错误与处理建议
  • 附录 E:FastAPI/redis-py 常用代码模板
  • 附录 F:术语与缩写
  • 附录 G:版本差异和旧命令说明
  • 附录 H:官方参考资料

第一篇:建立正确的 Redis 心智模型

第 1 章:为什么后端系统需要 Redis

1.1 从一个很普通的接口开始

假设你有一个 FastAPI 接口:

1
2
3
4
5
6
@router.get("/shops/{shop_id}")
async def get_shop(shop_id: int, db: AsyncSession = Depends(get_db)):
shop = await db.get(Shop, shop_id)
if shop is None:
raise HTTPException(status_code=404, detail="shop not found")
return shop

它每次收到请求都会访问数据库。用户少时没有问题;如果一家热门商户每秒被查询几千次,数据库会反复执行几乎相同的工作:

  1. 接收 SQL。
  2. 解析或复用执行计划。
  3. 查索引、访问数据页。
  4. 组装结果并通过网络返回。

商户名字和地址可能十分钟都没有变化,却被查了几万次。问题的本质不是数据库“不行”,而是系统把昂贵的重复工作做了太多次。

一个自然思路是:第一次查完以后,把结果放到访问更快的地方。后续先看那里有没有;有就直接返回,没有再查数据库。这个“把可复用结果临时保存在更近、更快的位置”的思想,就是缓存。

Redis 经常承担这个快存储层,但 Redis 不等于缓存。它还可以保存计数器、会话、排行榜、集合关系、限流状态、消息流和短期协调状态。

1.2 Redis 的精确定义

Redis 可以理解为一个通过网络访问的、主要在内存中操作数据的“数据结构服务器”。

这句话有四层含义:

  • 服务器:Redis 是独立进程。Python 程序不是读取一个本地 dict,而是通过 TCP 或 Unix Socket 向 Redis 发送命令。
  • 主要在内存中:核心读写作用于内存,所以延迟通常很低。Redis 也能将数据持久化到磁盘,但磁盘不是普通命令读取的主路径。
  • 数据结构:服务端直接理解 String、Hash、List、Set、Sorted Set、Stream 等结构。你不是只能保存一段无意义字节。
  • 命令式操作:客户端发送 SET、HSET、ZADD、XADD 等命令,Redis 执行后返回结果。

把 Redis 当成“远程共享 dict”只能解释最简单的 GET/SET,会遮蔽很多关键事实:

  • 网络可能失败,有网络往返时间。
  • 多个应用实例共享同一份状态。
  • Redis 命令有原子性,但多个命令拼起来未必原子。
  • 数据可能过期、被淘汰、因故障丢失或出现复制延迟。
  • 数据结构的时间复杂度和数据规模会影响事件循环中的其他请求。

1.3 Redis、关系型数据库和进程内缓存的区别

维度 进程内 dict/本地缓存 Redis 关系型数据库
所在位置 单个应用进程内 独立服务,网络访问 独立服务,网络访问
多实例共享 默认不能 可以 可以
主要介质 内存 内存为主,可持久化 磁盘/页缓存为主
查询能力 由代码自己实现 围绕数据结构和 Key SQL、索引、Join、约束
数据可靠性 进程退出通常丢失 取决于持久化与复制 通常是权威数据源
一致性与事务 进程内控制 命令原子、事务语义有限 成熟事务与约束
容量 受单进程内存限制 受实例或集群内存限制 通常更适合长期大数据
典型角色 L1 极热点缓存 缓存/共享短状态/队列等 系统事实和长期记录

一个稳妥的默认原则是:

数据库保存“业务事实”,Redis 保存“能重建的加速数据”或“有明确可靠性设计的共享状态”。

它不是铁律。例如 Redis 也可以作为某些数据的主存储,但那要求你明确设计持久化、复制、备份、恢复和一致性,而不能因为 Redis 快就自动获得数据库级可靠性。

1.4 Redis 为什么快

常见回答是“因为在内存里,而且是单线程”。这个回答不完整。

Redis 快来自多方面共同作用:

  1. 内存访问快:大多数命令不用等待随机磁盘 IO。
  2. 数据结构针对性强:查 Hash 字段、Set 成员或 Sorted Set 排名可以直接使用合适结构。
  3. 命令执行路径短:Redis 不是通用 SQL 引擎,很多操作的解析和执行成本较低。
  4. 事件驱动网络模型:一个线程可以管理大量连接的可读写事件,避免为每个连接创建一个线程。
  5. 命令执行通常串行:核心数据结构操作不需要到处加内部锁,语义简单。
  6. 批量和 Pipeline:可以降低网络往返带来的成本。
  7. 现代 Redis 会使用额外线程:例如后台持久化相关工作、异步释放、部分网络 IO 等。

因此,“Redis 是单线程的”应该准确表达为:

经典 Redis 的绝大多数普通命令对核心数据结构的执行是由主线程串行处理的;Redis 进程整体并非只有一个线程,现代版本还可使用 IO 线程和后台线程/子进程完成其他工作。

这个模型的好处是单命令天然不会被另一个普通命令执行到一半插入;坏处是一个很慢的命令可能堵住后面的所有命令。例如对几百万成员执行一次全量集合操作,即使算法没有 Bug,也可能造成明显延迟。

1.5 “快”到底是多少

不要背诵固定的“Redis 延迟是 X 毫秒、QPS 是 Y”。实际性能取决于:

  • 客户端与 Redis 的网络距离;
  • 命令类型和数据规模;
  • value 大小;
  • 是否使用 TLS;
  • CPU、内存、网卡和虚拟化环境;
  • 持久化 fork、内存碎片和操作系统调度;
  • 连接池、序列化和应用框架开销;
  • 是否存在热 Key、大 Key或慢命令。

比起一个脱离环境的数字,更值得掌握的是延迟组成:

1
2
3
4
5
6
7
8
一次业务 Redis 调用耗时
≈ 客户端排队
+ 获取连接
+ 序列化
+ 网络发送
+ Redis 排队与执行
+ 网络返回
+ 反序列化

1.6 Redis 适合什么

典型场景包括:

  • 查询缓存和计算结果缓存;
  • 登录会话、验证码、短期 Token;
  • 原子计数、库存预扣、限流窗口;
  • 去重集合、共同关系;
  • 排行榜和按时间/分数查询;
  • 轻量级分布式协调与锁;
  • 发布订阅、阻塞队列、Stream 消息流;
  • 地理位置附近查询;
  • UV 近似统计、位图状态;
  • L2 分布式缓存。

1.7 Redis 不适合什么

遇到下面需求应谨慎:

  • 需要复杂 Join、临时组合查询和强大的多字段索引;
  • 数据远大于可承受的内存成本;
  • 每一条已确认写入都绝对不能丢,但又没有专门设计持久化和共识系统;
  • 长时间运行的复杂计算;
  • 把巨大文件或超大 JSON 当成普通 value;
  • 仅因为“分布式锁听起来高级”就用 Redis 代替数据库唯一约束;
  • 需要 Kafka 级别的超大消息保留、分区生态和跨地域日志平台。

1.8 三种最重要的可靠性语言

学习分布式系统时,要警惕绝对化词语。

  • 保证:在写明的前提与故障模型下不会违反。
  • 降低概率/缩小窗口:风险还存在,只是更小。
  • 最终一致:中间可以不一致,经过传播或补偿后趋于一致。

例如:

  • SET NX PX 能原子地创建带租期的锁键,这是命令层面的保证。
  • 给锁设置 TTL 只能降低死锁风险,不保证持有者在执行期间锁永不过期。
  • 异步复制可以提高可用性和冗余,但不能保证已确认写入在所有故障下都不丢。

本章收束

核心结论

  • Redis 是主要在内存中工作的数据结构服务器,不只是缓存。
  • Redis 的快来自内存、结构、短执行路径、事件驱动和批量能力,而不是一个单独原因。
  • 核心命令串行执行带来单命令原子性,也意味着慢命令会影响其他请求。
  • 默认把数据库当业务事实源、把 Redis 当可重建加速层,是适合初学者的安全起点。

速记

1
2
3
4
Redis = 网络服务 + 内存为主 + 数据结构 + 命令
快 ≠ 永远快
高可用 ≠ 不丢数据
单命令原子 ≠ 一组业务步骤原子

常见误区

  • “用了 Redis 就不用数据库了。”
  • “Redis 单线程,所以只能服务一个客户端。”
  • “内存一定不会阻塞。”
  • “加了主从就绝对不会丢数据。”

你可以这样阐述

Redis 是一个以内存操作为主的数据结构服务器,客户端通过网络命令操作 String、Hash、Set、Sorted Set、Stream 等结构。它常用于缓存和共享短期状态。Redis 的优势是低延迟和原子数据结构操作,限制是内存成本、复杂查询能力较弱,以及需要自己面对过期、淘汰、持久化和分布式一致性问题。


第 2 章:安装、启动和第一次交互

这一章是全书唯一详细讲环境安装的部分。后续 Python、FastAPI 和数据库依赖只给最少说明。

2.1 Windows 用户应该怎么选

Redis 官方开源服务器主要面向 Linux 类环境。Windows 上推荐顺序是:

  1. Docker Desktop:教程主路径。环境隔离清楚,启动和删除方便。
  2. WSL2 中安装 Redis:适合希望学习 Linux 命令和服务管理的人。
  3. 云 Redis 或远程 Linux:适合已有环境,但不建议把学习实例暴露公网。

不要随便下载来历不明的 Windows Redis 可执行文件。它可能版本陈旧,行为与官方现代版本不同。

2.2 Docker 方式

先安装 Docker Desktop。安装 Docker 本身不在本文展开。确认终端能执行:

1
docker version

创建一个只供学习使用的目录,并准备如下 compose 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
services:
redis:
image: redis:7.4
container_name: redis-learning
restart: unless-stopped
ports:
- "127.0.0.1:6379:6379"
volumes:
- redis-data:/data
command:
- redis-server
- --appendonly
- "yes"
- --appendfsync
- everysec

volumes:
redis-data:

这里先固定 7.4 系列来保持示例稳定。阅读其他版本官方文档时应注意命令的 “since” 版本标记。Redis 8 可能增加新数据类型或改变客户端默认协议,但本文核心结构和经典命令仍适用。

逐项解释:

  • 127.0.0.1:6379:6379:只把宿主机本地回环地址的 6379 映射到容器。不要写成面向所有网卡的公网暴露。
  • redis-data:/data:把 Redis 数据目录放到 Docker 命名卷。删除容器不等于删除卷。
  • appendonly yes:打开 AOF 持久化,方便后续观察重启后的数据。
  • appendfsync everysec:通常每秒同步一次,含义会在持久化章节展开。

启动:

1
2
3
docker compose up -d
docker compose ps
docker compose logs redis

进入容器中的 CLI:

1
docker exec -it redis-learning redis-cli

看到提示符后输入:

1
PING

返回:

1
PONG

这只说明当前连接可以发命令并收到响应,并不代表磁盘、复制、内存余量和业务读写全部健康。

2.3 Linux / WSL 简述

Ubuntu/WSL 可以使用系统包管理器安装:

1
2
3
4
sudo apt update
sudo apt install redis-server
redis-server --version
redis-cli ping

系统仓库中的版本可能落后于官方当前版本。学习基础命令通常足够;研究新命令时要先看版本。

服务管理常见命令:

1
2
3
sudo systemctl status redis-server
sudo systemctl start redis-server
sudo systemctl restart redis-server

WSL 环境是否启用 systemd 取决于配置;无法使用 systemctl 时可以直接启动 redis-server 或使用 Docker。

2.4 macOS 简述

安装 Homebrew 后:

1
2
3
brew install redis
brew services start redis
redis-cli ping

2.5 redis-server、redis-cli 和客户端库

三者不要混:

  • redis-server:真正保存数据和执行命令的服务器进程。
  • redis-cli:官方命令行客户端,用于学习、观察和排障。
  • redis-py:Python 客户端库,把 Python 方法编码为 Redis 协议并通过网络发送。

客户端不等于服务器。只安装 redis-py,不会凭空出现一个 Redis 服务。

2.6 第一次读写

在 redis-cli 中执行:

1
2
3
4
5
6
SET greeting "hello redis"
GET greeting
TYPE greeting
EXISTS greeting
DEL greeting
GET greeting

典型返回含义:

  • SET 返回 OK:写入成功。
  • GET 返回字符串:Key 存在且类型正确。
  • TYPE 返回 string:顶层类型是 String。
  • EXISTS 返回 1:存在;不存在返回 0。
  • DEL 返回删除的 Key 数量。
  • 最后 GET 返回 nil:Key 不存在。nil 不是保存了字符串 "nil"。

2.7 数据库编号

独立 Redis 默认通常有多个逻辑数据库,编号从 0 开始:

1
2
3
4
SELECT 1
SET name "db-one"
SELECT 0
GET name

逻辑数据库共享同一个 Redis 实例的 CPU、内存、持久化和故障域,并不是强隔离租户。Redis Cluster 只支持数据库 0。生产项目通常使用独立实例/集群加 Key 前缀隔离,不依赖 SELECT 切库。

2.8 认证与安全基线

本机学习实例最重要的是:只监听本机,不暴露公网。

生产环境至少考虑:

  • 私有网络、安全组和防火墙;
  • ACL 用户与最小命令权限;
  • TLS 或可信内网链路;
  • 密码/证书由密钥管理系统提供,不写进代码仓库;
  • 限制 CONFIG、FLUSHALL、MODULE、DEBUG 等危险能力;
  • 关闭或重命名命令不是完整安全方案,网络隔离与 ACL 才是核心。

最简密码演示可在配置中加入 requirepass,但现代 Redis 更推荐理解 ACL:

1
ACL SETUSER appuser on >a-strong-learning-password ~app:* +@read +@write -@dangerous

符号含义大意是:

  • on:启用用户。
  • 大于号后的文本:设置密码。
  • ~app:*:只能访问匹配的 Key。
  • +@read、+@write:允许读写命令类别。
  • -@dangerous:拒绝危险类别。

实际 ACL 要依据应用所需命令精确设计。本文不把密码写入后续示例源码。

2.9 停止、重启与数据卷

1
2
3
4
docker compose restart redis
docker compose stop
docker compose start
docker compose down

compose down 默认删除容器和网络,但保留命名卷;带 -v 会连卷一起删,是破坏性操作。学习中也应养成先确认目标的习惯。

可以在重启前后观察:

1
SET persistent-demo "still here"

重启后再 GET。是否恢复取决于持久化配置和写入落盘时机。它不能证明“所有故障下都不丢数据”。

2.10 基础配置从哪里来

查看运行配置可使用:

1
2
3
4
5
6
CONFIG GET appendonly
CONFIG GET maxmemory
CONFIG GET maxmemory-policy
INFO server
INFO memory
INFO persistence

CONFIG SET 修改的是运行时配置,并非所有修改都会自动持久写回配置文件。生产上应让部署配置成为事实源,避免手工改完重启丢失。

本章收束

核心结论

  • Windows 学习优先用 Docker 或 WSL。
  • 服务器、CLI、Python 客户端是三个不同角色。
  • 只绑定本机地址,学习实例也不要暴露公网。
  • PING 只验证最基础连通性。
  • Docker 卷、AOF 和容器不是同一个生命周期。

常用命令速记

1
2
3
4
5
PING                 连通性
INFO [section] 服务状态
CONFIG GET pattern 查询运行配置
SELECT index 选择逻辑数据库
ACL ... 用户与权限

常见误区

  • 安装 redis-py 就以为安装了 Redis 服务。
  • 把 6379 直接暴露公网。
  • 把多个逻辑 DB 当成安全隔离。
  • 容器能重启就以为数据一定可靠。

你可以这样阐述

Redis 由服务器进程、客户端工具和语言客户端共同构成。学习环境可以通过 Docker 把服务限制在 127.0.0.1,并挂载数据卷。redis-cli 用于直接验证命令,Python 客户端则通过连接池向服务器发送同样的命令。生产安全要依靠网络隔离、ACL、TLS 和密钥管理。


第 3 章:键空间、类型、TTL 与安全遍历

数据结构之前必须先理解 Key。Redis 中每一个顶层对象都由 Key 找到,TTL 也附着在 Key 上,而不是附着在普通 String 的某个字符上。

3.1 Key 是什么

Key 是二进制安全的字节序列,技术上不只限于文本。但实际项目应使用可读、稳定、长度受控的字符串。

推荐格式:

1
2
3
4
5
业务:实体:标识
login:code:13800000000
login:session:4f2...
cache:shop:42
seckill:stock:1001

冒号只是人类可读的分隔符,不会创建目录。命名要避免:

  • 含明文隐私或密钥;
  • 无界长度;
  • 把整个请求 JSON 拼进 Key;
  • 同一业务多种风格混用;
  • 不带环境/租户隔离却共用同一实例。

生产环境可使用:

1
prod:shop-api:cache:shop:42

前缀越长越易读,但每个 Key 都会重复占内存,需要在可读性与成本间平衡。

3.2 通用键命令

1
2
3
4
5
6
SET user:1:name "Alice"
EXISTS user:1:name user:2:name
TYPE user:1:name
RENAME user:1:name user:1:display_name
RENAMENX user:1:display_name user:1:name
DEL user:1:name

EXISTS

可以接收多个 Key,返回存在的参数数量。若同一个存在 Key 写了两次,会计数两次,因此它不是“不同 Key 的去重计数”。

时间复杂度近似与参数个数 N 成正比。

TYPE

返回 string、list、set、zset、hash、stream 或 none。Bitmap 的 TYPE 仍是 string;GEO 的 TYPE 仍是 zset。

  • DEL:从键空间移除并同步释放相关内存。删除很大的集合可能占用主线程较长时间。
  • UNLINK:快速从键空间摘除,把实际内存回收交给后台线程,适合大 Key。
1
UNLINK huge:cache:key

两者从客户端可见性看都是删除;差别主要在释放成本放在哪里。

RENAME 与 RENAMENX

RENAME 会覆盖目标 Key,且在大对象覆盖时可能产生明显释放成本。RENAMENX 只在目标不存在时改名。Cluster 环境中相关 Key 还会受同槽约束。

3.3 TTL:数据的生存时间

TTL 是 Time To Live,表示 Key 还能存活多久。

1
2
3
4
SET login:code:13800000000 "482913"
EXPIRE login:code:13800000000 300
TTL login:code:13800000000
PTTL login:code:13800000000
  • EXPIRE:相对秒。
  • PEXPIRE:相对毫秒。
  • EXPIREAT:绝对 Unix 秒时间戳。
  • PEXPIREAT:绝对 Unix 毫秒时间戳。
  • TTL:剩余秒。
  • PTTL:剩余毫秒。
  • EXPIRETIME / PEXPIRETIME:返回绝对到期时间。
  • PERSIST:移除过期时间,使 Key 持久存在。

TTL 的特殊返回:

  • 正数:剩余时间。
  • -1:Key 存在,但没有过期时间。
  • -2:Key 不存在。

3.4 用 SET 一次完成写入和过期

不要把下面两步当成最优默认:

1
2
SET login:code:13800000000 "482913"
EXPIRE login:code:13800000000 300

如果客户端在两条命令之间崩溃,验证码可能永不过期。用一条命令:

1
SET login:code:13800000000 "482913" EX 300

这条命令内部一次完成设置 value 和 TTL。

3.5 哪些操作会保留或清除 TTL

这是非常容易出错的地方。

一般规律:

  • DEL 会连 Key 一起删除,TTL 自然不存在。
  • 对 Key 进行“原地修改”的命令通常保留 TTL,例如 INCR、HSET、LPUSH。
  • 用普通 SET 覆盖同名 Key 通常会清除原 TTL。
  • SET 可以用 KEEPTTL 保留旧 TTL。
  • RENAME 会把源 Key 的 TTL 一起移动到新 Key。

实验:

1
2
3
4
5
6
SET counter 1 EX 60
INCR counter
TTL counter
SET counter 99
TTL counter
SET counter 100 KEEPTTL

最后一条只有在执行前已经存在 TTL 时才有可保留内容。每种命令的精确规则仍应看命令文档,不能只靠猜。

3.6 到期不等于那一毫秒立刻物理删除

当一个 Key 到达过期时间,逻辑上它已经不应再被正常读到。Redis 的物理删除结合:

  • 惰性过期:访问 Key 时发现过期,删除它。
  • 主动过期:后台周期性抽样检查并删除过期 Key。

这样做是为了避免给每个 Key 都安排一个精确计时器。第 29 章会深入。

3.7 EXPIRE 的条件选项

现代 Redis 的 EXPIRE 支持条件:

1
2
3
4
EXPIRE mykey 60 NX
EXPIRE mykey 120 XX
EXPIRE mykey 30 GT
EXPIRE mykey 300 LT
  • NX:只在当前没有 TTL 时设置。
  • XX:只在已有 TTL 时设置。
  • GT:只有新 TTL 大于当前 TTL 才设置。
  • LT:只有新 TTL 小于当前 TTL 才设置。

这些选项适合续期或收紧过期策略,但复杂业务仍要考虑多命令竞态。

3.8 为什么不要在生产执行 KEYS *

KEYS pattern 会一次遍历匹配整个键空间并返回全部结果。Key 很多时,它会长时间占用主线程,还可能生成巨大的网络响应。

学习实例可以观察模式匹配:

1
KEYS cache:shop:*

生产遍历使用 SCAN:

1
SCAN 0 MATCH cache:shop:* COUNT 100

返回两部分:下一次游标和本批 Key。不断把返回游标传回 SCAN,直到游标重新变为 0。

伪代码:

1
2
3
4
5
6
7
8
9
10
11
cursor = 0
while True:
cursor, keys = await redis.scan(
cursor=cursor,
match="cache:shop:*",
count=100,
)
for key in keys:
process(key)
if cursor == 0:
break

SCAN 的重要语义:

  • COUNT 是工作量提示,不保证每批恰好多少条。
  • 遍历期间键空间还在变化,结果不是某个时刻的一致快照。
  • 完整迭代期间一直存在的元素应被返回,但可能出现重复,需要操作具备幂等性。
  • 某一批可以返回 0 条但游标还没结束。
  • SCAN 降低单次阻塞,并不让遍历成本消失。

Hash、Set、Sorted Set 分别有 HSCAN、SSCAN、ZSCAN。

3.9 查看规模与危险清空

1
DBSIZE

DBSIZE 返回当前逻辑数据库的 Key 数量,通常比 KEYS * 安全得多,但它不告诉你每个 Key 多大。

以下是破坏性命令:

1
2
FLUSHDB
FLUSHALL
  • FLUSHDB:清空当前逻辑数据库。
  • FLUSHALL:清空所有逻辑数据库。

生产 ACL 应限制它们。不要为了“清测试数据”对共享环境运行全库清空;测试应使用独立实例或唯一前缀并精准清理。

3.10 键命令的 Python 对应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from redis.asyncio import Redis


async def key_examples(redis: Redis) -> None:
await redis.set("cache:shop:42", '{"id":42}', ex=300)

exists_count = await redis.exists(
"cache:shop:42",
"cache:shop:404",
)
key_type = await redis.type("cache:shop:42")
ttl_seconds = await redis.ttl("cache:shop:42")

await redis.expire("cache:shop:42", 600)
await redis.unlink("cache:shop:42")

print(exists_count, key_type, ttl_seconds)

如果客户端设置了 decode_responses=True,文本响应通常解码为 str;否则经常得到 bytes。第 18 章会统一处理。

本章收束

核心结论

  • TTL 属于整个 Key。
  • 普通 SET 覆盖通常会清除 TTL,原地修改通常保留 TTL。
  • 写入临时值时优先用单条 SET EX/PX。
  • KEYS 适合极小的学习环境;生产遍历使用 SCAN 并接受弱一致和重复可能。
  • 删除大 Key 优先评估 UNLINK。

命令速记

目的 命令
判断存在 EXISTS
查看类型 TYPE
删除 DEL / UNLINK
改名 RENAME / RENAMENX
相对过期 EXPIRE / PEXPIRE
绝对过期 EXPIREAT / PEXPIREAT
查看过期 TTL / PTTL / EXPIRETIME
取消过期 PERSIST
增量遍历 SCAN
Key 数量 DBSIZE

常见误区

  • 认为冒号创建了目录。
  • SET 后再 EXPIRE,却忽略中间崩溃窗口。
  • 认为 TTL 为 -1 是 Key 不存在。
  • 认为 SCAN 每批数量固定或结果绝不重复。
  • 用 KEYS * 做在线接口。

你可以这样阐述

Redis 的数据位于统一键空间中,类型和 TTL 都附着在 Key 上。临时数据应尽量通过单条 SET EX 原子写入。全量 KEYS 会阻塞并产生大响应,生产遍历应使用基于游标的 SCAN,但 SCAN 不是一致性快照,调用者要允许空批次和重复结果。


第二篇:数据结构决定建模方式

到这里你已经会创建、查找和让 Key 过期。接下来不应该按“哪个命令看起来顺手”来存数据,而要从访问方式反推结构:

  • 只按 Key 整体读写、计数:String。
  • 对象字段局部读写:Hash。
  • 保留插入顺序并从两端操作:List。
  • 成员唯一、关心集合关系:Set。
  • 成员唯一且按分数排序:Sorted Set。
  • 大量布尔位:Bitmap。
  • 只估算去重数量:HyperLogLog。
  • 位置附近搜索:GEO。
  • 可追踪消费的追加日志:Stream。

第 4 章:String——缓存、计数器与条件写入

4.1 String 不只是字符串

Redis String 是一段二进制安全的字节序列。它可以存 UTF-8 文本、JSON、序列化对象、数字文本,也可以作为 Bitmap 的底层字节。

一个 String value 在协议上可以很大,但“允许很大”不代表业务应该存很大。大 value 会增加内存、网络、复制、持久化和删除成本。缓存对象通常应控制在较小尺寸,而不是把几十 MB 文件塞进去。

4.2 SET 是最值得深入的命令

基本语法:

1
2
3
4
SET key value
[NX | XX]
[GET]
[EX seconds | PX milliseconds | EXAT unix-seconds | PXAT unix-ms | KEEPTTL]

基本写入会覆盖旧值,并默认清除旧 TTL:

1
SET profile:1:name "Alice"

只在 Key 不存在时写:

1
SET order:request:abc "processing" NX EX 60

NX 是 Not eXists。成功返回 OK,Key 已存在通常返回 nil。它适合去重标记、初始化和锁键创建,但“创建锁键”只是分布式锁的一步。

只在 Key 已存在时写:

1
SET feature:flag "off" XX

XX 不是数据库的版本比较更新,因为它不比较旧 value。需要 compare-and-set 时用 WATCH 或 Lua。

写入新值并返回旧值:

1
SET counter:owner "worker-b" GET

常见过期选项:

1
2
3
4
SET cache:shop:42 '{"id":42}' EX 300
SET lock:shop:42 "unique-token" PX 10000 NX
SET campaign "on" EXAT 1790000000
SET session:abc "user:1" KEEPTTL

EX/PX 是相对时间,EXAT/PXAT 是绝对 Unix 时间。KEEPTTL 仅表示覆盖时保留旧 TTL。

4.3 获取和批量操作

1
2
3
4
5
6
7
GET profile:1:name
MGET cache:shop:1 cache:shop:2 cache:shop:3
MSET config:a "1" config:b "2"
MSETNX init:a "1" init:b "2"
GETDEL one-time:token
GETEX session:abc EX 1800
GETEX session:abc PERSIST
  • GET:不存在返回 nil,Key 是其他类型则报 WRONGTYPE。
  • MGET:按输入顺序返回,不存在位置为 nil。减少往返但不减少总响应字节。
  • MSET:设置多对 Key/value。
  • MSETNX:只有所有 Key 都不存在时才全部写入,否则全部不写。
  • GETDEL:原子取值并删除;取出后业务失败时,值也已经消失。
  • GETEX:读取同时更新或移除 TTL,可用于滑动会话,但会增加写流量。

Redis Cluster 中,多 Key 命令通常要求 Key 位于同一 hash slot。Hash tag 可以控制槽位:

1
2
cache:shop:{42}:base
cache:shop:{42}:hours

花括号内的 42 用于计算槽位。不要把毫无关联的海量 Key 强行放到同一槽,否则可能制造热点。

4.4 原子计数

1
2
3
4
5
6
SET page:view:42 0
INCR page:view:42
INCRBY page:view:42 10
DECR page:view:42
DECRBY page:view:42 3
INCRBYFLOAT price:index 0.25

INCR 是服务端单命令读—改—写,因此多个客户端同时执行不会像下面的复合操作一样丢失更新:

1
2
3
# 错误:GET 与 SET 之间可能插入另一个客户端
value = int(await redis.get("counter"))
await redis.set("counter", value + 1)

Key 不存在时,整数增减通常把它视为 0。value 必须能解析成相应数字。整数有 64 位有符号范围限制;浮点不适合精确货币账本,应使用整数分或数据库 Decimal。

计数原子只保证这个数字的变化不丢。如果还要同时写数据库、集合或消息,仍然是多步骤原子性问题。

4.5 字符串片段

1
2
3
4
APPEND log:line "abc"
STRLEN log:line
GETRANGE log:line 0 2
SETRANGE log:line 1 "Z"
  • STRLEN 返回字节长度,不是 Unicode 字符个数。
  • GETRANGE 的结束下标包含在内,负数从尾部计数。
  • SETRANGE 按字节偏移覆盖;偏移远大于当前长度时,中间填零字节。

不要用一个不断 APPEND 的 String 代替日志系统,它会变成不可控的大 Key。

4.6 内部编码与内存

Redis 会依据内容和长度选择整数编码或不同形式的简单动态字符串(SDS)。不需要背版本相关阈值,但要理解:

  • 看起来是整数的 String 可能使用整数形式;
  • 追加文本可能触发编码转换;
  • 内存不只包含 value,还包含 Key、对象头、哈希表节点和分配器对齐;
  • 一百万个短 Key/value 的内存远大于文本长度之和。

观察命令:

1
2
OBJECT ENCODING page:view:42
MEMORY USAGE page:view:42

业务逻辑不应依赖某个内部编码名称。

4.7 Python 异步调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from redis.asyncio import Redis


async def string_examples(redis: Redis) -> None:
created = await redis.set(
"login:code:13800000000",
"482913",
ex=300,
nx=True,
)

current = await redis.get("login:code:13800000000")
attempts = await redis.incr("login:attempts:13800000000")
await redis.expire("login:attempts:13800000000", 600, nx=True)

shops = await redis.mget(
"cache:shop:1",
"cache:shop:2",
)
print(created, current, attempts, shops)

这里 INCR 和 EXPIRE NX 仍是两条命令,中间崩溃会留下无 TTL 的计数 Key。限流实现会用 Lua 将首次计数和过期绑定起来。

4.8 String 还是 Hash

一个商户可保存为 JSON String:

1
SET cache:shop:42 '{"id":42,"name":"Coffee","rating":4.8}' EX 300

优点是一次 GET 得到完整对象,与 HTTP JSON 接近;缺点是更新单字段也要重写整体,Redis 看不懂 JSON 内字段。

也可保存为 Hash:

1
2
HSET shop:42 id 42 name Coffee rating 4.8
EXPIRE shop:42 300

优点是字段级读写;缺点是嵌套和类型需约定,TTL 默认属于整个 Hash。查询缓存通常优先 JSON String,频繁字段更新和计数时考虑 Hash。

本章收束

核心结论

  • String 是二进制 value,不只存文本。
  • SET 的 NX/XX 和过期选项让许多“判断再写入”变成一条原子命令。
  • INCR 适合原子计数,但不能让跨系统业务自动原子。
  • MGET/MSET 降低往返,不消除大响应和 Cluster 跨槽问题。

命令速记

类别 核心命令
写入 SET、MSET、MSETNX
读取 GET、MGET、GETDEL、GETEX
整数 INCR、INCRBY、DECR、DECRBY
浮点 INCRBYFLOAT
片段 APPEND、STRLEN、GETRANGE、SETRANGE

常见误区

  • 用 GET + SET 自己实现计数。
  • 认为 MGET 可以无成本读取任意多 Key。
  • 用普通 SET 覆盖有 TTL 的 Key,却不知道 TTL 消失。
  • 用浮点计数保存精确金额。

你可以这样阐述

Redis String 是二进制安全的 value,适合整体缓存、计数器和条件写入。SET 可以用 NX/XX 控制存在条件,用 EX/PX 原子附带 TTL。INCR 是单命令原子计数。批量操作能降低网络往返,但要控制响应大小和 Cluster 同槽限制。


第 5 章:Hash——对象字段与局部更新

5.1 Hash 的模型

一个 Redis Hash 是:

1
Key → { field1: value1, field2: value2, ... }

例如:

1
2
3
4
5
6
shop:42 →
{
"name": "Coffee Lab",
"rating": "4.8",
"view_count": "1024"
}

Hash 的 field 和 value 也是字节序列。它像 Python dict,但通过网络命令操作;像数据库一行,但没有 SQL 类型、外键、约束和任意字段索引。

5.2 创建和读取字段

1
2
3
4
HSET shop:42 name "Coffee Lab" rating "4.8" view_count "1024"
HGET shop:42 name
HMGET shop:42 name rating missing_field
HGETALL shop:42

HSET 可一次写多个 field-value,返回新增字段数量,而不是修改字段数量。覆盖已有字段成功,但不计入新增数。

  • HGET:字段或 Key 不存在时返回 nil。
  • HMGET:按输入顺序返回,不存在项为 nil。
  • HGETALL:返回全部字段和值;大 Hash 上会产生大响应。

5.3 存在、数量、删除与计数

1
2
3
4
5
6
7
HEXISTS shop:42 rating
HLEN shop:42
HDEL shop:42 rating missing_field
HSETNX shop:42 created_by "bootstrap"
HINCRBY shop:42 view_count 1
HINCRBY shop:42 stock -1
HINCRBYFLOAT shop:42 rating_sum 4.5
  • HEXISTS 返回 0/1。
  • HLEN 返回字段数。
  • HDEL 返回实际删除数;最后字段删除后整个 Key 消失。
  • HSETNX 只判断一个 field 是否存在。
  • HINCRBY 对字段做原子数值修改。

如果业务要求“库存大于 0 才减一”,单纯 HINCRBY -1 可能减成负数,需要 Lua 把判断和扣减放进一段原子逻辑。

5.4 遍历与大 Hash

1
2
3
HKEYS shop:42
HVALS shop:42
HSCAN shop:42 0 MATCH rate* COUNT 100

HKEYS、HVALS、HGETALL 的成本随字段总数增长。字段规模不受控时使用 HSCAN,并接受 SCAN 的弱一致与重复可能。

5.5 Hash 的 TTL

最通用的语义是 TTL 附着于整个 Hash Key:

1
2
HSET login:session:abc user_id 1 nickname Alice
EXPIRE login:session:abc 1800

HSET 修改字段通常保留整个 Key 的 TTL。

较新 Redis 版本开始支持 Hash 字段级过期相关命令,但部署版本、云服务和客户端支持可能不同。若每个字段必须使用不同 TTL,应选择拆 Key、明确锁定支持版本,或改变建模,而不是假设所有环境都有字段 TTL。

5.6 内部表示与聚合边界

字段少且较小时,Redis 可能使用紧凑 listpack;规模或元素大小超过阈值后转为哈希表。需要记住:

  • 小 Hash 聚合相似字段可能节省重复 Key 开销;
  • 大 Hash 的 HGET 仍快,但 HGETALL、删除、迁移、复制会变重;
  • 阈值随版本和配置变化;
  • 一个超级 Hash 塞入所有用户,会形成大 Key、单槽热点和统一 TTL。

三种存法:

  1. 一个对象一个 JSON String:整体读写、独立 TTL,适合查询缓存。
  2. 一个对象一个 Hash:局部字段更新、独立 TTL。
  3. 所有对象放在一个 Hash:省部分 Key 开销,但共享 TTL、槽位和故障影响,通常不应无限增长。

5.7 Python 映射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from redis.asyncio import Redis


async def save_shop_hash(redis: Redis, shop_id: int) -> None:
key = f"shop:{shop_id}"
await redis.hset(
key,
mapping={
"id": str(shop_id),
"name": "Coffee Lab",
"rating": "4.8",
"view_count": "0",
},
)
await redis.expire(key, 600)


async def read_shop_hash(
redis: Redis,
shop_id: int,
) -> dict[str, str] | None:
data = await redis.hgetall(f"shop:{shop_id}")
return data or None

HSET 与 EXPIRE 是两条命令。若缺少 TTL 绝对不可接受,可使用事务、Lua,或者改用 JSON String 的 SET EX。

本章收束

核心结论

  • Hash 适合字段级读取、修改和计数。
  • HSET 返回新增字段数,不是成功修改数。
  • TTL 默认属于整个 Hash Key。
  • HGETALL 会随字段数量线性增长。
  • 不要把所有对象放进一个无限增长的 Hash。

命令速记

类别 核心命令
写字段 HSET、HSETNX
读字段 HGET、HMGET、HGETALL
判断与数量 HEXISTS、HLEN
删除 HDEL
数值更新 HINCRBY、HINCRBYFLOAT
遍历 HSCAN

常见误区

  • 把 Hash 当成带 schema 和索引的数据库表。
  • 认为每个字段天然有独立 TTL。
  • 大 Hash 上频繁 HGETALL。
  • 只看 value 文本大小,不计算字段和对象开销。

你可以这样阐述

Redis Hash 在一个 Key 下保存多个 field-value,适合对象字段的局部读写和原子计数。它没有关系型数据库的类型和约束,TTL 通常作用于整个 Hash。对象整体缓存更适合 JSON String,字段频繁变化时 Hash 更自然。


第 6 章:List——顺序、栈、队列和阻塞等待

6.1 List 的模型

Redis List 是有顺序、可重复的字符串序列,擅长在两端插入和弹出:

1
左端 ← [a, b, b, c] → 右端

相同元素可以出现多次。它不是按任意字段查询的数组,也不适合频繁随机访问中间位置。

6.2 两端写入和弹出

1
2
3
4
5
6
7
8
9
LPUSH tasks "a"
LPUSH tasks "b" "c"
RPUSH tasks "d" "e"
LRANGE tasks 0 -1

LPOP tasks
RPOP tasks
LPOP tasks 3
RPOP tasks 3

多元素 LPUSH 时,参数逐个推到左侧,最终 c 比 b 更靠左。实际顺序最好用小数据观察,不要凭直觉。

LPUSH/RPUSH 返回操作后长度。LPOP/RPOP 不带 count 时通常返回单元素,带 count 时返回数组;List 不存在时返回 nil。

只在 List 已存在时推入:

1
2
LPUSHX tasks "only-if-exists"
RPUSHX tasks "only-if-exists"

栈与队列:

模式 写入 读取
栈(LIFO) LPUSH LPOP
队列(FIFO) RPUSH LPOP
反向 FIFO LPUSH RPOP

6.3 范围、索引和截断

1
2
3
4
5
LRANGE tasks 0 -1
LRANGE tasks 0 9
LINDEX tasks 3
LLEN tasks
LTRIM recent:events 0 99
  • LRANGE 的两端索引都包含;0 -1 表示全部。
  • LINDEX 访问中间位置需要遍历。
  • LTRIM 只保留指定范围,常与 LPUSH 组合维护最近 N 条。
1
2
LPUSH recent:events "event-json"
LTRIM recent:events 0 99

这两条命令间存在并发窗口;必须作为一个业务原子动作时用事务或 Lua。

修改、移除和定位:

1
2
3
LSET tasks 1 "new-value"
LREM tasks 2 "duplicate"
LPOS tasks "target"

LREM 的 count:

  • 大于 0:从左向右最多删除 count 个。
  • 小于 0:从右向左最多删除绝对值个。
  • 等于 0:删除全部匹配。

按 value 删除消息很脆弱,因为重复 value 无法唯一标识,扫描成本也会增长。

6.4 原子移动

1
LMOVE source processing RIGHT LEFT

LMOVE 原子地从源 List 一端弹出,并推到目标 List 一端。旧命令 RPOPLPUSH 可以视为固定方向的旧形式。

这可构造处理中列表:

1
pending --LMOVE--> processing

worker 崩溃时消息仍在 processing,但还要自行解决:

  • 谁判断处理超时;
  • 怎样重试;
  • 怎样避免重复业务效果;
  • processing 如何清理。

它是可靠队列的积木,不是完整系统。

6.5 阻塞操作

1
2
3
BLPOP tasks 5
BRPOP tasks 0
BLMOVE pending processing RIGHT LEFT 10
  • 有元素时立即返回。
  • 没元素时当前客户端连接等待。
  • 正 timeout 是最长等待秒数;0 通常是无限等待。
  • BLPOP 可以接多个 Key,按参数顺序检查非空列表。

阻塞客户端不会让整个 Redis 停止。Redis 记录该连接在等待,仍服务其他客户端;元素到来后再唤醒。

阻塞命令会长期占用一个连接。因此 worker 不应与普通请求争抢过小连接池;有限超时也便于检查退出信号。

6.6 为什么 List 不是可靠队列终点

最简单模型:

1
生产者 RPUSH → worker BRPOP → 处理

worker 取到消息后、业务完成前崩溃,消息已从 List 删除,造成丢失。

BLMOVE 到 processing 可以改善,但仍缺少原生消费者组、待确认元数据、空闲时长、消费次数和自动认领。Stream 会补上这些能力。

6.7 内部实现和复杂度

现代 Redis List 通常使用 quicklist 组织紧凑节点。高层结论:

  • 两端 push/pop 通常 O(1)。
  • LINDEX、LSET 最坏随到目标位置的距离增长。
  • LRANGE 返回 N 项至少 O(N),网络也需传 N 项。
  • LREM 需要扫描。
  • 不要用 LRANGE 0 -1 监控无界队列。

6.8 Python 基础调用

1
2
3
4
5
6
7
8
9
10
11
12
13
from redis.asyncio import Redis


async def enqueue(redis: Redis, payload: str) -> int:
return await redis.rpush("queue:email", payload)


async def consume_one(redis: Redis) -> str | None:
result = await redis.blpop("queue:email", timeout=5)
if result is None:
return None
_queue_name, payload = result
return payload

decode_responses=True 时返回 str,否则通常为 bytes。

本章收束

核心结论

  • List 有序、可重复,擅长两端操作。
  • 阻塞弹出避免无消息时高频轮询。
  • BRPOP 取出即删除,有崩溃丢失窗口。
  • LMOVE/BLMOVE 能构造处理中列表,但重试和幂等仍需自行设计。
  • 随机访问和全量扫描不是 List 强项。

命令速记

类别 核心命令
两端写 LPUSH、RPUSH、LPUSHX、RPUSHX
两端弹 LPOP、RPOP
阻塞弹 BLPOP、BRPOP
查看 LRANGE、LINDEX、LLEN、LPOS
修改 LSET、LREM、LTRIM
移动 LMOVE、BLMOVE

常见误区

  • 混淆 LPUSH 多参数后的顺序。
  • 把 BRPOP 当成不会丢消息的可靠消费。
  • 在巨大 List 上 LRANGE 0 -1。
  • 认为阻塞连接会阻塞整个 Redis。

你可以这样阐述

Redis List 是有序可重复的双端序列,两端 push/pop 高效,可用 BLPOP/BRPOP 构造阻塞队列。简单弹出会在 worker 崩溃时丢消息,LMOVE 可引入处理中列表但仍需自行确认和重试;需要完整消费跟踪时更适合 Stream。


第 7 章:Set——唯一性、集合关系和抽样

7.1 Set 的模型与基础命令

Set 是无序、成员唯一的集合:

1
followers:user:1 = {"user:2", "user:7", "user:9"}

同一成员加入多次仍只有一份。“无序”表示没有可依赖的业务顺序。

1
2
3
4
5
6
SADD tags:shop:42 coffee quiet wifi
SADD tags:shop:42 coffee
SISMEMBER tags:shop:42 wifi
SMISMEMBER tags:shop:42 wifi parking quiet
SCARD tags:shop:42
SREM tags:shop:42 quiet
  • SADD 返回实际新增成员数。
  • SISMEMBER 返回是否存在。
  • SMISMEMBER 批量返回 0/1。
  • SCARD 返回成员数量。
  • SREM 返回实际删除数。

适合标签、黑白名单、已点赞用户、已购用户、权限和关注关系。

7.2 获取、遍历和随机

1
2
3
4
5
6
7
SMEMBERS tags:shop:42
SSCAN tags:shop:42 0 MATCH w* COUNT 100

SRANDMEMBER lottery:users
SRANDMEMBER lottery:users 3
SRANDMEMBER lottery:users -3
SPOP lottery:users 3

SMEMBERS 返回全部成员,大 Set 应使用 SSCAN。

随机命令:

  • SRANDMEMBER 不删除。
  • 正 count 返回不同成员,数量不超过集合大小。
  • 负 count 允许重复,可超过集合大小。
  • SPOP 随机取出并删除。

涉及公平、审计或财务权益的抽奖,不能只靠一个随机命令就声称合规,还需要可验证随机源、名单冻结和审计记录。

7.3 集合运算

1
2
3
4
5
6
7
8
SADD follow:user:1 user:2 user:3 user:4
SADD follow:user:9 user:3 user:4 user:5

SINTER follow:user:1 follow:user:9
SUNION follow:user:1 follow:user:9
SDIFF follow:user:1 follow:user:9
SINTERCARD 2 follow:user:1 follow:user:9
SINTERSTORE common:1:9 follow:user:1 follow:user:9
  • SINTER:交集,共同关注。
  • SUNION:并集。
  • SDIFF:第一个集合有而后续集合没有的成员。
  • SINTERCARD:只返回交集数量,可用 LIMIT 提前停止。
  • SINTERSTORE/SUNIONSTORE/SDIFFSTORE:把结果写到目标 Set,并覆盖目标原内容。

集合运算的 CPU 和响应成本取决于输入集合与结果规模。不要在在线请求中对多个百万成员集合做无界交集。只要数量时不要取回所有成员。

7.4 内部表示与精确性

成员全为整数且规模较小时,Redis 可能使用紧凑整数集合;条件不满足后转换为通用表示。阈值随版本变化,业务不应依赖。

Set 精确保留每个成员。如果只要近似去重数量、不需要列出成员,可以使用 HyperLogLog,以很小固定空间换误差。

7.5 Python 示例

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
from redis.asyncio import Redis


async def common_following(
redis: Redis,
left_user_id: int,
right_user_id: int,
) -> set[str]:
members = await redis.sinter(
f"follow:user:{left_user_id}",
f"follow:user:{right_user_id}",
)
return set(members)


async def mark_ordered(
redis: Redis,
voucher_id: int,
user_id: int,
) -> bool:
added = await redis.sadd(
f"seckill:ordered:{voucher_id}",
str(user_id),
)
return added == 1

SADD 可原子判断并加入已购集合;如果还要扣库存和发消息,需要 Lua 让步骤共同原子。

本章收束

核心结论

  • Set 的核心价值是唯一性和集合关系。
  • SADD 返回新增数量,可用于原子去重。
  • SMEMBERS 和集合运算必须考虑输入输出规模。
  • SRANDMEMBER 不删除,SPOP 删除。
  • Set 精确存成员,HyperLogLog 只近似计数。

命令速记

类别 核心命令
写/删 SADD、SREM
判断 SISMEMBER、SMISMEMBER
数量 SCARD、SINTERCARD
获取/遍历 SMEMBERS、SSCAN
随机 SRANDMEMBER、SPOP
运算 SINTER、SUNION、SDIFF 及 STORE

常见误区

  • 依赖 SMEMBERS 的输出顺序。
  • 把重复 SADD 当作计数。
  • 请求路径上做超大集合全量交集。
  • 抽奖后不保留审计证据。

你可以这样阐述

Redis Set 保存无序且唯一的成员,适合去重、标签、黑白名单和集合关系。SADD 可原子判断是否首次加入,SINTER/SUNION/SDIFF 能计算交并差,但输入或结果很大时会占用主线程,应限制规模或离线计算。


第 8 章:Sorted Set——排行榜、权重与时间窗口

8.1 模型

Sorted Set(有序集合,命令前缀 Z)的每个 member 唯一,并关联一个浮点 score:

1
2
3
4
5
ranking:weekly
member score
user:7 98.5
user:3 98.5
user:1 75

成员先按 score 排序;score 相同时按 member 的字典序确定顺序。重新 ZADD 同一 member 会更新 score,而不是产生重复成员。

它与其他结构的区别:

  • Set:唯一,但没有业务顺序。
  • List:有位置顺序,但不能按任意分数高效重排。
  • Sorted Set:member 唯一,顺序由 score 动态决定。

8.2 ZADD 的条件写入

1
2
3
4
ZADD ranking:weekly 75 user:1 98.5 user:7
ZADD ranking:weekly 99 user:1
ZSCORE ranking:weekly user:1
ZMSCORE ranking:weekly user:1 user:2 user:7

常用选项:

1
2
3
4
5
6
ZADD ranking:weekly NX 80 user:2
ZADD ranking:weekly XX 100 user:1
ZADD ranking:weekly GT 101 user:1
ZADD ranking:weekly LT 70 user:1
ZADD ranking:weekly CH 90 user:2
ZADD ranking:weekly INCR 5 user:1
  • NX:只增加新 member。
  • XX:只更新已存在 member。
  • GT:仅在新 score 更大时更新。
  • LT:仅在新 score 更小时更新。
  • CH:返回发生新增或分数变化的成员数;默认主要返回新增数。
  • INCR:把 score 参数作为增量,一次只能处理一对。

NX、XX、GT、LT 有组合限制,落地时查看目标版本命令文档,不要盲目拼选项。

8.3 排名和范围

1
2
ZRANK ranking:weekly user:1
ZREVRANK ranking:weekly user:1
  • ZRANK 从低到高,排名从 0 开始。
  • ZREVRANK 从高到低,排名也从 0 开始。
  • member 不存在时返回 nil。

给用户展示“第几名”通常要在返回值上加 1。

现代 ZRANGE 统一了多类范围查询。

按排名:

1
2
ZRANGE ranking:weekly 0 9 WITHSCORES
ZRANGE ranking:weekly 0 9 REV WITHSCORES

按 score:

1
2
3
ZRANGE ranking:weekly 80 100 BYSCORE WITHSCORES
ZRANGE ranking:weekly (80 +inf BYSCORE LIMIT 0 20
ZRANGE ranking:weekly 100 80 BYSCORE REV LIMIT 0 20
  • 80 包含边界,(80 排除边界。
  • -inf 和 +inf 表示无穷边界。
  • REV 时按高到低提供范围边界。
  • LIMIT 是偏移与数量;大 offset 依然可能有成本。

按字典序:

1
ZRANGE dictionary [a (z BYLEX

BYLEX 通常用于相关成员 score 相同的集合。不应把它当成任意不同 score 下的全文索引。

ZRANGESTORE 可以把范围结果写到另一个 Sorted Set。

8.4 计数、增量、删除与弹出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ZCARD ranking:weekly
ZCOUNT ranking:weekly 80 100
ZLEXCOUNT dictionary [a [z

ZINCRBY ranking:weekly 5 user:1
ZREM ranking:weekly user:1
ZREMRANGEBYRANK ranking:weekly 0 99
ZREMRANGEBYSCORE events -inf 1700000000
ZREMRANGEBYLEX dictionary [a (b

ZPOPMIN jobs:priority 1
ZPOPMAX ranking:weekly 3
BZPOPMIN jobs:priority 5
ZMPOP 2 zset:a zset:b MIN COUNT 2

ZPOPMIN/MAX 会删除并返回元素。阻塞弹出可用于优先任务,但若 score 是未来时间戳,BZPOPMIN 不会自动等到时间戳到期:只要集合非空,它就立即弹最小元素。延迟队列必须额外比较 score 与当前时间。

8.5 多集合聚合

1
2
3
ZUNION 2 rank:likes rank:comments WEIGHTS 1 2 AGGREGATE SUM WITHSCORES
ZINTER 2 rank:a rank:b AGGREGATE MIN WITHSCORES
ZDIFF 2 rank:a rank:b WITHSCORES

WEIGHTS 给各输入分数加权,AGGREGATE 用 SUM、MIN 或 MAX 合并。对应 STORE 命令会将结果写入目标 Key。

大集合聚合可能成为慢命令。不要因为“一条命令”就认为代价固定。

8.6 三个经典模型

排行榜

1
2
3
Key: ranking:2026-w33
member: user_id
score: 积分

ZINCRBY 加分,ZRANGE REV 获取前 N 名,ZREVRANK 查个人排名。

延迟任务索引

1
2
3
Key: delayed:jobs
member: job_id
score: 应执行 Unix 时间

消费者查 score <= now 的任务并原子认领。Sorted Set 没有 ACK 和消费者组,可靠性需另行设计。

滑动窗口限流

1
2
3
Key: rate:user:1
member: 唯一请求 ID
score: 请求毫秒时间

Lua 可原子删除窗口前记录、统计、加入当前请求并设置 TTL。这比固定窗口精确,但每个请求产生一个成员,内存和 CPU 更高。

8.7 score 精度与内部结构

score 是双精度浮点数。整数只在一定范围内精确表示;不要把多个巨大整数随意拼成 score。需要复合排序时可让 score 承担主排序,member 编码次级顺序,或改用数据库索引。

小集合可能用 listpack,一般集合使用哈希表和跳表等组合,以兼顾 member 查 score 和 score 范围。高层复杂度:

  • ZADD、ZREM、排名通常与 log N 相关。
  • 范围查询还要付出返回 M 个元素的成本。
  • 大范围删除和聚合仍可能阻塞主线程。

8.8 Python 排行榜

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
34
35
36
37
38
from dataclasses import dataclass

from redis.asyncio import Redis


@dataclass(frozen=True)
class RankItem:
user_id: int
score: float


async def add_score(
redis: Redis,
user_id: int,
delta: float,
) -> float:
return await redis.zincrby(
"ranking:weekly",
delta,
f"user:{user_id}",
)


async def top_users(redis: Redis, limit: int = 10) -> list[RankItem]:
rows = await redis.zrange(
"ranking:weekly",
0,
limit - 1,
desc=True,
withscores=True,
)
return [
RankItem(
user_id=int(member.split(":")[1]),
score=float(score),
)
for member, score in rows
]

客户端方法签名可能随 redis-py 大版本演进,实际项目以安装版本文档为准。

本章收束

核心结论

  • member 唯一,score 决定顺序。
  • 排名从 0 开始,展示时通常加 1。
  • ZRANGE 可按 rank、score、lex 查询,并支持 REV。
  • 延迟队列和滑动窗口只是建模,可靠消费与成本仍需设计。

命令速记

类别 核心命令
写入 ZADD、ZINCRBY
分数 ZSCORE、ZMSCORE
排名 ZRANK、ZREVRANK
范围 ZRANGE、ZRANGESTORE
数量 ZCARD、ZCOUNT、ZLEXCOUNT
删除 ZREM、ZREMRANGEBY*
弹出 ZPOPMIN/MAX、BZPOPMIN/MAX、ZMPOP
聚合 ZINTER、ZUNION、ZDIFF 及 STORE

常见误区

  • 忘记排名从 0 开始。
  • REV 查询时写反 score 边界。
  • 用 Sorted Set 延迟任务却不设计认领、重试和幂等。
  • 把浮点 score 当无限精度。

你可以这样阐述

Sorted Set 保存唯一 member 与可排序 score,适合排行榜、时间索引和权重集合。ZADD 支持条件更新,ZRANGE 可按排名、分数或字典序查询。典型操作接近 O(log N) 加结果返回成本,但大范围删除和聚合仍会阻塞。


第 9 章:Bitmap 与 Bitfield——紧凑状态存储

9.1 Bitmap 不是新的顶层类型

Bitmap 是对 Redis String 的每一个 bit 进行操作。TYPE 仍返回 string。

如果每个用户只需表示“今天是否签到”,用一个字节甚至一个完整 Key 保存 true/false 浪费很大。Bitmap 一位代表一个状态,理论上八个布尔值只需一个字节。

9.2 位操作

1
2
3
4
SETBIT sign:2026-08-12 1001 1
GETBIT sign:2026-08-12 1001
BITCOUNT sign:2026-08-12
BITPOS sign:2026-08-12 1
  • SETBIT key offset value:将 offset 位设为 0 或 1,返回旧位。
  • GETBIT:读取一位。
  • BITCOUNT:统计 1 的数量,可限定字节或在新版本中指定位范围。
  • BITPOS:寻找第一个 0 或 1 的位置。

offset 从 0 开始。Redis 会扩展 String 以容纳目标 offset。

9.3 稀疏大偏移陷阱

如果误把一个巨大手机号直接作为 offset:

1
SETBIT signed 13800000000 1

Redis 需要扩展到能覆盖该位,可能瞬间分配大量连续空间并阻塞。Bitmap 适合密集、可映射到紧凑整数范围的 ID。可以用数据库内部递增 ID,或先做 ID 映射。

理论字节数约为:

1
floor(max_offset / 8) + 1

9.4 多 Bitmap 运算

1
2
3
4
BITOP AND active:both active:monday active:tuesday
BITOP OR active:either active:monday active:tuesday
BITOP XOR active:one-day active:monday active:tuesday
BITOP NOT inactive:monday active:monday

可计算多日都活跃、任一日活跃等。结果写入目标 String。操作成本与最长输入字符串长度相关,大 Bitmap 运算仍会消耗 CPU 和内存带宽。

9.5 Bitfield:把位段看成整数

BITFIELD 可在一个 String 上读写多种固定宽度整数:

1
2
3
BITFIELD player:1 SET u8 0 120 SET u16 8 3000
BITFIELD player:1 GET u8 0 GET u16 8
BITFIELD player:1 INCRBY u8 0 10
  • u8:8 位无符号整数。
  • i16:16 位有符号整数。
  • offset 可按绝对位或类型宽度相对定位。
  • OVERFLOW WRAP/SAT/FAIL 控制溢出环绕、饱和或失败。
  • BITFIELD_RO 用于只读场景。

Bitfield 很紧凑,但可读性、迁移和 schema 演进成本高。除非确有百万级紧凑状态需求,否则普通 Hash 往往更易维护。

9.6 Python 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from datetime import date

from redis.asyncio import Redis


async def sign_in(
redis: Redis,
user_id: int,
day: date,
) -> bool:
key = f"sign:{day.isoformat()}"
old_bit = await redis.setbit(key, user_id, 1)
await redis.expire(key, 90 * 24 * 3600, nx=True)
return old_bit == 0


async def signed_count(redis: Redis, day: date) -> int:
return await redis.bitcount(f"sign:{day.isoformat()}")

SETBIT 和 EXPIRE 仍是两步;若 TTL 严格必需,用事务或脚本。

本章收束

核心结论

  • Bitmap 和 Bitfield 建立在 String 上。
  • Bitmap 适合密集整数 ID 的大量布尔状态。
  • 稀疏巨大 offset 会导致大内存分配。
  • Bitfield 更紧凑,但维护成本高。

命令速记

1
2
3
SETBIT / GETBIT
BITCOUNT / BITPOS / BITOP
BITFIELD / BITFIELD_RO

常见误区

  • 把手机号直接当 offset。
  • 以为 TYPE 会返回 bitmap。
  • 用 BITCOUNT 的字节范围误当位范围而不看版本语义。
  • 为少量字段牺牲可读性使用 Bitfield。

你可以这样阐述

Bitmap 是 String 的位视图,用一位表示一个布尔状态,适合用户 ID 较密集的签到和活跃统计。它很节省 value 空间,但最大 offset 决定实际长度,稀疏大 ID 会浪费巨大内存。Bitfield 还能把连续位解释为小整数,但 schema 维护更复杂。


第 10 章:HyperLogLog——概率统计

10.1 为什么近似统计有价值

统计每天独立访客 UV,精确方案可用 Set:

1
2
SADD uv:2026-08-12 user:1 user:9
SCARD uv:2026-08-12

它能准确计数并列出成员,但一亿用户会占大量内存。如果只关心“约有多少不同用户”,不需要知道他们是谁,可以用 HyperLogLog。

10.2 核心命令

1
2
3
4
5
6
PFADD uv:2026-08-12 user:1 user:9 user:1
PFCOUNT uv:2026-08-12

PFADD uv:2026-08-13 user:1 user:3
PFCOUNT uv:2026-08-12 uv:2026-08-13
PFMERGE uv:two-days uv:2026-08-12 uv:2026-08-13
  • PFADD 把元素纳入基数估计。
  • PFCOUNT 估计一个或多个结构合并后的不同元素数。
  • PFMERGE 把多个结构合并到目标 Key。

HyperLogLog 不保存可枚举的原始成员,无法问“user:9 是否访问过”。结果有小概率误差,经典相对标准误差大约在 1% 左右,而不是精确值。

10.3 使用边界

适合:

  • 页面 UV;
  • 搜索词独立用户数;
  • 设备/来源去重趋势;
  • 大规模监控指标。

不适合:

  • 库存;
  • 账单;
  • 安全权限;
  • 精确人数结算;
  • 必须列出或删除单个成员;
  • “是否已经处理过”的幂等判断。

10.4 Python 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from datetime import date

from redis.asyncio import Redis


async def record_visit(
redis: Redis,
user_id: int,
day: date,
) -> None:
await redis.pfadd(f"uv:{day.isoformat()}", f"user:{user_id}")


async def estimated_uv(redis: Redis, day: date) -> int:
return await redis.pfcount(f"uv:{day.isoformat()}")

本章收束

核心结论

  • HyperLogLog 用固定的小空间估算去重基数。
  • 它有误差,不能列出或判断单个成员。
  • 精确性是业务需求,不是越省内存越好。

命令速记:PFADD、PFCOUNT、PFMERGE。

常见误区

  • 用它做精确计费。
  • 认为能反查成员。
  • 把估算值的微小波动当成数据损坏。

你可以这样阐述

HyperLogLog 用概率算法以很小空间估算不同元素数量,适合 UV 等趋势统计。代价是小幅误差且不能枚举成员,所以不能替代需要精确成员关系的 Set。


第 11 章:GEO——附近的人不是一张地图

11.1 GEO 的模型

Redis GEO 保存经纬度与 member,并支持距离和附近范围查询。它底层建立在 Sorted Set 上,所以 TYPE 返回 zset,而不是 geo。

Redis 将经纬度编码为可排序的地理位置分值,从而缩小附近候选。它适合“找附近门店”,不是完整 GIS(地理信息系统),不擅长道路导航、行政区多边形、海拔、真实行车距离和复杂空间关系。

11.2 添加、查看和距离

1
2
3
4
GEOADD geo:shops 116.397128 39.916527 shop:1
GEOADD geo:shops 116.4074 39.9042 shop:2
GEOPOS geo:shops shop:1 shop:404
GEODIST geo:shops shop:1 shop:2 km

GEOADD 的坐标顺序是经度 longitude 在前,纬度 latitude 在后,这是最常写反的地方。中国常见坐标还涉及坐标系差异,GPS/WGS84、GCJ-02、BD-09 不能不经转换直接混用。

GEOPOS 返回保存的坐标近似值;GEODIST 可用 m、km、mi、ft。

11.3 附近搜索

1
2
3
4
5
6
7
8
9
10
11
12
13
GEOSEARCH geo:shops
FROMLONLAT 116.397128 39.916527
BYRADIUS 3 km
ASC
COUNT 10
WITHDIST
WITHCOORD

GEOSEARCH geo:shops
FROMMEMBER shop:1
BYBOX 5 3 km
ASC
COUNT 20
  • FROMLONLAT / FROMMEMBER:中心点。
  • BYRADIUS / BYBOX:圆形半径或矩形宽高。
  • ASC / DESC:按距离。
  • COUNT:限制结果;ANY 可允许更快但不保证全局最优数量结果。
  • WITHDIST/WITHCOORD/WITHHASH:附加信息。

GEOSEARCHSTORE 可以把搜索结果保存到目标 Sorted Set,便于后续处理,但要管理目标 Key 的 TTL。

11.4 精度和业务边界

  • Redis 的距离是近似地表距离,不是路线距离。
  • 高纬度、坐标误差和定位漂移影响结果。
  • “附近”候选后还可能按营业状态、品类和权限过滤。
  • 若先取太少候选再过滤,最终可能不足,应扩大候选。
  • 坐标属于敏感数据,应控制权限和保留周期。

11.5 Python 示例

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
34
35
36
37
38
39
40
41
42
43
44
45
from dataclasses import dataclass

from redis.asyncio import Redis


@dataclass(frozen=True)
class NearbyShop:
shop_id: int
distance_km: float


async def add_shop_location(
redis: Redis,
shop_id: int,
longitude: float,
latitude: float,
) -> None:
await redis.geoadd(
"geo:shops",
(longitude, latitude, f"shop:{shop_id}"),
)


async def nearby_shops(
redis: Redis,
longitude: float,
latitude: float,
) -> list[NearbyShop]:
rows = await redis.geosearch(
"geo:shops",
longitude=longitude,
latitude=latitude,
radius=3,
unit="km",
sort="ASC",
count=10,
withdist=True,
)
return [
NearbyShop(
shop_id=int(member.split(":")[1]),
distance_km=float(distance),
)
for member, distance in rows
]

redis-py 对 GEOADD 的参数形式可能随版本变化,实际使用以安装版本签名为准。

本章收束

核心结论

  • GEO 是建立在 Sorted Set 上的地理索引。
  • 坐标顺序是经度在前、纬度在后。
  • GEOSEARCH 适合附近候选,不是路线规划或专业 GIS。
  • 坐标系、精度、过滤和隐私是业务必须补充的部分。

命令速记:GEOADD、GEOPOS、GEODIST、GEOSEARCH、GEOSEARCHSTORE。

常见误区

  • 写反经纬度。
  • 混用不同坐标系。
  • 把直线距离当行车距离。
  • 以为 TYPE 返回 geo。

你可以这样阐述

Redis GEO 把 member 与经纬度放进基于 Sorted Set 的空间索引,能按圆形或矩形范围查附近候选并返回距离。它适合门店附近搜索,不提供道路网络、多边形和复杂 GIS 能力。


第 12 章:Stream——可持久化日志与消费者组

List 让消息排队,却难回答“消息有没有确认、哪个消费者拿过、消费者死后谁接管”。Stream 引入了追加日志和消费跟踪。

12.1 五个核心概念

  1. Stream Key:一条有顺序的日志,如 stream:orders。
  2. Entry:由 ID 和多组 field-value 构成的日志记录。
  3. ID:形如 1720000000000-0,通常含毫秒时间部分与序列部分。
  4. Consumer Group:协作消费者组,每条新消息通常交给组内某一个消费者。
  5. PEL:Pending Entries List,组内已投递但尚未 XACK 的记录。

不同消费组可以各自读到同一条消息;同一消费组的消费者分担消息,而不是每人收到一份。

12.2 生产和裁剪

1
2
3
XADD stream:orders * order_id 1001 user_id 42 amount 1999
XADD stream:events MAXLEN ~ 100000 * type click user_id 42
XTRIM stream:events MAXLEN ~ 100000

星号让 Redis 自动生成 ID。显式 ID 必须递增,业务通常使用自动 ID,并把订单 ID 放入字段。

波浪号表示近似裁剪,通常比每次精确裁剪高效。裁剪是容量策略,不是备份;过度裁剪会让慢消费者需要的消息消失。

12.3 历史与普通读取

1
2
3
4
5
6
XRANGE stream:orders - +
XRANGE stream:orders 1720000000000-0 + COUNT 10
XREVRANGE stream:orders + - COUNT 10

XREAD COUNT 10 STREAMS stream:orders 0-0
XREAD BLOCK 5000 COUNT 10 STREAMS stream:orders $
  • XRANGE 从旧到新,XREVRANGE 从新到旧。
  • XREAD 的 0-0 表示从最早之后读取。
  • 美元符号表示以调用时末尾为起点,只等新消息。
  • BLOCK 是最长阻塞毫秒;0 表示无限阻塞。

XREAD 的读者自行维护位置,Redis 不替它保存 ACK。

12.4 消费者组与新消息

1
2
3
4
5
6
7
8
XGROUP CREATE stream:orders order-workers 0-0 MKSTREAM

XREADGROUP GROUP order-workers worker-1
COUNT 10
BLOCK 5000
STREAMS stream:orders >

XACK stream:orders order-workers 1720000000000-0
  • 建组起点 0-0 表示从历史开始;美元符号表示仅处理创建后的消息。
  • MKSTREAM 在 Stream 不存在时创建空 Stream。
  • 组名已存在会报 BUSYGROUP,初始化程序应识别它。
  • 大于号表示读取尚未投递给该组的新消息。
  • 返回后消息进入 PEL,记录消费者、投递次数与空闲时间。
  • XACK 从 PEL 移除确认状态,但默认不删除 Stream entry。

正确顺序通常是:

1
读消息 → 执行业务并提交数据库 → XACK

先 ACK 再写数据库,中途崩溃会丢业务;先提交再 ACK,中途崩溃会重复消费。因此必须幂等。

12.5 Pending 与故障接管

1
2
3
4
5
6
XPENDING stream:orders order-workers
XPENDING stream:orders order-workers - + 10
XPENDING stream:orders order-workers IDLE 60000 - + 10

XCLAIM stream:orders order-workers worker-2 60000 1720000000000-0
XAUTOCLAIM stream:orders order-workers worker-2 60000 0-0 COUNT 100

XPENDING 可查看待处理数量、消费者分布、空闲时间和投递次数。

XCLAIM 按 ID 接管;XAUTOCLAIM 扫描并批量接管达到最小空闲时长的消息。XAUTOCLAIM 会返回下一扫描起点,调用者需要循环推进。

接管不等于成功处理。应根据投递次数决定继续重试、退避、写死信 Stream 或告警。

12.6 删除、保留和至少一次

1
2
XDEL stream:orders 1720000000000-0
XTRIM stream:orders MAXLEN ~ 100000

不要用删除 entry 代替 XACK。设计保留策略要考虑最慢消费组,并处理 PEL 有 ID 但 payload 已被裁剪的情况。

Stream 常实现至少一次:

  • 不 ACK,消息留在 PEL,可重试。
  • 业务提交后、ACK 前崩溃,同一消息再次处理。

“恰好一次业务效果”通常来自数据库唯一约束、幂等记录、状态机或外部接口幂等键。Redis 无法让 XACK 和另一个数据库事务自动成为全局原子事务。

12.7 与 List、Pub/Sub 对比

能力 List Pub/Sub Stream
离线保留
消费确认 无原生完整机制 PEL + XACK
消费者组 手工 广播订阅 原生
历史读取 XRANGE
认领失败消息 手工 XCLAIM/XAUTOCLAIM
典型用途 简单队列 实时通知 可追踪消息流

12.8 Python 消费骨架

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
34
35
36
37
38
39
from redis.asyncio import Redis
from redis.exceptions import ResponseError


STREAM = "stream:orders"
GROUP = "order-workers"


async def ensure_group(redis: Redis) -> None:
try:
await redis.xgroup_create(
STREAM,
GROUP,
id="0-0",
mkstream=True,
)
except ResponseError as exc:
if "BUSYGROUP" not in str(exc):
raise


async def read_new_messages(
redis: Redis,
consumer_name: str,
) -> list[tuple[str, dict[str, str]]]:
rows = await redis.xreadgroup(
groupname=GROUP,
consumername=consumer_name,
streams={STREAM: ">"},
count=10,
block=5000,
)
if not rows:
return []

messages: list[tuple[str, dict[str, str]]] = []
for _stream_name, entries in rows:
messages.extend(entries)
return messages

完整 ACK、幂等、重试和死信会在第 27 章实现。

本章收束

核心结论

  • Stream 是带 ID 的追加日志,消费者组保存投递状态。
  • 新消息进入 PEL,业务成功后 XACK。
  • XAUTOCLAIM 能接管长期未确认消息。
  • Stream 常实现至少一次,业务恰好一次效果依赖幂等。

命令速记

类别 核心命令
生产 XADD
历史读取 XRANGE、XREVRANGE
普通读取 XREAD
消费者组 XGROUP、XREADGROUP
确认和待处理 XACK、XPENDING
接管 XCLAIM、XAUTOCLAIM
保留 XTRIM、XDEL

常见误区

  • 业务开始前先 ACK。
  • 认为 XACK 会删除 Stream entry。
  • 只消费新消息,从不处理 PEL。
  • 声称 Stream 与数据库间天然恰好一次。

你可以这样阐述

Redis Stream 是带单调 ID 的追加日志。消费者组让组内消费者分担消息,已投递未确认消息进入 PEL,成功后 XACK,失联消费者消息可由 XAUTOCLAIM 接管。因此它比 List 更适合可追踪队列,但通常仍是至少一次,业务必须幂等。


第 13 章:数据结构选型与大 Key、热 Key

13.1 从查询反推结构

需求 首选 原因
按 Key 整体读对象 String(JSON) GET/SET 简单,可原子附 TTL
对象字段局部更新 Hash HGET/HSET/HINCRBY
双端有序序列 List 两端操作、阻塞弹出
精确唯一成员 Set 判断成员、交并差
唯一成员按权重排序 Sorted Set score、排名、范围
大量布尔状态 Bitmap 位级紧凑
少量紧凑整数槽 Bitfield 位段整数
只估算去重数量 HyperLogLog 小空间、允许误差
附近候选 GEO 地理范围查询
带确认和消费组的日志 Stream PEL、ACK、claim

同一需求可能有多种建模。选择由读写模式、TTL 粒度、精确性、数据规模、Cluster 槽位和维护成本共同决定。

13.2 大 Key

大 Key 不是 Key 名很长,而是 value 或集合很大,例如:

  • 几十 MB 的 String;
  • 数百万 field 的 Hash;
  • 数百万 member 的 Set/ZSet;
  • 长期不裁剪的 List/Stream。

危害:

  • 读取占大量带宽和客户端内存;
  • 删除和过期回收产生延迟;
  • 复制、AOF/RDB、迁槽成本高;
  • Cluster 槽位负载倾斜;
  • 全量命令堵塞主线程。

观察:

1
2
3
4
5
6
7
8
MEMORY USAGE some:key
OBJECT ENCODING some:key
STRLEN string:key
HLEN hash:key
LLEN list:key
SCARD set:key
ZCARD zset:key
XLEN stream:key

命令行扫描:

1
2
redis-cli --bigkeys
redis-cli --memkeys

这些操作也会增加实例负载,应在理解环境后使用。

13.3 热 Key

热 Key 是访问频率或带宽远高于其他 Key 的键,不一定很大。一个 1 KB 全国热点缓存每秒读取几十万次,也会让某节点或网卡成为瓶颈。

大 Key 与热 Key 叠加尤其危险:高频传输大 value。

诊断方向:

  • 应用指标按 Key 模式聚合;
  • 在适当 LFU 配置下使用 redis-cli --hotkeys;
  • 节点 CPU、网络和命令统计;
  • Cluster 各节点流量是否倾斜。

解决方向:

  • 应用进程 L1 缓存;
  • 只缓存必要字段,压缩或拆 value;
  • 请求合并、限流、降级;
  • 写热点按桶拆分后聚合;
  • 复制热点到多个 Key 并分流,但要承担一致性复杂度;
  • 从副本读时接受复制延迟。

13.4 拆分不是万能答案

将一个大 Hash 拆成一百万小 Key,会获得独立 TTL 和分片能力,但 Key 元数据开销增大,跨槽批量和失效更复杂。

把一百万对象放进一个 Hash,可省部分 Key 开销,但产生大 Key、统一 TTL、热点和迁槽问题。

应根据这些问题选择:

  • 数据总是一起读写吗?
  • 生命周期相同吗?
  • 需要独立失效吗?
  • 单次返回多大?
  • Cluster 是否需要同槽原子操作?

13.5 安全处理和错误选型

  • 大 Key 优先评估 UNLINK。
  • 大集合用 SCAN 族分批处理,每批有界且幂等。
  • List/Stream 使用明确容量和保留策略。
  • 不要在高峰突然全量序列化或迁移巨型 Key。

常见错误:

  • List 保存已点赞用户:判断需扫描、重复难控制,应使用 Set。
  • Set 做排行榜:没有 score,应使用 Sorted Set。
  • HyperLogLog 防重复下单:不精确也不能查成员,应使用 Set 和数据库唯一约束。
  • Pub/Sub 发送必须送达订单:离线丢失,应使用 Stream 或专业消息系统。
  • Hash 保存每字段独立 TTL 临时令牌:通用语义是整个 Key 一个 TTL,应拆 Key或锁定支持字段 TTL 的版本。

本章收束

核心结论

  • 数据结构由查询方式和约束决定。
  • 大 Key 是单对象规模问题,热 Key 是访问倾斜问题。
  • 拆 Key 与聚合 Key 都有元数据、TTL、槽位和访问代价。
  • 选型要同时看语义、复杂度、返回字节和故障行为。

选型速记

1
2
3
4
5
6
7
8
9
整体值 String
字段 Hash
双端顺序 List
唯一关系 Set
分数排序 ZSet
布尔位 Bitmap
近似基数 HLL
附近 GEO
可靠消费跟踪 Stream

常见误区

  • 只比较命令复杂度,不看返回字节。
  • 对大集合执行全量命令。
  • 认为分片总能解决热 Key。
  • 用近似结构做精确判断。

你可以这样阐述

Redis 数据结构选型要从访问模式反推:是否整体读取、字段更新、唯一、顺序、分数或允许近似。还要控制大 Key 和热 Key,因为 Redis 的主线程、网络、复制与持久化都会受无界数据影响。


第三篇:原子性、批量执行与服务器端逻辑

前两篇主要使用单条命令。真实业务经常需要“先判断再修改”“一次发很多命令”“把多步操作变成不可分割整体”。这分别引出事务、Pipeline 与 Lua,它们不是同一种工具。

第 14 章:命令原子性、并发竞态与事务

14.1 单命令原子不等于业务原子

Redis 普通命令在服务端串行执行。单条 INCR 不会被另一条命令从中间插入,所以它是原子的。

但两条命令之间可以插入别的客户端:

1
2
3
4
客户端 A: GET stock → 1
客户端 B: GET stock → 1
客户端 A: SET stock 0
客户端 B: SET stock 0

两个请求都认为购买成功,只扣了一份数值。把代码写进一个 Python 函数不改变网络命令的并发边界。

优先原则:

  1. 能用单条原子命令就不要 GET 后 SET。
  2. 简单乐观并发可用 WATCH。
  3. 判断和修改复杂时用 Lua。
  4. 跨 Redis 与数据库时依赖数据库约束、幂等和补偿,而不是幻想单个 Redis 事务覆盖所有系统。

14.2 MULTI 和 EXEC

1
2
3
4
MULTI
SET account:1:name "Alice"
INCR account:1:login_count
EXEC

MULTI 之后,普通命令先进入队列,通常返回 QUEUED;EXEC 触发执行并按顺序返回每条结果。在同一事务执行过程中,其他客户端普通命令不会插入这些队列命令之间。

放弃队列:

1
2
3
MULTI
SET temp:value "x"
DISCARD

断开连接且尚未 EXEC,队列命令不会执行。

14.3 Redis 事务为什么不同于数据库事务

Redis 事务没有传统关系型数据库那种自动回滚语义。

两类错误:

  1. 入队阶段就能发现的错误:命令语法、参数数量错误。事务可能被标记,EXEC 拒绝整体执行。
  2. 执行阶段才发现的错误:例如对 String 执行 LPUSH。该命令返回错误,但事务中其他合法命令仍可能执行。
1
2
3
4
5
6
SET demo:string "hello"
MULTI
INCR good:counter
LPUSH demo:string "x"
SET another:key "done"
EXEC

中间命令类型错误不表示前后命令自动回滚。Redis 事务的重点是排队后连续执行,不是数据库式回滚。

14.4 WATCH 乐观锁

WATCH 监视 Key。如果从 WATCH 到 EXEC 之间被其他客户端改变,EXEC 会放弃,客户端重试整个读—判断—写过程。

1
2
3
4
5
WATCH balance:user:1
GET balance:user:1
MULTI
SET balance:user:1 new_value
EXEC

Python 伪实现:

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
from redis.asyncio import Redis
from redis.exceptions import WatchError


async def deduct_if_enough(
redis: Redis,
key: str,
amount: int,
) -> bool:
while True:
try:
async with redis.pipeline() as pipe:
await pipe.watch(key)
current_raw = await pipe.get(key)
current = int(current_raw or 0)
if current < amount:
await pipe.unwatch()
return False

pipe.multi()
pipe.set(key, current - amount)
await pipe.execute()
return True
except WatchError:
continue

高冲突时不断重试会浪费网络和 CPU,Lua 通常更合适。重试必须有次数、超时或退避,不能无限循环。

WATCH 是连接状态;事务期间必须使用同一连接。客户端 Pipeline 上下文帮助保证这一点。

14.5 事务能解决什么、不能解决什么

能解决:

  • 多条 Redis 命令连续执行,期间不插入其他普通命令;
  • 配合 WATCH 做乐观 compare-and-set;
  • 把一组无需读取中间结果的命令作为原子批次。

不能解决:

  • 自动回滚运行时错误;
  • 在 MULTI 内先 GET 结果再由客户端据此决定后续命令;
  • 跨数据库、短信服务、HTTP 服务的全局事务;
  • 自动保证数据建模正确;
  • 避免长事务阻塞其他请求。

本章收束

核心结论

  • Python 函数边界不是 Redis 原子边界。
  • MULTI/EXEC 让排队命令连续执行,但不提供数据库式回滚。
  • WATCH 是乐观锁,冲突时客户端重试。
  • 复杂判断修改通常由 Lua 更直接完成。

命令速记:MULTI、EXEC、DISCARD、WATCH、UNWATCH。

常见误区

  • 认为 MULTI 中命令立刻执行。
  • 认为任一命令失败会整体回滚。
  • WATCH 后换连接执行事务。
  • 高竞争下无限重试。

你可以这样阐述

Redis 单命令具有原子性,但多个命令之间可能发生竞态。MULTI/EXEC 会先排队再连续执行,却不会像数据库那样回滚运行时错误。WATCH 可监视 Key 实现乐观并发,冲突时 EXEC 放弃,由客户端重新计算。


第 15 章:Pipeline、批量命令和网络往返

15.1 为什么命令很快,程序仍可能慢

假设客户端与 Redis 往返延迟 RTT 为 1 ms,连续发送 1000 个简单命令,每个都等回复后再发下一个,仅等待往返就约需 1 秒。Redis 执行每条命令也许只需很短时间。

Pipeline 让客户端先连续发送多条命令,再批量读取回复:

1
2
普通:发1→等1→发2→等2→发3→等3
管道:发1、发2、发3→一起读1、2、3

它主要优化网络往返,不自动改变命令语义。

15.2 redis-py Pipeline

1
2
3
4
5
6
7
8
9
from redis.asyncio import Redis


async def fetch_shops(redis: Redis, ids: list[int]) -> list[str | None]:
async with redis.pipeline(transaction=False) as pipe:
for shop_id in ids:
pipe.get(f"cache:shop:{shop_id}")
values = await pipe.execute()
return values

调用 pipe.get 时命令被缓存在客户端,直到 execute 才发送。返回列表与命令顺序一致。

redis-py 的 pipeline 默认值可能启用 transaction=True,即用 MULTI/EXEC 包裹。只为降低 RTT 且不需要事务时,应显式 transaction=False;需要原子批次时显式 True。

15.3 Pipeline 与事务的区别

维度 Pipeline MULTI/EXEC
主要目的 减少 RTT 命令连续原子执行
是否允许其他命令穿插 transaction=False 时允许 EXEC 执行批次时不允许
命令何时发送 execute 时批量发送 可由客户端批量发送协议
是否回滚 也不做数据库式回滚

Pipeline 是传输优化,事务是执行隔离语义。客户端可以把两者组合。

15.4 MGET、Pipeline、Lua 怎么选

  • MGET/MSET:服务端已有专门批量命令,语义最直接。
  • Pipeline:多条独立命令,主要减少 RTT。
  • 事务 Pipeline:多条命令必须连续执行,但无需根据中间结果分支。
  • Lua:需要读取中间状态后判断并修改,且整体原子。

例如批量读取 100 个 String,优先 MGET;同时读取 Hash、TTL、ZSet 分数,可用 Pipeline;检查库存与购买资格再扣减,用 Lua。

15.5 批量不是越大越好

客户端在发送时保存命令,Redis 和网络缓冲响应,客户端又一次接收大量结果。一次 Pipeline 几十万命令会造成:

  • 内存峰值;
  • 单连接长时间占用;
  • 其他请求延迟;
  • 超时和重试放大;
  • 巨大响应解析开销。

使用有界批次,例如每批 100–1000 条,具体需压测。批次越大,RTT 越少,但公平性和内存越差。

15.6 Cluster 限制

Cluster 把 Key 分布到不同节点。一个普通连接只能向一个节点发送 Pipeline;Cluster 客户端可能按节点拆批,但不能把跨槽事务或 Lua 自动变成全局原子。

相关 Key 需要用 hash tag 同槽:

1
2
seckill:{1001}:stock
seckill:{1001}:ordered

同槽便于 Lua 和事务,但也把流量集中到一个槽。原子性与分布能力存在取舍。

本章收束

核心结论

  • Pipeline 优化 RTT,不自动提供原子性。
  • redis-py 要显式理解 transaction 参数。
  • 优先使用合适的服务端批量命令。
  • 批次要有上限;Cluster 跨节点不能获得全局原子。

常见误区

  • 把 Pipeline 当事务。
  • Pipeline 入队后忘记 execute。
  • 一次塞入无限命令。
  • 认为 Cluster 客户端能让跨槽 Lua 原子。

你可以这样阐述

Pipeline 通过一次发送多条命令、再批量读取响应减少网络往返。它本身是传输优化,不等于事务。redis-py 的 Pipeline 可选择是否用 MULTI/EXEC 包裹;批量必须控制大小,并考虑 Cluster 按节点和同槽限制。


第 16 章:Lua 脚本与 Redis Functions

16.1 为什么需要服务端脚本

业务要求:

1
2
3
如果库存 > 0 且用户未购买:
库存减 1
记录用户已购买

客户端 GET、SISMEMBER、DECR、SADD 之间可被其他请求插入。Lua 将读取、判断与修改一次送到 Redis,在脚本执行期间不被其他普通命令穿插。

16.2 EVAL、KEYS 与 ARGV

1
EVAL "return redis.call('GET', KEYS[1])" 1 demo:key

格式:

1
EVAL script number_of_keys key1 key2 ... arg1 arg2 ...
  • KEYS 数组保存脚本访问的 Redis Key。
  • ARGV 保存普通参数。
  • Lua 数组从 1 开始。
  • Key 必须作为 KEYS 显式传入,特别是 Cluster 需要知道访问哪些槽。

条件扣库存:

1
2
3
4
5
6
7
local stock = tonumber(redis.call("GET", KEYS[1]) or "0")
if stock <= 0 then
return 0
end

redis.call("DECR", KEYS[1])
return 1

执行:

1
EVAL "<script>" 1 seckill:stock:1001

16.3 错误与返回

  • redis.call 遇到错误会终止脚本并返回错误。
  • redis.pcall 捕获 Redis 命令错误,返回错误对象供脚本处理。
  • 脚本已执行的写操作不会像数据库事务那样自动回滚。
  • Lua 与 Redis 协议的布尔、nil、数组和数字映射有版本细节,客户端代码要明确约定返回码。

因此脚本应在写入前完成所有可预见校验,并保持短小。

16.4 EVALSHA 与脚本缓存

每次发送完整脚本浪费带宽:

1
2
3
SCRIPT LOAD "<script>"
EVALSHA <sha1> 1 some:key
SCRIPT EXISTS <sha1>

服务器重启、故障转移或 SCRIPT FLUSH 后可能返回 NOSCRIPT。客户端可先 EVALSHA,NOSCRIPT 时重新加载;redis-py 的 register_script 能帮助封装,但 Cluster 各节点缓存仍需理解。

16.5 脚本会阻塞

Lua 执行期间,其他普通命令不能穿插。这提供原子性,也意味着长脚本会阻塞 Redis:

  • 不在脚本中遍历无界大集合;
  • 不做复杂长循环;
  • 不访问未通过 KEYS 声明的动态 Key;
  • 不把脚本当通用应用服务器;
  • 脚本上线前评估最坏数据规模。

Lua 不能发任意 HTTP 请求或直接提交关系型数据库事务。

16.6 安全释放锁的脚本

1
2
3
4
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0

它原子完成“value 是否仍是我的 token”与删除。若先 GET 再 DEL,检查后可能过期并被新持有者获取,旧客户端随后误删新锁。

16.7 redis-py 调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from redis.asyncio import Redis


RELEASE_LOCK_SCRIPT = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
"""


async def release_lock(
redis: Redis,
key: str,
token: str,
) -> bool:
deleted = await redis.eval(
RELEASE_LOCK_SCRIPT,
1,
key,
token,
)
return deleted == 1

16.8 Redis Functions

Redis Functions 允许把函数库持久注册在服务器端,然后通过 FCALL 调用。与临时脚本相比:

  • 函数库作为数据库状态的一部分管理;
  • 可统一部署、命名和复用;
  • 仍受原子执行与阻塞限制;
  • 客户端和运维需管理函数版本、权限和发布。

常见命令:

1
2
3
4
5
FUNCTION LOAD
FUNCTION LIST
FUNCTION DELETE
FCALL
FCALL_RO

学习和少量原子逻辑可先用 Lua 脚本。需要在组织内管理稳定服务端逻辑时再评估 Functions。

本章收束

核心结论

  • Lua 将读取、判断和修改放进服务器端原子执行。
  • Key 应通过 KEYS 传入,普通参数通过 ARGV。
  • 脚本不自动回滚已发生写入,且长脚本会阻塞。
  • EVALSHA 减少脚本文本传输,但需处理 NOSCRIPT。
  • Functions 适合管理长期服务器端函数库。

命令速记:EVAL、EVALSHA、SCRIPT LOAD/EXISTS/FLUSH、FUNCTION、FCALL。

常见误区

  • 在脚本中扫描无界集合。
  • 隐藏动态 Key,不通过 KEYS 声明。
  • 认为脚本报错会回滚所有写入。
  • 认为 Lua 能原子提交数据库。

你可以这样阐述

Lua 把多步 Redis 读取、判断和写入放到服务器端,在执行期间不被其他普通命令插入,因此适合条件扣减和安全解锁。脚本必须短小、有界,并显式声明 Key;它不能跨数据库提供全局事务,也不会自动回滚已执行写入。


第 17 章:发布订阅与通知机制

17.1 Pub/Sub 模型

发布者向 channel 发送消息,当前订阅者收到:

1
2
3
SUBSCRIBE cache:invalidate
PUBLISH cache:invalidate "shop:42"
PSUBSCRIBE cache:*
  • SUBSCRIBE 订阅具体频道。
  • PSUBSCRIBE 按模式订阅。
  • PUBLISH 返回当时接收到消息的订阅客户端数量,但这不是业务成功数。

17.2 它是实时广播,不是可靠队列

Pub/Sub 的核心语义:

  • 订阅者在线时接收;
  • 订阅者离线时消息不会为它保留;
  • 没有 ACK、重试、PEL 和历史回放;
  • 慢订阅者可能积压客户端输出缓冲并被断开;
  • 多个订阅者通常各收到一份,适合广播。

因此适合:

  • 在线状态通知;
  • 可丢的实时事件;
  • L1 缓存失效提示,同时辅以 TTL/版本校验;
  • 开发调试通知。

不适合必须送达的订单、支付和审计消息。

17.3 redis-py 异步订阅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import asyncio

from redis.asyncio import Redis


async def listen_invalidations(redis: Redis) -> None:
async with redis.pubsub() as pubsub:
await pubsub.subscribe("cache:invalidate")
while True:
message = await pubsub.get_message(
ignore_subscribe_messages=True,
timeout=1.0,
)
if message is None:
await asyncio.sleep(0)
continue

key = message["data"]
print("invalidate local cache:", key)

常驻监听器应处理取消、重连、重复订阅、解析失败和健康状态。不能把它直接放在每个 HTTP 请求里创建。

17.4 Keyspace Notifications

Redis 可配置键空间通知,在过期、删除、写入等事件发生时发布通知。它默认可能未启用,因为会有额外开销。

重要限制:

  • 仍基于 Pub/Sub,不可靠、不可回放;
  • 订阅者离线丢事件;
  • 过期事件的发送时间依赖实际删除,不保证恰好在 TTL 归零毫秒触发;
  • Cluster 各节点通知需分别处理;
  • 配置的事件类别决定能收到什么。

因此不能仅靠过期通知实现必须执行的订单关闭。可靠定时任务应有持久状态、扫描补偿和幂等。

17.5 三种消息能力选型

需求 选择
最简单 FIFO,能接受自行补可靠性 List
当前在线订阅者实时广播,可丢 Pub/Sub
保存历史、消费者组、ACK、重试 Stream
超大吞吐、长保留、成熟消息生态 Kafka/RabbitMQ 等专业系统

本章收束

核心结论

  • Pub/Sub 是在线广播,不保存离线消息。
  • PUBLISH 返回订阅客户端数,不表示业务处理成功。
  • Keyspace Notifications 也不是可靠事件源。
  • 必须送达的业务使用 Stream 或专业消息系统。

命令速记:PUBLISH、SUBSCRIBE、UNSUBSCRIBE、PSUBSCRIBE、PUNSUBSCRIBE。

常见误区

  • 用 Pub/Sub 发送支付消息。
  • 认为订阅者离线后能补收。
  • 把收到频道消息等同于业务提交。
  • 把键过期通知当精确调度器。

你可以这样阐述

Redis Pub/Sub 是面向当前在线订阅者的实时广播,没有消息存储、确认和重放。它适合可丢通知和缓存失效提示,不适合必须送达业务。Keyspace Notifications 也继承同样的不可靠属性。


第四篇:Python 与 FastAPI 工程化接入

你已经知道 Redis 命令本身的语义。客户端工程化的目标不是把 redis.get 包一层,而是管理连接生命周期、超时、序列化、失败策略和业务边界。

第 18 章:redis-py 异步客户端

18.1 依赖和最小连接

只需安装:

1
pip install "redis>=5" fastapi uvicorn pydantic-settings

本文使用 redis-py 提供的 redis.asyncio。旧教程中的独立 aioredis 包不是现代推荐主线。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import asyncio

from redis.asyncio import Redis


async def main() -> None:
redis = Redis.from_url(
"redis://localhost:6379/0",
decode_responses=True,
)
try:
await redis.set("demo:greeting", "hello", ex=60)
value = await redis.get("demo:greeting")
print(value)
finally:
await redis.aclose()


asyncio.run(main())

异步命令需要 await。异步客户端应显式关闭,不能依赖异步析构自动断开。

URL 常见形式:

1
2
3
redis://[[username]:password@]host:port/db
rediss://... 使用 TLS
unix://... Unix Domain Socket

不要在源码硬编码真实密码。

18.2 decode_responses

默认文本响应经常是 bytes,例如 b"hello"。设置 decode_responses=True 后按 encoding(通常 UTF-8)解码为 str。

  • 只存文本/JSON:True 更方便。
  • 存任意二进制:False,自己处理 bytes。
  • 同一封装不要混合假设,避免对 str 再调用 decode。

Redis String 始终是字节序列;decode_responses 只是客户端便利功能。

18.3 客户端与连接池

Redis 对象通常持有连接池,不是每条命令新建 TCP 连接。命令从池借连接、执行后归还。

1
2
3
4
5
6
7
8
9
from redis.asyncio import ConnectionPool, Redis


pool = ConnectionPool.from_url(
"redis://localhost:6379/0",
decode_responses=True,
max_connections=100,
)
redis = Redis(connection_pool=pool)

多个客户端共享显式池时,要关闭客户端并最终关闭池;客户端拥有内部池时,aclose 通常一并关闭。准确所有权以所用 redis-py 版本为准。

连接池不是越大越好:

  • 太小:请求等待连接或报连接不足。
  • 太大:Redis 客户端数、文件描述符和并发突发增加。
  • 应根据应用实例、worker、阻塞消费者和峰值并发一起估算。

18.4 超时和健康检查

1
2
3
4
5
6
7
8
redis = Redis.from_url(
"redis://localhost:6379/0",
decode_responses=True,
socket_connect_timeout=1.0,
socket_timeout=1.0,
health_check_interval=30,
max_connections=100,
)
  • socket_connect_timeout:建立连接最长等待。
  • socket_timeout:命令响应最长等待。
  • health_check_interval:空闲连接复用前按周期检查。

阻塞命令的 socket_timeout 必须大于 BLOCK 超时,或使用专用客户端,否则客户端可能先超时。

18.5 重试要看幂等性

  • GET 重试通常安全。
  • SET 同值常可接受,但响应丢失时不知道第一次是否成功。
  • INCR 重试可能重复加一。
  • XADD 重试可能产生重复消息。
  • 锁重试需要等待与 token 设计。

对非幂等写,应使用请求 ID、Lua 去重、消息 ID 或数据库唯一约束。

18.6 Pipeline、Pub/Sub 和脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async with redis.pipeline(transaction=False) as pipe:
pipe.get("a")
pipe.hget("user:1", "name")
pipe.zscore("rank", "user:1")
a, name, score = await pipe.execute()

async with redis.pubsub() as pubsub:
await pubsub.subscribe("events")
message = await pubsub.get_message(timeout=1)

result = await redis.eval(
'return redis.call("GET", KEYS[1])',
1,
"a",
)

Pub/Sub 与阻塞消费是长生命周期用途,应与普通 HTTP 请求连接使用模式分离。

18.7 异常和降级

常见异常:ConnectionError、TimeoutError、ResponseError、WatchError。不要捕获所有 Exception 后返回空值,否则代码 Bug、序列化错误和 Redis 故障都会被伪装成缓存未命中。

1
2
3
4
5
6
7
8
9
10
11
12
from redis.exceptions import ConnectionError, TimeoutError


async def optional_cache_get(
redis: Redis,
key: str,
) -> str | None:
try:
return await redis.get(key)
except (ConnectionError, TimeoutError):
# 真实项目应记录结构化日志和指标
return None

这种 fail-open 只适合可回源缓存。验证码校验不能这样做,否则会绕过认证。

本章收束

核心结论

  • 使用 redis.asyncio,命令 await,应用结束显式 aclose。
  • 客户端复用连接池,不应每个请求创建新客户端。
  • decode_responses 决定客户端 bytes/str 形态。
  • 超时、连接池和重试必须匹配业务语义。
  • 缓存可降级与认证 fail-closed 是不同策略。

代码速记

1
2
3
4
5
6
7
redis = Redis.from_url(
settings.redis_url,
decode_responses=True,
socket_connect_timeout=1,
socket_timeout=1,
max_connections=100,
)

常见误区

  • 在 async def 中使用同步客户端。
  • 每个请求新建并关闭客户端。
  • 无脑重试 INCR/XADD。
  • 捕获所有异常并假装缓存未命中。

你可以这样阐述

redis-py 的异步客户端位于 redis.asyncio,背后复用连接池,应用结束时显式关闭。工程上需要统一 bytes/str、连接和命令超时、池容量与重试语义;不同业务对 Redis 故障应选择 fail-open 或 fail-closed。


第 19 章:FastAPI 生命周期与依赖组织

19.1 正确生命周期

不要在每个请求中反复创建客户端和池。正确模型:

1
2
3
应用启动 → 创建 Redis 客户端/池
所有请求 → 复用
应用关闭 → 显式关闭

配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# app/core/config.py
from functools import lru_cache

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
redis_url: str = "redis://localhost:6379/0"
redis_max_connections: int = 100
cache_default_ttl_seconds: int = 300

model_config = SettingsConfigDict(
env_file=".env",
env_prefix="APP_",
extra="ignore",
)


@lru_cache
def get_settings() -> Settings:
return Settings()

生产密码由环境变量或密钥系统提供,.env 不保存真实凭据。

lifespan:

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
# app/main.py
from contextlib import asynccontextmanager
from typing import AsyncIterator

from fastapi import FastAPI
from redis.asyncio import Redis

from app.core.config import get_settings


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
redis = Redis.from_url(
settings.redis_url,
decode_responses=True,
max_connections=settings.redis_max_connections,
socket_connect_timeout=1.0,
socket_timeout=1.0,
health_check_interval=30,
)
app.state.redis = redis
try:
yield
finally:
await redis.aclose()


app = FastAPI(lifespan=lifespan)

是否在启动时 PING 是架构选择:若 Redis 是认证等关键依赖,可让 readiness 失败;若只是可选缓存,可启动并降级回源。

19.2 提供依赖

1
2
3
4
5
6
7
8
9
10
11
12
# app/core/redis.py
from typing import Annotated

from fastapi import Depends, Request
from redis.asyncio import Redis


def get_redis(request: Request) -> Redis:
return request.app.state.redis


RedisDep = Annotated[Redis, Depends(get_redis)]

路由:

1
2
3
4
5
6
7
8
9
10
from fastapi import APIRouter

from app.core.redis import RedisDep

router = APIRouter()


@router.get("/redis-health")
async def redis_health(redis: RedisDep) -> dict[str, bool]:
return {"ping": bool(await redis.ping())}

19.3 业务服务边界

1
2
3
4
5
6
7
8
9
10
11
12
13
from redis.asyncio import Redis


class SessionService:
def __init__(self, redis: Redis) -> None:
self.redis = redis

async def get_user_id(self, token: str) -> int | None:
raw = await self.redis.hget(
f"login:session:{token}",
"user_id",
)
return int(raw) if raw is not None else None

路由依赖 SessionService,而不是散落 HGET,有利于测试和统一会话语义。但若每层只是机械转发 Redis 方法,就是过度封装。

19.4 fail-open 与 fail-closed

场景 Redis 故障策略
普通查询缓存 有容量保护地回源,fail-open
验证码校验 拒绝登录,fail-closed
登录限流 通常保守拒绝或降级
分布式锁 未取得锁就不进入临界区
非关键推荐 返回默认值或空结果
秒杀资格 不可绕过校验

缓存故障直接回源也可能把数据库压垮,需要限流、熔断与降级。

19.5 健康检查不只是 PING

PING 只证明基础响应。Readiness 还可考虑:

  • 能否及时获得连接;
  • 命令延迟是否正常;
  • 是否连接到正确角色;
  • Cluster 槽是否可用;
  • 关键脚本或消费组是否初始化;
  • 数据库回源能力。

健康端点本身不能做昂贵检查。

本章收束

核心结论

  • Redis 客户端由 FastAPI lifespan 创建和关闭。
  • 请求共享客户端与连接池。
  • Service 表达业务语义,路由不散落底层命令。
  • Redis 故障策略必须按场景区分。
  • PING 不是完整健康证明。

代码速记

1
lifespan 创建 → app.state 保存 → Depends 获取 → shutdown aclose

常见误区

  • 请求内创建客户端。
  • 缓存失败时无限回源。
  • 验证码 Redis 故障时放行。
  • PING 成功就认为业务健康。

你可以这样阐述

FastAPI 应在 lifespan 中创建 Redis 客户端并在关闭时 aclose,通过依赖把共享客户端或业务 Service 提供给路由。缓存故障可有保护地回源,而认证、锁与秒杀等安全敏感场景应 fail-closed。


第 20 章:序列化、Key 设计和通用封装

20.1 Redis 不知道 Python 对象

Redis 接收字节。Pydantic 模型必须转换为 JSON、Hash 字段或其他二进制格式。

JSON 优点:可读、跨语言、适合整体缓存。缺点:类型有损,Decimal、datetime、Enum 需规则,单字段更新要重写整体。

Hash 优点:字段级操作。缺点:嵌套结构、None、类型恢复和 schema 迁移需自定义。

Python pickle 不适合缓存不可信数据:反序列化恶意 pickle 可执行代码,且跨语言和版本兼容差。

20.2 Pydantic JSON

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from datetime import datetime
from decimal import Decimal

from pydantic import BaseModel


class ShopDTO(BaseModel):
id: int
name: str
rating: Decimal
updated_at: datetime


def dump_shop(shop: ShopDTO) -> str:
return shop.model_dump_json()


def load_shop(raw: str) -> ShopDTO:
return ShopDTO.model_validate_json(raw)

让 Pydantic 负责时间与 Decimal,比手写 json.dumps(model.dict) 更稳定。schema 演进时应考虑旧缓存缺字段或字段改名,可通过缓存版本前缀整体切换。

20.3 KeyBuilder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Keys:
@staticmethod
def login_code(phone: str) -> str:
return f"login:code:{{{phone}}}:value"

@staticmethod
def login_session(token: str) -> str:
return f"login:session:{token}"

@staticmethod
def shop(shop_id: int) -> str:
return f"cache:v1:shop:{shop_id}"

@staticmethod
def shop_lock(shop_id: int) -> str:
return f"cache:shop:lock:{shop_id}"

集中构造避免键名拼写漂移。Key 中不要包含可注入无界分隔结构的原始用户输入,应先验证和规范化手机号、ID、租户等。

20.4 一个有边界的 CacheService

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
34
35
36
37
38
39
40
import random
from collections.abc import Callable
from typing import TypeVar

from pydantic import BaseModel
from redis.asyncio import Redis

ModelT = TypeVar("ModelT", bound=BaseModel)


class CacheService:
def __init__(self, redis: Redis) -> None:
self.redis = redis

async def get_model(
self,
key: str,
model_type: type[ModelT],
) -> ModelT | None:
raw = await self.redis.get(key)
if raw is None:
return None
return model_type.model_validate_json(raw)

async def set_model(
self,
key: str,
model: BaseModel,
ttl_seconds: int,
jitter_seconds: int = 0,
) -> None:
jitter = random.randint(0, jitter_seconds) if jitter_seconds else 0
await self.redis.set(
key,
model.model_dump_json(),
ex=ttl_seconds + jitter,
)

async def delete(self, key: str) -> None:
await self.redis.unlink(key)

TTL 抖动让大量同批缓存不会同一秒过期。jitter 应受控,不能让安全 Token 随机延长超出策略;它适合普通缓存,不是所有临时数据。

20.5 不要写“万能 Redis 工具类”

坏封装常有一个 execute(command, *args),把类型、错误和业务语义全部抹掉。更好的边界是:

  • SessionService 表达会话;
  • ShopCache 表达商户缓存;
  • DistributedLock 表达锁;
  • OrderStream 表达订单消息。

底层可共享连接与少量序列化工具,业务规则留在对应服务。

20.6 缓存可观测性

封装应能记录:

  • hit / miss;
  • 读取与写入延迟;
  • 序列化失败;
  • Redis 错误和回源;
  • payload 大小;
  • 业务 Key 模式,而不是完整敏感 Key。

不要把手机号、Token、验证码写进日志。

本章收束

核心结论

  • Redis 存字节,Python 对象必须明确序列化。
  • JSON 适合整体缓存,Hash 适合字段操作。
  • Key 构造集中管理,缓存 schema 用版本前缀演进。
  • TTL 抖动只用于适合随机过期的缓存。
  • 封装要表达业务,不要隐藏所有底层语义。

速记

1
2
3
模型 → model_dump_json → SET EX
GET → model_validate_json → 模型
cache:v1:... 用于 schema 版本

常见误区

  • 缓存不可信 pickle。
  • Key 中拼接未验证任意用户输入。
  • 所有 TTL 都加随机延长。
  • 万能工具类吞掉异常和类型。

你可以这样阐述

Redis 只保存字节,FastAPI 项目要统一 JSON 或 Hash 序列化、Key 命名和 schema 版本。通用封装负责稳定的序列化与指标,业务服务负责会话、缓存或队列规则;不能用万能 execute 隐藏原子性和错误语义。


第五篇:从实际模块理解 Redis 应用模式

基础能力已经齐全。下面不再孤立讲命令,而是把它们放入完整的数据流、并发窗口和失败处理。

第 21 章:短信验证码登录

21.1 完整流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
请求验证码
→ 校验手机号
→ 检查手机号/IP发送频率
→ 生成验证码
→ 调用短信服务
→ Redis 保存验证码与错误计数

提交手机号+验证码
→ Lua 原子校验并消费验证码
→ 查找或创建用户
→ 生成随机 Token
→ Redis 保存会话
→ 返回 Token

后续请求
→ Authorization: Bearer <token>
→ Redis 查会话
→ 注入当前用户

教学代码用打印函数模拟短信服务,不连接真实服务,也不会记录真实验证码日志。

21.2 Key 和时间策略

1
2
3
4
5
login:code:{phone}:value       验证码,5 分钟
login:code:{phone}:errors 错误次数,约 10 分钟
login:code:send:{phone} 手机号发送冷却,60 秒
login:code:ip:{ip}:{window} IP 窗口计数
login:session:{token} 登录会话,30 分钟或更长

手机号是隐私数据。生产系统可以对标准化手机号做 HMAC 后用作 Key 后缀,减少运维界面的明文暴露。普通无盐哈希无法阻止小空间枚举。

21.3 请求模型和验证码生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import secrets

from pydantic import BaseModel, Field, field_validator


class SendCodeRequest(BaseModel):
phone: str

@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str) -> str:
digits = "".join(ch for ch in value if ch.isdigit())
if len(digits) != 11:
raise ValueError("invalid phone number")
return digits


class LoginRequest(SendCodeRequest):
code: str = Field(pattern=r"^\d{6}$")


def generate_code() -> str:
return f"{secrets.randbelow(1_000_000):06d}"

不要用 random.randint 生成安全验证码;secrets 面向安全随机。验证码仍只是短数字,必须配合短 TTL、错误次数和发送限制。

21.4 发送频率控制

先抢占一个 60 秒冷却标记:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from redis.asyncio import Redis


class TooManyRequestsError(Exception):
pass


async def reserve_send_slot(redis: Redis, phone: str) -> None:
accepted = await redis.set(
f"login:code:send:{phone}",
"1",
ex=60,
nx=True,
)
if not accepted:
raise TooManyRequestsError("please retry later")

这个限制只按手机号,攻击者可换手机号;还应加 IP、设备和全局供应商预算保护。固定窗口计数可以用 Lua 原子完成首次计数与 TTL:

1
2
3
4
5
local current = redis.call("INCR", KEYS[1])
if current == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return current

窗口边界会出现突发,例如一分钟末尾和下一分钟开头各打一批。更精细时用滑动窗口 ZSet 或令牌桶,但成本更高。

21.5 先发送还是先保存

一种顺序:

  1. 生成验证码。
  2. 调真实短信服务。
  3. 发送成功后 SET EX 保存验证码。

如果短信已送达但 Redis 写失败,用户收到却无法登录;接口应返回失败,允许重新获取,而不能绕过验证。

另一种顺序先存再发,短信失败时 Redis 留有用户没收到的验证码,应删除或覆盖。真实系统还要处理供应商“请求成功但最终未送达”的异步状态。没有一个顺序能自动消灭跨系统一致性问题。

教学服务:

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
class SmsGateway:
async def send_code(self, phone: str, code: str) -> None:
# 教学占位。生产中调用真实供应商,且日志不能输出 code。
return None


class AuthService:
CODE_TTL = 300

def __init__(self, redis: Redis, sms: SmsGateway) -> None:
self.redis = redis
self.sms = sms

async def send_code(self, phone: str) -> None:
await reserve_send_slot(self.redis, phone)
code = generate_code()
try:
await self.sms.send_code(phone, code)
await self.redis.set(
f"login:code:{{{phone}}}:value",
code,
ex=self.CODE_TTL,
)
except Exception:
await self.redis.delete(f"login:code:send:{phone}")
raise

生产中是否删除冷却标记要防止供应商失败被攻击者无限重试,应按错误类型设计。

21.6 原子校验、错误计数与一次性消费

如果 GET 校验成功后再 DEL,两个并发登录都可能看到同一个正确验证码。Lua:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
local actual = redis.call("GET", KEYS[1])

if not actual then
return -1
end

if actual ~= ARGV[1] then
local errors = redis.call("INCR", KEYS[2])
if errors == 1 then
redis.call("EXPIRE", KEYS[2], ARGV[2])
end
if errors >= tonumber(ARGV[3]) then
redis.call("DEL", KEYS[1])
end
return 0
end

redis.call("DEL", KEYS[1])
redis.call("DEL", KEYS[2])
return 1

返回码约定:

  • 1:正确且已经消费。
  • 0:错误,累计次数。
  • -1:不存在、过期或错误过多已删除。
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
VERIFY_CODE_SCRIPT = """
local actual = redis.call("GET", KEYS[1])
if not actual then return -1 end
if actual ~= ARGV[1] then
local errors = redis.call("INCR", KEYS[2])
if errors == 1 then redis.call("EXPIRE", KEYS[2], ARGV[2]) end
if errors >= tonumber(ARGV[3]) then redis.call("DEL", KEYS[1]) end
return 0
end
redis.call("DEL", KEYS[1])
redis.call("DEL", KEYS[2])
return 1
"""


async def consume_valid_code(
redis: Redis,
phone: str,
submitted_code: str,
) -> bool:
result = await redis.eval(
VERIFY_CODE_SCRIPT,
2,
f"login:code:{{{phone}}}:value",
f"login:code:{{{phone}}}:errors",
submitted_code,
"600",
"5",
)
return result == 1

双花括号让 Python f-string 在实际 Key 中保留一层花括号。例如手机号 13800000000 会生成 login:code:{13800000000}:valuelogin:code:{13800000000}:errors,Cluster 只用花括号中的手机号计算槽,因此 Lua 的两个 Key 同槽。

21.7 创建 Token 会话

Token 应足够随机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from datetime import UTC, datetime
import secrets


async def create_session(
redis: Redis,
user_id: int,
ttl_seconds: int = 1800,
) -> str:
token = secrets.token_urlsafe(32)
key = f"login:session:{token}"
await redis.hset(
key,
mapping={
"user_id": str(user_id),
"created_at": datetime.now(UTC).isoformat(),
},
)
await redis.expire(key, ttl_seconds)
return token

HSET 与 EXPIRE 有中间窗口。更严格可用事务 Pipeline 或 Lua;也可把会话存成 JSON String 并 SET EX 一次写入。

Token 不要保存在 URL 查询参数或日志。客户端通过 Authorization Bearer 发送。

21.8 鉴权与滑动过期

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
from typing import Annotated

from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

bearer = HTTPBearer(auto_error=False)


async def get_current_user_id(
credentials: Annotated[
HTTPAuthorizationCredentials | None,
Depends(bearer),
],
redis: RedisDep,
) -> int:
if credentials is None:
raise HTTPException(status_code=401, detail="unauthorized")

key = f"login:session:{credentials.credentials}"
user_id = await redis.hget(key, "user_id")
if user_id is None:
raise HTTPException(status_code=401, detail="invalid session")

await redis.expire(key, 1800)
return int(user_id)

每请求续期是滑动过期,活跃会话可一直延长。通常还需绝对最长生存期、设备撤销、密码修改后批量失效等策略。每次 EXPIRE 也增加写负载,可仅在剩余 TTL 低于阈值时续期。

注销:

1
await redis.unlink(f"login:session:{token}")

21.9 接口不要泄露账号信息

验证码发送接口可以统一返回“如果号码可用,验证码将发送”,避免暴露手机号是否注册。错误信息不要区分“手机号存在但验证码错”等可枚举状态。日志脱敏,监控记录 Key 模式而非完整 Token。

Redis 不可用时,验证码校验应 fail-closed:返回临时不可用,而不是跳过校验。

本章收束

核心结论

  • 验证码需要 TTL、一次性消费、错误次数和多维限流。
  • 校验与删除必须原子,Lua 能避免并发复用。
  • Token 应安全随机,Redis 保存服务端会话和过期。
  • 短信、Redis 与数据库跨系统操作仍有一致性窗口。
  • 认证链路 Redis 故障应 fail-closed。

Key 速记

1
2
3
4
login:code:{phone}:value
login:code:{phone}:errors
login:code:send:{phone}
login:session:{token}

常见误区

  • 只给验证码 TTL,不限制猜测次数。
  • GET 正确后再 DEL,允许并发重复使用。
  • 日志输出验证码和 Token。
  • Redis 故障时绕过鉴权。

你可以这样阐述

短信登录用短 TTL Key 保存验证码,并通过手机号、IP、设备维度限流。Lua 原子完成校验、错误计数和成功删除,避免并发复用。登录成功后生成高熵 Token,把会话保存在 Redis;认证属于安全链路,Redis 故障不能直接放行。


第 22 章:Cache-Aside 查询缓存

22.1 最常用的缓存模式

Cache-Aside(旁路缓存):

1
2
3
4
5
6
读请求:
1. 查 Redis
2. 命中 → 反序列化返回
3. 未命中 → 查数据库
4. 数据库有 → 写 Redis → 返回
5. 数据库无 → 404(可缓存空值)

应用负责缓存,数据库和 Redis 不自动同步。

22.2 DTO 与 Repository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from datetime import datetime
from decimal import Decimal

from pydantic import BaseModel


class ShopDTO(BaseModel):
id: int
name: str
address: str
rating: Decimal
updated_at: datetime


class ShopRepository:
def __init__(self, db: "AsyncSession") -> None:
self.db = db

async def get_by_id(self, shop_id: int) -> ShopDTO | None:
model = await self.db.get(Shop, shop_id)
if model is None:
return None
return ShopDTO.model_validate(model, from_attributes=True)

Repository 代表数据库事实源,缓存服务不直接伪造业务数据。

22.3 完整读取

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import random

from redis.asyncio import Redis
from redis.exceptions import ConnectionError, TimeoutError


class ShopQueryService:
NULL_SENTINEL = "__NULL__"

def __init__(
self,
redis: Redis,
repository: ShopRepository,
) -> None:
self.redis = redis
self.repository = repository

async def get_shop(self, shop_id: int) -> ShopDTO | None:
key = f"cache:v1:shop:{shop_id}"

try:
cached = await self.redis.get(key)
except (ConnectionError, TimeoutError):
cached = None

if cached == self.NULL_SENTINEL:
return None

if cached is not None:
try:
return ShopDTO.model_validate_json(cached)
except ValueError:
await self.redis.unlink(key)

shop = await self.repository.get_by_id(shop_id)
if shop is None:
try:
await self.redis.set(key, self.NULL_SENTINEL, ex=30)
except (ConnectionError, TimeoutError):
pass
return None

try:
await self.redis.set(
key,
shop.model_dump_json(),
ex=300 + random.randint(0, 60),
)
except (ConnectionError, TimeoutError):
pass
return shop

这个版本选择:Redis 失败则回源数据库。但必须配套数据库连接池、限流和熔断,否则 Redis 大面积故障时所有流量压向数据库。

反序列化失败时删缓存并回源,但代码中 UNLINK 本身也可能因 Redis 故障失败,严格实现还要捕获和记录;示例为可读性省略外围日志。

22.4 FastAPI 路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from fastapi import APIRouter, Depends, HTTPException

router = APIRouter(prefix="/shops", tags=["shops"])


@router.get("/{shop_id}", response_model=ShopDTO)
async def get_shop(
shop_id: int,
service: "ShopQueryService" = Depends(get_shop_query_service),
) -> ShopDTO:
shop = await service.get_shop(shop_id)
if shop is None:
raise HTTPException(status_code=404, detail="shop not found")
return shop

22.5 命中、未命中和空值指标

至少区分:

  • hit:缓存中有有效商户。
  • miss:缓存不存在,回源。
  • null_hit:命中空值。
  • deserialize_error:缓存格式坏。
  • redis_error:Redis 失败。
  • db_fallback:实际回源数据库。

命中率:

1
hit_rate = hit / (hit + miss)

空值是否算 hit 要在指标定义里写清楚。高命中率不等于性能一定好:可能 value 很大或少数 miss 极昂贵。

22.6 批量查询与部分命中

批量查询 100 个商户不能循环 100 次 GET。流程:

  1. MGET 所有 Key。
  2. 保持输入位置映射。
  3. 收集未命中 ID。
  4. 数据库用 WHERE id IN (...) 批量查询。
  5. Pipeline/批量写回。
  6. 按原输入顺序组装。

还要限制批量 ID 数量,避免超大 MGET 和 SQL IN。

22.7 TTL 是业务陈旧预算

TTL 不是随便写 300:

  • 变化频繁且陈旧敏感:短 TTL 或主动失效。
  • 稳定基础资料:更长 TTL。
  • 热点且重建昂贵:较长 TTL + 主动更新/逻辑过期。
  • 不存在数据:短空值 TTL。

TTL 越短,不一致窗口可能越小,但回源和重建更多;越长,性能更稳但陈旧更久。

本章收束

核心结论

  • Cache-Aside 是应用先查缓存,miss 后回源并回填。
  • 数据库是事实源,缓存内容可重建。
  • 空值缓存能缓解不存在 ID 的反复查询。
  • Redis 失败回源必须保护数据库。
  • TTL 表达可接受陈旧与重建成本的权衡。

流程速记

1
2
3
GET → hit 返回
→ miss → DB → SET EX → 返回
→ none → 短空值

常见误区

  • Redis 故障无限回源。
  • 所有业务统一 TTL。
  • 循环单条 GET 做批量查询。
  • 缓存 JSON 格式变更却不更新版本前缀。

你可以这样阐述

Cache-Aside 由应用管理:先查 Redis,命中直接返回;未命中查询数据库并回填。数据库仍是事实源。缓存空值可缓解无效 ID 穿透,TTL 则权衡陈旧时间与重建成本;Redis 故障回源必须配套数据库保护。


第 23 章:缓存一致性与更新策略

23.1 三类模式

Cache-Aside:应用读缓存,写数据库后主动删除/更新缓存。最常见。

Read/Write Through:应用面对统一缓存层,由该层同步读写后端存储。Redis 本身不会自动替普通关系数据库实现完整 through 逻辑。

Write Behind:先写缓存/队列,异步落数据库,延迟低但丢失窗口、顺序、重试和冲突更复杂。

本文默认 Cache-Aside。

23.2 为什么通常“更新数据库,再删除缓存”

更新流程:

1
2
3
4
BEGIN 数据库事务
UPDATE shop ...
COMMIT
DEL cache:v1:shop:42

先更新数据库确保事实源成功,再让下次读取回填新值。

为什么删除而非直接更新缓存:

  • 删除简单,不需要复制完整序列化逻辑;
  • 避免更新一个很少读取的数据;
  • 多张表组合结果难在写路径准确重建。

但删除失败会留下旧缓存,需重试。

23.3 先删缓存再更新数据库的典型问题

1
2
3
4
写请求 A:删除缓存
读请求 B:缓存 miss,读取数据库旧值
写请求 A:提交数据库新值
读请求 B:把旧值写回缓存

旧值可能一直存在到 TTL。故一般不选先删再更新。

23.4 更新数据库后删缓存也不是绝对一致

仍有竞态:

1
2
3
读 B:缓存 miss,读取数据库旧值(暂未回填)
写 A:更新数据库新值并删除缓存
读 B:把刚才读到的旧值回填

这个窗口通常比先删方案苛刻,但并非不可能。可以通过较短 TTL、互斥重建、版本校验、CDC 失效等降低风险。

23.5 删除必须在事务提交后

不能在数据库事务尚未提交时删除:其他请求可能立刻回源,仍读到旧值并回填。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async def update_shop(
db: "AsyncSession",
redis: Redis,
shop_id: int,
patch: dict,
) -> None:
shop = await db.get(Shop, shop_id)
if shop is None:
raise LookupError("shop not found")

for field, value in patch.items():
setattr(shop, field, value)

await db.commit()

try:
await redis.unlink(f"cache:v1:shop:{shop_id}")
except (ConnectionError, TimeoutError):
await enqueue_cache_invalidation(shop_id)

enqueue_cache_invalidation 需要可靠重试设施,不能只是内存函数。可写数据库 outbox,在同一事务记录失效事件,再由 worker 投递。

23.6 延迟双删

思路:更新后立即删一次,稍后再删一次,试图清除并发读回填的旧值。

它只能缓解,不能作为强一致证明:

  • 延迟时间难选;
  • 第二次删除任务可能丢;
  • 更慢的读仍可能晚于第二次删除回填;
  • 增加写与运维复杂度。

如果使用,第二次删除必须进入可靠队列并可观测,而不是进程内 sleep。

23.7 CDC / binlog 失效

数据库提交后,通过变更数据捕获读取 binlog/WAL,再向缓存发送失效事件:

1
数据库提交 → CDC → 失效消息 → 删除 Redis/L1

优点:缓存失效与所有数据库写入口解耦;缺点:部署复杂、异步延迟、重复事件和顺序都要处理。删除操作通常天然幂等。

23.8 强一致需求怎么办

普通缓存模式无法轻易保证任何时刻都与数据库相同。若业务是余额、权限撤销、库存最终扣减:

  • 关键判断直接读权威数据库/一致性存储;
  • 数据库唯一约束和事务兜底;
  • 缓存只做候选/加速,不作为唯一裁决;
  • 用版本号校验或短 TTL 限制陈旧;
  • 必要时牺牲缓存命中与可用性。

一致性、可用性和延迟需要权衡,不能靠改变 DEL 顺序获得魔法强一致。

本章收束

核心结论

  • 默认策略:数据库提交成功后删除缓存。
  • 删除失败必须可重试。
  • 两种顺序都存在并发窗口,只是概率与窗口不同。
  • 延迟双删是缓解,不是强一致保证。
  • 强一致判断应回到权威存储和数据库约束。

流程速记

1
2
DB COMMIT → DEL cache
DEL 失败 → durable retry/outbox

常见误区

  • 先删缓存再慢慢提交数据库。
  • 数据库回滚了却已经删除并回填。
  • 把延迟双删称为绝对一致。
  • 直接更新复杂组合缓存,遗漏字段。

你可以这样阐述

Cache-Aside 写路径通常先提交数据库再删除缓存,让后续读回源新值。该方案仍有读写竞态与删除失败窗口,需要 TTL、可靠重试、版本或 CDC 缓解。强一致业务不能把缓存当唯一裁决,应依赖权威数据库事务和约束。


第 24 章:缓存穿透、击穿、雪崩和热点 Key

这四个概念经常被混叫,先严格区分。

24.1 缓存穿透

定义:查询的数据本来就不存在,缓存与数据库都 miss;恶意或错误请求不断换不存在 ID,每次都穿过缓存访问数据库。

解决:

  1. 输入验证:拒绝不合法 ID。
  2. 空值缓存:不存在也缓存短 TTL。
  3. Bloom Filter:先判断“可能存在吗”。
  4. 限流和风控:防止大量随机 Key。

空值缓存:

1
cache:v1:shop:404 → __NULL__,TTL 30 秒

缺点是短暂掩盖刚创建的数据,所以创建后主动删除对应空值。

Bloom Filter 是概率结构:

  • 说“不存在”时可确定不在集合(前提是过滤器维护正确)。
  • 说“可能存在”时可能误判,仍需查数据库。
  • 普通过滤器不擅长删除,数据同步与重建是难点。

Bloom 只挡明显不存在请求,不替代数据库。

24.2 缓存击穿

定义:某一个非常热的 Key 过期或被删除,大量并发同时回源重建。

方案一:互斥重建。

1
2
miss → 只有一个请求拿重建锁查 DB
→ 其他请求短暂等待/重试/返回旧值

方案二:逻辑过期。

缓存 value 内保存 logical_expire_at,Redis Key 本身较长时间不物理过期:

1
2
3
4
{
"logical_expire_at": "2026-08-12T10:00:00Z",
"data": {"id": 42, "name": "Coffee"}
}
  • 未逻辑过期:直接返回。
  • 已过期:尝试获取重建权;成功者后台刷新,当前请求可返回旧值;失败者也返回旧值。

优点是热点请求延迟稳定;缺点是会返回陈旧数据,需要后台刷新、永不过期 Key 清理和冷启动预热。

方案三:进程内 singleflight,同一应用实例内合并相同 Key 的并发重建。多实例仍需要分布式协调。

24.3 缓存雪崩

定义:大量不同缓存同时失效,或 Redis 整体故障,导致大范围回源,数据库和下游被压垮。

原因:

  • 批量预热的 Key 统一 TTL;
  • Redis 节点/集群不可用;
  • 版本切换导致全量 miss;
  • 错误清空缓存;
  • 网络故障。

缓解:

  • TTL 随机抖动;
  • 分批预热、分批切版本;
  • Redis 高可用;
  • 数据库限流、熔断、队列和降级;
  • 热点永不过期 + 逻辑刷新;
  • 本地缓存兜底,但限制陈旧时间;
  • 容量演练。

24.4 热 Key

热 Key 本身不一定过期,只是流量高度集中。它可导致单节点 CPU、网卡或连接瓶颈。对策包括 L1 缓存、请求合并、拆 value、读副本(接受延迟)和热点复制。

不要把热 Key、击穿与雪崩混为一谈:

问题 数据范围 核心触发
穿透 大量不存在 Key 缓存和 DB 都无数据
击穿 一个/少数热点 Key 热 Key 失效并发回源
雪崩 大量不同 Key/整个缓存 批量失效或服务故障
热 Key 一个/少数 Key 流量长期倾斜

24.5 互斥重建骨架

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import asyncio
import secrets

from redis.asyncio import Redis


async def get_shop_with_mutex(
redis: Redis,
repository: ShopRepository,
shop_id: int,
) -> ShopDTO | None:
cache_key = f"cache:v1:shop:{shop_id}"
lock_key = f"cache:shop:lock:{shop_id}"

for _attempt in range(5):
cached = await redis.get(cache_key)
if cached is not None:
return ShopDTO.model_validate_json(cached)

token = secrets.token_hex(16)
acquired = await redis.set(
lock_key,
token,
nx=True,
px=10_000,
)
if acquired:
try:
# 双重检查:等待锁期间别人可能已经回填
cached = await redis.get(cache_key)
if cached is not None:
return ShopDTO.model_validate_json(cached)

shop = await repository.get_by_id(shop_id)
if shop is not None:
await redis.set(
cache_key,
shop.model_dump_json(),
ex=300,
)
return shop
finally:
await release_lock(redis, lock_key, token)

await asyncio.sleep(0.05)

# 超过等待预算:可返回 503、旧值或受保护回源
raise TimeoutError("cache rebuild is busy")

锁 TTL 10 秒若小于数据库查询时间,会有多个重建者。这里的锁只是减少回源,不承担金融临界区,因此可接受一定重复重建;第 25 章解释更严格锁语义。

24.6 没有万能组合

不应该给每个 Key 同时加 Bloom、空值、锁、逻辑过期和 L1:复杂度会超过收益。

按场景选:

  • 普通低热度数据:Cache-Aside + TTL。
  • 不存在查询多:输入验证 + 空值。
  • 合法 ID 集合稳定且攻击强:Bloom + 空值。
  • 极热点重建昂贵:互斥/singleflight 或逻辑过期。
  • 大面积风险:TTL 抖动 + 高可用 + 下游保护。

本章收束

核心结论

  • 穿透是不存在数据,击穿是热点失效,雪崩是大范围失效,热 Key 是流量倾斜。
  • 空值和 Bloom 解决方向不同。
  • 互斥重建降低热点并发回源,逻辑过期用陈旧换可用。
  • 防雪崩必须同时保护数据库和下游。

速记

1
2
3
4
穿透 → validate / null / bloom
击穿 → mutex / singleflight / logical expire
雪崩 → jitter / HA / rate-limit / degrade
热 Key → L1 / merge / split / replicas

常见误区

  • 把所有缓存故障叫雪崩。
  • Bloom 说“可能存在”就直接返回数据。
  • 互斥等待没有上限。
  • 逻辑过期不设计刷新失败和陈旧上限。

你可以这样阐述

穿透是反复查询不存在数据,击穿是单个热点 Key 失效后并发回源,雪崩是大量 Key 或 Redis 故障导致大面积回源,热 Key 则是持续流量倾斜。对应手段分别是空值/Bloom、互斥或逻辑过期、TTL 抖动与下游保护、L1 与请求合并。


第 25 章:分布式锁从错误版本到可靠版本

25.1 为什么 asyncio.Lock 不够

一个 FastAPI 进程内可用 asyncio.Lock 协调协程。但生产常有多个进程、容器和机器,各自内存锁互不知晓:

1
实例 A 的锁 ≠ 实例 B 的锁

分布式锁让所有实例竞争共享系统中的同一把锁。Redis 可以实现有租期的互斥,但它不是自动适合所有严格一致场景。

25.2 错误版本一:SETNX 后 EXPIRE

1
2
SETNX lock:order:42 worker-a
EXPIRE lock:order:42 10

若 SETNX 成功后进程崩溃,EXPIRE 未执行,锁永久存在。必须一条命令:

1
SET lock:order:42 unique-token NX PX 10000

25.3 错误版本二:直接 DEL

时序:

1
2
3
4
5
A 获取锁,TTL 10 秒
A 暂停超过 10 秒,锁过期
B 获取同名新锁
A 恢复,执行 DEL
A 把 B 的锁删了

锁 value 必须是每次获取唯一 token,释放时原子比较 token 后删除。

1
2
3
4
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0

不能客户端 GET 后 DEL,因为两条命令之间锁可能过期并易主。

25.4 一个可读的锁类

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import asyncio
import secrets
import time

from redis.asyncio import Redis


RELEASE_SCRIPT = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
"""


class RedisLock:
def __init__(
self,
redis: Redis,
key: str,
lease_ms: int = 10_000,
) -> None:
self.redis = redis
self.key = key
self.lease_ms = lease_ms
self.token = secrets.token_hex(16)
self.acquired = False

async def acquire(
self,
wait_timeout: float = 2.0,
retry_interval: float = 0.05,
) -> bool:
deadline = time.monotonic() + wait_timeout
while time.monotonic() < deadline:
ok = await self.redis.set(
self.key,
self.token,
nx=True,
px=self.lease_ms,
)
if ok:
self.acquired = True
return True
await asyncio.sleep(retry_interval)
return False

async def release(self) -> bool:
if not self.acquired:
return False
deleted = await self.redis.eval(
RELEASE_SCRIPT,
1,
self.key,
self.token,
)
self.acquired = False
return deleted == 1

async def __aenter__(self) -> "RedisLock":
if not await self.acquire():
raise TimeoutError(f"failed to acquire {self.key}")
return self

async def __aexit__(self, exc_type, exc, traceback) -> None:
await self.release()

使用:

1
2
async with RedisLock(redis, "lock:shop:42", lease_ms=10_000):
await update_shared_resource()

这是教学实现。生产还要处理取消、释放连接失败、指标、随机退避和 Redis 故障策略。

25.5 等待、退避和惊群

许多客户端固定 50ms 重试,会周期性一起轰击 Redis。可加入随机抖动:

1
await asyncio.sleep(random.uniform(0.03, 0.08))

等待必须有预算。锁获取失败可返回 409/429/503,排队或降级,不能无限占用 HTTP 请求。

Redis 锁通常不保证公平:后来者可能先拿到。需要严格公平排队时应换协调方案或显式队列。

25.6 租期与续期

TTL 太短,业务未完成锁就过期;太长,持有者崩溃后别人等待久。

续期(watchdog)会周期性检查 token 仍属于自己,再延长 TTL:

1
2
3
4
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0

续期也不是绝对保证:进程长暂停、网络分区或 Redis 不可用时续期失败。旧持有者可能在锁失效后继续写共享资源。

25.7 Fencing Token

更严格的做法是每次成功获取锁时获得单调递增编号:

1
2
3
4
5
A 获得 token 41
A 暂停,锁过期
B 获得 token 42 并写资源
A 恢复,带 token 41 写资源
资源服务发现 41 < 已见 42,拒绝 A

这叫 fencing token。关键在于下游资源必须检查并拒绝旧编号;只在 Redis 生成 INCR 编号而数据库不检查,没有效果。

数据库可保存 last_fencing_token,并用条件更新:

1
2
3
UPDATE resource
SET value = :value, last_token = :token
WHERE id = :id AND last_token < :token;

25.8 Redlock 与边界

Redlock 尝试在多个相互独立 Redis 主节点上获取多数锁,减少单实例故障导致双持有。但它的安全性依赖时钟、延迟、租期和故障模型,业界有长期争议。

工程决策:

  • 缓存重建、重复任务抑制等允许偶发重复:单 Redis 租约锁常足够。
  • 订单唯一、余额扣减:数据库唯一约束/事务必须兜底。
  • 严格分布式协调和线性一致需求:评估 etcd、ZooKeeper、Consul 等共识系统,加 fencing。

不要把“用了 Redlock”写成“任何故障下绝对安全”。

25.9 锁不是约束替代品

“一个用户只能买一次”最终用数据库唯一索引:

1
UNIQUE (user_id, voucher_id)

锁可减少冲突与重复工作,但若锁失效、客户端 Bug 或其他写入口绕开锁,唯一约束仍阻止重复事实。

本章收束

核心结论

  • 获取锁要用 SET NX PX 一条命令。
  • value 是唯一 token,释放用 Lua 比较删除。
  • TTL 是租期,不保证业务期间永不过期。
  • 续期缓解长任务,fencing token 防旧持有者写入。
  • 严格业务仍需数据库约束或共识协调。

速记

1
2
3
acquire = SET key token NX PX lease
release = if GET == token then DEL end
strict downstream = fencing token

常见误区

  • SETNX 与 EXPIRE 分两步。
  • finally 中直接 DEL。
  • 锁无 TTL。
  • 无限重试或宣称绝对公平。
  • 用锁代替数据库唯一约束。

你可以这样阐述

Redis 分布式锁用 SET key token NX PX lease 原子获取,以唯一 token 标识持有者,再用 Lua 比较并删除。TTL 只是租期,任务暂停后可能失效,续期只能缓解;严格资源应使用 fencing token 和数据库约束拒绝过期持有者。


第 26 章:List 阻塞队列

26.1 最简单的生产消费

HTTP 请求不必等待发送邮件,可将任务写入 List:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import json
import secrets

from fastapi import APIRouter

router = APIRouter(prefix="/notifications")


@router.post("/email", status_code=202)
async def enqueue_email(
request: "EmailRequest",
redis: RedisDep,
) -> dict[str, str]:
task_id = secrets.token_hex(16)
payload = json.dumps(
{
"task_id": task_id,
"to": request.to,
"template": request.template,
}
)
await redis.rpush("queue:email:pending", payload)
return {"task_id": task_id, "status": "accepted"}

202 Accepted 表示已受理,不表示邮件已经发送。

worker:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import asyncio
import json

from redis.asyncio import Redis


async def email_worker(redis: Redis) -> None:
while True:
result = await redis.blpop(
"queue:email:pending",
timeout=5,
)
if result is None:
# 有机会检查关闭信号和健康状态
continue

_queue, raw = result
task = json.loads(raw)
await send_email(task)

有限 timeout 比无限阻塞更容易优雅退出。socket_timeout 要大于阻塞时间。

26.2 崩溃丢失窗口

BLPOP 已把消息删除,send_email 前进程崩溃,消息丢失。

改用 BLMOVE:

1
2
3
4
5
6
7
raw = await redis.blmove(
"queue:email:pending",
"queue:email:processing",
timeout=5,
src="LEFT",
dest="RIGHT",
)

成功后处理,再从 processing 移除:

1
2
3
4
5
removed = await redis.lrem(
"queue:email:processing",
1,
raw,
)

但若两个 payload 完全相同,按 value LREM 可能移除错误实例。必须给消息唯一 ID,最好 processing 不只保存原始重复文本,并建立状态元数据。

26.3 重试和死信要自己实现

List 没有 PEL。需要额外设计:

  • processing 中何时算超时;
  • 重试次数存在哪里;
  • 谁扫描超时任务;
  • 重试是否延迟;
  • 超过次数如何进 dead-letter;
  • worker 名称和认领记录;
  • 消息保留和积压监控。

可以用 Hash 保存元数据、ZSet 记录 deadline、List 保存 ID,但很快就重新实现了一个简化消息系统。

26.4 重复处理与幂等

为了不丢消息而重试,就会出现重复。发送邮件可能重复发送;可在数据库/Redis 保存 task_id 状态:

1
queued → processing → sent / failed

外部邮件供应商若支持 idempotency key,应传 task_id。若不支持,进程在供应商成功后、记录成功前崩溃,仍可能重复;这是跨系统窗口。

26.5 连接与进程组织

不要在 FastAPI 每个 web worker 的 startup 随意启动相同后台消费任务,否则扩容会改变消费者数量,部署关闭也难控制。通常将 worker 作为独立进程/容器:

1
2
Web 实例:接收请求、RPUSH
Worker 实例:BLMOVE、处理、确认

阻塞 worker 使用专用 Redis 客户端和连接配置,不与 HTTP 请求池争抢。

26.6 什么时候 List 足够

适合:

  • 简单内部任务;
  • 丢失或偶尔重复可接受;
  • 团队愿意自行补状态;
  • 消息历史和多消费组不是需求。

当你需要 ACK、pending、消费者组、认领、投递次数和历史范围时,使用 Stream。

本章收束

核心结论

  • BLPOP/BRPOP 能高效等待消息,但取出即删除。
  • BLMOVE 加 processing 缓解丢失,却需要自行确认和超时重试。
  • 为避免丢失引入重试后,消费者必须幂等。
  • worker 应独立部署并使用专用连接。

流程速记

1
2
simple: RPUSH → BLPOP → process
safer: RPUSH → BLMOVE processing → process → LREM

常见误区

  • 202 返回后告诉用户“已发送”。
  • BLPOP 后处理失败不记录。
  • 用重复 payload 做唯一确认。
  • Web worker 与阻塞消费者共用小连接池。

你可以这样阐述

List 可用 RPUSH + BLPOP 实现简单阻塞队列,但弹出即删除会在 worker 崩溃时丢消息。BLMOVE 可先移到 processing,再处理和移除,不过 pending 元数据、重试、认领与死信都需自行实现;需求复杂时应升级为 Stream。


第 27 章:Stream 可靠消息队列

27.1 目标语义

本模块要做到:

  • HTTP 生产消息后快速返回受理;
  • 多 worker 在同一消费者组分担;
  • 业务成功才 ACK;
  • 崩溃消息留在 PEL;
  • 其他 worker 可认领超时消息;
  • 多次失败进入死信;
  • 数据库层幂等。

这仍是至少一次,不宣称跨 Redis 与数据库恰好一次。

27.2 消息格式

1
2
3
4
5
6
stream:orders entry:
message_id = UUID
order_id = 业务订单 ID
user_id
voucher_id
created_at = Unix 毫秒

Stream entry 自带 ID,但独立 message_id 便于跨重试、死信和数据库幂等。

生产:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from datetime import UTC, datetime
import secrets


async def publish_order(
redis: Redis,
order_id: int,
user_id: int,
voucher_id: int,
) -> str:
return await redis.xadd(
"stream:orders",
{
"message_id": secrets.token_hex(16),
"order_id": str(order_id),
"user_id": str(user_id),
"voucher_id": str(voucher_id),
"created_at": str(
int(datetime.now(UTC).timestamp() * 1000)
),
},
maxlen=100_000,
approximate=True,
)

过早裁剪可能删除 PEL 对应 payload,容量应根据产生速率与最慢恢复时间估算,而不是照抄 100000。

27.3 初始化消费组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from redis.exceptions import ResponseError


async def ensure_order_group(redis: Redis) -> None:
try:
await redis.xgroup_create(
"stream:orders",
"order-workers",
id="0-0",
mkstream=True,
)
except ResponseError as exc:
if "BUSYGROUP" not in str(exc):
raise

只忽略明确 BUSYGROUP,不能吞掉权限、连接或参数错误。

27.4 业务幂等

数据库设计至少有:

1
2
3
4
5
6
7
CREATE TABLE processed_messages (
message_id VARCHAR(64) PRIMARY KEY,
processed_at TIMESTAMP NOT NULL
);

CREATE UNIQUE INDEX uq_order_user_voucher
ON orders(user_id, voucher_id);

在一个数据库事务中:

  1. 插入 processed_messages。
  2. 若主键已存在,说明已处理,可视为成功。
  3. 创建订单;唯一索引阻止重复购买。
  4. 提交。
  5. 再 XACK。

数据库提交后 ACK 前崩溃,重试会遇到 message_id 已存在,然后 ACK,不重复产生业务事实。

27.5 消费主循环

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import asyncio


class OrderWorker:
STREAM = "stream:orders"
GROUP = "order-workers"

def __init__(
self,
redis: Redis,
consumer_name: str,
order_service: "OrderService",
) -> None:
self.redis = redis
self.consumer_name = consumer_name
self.order_service = order_service

async def run(self) -> None:
await ensure_order_group(self.redis)
while True:
try:
rows = await self.redis.xreadgroup(
groupname=self.GROUP,
consumername=self.consumer_name,
streams={self.STREAM: ">"},
count=10,
block=5000,
)
if not rows:
await self.recover_stale()
continue

for _stream, entries in rows:
for entry_id, fields in entries:
await self.process(entry_id, fields)
except asyncio.CancelledError:
raise
except Exception:
# 真实项目:日志、指标、带上限退避
await asyncio.sleep(1)

async def process(
self,
entry_id: str,
fields: dict[str, str],
) -> None:
await self.order_service.process_idempotently(fields)
await self.redis.xack(
self.STREAM,
self.GROUP,
entry_id,
)

不要捕获业务异常后仍 ACK。毒消息若永远失败,会一直 pending,需要重试计数与死信。

27.6 认领、重试与死信

XAUTOCLAIM 的精确返回形态会随客户端/服务端版本细节变化,以下展示逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async def recover_stale(self) -> None:
cursor = "0-0"
while True:
cursor, entries, *_ = await self.redis.xautoclaim(
self.STREAM,
self.GROUP,
self.consumer_name,
min_idle_time=60_000,
start_id=cursor,
count=20,
)

for entry_id, fields in entries:
await self.process_or_dead_letter(entry_id, fields)

if cursor == "0-0":
break

获取投递次数可通过 XPENDING 明细。超过阈值后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async def move_to_dead_letter(
redis: Redis,
entry_id: str,
fields: dict[str, str],
reason: str,
) -> None:
await redis.xadd(
"stream:orders:dead",
{
**fields,
"source_entry_id": entry_id,
"failure_reason": reason[:500],
},
)
await redis.xack(
"stream:orders",
"order-workers",
entry_id,
)

XADD 死信与 XACK 源消息是两条命令,存在中间崩溃窗口。可用同槽 Lua 原子化 Redis 内这两步,但数据库和外部系统仍不在同一事务。死信写入要幂等,或接受重复死信并按 source_entry_id 去重。

27.7 积压监控

至少观察:

  • XLEN:Stream 总长度;
  • XINFO GROUPS:每组 pending、lag 等可用信息;
  • XPENDING:待处理数量与最老空闲时间;
  • 每秒生产/消费数;
  • 处理耗时、失败率、重试次数;
  • 死信增长;
  • 消费者最后心跳。

XLEN 大不一定是积压,因为已 ACK 历史仍保留。应看组 lag 与 pending。

27.8 何时换专业消息系统

当需要超高吞吐、长时间海量保留、复杂分区顺序、成熟跨地域复制、生态连接器或严格消息治理时,Kafka/RabbitMQ/Pulsar 等更合适。Stream 的优势是系统已经使用 Redis、规模适中且需要轻量可靠队列。

本章收束

核心结论

  • 业务提交后才 XACK。
  • PEL 保存已投递未确认消息,XAUTOCLAIM 恢复失联任务。
  • 至少一次意味着消费者必须幂等。
  • 死信、裁剪、重试与积压都需要策略和指标。
  • XACK 与数据库事务之间仍有双写窗口。

流程速记

1
2
XADD → XREADGROUP → DB idempotent commit → XACK
stale PEL → XAUTOCLAIM → retry / dead-letter

常见误区

  • 读取后立即 ACK。
  • 只消费新消息,不恢复 pending。
  • 用 XLEN 单独判断积压。
  • 过度裁剪导致慢消费者 payload 消失。
  • 把至少一次说成恰好一次。

你可以这样阐述

Stream 通过消费者组和 PEL 跟踪已投递未确认消息。worker 完成数据库幂等事务后 XACK;崩溃消息由 XAUTOCLAIM 接管,多次失败进入死信。这样实现至少一次交付,重复业务效果由 message_id 和数据库唯一约束消除。


第 28 章:秒杀与异步订单综合链路

秒杀把 String 计数、Set 去重、Lua 原子性、Stream 队列、数据库约束和补偿串在一起。

28.1 为什么不能直接每个请求查数据库库存

热点商品瞬间涌入大量请求,数据库行锁、连接池和事务可能成为瓶颈。目标是:

  1. Redis 内存中快速拒绝无库存与重复购买。
  2. 资格成功请求写入 Stream,HTTP 快速返回。
  3. worker 异步创建数据库订单。
  4. 数据库唯一约束与库存条件更新最终兜底。

28.2 Key 设计与 Cluster 同槽

1
2
3
seckill:{1001}:stock       String 库存
seckill:{1001}:ordered Set 已成功占资格用户
seckill:{1001}:orders Stream 订单消息

花括号内 voucher_id 相同,三个 Key 落在同一槽,Lua 才能在 Cluster 中访问。代价是同一热门券全部流量集中在一个槽/主节点,这正是单商品原子操作与水平分散的矛盾。

这里没有复用第 27 章的全局 stream:orders:全局 Stream 通常不会与每张券的库存 Key 同槽,Cluster 下 Lua 无法跨槽原子写入。按券建 Stream 时,worker 需要发现活动券并分别消费;活动很多时可把券预先分配到有限分片,并让每个分片的库存、去重和 Stream 使用同一 hash tag。分片越多,吞吐越分散,但全局库存和单用户约束越依赖数据库与额外协调。

28.3 预热

秒杀开始前把数据库券状态与库存载入 Redis:

1
2
3
4
5
6
7
async def warm_voucher(
redis: Redis,
voucher_id: int,
stock: int,
) -> None:
await redis.set(f"seckill:{{{voucher_id}}}:stock", stock)
# ordered Set 通常来自既有订单重建或保证活动开始时为空

预热不是随意复制值:要确定活动版本、开始结束时间、重跑幂等和 Redis 重启恢复方案。

28.4 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
local stock_key = KEYS[1]
local ordered_key = KEYS[2]
local stream_key = KEYS[3]

local stock = tonumber(redis.call("GET", stock_key) or "0")
if stock <= 0 then
return 1
end

if redis.call("SISMEMBER", ordered_key, ARGV[1]) == 1 then
return 2
end

redis.call("DECR", stock_key)
redis.call("SADD", ordered_key, ARGV[1])
redis.call(
"XADD", stream_key, "*",
"message_id", ARGV[2],
"order_id", ARGV[3],
"user_id", ARGV[1],
"voucher_id", ARGV[4],
"created_at", ARGV[5]
)
return 0

返回码:0 成功受理,1 无库存,2 重复购买。

因为脚本执行期间不插入其他普通命令,Redis 内的资格检查、扣库存、记用户与入 Stream 共同原子。脚本应先完成所有校验再写,保持短小。

28.5 FastAPI 接口

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from datetime import UTC, datetime
import secrets

from fastapi import APIRouter, Depends, HTTPException, status


SECKILL_SCRIPT = """
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then return 1 end
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then return 2 end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
redis.call('XADD', KEYS[3], '*',
'message_id', ARGV[2], 'order_id', ARGV[3],
'user_id', ARGV[1], 'voucher_id', ARGV[4],
'created_at', ARGV[5])
return 0
"""


@router.post(
"/vouchers/{voucher_id}/seckill",
status_code=status.HTTP_202_ACCEPTED,
)
async def seckill(
voucher_id: int,
redis: RedisDep,
user_id: int = Depends(get_current_user_id),
) -> dict[str, str]:
# 教学中用随机字符串代表全局订单 ID;生产可用雪花 ID等方案。
order_id = secrets.token_hex(12)
message_id = secrets.token_hex(16)
tag = f"{{{voucher_id}}}"

result = await redis.eval(
SECKILL_SCRIPT,
3,
f"seckill:{tag}:stock",
f"seckill:{tag}:ordered",
f"seckill:{tag}:orders",
str(user_id),
message_id,
order_id,
str(voucher_id),
str(int(datetime.now(UTC).timestamp() * 1000)),
)

if result == 1:
raise HTTPException(status_code=409, detail="sold out")
if result == 2:
raise HTTPException(status_code=409, detail="already ordered")

return {"order_id": order_id, "status": "accepted"}

参数默认值写法在真实依赖注入中应按项目统一,这里重点是数据流。202 只表示 Redis 资格与消息写入成功,不表示数据库订单已经创建。

28.6 worker 落库

数据库仍需要:

1
UNIQUE(user_id, voucher_id)

库存条件更新:

1
2
3
UPDATE vouchers
SET stock = stock - 1
WHERE id = :voucher_id AND stock > 0;

事务伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async def create_order_idempotently(message: dict[str, str]) -> None:
async with db.begin():
inserted = await insert_processed_message_if_absent(
message["message_id"]
)
if not inserted:
return

changed = await decrement_stock_if_positive(
int(message["voucher_id"])
)
if changed != 1:
raise StockMismatchError()

await insert_order(
order_id=message["order_id"],
user_id=int(message["user_id"]),
voucher_id=int(message["voucher_id"]),
)

提交后 XACK。数据库唯一约束阻止绕开 Redis 或重复消息造成重复订单。

28.7 Redis 库存与数据库库存为何可能不一致

可能场景:

  • Redis 已扣并入队,数据库长期故障;
  • 消息被错误裁剪或丢失;
  • worker 最终死信;
  • 数据库库存预热值错误;
  • 活动取消;
  • 用户资格成功后落单因约束失败。

需要补偿与对账:

  1. 定期对比受理数、Stream 成功数、数据库订单数、死信数。
  2. 永久失败消息经业务确认后,Lua 恢复 Redis 库存并从 ordered Set 移除用户,或关闭活动后统一重建。
  3. 补偿操作必须带 message_id 幂等,避免加两次库存。
  4. 不在不清楚数据库是否已提交时盲目补偿。

28.8 这个方案保证什么

在 Redis 单个脚本成功执行的前提下,它保证 Redis 内:

  • 不会库存检查通过却没扣 Redis 库存;
  • 不会扣了 Redis 库存却没写 Stream;
  • 同一用户不会在 ordered Set 语义下重复通过。

它不保证:

  • Redis 故障下数据绝不丢;
  • 数据库订单一定最终成功;
  • Redis 与数据库实时强一致;
  • 用户绝不收到重复外部通知;
  • Cluster 所有商品跨槽全局原子。

本章收束

核心结论

  • 秒杀用 Redis 前置快速资格判断与削峰。
  • Lua 原子完成库存、去重与 XADD。
  • Stream worker 用数据库幂等事务创建订单,再 ACK。
  • 数据库唯一约束和条件扣减是最终防线。
  • Redis 与数据库的一致性依靠重试、补偿和对账。

流程速记

1
2
3
HTTP → Lua(stock + ordered + XADD) → 202
Worker → DB idempotent transaction → XACK
failure → retry / dead-letter / reconcile / compensate

常见误区

  • Lua 只扣库存,随后客户端再 XADD。
  • 202 当作订单已成功。
  • 数据库不设唯一索引。
  • worker 失败就直接加回库存,不查提交状态。
  • 不监控死信和库存差异。

你可以这样阐述

秒杀先用同槽 Lua 在 Redis 中原子检查库存和重复购买,扣减后写入 Stream,接口返回已受理。worker 至少一次消费并在数据库事务中用 message_id、唯一索引和条件库存更新保证幂等,提交后 ACK;跨 Redis 与数据库的差异通过重试、死信、对账和补偿处理。


第六篇:从单机缓存走向生产级分布式系统

应用模式已经具备。下面回答:内存满了怎么办、机器重启怎么办、主节点故障怎么办、容量超过一台机器怎么办,以及多级缓存如何失效。

第 29 章:过期删除、内存淘汰和内存管理

29.1 过期与淘汰不是一回事

  • 过期 expiration:Key 自己设了 TTL,到时间后逻辑失效。
  • 淘汰 eviction:Redis 达到 maxmemory 后,为给新写入腾空间,根据策略删除某些 Key。

一个无 TTL 的 Key 不会“过期”,但 allkeys 淘汰策略可能删它。一个带 TTL 的 Key 即使未到期,也可能被淘汰。

29.2 惰性删除与主动过期

Redis 不给每个 Key 创建精确定时器,结合:

  • 访问时发现已过期并删除;
  • 后台周期抽样过期候选并删除。

因此 TTL 到零时逻辑上不可读,但内存回收和过期通知不保证恰好同一毫秒发生。大量 Key 同时过期仍会形成 CPU 和释放压力。

29.3 maxmemory

1
2
3
CONFIG GET maxmemory
CONFIG GET maxmemory-policy
INFO memory

maxmemory 是 Redis 用于数据集/相关内存管理的上限配置,但实际进程 RSS 还会受复制缓冲、客户端缓冲、内存碎片、fork 写时复制等影响,可能高于该值。容器内存限制必须留余量。

29.4 淘汰策略

常见策略族:

策略 候选范围 选择方式
noeviction 不淘汰 超限写入报错
allkeys-lru 所有 Key 近似最近最少使用
allkeys-lfu 所有 Key 近似最不常使用
allkeys-random 所有 Key 随机
volatile-lru 有 TTL 的 Key 近似 LRU
volatile-lfu 有 TTL 的 Key 近似 LFU
volatile-random 有 TTL 的 Key 随机
volatile-ttl 有 TTL 的 Key 更接近过期的优先

LRU 是 recency(多久没用),LFU 是 frequency(使用频率)。Redis 使用采样/近似算法,不维护昂贵的严格全局顺序。

选择:

  • 纯缓存实例:allkeys-lru 或 allkeys-lfu 常见。
  • 混有不可丢持久状态:不要指望 volatile 策略自动安全隔离,最好缓存与关键状态分实例。
  • noeviction:保护数据不被自动删,但写会失败,应用必须处理。

volatile 策略若没有合适 TTL 候选,写入仍可能失败。

29.5 缓存和会话混部风险

同一实例同时存:

  • 可随便淘汰的查询缓存;
  • 不能提前消失的登录会话;
  • Stream 消息;
  • 锁。

allkeys-lru 可能提前淘汰会话或队列;volatile 策略也无法提供业务可靠隔离。生产上按可靠性和淘汰语义拆实例/集群,是比巧配 policy 更清晰的方案。

29.6 内存碎片与 RSS

删除 Key 后,分配器可能保留内存页,Redis used_memory 下降而操作系统 RSS 不立即下降。指标:

1
2
3
INFO memory
MEMORY STATS
MEMORY DOCTOR

重点理解:

  • used_memory:Redis 统计使用内存。
  • used_memory_rss:操作系统看到的常驻内存。
  • mem_fragmentation_ratio:粗略碎片/额外内存线索,低或高都要结合绝对值。
  • allocator 指标:分配器视角。

不要看到 ratio > 1 就立刻重启;小数据集比例可能天然显得高。

29.7 fork 与写时复制峰值

RDB 保存或 AOF 重写会 fork 子进程。父子初始共享内存页;父进程继续写时发生 Copy-on-Write,修改页被复制,额外内存上升。写流量大、数据集大时,峰值可能触发 OOM。

容量规划要留:

  • 数据增长空间;
  • fork COW 峰值;
  • 复制和客户端缓冲;
  • 碎片;
  • 操作系统和容器余量。

29.8 OOM 与应用行为

达到内存上限时:

  • 淘汰策略有候选:淘汰后尝试写。
  • noeviction 或无候选:写命令报 OOM,读通常仍可能工作。

应用不能把 OOM 当缓存 miss。应告警并采取扩容、清理大 Key、调整 TTL 或流量降级,而不是无限重试写入。

本章收束

核心结论

  • 过期由 TTL 驱动,淘汰由内存压力驱动。
  • Redis 的 LRU/LFU 是近似策略。
  • 混合不同可靠性数据会让淘汰策略难以正确。
  • maxmemory 不是操作系统 RSS 的硬等号。
  • 持久化 fork 需要额外内存余量。

命令速记:INFO memory、MEMORY USAGE/STATS/DOCTOR、CONFIG GET maxmemory*。

常见误区

  • 认为只有到 TTL 才会删除。
  • maxmemory 等于进程绝不超过的 RSS。
  • allkeys-lru 实现严格 LRU。
  • 缓存、锁、会话和队列随便混部。

你可以这样阐述

Redis 过期是 Key TTL 到期,淘汰是达到 maxmemory 后按策略腾空间。LRU/LFU 是近似采样。生产容量还要考虑碎片、客户端与复制缓冲、RDB/AOF fork 的写时复制峰值;不同可靠性数据最好分实例。


第 30 章:RDB、AOF 与数据恢复

30.1 持久化解决什么

Redis 主要在内存操作。持久化把状态保存到磁盘,以便进程重启后恢复。它解决“重启恢复”,不等于:

  • 高可用自动切换;
  • 异地备份;
  • 任意故障零丢失;
  • 数据库强事务。

30.2 RDB 快照

RDB 在某个时间点生成紧凑快照文件。常见触发:配置的 save 规则、BGSAVE、关闭流程等。

1
2
3
BGSAVE
LASTSAVE
INFO persistence

大致过程:主进程 fork 子进程,子进程遍历快照并写临时文件,完成后替换旧 RDB;父进程继续服务,写时复制导致额外内存。

优点:

  • 文件紧凑,适合备份;
  • 恢复通常较快;
  • 对持续写入主路径影响相对可控。

缺点:

  • 故障时可能丢失最近一次快照之后的写入;
  • fork 和 COW 在大数据集上产生延迟/内存峰值;
  • 快照周期越密,资源成本越高。

SAVE 会同步阻塞服务器,生产通常不随意使用。

30.3 AOF 追加日志

AOF 记录改变数据集的命令效果,重启时重放恢复。

1
2
appendonly yes
appendfsync always | everysec | no
  • always:每次写尽量 fsync,耐久性更强但延迟成本高。
  • everysec:通常每秒 fsync,性能与丢失窗口折中。
  • no:交给操作系统决定刷盘,性能好但窗口更不可控。

即使 everysec,也不能说最多绝对只丢一秒;故障模型、磁盘和系统行为会影响。

30.4 AOF 重写

AOF 会增长。重写不是简单复制旧日志,而是根据当前数据集生成能重建相同状态的更小日志/前导数据。重写期间新写入还要记录增量,仍有 fork、IO 和内存成本。

1
2
BGREWRITEAOF
INFO persistence

不要在高峰随意同时触发重写、备份和大规模删除。

30.5 RDB + AOF 与混合前导

可同时启用 RDB 与 AOF。恢复时若 AOF 启用,Redis 通常优先用 AOF,因为它通常更新。现代版本 AOF 内部格式/清单机制可能包含 RDB 格式前导以兼顾恢复速度和增量日志。

不必死记文件实现细节,选择关注:

  • 可接受丢失窗口;
  • 恢复时长 RTO;
  • 磁盘与 fork 成本;
  • 数据是否可从数据库重建。

纯缓存可降低持久化要求;会话、Stream 或不可轻易重建状态则需更严设计。

30.6 正确关闭与恢复验证

1
2
SHUTDOWN SAVE
SHUTDOWN NOSAVE

它们是管理命令,生产由编排和运维流程控制。不要随意执行。

备份的价值要通过恢复演练验证:

  1. 复制持久化文件到独立安全位置。
  2. 校验完整性和保留周期。
  3. 在隔离环境启动恢复。
  4. 验证关键 Key、版本和业务一致性。
  5. 记录恢复时间。

只看到磁盘有文件不等于可恢复。

30.7 持久化与复制的危险组合

若主节点关闭持久化却自动重启为空数据,副本可能把自己同步为空主节点的数据。使用复制时,关闭主持久化和自动重启组合需要极谨慎。高可用章节还会讨论。

本章收束

核心结论

  • RDB 是时间点快照,AOF 是写操作日志。
  • 两者都有 fork、IO、丢失窗口和恢复权衡。
  • everysec 是常见折中,不是绝对零丢失。
  • 持久化不等于高可用与备份。
  • 备份必须定期恢复演练。

命令速记:BGSAVE、BGREWRITEAOF、LASTSAVE、INFO persistence、SHUTDOWN。

常见误区

  • 有 AOF 就绝不丢数据。
  • 有副本就不需要备份。
  • 在高峰同步 SAVE。
  • 只备份不恢复验证。

你可以这样阐述

RDB 定期生成数据快照,文件紧凑、恢复快但可能丢快照后的写;AOF 记录写操作,通常丢失窗口更小但日志和重写成本更高。两者都不等于高可用,生产还需复制、备份与恢复演练。


第 31 章:主从复制与读写分离

31.1 复制的角色

一个主节点(master/primary)接受写,副本(replica)复制其数据:

1
clients write → master → replication stream → replicas

现代文档常用 master/replica。副本可用于冗余、故障切换候选、备份/读扩展,但复制不是自动故障切换;Sentinel 或 Cluster 才负责检测和提升。

31.2 全量与部分同步

主节点用 replication ID 与 offset 标识复制历史。副本断开重连时:

  • 若主节点 backlog 仍保留缺失部分且历史匹配,进行部分同步,只补命令流。
  • 否则全量同步,主节点生成/传输数据快照,副本加载后再追增量。

复制 backlog 太小、断线太久会增加全量同步概率。全量同步消耗 CPU、网络、磁盘/内存,并可能让副本暂时不可服务。

查看:

1
2
INFO replication
ROLE

31.3 异步复制与丢失窗口

Redis 复制通常异步:主节点向客户端确认写时,副本可能尚未收到。主节点突然故障并提升落后的副本,已确认写可能丢失。

WAIT:

1
2
SET important:key value
WAIT 1 1000

请求等待至少一个副本确认处理到该写,最长 1000ms。它能显著降低某些故障下丢失概率,但不是强一致提交:副本确认后仍可能和主同时故障,故障切换选择也有条件。

较新版本 WAITAOF 可等待本地/副本 AOF 同步条件,但仍需看部署与版本语义,不能称绝对持久。

31.4 读写分离的一致性

从副本读可以分担读流量,但会读到旧数据:

1
客户端写主 → 立即读副本 → 副本尚未同步 → 读到旧值

这叫 read-after-write 不满足。解决选择:

  • 写后短时间读主;
  • 用版本号等检测;
  • WAIT 后再读,但仍不是绝对;
  • 对陈旧敏感数据始终读主;
  • 只让统计、推荐等可容忍陈旧的流量读副本。

缓存读副本还可能在删除缓存后短暂读到副本旧缓存,延长一致性窗口。

31.5 副本只读不等于权限安全

副本默认常为只读,但仍要网络认证和 ACL。只读配置主要防普通写,并不替代访问控制。副本也可能通过故障转移成为主,安全配置需一致。

31.6 复制延迟监控

观察:

  • master_link_status;
  • master_last_io_seconds_ago;
  • 主从 replication offset 差距;
  • backlog 大小;
  • 全量同步次数;
  • 网络带宽和副本加载状态。

业务指标还要看读旧值影响,不能只看链路为 up。

本章收束

核心结论

  • 复制提供数据副本,不自动切换。
  • 断线后能否部分同步取决于复制历史与 backlog。
  • 异步复制存在已确认写丢失和副本旧读窗口。
  • WAIT 只能降低风险,不提供绝对强一致。
  • 读副本要明确陈旧预算。

命令速记:INFO replication、ROLE、REPLICAOF、WAIT、WAITAOF(版本相关)。

常见误区

  • 主从等于自动高可用。
  • 副本读永远最新。
  • WAIT 等于共识提交。
  • 有副本就不需要持久化和备份。

你可以这样阐述

Redis 主从复制通常是异步的,副本断线后尽量用 backlog 部分同步,否则全量同步。副本提供冗余与读扩展,但可能落后,也不自动故障切换。WAIT 可等待副本确认以降低写丢失概率,却不等于强一致共识。


第 32 章:Sentinel 高可用

32.1 Sentinel 解决什么

Sentinel 用于非 Cluster 部署的高可用:

  • 监控主与副本;
  • 通知故障;
  • 判断主节点下线;
  • 选副本提升为新主;
  • 配置其他副本跟随新主;
  • 向客户端提供当前主地址。

它不做数据分片,容量仍受单主数据集限制。

32.2 典型拓扑

1
2
3
Redis master + replicas
Sentinel A + Sentinel B + Sentinel C
application uses Sentinel-aware client

Sentinel 自身也需要多个独立故障域。把三个 Sentinel 都放同一台机器,只增加进程数,不抗机器故障。

32.3 主观下线与客观下线

  • SDOWN 主观下线:一个 Sentinel 认为实例超时不可达。
  • ODOWN 客观下线:足够数量 Sentinel 同意主节点不可达。

配置示意:

1
2
3
4
sentinel monitor mymaster 10.0.0.10 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
sentinel parallel-syncs mymaster 1

最后的 2 是 quorum,用于 ODOWN 判断。真正授权故障转移还需要 Sentinel 多数派参与选举。quorum 与多数派不是完全同一概念。

32.4 故障转移过程

简化流程:

  1. Sentinel 发现主节点主观下线。
  2. 达到 quorum,形成客观下线。
  3. Sentinel 选出负责本次 failover 的领导者。
  4. 按复制偏移、优先级等选择合适副本。
  5. 对它执行不再复制旧主,提升为主。
  6. 让其他副本跟随新主。
  7. 客户端通过 Sentinel 获取新主并重连。
  8. 旧主恢复后通常被配置为新主的副本。

切换不是瞬间无错误,应用必须处理短暂连接失败、超时和重连。

32.5 redis-py Sentinel 客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from redis.asyncio.sentinel import Sentinel


sentinel = Sentinel(
[
("sentinel-a", 26379),
("sentinel-b", 26379),
("sentinel-c", 26379),
],
socket_timeout=1.0,
)

redis_master = sentinel.master_for(
"mymaster",
decode_responses=True,
)

redis_replica = sentinel.slave_for(
"mymaster",
decode_responses=True,
)

应用不能把旧主 IP 写死,否则 Sentinel 切换后仍连旧地址。真实参数按 redis-py 版本和认证配置调整。

32.6 脑裂与写丢失

网络分区时,旧主可能仍被部分客户端访问并接受写;多数 Sentinel 在另一侧提升新主。分区恢复后旧主被降为副本,它在分区期间独有的写会被丢弃。

可配置主节点在可用副本数量或延迟不满足时拒绝写:

1
2
min-replicas-to-write 1
min-replicas-max-lag 10

这缩小风险,但牺牲网络分区时可用性,也不能保证零丢失。

32.7 Docker/NAT 与时间

Sentinel 自动发现依赖节点报告的地址。Docker 端口映射、NAT、跨网络 DNS 配错会使它发现不可达地址。Sentinel 也依赖时间判断超时,严重时钟跳变或进程暂停会触发保护行为。

高可用必须在真实网络拓扑做故障演练,而不是只看配置文件。

本章收束

核心结论

  • Sentinel 为非分片 Redis 提供监控、发现和自动故障转移。
  • quorum 用于客观下线,故障转移授权还依赖多数派。
  • 应用必须使用 Sentinel-aware 客户端并处理切换窗口。
  • 异步复制意味着切换仍可能丢已确认写。
  • Sentinel 不解决容量水平扩展。

命令/配置速记:SENTINEL MASTER/REPLICAS/SENTINELS、sentinel monitor、down-after、failover-timeout。

常见误区

  • 三个 Sentinel 放一台机。
  • 客户端仍写死主节点地址。
  • 把 quorum 等同于所有选举多数规则。
  • 认为 Sentinel 切换零错误、零丢数据。

你可以这样阐述

Sentinel 监控非 Cluster 主从拓扑,在足够 Sentinel 判断主节点下线后选举并提升副本,客户端通过 Sentinel 发现新主。它提高可用性但不分片,且底层仍是异步复制,因此故障切换窗口可能报错或丢最近写入。


第 33 章:Redis Cluster

33.1 Cluster 解决什么

Sentinel 解决单主数据集的自动切换,Cluster 进一步把 Key 分到多个主节点:

1
2
3
4
5
16384 个 hash slots
主节点 A 管一部分槽
主节点 B 管一部分槽
主节点 C 管一部分槽
每个主可有副本

它同时提供数据分片和节点故障转移,但运维与命令限制更复杂。

33.2 Key 如何找到槽

概念公式:

1
slot = CRC16(key) mod 16384

若 Key 含合法花括号片段,只对第一个有效 hash tag 内容计算:

1
2
seckill:{1001}:stock
seckill:{1001}:ordered

两者同槽。Hash tag 用于多 Key、Lua 和事务需要同槽的场景,不要把所有 Key 都写成相同 tag,否则所有流量落一个槽,失去分片意义。

33.3 MOVED 与 ASK

客户端向错误节点发命令:

  • MOVED:槽稳定属于另一个节点,客户端应更新槽位映射。
  • ASK:迁槽过程中临时去目标节点,并先发送 ASKING。

普通 Redis 客户端若不理解这些重定向,应用会报错。必须使用 cluster-aware 客户端维护拓扑、处理重定向和故障切换。

33.4 多 Key 与跨槽

1
2
3
MGET a b
SUNION set:a set:b
RENAME old new

Cluster 中这些命令通常要求所有 Key 同槽,否则 CROSSSLOT。事务、Lua 脚本访问的 Key 也要同槽。

选择:

  • 用 hash tag 将同一聚合根的少量相关 Key 同槽;
  • 客户端拆成每节点独立请求,再在应用合并,但失去全局原子;
  • 改数据模型;
  • 真正跨分片强事务交给其他系统。

33.5 扩缩容与迁槽

增加节点不会自动让每个 Key 自己重新哈希搬家;运维要把部分槽从旧节点迁给新节点。迁移期间客户端可能收到 ASK,完成后收到 MOVED。

风险:

  • 大 Key 迁移慢;
  • 热槽迁移造成网络压力;
  • 槽数均匀不等于内存或 QPS 均匀;
  • 迁移时延迟和重定向增加。

扩容前先处理大 Key 和数据倾斜。

33.6 故障转移

Cluster 各主节点通常配置副本。多数可用主节点判断某主故障后,可提升其副本。若负责一部分槽的主及副本都不可用,集群可能无法完整服务。

Cluster 同样使用异步复制,切换可能丢最近写。它不是强一致数据库。

33.7 热点槽与分片悖论

一个爆款秒杀商品的相关 Key 必须同槽才能用 Lua 原子操作,结果所有流量集中一个节点。

如果库存拆为 100 个桶分散槽,可以扩写吞吐,却难以保证全局精确库存和单用户去重原子,需要更复杂聚合/分配协议。分布式系统中“全局原子”和“完全水平扩展”常冲突。

33.8 Cluster、Sentinel 还是客户端分片

方案 自动切换 数据分片 多 Key 约束 运维复杂度
单机
主从 + Sentinel
Redis Cluster 同槽限制
客户端分片 自行设计 应用处理

如果数据能放单主且主要需求是高可用,Sentinel 更简单;容量或写吞吐必须跨节点,考虑 Cluster 或托管集群。

本章收束

核心结论

  • Cluster 将 16384 槽分给多个主节点。
  • hash tag 控制相关 Key 同槽。
  • MOVED 是稳定重定向,ASK 是迁槽临时重定向。
  • 多 Key、事务和 Lua 受同槽限制。
  • 槽位均匀不等于内存和流量均匀。

命令速记:CLUSTER INFO/NODES/SLOTS/SHARDS/KEYSLOT,redis-cli --cluster(管理工具)。

常见误区

  • 使用不懂 Cluster 的客户端。
  • 所有 Key 使用同一 hash tag。
  • 认为加节点后大 Key 自动均匀迁移。
  • 认为 Cluster 自动提供强一致。

你可以这样阐述

Redis Cluster 把 16384 个槽分布到多个主节点,实现容量和吞吐分片,并用副本故障转移。客户端必须理解 MOVED/ASK。多 Key、事务和 Lua 要求同槽,可用 hash tag 控制,但同槽也可能形成热点。


第 34 章:分布式缓存架构

34.1 演进路径

1
2
3
4
5
进程内缓存
→ 单 Redis
→ 主从 + Sentinel
→ Redis Cluster / 托管集群
→ 跨区域分层与灾备

不是所有系统都要走到最后。架构按容量、可用性目标、团队能力和成本演进。

34.2 单实例

优点:简单、命令限制少、成本低。缺点:单点、容量和维护停机问题。适合开发、小流量、可丢缓存,前提是数据库能承受降级。

34.3 主从 + Sentinel

数据不需分片但要求自动切换时使用。读取可分担到副本但有陈旧。需要多个故障域、Sentinel-aware 客户端、切换演练和持久化/备份。

34.4 Cluster

容量或写吞吐超过单主时分片。需要:

  • 设计 Key 槽;
  • 限制跨槽操作;
  • 监控每节点内存/QPS/带宽;
  • 规划扩缩容与迁槽;
  • 处理部分槽不可用;
  • 对脚本和 Pipeline 做 Cluster 适配。

34.5 托管服务

云服务可代管补丁、监控、备份、故障切换等,但不替应用解决:

  • Key 和数据结构设计;
  • 缓存一致性;
  • 热 Key 与大 Key;
  • 幂等和消息语义;
  • 成本和网络延迟;
  • 云产品与开源 Redis 的版本/命令差异。

34.6 分布式缓存请求路径

1
2
3
4
5
6
FastAPI 实例
→ 本地连接池
→ Cluster 路由目标节点
→ hit 返回
→ miss 访问数据库
→ 回填目标槽

每个应用实例都有连接池。实例数 × 每实例最大连接数可能非常大,容量规划不能只看单实例配置。

34.7 可用性与一致性

缓存系统通常倾向可用和低延迟,允许有限陈旧。但认证撤销、价格、权限等业务可能不同。按数据分类:

数据 陈旧容忍 故障策略
商户展示信息 秒/分钟 回源或旧值
推荐列表 较高 空/默认降级
登录会话 fail-closed
权限撤销 很低 权威源或短 TTL
秒杀资格 很低 拒绝,不绕过

一个 Redis 集群承载所有数据,会让可用性和淘汰策略互相冲突。按业务等级拆分是生产设计核心。

34.8 跨地域

跨地域网络延迟和分区更明显。不要简单承诺多地 Redis 实时强一致。常见方向:

  • 每地域独立缓存,从本地权威/复制数据回填;
  • 缓存失效异步传播,接受窗口;
  • 用户/租户按地域归属;
  • 强一致事实集中到具备相应能力的数据层;
  • 跨地域灾备明确 RPO/RTO。

34.9 容量估算

粗略考虑:

1
2
3
4
5
数据内存
= Key + value + 对象/结构开销 + 分配器开销

实例容量预算
= 数据增长 + 碎片 + fork COW + 缓冲 + 安全余量

通过抽样 MEMORY USAGE 和真实数据压测估算,而不是只相加 JSON 字符数。

本章收束

核心结论

  • 分布式缓存是架构权衡,不只是部署更多 Redis。
  • Sentinel 解决单主高可用,Cluster 解决分片容量并带高可用。
  • 托管服务不替代数据建模和一致性设计。
  • 数据按可靠性与淘汰语义分级、分实例。
  • 跨地域必须明确陈旧窗口与 RPO/RTO。

常见误区

  • 小系统一开始就上复杂 Cluster。
  • 只算 value 字节做容量规划。
  • 所有业务共用同一淘汰策略。
  • 把托管服务等同于无需故障演练。

你可以这样阐述

分布式缓存从单机、Sentinel 到 Cluster 逐步增加高可用和容量,但也引入异步复制、跨槽和运维复杂度。选型要按数据规模、陈旧容忍、故障策略和团队能力,而非一味追求节点数量。


第 35 章:多级缓存

35.1 为什么还要 L1

即使 Redis 很快,每次请求仍要获取连接、网络往返、执行和反序列化。极热点数据可先放在应用进程内:

1
2
3
L1:FastAPI 进程内缓存,最快、容量小、各进程独立
L2:Redis,跨实例共享
L3:数据库,权威事实源

读取:

1
2
3
4
5
请求 → L1 hit
↓ miss
Redis hit → 回填 L1
↓ miss
DB → 回填 Redis → 回填 L1

35.2 L1 的本质限制

假设 4 个进程 × 3 台机器,共 12 份 L1。修改一个商户时,需要让 12 份副本都失效,否则每份直到 TTL 才更新。

L1 进程退出即消失,扩容时新进程冷启动。它没有跨进程共享,也不受 Redis maxmemory 管理。

因此 L1 只适合:

  • 极热点;
  • value 较小;
  • 允许短期陈旧;
  • 有严格容量、TTL 和失效兜底。

35.3 简化 L1 实现

生产可使用带 TTL 与 LRU 的成熟库。为了理解,写一个有界版本:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import asyncio
import time
from collections import OrderedDict
from dataclasses import dataclass
from typing import Generic, TypeVar

T = TypeVar("T")


@dataclass
class LocalEntry(Generic[T]):
value: T
expires_at: float
version: int


class LocalTTLCache(Generic[T]):
def __init__(self, maxsize: int = 1000) -> None:
self.maxsize = maxsize
self._items: OrderedDict[str, LocalEntry[T]] = OrderedDict()
self._lock = asyncio.Lock()

async def get(self, key: str) -> LocalEntry[T] | None:
async with self._lock:
entry = self._items.get(key)
if entry is None:
return None
if entry.expires_at <= time.monotonic():
self._items.pop(key, None)
return None
self._items.move_to_end(key)
return entry

async def set(
self,
key: str,
value: T,
ttl_seconds: float,
version: int,
) -> None:
async with self._lock:
self._items[key] = LocalEntry(
value=value,
expires_at=time.monotonic() + ttl_seconds,
version=version,
)
self._items.move_to_end(key)
while len(self._items) > self.maxsize:
self._items.popitem(last=False)

async def delete(self, key: str) -> None:
async with self._lock:
self._items.pop(key, None)

进程内 TTL 用 time.monotonic,不受系统时钟向后调整影响。

35.4 L2 value 带版本

1
2
3
class VersionedShop(BaseModel):
version: int
data: ShopDTO

数据库可用 updated_at 版本、递增 version 或变更序号。版本帮助判断失效消息乱序:只删除/更新比本地版本新的事件。

35.5 完整读取

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
34
35
36
37
38
39
40
41
42
class MultiLevelShopCache:
def __init__(
self,
local: LocalTTLCache[ShopDTO],
redis: Redis,
repository: ShopRepository,
) -> None:
self.local = local
self.redis = redis
self.repository = repository

async def get(self, shop_id: int) -> ShopDTO | None:
key = f"cache:v1:shop:{shop_id}"

local_entry = await self.local.get(key)
if local_entry is not None:
return local_entry.value

raw = await self.redis.get(key)
if raw is not None:
wrapped = VersionedShop.model_validate_json(raw)
await self.local.set(
key,
wrapped.data,
ttl_seconds=10,
version=wrapped.version,
)
return wrapped.data

shop = await self.repository.get_by_id(shop_id)
if shop is None:
return None

version = int(shop.updated_at.timestamp() * 1_000_000)
wrapped = VersionedShop(version=version, data=shop)
await self.redis.set(
key,
wrapped.model_dump_json(),
ex=300,
)
await self.local.set(key, shop, ttl_seconds=10, version=version)
return shop

真实实现还需 Redis 故障降级、空值、互斥重建和指标。L1 TTL 通常远短于 L2,以限制失效消息丢失的陈旧时间。

35.6 更新与失效广播

更新数据库提交后:

  1. 删除 L2 Redis。
  2. 删除当前进程 L1。
  3. 向失效频道/Stream 发 {key, version}
  4. 其他进程收到后删除 L1。

Pub/Sub:低延迟但离线丢失。由于 L1 还有短 TTL,丢一次通知只会陈旧有限时间,常可接受。

Stream:能补消费但每个应用实例都要收到失效,不能把所有实例放同一组分担;需要每实例独立消费组或广播设计,管理成本显著增加。

版本拉取:L1 value 带版本,定期或读取时与 Redis 版本比较,更可靠但增加 Redis 请求,削弱 L1 价值。

常用折中:Pub/Sub 快速失效 + 短 L1 TTL 兜底 + 版本处理乱序。

35.7 防止缓存回填竞态

时序:

1
2
3
A 读 DB 旧版本 10
B 更新 DB 为版本 11,发失效
A 晚到,把版本 10 回填 L1/L2

可用版本化 Key/value:写入回填前比较当前版本,或数据库提交后删除策略配合短 TTL和重建锁。多级缓存会放大一致性窗口,不能只增加层数不增加版本/失效设计。

35.8 缓存层越多越好吗

每多一层:

  • 命中更快;
  • 状态副本更多;
  • 指标更多;
  • 失效路径更复杂;
  • 内存成本和调试难度上升。

普通业务 Redis 已足够。只有经过指标确认 Redis 网络/热 Key 是瓶颈,再引入 L1。

本章收束

核心结论

  • 多级缓存通常是进程 L1 + Redis L2 + 数据库。
  • 每个 FastAPI worker 有独立 L1。
  • L1 必须有容量上限和短 TTL。
  • 失效可用 Pub/Sub 快速通知、TTL 兜底和版本防乱序。
  • 多一层缓存就多一层一致性复杂度。

流程速记

1
2
read: L1 → L2 → DB → backfill L2/L1
write: DB commit → DEL L2 → DEL local L1 → broadcast invalidate

常见误区

  • 认为多 worker 共享 Python dict。
  • L1 无 maxsize 或 TTL。
  • 仅靠 Pub/Sub 且永不过期。
  • 更新只删除 Redis,不通知 L1。
  • 无指标就盲目增加缓存层。

你可以这样阐述

多级缓存用进程内 L1 吸收极热点,Redis L2 在实例间共享,数据库作为事实源。由于每个 worker 都有独立 L1,更新时需删除 L2 并传播 L1 失效;常用 Pub/Sub 低延迟通知、短 TTL 兜底和版本号处理乱序。


第七篇:把系统拼起来并看懂它

最后一篇不再引入新的核心数据结构,而是把前文整理为可维护项目,并建立测试、性能、监控、安全和口述知识地图。

第 36 章:综合项目拼装

36.1 目录结构

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
34
redis_fastapi_lab/
├── app/
│ ├── main.py
│ ├── core/
│ │ ├── config.py
│ │ └── redis.py
│ ├── db/
│ │ ├── session.py
│ │ └── models.py
│ ├── schemas/
│ │ ├── auth.py
│ │ ├── shop.py
│ │ └── order.py
│ ├── repositories/
│ │ └── shops.py
│ ├── services/
│ │ ├── auth.py
│ │ ├── shop_cache.py
│ │ ├── lock.py
│ │ └── orders.py
│ ├── routers/
│ │ ├── auth.py
│ │ ├── shops.py
│ │ └── seckill.py
│ ├── workers/
│ │ └── orders.py
│ └── lua/
│ ├── verify_code.lua
│ ├── release_lock.lua
│ └── seckill.lua
├── tests/
├── docker-compose.yml
├── pyproject.toml
└── .env.example

边界:

  • core:配置和共享基础设施生命周期。
  • repositories:关系型数据库访问。
  • services:缓存、认证、锁和订单业务。
  • routers:HTTP 输入输出,不堆 Redis 细节。
  • workers:独立长运行消费者。
  • lua:服务器端原子业务。

36.2 最小依赖

1
2
3
4
5
6
7
8
9
10
11
12
[project]
name = "redis-fastapi-lab"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi",
"uvicorn[standard]",
"redis",
"sqlalchemy[asyncio]",
"aiosqlite",
"pydantic-settings",
]

生产数据库可替换为 PostgreSQL/MySQL 对应异步驱动。SQLite 仅降低教学门槛,不代表其并发锁、隔离级别与生产数据库相同。

36.3 应用入口

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
# app/main.py
from contextlib import asynccontextmanager
from typing import AsyncIterator

from fastapi import FastAPI
from redis.asyncio import Redis

from app.core.config import get_settings
from app.routers import auth, seckill, shops


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
app.state.redis = Redis.from_url(
settings.redis_url,
decode_responses=True,
max_connections=settings.redis_max_connections,
socket_connect_timeout=1,
socket_timeout=1,
health_check_interval=30,
)
try:
yield
finally:
await app.state.redis.aclose()


app = FastAPI(title="Redis FastAPI Lab", lifespan=lifespan)
app.include_router(auth.router)
app.include_router(shops.router)
app.include_router(seckill.router)

36.4 三条端到端路径

登录

1
2
3
4
5
6
7
8
9
10
POST /auth/code
→ 发送冷却 SET NX EX
→ 模拟 SMS
→ SET 验证码 EX

POST /auth/login
→ Lua 校验并消费
→ DB 查/建用户
→ HSET 会话 + EXPIRE
→ Token

查询商户

1
2
3
4
5
6
GET /shops/42
→ L1(启用时)
→ GET cache:v1:shop:42
→ DB repository
→ SET EX 回填
→ DTO

秒杀

1
2
3
4
5
6
7
POST /vouchers/1001/seckill
→ Bearer 会话
→ Lua stock + ordered + XADD
→ 202 accepted
→ Order worker XREADGROUP
→ DB 幂等事务
→ XACK

36.5 统一 Key 清单

1
2
3
4
5
6
7
8
9
10
login:code:{phone}:value
login:code:{phone}:errors
login:code:send:{phone}
login:session:{token}
cache:v1:shop:{shop_id}
cache:shop:lock:{shop_id}
seckill:{voucher_id}:stock
seckill:{voucher_id}:ordered
seckill:{voucher_id}:orders
stream:orders:dead

大括号在验证码和秒杀 Key 中表示 Cluster hash tag;在文档占位符语境中也代表替换值。实际生成结果不能保留字面 {phone},要替换为规范化值。

第 27 章的 stream:orders 是通用单 Stream 模型;第 28 章的 seckill:{voucher_id}:orders 是为了让秒杀 Lua 在 Cluster 中与该券库存、已购集合保持同槽。真实项目要明确选定一种分区模型,并让 worker、死信和监控使用同一命名方案。

36.6 启动顺序概念

不展开通用环境安装,只列逻辑:

  1. 启动 Redis 与数据库。
  2. 初始化数据库表。
  3. 启动 FastAPI web。
  4. 初始化 Stream 消费组。
  5. 启动独立 order worker。
  6. 配置监控与日志。

生产中 worker 与 web 分开扩缩容,部署前运行数据库迁移,密钥来自密钥系统。

36.7 教学项目距离生产还有什么

  • 真短信供应商和回执;
  • 数据库迁移、连接池和事务隔离配置;
  • API 网关、TLS、WAF、限流与审计;
  • 完整结构化日志、指标和追踪;
  • Redis Sentinel/Cluster 客户端配置;
  • 配置中心与密钥管理;
  • 可靠 outbox/CDC;
  • 备份、容灾和演练;
  • 真实压测与容量计划。

本章收束

核心结论

  • HTTP、业务服务、数据库访问、worker 和 Lua 各有清晰边界。
  • web 请求和阻塞 worker 使用不同运行生命周期。
  • 三条核心路径共享前文 Key 与序列化约定。
  • 教学代码展示核心机制,不等于可直接上线。

常见误区

  • 所有代码写进 main.py
  • worker 绑定在每个 web 进程里。
  • SQLite 行为泛化为生产数据库。
  • 漏掉迁移、观测、安全和恢复设施。

你可以这样阐述

项目将 FastAPI 路由、Redis/数据库服务、Repository、Lua 与独立 worker 分层。登录使用短期验证码和 Redis 会话,商户读取使用 Cache-Aside,秒杀通过 Lua 原子入 Stream,再由幂等 worker 落库。生产还需安全、监控、迁移与容灾设施。


第 37 章:测试策略

本文没有实际运行代码。本章说明你把方案落地后应如何验证,不能把这些建议当成已经通过的测试结果。

37.1 测试层次

  1. 纯单元测试:KeyBuilder、序列化、返回码映射、业务分支。
  2. 真实 Redis 集成测试:TTL、Lua、事务、Stream、阻塞和错误类型。
  3. FastAPI 接口测试:认证、404、降级、202 等 HTTP 语义。
  4. 数据库集成测试:唯一约束、事务回滚、幂等。
  5. 并发测试:锁、验证码一次性、秒杀超卖、缓存重建。
  6. 故障测试:Redis 重启、网络延迟、worker 崩溃、主从切换。

Fake Redis 可以测试方法调用和简单逻辑,但不能可靠模拟 Lua、真实过期时序、网络断开、Cluster、复制和阻塞公平性。关键语义必须用目标版本真实 Redis。

37.2 测试隔离

不要对共享 Redis 执行 FLUSHALL。每次测试用唯一前缀:

1
2
3
4
5
import secrets


def test_prefix() -> str:
return f"test:{secrets.token_hex(8)}"

测试结束 SCAN 此精确前缀并 UNLINK,或为测试启动独立 Redis 容器/实例。Cluster 不支持 SELECT 多逻辑 DB,因此前缀/独立实例更通用。

37.3 TTL 测试

不要只 sleep 一个精确秒数然后断言,CI 调度会抖动。可以:

  • 断言 TTL 在合理区间;
  • 使用短 TTL 并轮询到上限;
  • 逻辑过期把时钟作为依赖注入;
  • 分开验证 Redis 真实过期与业务时间计算。

37.4 并发不变量

短信验证码:100 个并发提交正确验证码,最多一个返回成功。

锁:临界区并发计数最大值应为 1;还要测试租期过期后旧持有者行为。

秒杀:初始库存 100,1000 并发请求后:

  • Redis 成功受理不超过 100;
  • 同用户最多一个;
  • worker 后数据库订单不超过 100;
  • 唯一约束无重复;
  • Redis、Stream、DB 差异可解释并可对账。

37.5 Stream 故障测试

  1. worker 读取消息后、DB 提交前终止:消息留 PEL,接管后正常处理。
  2. DB 提交后、XACK 前终止:重复投递,幂等表阻止重复订单。
  3. 永久坏 payload:超过次数进入死信。
  4. 消费者长期离线:lag 告警。
  5. 裁剪边界:确保保留时间覆盖恢复目标。

37.6 缓存故障测试

  • Redis GET 超时:受保护回源。
  • Redis SET 失败:业务结果仍返回,但指标告警。
  • 数据库也慢:限流/熔断,不无限堆积。
  • 坏 JSON:删除并回源,不返回 500 污染所有请求。
  • 大量同 Key miss:重建次数有界。
  • 更新后缓存失效失败:可靠重试最终删除。

本章收束

核心结论

  • 核心 Redis 语义要用真实实例做集成测试。
  • 测试清理用独立实例或唯一前缀,不清共享库。
  • 并发测试验证不变量,不只看状态码。
  • 故障测试要覆盖 DB commit 与 XACK 两侧窗口。

常见误区

  • Fake Redis 通过就声称 Lua/Cluster 正确。
  • TTL 测试依赖精确 sleep。
  • 只测成功路径。
  • 并发测试不检查数据库约束和对账。

你可以这样阐述

Redis 项目测试要分纯逻辑、真实 Redis 集成、接口、数据库、并发和故障层。重点验证不变量和崩溃窗口,例如验证码最多消费一次、秒杀不超库存、数据库提交后 ACK 前崩溃仍由幂等消除重复。


第 38 章:性能、压测和优化

38.1 先测业务链路,不只测 Redis

redis-benchmark 能测特定命令在特定环境的基准:

1
2
redis-benchmark -t get,set -n 100000 -c 50
redis-benchmark -t get,set -n 100000 -c 50 -P 16
  • n:请求数。
  • c:并发连接。
  • P:Pipeline 深度。

它不能代表 FastAPI 的 JSON、认证、数据库回源、连接池和业务 Lua。基准工具的高 QPS 不等于接口达到同样 QPS。

38.2 四个核心指标

  • 吞吐:每秒操作/请求数。
  • 延迟分位数:p50、p95、p99、p999;平均值会掩盖尾延迟。
  • 错误率:超时、连接失败、OOM、MOVED/CROSSSLOT 等。
  • 资源:CPU、内存、网络、磁盘、连接数。

缓存还要看:命中率、回源率、重建并发、value 字节;Stream 看 lag、pending、处理时长和死信。

38.3 优化顺序

  1. 消除错误数据结构和无界命令:KEYS、大集合全量返回。
  2. 控制大 Key/value:减少字节比微调客户端更有价值。
  3. 减少网络往返:MGET、批量、Pipeline、Lua。
  4. 连接复用和超时:避免创建连接风暴。
  5. 序列化:避免重复解析、过大 JSON。
  6. 热 Key 与分片:L1、请求合并、重建保护。
  7. 硬件/拓扑:Redis 与应用网络距离、CPU、网卡。

不要一开始就调整底层参数,而忽略一次 HGETALL 返回几十 MB。

38.4 慢命令与慢系统

Redis 命令本身快,应用仍可能慢:

  • 连接池排队;
  • DNS/TLS/跨地域网络;
  • 客户端事件循环被同步代码阻塞;
  • JSON 反序列化大 value;
  • 数据库回源慢;
  • Python GC 或进程 CPU 满。

需要端到端 tracing:

1
2
3
4
5
6
HTTP total
├─ acquire redis connection
├─ redis command RTT
├─ deserialize
├─ database fallback
└─ response encode

38.5 Pipeline 压测

Pipeline 深度越大,吞吐通常上升,但单批内存和其他请求等待也上升。测试多个深度并观察 p99,而不是只选最高 QPS。

生产在线接口和离线批处理应使用不同连接/限额,避免大批处理淹没交互请求。

38.6 持久化和后台任务影响

压测要覆盖:

  • BGSAVE/AOF 重写时;
  • 副本全量同步;
  • Cluster 迁槽;
  • 大量 Key 同时过期;
  • Stream 裁剪和积压恢复;
  • 网络抖动与故障切换。

平稳空闲环境的峰值数字对生产意义有限。

38.7 不要给出脱离环境的性能承诺

“Redis 能扛十万 QPS”缺少命令、value、Pipeline、持久化、硬件、网络、分位延迟和错误率,几乎没有决策价值。正确描述:

1
2
在指定实例、GET 1KB value、并发 N、无/有 Pipeline、持久化配置 X 下,
达到吞吐 Y,p99 Z,错误率 E,CPU/带宽为 ...

本章收束

核心结论

  • redis-benchmark 不是业务接口压测。
  • 优化同时看吞吐、尾延迟、错误率和资源。
  • 先消除无界命令、大 value 和网络往返。
  • Pipeline 需要在吞吐与公平/内存间取舍。
  • 压测必须包含后台持久化和故障场景。

常见误区

  • 只看平均延迟或最高 QPS。
  • benchmark 用小 value 推断真实大 JSON。
  • 无限增大连接池和 Pipeline。
  • 空闲环境结果直接当生产容量。

你可以这样阐述

Redis 性能评估要明确命令、value、并发、网络、持久化和 Pipeline,并观察 p99 与错误率。优化优先处理无界命令、大 Key和网络往返,再调连接与架构;业务链路还需包含序列化和数据库回源。


第 39 章:监控与故障排查

39.1 排障框架

1
现象 → 指标 → 假设 → 最小验证 → 临时缓解 → 根因修复 → 复盘

不要看到 Redis 报错就先重启。重启可能造成缓存雪崩、复制全量同步和证据丢失。

39.2 INFO

1
2
3
4
5
6
7
8
9
INFO server
INFO clients
INFO memory
INFO stats
INFO persistence
INFO replication
INFO cpu
INFO commandstats
INFO keyspace

重点:

  • connected_clients、blocked_clients;
  • used_memory、RSS、碎片、evicted_keys;
  • keyspace_hits/misses;
  • expired_keys;
  • instantaneous_ops_per_sec;
  • rejected_connections;
  • AOF/RDB 状态;
  • 复制链路与 offset;
  • 每命令调用和耗时统计。

命中率要结合业务窗口计算,Redis 累计计数可能跨很长时间。

39.3 SLOWLOG

1
2
3
SLOWLOG LEN
SLOWLOG GET 20
SLOWLOG RESET

Slow Log 记录命令在服务器执行阶段超过阈值的部分,通常不含完整网络排队与响应传输,因此应用慢而 SLOWLOG 空并不矛盾。

命令参数可能含敏感数据,访问和导出慢日志要脱敏。

39.4 LATENCY、CLIENT 与 MONITOR

1
2
3
4
5
LATENCY LATEST
LATENCY DOCTOR
CLIENT LIST
CLIENT INFO
CLIENT KILL ...

用于观察事件延迟、客户端连接与异常阻塞。CLIENT KILL 是破坏性操作,先确认目标。

1
MONITOR

MONITOR 实时输出命令,开销和敏感信息风险很大,不应在繁忙生产长期运行。优先使用指标、慢日志和采样。

39.5 症状矩阵

症状 先看 常见原因 缓解方向
延迟突然升高 p99、SLOWLOG、CPU、fork 大命令、持久化、网络 限流、停大批次、处理大 Key
内存持续涨 used_memory、keyspace、TTL 无 TTL、Stream/List 无界 加保留、清理、扩容
RSS 高于 used 碎片、COW 删除后碎片、fork 写入 留余量、评估碎片整理/滚动替换
命中率下降 hits/misses、发布版本 Key 版本切换、雪崩 分批预热、抖动、回源保护
连接耗尽 clients、池等待 泄漏、阻塞消费共池 关闭泄漏、分池、合理容量
evicted_keys 增长 maxmemory policy 容量不足、TTL 不合理 扩容、缩 value、调策略
Stream 积压 group lag、PEL worker 慢/坏消息 扩 worker、修错误、死信
复制延迟 offsets、网络 大写流量、全量同步 带宽、backlog、拆大 Key

39.6 三个案例

案例一:缓存命中率突然降到 0

检查应用是否发布了 cache:v2 但未预热;Redis Key 仍在 v1。临时分批回填或兼容读旧版本,根治是版本切换计划。

案例二:Redis 命令 p99 高但 SLOWLOG 没记录

可能是客户端连接池等待、跨地域网络、响应 value 很大或事件循环阻塞。拆分 acquire、socket RTT 和 deserialize 指标。

案例三:删除大 Hash 后卡顿

DEL 同步释放占主线程。临时避免重复删除,后续使用 UNLINK、分批处理和大 Key 预防。

39.7 故障时保护下游

Redis 故障时缓存 miss 暴增:

  • 对数据库设置并发 semaphore;
  • 按接口限流;
  • 返回可接受旧数据;
  • 非核心功能降级;
  • 熔断超时下游;
  • 防止客户端无限重试同步风暴。

恢复 Redis 后也要渐进预热,避免所有请求同时重建。

本章收束

核心结论

  • 从业务症状和端到端指标出发,不盲目重启。
  • INFO、SLOWLOG、LATENCY 和 CLIENT 各观察不同层次。
  • SLOWLOG 不含完整客户端体验。
  • MONITOR 高风险,只做短时谨慎诊断。
  • Redis 故障的第一目标之一是保护数据库。

命令速记:INFO、SLOWLOG、LATENCY、CLIENT LIST、MEMORY、MONITOR(慎用)。

常见误区

  • SLOWLOG 空就断言 Redis 没问题。
  • 长期开 MONITOR。
  • 故障时所有请求无上限回源和重试。
  • 未保存指标就重启丢失证据。

你可以这样阐述

Redis 排障按现象、指标、假设和验证推进。INFO 看整体资源与复制,SLOWLOG 看服务端执行慢命令,LATENCY 看事件,CLIENT 看连接。应用端还要拆连接池等待、网络和反序列化;缓存故障时优先保护数据库并渐进恢复。


第 40 章:安全、上线检查与知识地图

40.1 安全基线

  1. Redis 不直接暴露公网,只允许所需应用网络访问。
  2. 使用 ACL 独立应用用户和最小权限,不共用管理员账号。
  3. 跨不可信网络使用 TLS。
  4. 密码/证书进入密钥系统,不进源码、镜像和日志。
  5. 限制 CONFIG、FLUSH、DEBUG、MODULE、SHUTDOWN 等危险命令。
  6. Key 与日志不泄露验证码、Token、完整手机号和敏感 payload。
  7. 及时更新受支持版本并审阅安全公告。
  8. 备份加密、限制访问并做恢复演练。

“Redis 有密码”不是完整安全方案。网络隔离、ACL、TLS、密钥轮换和审计共同构成边界。

40.2 上线检查清单

数据与命令

  • Key 命名、TTL、最大 value/集合规模有上限。
  • 不在线使用 KEYS、全量大集合和危险命令。
  • Lua 最坏运行时间有界。
  • Cluster 多 Key 同槽规则已设计。

内存与持久化

  • maxmemory、淘汰策略符合数据等级。
  • 缓存与关键会话/消息是否应拆实例。
  • RDB/AOF 策略符合 RPO/RTO。
  • 容器内存为碎片、fork COW 和缓冲留余量。
  • 备份与恢复演练完成。

高可用

  • Sentinel/Cluster 节点跨故障域。
  • 客户端支持发现与重连。
  • 故障转移窗口经过演练。
  • 明确异步复制丢失窗口。

应用

  • lifespan 连接复用和显式关闭。
  • 连接/命令超时合理。
  • 非幂等写不盲目重试。
  • fail-open/fail-closed 按业务分类。
  • DB 回源有限流、熔断与降级。

缓存

  • 空值、击穿、雪崩策略按实际风险选择。
  • 更新在数据库提交后失效缓存。
  • 删除失败有可靠重试。
  • L1 有 maxsize、短 TTL 和失效传播。

锁与队列

  • 锁用 NX PX + token + Lua 释放。
  • 严格资源有数据库约束/fencing。
  • Stream 业务提交后 ACK。
  • PEL 恢复、死信、幂等和保留策略完整。

观测

  • 命中率、p99、错误率、连接池等待、回源率。
  • 内存、淘汰、持久化、复制和 Cluster 状态。
  • Stream lag、pending、最老消息和死信。
  • 告警有负责人、阈值和处理手册。

40.3 从需求到结构的决策树

1
2
3
4
5
6
7
8
9
10
11
要精确保存成员吗?
├─ 否,只要去重数量 → HyperLogLog
└─ 是
├─ 只需要布尔状态且 ID 密集 → Bitmap
├─ 成员唯一?
│ ├─ 需要按 score/时间排序 → Sorted Set
│ └─ 只需关系/交并差 → Set
├─ 需要有序可重复且两端操作 → List
├─ 需要消息 ID、ACK、消费者组 → Stream
├─ 对象字段局部操作 → Hash
└─ 整体值/计数/JSON → String

GEO 是附近空间候选;Bitfield 是位段整数的特殊紧凑表示。

40.4 缓存方案口述模板

我会先判断数据是否适合缓存以及允许多长陈旧。读路径使用 Cache-Aside:先查 Redis,miss 后受保护地回源数据库并 SET EX。不存在数据可短期缓存空值;热点重建用 singleflight/互斥或逻辑过期;TTL 加受控抖动防批量失效。写路径在数据库提交后删除缓存,删除失败进入可靠重试。数据库仍是权威源,强一致判断不只依赖缓存。

40.5 分布式锁口述模板

多实例间互斥可用 SET key token NX PX lease 获取锁,token 每次唯一,释放通过 Lua 比较 token 后 DEL,避免误删新持有者。租期过短会在任务未完时失效,续期只能缓解暂停和网络故障。严格资源使用 fencing token 和数据库约束,不能把 Redis 锁当绝对正确性来源。

40.6 消息队列口述模板

List 的阻塞弹出适合简单队列,但弹出即删除会有崩溃丢失窗口;BLMOVE 可加 processing 但确认和认领要自行实现。Stream 提供消费者组、PEL、XACK 与 XAUTOCLAIM,可做至少一次投递。worker 在数据库幂等事务提交后 ACK,重复消息由 message_id 和唯一约束消除,多次失败进入死信。

40.7 多级缓存口述模板

多级缓存通常是进程内 L1、Redis L2 与数据库。读取依次查 L1、L2、DB并回填。每个 FastAPI worker 的 L1 独立,所以写路径在数据库提交后删除 L2、当前 L1 并广播失效;Pub/Sub 低延迟但会丢,因此用短 L1 TTL 和版本号兜底。

40.8 最终知识图

1
2
3
4
5
6
7
8
语义层:Key / TTL / 数据结构 / 原子命令
协调层:事务 / Pipeline / Lua / PubSub
客户端层:asyncio / pool / lifespan / serialization / timeout
模式层:session / cache / lock / queue / stream / seckill
存储层:expire / eviction / RDB / AOF
可用层:replication / Sentinel / Cluster
架构层:distributed cache / L1+L2 / degradation
运维层:test / benchmark / metrics / troubleshooting / security

如果你能沿这张图讲出每层“解决什么、做不到什么、失败后怎样”,你就不是只会背命令,而是能够独立使用 Redis。

本章收束

核心结论

  • 安全需要网络、身份、加密、最小权限和审计共同完成。
  • 上线前必须审查数据规模、故障语义和下游保护。
  • 选结构从查询与精确性反推。
  • 能说清边界比记住所有参数更重要。

常见误区

  • 只设置密码就暴露公网。
  • 上线前不做容量与故障演练。
  • 回答方案时只说优点,不说失败窗口。
  • 把缓存、锁和队列当作绝对一致机制。

你可以这样阐述

Redis 的学习主线是数据结构语义、原子执行、客户端工程、业务模式和生产架构。选型时同时考虑查询、复杂度、网络字节、TTL、故障和一致性。生产能力来自合理边界、下游兜底、可观测与演练,而不是某一条万能命令。


附录

附录 A:核心命令分类速查表

这是一张开发核心命令地图,不追求收录每一个模块命令、内部命令和管理命令。首次使用时仍应查看目标 Redis 版本的官方命令页。

A.1 连接、服务与键

目的 命令 注意
连通 PING、ECHO PING 不代表完整业务健康
服务信息 INFO、ROLE 可按 section 查询
认证 AUTH、HELLO 生产使用 ACL/TLS
切逻辑 DB SELECT Cluster 只用 DB 0
判断 Key EXISTS、TYPE EXISTS 多参数返回存在参数数
删除 DEL、UNLINK 大 Key 优先评估 UNLINK
改名 RENAME、RENAMENX 覆盖与 Cluster 同槽风险
复制 Key COPY 目标 DB/替换选项;Cluster 关注槽位与版本
标记访问 TOUCH 更新访问元数据并返回存在数
遍历 SCAN 弱一致、可重复、COUNT 是提示
随机 Key RANDOMKEY 仅诊断/抽样,不依赖顺序
数量 DBSIZE 不是内存大小
过期 EXPIRE、PEXPIRE、EXPIREAT、PEXPIREAT TTL 属于 Key
查过期 TTL、PTTL、EXPIRETIME、PEXPIRETIME -1 无 TTL,-2 不存在
取消过期 PERSIST Key 变为无 TTL
内存/编码 MEMORY USAGE、OBJECT ENCODING 诊断用,不让业务依赖编码
序列化搬运 DUMP、RESTORE、MIGRATE 管理用途,谨慎处理版本和权限
服务端排序 SORT、SORT_RO 可很重,控制类型和返回规模
清库 FLUSHDB、FLUSHALL 高危,生产 ACL 限制

A.2 String

目的 命令 关键点
SET NX/XX、EX/PX、GET、KEEPTTL
GET 类型不匹配 WRONGTYPE
批量 MGET、MSET、MSETNX 大响应与 Cluster 同槽
一次性取 GETDEL 取出即删除
读取续期 GETEX 会增加写流量
整数 INCR、INCRBY、DECR、DECRBY 单命令原子
浮点 INCRBYFLOAT 不用于精确金额
字节片段 APPEND、STRLEN、GETRANGE、SETRANGE 长度/偏移按字节

A.3 Hash

目的 命令 关键点
写字段 HSET、HSETNX HSET 返回新增字段数
读字段 HGET、HMGET、HGETALL 大 Hash 避免 HGETALL
判断/数量 HEXISTS、HLEN 字段级语义
删除 HDEL 最后字段删除后 Key 消失
数值 HINCRBY、HINCRBYFLOAT 原子字段计数
字段长度/随机 HSTRLEN、HRANDFIELD 长度按字节;count 影响返回形态
遍历 HSCAN 增量、弱一致

A.4 List

目的 命令 关键点
推入 LPUSH、RPUSH、LPUSHX、RPUSHX 多参数顺序要确认
弹出 LPOP、RPOP 带 count 返回形态不同
阻塞弹 BLPOP、BRPOP 占用连接,配置超时
查看 LRANGE、LINDEX、LLEN、LPOS 中间访问/大范围有成本
修改 LSET、LREM、LTRIM LREM 扫描,LTRIM 常做有界列表
插入 LINSERT 需搜索 pivot,中间操作随长度增长
移动 LMOVE、BLMOVE 可构造 processing List

A.5 Set

目的 命令 关键点
增删 SADD、SREM SADD 返回新增数
判断 SISMEMBER、SMISMEMBER 精确成员判断
数量 SCARD 精确基数
全量/遍历 SMEMBERS、SSCAN 大 Set 用 SSCAN
随机 SRANDMEMBER、SPOP SPOP 删除;负 count 可重复
集合间移动 SMOVE 原子从源 Set 移到目标 Set
交并差 SINTER、SUNION、SDIFF 控制集合和结果规模
只要交集数 SINTERCARD 可 LIMIT
保存运算结果 SINTERSTORE、SUNIONSTORE、SDIFFSTORE 覆盖目标 Key

A.6 Sorted Set

目的 命令 关键点
写/增量 ZADD、ZINCRBY NX/XX/GT/LT/CH
分数 ZSCORE、ZMSCORE member 不存在返回 nil
排名 ZRANK、ZREVRANK 从 0 开始
范围 ZRANGE、ZRANGESTORE rank/BYSCORE/BYLEX/REV
数量 ZCARD、ZCOUNT、ZLEXCOUNT、ZINTERCARD 只需交集数时别取成员
随机成员 ZRANDMEMBER count 正负与 WITHSCORES 影响返回
删除 ZREM、ZREMRANGEBYRANK/SCORE/LEX 大范围删除可能阻塞
弹出 ZPOPMIN/MAX、BZPOPMIN/MAX、ZMPOP/BZMPOP 弹出会删除
聚合 ZINTER、ZUNION、ZDIFF 及 STORE WEIGHTS/AGGREGATE,大集合风险

A.7 位图、概率与地理

能力 命令
Bitmap SETBIT、GETBIT、BITCOUNT、BITPOS、BITOP
Bitfield BITFIELD、BITFIELD_RO
HyperLogLog PFADD、PFCOUNT、PFMERGE
GEO GEOADD、GEOPOS、GEODIST、GEOSEARCH、GEOSEARCHSTORE

A.8 Stream

目的 命令 关键点
追加 XADD 自动 ID、MAXLEN/MINID 裁剪
历史范围 XRANGE、XREVRANGE 不创建 ACK 状态
普通读取 XREAD 调用者自己维护位置
组管理 XGROUP CREATE/DESTROY/SETID/CREATECONSUMER 初始化处理 BUSYGROUP
消费者清理 XGROUP DELCONSUMER 先理解其 pending 影响
组读取 XREADGROUP 新消息使用大于号游标
确认 XACK 从 PEL 移除,不默认删 entry
Pending XPENDING 数量、消费者、空闲、投递次数
接管 XCLAIM、XAUTOCLAIM 处理失联消费者
信息 XINFO STREAM/GROUPS/CONSUMERS 监控拓扑与 lag
保留 XTRIM、XDEL 不代替 ACK
管理 ID XSETID 恢复/复制管理用途,不在普通业务随意改

A.9 原子、批量与通知

类别 命令/能力
事务 MULTI、EXEC、DISCARD、WATCH、UNWATCH
Pipeline 客户端能力,不是一条 Redis 命令
Lua EVAL、EVALSHA、SCRIPT LOAD/EXISTS/FLUSH
Functions FUNCTION LOAD/LIST/DELETE、FCALL、FCALL_RO
Pub/Sub PUBLISH、SUBSCRIBE、UNSUBSCRIBE、PSUBSCRIBE、PUNSUBSCRIBE

A.10 运维诊断

目的 命令 风险
整体状态 INFO 返回较多,按 section 使用
慢命令 SLOWLOG 参数可能敏感
延迟事件 LATENCY 需结合应用指标
客户端 CLIENT LIST/INFO CLIENT KILL 高危
内存 MEMORY STATS/DOCTOR/USAGE 抽样解读
命令流 MONITOR 高开销、泄密,生产慎用
持久化 BGSAVE、BGREWRITEAOF fork/IO 成本
配置/权限 CONFIG GET/SET、ACL、COMMAND 变更属于管理操作
复制 INFO replication、ROLE、WAIT WAIT 非强一致
Cluster CLUSTER INFO/NODES/SLOTS/SHARDS/KEYSLOT 管理操作谨慎

附录 B:数据结构选型矩阵

结构 唯一 有序 范围查询 字段更新 精确 可确认消费 典型用途
String Key 唯一 字节片段 JSON、计数、Token
Hash field 唯一 HSCAN 非排序 对象字段
List 位置顺序 rank 范围 位置修改昂贵 手工 双端队列、最近列表
Set member 唯一 无排序 不适用 去重、关系、标签
Sorted Set member 唯一 score 顺序 rank/score/lex score 更新 排名、时间索引
Bitmap offset 唯一 位偏移 位/字节范围 位更新 签到、活跃布尔
HyperLogLog 不保存成员 不适用 UV 估计
GEO member 唯一 地理编码 半径/矩形 坐标更新 近似位置 附近候选
Stream entry ID 唯一 追加顺序 ID 范围 entry 基本不可变 可靠消息流

快速问答:

  • 要检查“某用户是否已买”?Set + 数据库唯一约束。
  • 要查“第几名”?Sorted Set。
  • 要保存完整商户 JSON?String。
  • 要让 view_count 原子增加?Hash 的 HINCRBY 或 String 的 INCR。
  • 要邮件任务阻塞等待且简单?List。
  • 要订单消息 pending/ACK/接管?Stream。
  • 要统计亿级 UV,不需用户名单?HyperLogLog。
  • 要统计每天签到总数,用户 ID 密集?Bitmap。

附录 C:缓存问题诊断矩阵

现象 可能问题 识别特征 优先措施
大量随机不存在 ID 打 DB 穿透 Redis 与 DB 都 miss 验证、空值、Bloom、风控
单个热商户过期,DB 瞬间尖峰 击穿 同 Key 高并发回源 singleflight、互斥、逻辑过期
大量不同 Key 同时 miss 雪崩 TTL 同时到期/Redis 故障 抖动、预热、HA、下游保护
某节点 CPU/带宽远高 热 Key/热槽 少数 Key 流量集中 L1、请求合并、拆 value
删除/迁槽卡顿 大 Key 单 Key value/成员巨大 UNLINK、拆分、限制规模
更新后长期读旧 失效失败/竞态 DB 新、缓存旧 提交后删、可靠重试、版本
命中率高但接口慢 大 value/网络/解析 Redis hit,p99 仍高 缩 payload、看 RTT/反序列化
Redis 故障后 DB 也挂 回源风暴 miss 与 DB 连接同步飙升 限流、熔断、旧值、降级

处理顺序:先限流保护事实源,再定位失效范围,再恢复/预热,最后修 TTL、Key 与架构根因。


附录 D:常见 Redis 错误与处理建议

错误/现象 含义 建议
WRONGTYPE 对错误顶层类型执行命令 TYPE 检查、修 Key 冲突,不要吞掉
nil / None Key/field/member 不存在 与空字符串、空集合区分
OOM command not allowed 达 maxmemory 且无法淘汰 告警、扩容/减数据/调策略,不无限重试
NOAUTH / NOPERM 未认证或 ACL 拒绝 修凭据/最小权限,不用管理员绕过
MOVED Cluster 槽稳定在别节点 使用 cluster-aware 客户端并刷新拓扑
ASK Cluster 迁槽临时重定向 客户端按 ASKING 流程处理
CROSSSLOT 多 Key 不同槽 hash tag、拆请求或改模型
BUSYGROUP Stream 消费组已存在 初始化时仅忽略明确 BUSYGROUP
NOGROUP Stream/组不存在 初始化组、核对 Key 与环境
NOSCRIPT 脚本缓存不存在 SCRIPT LOAD 后重试或客户端脚本封装
WatchError WATCH Key 被修改 有界重试/退避,或改 Lua
ConnectionError 网络/节点/连接断开 按场景降级,非幂等写先判断不确定结果
TimeoutError 获取连接或响应超时 区分池等待、网络、慢命令、阻塞超时
READONLY 向只读副本写 客户端发现新主/检查拓扑
LOADING 节点正在载入数据 等待 readiness,避免业务流量进入
MISCONF 持久化写盘问题导致写保护等 先查磁盘/权限/持久化,不关闭保护掩盖根因

错误处理原则:

  1. 区分确定失败与结果不确定。
  2. 只自动重试幂等或带幂等键的操作。
  3. 记录命令类别和 Key 模式,不记录敏感完整值。
  4. 缓存错误可降级;认证、锁、秒杀不能绕过。

附录 E:FastAPI / redis-py 常用代码模板

E.1 lifespan 客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from contextlib import asynccontextmanager
from typing import AsyncIterator

from fastapi import FastAPI
from redis.asyncio import Redis


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
redis = Redis.from_url(
"redis://localhost:6379/0",
decode_responses=True,
socket_connect_timeout=1,
socket_timeout=1,
max_connections=100,
)
app.state.redis = redis
try:
yield
finally:
await redis.aclose()

E.2 依赖

1
2
3
4
5
6
7
8
9
10
11
from typing import Annotated

from fastapi import Depends, Request
from redis.asyncio import Redis


def get_redis(request: Request) -> Redis:
return request.app.state.redis


RedisDep = Annotated[Redis, Depends(get_redis)]

E.3 JSON 模型缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from typing import TypeVar

from pydantic import BaseModel
from redis.asyncio import Redis

T = TypeVar("T", bound=BaseModel)


async def get_model(
redis: Redis,
key: str,
model_type: type[T],
) -> T | None:
raw = await redis.get(key)
return model_type.model_validate_json(raw) if raw else None


async def set_model(
redis: Redis,
key: str,
model: BaseModel,
ttl_seconds: int,
) -> None:
await redis.set(key, model.model_dump_json(), ex=ttl_seconds)

E.4 安全释放锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
RELEASE_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
"""


async def release_lock(
redis: Redis,
key: str,
token: str,
) -> bool:
return await redis.eval(RELEASE_SCRIPT, 1, key, token) == 1

E.5 创建 Stream 消费组

1
2
3
4
5
6
7
8
9
10
11
12
13
from redis.exceptions import ResponseError


async def ensure_group(
redis: Redis,
stream: str,
group: str,
) -> None:
try:
await redis.xgroup_create(stream, group, id="0-0", mkstream=True)
except ResponseError as exc:
if "BUSYGROUP" not in str(exc):
raise

E.6 Pipeline

1
2
3
4
async with redis.pipeline(transaction=False) as pipe:
for key in keys:
pipe.get(key)
values = await pipe.execute()

以上模板需按目标版本、错误策略、日志与测试补全,不是复制即上线的框架。


附录 F:术语与缩写

术语 含义
ACL Access Control List,访问控制列表
AOF Append Only File,追加写日志持久化
Cache-Aside 应用维护的旁路缓存模式
CDC Change Data Capture,变更数据捕获
COW Copy-on-Write,写时复制
DB Database,本文通常指关系型数据库
Eviction 内存压力下的淘汰
Expiration TTL 到期的过期
Fencing Token 单调令牌,下游拒绝旧锁持有者
GIS Geographic Information System,地理信息系统
HA High Availability,高可用
Hash Tag Cluster 中花括号内用于计算槽的片段
Hot Key 访问流量高度集中的 Key
HLL HyperLogLog,近似基数结构
Idempotency 幂等,重复执行不产生额外业务效果
L1/L2 一级本地缓存/二级分布式缓存
LFU Least Frequently Used,最不常使用
LRU Least Recently Used,最近最少使用
PEL Pending Entries List,Stream 待处理列表
Pub/Sub Publish/Subscribe,发布订阅
RDB Redis 快照文件/快照持久化
RPO Recovery Point Objective,可接受数据丢失点目标
RTO Recovery Time Objective,恢复时间目标
RTT Round-Trip Time,网络往返时间
SDS Simple Dynamic String,Redis 字符串内部结构概念
Sentinel Redis 哨兵高可用系统
Singleflight 合并相同 Key 的并发请求,只让一个重建
Slot Redis Cluster 的哈希槽,共 16384 个
TTL Time To Live,剩余生存时间
UV Unique Visitors,独立访客
Write Behind 先写缓存/队列,异步落事实源

附录 G:版本差异和旧命令说明

Redis 与 redis-py 持续演进。本文以现代通用用法为主,阅读旧教程时注意:

旧内容 现代方向 说明
独立 aioredis 包 redis.asyncio 异步能力已合入 redis-py
HMSET HSET mapping/多 field-value HMSET 已不作为推荐主线
RPOPLPUSH / BRPOPLPUSH LMOVE / BLMOVE 新命令方向更通用
ZRANGEBYSCORE ZRANGE ... BYSCORE 统一 ZRANGE 能力;旧命令仍可能可用
ZREVRANGE ZRANGE ... REV 统一接口方向
GEORADIUS 系列 GEOSEARCH 新查询更统一
SETNX + EXPIRE SET NX EX/PX 一条命令避免崩溃窗口
KEYS 做遍历 SCAN SCAN 仍是弱一致和增量工作
只有 String/Hash 等经典类型 新版还有扩展/新增类型 JSON、Search、Time Series、Vector 等是否内置/模块化依版本和发行版而异

特别注意:

  • Redis 7、8 可能增加命令、字段 TTL、新数据类型和协议能力。
  • redis-py 大版本可能调整默认 RESP 协议、方法签名、关闭/连接池所有权与返回结构。
  • 云托管产品可能禁用管理命令、延后新版本或添加代理限制。
  • 每个命令官方页都有版本、复杂度、ACL 类别和 Cluster 说明。
  • 教程中的 GEO、XAUTOCLAIM、Cluster 和字段过期等内容,落地时应对照实际版本。

升级步骤:

  1. 阅读 Redis 和客户端 release notes。
  2. 检查废弃命令与返回形态。
  3. 在隔离环境恢复数据副本。
  4. 跑命令语义、Lua、事务、Stream 与故障测试。
  5. 灰度客户端和服务器,监控延迟、错误和复制。
  6. 保留回滚路径。

附录 H:官方参考资料

技术事实可能随版本更新,以下优先使用官方资料:

阅读某条命令时,至少检查:

1
2
语法 → 返回值 → 时间复杂度 → since 版本
→ ACL 类别 → Key 访问声明 → Cluster 行为 → 历史变更

结语

Redis 最难的部分不是记住 SET、HSET 或 ZADD,而是知道一条命令的原子边界在哪里、数据规模变大后成本在哪里、服务故障后业务事实在哪里。

你可以用三个问题复查任何 Redis 设计:

  1. 语义:为什么是这个数据结构,这条命令精确保证什么?
  2. 规模:Key、value、成员、返回和访问频率最大是多少?
  3. 故障:过期、淘汰、超时、重试、崩溃、复制延迟和数据库双写时会发生什么?

能持续回答这三个问题,你就具备了从零继续深入 Redis 的方法。


Redis学习
http://jack-constantine.github.io/2026/08/12/Redis学习/
作者
JackConstantine
发布于
2026年8月12日
许可协议