MySQL 复杂链表查询 × Redis 高访问量 —— 生产落地指南

0 阅读38分钟

适用版本:MySQL 8.0+、Redis 7.x、Spring Boot 3.x / Node.js 20+ 更新日期:2026-03


目录

  1. 核心设计原则
  2. MySQL 复杂链表查询优化
    • 2.1 典型链表场景拆解
    • 2.2 索引策略
    • 2.3 SQL 写法最佳实践
    • 2.4 分页优化
    • 2.5 递归 CTE(树形/层级结构)
  3. Redis 缓存架构设计
    • 3.1 缓存分层模型
    • 3.2 数据结构选型
    • 3.3 缓存穿透 / 击穿 / 雪崩防御
    • 3.4 热点 Key 处理
  4. MySQL + Redis 协同模式
    • 4.1 读写分离 + 缓存旁路
    • 4.2 延迟双删策略
    • 4.3 订阅 Binlog 同步(Canal / Debezium)
  5. 高访问量场景实战
    • 5.1 商品详情 + 关联数据
    • 5.2 用户关系链(好友/关注)
    • 5.3 排行榜 + 计数器
  6. 监控与运维
  7. 常见坑与解决方案

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=456123,跳过
扫描第2行: user_id=789123,跳过
扫描第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=2021 的部门 → [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简单,整体读写
对象部分字段更新Hashuser:67890可单字段更新,不用读-改-写整个对象
列表 / Feed 流List 或 ZSetfeed:uid:1001ZSet 可按时间戳排序
集合关系(关注/粉丝)Setfollowing:1001支持求交集、并集、差集
排行榜ZSetrank:game:dailyscore=分数,自动排序
计数器(浏览量/点赞数)String + INCRpv:article:888原子递增,无并发问题
防缓存穿透Bloom Filterbloom:user_idsRedis 7 原生模块,内存极省
分布式锁String + NXlock: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=200T=1ms:   写操作:删除 Redis 缓存(缓存为空)
T=2ms:   读操作:查 Redis,未命中
T=3ms:   读操作:查 MySQL 从库(主从同步未完成,还是旧数据 amount=100T=4ms:   读操作:把旧数据写入 Redis(Redis 里是 amount=100T=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 > 100ALL: 全表扫描,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}:{子字段}