适用版本:MySQL 8.0+、Redis 7.x、Spring Boot 3.x / Node.js 20+ 更新日期:2026-03
目录
- 核心设计原则
- MySQL 复杂链表查询优化
- 2.1 典型链表场景拆解
- 2.2 索引策略
- 2.3 SQL 写法最佳实践
- 2.4 分页优化
- 2.5 递归 CTE(树形/层级结构)
- Redis 缓存架构设计
- 3.1 缓存分层模型
- 3.2 数据结构选型
- 3.3 缓存穿透 / 击穿 / 雪崩防御
- 3.4 热点 Key 处理
- MySQL + Redis 协同模式
- 4.1 读写分离 + 缓存旁路
- 4.2 延迟双删策略
- 4.3 订阅 Binlog 同步(Canal / Debezium)
- 高访问量场景实战
- 5.1 商品详情 + 关联数据
- 5.2 用户关系链(好友/关注)
- 5.3 排行榜 + 计数器
- 监控与运维
- 常见坑与解决方案
1. 核心设计原则
| 原则 | 说明 | 反例(常见错误) |
|---|---|---|
| 冷热分离 | 热数据进 Redis,冷数据留 MySQL,不做无意义缓存 | 把一年前的归档订单也缓存到 Redis |
| 读多写少才缓存 | 读写比 > 10:1 的场景才值得引入缓存层 | 写操作频繁的库存数据硬塞进 Redis |
| 缓存不是银弹 | 复杂聚合/实时数据直接查 MySQL,别强行缓存 | 把 GROUP BY 聚合结果缓存,导致数据长期不一致 |
| 数据一致性分级 | 强一致用 Binlog 同步,最终一致用 TTL 兜底 | 所有数据都用 TTL 过期方案,金融数据出现脏读 |
| SQL 先优化再缓存 | 慢 SQL 加了缓存只是掩盖问题,根因在索引和查询计划 | 一条全表扫描的 SQL 套上 Redis,缓存失效时瞬间压垮 DB |
2. MySQL 复杂链表查询优化
2.1 典型链表场景拆解
什么是"链表查询"(JOIN)?
JOIN 就是把多张表的数据按照某个关联条件合并成一行。例如订单表里有 user_id,但只有用户表里才有用户名——需要 JOIN 来把两张表的数据拼在一起。
订单表 (orders)
└── JOIN 用户表 (users) 通过 orders.user_id = users.id
└── JOIN 商品表 (goods) 通过 orders.goods_id = goods.id
└── JOIN 商品分类 (categories) 通过 goods.category_id = categories.id
└── JOIN 仓库库存 (inventory) 通过 goods.id = inventory.goods_id
为什么多层 JOIN 是性能杀手?
MySQL 执行 JOIN 的过程(嵌套循环联接):
for 每一行 orders: → 假设 1000 行
for 每一行 users: → 假设需要查 1 次(有索引)
for 每一行 goods: → 假设需要查 1 次(有索引)
for 每一行 categories: → 假设需要查 1 次(有索引)
for 每一行 inventory:→ 假设需要查 1 次(有索引)
理想情况:1000 × 1 × 1 × 1 × 1 = 1000 次操作(索引都命中)
糟糕情况:如果某层没走索引,例如 categories 全表扫描(100行):
1000 × 1 × 1 × 100 × 1 = 100,000 次操作
→ 多一层 JOIN,风险就多一层,执行计划越复杂,MySQL 优化器越容易选错路径
原则:3 张表以上的 JOIN 考虑拆分查询 + 应用层聚合。
2.2 索引策略
什么是索引?为什么需要它?
没有索引的查询,MySQL 需要从第一行开始扫描每一行数据(全表扫描):
没有索引:查 user_id=123 的订单
扫描第1行: user_id=456 ≠ 123,跳过
扫描第2行: user_id=789 ≠ 123,跳过
扫描第3行: user_id=123 = 123,命中!
...扫描所有 100 万行
耗时:几秒
有索引(B+ 树):
直接跳转到 user_id=123 对应的叶子节点
耗时:毫秒级
联合索引设计(最左前缀原则)
联合索引是对多个字段组合建立的索引,就像电话簿按"姓 + 名"排序——你可以快速找到"张三",也可以快速找到所有"张"姓的人,但不能快速找到所有名字是"三"的人(因为排序是先按姓,再按名)。
-- 查询场景:WHERE user_id = ? AND status = ? ORDER BY created_at DESC
-- 正确索引:字段顺序与查询条件对齐
ALTER TABLE orders ADD INDEX idx_user_status_time (user_id, status, created_at);
-- 这个索引能命中的查询:
WHERE user_id = 123 ✅ 用了 user_id
WHERE user_id = 123 AND status = 'paid' ✅ 用了 user_id + status
WHERE user_id = 123 AND status = 'paid' ORDER BY ... ✅ 三个字段全用上
-- 不能命中的查询(跳过了最左的字段):
WHERE status = 'paid' ❌ 跳过了 user_id
WHERE created_at > '2025-01-01' ❌ 跳过了 user_id + status
-- 错误示范:选择性低的字段放前面
-- status 只有几种值(pending/paid/cancelled),基数太低
-- 查询时扫描的行数多,区分度差
ALTER TABLE orders ADD INDEX idx_status_user (status, user_id) -- ❌
如何判断字段放前还是放后?
-- 查看字段的基数(不同值的数量)
SELECT COUNT(DISTINCT status) FROM orders; -- 可能是 3(基数低)
SELECT COUNT(DISTINCT user_id) FROM orders; -- 可能是 10万(基数高)
-- 结论:基数高的字段放前面,区分度更好,过滤效果更强
-- 正确顺序:(user_id, status, created_at)
-- 错误顺序:(status, user_id, created_at)
覆盖索引(避免回表)
什么是"回表"?
B+ 树索引结构:
非聚簇索引(二级索引):
叶子节点存储的是 → 主键 ID
查到 ID 后,还需要再去主键索引(聚簇索引)查完整数据行
这个"再查一次"就叫 回表
覆盖索引:索引本身包含了查询需要的所有列
不需要回表,性能更好(减少一次 IO)
-- 查询:只需要 order_id, status, amount 三个字段
SELECT order_id, status, amount
FROM orders
WHERE user_id = 123 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;
-- 如果索引只有 (user_id, status, created_at):
-- 通过索引找到满足条件的行的主键 ID
-- 再根据主键 ID 去聚簇索引查 order_id, amount → 回表,额外开销
-- 建覆盖索引:把 SELECT 的列也加进去
-- 这样索引叶子节点本身就包含了 order_id 和 amount
ALTER TABLE orders ADD INDEX idx_cover (user_id, status, created_at, order_id, amount);
-- 查询时直接从索引返回数据,不回表 → 快!
用 EXPLAIN ANALYZE 验证(MySQL 8.0+)
-- EXPLAIN ANALYZE 会真实执行 SQL 并显示详细执行过程
EXPLAIN ANALYZE
SELECT o.order_id, u.username, g.title
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN goods g ON o.goods_id = g.id
WHERE o.status = 'paid' AND o.created_at > '2025-01-01';
输出结果解读:
-> Nested loop inner join (actual time=0.5..12.3 rows=150 loops=1)
-> Filter: (o.status = 'paid') (actual time=0.3..8.1 rows=200 loops=1)
-> Index range scan on orders using idx_created_at
(actual time=0.2..6.0 rows=5000 loops=1) ← 扫描了5000行,过滤后200行
-> Single-row index lookup on users using PRIMARY (id=o.user_id)
(actual time=0.02..0.02 rows=1 loops=200) ← 每次都是主键查,快
-> Single-row index lookup on goods using PRIMARY (id=o.goods_id)
(actual time=0.02..0.02 rows=1 loops=150)
关注的四个关键词:
| 关键词 | 含义 | 优化方向 |
|---|---|---|
rows=5000 但实际用 200 | 扫描行数远多于结果行数 | 加索引减少扫描范围 |
Using filesort | 排序没走索引,在内存/磁盘排序 | 调整索引包含 ORDER BY 字段 |
Using temporary | 使用了临时表(GROUP BY / DISTINCT) | 优化 GROUP BY 字段的索引 |
Using index | 覆盖索引,不回表 | 好!保持这个状态 |
2.3 SQL 写法最佳实践
拆分大 JOIN 为小查询
-- ❌ 反例:5 表 JOIN,执行计划复杂,MySQL 优化器可能选错路径
SELECT o.*, u.username, g.title, c.name, i.stock
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN goods g ON o.goods_id = g.id
JOIN categories c ON g.category_id = c.id
JOIN inventory i ON g.id = i.goods_id
WHERE o.user_id = 123;
-- ✅ 正例:分两步,应用层(Java/Python/Go 代码)组装结果
-- Step 1: 先查用户的订单,拿到商品 ID 列表
SELECT order_id, goods_id, amount, status
FROM orders
WHERE user_id = 123;
-- 假设返回:goods_id 列表 = [101, 102, 103]
-- Step 2: 用 IN 批量查商品信息(IN 里的 ID 数量要控制,建议 < 1000 个)
SELECT g.id, g.title, c.name, i.stock
FROM goods g
JOIN categories c ON g.category_id = c.id
JOIN inventory i ON g.id = i.goods_id
WHERE g.id IN (101, 102, 103);
-- 只有 3 张表 JOIN,可控
-- Step 3: 在应用代码里把两个查询结果合并
-- orders 里有 goods_id,goods 里有 id,按 id 对应拼装
为什么这样更好?
- 每个查询更简单,MySQL 优化器更容易选到最优执行计划
- 可以分别对每张表做缓存(Step 2 的商品数据可以缓存)
- 定位慢查询更容易
用 EXISTS 替换 IN(子查询优化)
-- ❌ IN 子查询:先执行内层查询,得到 user_id 列表,再到 orders 里匹配
-- 如果 VIP 用户有 100 万人,这个 IN 列表就有 100 万个值!
SELECT * FROM orders
WHERE user_id IN (
SELECT id FROM users WHERE vip_level > 2
);
-- 执行过程:
-- 1. 查 users 表,拿到所有 vip_level > 2 的 id(可能几百万个)
-- 2. 对每个 orders 行,检查 user_id 是否在这个大列表里
-- ✅ EXISTS:对每个 orders 行,检查是否存在满足条件的 user 记录
-- 一旦找到一条就停止查找(短路),不需要完整扫描 users 表
SELECT o.* FROM orders o
WHERE EXISTS (
SELECT 1 FROM users u
WHERE u.id = o.user_id -- 关联条件
AND u.vip_level > 2 -- 过滤条件
);
-- 执行过程:
-- 对每个 orders 行,去 users 表用主键查一次(极快),
-- 找到了就返回 true,找不到就跳过
避免函数导致索引失效
这是最常见的索引失效原因之一:
-- ❌ 对 created_at 列使用了函数,MySQL 无法用索引定位
-- 相当于对索引里的每个值都计算一次 DATE(),再和条件比较
WHERE DATE(created_at) = '2025-01-01'
WHERE YEAR(created_at) = 2025
WHERE LEFT(username, 3) = 'abc'
-- ✅ 把函数挪到等号右边(常量侧),列保持原样,索引正常生效
WHERE created_at >= '2025-01-01 00:00:00'
AND created_at < '2025-01-02 00:00:00'
-- 索引直接定位到 created_at 在这个范围内的行,不需要对每行做计算
-- ❌ 隐式类型转换也会导致索引失效
-- user_id 是 INT,但传了字符串,MySQL 会对每行做类型转换
WHERE user_id = '123' -- user_id 是 INT 类型
-- ✅ 类型匹配
WHERE user_id = 123
记忆口诀:索引列保持"裸奔",不加任何函数、运算、类型转换。
2.4 分页优化
深分页问题(OFFSET 过大)
-- ❌ 反例:看第 5001 页(每页 20 条)
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 100000;
-- MySQL 实际执行过程:
-- 1. 按 id 顺序扫描,找到第 100001 到 100020 行
-- 2. 但为了确认第 100001 行在哪,需要先扫描前 100000 行!
-- 3. 把前 100000 行全部丢掉,只返回第 100001-100020 行
-- → 浪费扫描 100000 行,随着 OFFSET 增大,越来越慢
-- 实际测试:
OFFSET 0: 0.001s
OFFSET 10000: 0.05s
OFFSET 100000: 0.5s
OFFSET 1000000: 5s+ ← 无法接受
-- ✅ 正例:游标分页(Keyset Pagination)
-- 前端记住上次翻页后最后一行的 id
-- 第一页
SELECT * FROM orders ORDER BY id ASC LIMIT 20;
-- 返回 id: 1, 2, 3, ..., 20 → 记录最后一个 id = 20
-- 第二页(传入上次最后一个 id)
SELECT * FROM orders
WHERE id > 20 -- 游标,直接定位到 id=20 之后
ORDER BY id ASC
LIMIT 20;
-- 返回 id: 21, 22, ..., 40 → 记录最后一个 id = 40
-- 第 5001 页(就算翻到很深的页面也一样快)
SELECT * FROM orders
WHERE id > 100000 -- 直接从 id=100000 处开始
ORDER BY id ASC
LIMIT 20;
-- 通过索引直接定位,不扫描前面的数据,O(log N) 复杂度
游标分页的限制:不支持"跳到第 N 页",只能"下一页/上一页"。适合无限滚动的 Feed 流,不适合需要页码跳转的后台管理系统。
覆盖索引 + 子查询分页(后台管理系统方案)
-- 当业务必须用 OFFSET(比如后台有页码跳转需求),用这个优化方案
-- ❌ 慢:取全量数据再 OFFSET
SELECT * FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20 OFFSET 1000;
-- 问题:* 号取全量,回表 1020 次
-- ✅ 快:先用覆盖索引找主键,再回表取全量
SELECT o.*
FROM orders o
JOIN (
-- 内层:只查 id(覆盖索引,不回表),OFFSET 的回表开销为零
SELECT id FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20 OFFSET 1000
) t ON o.id = t.id;
-- 1. 内层子查询:用索引 (user_id, created_at, id) 覆盖,OFFSET 1000 只操作索引,不回表
-- 2. 外层 JOIN:只对结果的 20 行回表取完整数据
-- 效果:回表次数从 1020 降到 20
2.5 递归 CTE(树形/层级结构)
什么是 CTE?什么是递归 CTE?
CTE(Common Table Expression,公共表表达式)就是"临时命名的查询结果",可以在主查询里引用。递归 CTE 是在 CTE 里引用自身,实现循环查询。
应用场景:组织架构树、商品分类树、评论的多级回复、地区/省市县级联
-- 场景:公司组织架构,每个部门有 parent_id 指向上级部门
-- 需要查:id=10 的部门及其所有子孙部门
WITH RECURSIVE org_tree AS (
-- 第一部分:锚点查询(起始数据,只执行一次)
SELECT id, name, parent_id, 1 AS depth
FROM departments
WHERE id = 10 -- 从 id=10 的部门开始
UNION ALL -- 把每次递归结果合并
-- 第二部分:递归查询(重复执行,直到没有新结果)
SELECT d.id, d.name, d.parent_id, ot.depth + 1
FROM departments d
JOIN org_tree ot ON d.parent_id = ot.id -- 找上一轮结果的子节点
WHERE ot.depth < 10 -- 安全限制:防止数据有环导致无限递归
)
SELECT * FROM org_tree ORDER BY depth, id;
执行过程图解:
初始:depth=1,查到 id=10 的部门 [10]
第1次递归:找 parent_id=10 的部门 → [20, 21] depth=2
第2次递归:找 parent_id=20 或 21 的部门 → [30, 31, 32] depth=3
第3次递归:找 parent_id=30, 31, 32 的部门 → [] 没有结果,停止
最终结果:[10, 20, 21, 30, 31, 32](按 depth 排序就是层级顺序)
注意:递归 CTE 结果不宜直接暴露给高并发接口。部门树变化频率极低(几乎不变),应该缓存到 Redis Hash 结构,避免每次请求都执行一次递归查询。
3. Redis 缓存架构设计
3.1 缓存分层模型
请求进来
│
▼
[L1] 进程内缓存(Caffeine / 本地 LRU Map)
- 存在于 JVM/Node 进程内存中
- TTL:1-5 秒
- 容量:小(最多几百个 Key)
- 作用:挡住"同一秒内"的重复请求,避免 Redis 变热点
│
│ L1 缓存未命中
▼
[L2] Redis Cluster
- 独立的缓存服务器
- TTL:5分钟 ~ 1小时
- 容量:大(GB 级)
- 作用:主缓存层,承担绝大多数缓存读取
│
│ L2 缓存未命中
▼
[L3] MySQL(主从读写分离)
- 读请求走从库(Read Replica)
- 写请求走主库(Master)
│
▼ 查询结果回填 L2,L2 再回填 L1
什么场景需要 L1 本地缓存?
场景:某个热点商品详情,每秒 QPS = 10,000
如果只有 Redis:
10,000 次请求 → 10,000 次 Redis 网络请求
Redis 单节点 QPS 上限约 10 万,但这 1 个 Key 占掉了 10%
加了 L1(TTL=1s):
第 1 个请求查 Redis,结果存入本地内存
后面 9,999 个请求命中本地内存,不走 Redis
→ Redis 的 QPS 从 10,000 降到了 1
3.2 数据结构选型
| 场景 | 数据结构 | Key 示例 | 说明 |
|---|---|---|---|
| 单对象缓存 | String(JSON 序列化) | order:12345 | 简单,整体读写 |
| 对象部分字段更新 | Hash | user:67890 | 可单字段更新,不用读-改-写整个对象 |
| 列表 / Feed 流 | List 或 ZSet | feed:uid:1001 | ZSet 可按时间戳排序 |
| 集合关系(关注/粉丝) | Set | following:1001 | 支持求交集、并集、差集 |
| 排行榜 | ZSet | rank:game:daily | score=分数,自动排序 |
| 计数器(浏览量/点赞数) | String + INCR | pv:article:888 | 原子递增,无并发问题 |
| 防缓存穿透 | Bloom Filter | bloom:user_ids | Redis 7 原生模块,内存极省 |
| 分布式锁 | String + NX | lock:order:create | 原子 SET NX PX |
String vs Hash 怎么选?
场景:缓存用户信息 {id:1001, name:"张三", age:25, email:"a@b.com"}
用 String(整体 JSON):
SET user:1001 '{"id":1001,"name":"张三","age":25,"email":"a@b.com"}'
优点:读取快(一次 GET)
缺点:更新 age 时,需要先 GET 整个对象,改 age,再 SET 回去 → 读-改-写,有并发风险
用 Hash(字段分开存):
HSET user:1001 id 1001 name 张三 age 25 email a@b.com
优点:更新 age 只需 HSET user:1001 age 26,不影响其他字段
缺点:读取所有字段需要 HGETALL,比 GET 稍慢
结论:
- 数据整体读、整体写 → 用 String
- 数据字段频繁独立更新(如在线状态、积分)→ 用 Hash
3.3 缓存穿透 / 击穿 / 雪崩防御
三者的区别(容易混淆)
缓存穿透:查询一个"根本不存在"的数据
例如:攻击者请求 user_id=-1,数据库里没有,缓存也没有
结果:每次请求都穿透到数据库,大量无效查询压垮 DB
缓存击穿:一个"热点 Key"突然过期,大量请求同时涌入数据库
例如:爆款商品缓存在整点失效,10000 个用户同时查数据库
结果:瞬间大量请求压垮 DB
缓存雪崩:大量 Key 在同一时刻集中过期
例如:批量初始化缓存时所有 Key 都设置了相同的 TTL=3600
结果:1小时后所有缓存同时失效,所有请求涌入 DB
缓存穿透防御
# 方案一:缓存空值(简单有效,推荐首选)
def get_user(user_id):
key = f"user:{user_id}"
cached = redis.get(key)
if cached == "NULL": # 命中空值标记
return None # 快速返回,不查 DB
if cached:
return json.loads(cached) # 命中真实数据
# 缓存未命中,查数据库
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
if user is None:
# 数据库也没有 → 缓存一个"空值标记",TTL 设短一点(60s)
# 短TTL原因:万一这个用户后来注册了,60s后缓存过期,能查到真实数据
redis.setex(key, 60, "NULL")
else:
redis.setex(key, 3600, json.dumps(user))
return user
# 方案二:布隆过滤器(适合 ID 量巨大的场景,如商品 ID 有几千万)
# 布隆过滤器:用极少的内存(几MB)判断"某个值是否可能存在"
# 特性:说"不存在"的一定不存在;说"存在"的有极小概率是误判
# 系统启动时,把数据库中所有合法的 user_id 加入布隆过滤器
bf = redis.bf()
for user_id in db.query("SELECT id FROM users"):
bf.add("bloom:user_ids", user_id)
# 请求进来时,先问布隆过滤器
def get_user(user_id):
if not bf.exists("bloom:user_ids", user_id):
return None # 布隆过滤器说不存在,直接返回,不查 DB
# 布隆过滤器说可能存在,走正常缓存流程
...
缓存击穿防御
import redis
import time
import json
r = redis.Redis()
def get_hot_product(product_id):
key = f"product:{product_id}"
# Step 1: 正常查缓存
data = r.get(key)
if data:
return json.loads(data)
# Step 2: 缓存未命中,使用分布式互斥锁
# 同一时刻只允许一个线程去查数据库,其他线程等待
lock_key = f"lock:product:{product_id}"
# SET NX(只在 key 不存在时设置)PX 3000(3秒超时,防止持锁线程崩溃后锁永不释放)
acquired = r.set(lock_key, "1", nx=True, px=3000)
if acquired:
# 获取锁成功:我来查数据库
try:
product = db.query("SELECT * FROM goods WHERE id = ?", product_id)
r.setex(key, 3600, json.dumps(product))
return product
finally:
r.delete(lock_key) # 一定要释放锁!
else:
# 获取锁失败:别人正在查,等 50ms 后重试
# 等待期间,持锁线程应该已经把数据写入缓存了
time.sleep(0.05)
return get_hot_product(product_id) # 重试,大概率命中缓存
更优雅的方案:逻辑过期(热点数据永不真正过期)
# 热点数据(如首页 Banner)使用逻辑过期,缓存里不设 TTL
# 由后台线程定时刷新
def set_with_logical_expiry(key, data, ttl_seconds):
"""存储数据时,把过期时间也存进去(但不设 Redis 的真实 TTL)"""
payload = {
"data": data,
"expire_at": time.time() + ttl_seconds # 逻辑过期时间
}
r.set(key, json.dumps(payload)) # 注意:没有 EX 参数,Key 永不过期
def get_with_logical_expiry(key):
raw = r.get(key)
if not raw:
return None # Key 不存在(系统刚启动,还没预热)
payload = json.loads(raw)
if time.time() > payload["expire_at"]:
# 逻辑上过期了 → 触发异步刷新(放到线程池里执行)
# 当前请求返回"旧数据",不等刷新完成(保证响应速度)
thread_pool.submit(refresh_cache, key)
return payload["data"] # 返回数据(可能是稍旧的)
缓存雪崩防御
import random
# 方法一:TTL 加随机抖动(简单有效)
base_ttl = 3600 # 基础过期时间:1小时
# 每个 Key 的实际 TTL = 1小时 + 随机 0~10 分钟
# 原来同时过期的 10000 个 Key,现在分散在 1小时到1小时10分钟之间过期
jitter = random.randint(0, 600)
r.setex(key, base_ttl + jitter, data)
# 方法二:Redis 集群 + 多实例
# 把不同业务的缓存分散到不同 Redis 节点
# 单个节点故障不影响全局
# 方法三:熔断降级(最后防线)
# 如果 Redis 完全不可用,返回默认值/降级数据,不查 DB
def get_with_fallback(key):
try:
return r.get(key)
except redis.ConnectionError:
# Redis 挂了,返回降级数据(如空列表、默认配置)
return DEFAULT_VALUE
3.4 热点 Key 处理
热点 Key 的典型场景:
场景 1:微博热搜第一名,每秒 10 万次访问同一个 Key
场景 2:电商大促,某款爆品每秒被访问 5 万次
场景 3:配置中心,全部服务器都在读同一个配置 Key
问题:Redis Cluster 虽然有多个节点,但同一个 Key 只在一个节点上!
这个节点成为性能瓶颈,单个 Redis 节点处理能力约 10 万 QPS
解决方案一:本地缓存(L1)
from cachetools import TTLCache # Python 本地 LRU 缓存库
# 每个服务实例各自维护一个小缓存,TTL=1s
local_cache = TTLCache(maxsize=1000, ttl=1)
def get_hot_config(key):
# 先查本地缓存(内存操作,纳秒级)
if key in local_cache:
return local_cache[key]
# 本地缓存未命中,查 Redis
value = r.get(key)
local_cache[key] = value # 存入本地缓存
return value
# 效果:1秒内同一个 Key 只查一次 Redis
# 如果有 100 台服务器,每台每秒查 1 次 Redis = 100 QPS(而不是 100,000 QPS)
解决方案二:Key 拆分(读多写少的配置类数据)
import random
# 原始 Key:hot_config(所有请求都打到同一个 Redis 节点)
# 拆分为 N 个副本(N=10)
def write_hot_config(config_data):
"""写入时,更新所有副本"""
for i in range(10):
r.set(f"hot_config:{i}", json.dumps(config_data))
def read_hot_config():
"""读取时,随机选一个副本"""
shard = random.randint(0, 9)
return r.get(f"hot_config:{shard}")
# 效果:10 万 QPS 均匀分散到 10 个不同的 Key(不同节点),每个只承受 1 万 QPS
# 代价:写入时要写 10 份(但写操作频率极低,可接受)
4. MySQL + Redis 协同模式
4.1 读写分离 + 缓存旁路(Cache-Aside)
Cache-Aside(旁路缓存) 是最常用的模式,应用代码负责维护缓存:
写操作流程:
应用代码 → 写 MySQL 主库 → 删除 Redis 缓存(注意:是删除,不是更新)
读操作流程:
应用代码 → 查 Redis
命中(缓存有数据)→ 直接返回
未命中(缓存没数据)→ 查 MySQL 从库 → 把结果写入 Redis → 返回
为什么写操作要"删缓存"而不是"更新缓存"?
场景:两个线程并发操作
错误方案(写操作更新缓存):
时间线:
T1: 线程A写DB(order.amount=200)
T2: 线程B写DB(order.amount=300)
T3: 线程B更新缓存(amount=300)← 后写
T4: 线程A更新缓存(amount=200)← 先写反而后到,覆盖了300!
缓存里是 200,DB 里是 300 → 数据不一致!
正确方案(写操作删缓存):
T1: 线程A写DB(amount=200)
T2: 线程B写DB(amount=300)
T3: 线程B删缓存
T4: 线程A删缓存
→ 缓存为空
下次读取:查 DB,得到最新值 300,写入缓存 → 一致!
核心思路:删缓存是幂等操作,多次删结果都一样;更新缓存有并发竞争问题
// Spring Boot 实现示例
@Service
public class OrderService {
@Autowired
private RedisTemplate<String, Object> redis;
@Autowired
private OrderMapper orderMapper; // 走从库
// 读操作:先查 Redis,未命中查 MySQL,结果写入 Redis
public Order getOrder(Long orderId) {
String key = "order:" + orderId;
// 查 Redis
Order cached = (Order) redis.opsForValue().get(key);
if (cached != null) {
return cached; // 缓存命中,直接返回
}
// 缓存未命中,查 MySQL 从库
Order order = orderMapper.selectById(orderId);
if (order != null) {
// 写入 Redis,TTL 加随机抖动防雪崩
int ttl = 30 + new Random().nextInt(10); // 30~40 分钟
redis.opsForValue().set(key, order, ttl, TimeUnit.MINUTES);
}
return order;
}
// 写操作:先写 MySQL,再删 Redis(不是更新!)
@Transactional
public void updateOrder(Order order) {
orderMapper.updateById(order); // 写 MySQL 主库
redis.delete("order:" + order.getId()); // 删除 Redis 缓存
// 下次有人读这个 Order 时,会从 MySQL 加载最新数据并写入缓存
}
}
4.2 延迟双删策略
问题场景:有读写分离(主从库)时,Cache-Aside 存在一个竞态条件:
时间线(有主从延迟,假设同步延迟 300ms):
T=0ms: 写操作:更新 MySQL 主库(order.amount=200)
T=1ms: 写操作:删除 Redis 缓存(缓存为空)
T=2ms: 读操作:查 Redis,未命中
T=3ms: 读操作:查 MySQL 从库(主从同步未完成,还是旧数据 amount=100)
T=4ms: 读操作:把旧数据写入 Redis(Redis 里是 amount=100)
T=300ms: 主从同步完成,MySQL 从库更新为 amount=200
但 Redis 里还是旧的 amount=100!
这个脏数据会存活到缓存 TTL 过期(可能 30 分钟后)
解法:延迟双删
def update_user(user_id, data):
# 第一次删缓存(可选,防止写 DB 期间有读请求写入旧数据)
redis.delete(f"user:{user_id}")
# 更新数据库主库
db.execute("UPDATE users SET ... WHERE id = ?", user_id)
# 延迟 500ms 后,第二次删缓存
# 目的:等主从同步完成,让从库也有了最新数据之后,再删一次缓存
# 这样下一次读操作从从库取到的就是最新数据
schedule_async_delete(
key=f"user:{user_id}",
delay_ms=500 # 这个值要大于实际主从延迟(通过监控确认)
)
注意:延迟时间需要根据实际主从同步延迟设置,通过监控 Seconds_Behind_Master 来确定。
4.3 订阅 Binlog 同步(强一致推荐方案)
延迟双删还是有时间窗口内的数据不一致。如果需要更强的一致性,用 Binlog 监听方案。
原理:
MySQL 的每次数据变更,都会写入 Binlog(Binary Log,二进制日志)
这是 MySQL 主从复制的底层机制
Canal 伪装成 MySQL 的"从库",订阅 Binlog 事件
每当数据库有 INSERT/UPDATE/DELETE 操作,Canal 就能收到通知
Canal → Kafka → 消费者服务 → 删除/更新对应的 Redis 缓存
完整数据流:
用户请求 → 应用服务 → 写 MySQL 主库
│
│ Binlog 事件
▼
Canal Server(监听 Binlog)
│
│ 推送变更事件
▼
Kafka Topic
│
│ 消费
▼
缓存同步消费者服务
│
▼
删除/更新 Redis 对应缓存
Canal 配置:
# Canal 实例配置 canal.properties
canal.instance.mysql.slaveId=1234 # Canal 的 slave ID(随机唯一值)
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=canal # MySQL 专用账号
canal.instance.dbPassword=canal
# 过滤规则:只监听 mydb 库的 orders 和 users 表
canal.instance.filter.regex=mydb\\.orders,mydb\\.users
// 消费 Canal 事件,同步删除 Redis 缓存
@Component
public class CanalConsumer {
@Autowired
private RedisTemplate<String, Object> redis;
@KafkaListener(topics = "canal-orders")
public void handleOrderChange(CanalMessage msg) {
// 取出变更行的主键 ID
String orderId = msg.getRowData()
.getAfterColumnsList()
.stream()
.filter(col -> col.getName().equals("id"))
.findFirst()
.get()
.getValue();
switch (msg.getEventType()) {
case UPDATE:
case DELETE:
// 数据库数据变了 → 删除对应的 Redis 缓存
redis.delete("order:" + orderId);
break;
case INSERT:
// 新数据一般不用预热缓存,等第一次读时自动加载
break;
}
}
}
三种缓存同步方案对比:
| 方案 | 一致性 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| 简单删缓存 | 有短暂不一致窗口(主从延迟期间) | 简单 | 无主从复制,或允许秒级不一致 |
| 延迟双删 | 不一致窗口缩短至主从延迟时间内 | 中等 | 有主从复制,允许毫秒级不一致 |
| Binlog 同步 | 接近强一致(毫秒级延迟) | 复杂(需要 Canal + Kafka) | 金融、库存等对一致性要求高的场景 |
5. 高访问量场景实战
5.1 商品详情 + 关联数据
场景:电商商品详情页,QPS 5000+,一个页面需要:商品基本信息、分类路径、实时库存、评分、评论数
关键思路:按数据变更频率分层缓存
商品基本信息(标题、描述、图片)
→ 低频变更(商家改价才变)
→ Redis Hash,TTL 1 小时
→ 合适:长期缓存
实时库存
→ 高频变更(每次下单都变)
→ 策略一:不缓存,直接查 DB(库存必须准确)
→ 策略二:Redis String,TTL 30 秒(允许 30s 误差,如秒杀预热)
评分 / 评论数
→ 计数器类型,频繁 +1 -1
→ Redis String INCR(原子操作),每小时同步一次 MySQL
商品分类信息("手机>苹果>iPhone"这条路径)
→ 极低频变更(分类架构几乎不变)
→ L1 本地缓存,TTL 10 分钟(不需要走 Redis)
def get_product_detail(product_id):
# 用 Redis Pipeline 把多个查询合并为一次网络请求
pipe = r.pipeline()
pipe.hgetall(f"product:{product_id}") # 基本信息(Hash)
pipe.get(f"product:stock:{product_id}") # 库存(String)
pipe.get(f"product:rating:{product_id}") # 评分(String)
pipe.get(f"product:review_count:{product_id}") # 评论数(String)
product, stock, rating, review_count = pipe.execute()
# 缓存未命中的部分,从 MySQL 补查并回填
if not product:
product_data = db.query("SELECT * FROM goods WHERE id=?", product_id)
r.hset(f"product:{product_id}", mapping=product_data)
r.expire(f"product:{product_id}", 3600)
product = product_data
# 分类信息从本地缓存拿
category_path = local_cache.get(f"category:{product['category_id']}")
if not category_path:
category_path = db.query("SELECT ... FROM categories WHERE id=?", ...)
local_cache[f"category:{product['category_id']}"] = category_path
return {
"product": product,
"stock": int(stock) if stock else 0,
"rating": float(rating) if rating else 0,
"review_count": int(review_count) if review_count else 0,
"category_path": category_path
}
5.2 用户关系链(好友/关注)
Set 数据结构的天然优势:Redis 的 Set 支持集合运算,不用写复杂 SQL。
# ===== 关注 / 取关操作 =====
def follow(follower_id, target_id):
"""follower_id 关注 target_id"""
pipe = r.pipeline()
pipe.sadd(f"following:{follower_id}", target_id) # 我的关注列表加入 target
pipe.sadd(f"followers:{target_id}", follower_id) # target 的粉丝列表加入我
pipe.execute()
# 异步写入 MySQL(用 MQ 解耦,不阻塞请求)
mq.publish("follow_event", {
"follower": follower_id,
"target": target_id,
"action": "follow"
})
def unfollow(follower_id, target_id):
"""follower_id 取关 target_id"""
pipe = r.pipeline()
pipe.srem(f"following:{follower_id}", target_id)
pipe.srem(f"followers:{target_id}", follower_id)
pipe.execute()
# ===== 关系查询(全部在 Redis 里计算,不查 DB)=====
def get_mutual_following(user_a, user_b):
"""共同关注:A 和 B 都关注了哪些人(两个集合的交集)"""
return r.sinter(f"following:{user_a}", f"following:{user_b}")
# 对应 SQL:SELECT target FROM follows WHERE follower=A
# INTERSECT
# SELECT target FROM follows WHERE follower=B
# Redis 直接计算交集,比 SQL 快得多
def get_recommendations(me, other):
"""推荐关注:other 关注的人 中 我还没关注的(差集)"""
return r.sdiff(f"following:{other}", f"following:{me}")
# = other 的关注列表 - 我的关注列表
def is_following(me, target):
"""判断我是否关注了 target"""
return r.sismember(f"following:{me}", target) # O(1)
def get_follow_count(user_id):
"""获取关注数和粉丝数"""
pipe = r.pipeline()
pipe.scard(f"following:{user_id}") # 关注数
pipe.scard(f"followers:{user_id}") # 粉丝数
return pipe.execute()
大 V 账号(粉丝过亿)的特殊处理:
问题:某明星有 1 亿粉丝,followers:uid 这个 Set 有 1 亿个元素
内存占用约 4GB,SCARD、SMEMBERS 等操作很慢
解决方案:
1. 大 V 的粉丝列表不放 Redis,只在 MySQL 里存,不缓存
2. 粉丝数只存计数(INCR),不存完整列表
3. "是否互关"等需要集合运算的,对大 V 做特殊处理(查 MySQL,结果短期缓存)
5.3 排行榜 + 计数器
ZSet(有序集合)是排行榜的最佳选择:
import time
def today():
return time.strftime("%Y-%m-%d")
# ===== 排行榜操作 =====
def add_score(game_id: str, user_id: int, delta: float):
"""用户在游戏中得分,delta 可以是正数(加分)或负数(减分)"""
key = f"rank:{game_id}:{today()}"
r.zincrby(key, delta, user_id) # 原子操作:score += delta
r.expire(key, 86400 * 3) # 保留 3 天的数据
def get_top100(game_id: str) -> list:
"""获取今日前 100 名(按分数从高到低)"""
key = f"rank:{game_id}:{today()}"
return r.zrevrange(key, 0, 99, withscores=True)
# 返回:[(user_id, score), ...],按 score 降序排列
def get_user_rank(game_id: str, user_id: int) -> dict:
"""查询某用户的排名和分数"""
key = f"rank:{game_id}:{today()}"
rank = r.zrevrank(key, user_id) # 返回 0-based 排名,第1名返回0
score = r.zscore(key, user_id)
return {
"rank": (rank + 1) if rank is not None else None, # 转为 1-based
"score": score
}
def get_nearby_users(game_id: str, user_id: int, range_size: int = 5) -> list:
"""获取某用户附近排名的用户(用于显示"我的排名附近")"""
key = f"rank:{game_id}:{today()}"
rank = r.zrevrank(key, user_id)
if rank is None:
return []
start = max(0, rank - range_size)
end = rank + range_size
return r.zrevrange(key, start, end, withscores=True)
计数器(浏览量/点赞数)定时落库:
# ===== 计数器操作 =====
def increment_pv(article_id: int):
"""文章被访问,PV + 1"""
r.incr(f"pv:article:{article_id}") # Redis 原子递增,并发安全
def get_pv(article_id: int) -> int:
"""获取文章 PV"""
count = r.get(f"pv:article:{article_id}")
return int(count) if count else 0
# ===== 定时将 Redis 计数器同步到 MySQL =====
# 为什么要定时同步?Redis 重启后计数器清零!
# 一般做法:Redis 存增量,MySQL 存累计值
@scheduler.scheduled(cron="0 * * * *") # 每小时执行一次
def sync_pv_to_mysql():
# 用 SCAN 遍历所有 PV Key(不能用 KEYS,会阻塞 Redis)
for key in r.scan_iter("pv:article:*"):
article_id = key.decode().split(":")[-1]
# GETDEL:原子获取并删除(避免计数被重复加到 MySQL)
count = r.getdel(key)
if count and int(count) > 0:
# MySQL 累加,不是直接赋值
db.execute(
"UPDATE articles SET pv = pv + ? WHERE id = ?",
int(count), article_id
)
注意:GETDEL 是原子操作,先获取值再删除,避免两个同步任务实例重复处理同一个计数。
6. 监控与运维
MySQL 慢查询监控
-- 开启慢查询日志(MySQL 8.0)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.5; -- 超过 500ms 的查询记录到日志
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
-- 查看当前正在执行的查询(实时监控)
SHOW PROCESSLIST;
-- 查看表的索引情况
SHOW INDEX FROM orders;
-- 检查某个查询是否用到了索引
EXPLAIN SELECT * FROM orders WHERE user_id = 123;
-- MySQL 8.0 更详细的执行分析(真实执行,有 actual 开销)
EXPLAIN ANALYZE SELECT ...;
EXPLAIN 输出中最重要的字段:
type 列(越靠前越好):
system > const > eq_ref > ref > range > index > ALL(全表扫描,很差)
system: 表只有一行,最快
const: 用主键或唯一索引查一行
ref: 用普通索引
range: 索引范围扫描(WHERE id > 100)
ALL: 全表扫描,QPS 高时必须优化
key 列:显示实际使用的索引(null 表示没用索引)
rows 列:预估扫描行数(越小越好)
Extra 列:
Using index → 覆盖索引(好!)
Using filesort → 需要额外排序(看情况优化)
Using temporary → 使用临时表(尽量避免)
用 pt-query-digest 分析慢查询日志:
# 安装 Percona Toolkit
yum install percona-toolkit
# 分析慢查询日志,找出最耗时的查询
pt-query-digest /var/log/mysql/slow.log | head -100
# 输出示例:
# Rank Query ID Response time Calls R/Call
# 1 0xABC... 120.5s 45.2% 5000 0.024s
# SELECT * FROM orders WHERE ...
# → 这条 SQL 执行了 5000 次,总耗时 120 秒 → 必须优化
Redis 关键监控指标
# ===== 实时监控所有 Redis 命令(谨慎在生产使用,很耗性能)=====
redis-cli MONITOR
# ===== 关键状态指标 =====
# 每秒处理的命令数(OPS)
redis-cli INFO stats | grep instantaneous_ops_per_sec
# 命中率(理想 > 90%)
# keyspace_hits / (keyspace_hits + keyspace_misses)
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"
# 内存使用情况
redis-cli INFO memory | grep -E "used_memory_human|maxmemory_human|mem_fragmentation_ratio"
# mem_fragmentation_ratio > 1.5 表示内存碎片较多,考虑重启或 MEMORY PURGE
# 所有 DB 的 Key 数量
redis-cli INFO keyspace
# 连接数
redis-cli INFO clients | grep connected_clients
# ===== 慢命令日志 =====
# 查看最近 10 条慢命令(默认 > 10ms 记录)
redis-cli SLOWLOG GET 10
# 调整慢命令阈值为 5ms(单位是微秒)
redis-cli CONFIG SET slowlog-log-slower-than 5000
# ===== 热 Key 分析 =====
# 方法一:Redis 内置热 Key 分析(Redis 4.0+,有一定采样误差)
redis-cli --hotkeys
# 方法二:抓取一段时间的命令,分析频率
redis-cli MONITOR | head -10000 | awk '{print $4}' | sort | uniq -c | sort -rn | head -20
告警阈值参考
| 指标 | 正常 | 警告 | 说明 |
|---|---|---|---|
| MySQL 慢查询 QPS | < 1/min | > 5/min | 持续出现慢查询需排查 |
| Redis 命中率 | > 95% | < 90% | 命中率低说明缓存策略有问题 |
| Redis 内存使用 | < 70% | > 80% | 超过 80% 有 OOM 风险 |
| Redis 响应 P99 | < 5ms | > 20ms | 慢命令或内存碎片导致 |
| MySQL 主从延迟 | < 100ms | > 1s | 延迟高影响读写分离一致性 |
| MySQL 连接数 | < 最大值 60% | > 最大值 80% | 连接池不足需扩容 |
7. 常见坑与解决方案
坑1:大 Key 阻塞 Redis
什么是大 Key?
Redis 是单线程处理命令的。如果一个 Key 很大,
执行 DEL / HGETALL / SMEMBERS 等操作时会阻塞整个 Redis 几十毫秒。
其他所有请求都在排队等待 → 整体延迟飙升。
大 Key 的判断标准:
String > 10KB
Hash / Set / List / ZSet 的元素数 > 1万 或 总大小 > 10MB
# 扫描大 Key(不阻塞,使用 SCAN 内部实现)
redis-cli --bigkeys -i 0.01 # -i 0.01 = 每次 SCAN 之间休眠 10ms,降低 Redis 负担
# 在线分析(更精确)
redis-cli --memkeys
# 输出示例:
# Biggest string found 'product:12345' has 256000 bytes
# Biggest hash found 'group:g_10086:members' has 50000 fields
大 Key 的解决方案:
# 场景:某个群的成员列表有 50000 个字段的 Hash,太大了
# 方案一:拆分 Key(按范围分片)
# 原来:HSET group:g_10086:members uid1 role uid2 role ...(5万个字段)
# 拆分:每 1000 个成员一个 Key
shard = uid // 1000 # uid 10001 → shard=10 → 存入 group:g_10086:members:10
r.hset(f"group:{group_id}:members:{shard}", uid, role)
# 读取所有成员:
members = {}
for shard in range(max_shard + 1):
shard_data = r.hgetall(f"group:{group_id}:members:{shard}")
members.update(shard_data)
# 方案二:压缩 String 值(对大文本值)
import gzip
import json
data = json.dumps(large_object)
if len(data) > 1024:
compressed = gzip.compress(data.encode())
r.set(key, compressed)
else:
r.set(key, data)
坑2:N+1 查询问题
这是最常见的性能问题,在代码 Review 中经常被忽视:
# ❌ 反例:循环中查 Redis(N+1 问题)
order_ids = [1001, 1002, 1003, ..., 1100] # 100 个订单 ID
for order_id in order_ids:
order = r.get(f"order:{order_id}") # 每次一个网络往返
# 100 个订单 = 100 次 Redis 网络请求 = 约 100ms 网络延迟
# ✅ 正例:Pipeline 或 MGET 批量获取(1 次网络往返)
keys = [f"order:{oid}" for oid in order_ids]
orders = r.mget(*keys) # 一次请求获取所有结果,约 1ms 网络延迟
# 处理结果(orders 是一个列表,None 表示该 Key 不存在)
for i, order_data in enumerate(orders):
if order_data is None:
# 缓存未命中,需要查 DB
missing_ids.append(order_ids[i])
else:
result[order_ids[i]] = json.loads(order_data)
# 批量查 DB 补全未命中的
if missing_ids:
db_orders = db.query(f"SELECT * FROM orders WHERE id IN ({','.join(map(str, missing_ids))})")
# 批量写回 Redis...
同样的问题出现在 MySQL 里:
# ❌ 反例:先查订单,再循环查每个订单的商品
orders = db.query("SELECT * FROM orders WHERE user_id=?", user_id)
for order in orders:
order['goods'] = db.query("SELECT * FROM goods WHERE id=?", order['goods_id'])
# 10 个订单 = 1 + 10 = 11 次查询
# ✅ 正例:一次 IN 查询
orders = db.query("SELECT * FROM orders WHERE user_id=?", user_id)
goods_ids = [o['goods_id'] for o in orders]
goods_list = db.query(f"SELECT * FROM goods WHERE id IN ({','.join(map(str, goods_ids))})")
# 构建 goods_id → goods 的映射
goods_map = {g['id']: g for g in goods_list}
for order in orders:
order['goods'] = goods_map.get(order['goods_id'])
# 只有 2 次查询
坑3:Redis Key 设计无规范
规范命名格式:{业务域}:{对象类型}:{唯一ID}:{子字段}
✅ 好的例子:
order:detail:12345 订单详情
user:profile:67890 用户资料
rank:game:tetris:2026-03-12 某游戏某天的排行榜
lock:order:create:user:12345 锁:某用户创建订单的锁
❌ 不好的例子:
orderinfo 没有命名空间,无法分类管理
Order:12345 大小写混用,不一致
u_67890_profile 下划线不统一,看起来是内部变量名
Key 过长的问题:
# Redis 内部对 Key 的存储编码:
# <= 44 字节:使用 embstr 编码(连续内存,快)
# > 44 字节:使用 raw 编码(额外内存分配)
# 如果 Key 本身就很长,内存开销增加,查找也慢一点
# 处理方式:对特别长的 Key,可以 MD5 压缩
import hashlib
long_key = "search:result:keyword:苹果手机最新款:sort:price:filter:brand=apple,color=white"
short_key = "search:" + hashlib.md5(long_key.encode()).hexdigest()[:16]
坑4:主从延迟导致读到旧数据
完整场景还原:
T=0: 用户修改了头像,写操作:UPDATE users SET avatar='new.jpg' WHERE id=1001
T=1: 写操作删除缓存:DEL user:1001
T=2: 另一个请求查询用户信息
T=3: 查 Redis,未命中
T=4: 查 MySQL 从库(主从同步还没完成,从库还是旧头像 old.jpg)
T=5: 旧数据写入 Redis:SET user:1001 {...avatar: 'old.jpg'...}
T=300ms: 主从同步完成,从库更新
但 Redis 里仍然是 old.jpg,要等 TTL 过期才更新(可能30分钟后)
解决方案(选一种):
1. 延迟双删:写操作后 500ms 再删一次缓存(适合大多数场景)
2. 写操作后短时间强制读主库(在请求 Context 中标记,30秒内读主库)
3. Canal Binlog 监听:从库同步完成后,再删缓存(强一致)
坑5:KEYS 命令导致 Redis 阻塞
# ❌ 绝对不要在生产环境使用
KEYS user:* # 全量扫描所有 Key!100万 Key 可能阻塞 Redis 几秒钟
# Redis 是单线程,阻塞期间所有其他请求超时
# ✅ 使用 SCAN 代替(游标分批扫描,每次扫描一小批,不阻塞)
cursor = 0
while True:
cursor, keys = r.scan(cursor, match="user:*", count=100) # 每次最多扫100个
for key in keys:
process(key)
if cursor == 0: # cursor 回到 0 表示扫描完了
break
坑6:分布式锁未设过期时间
# ❌ 危险写法一:SETNX 和 EXPIRE 是两步,不是原子的
redis.setnx("lock:key", "1") # 如果这里成功了
redis.expire("lock:key", 5) # 但进程在这一行之前崩溃了
# 锁永远不会过期!(死锁)
# ❌ 危险写法二:锁没有持有者标识,可能误释放别人的锁
def release_lock():
redis.delete("lock:key") # 如果锁已经被别人持有了,这里会删掉别人的锁!
# ✅ 正确写法:
import uuid
def acquire_lock(resource, expire_ms=5000):
token = str(uuid.uuid4()) # 每次加锁生成唯一 token
acquired = redis.set(
f"lock:{resource}",
token, # 存入唯一标识
nx=True, # NX = 只在不存在时设置
px=expire_ms # PX = 毫秒级过期(原子操作,和 NX 一起执行)
)
return token if acquired else None
def release_lock(resource, token):
"""释放锁时,先验证 token 是否是自己的,再删除(用 Lua 保证原子性)"""
lua_script = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"""
result = redis.eval(lua_script, 1, f"lock:{resource}", token)
return result == 1
# 使用方式:
token = acquire_lock("order:create:user:1001")
if token:
try:
# 执行需要加锁的业务逻辑
create_order(...)
finally:
release_lock("order:create:user:1001", token) # 一定要在 finally 里释放
else:
# 获取锁失败,说明其他实例正在处理,返回提示或排队
raise Exception("系统繁忙,请稍后重试")
Lua 脚本保证原子性的原因:
如果不用 Lua,分两步操作:
step 1: GET lock:key → 得到 "my-token",验证是自己的
step 2: DEL lock:key → 删除
问题:step1 和 step2 之间,锁可能恰好过期了,被别人重新获取了
这时候 DEL 会删掉别人刚获取的锁!
Lua 脚本在 Redis 里是原子执行的(执行期间不接受其他命令),
GET 和 DEL 要么都执行,要么都不执行,没有并发问题。
快速参考卡
MySQL 查询优化检查清单:
□ 用 EXPLAIN ANALYZE 分析执行计划,确认 type 不是 ALL
□ 联合索引遵循最左前缀,字段顺序按基数从高到低
□ SELECT 列和 WHERE/ORDER BY 列都加入索引,实现覆盖索引避免回表
□ WHERE 条件中不对列使用函数(DATE(), YEAR()等),避免索引失效
□ 深翻页(OFFSET > 1000)改为游标分页或子查询分页
□ 超过 3 张表 JOIN 考虑拆分为多次查询在应用层聚合
□ 树形结构用递归 CTE,结果缓存到 Redis
Redis 缓存检查清单:
□ 设置合理 TTL + 随机抖动(防雪崩)
□ 缓存空值("NULL"标记)防穿透;大量 ID 用布隆过滤器
□ 热点 Key 过期防击穿:分布式互斥锁或逻辑过期
□ 写操作先删缓存(不是更新),有主从延迟时用延迟双删
□ 强一致性场景用 Canal 监听 Binlog 删缓存
□ 批量操作用 Pipeline / MGET,避免 N+1
□ 禁止用 KEYS 命令,用 SCAN 遍历
□ 定期用 --bigkeys 检查大 Key,超标的拆分处理
□ 分布式锁必须原子设置 NX + PX,释放时用 Lua 脚本验证 token
□ Key 命名规范:{业务}:{对象}:{ID}:{子字段}