高并发系统设计
从"扛不住"到"稳如磐石"——限流熔断、缓存策略、队列削峰、读写分离、连接池优化、热点数据处理,用真实案例和可运行代码构建一套后端工程师的高并发生存指南。
1. 什么是高并发:先搞清楚你在解决什么问题
"高并发"可能是后端面试中出现频率最高的词,但很多人对它的理解停留在"能抗很多请求"。在动手写代码之前,先搞清楚三个问题:什么指标衡量"并发高不高"?高并发和高可用有什么区别?瓶颈通常出在哪里?
1.1 QPS、TPS、RT:三个核心指标
高并发的三把尺子:
QPS(Queries Per Second)— 每秒查询数
═══════════════════════════════════
→ 服务器每秒能处理多少个请求
→ 衡量系统的吞吐量
→ 例子:商品详情页 QPS = 5000,表示每秒能响应 5000 次查询
→ ⚠️ QPS 是结果,不是原因——你不能"设置"QPS,只能优化系统让它更高
TPS(Transactions Per Second)— 每秒事务数
═══════════════════════════════════
→ 每秒完成多少个完整业务事务
→ 一个事务可能包含多个请求(查库存→扣库存→创建订单→扣款)
→ TPS 通常 < QPS,因为一个事务 = 多次查询
→ 例子:下单 TPS = 500,意味着每秒完成 500 个订单
RT(Response Time)— 响应时间
═══════════════════════════════════
→ 从请求发出到收到响应的时间
→ 通常看 P50(中位数)、P99(99% 请求的响应时间)
→ P99 比平均值重要!平均 50ms 但 P99 = 2s,说明 1% 用户体验极差
→ 例子:P99 = 200ms 意味着 99% 的请求在 200ms 内完成三者的关系:
QPS 和 RT 的关系公式:
QPS = 并发数 / 平均RT
例子:
→ 10 个并发线程,每个请求 100ms
→ QPS = 10 / 0.1 = 100
→ 要提高 QPS?两条路:加并发数 或 降 RT
QPS 的天花板:
→ 单线程最大 QPS = 1000 / RT(ms)
→ RT = 10ms → 单线程 QPS = 100
→ RT = 1ms → 单线程 QPS = 1000
→ 所以:降低 RT 是提高 QPS 最直接的手段| 指标 | 衡量什么 | 关注维度 | 优化方向 |
|---|---|---|---|
| QPS | 吞吐量 | 越高越好 | 加缓存、加机器、异步化 |
| TPS | 业务处理能力 | 越高越好 | 减少事务耗时、拆分事务 |
| RT(P99) | 用户体验 | 越低越好 | 减少 IO、优化 SQL、加缓存 |
💡 面试陷阱:面试官问"你的系统 QPS 是多少",你要追问"哪个接口的 QPS"。商品列表和下单创建订单的 QPS 差了一个数量级——不同接口不能混在一起说。
1.2 高并发 vs 高可用 vs 高性能:别混为一谈
三个概念经常混淆,但解决的是不同的问题:
高并发(High Concurrency)
═══════════════════════════════════
问题:同时来了 10 万个请求,系统能不能处理?
关键词:吞吐量、QPS、扩容
手段:缓存、异步、水平扩展、限流
高可用(High Availability)
═══════════════════════════════════
问题:服务器挂了一台,系统还能不能用?
关键词:99.99%、冗余、故障转移
手段:多副本、主从切换、熔断降级、异地多活
高性能(High Performance)
═══════════════════════════════════
问题:单个请求能不能在 50ms 内返回?
关键词:响应时间、延迟
手段:算法优化、减少 IO、缓存、连接池它们的关系:
高并发 ←──────→ 高性能
↑ ↑
│ 互相影响 │
↓ ↓
高可用 ←──────→ 系统设计
→ 高性能(低 RT)有助于高并发(高 QPS)
→ 高并发需要高可用(流量大了更不能挂)
→ 但它们需要的技术不完全相同
→ 本书聚焦"高并发",但会涉及高可用和高性能的手段| 维度 | 高并发 | 高可用 | 高性能 |
|---|---|---|---|
| 核心指标 | QPS / TPS | 可用率(99.99%) | RT / P99 |
| 核心手段 | 扩容 + 缓存 + 异步 | 冗余 + 故障转移 | 优化算法 + 减少 IO |
| 典型场景 | 秒杀、春晚红包 | 银行转账、支付 | 搜索引擎、游戏实时对战 |
| 本书覆盖 | ✅ 主线 | 部分涉及 | 部分涉及 |
1.3 瓶颈定位:CPU、IO、网络、数据库谁先扛不住
高并发系统的四类瓶颈:
CPU 瓶颈
═══════════════════════════════════
表现:CPU 使用率 > 80%,请求响应慢
典型场景:复杂计算、JSON 序列化、加密解密
排查:top / htop 看 CPU 使用率
解法:优化算法、加缓存减少重复计算、水平扩展
IO 瓶颈(磁盘/文件)
═══════════════════════════════════
表现:磁盘 IO wait 高,数据库查询慢
典型场景:大量日志写入、文件上传、数据库全表扫描
排查:iostat / iotop 看磁盘读写
解法:SSD、减少磁盘操作、读写分离
网络瓶颈
═══════════════════════════════════
表现:带宽打满、大量 TIME_WAIT 连接
典型场景:返回大 JSON、文件下载、微服务间调用过多
排查:iftop / netstat 看连接数和带宽
解法:压缩响应、CDN、减少网络往返次数
数据库瓶颈(最常见!)
═══════════════════════════════════
表现:慢查询、连接数打满、锁等待
典型场景:几乎所有 Web 应用的第一个瓶颈
排查:慢查询日志、EXPLAIN、连接池监控
解法:加缓存(第 2 章)、读写分离(第 6 章)、优化 SQL瓶颈排查优先级(大多数 Web 应用):
典型后端系统的瓶颈出现顺序:
数据库 → 网络 → CPU → 磁盘 IO
━━━━━━━ ━━━━ ━━━ ━━━━━━
最先扛不住 其次 较少 最少
所以高并发优化的通常路径是:
1. 加缓存(减少数据库压力) → 第 2 章
2. 加限流(保护数据库不被打爆) → 第 3 章
3. 异步化(数据库写入削峰) → 第 5 章
4. 读写分离(分担数据库负载) → 第 6 章常见系统的 QPS 基准线(单机参考):
| 组件 | 典型 QPS | 说明 |
|---|---|---|
| Nginx(静态文件) | 10,000 - 100,000 | 纯转发,几乎不消耗 CPU |
| Python Web(FastAPI) | 1,000 - 5,000 | 简单接口,无数据库 |
| MySQL(简单查询) | 3,000 - 8,000 | 主键查询,行级锁 |
| Redis(读取) | 100,000+ | 纯内存,极快 |
| Python Web + MySQL | 500 - 2,000 | 真实场景的瓶颈 |
💡 记住一个经验数字:单台 MySQL 在不做任何优化的情况下,大约能扛 3000-5000 QPS。你的 Web 应用如果 QPS 超过这个数字,第一反应应该是"先加 Redis 缓存"。
第 1 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| QPS | 每秒处理的请求数,衡量吞吐量 |
| P99 RT | 99% 请求的响应时间,比平均值更能反映真实体验 |
| QPS 公式 | QPS = 并发数 / 平均 RT,降 RT 是提高 QPS 最直接的手段 |
| 最常见瓶颈 | 数据库——几乎所有 Web 应用第一个扛不住的都是它 |
| 优化路径 | 缓存 → 限流 → 异步 → 读写分离,按这个顺序来 |
2. 缓存:高并发的第一道防线
上一章说了,数据库是大多数系统最先扛不住的组件。解决方案的第一步几乎永远是——加缓存。这一章讲清楚缓存的策略选择和三大致命问题。
2.1 为什么加 Redis 就能扛 10 倍流量
MySQL vs Redis 速度对比:
MySQL(磁盘)
═══════════════════════════════════
→ 数据存在磁盘上,查询需要磁盘 IO
→ 简单查询:1-10ms
→ 复杂查询(JOIN + WHERE):10-100ms
→ 单机 QPS 上限:3000-8000
Redis(内存)
═══════════════════════════════════
→ 数据存在内存里,纯内存操作
→ 读取延迟:0.1-0.5ms(比 MySQL 快 10-100 倍)
→ 单机 QPS 上限:100,000+
→ 所以:把热数据放 Redis,数据库压力瞬间降 10 倍加缓存前后的架构对比:
不加缓存:
═══════════════════════════════════
客户端 ──▶ Web API ──▶ MySQL(每次都查库)
↑
QPS 5000 就顶不住了
加了 Redis 缓存:
═══════════════════════════════════
客户端 ──▶ Web API ──▶ Redis(命中?返回!)
│
╳ 未命中
│
▼
MySQL(查库 → 写回 Redis)
→ 假设缓存命中率 90%
→ 10000 QPS × 10% = 只有 1000 QPS 打到 MySQL
→ MySQL 轻松应对缓存命中率与数据库压力的关系:
| 缓存命中率 | 总 QPS = 10,000 时到达 MySQL 的 QPS | 数据库压力 |
|---|---|---|
| 0%(无缓存) | 10,000 | 💀 扛不住 |
| 80% | 2,000 | 😰 有点紧张 |
| 90% | 1,000 | 😊 轻松 |
| 95% | 500 | 😎 很舒服 |
| 99% | 100 | 🎉 毫无压力 |
💡 缓存命中率从 90% 提升到 99%,数据库压力降低了 10 倍。高并发优化的核心就是想尽办法提高缓存命中率。常见手段:合理设置过期时间、预热热点数据、使用多级缓存。
2.2 三种缓存模式:Cache-Aside、Read-Through、Write-Behind
三种模式的核心区别:
Cache-Aside(旁路缓存)— 最常用 ⭐
═══════════════════════════════════
读:先查缓存 → 未命中 → 查数据库 → 写入缓存
写:先写数据库 → 删除缓存(不是更新缓存!)
→ 业务代码自己控制缓存逻辑
→ 90% 的场景用这个就够了
Read-Through(读穿透)
═══════════════════════════════════
读:业务只和缓存交互 → 缓存内部去查数据库
写:同 Cache-Aside
→ 缓存层封装了数据库访问,业务代码更简单
→ 需要缓存中间件支持(如 Spring Cache)
Write-Behind(异步写回)
═══════════════════════════════════
写:先写缓存 → 异步批量写数据库
→ 写性能极高(不等数据库)
→ 风险:缓存挂了数据就丢了
→ 适合:日志、计数器等允许少量丢失的场景Cache-Aside 模式 Python 代码:
import redis
import json
r = redis.Redis()
async def get_product(product_id: int):
"""Cache-Aside 读取"""
# 1. 先查缓存
cache_key = f"product:{product_id}"
cached = r.get(cache_key)
if cached:
return json.loads(cached) # 命中,直接返回
# 2. 未命中,查数据库
product = await db.fetch_product(product_id)
if product is None:
# 🔥 防穿透:缓存空值,短过期
r.setex(cache_key, 60, json.dumps(None))
return None
# 3. 写入缓存(设置过期时间)
r.setex(cache_key, 3600, json.dumps(product))
return product
async def update_product(product_id: int, data: dict):
"""Cache-Aside 写入"""
# 1. 先写数据库
await db.update_product(product_id, data)
# 2. 删除缓存(不是更新!)
r.delete(f"product:{product_id}")💡 为什么删除缓存而不是更新缓存? 因为并发场景下,两个请求同时更新缓存可能导致脏数据。假设 A 写完DB、B 写完DB、B 更新缓存、A 更新缓存——缓存里存的是 A 的旧值。删除缓存让下一次读重新从 DB 加载,简单且安全。
| 模式 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| Cache-Aside | 较好 | 好 | 低 | ✅ 大多数场景首选 |
| Read-Through | 较好 | 好 | 中 | 框架支持时使用 |
| Write-Behind | 弱(可能丢数据) | 极高 | 高 | 日志、计数器、允许丢失 |
2.3 缓存三大杀手:穿透、雪崩、击穿
三大缓存问题——面试高频 + 生产中真的会遇到:
缓存穿透(Cache Penetration)
═══════════════════════════════════
问题:查询一个不存在的数据(如 id = -1)
→ 缓存没有 → 数据库也没有 → 每次都穿透到 DB
→ 恶意攻击大量请求不存在的 ID → 数据库被打爆
解决方案:
① 缓存空值:查不到也缓存 None,短过期(60s)
② 布隆过滤器:请求先过滤器判断 ID 是否可能存在
→ 不存在的 ID 直接拒绝,不查库
③ 参数校验:ID < 0 直接拒绝
缓存雪崩(Cache Avalanche)
═══════════════════════════════════
问题:大量缓存同时过期 → 所有请求瞬间打到 DB
→ 通常发生在:缓存批量导入时设了相同过期时间
→ 或 Redis 节点宕机
解决方案:
① 过期时间加随机值:TTL = 3600 + random(0, 300)
② 多级缓存:本地缓存 + Redis,Redis 挂了本地兜底
③ 限流降级:缓存失效时对 DB 查询限流
④ Redis 高可用:哨兵 + 集群,避免单点故障
缓存击穿(Cache Breakdown)
═══════════════════════════════════
问题:某个热点 Key 过期的瞬间,大量请求同时打到 DB
→ 和雪崩的区别:雪崩是大面积过期,击穿是单个热点 Key
→ 例子:首页推荐数据缓存过期 → 瞬间 10000 请求查 DB
解决方案:
① 互斥锁:只让一个请求查 DB,其他等待
② 热点不过期:热点 Key 永不过期,后台异步更新
③ 逻辑过期:缓存里存过期时间,发现过期后异步更新互斥锁防击穿 Python 实现:
async def get_hot_data(key: str):
"""互斥锁防击穿"""
cached = r.get(key)
if cached:
return json.loads(cached)
# 尝试获取分布式锁
lock_key = f"lock:{key}"
if r.set(lock_key, "1", nx=True, ex=5): # 5 秒自动释放
try:
# 拿到锁,查数据库
data = await db.fetch(key)
r.setex(key, 3600, json.dumps(data))
return data
finally:
r.delete(lock_key)
else:
# 没拿到锁,等 50ms 后重试
await asyncio.sleep(0.05)
return await get_hot_data(key) # 递归重试| 问题 | 原因 | 核心解法 |
|---|---|---|
| 穿透 | 查不存在的数据 | 缓存空值 + 布隆过滤器 |
| 雪崩 | 大量缓存同时过期 | 过期时间加随机值 |
| 击穿 | 热点 Key 过期 | 互斥锁 / 永不过期 |
💡 面试回答技巧:穿透 → "查不存在的";雪崩 → "大面积同时过期";击穿 → "单个热点过期"。先说清楚区别,再说解决方案,最后说你在项目中用了哪个。
第 2 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| 缓存命中率 | 90%→99% 数据库压力降 10 倍,是最关键的指标 |
| Cache-Aside | 先查缓存→未命中查 DB→写缓存,90% 场景用这个 |
| 删除而非更新 | 写 DB 后删缓存,避免并发更新导致脏数据 |
| 穿透 | 查不存在的数据,用缓存空值 + 布隆过滤器 |
| 雪崩/击穿 | 大面积 / 单热点过期,用随机 TTL / 互斥锁 |
3. 限流:保护系统的最后一道门
缓存能扛住大部分读流量,但对于写操作(下单、支付)或缓存穿透场景,流量还是会打到后端。限流就是在系统扛不住之前,主动拒绝超出能力的请求——宁可让少数用户失败,也不能让所有用户一起挂。
3.1 不限流的后果:一个请求拖垮整个系统
不限流的连锁崩溃过程:
正常状态(QPS = 1000,系统能力 = 2000)
═══════════════════════════════════
请求 ──▶ Web 服务 ──▶ 数据库 ✅ 一切正常
突发流量(QPS = 5000,超出系统能力)
═══════════════════════════════════
Step 1: 5000 请求涌入 → 数据库连接池打满
Step 2: 新请求排队等连接 → 响应时间从 50ms 飙到 5s
Step 3: 前端超时重试 → 请求量翻倍到 10000
Step 4: 线程池/进程池全被占满 → 健康检查接口也超时
Step 5: 负载均衡认为节点挂了 → 流量转到其他节点
Step 6: 其他节点也被打爆 → 全站崩溃 💀
核心问题:没有"保险丝"
→ 电路过载了要跳闸(熔断 → 第 4 章)
→ 水压太大了要减压阀(限流 → 本章)限流的本质——有损保护:
不限流 限流
══════ ════
QPS = 5000 全部放进来 只放 2000 进来
↓ ↓
结果 全部超时 5s 2000 正常 + 3000 快速返回 429
↓ ↓
用户体验 所有人都挂 60% 用户正常,40% 稍后重试💡 限流不是目的,保护系统才是。被限流的请求应该快速返回错误(HTTP 429 Too Many Requests),而不是在队列里干等。快速失败 + 前端引导重试 = 用户体验可控。
3.2 四种限流算法:从固定窗口到令牌桶
四种限流算法对比:
① 固定窗口(Fixed Window)
═══════════════════════════════════
原理:每个时间窗口(如 1 秒)内计数,超过阈值就拒绝
|----1s----|----1s----|
| 100 请求 | 100 请求 | 限制:100/s
缺点:窗口边界可能突发 2 倍流量
→ 第 1 秒末 99 个 + 第 2 秒初 99 个 = 1 秒内 198 个 ❌
② 滑动窗口(Sliding Window)
═══════════════════════════════════
原理:窗口按时间滑动,精确统计最近 N 秒的请求数
|---滑动---▶|
更精确,但内存开销大(要记录每个请求的时间戳)
③ 漏桶(Leaky Bucket)
═══════════════════════════════════
原理:请求先进桶,以固定速率漏出处理
│ 请求涌入 │
▼ ▼
┌────────────────┐
│ 桶(队列) │ ← 桶满了就拒绝
└──────┬─────────┘
▼ 固定速率漏出
处理请求
优点:输出速率恒定
缺点:突发流量也被平滑,无法利用空闲时段
④ 令牌桶(Token Bucket)— 最推荐 ⭐
═══════════════════════════════════
原理:以固定速率生成令牌,请求拿到令牌才能通过
→ 桶里有令牌 → 直接通过
→ 桶空了 → 等待或拒绝
→ 桶可以积攒令牌 → 允许一定突发流量
优点:平时稳定,突发时可以用积攒的令牌令牌桶 Python 实现:
import time
class TokenBucket:
"""令牌桶限流器"""
def __init__(self, rate: float, capacity: int):
self.rate = rate # 每秒生成令牌数
self.capacity = capacity # 桶的最大容量
self.tokens = capacity # 当前令牌数
self.last_time = time.time()
def allow(self) -> bool:
"""检查是否允许通过"""
now = time.time()
# 补充令牌
elapsed = now - self.last_time
self.tokens = min(
self.capacity,
self.tokens + elapsed * self.rate
)
self.last_time = now
# 消耗令牌
if self.tokens >= 1:
self.tokens -= 1
return True # 允许通过
return False # 限流拒绝
# 使用:每秒 100 个请求,最多积攒 200 个令牌
limiter = TokenBucket(rate=100, capacity=200)
if limiter.allow():
handle_request()
else:
return Response(status_code=429, content="请求过多,请稍后重试")| 算法 | 突发流量 | 精确度 | 复杂度 | 推荐场景 |
|---|---|---|---|---|
| 固定窗口 | ❌ 边界突发 | 低 | 最低 | 简单场景、原型 |
| 滑动窗口 | ✅ 精确 | 高 | 中 | 精确限流 |
| 漏桶 | ❌ 强制平滑 | 高 | 中 | 恒定速率处理 |
| 令牌桶 | ✅ 允许突发 | 高 | 中 | ✅ 大多数场景首选 |
3.3 分布式限流:Redis + Lua 实战
单机令牌桶只能限单个进程的流量。分布式环境下多台机器共享限流计数器,需要 Redis。
为什么用 Redis + Lua?
问题:多台机器分别计数 → 总量超标
═══════════════════════════════════
限制 100/s,4 台机器各限 25/s?
→ 不均匀!某台可能收到 80 请求,其他 3 台只收 20
→ 必须用全局计数器
为什么用 Lua?
═══════════════════════════════════
→ Redis 是单线程,但 GET + INCR + EXPIRE 是 3 个命令
→ 两个请求可能在 GET 和 INCR 之间交错 → 计数不准
→ Lua 脚本在 Redis 中原子执行,不会被中断Redis + Lua 滑动窗口限流脚本:
import redis
import time
r = redis.Redis()
# Lua 脚本:滑动窗口限流(原子操作)
SLIDING_WINDOW_SCRIPT = """
local key = KEYS[1]
local window = tonumber(ARGV[1]) -- 窗口大小(秒)
local limit = tonumber(ARGV[2]) -- 窗口内最大请求数
local now = tonumber(ARGV[3]) -- 当前时间戳(毫秒)
-- 移除窗口外的记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000)
-- 当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count < limit then
-- 未超限,添加当前请求
redis.call('ZADD', key, now, now .. math.random())
redis.call('EXPIRE', key, window)
return 1 -- 允许
else
return 0 -- 拒绝
end
"""
# 注册脚本
script = r.register_script(SLIDING_WINDOW_SCRIPT)
def is_allowed(user_id: str, window: int = 1, limit: int = 100) -> bool:
"""检查是否允许通过"""
key = f"rate_limit:{user_id}"
now = int(time.time() * 1000) # 毫秒时间戳
result = script(keys=[key], args=[window, limit, now])
return result == 1FastAPI 中间件接入:
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
client_ip = request.client.host
if not is_allowed(client_ip, window=1, limit=100):
raise HTTPException(
status_code=429,
detail="请求过于频繁,请稍后重试"
)
return await call_next(request)💡 生产环境限流策略:① API 网关层(Nginx / Kong)做全局粗粒度限流 ② 应用层做细粒度限流(按用户/按接口)③ 关键接口单独配限流规则(如下单接口 50/s/用户)。
第 3 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| 限流本质 | 有损保护——拒绝部分请求,保住整个系统 |
| 令牌桶 | 允许突发流量 + 整体限速,大多数场景首选 |
| Redis + Lua | 分布式限流的标准方案,原子操作保证计数准确 |
| HTTP 429 | 限流返回码,配合 Retry-After 头部引导重试 |
| 分层限流 | 网关粗粒度 + 应用细粒度,双重保护 |
4. 熔断与降级:优雅地失败
限流保护的是"入口"——拒绝超量请求。但如果问题出在下游(调用的某个服务挂了),限流帮不上忙。这时候需要熔断(断开故障服务)和降级(用备选方案兜底)。
4.1 服务雪崩:一个慢接口引发的连锁事故
服务雪崩的真实路径:
正常状态:
═══════════════════════════════════
订单服务 ──▶ 库存服务(50ms)──▶ 返回 ✅
订单服务 ──▶ 支付服务(100ms)──▶ 返回 ✅
订单服务 ──▶ 推荐服务(30ms)──▶ 返回 ✅
推荐服务出问题了(RT 从 30ms 飙到 10s):
═══════════════════════════════════
Step 1: 订单服务调推荐服务 → 等待 10s → 线程被占住
Step 2: 请求不断涌入 → 线程池 50 个线程全在等推荐服务
Step 3: 新的订单请求进来 → 没有空闲线程 → 排队
Step 4: 订单接口从 100ms 变成 10s+ → 前端超时
Step 5: 用户看到下单失败 → 疯狂重试
Step 6: 订单服务彻底挂了 → 连带库存和支付也用不了 💀
根本原因:
→ 一个非核心服务(推荐)的故障
→ 拖垮了核心服务(订单)
→ 因为没有隔离和熔断机制限流 vs 熔断 vs 降级——三者的区别:
限流(第 3 章):
→ 控制入口流量,"不让太多人进来"
→ 保护自己不被打爆
熔断(本章 4.2):
→ 发现下游故障后快速失败,"不去叫他了"
→ 防止故障传播、保护上游
降级(本章 4.3):
→ 故障时提供备选方案,"虽然不完美但能用"
→ 保证核心功能可用💡 类比:限流是高速公路入口的红绿灯限行。熔断是发现前方桥断了,直接掉头不过去。降级是桥断了走旁边的小路(慢一点但能到)。
4.2 熔断器模式:Closed → Open → Half-Open
熔断器状态机(来自 Netflix Hystrix 的经典设计):
┌──────────┐ 失败率 > 阈值 ┌──────────┐
│ CLOSED │ ═══════════════▶ │ OPEN │
│ (正常) │ │ (熔断) │
└────┬─────┘ └────┬─────┘
↑ │
│ 探测请求成功 │ 等待超时时间
│ ↓
│ ┌──────────┐
└═════════════════════════│HALF-OPEN │
失败 → 回 OPEN │ (探测) │
└──────────┘
CLOSED(关闭 = 正常请求通过):
→ 正常调用下游服务
→ 统计失败率,超过阈值 → 切到 OPEN
OPEN(打开 = 请求被拦截):
→ 所有请求直接返回错误(快速失败)
→ 不再调用下游服务
→ 等待一段时间(如 30 秒)→ 切到 HALF-OPEN
HALF-OPEN(半开 = 试探性恢复):
→ 放一个请求去试探下游
→ 成功 → 切回 CLOSED,恢复正常
→ 失败 → 切回 OPEN,继续等待Python 熔断器实现:
import time
from enum import Enum
class State(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitBreaker:
"""简易熔断器"""
def __init__(
self,
failure_threshold: int = 5, # 连续失败 N 次触发熔断
recovery_timeout: int = 30, # 熔断后等待 N 秒再探测
):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.state = State.CLOSED
self.failure_count = 0
self.last_failure_time = 0
async def call(self, func, *args, fallback=None, **kwargs):
"""带熔断的调用"""
# OPEN 状态:快速失败
if self.state == State.OPEN:
if time.time() - self.last_failure_time > self.recovery_timeout:
self.state = State.HALF_OPEN # 超时了,试探
elif fallback:
return await fallback() # 执行降级
else:
raise Exception("Circuit breaker is OPEN")
try:
result = await func(*args, **kwargs)
# 成功:重置计数
if self.state == State.HALF_OPEN:
self.state = State.CLOSED # 探测成功,恢复
self.failure_count = 0
return result
except Exception as e:
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = State.OPEN # 触发熔断
if fallback:
return await fallback()
raise e
# 使用示例
breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=30)
async def get_recommendations(user_id):
return await breaker.call(
recommendation_service.get, # 正常调用
user_id,
fallback=lambda: {"items": []} # 降级:返回空推荐
)4.3 降级策略:体面地告诉用户"稍后再试"
熔断之后不能只返回错误——要有降级方案,让用户感知尽量小。
四种降级策略:
① 返回默认值 / 空结果
═══════════════════════════════════
场景:推荐服务挂了
降级:返回空推荐列表 []
用户感知:推荐位空了,但不影响下单
② 缓存兜底
═══════════════════════════════════
场景:商品详情服务超时
降级:返回上次缓存的商品数据(可能不是最新价格)
用户感知:看到的可能是 5 分钟前的价格
③ 功能裁剪(关闭非核心功能)
═══════════════════════════════════
场景:大促期间系统压力大
降级:关闭评论、推荐、个性化等非核心功能
用户感知:只能浏览和下单,暂时不能写评论
④ 排队等待
═══════════════════════════════════
场景:秒杀抢购
降级:把请求放入队列,"您前面还有 XX 人"
用户感知:在排队,比直接报错体验好降级决策的核心原则:
功能重要性分级:
P0(绝不能降级):下单、支付、登录
P1(最后才降级):商品详情、库存查询
P2(可以降级):推荐、评论、历史记录
P3(优先降级):数据统计、日志上报、非实时通知
压力逐级增大时的降级顺序:
P3 → P2 → P1(P0 永远不降级)# FastAPI 降级装饰器示例
from functools import wraps
def with_fallback(fallback_value):
"""降级装饰器:异常时返回兜底值"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except Exception as e:
logger.warning(f"{func.__name__} 降级: {e}")
return fallback_value
return wrapper
return decorator
@with_fallback(fallback_value={"items": [], "degraded": True})
async def get_recommendations(user_id: int):
"""获取推荐——如果推荐服务挂了,返回空列表"""
return await recommendation_service.get(user_id)💡 降级不是 bug,是设计好的预案。上线前就要想清楚:每个外部依赖挂了怎么办?写在代码里,而不是等线上出事再临时想方案。
第 4 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| 服务雪崩 | 一个慢服务拖垮整个调用链 |
| 熔断器三态 | CLOSED→OPEN→HALF-OPEN,自动探测恢复 |
| 降级策略 | 默认值 / 缓存兜底 / 功能裁剪 / 排队等待 |
| 功能分级 | P0 绝不降级,P3 优先降级 |
| 限流 vs 熔断 | 限流保护入口,熔断保护调用链 |
5. 队列削峰:把洪水变成细流
限流是"拒绝多余请求",但有些请求不能拒绝(比如用户下单)。这时候的思路是——不拒绝,但不立刻处理。先放到队列里,后台慢慢消化。
5.1 同步 vs 异步:为什么不是所有请求都要立刻处理
同步处理(传统模式):
═══════════════════════════════════
用户下单 ──▶ 扣库存 ──▶ 创建订单 ──▶ 发短信 ──▶ 返回成功
总耗时:500ms
→ 用户等 500ms 才能看到结果
→ 每个步骤都占着线程/连接
→ QPS 受最慢环节限制
异步处理(队列模式):
═══════════════════════════════════
用户下单 ──▶ 扣库存 ──▶ 创建订单 ──▶ 返回"下单成功"(200ms)
│
▼ 异步
消息队列 ──▶ 发短信
──▶ 更新统计
──▶ 推送通知
→ 用户只等核心操作(200ms)
→ 非核心操作异步执行,不阻塞主流程哪些操作可以异步化?
| 操作 | 同步/异步 | 理由 |
|---|---|---|
| 扣库存 | ✅ 同步 | 必须立刻确认(防超卖) |
| 创建订单 | ✅ 同步 | 用户需要看到订单号 |
| 发短信/邮件 | 异步 ✅ | 延迟几秒用户无感 |
| 更新统计报表 | 异步 ✅ | 不影响用户体验 |
| 推送通知 | 异步 ✅ | 延迟可接受 |
| 生成发票 | 异步 ✅ | 后台处理,用户稍后查看 |
削峰填谷的原理:
没有队列(同步处理):
═══════════════════════════════════
请求量: ████████████ (峰值 5000 QPS)
处理量: ████████████ (系统也必须 5000 QPS)
→ 系统处理能力 < 峰值 → 崩溃 💀
有队列(异步处理):
═══════════════════════════════════
请求量: ████████████ (峰值 5000 QPS)
队列: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ (缓冲)
处理量: ████ ████ ████ ████ ████ (稳定 1000 QPS)
→ 峰值被"削平",低谷被"填满"
→ 系统以恒定速率处理,不会崩溃💡 核心思维转换:"用户点了下单,一定要在这个请求里完成所有事吗?" 不一定。只做必须同步的(扣库存、创建订单),其他全部扔到队列异步处理。
5.2 消息队列削峰:秒杀场景实战
秒杀场景的队列削峰架构:
不用队列(直接处理):
═══════════════════════════════════
10:00:00 秒杀开始 → 10000 请求同时到达
→ 数据库连接池 100 → 9900 请求排队
→ 数据库 CPU 100% → 超时 → 全部失败 💀
用队列(削峰):
═══════════════════════════════════
用户请求 ──▶ 接入层(校验 + 限流)
│
▼ 放入队列(瞬间完成)
┌──────────┐
│ 消息队列 │ ← 10000 条消息缓冲
└────┬─────┘
▼ 消费者以稳定速率处理
扣库存 + 创建订单(500/s)
│
▼
推送结果给用户(WebSocket / 轮询)Redis List 作为轻量消息队列:
import redis
import json
import time
r = redis.Redis()
# ========= 生产者(接收请求,放入队列)=========
async def seckill_request(user_id: int, product_id: int):
"""秒杀请求:校验后放入队列"""
# 1. 前置校验(快速拒绝)
stock = r.get(f"stock:{product_id}")
if not stock or int(stock) <= 0:
return {"success": False, "msg": "已售罄"}
# 2. 放入队列(毫秒级完成)
message = json.dumps({
"user_id": user_id,
"product_id": product_id,
"timestamp": time.time()
})
r.lpush("seckill_queue", message)
return {"success": True, "msg": "排队中,请稍候查询结果"}
# ========= 消费者(后台稳定处理)=========
def seckill_consumer():
"""消费者:从队列取消息,逐个处理"""
while True:
# BRPOP:阻塞等待,有消息才返回
result = r.brpop("seckill_queue", timeout=5)
if result is None:
continue
_, message = result
data = json.loads(message)
try:
# 原子扣减库存(Lua 脚本保证原子性)
stock = r.decr(f"stock:{data['product_id']}")
if stock < 0:
# 库存不足,恢复
r.incr(f"stock:{data['product_id']}")
notify_user(data["user_id"], "抢购失败,已售罄")
continue
# 创建订单(写数据库)
create_order(data["user_id"], data["product_id"])
notify_user(data["user_id"], "抢购成功!")
except Exception as e:
# 处理失败,放回队列重试(或放入死信队列)
r.lpush("seckill_dead_letter", message)
logger.error(f"秒杀处理失败: {e}")5.3 消费者设计:并发控制与消息积压
消费者的三个关键问题:
① 消费速度怎么控制?
═══════════════════════════════════
→ 消费太快 → 数据库又被打爆(削了个寂寞)
→ 消费太慢 → 消息堆积,用户等太久
→ 最佳实践:消费速率 = 数据库安全水位的 80%
→ 例子:DB 能扛 1000 QPS → 消费速率设 800/s
② 消息积压了怎么办?
═══════════════════════════════════
→ 监控队列长度,积压超过阈值立刻告警
→ 紧急方案:临时增加消费者数量
→ 极端方案:把消息转存到数据库/文件,稍后批量处理
→ 过期消息:超过 5 分钟的秒杀请求直接丢弃
③ 消息处理失败怎么办?
═══════════════════════════════════
→ 重试:失败的消息放回队列尾部,最多重试 3 次
→ 死信队列:重试 3 次还失败 → 放入死信队列人工处理
→ 幂等性:同一消息被处理 2 次结果应该一样
→ 用唯一订单号做幂等键,防止重复下单消息队列选型:
| 队列 | 适用场景 | QPS 级别 | 特点 |
|---|---|---|---|
| Redis List | 轻量级、延迟敏感 | 10 万+ | 简单、无持久化保证 |
| RabbitMQ | 企业级、需要 ACK | 1-5 万 | 成熟、功能丰富、保证投递 |
| Kafka | 大数据、日志流 | 百万级 | 高吞吐、持久化、但延迟稍高 |
| Redis Streams | Redis 生态、需要消费组 | 10 万+ | Redis 5.0+,像轻量版 Kafka |
# 多消费者并发处理(线程池模式)
import concurrent.futures
def run_consumers(num_workers: int = 4):
"""启动多个消费者并发处理"""
with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as pool:
futures = [pool.submit(seckill_consumer) for _ in range(num_workers)]
# 等待(实际生产中用更优雅的停止机制)
concurrent.futures.wait(futures)
# 4 个消费者 × 200/s = 总消费速率 800/s💡 务实建议:小项目用 Redis List 就够了(代码简单、延迟低)。当你需要消息持久化、ACK 确认、死信队列等企业级特性时,再切换到 RabbitMQ。Kafka 留给日志和大数据场景。
第 5 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| 削峰填谷 | 用队列缓冲峰值流量,消费者以稳定速率处理 |
| 同步 vs 异步 | 核心操作同步,非核心(通知/统计)异步 |
| BRPOP | Redis 阻塞取队列,有消息才返回,不浪费 CPU |
| 死信队列 | 处理失败的消息归档,防止丢失 + 人工介入 |
| 幂等性 | 同一消息处理 N 次结果相同,防止重复操作 |
6. 数据库优化:高并发下的读写分离与分库分表
前面 5 章的手段(缓存、限流、熔断、队列)本质上都是在"减少到达数据库的请求数"。但最终总有请求要落到数据库——这一章讲怎么让数据库本身更能抗。
6.1 为什么数据库总是第一个扛不住
数据库的四大瓶颈:
① 连接数有限
═══════════════════════════════════
→ MySQL 默认 max_connections = 151
→ 每个连接占用内存(约 10MB)
→ 1000 连接 = 10GB 内存只用来维护连接
→ 连接打满 → 新请求直接报错 "Too many connections"
② 锁竞争严重
═══════════════════════════════════
→ 行锁:多个请求同时更新同一行 → 排队等锁
→ 表锁:DDL 操作会锁整张表
→ 死锁:A 等 B,B 等 A → 数据库自动检测并回滚一个
→ 锁等待 → 响应时间飙升
③ 磁盘 IO
═══════════════════════════════════
→ 数据最终存在磁盘上(即使有 Buffer Pool)
→ 复杂查询、全表扫描 → 大量随机磁盘读
→ 写操作需要 WAL(Write-Ahead Log)→ 磁盘顺序写
④ CPU 计算
═══════════════════════════════════
→ 复杂 JOIN、子查询、排序 → CPU 密集
→ 大结果集的序列化和网络传输典型 Web 应用的读写比例:
大多数 Web 应用的读写比 = 8:2 到 9:1
电商商品页:读 95%,写 5%
社交 Feed 流:读 90%,写 10%
后台管理系统:读 70%,写 30%
这意味着:
→ 读请求是主要压力来源
→ 优化读(加缓存→第 2 章,读写分离→本章 6.2)
→ 保护写(限流→第 3 章,队列→第 5 章)💡 数据库不是用来抗高并发的——它是用来保证数据一致性的。高并发的本质是尽量把流量挡在数据库前面(缓存/队列),让到达数据库的请求量在安全范围内。
6.2 读写分离:一主多从架构
读写分离架构:
写请求 ──▶ 主库(Master)
│
│ 主从复制(binlog 同步)
↓
读请求 ──▶ 从库 1(Slave)
──▶ 从库 2(Slave)
──▶ 从库 3(Slave)
效果:
→ 写操作全走主库(1 台)
→ 读操作分散到从库(N 台)
→ 读 QPS 提升 N 倍(加从库就行)
→ 3 个从库 = 读 QPS × 3Python 应用层路由实现:
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
import random
# 主库(写)
master_engine = create_engine("postgresql://user:pass@master:5432/mydb")
# 从库(读)
slave_engines = [
create_engine("postgresql://user:pass@slave1:5432/mydb"),
create_engine("postgresql://user:pass@slave2:5432/mydb"),
]
def get_read_session() -> Session:
"""读请求:随机选一个从库"""
engine = random.choice(slave_engines)
return Session(engine)
def get_write_session() -> Session:
"""写请求:走主库"""
return Session(master_engine)
# 使用
async def get_product(product_id: int):
with get_read_session() as session: # 读 → 从库
return session.query(Product).get(product_id)
async def create_order(data: dict):
with get_write_session() as session: # 写 → 主库
order = Order(**data)
session.add(order)
session.commit()主从延迟问题:
写后读不一致场景:
t0: 用户更新昵称 → 写主库 → 成功
t1: 用户刷新页面 → 读从库 → 还是旧昵称!
→ 因为主从复制有延迟(通常 10-100ms)
解决方案:
═══════════════════════════════════
① 关键读走主库:刚写完的数据,短时间内读主库
② 强制延迟读:写完等 200ms 再返回
③ 版本号/时间戳:前端带上写入时间,从库数据旧就重查主库
④ 缓存兜底:写完同时更新缓存,读缓存而非从库💡 主从延迟是读写分离最大的坑。最简单的解法:写操作完成后,在 Redis 里标记
user:{id}:just_wrote = 1(TTL 2 秒),2 秒内该用户的读请求走主库。
6.3 连接池调优与慢 SQL 治理
连接池关键参数:
# SQLAlchemy 连接池配置
engine = create_engine(
"postgresql://user:pass@localhost:5432/mydb",
pool_size=20, # 常驻连接数
max_overflow=10, # 允许额外创建的连接数(峰值)
pool_timeout=10, # 获取连接超时(秒)
pool_recycle=3600, # 连接回收时间(防止被 DB 断开)
pool_pre_ping=True, # 使用前 ping 检查连接是否存活
)
# 总最大连接数 = pool_size + max_overflow = 30
# 超过 30 个并发请求 → 等待 pool_timeout → 超时报错慢 SQL 排查五步法:
Step 1: 开启慢查询日志
═══════════════════════════════════
-- MySQL
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5; -- 超过 500ms 的算慢查询
Step 2: 找到最慢的 SQL
→ mysqldumpslow 分析慢查询日志
→ 或查看 APM 工具(Prometheus / Datadog)
Step 3: EXPLAIN 分析执行计划
═══════════════════════════════════
EXPLAIN SELECT * FROM orders WHERE user_id = 123;
→ type = ALL → 全表扫描 ❌
→ type = ref → 用了索引 ✅
→ rows = 1000000 → 扫描了 100 万行 ❌
Step 4: 加索引
═══════════════════════════════════
CREATE INDEX idx_orders_user_id ON orders(user_id);
→ 再 EXPLAIN → type = ref, rows = 50 ✅
Step 5: 持续监控
→ 新代码上线后关注慢查询数量变化SQL 优化速查表:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 全表扫描 | WHERE 字段没索引 | 加索引 |
| 索引失效 | LIKE '%xxx' / 函数包裹字段 | 避免前缀通配符、避免函数 |
| 查询太多字段 | SELECT * | 只查需要的字段 |
| 大分页 | OFFSET 100000 | 改用游标分页(WHERE id > last_id) |
| N+1 查询 | ORM 懒加载 | 用 joinedload / selectinload |
| 锁等待 | 长事务持有锁 | 减小事务范围、拆分大事务 |
💡 一条经验:90% 的慢查询都是因为缺索引。上线前用 EXPLAIN 检查每个核心 SQL 的执行计划,确保
type不是ALL(全表扫描)。
第 6 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| 四大瓶颈 | 连接数、锁竞争、磁盘 IO、CPU,通常连接数先爆 |
| 读写分离 | 写走主库,读走从库,读 QPS 按从库数量线性扩展 |
| 主从延迟 | 写后立刻读可能读到旧数据,用 Redis 标记 + 短时走主库 |
| 连接池 | pool_size + max_overflow = 最大并发数,不能设太大 |
| 慢 SQL | EXPLAIN 看执行计划,90% 的问题是缺索引 |
7. 连接池与资源管理:别让"池子"成为瓶颈
第 6 章提到了数据库连接池配置。这章把连接池讲透——不只是数据库,HTTP 调用、Redis 访问都有连接池的问题。连接池用不好,就是高并发的隐形杀手。
7.1 连接池的本质:为什么不能每次都新建连接
一次数据库连接的成本:
创建连接:
═══════════════════════════════════
① TCP 三次握手 → 1-3ms
② 数据库认证(用户名/密码)→ 1-2ms
③ 分配内存/创建线程 → 1-2ms
④ 初始化会话参数 → 0.5ms
──────────────────────────────────
总计:3-8ms(每次请求都要付出的"建连成本")
如果你的 SQL 查询只要 2ms
→ 建连 5ms + 查询 2ms = 7ms(70% 时间在建连!)
→ 连接池:建连一次,复用 N 次 → 查询 2ms ✅
不用连接池 用连接池
══════════ ═════════
每次请求:建连+查询+断连 请求:从池中借+查询+归还
5+2+1 = 8ms 0+2+0 = 2ms
开销 4 倍! ✅ 只有查询时间三种连接池的 Python 配置:
# === 1. 数据库连接池(SQLAlchemy)===
from sqlalchemy import create_engine
db_engine = create_engine(
"postgresql://user:pass@localhost:5432/mydb",
pool_size=20, max_overflow=10
)
# === 2. Redis 连接池 ===
import redis
redis_pool = redis.ConnectionPool(
host='localhost', port=6379,
max_connections=50, # 最大连接数
decode_responses=True
)
r = redis.Redis(connection_pool=redis_pool)
# === 3. HTTP 连接池(httpx)===
import httpx
http_client = httpx.AsyncClient(
limits=httpx.Limits(
max_connections=100, # 总连接数
max_keepalive_connections=20 # Keep-Alive 连接数
),
timeout=10.0
)
# ⚠️ 不要每次请求都 httpx.AsyncClient(),全局复用一个!💡 连接池是"借还"模式——从池中借一个连接,用完归还。关键是:借了必须还!不还就是"连接泄漏"(7.3 节)。
7.2 池大小怎么算:不是越大越好
经典公式(来自 HikariCP 作者):
pool_size = CPU 核数 × 2 + 磁盘数
例子:
→ 4 核 CPU + 1 块 SSD
→ pool_size = 4 × 2 + 1 = 9
→ 没错,就这么少!
为什么不能设太大?
═══════════════════════════════════
pool_size = 100 时:
→ 100 个连接同时执行 SQL
→ CPU 只有 4 核 → 频繁上下文切换
→ 每个查询都变慢(从 2ms 变成 20ms)
→ 总 QPS 反而下降!
pool_size = 10 时:
→ 10 个连接排队执行
→ CPU 切换少,每个查询稳定 2ms
→ 总 QPS = 10 / 0.002 = 5000 ✅池太小 vs 池太大:
池太小(pool_size = 3):
→ 第 4 个请求等不到连接 → 超时报错
→ 系统能力没有充分利用
池太大(pool_size = 200):
→ 200 个查询同时跑 → CPU 上下文切换风暴
→ 每个查询变慢 → 连接占用时间变长 → 需要更多连接
→ 恶性循环!
刚好(pool_size = CPU×2+磁盘):
→ CPU 被充分利用,不会过载
→ 查询速度最快 → 连接很快归还 → 小池高吞吐不同场景推荐配置:
| 场景 | CPU 核数 | 推荐 pool_size | max_overflow |
|---|---|---|---|
| 开发环境 | 2 | 5 | 5 |
| 小型应用 | 4 | 10 | 10 |
| 中型应用 | 8 | 20 | 10 |
| 大型应用 | 16 | 30 | 20 |
💡 违反直觉但正确:连接池越小,吞吐量可能越高。因为少量连接=少量并发=CPU 不切换=查询快=连接快速归还=更多请求被处理。HikariCP 官方建议初始值用公式算,然后压测微调。
7.3 连接泄漏:最隐蔽的高并发杀手
连接泄漏 = 借了连接没还
表现:
═══════════════════════════════════
→ 系统刚启动时一切正常
→ 运行几小时/几天后开始报 "连接超时"
→ 重启后恢复,过一段时间又出问题
→ 连接池监控显示:活跃连接数只增不减
原因:
═══════════════════════════════════
→ 代码异常时没有释放连接(最常见)
→ 忘记关闭 Session/Connection
→ 在函数里创建连接但没在 finally 中释放典型泄漏代码 vs 正确写法:
# ❌ 错误写法:异常时连接不会归还
def get_user_bad(user_id: int):
session = Session()
user = session.query(User).get(user_id) # 如果这里抛异常
session.close() # ← 这行不会执行!连接泄漏!
return user
# ✅ 正确写法 1:try/finally
def get_user_v1(user_id: int):
session = Session()
try:
return session.query(User).get(user_id)
finally:
session.close() # ← 无论成功失败都会执行
# ✅ 正确写法 2:上下文管理器(推荐)
def get_user_v2(user_id: int):
with Session() as session: # ← 自动关闭
return session.query(User).get(user_id)
# ✅ 正确写法 3:FastAPI 依赖注入
def get_db():
db = Session()
try:
yield db
finally:
db.close()
@app.get("/users/{user_id}")
async def get_user(user_id: int, db: Session = Depends(get_db)):
return db.query(User).get(user_id)
# 请求结束自动执行 finally → 连接归还排查连接泄漏:
# 查看数据库当前连接数
# PostgreSQL
SELECT count(*) FROM pg_stat_activity;
# MySQL
SHOW STATUS LIKE 'Threads_connected';
# 如果连接数持续增长且不释放 → 有泄漏
# SQLAlchemy 开启连接池日志
import logging
logging.getLogger('sqlalchemy.pool').setLevel(logging.DEBUG)💡 防泄漏铁律:永远用
with或try/finally管理连接。永远不要session = Session()后直接用。在 FastAPI 中用Depends(get_db)是最安全的模式。
第 7 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| 建连成本 | 3-8ms/次,连接池复用后几乎为 0 |
| 池大小公式 | CPU核数 × 2 + 磁盘数,不要拍脑袋设 100 |
| 池太大的代价 | CPU 上下文切换 → 查询变慢 → 恶性循环 |
| 连接泄漏 | 借了不还,表现为连接数只增不减 |
| 防泄漏 | 永远用 with / try-finally / Depends |
8. 热点数据处理:当所有请求都打到同一个 Key
前面讲的缓存策略假设流量均匀分散在很多 Key 上。但现实中经常有"热点"——某个商品搞秒杀、某条微博突然爆了——所有请求集中到同一个 Key,把 Redis 单节点打爆。
8.1 热点 Key 是怎么产生的
热点 Key 的典型场景:
① 秒杀商品
═══════════════════════════════════
→ product:12345 → 10 万人同时访问
→ 所有请求打到 Redis 同一个 Key
② 明星出轨/热搜事件
═══════════════════════════════════
→ weibo:hot:1 → 千万人同时刷
→ 流量在几分钟内从 0 飙到百万
③ 首页大促 Banner
═══════════════════════════════════
→ homepage:banner → 每个用户打开 App 都请求
→ 所有 DAU 的流量集中在一个 Key
④ 计数器/排行榜
═══════════════════════════════════
→ like_count:post_999 → 爆款文章疯狂点赞热点 Key 的危害:
Redis 集群模式下(Cluster / 分片):
═══════════════════════════════════
→ 一个 Key 只存在于一个分片上
→ 10 万 QPS 全打到分片 3 → 分片 3 过载
→ 其他分片空闲 → 集群扩容解决不了热点问题
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│分片 1│ │分片 2│ │分片 3│ │分片 4│
│ 空闲 │ │ 空闲 │ │ 💀 │ │ 空闲 │
└──────┘ └──────┘ └──────┘ └──────┘
↑
10 万 QPS 全在这里热点 Key 识别方法:
# Redis 4.0+ 热点 Key 分析
redis-cli --hotkeys
# 监控命令(实时显示高频命令)
redis-cli monitor | head -1000
# 业务层:记录 Key 访问频率
# 在应用层统计,访问超过阈值的 Key 标记为热点💡 热点 Key 不能靠加 Redis 节点解决——因为一个 Key 只在一个节点上。解法是"把一个 Key 变成多个 Key"(打散)或"在 Redis 前面再加一层本地缓存"(多级缓存)。
8.2 多级缓存:本地缓存 + Redis 的组合拳
多级缓存架构:
请求 ──▶ L1 本地缓存(进程内存)
│
╳ 未命中
│
▼
L2 Redis 缓存
│
╳ 未命中
│
▼
L3 数据库
各层特点:
═══════════════════════════════════
L1 本地缓存:延迟 < 0.01ms,容量小(几百 MB)
→ 命中的话,请求根本不出进程
→ 多实例之间数据不同步(最终一致)
L2 Redis:延迟 0.1-0.5ms,容量大(几十 GB)
→ 所有实例共享,数据一致
→ 热点 Key 10 万 QPS 可能打爆单节点
L3 数据库:延迟 1-100ms,容量最大
→ 最终数据源,保证持久化Python 本地缓存实现:
from cachetools import TTLCache
import redis
import json
# L1:本地缓存(进程内存,最多 1000 个 Key,TTL 10 秒)
local_cache = TTLCache(maxsize=1000, ttl=10)
# L2:Redis
r = redis.Redis()
async def get_product_multilevel(product_id: int):
"""多级缓存读取"""
cache_key = f"product:{product_id}"
# L1:查本地缓存
if cache_key in local_cache:
return local_cache[cache_key] # 命中!不经网络
# L2:查 Redis
cached = r.get(cache_key)
if cached:
data = json.loads(cached)
local_cache[cache_key] = data # 回填 L1
return data
# L3:查数据库
data = await db.fetch_product(product_id)
if data:
r.setex(cache_key, 3600, json.dumps(data)) # 回填 L2
local_cache[cache_key] = data # 回填 L1
return data热点 Key 打散策略:
把 1 个热点 Key 变成 N 个 Key:
原始:product:12345 → 10 万 QPS 打到 1 个分片
打散:product:12345:0
product:12345:1
product:12345:2 → 分散到不同分片
...
product:12345:9
写入时:10 个 Key 都写入同样的数据
读取时:随机选一个 Key 读取
→ 10 万 QPS 分散到 10 个分片 = 每个分片 1 万 QPS ✅import random
def get_sharded_key(base_key: str, shard_count: int = 10) -> str:
"""生成打散后的 Key"""
shard = random.randint(0, shard_count - 1)
return f"{base_key}:{shard}"
# 读取:随机选一个分片
key = get_sharded_key("product:12345")
data = r.get(key)
# 写入/更新:所有分片都更新
for i in range(10):
r.setex(f"product:12345:{i}", 3600, json.dumps(data))8.3 秒杀库存扣减:Redis + Lua 原子操作
秒杀超卖的根本原因——非原子操作:
❌ 错误流程(先查后扣,两步操作):
═══════════════════════════════════
线程 A: GET stock → 返回 1 (还有库存)
线程 B: GET stock → 返回 1 (也看到有库存)
线程 A: SET stock 0 (扣减成功)
线程 B: SET stock -1 (超卖了!💀)
✅ 正确流程(Lua 脚本原子操作):
═══════════════════════════════════
线程 A: Lua(检查+扣减) → 成功,stock = 0
线程 B: Lua(检查+扣减) → 失败,库存不足
→ Lua 在 Redis 中原子执行,中间不会被打断Redis + Lua 原子扣减库存:
DEDUCT_STOCK_SCRIPT = """
local stock_key = KEYS[1]
local quantity = tonumber(ARGV[1])
-- 获取当前库存
local stock = tonumber(redis.call('GET', stock_key))
if stock == nil then
return -1 -- Key 不存在
end
if stock < quantity then
return -2 -- 库存不足
end
-- 扣减库存(原子操作)
local new_stock = redis.call('DECRBY', stock_key, quantity)
return new_stock -- 返回剩余库存
"""
deduct_stock = r.register_script(DEDUCT_STOCK_SCRIPT)
async def seckill(user_id: int, product_id: int):
"""完整的秒杀流程"""
stock_key = f"stock:{product_id}"
# Step 1: Lua 原子扣减库存
result = deduct_stock(keys=[stock_key], args=[1])
if result == -1:
return {"success": False, "msg": "商品不存在"}
if result == -2:
return {"success": False, "msg": "已售罄"}
# Step 2: 库存扣减成功,发送到队列异步创建订单
r.lpush("order_queue", json.dumps({
"user_id": user_id,
"product_id": product_id,
"remaining_stock": result
}))
return {"success": True, "msg": f"抢购成功!剩余 {result} 件"}秒杀完整防护体系(串联前面所有章节):
用户点击"抢购"
│
▼
① 限流(第 3 章):令牌桶限制 1000/s
│
▼
② 本地缓存(8.2):检查是否已售罄(避免无效请求到 Redis)
│
▼
③ Lua 原子扣减(8.3):Redis 原子操作,防超卖
│
▼
④ 队列(第 5 章):扣减成功后放入队列
│
▼
⑤ 消费者创建订单(写数据库)
│
▼
⑥ 通知用户结果(WebSocket / 轮询)💡 秒杀系统的核心不是"快",是"准"。宁可慢一点处理,也不能超卖。Redis Lua 保证了扣减的原子性,队列保证了下游不被打爆,限流保证了整体不崩溃。
第 8 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| 热点 Key | 所有请求集中到一个 Key,集群扩容也解决不了 |
| 多级缓存 | L1 本地(无网络)→ L2 Redis → L3 DB |
| Key 打散 | 1 个 Key → N 个 Key,分散到不同分片 |
| Lua 原子操作 | 检查 + 扣减在 Redis 内一步完成,防超卖 |
| 秒杀体系 | 限流 → 本地缓存 → Lua 扣减 → 队列 → 创建订单 |
9. 压测与容量规划:上线前的最后一关
前面 8 章讲了各种高并发手段,但最终有没有效果,必须用数据说话。压测是上线前验证系统能力的唯一方式——不压测就上线,等于闭着眼过马路。
9.1 压测工具选型:wrk、Locust、k6
| 工具 | 语言 | 学习成本 | 适用场景 | 特点 |
|---|---|---|---|---|
| wrk | C | 低 | 单接口极限测试 | 极快、资源占用少、不适合复杂场景 |
| Locust | Python | 低 | 业务场景模拟 | ✅ Python 写用例、Web UI、分布式 |
| k6 | Go/JS | 中 | CI/CD 集成 | JS 写脚本、支持阈值检查、可嵌入流水线 |
| JMeter | Java | 高 | 企业级全链路 | 功能全但笨重、GUI 操作 |
wrk 快速压测(30 秒搞定):
# 安装
brew install wrk
# 基础压测:8 线程、200 连接、持续 30 秒
wrk -t8 -c200 -d30s http://localhost:8000/api/products
# 输出示例:
# Requests/sec: 3521.47 ← QPS
# Latency Avg: 56.78ms ← 平均延迟
# 99%: 234.12ms ← P99 延迟
# Transfer/sec: 1.23MB ← 吞吐量Locust 业务场景压测(推荐):
# locustfile.py
from locust import HttpUser, task, between
class WebUser(HttpUser):
wait_time = between(1, 3) # 用户思考时间 1-3 秒
@task(10) # 权重 10:高频操作
def browse_products(self):
self.client.get("/api/products")
@task(5) # 权重 5:中频操作
def view_product(self):
self.client.get("/api/products/1")
@task(1) # 权重 1:低频操作
def create_order(self):
self.client.post("/api/orders", json={
"product_id": 1,
"quantity": 1
})# 启动 Locust(Web UI 模式)
locust -f locustfile.py --host http://localhost:8000
# 打开 http://localhost:8089 设置并发用户数和启动速率
# 实时看 QPS、RT、错误率曲线💡 推荐 Locust:Python 写用例(后端同学无学习成本)、支持混合场景模拟(浏览:详情:下单 = 10:5:1)、Web UI 实时看图表、支持分布式多机压测。
9.2 四步压测法:从单接口到全链路
四步压测法:
Step 1: 基准测试
═══════════════════════════════════
目的:测出系统"裸机"能力
方法:最简单的接口(如 /health),不经过业务逻辑
关注:框架本身的 QPS 上限、网络/系统瓶颈
→ 这是你系统的理论天花板
Step 2: 单接口压测
═══════════════════════════════════
目的:找到每个核心接口的 QPS 和 P99
方法:逐个压核心接口(商品列表、详情、下单)
关注:各接口的 QPS、P99 RT、错误率
→ 找到最弱的接口(木桶的短板)
Step 3: 混合场景压测
═══════════════════════════════════
目的:模拟真实用户行为
方法:按比例混合接口(浏览 60%、详情 30%、下单 10%)
关注:整体 QPS、各接口是否互相影响
→ 接近真实场景,可能发现新瓶颈
Step 4: 全链路压测
═══════════════════════════════════
目的:验证系统在峰值下的整体表现
方法:包含缓存、队列、数据库、外部服务
关注:端到端延迟、各组件水位、报警触发
→ 最接近生产的测试压测时关注的核心指标:
| 指标 | 关注点 | 告警阈值(参考) |
|---|---|---|
| QPS | 是否达到预期 | < 目标 QPS 的 80% |
| P99 RT | 长尾延迟 | > 500ms |
| 错误率 | 是否有请求失败 | > 0.1% |
| CPU | 是否过载 | > 80% |
| 内存 | 是否泄漏 | 持续增长不回落 |
| DB 连接数 | 是否打满 | > pool_size × 80% |
| Redis QPS | 是否达到瓶颈 | > 单节点 8 万 |
压测常见坑:
❌ 在本机压本机 → 压测工具和服务抢 CPU
❌ 数据库是空表 → 空表查询比百万行快 10 倍
❌ 没有预热缓存 → 冷启动 QPS 远低于正常值
❌ 只看平均 RT → P99 可能是平均值的 10 倍
❌ 压测时间太短 → 来不及暴露内存泄漏/连接泄漏
✅ 正确做法:
→ 压测客户端和服务部署在不同机器
→ 用生产级数据量(至少百万级)
→ 先预热 5 分钟再开始计数
→ 至少压 10 分钟以上9.3 容量规划:算清楚需要多少机器
容量规划公式:
所需机器数 = 目标 QPS / 单机 QPS × 冗余系数
冗余系数:
═══════════════════════════════════
→ 通常取 1.5 - 2.0
→ 保证单台挂了,剩余机器能抗住
→ 保证突发流量有余量
例子:
→ 目标 QPS = 10000
→ 单机 QPS(压测得出)= 2000
→ 冗余系数 = 1.5
→ 所需机器 = 10000 / 2000 × 1.5 = 7.5 → 8 台完整容量规划实例(电商大促):
已知条件:
→ 大促预估 DAU = 100 万
→ 峰值系数 = 日均流量的 3 倍(集中在晚 8-10 点)
→ 平均每用户每分钟 2 次请求
Step 1: 算日均 QPS
═══════════════════════════════════
日总请求 = 100 万 × 20 次/用户 = 2000 万
日均 QPS = 2000 万 / 86400 ≈ 230
Step 2: 算峰值 QPS
═══════════════════════════════════
峰值 QPS = 日均 QPS × 峰值系数 = 230 × 3 ≈ 700
→ 这是正常峰值
Step 3: 大促峰值(额外 ×3-5 倍)
═══════════════════════════════════
大促峰值 QPS = 700 × 5 = 3500
Step 4: 算机器数
═══════════════════════════════════
单机 QPS(压测)= 1000
机器数 = 3500 / 1000 × 1.5 = 5.25 → 6 台 Web 服务器
Step 5: 别忘了数据库和 Redis
═══════════════════════════════════
→ 缓存命中率 90% → 350 QPS 到 DB → 1 台 MySQL 够
→ Redis 单机 10 万+ QPS → 1 台 Redis 够
→ 但都要配主从/哨兵做高可用| 组件 | 规划方式 | 关键数字 |
|---|---|---|
| Web 服务 | 目标 QPS / 单机 QPS × 1.5 | 压测得出单机能力 |
| 数据库 | 到达 DB 的 QPS / 单机安全水位 | 考虑缓存命中率 |
| Redis | 热点 Key QPS / 单节点能力 | 单节点约 10 万 |
| 消息队列 | 峰值写入 TPS / 单节点能力 | 考虑消费速率 |
💡 容量规划不是一次性的。每次大版本发布后都要重新压测,因为新功能可能改变系统性能特征。最好把压测集成到 CI/CD 里,每次发版自动跑性能回归。
第 9 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| Locust | Python 写压测用例,Web UI 实时看结果,推荐首选 |
| 四步压测 | 基准 → 单接口 → 混合场景 → 全链路,逐步逼近真实 |
| P99 > 平均值 | 关注第 99 百分位延迟,不要被平均值骗了 |
| 容量公式 | 机器数 = 目标 QPS / 单机 QPS × 冗余系数(1.5-2) |
| 预热 | 压测前先跑 5 分钟,让缓存和连接池热起来 |
10. 实战案例:从 0 到 10 万 QPS 的演进路径
前面 9 章是"工具箱",这一章是"装修实战"——用一个电商系统的成长过程,把所有工具串联起来。每个阶段遇到什么瓶颈、用什么方案、架构怎么演进,一步步看。
10.1 阶段一(100 QPS):单机能跑就行
初始架构——最简单的单体应用:
用户 ──▶ Nginx ──▶ FastAPI ──▶ PostgreSQL
│
└──▶ 本地文件存储
技术栈:
→ FastAPI + SQLAlchemy + PostgreSQL
→ 单台 4 核 8GB 云服务器
→ Nginx 做反向代理 + 静态文件
→ 日活 1000,QPS ≈ 50-100# 阶段一的代码——直接查数据库,简单粗暴
@app.get("/api/products/{product_id}")
async def get_product(product_id: int, db: Session = Depends(get_db)):
product = db.query(Product).get(product_id)
if not product:
raise HTTPException(404, "商品不存在")
return product
@app.post("/api/orders")
async def create_order(data: OrderCreate, db: Session = Depends(get_db)):
product = db.query(Product).get(data.product_id)
if product.stock < data.quantity:
raise HTTPException(400, "库存不足")
product.stock -= data.quantity
order = Order(**data.dict())
db.add(order)
db.commit()
# 同步发短信(阻塞 200ms)
send_sms(order.user_phone, f"订单创建成功: {order.id}")
return order阶段一的问题和感受:
✅ 能跑就行,开发速度快
✅ 架构简单,好理解好维护
⚠️ 随着用户增长出现的第一批问题:
→ 商品详情页有时候 500ms+ 才返回(慢 SQL)
→ 订单创建因为发短信要等 200ms
→ 某次推广活动 QPS 到 500 → 数据库连接打满
→ 结论:该加缓存了!→ 进入阶段二10.2 阶段二(1000-10000 QPS):缓存 + 限流 + 队列
阶段二架构——加了 Redis 和消息队列:
用户 ──▶ Nginx(限流)──▶ FastAPI ──▶ Redis(缓存)
│ │
│ ╳ 未命中
│ ↓
│ PostgreSQL
│
└──▶ Redis Queue ──▶ 消费者(发短信/统计)
新增组件:
→ Redis:缓存热点数据 + 分布式限流
→ 消息队列:异步处理非核心操作
→ 服务器扩到 2 台(Nginx 负载均衡)改造后的代码——对比阶段一:
# 阶段二:加了缓存 + 异步
@app.get("/api/products/{product_id}")
async def get_product(product_id: int):
# 改造 1:先查缓存(第 2 章)
cache_key = f"product:{product_id}"
cached = r.get(cache_key)
if cached:
return json.loads(cached) # 命中率 90% → DB 压力降 10 倍
product = await db.fetch_product(product_id)
if product:
r.setex(cache_key, 3600, json.dumps(product))
return product
@app.post("/api/orders")
async def create_order(data: OrderCreate):
# 改造 2:限流(第 3 章)
if not is_allowed(data.user_id, limit=10):
raise HTTPException(429, "操作过于频繁")
# 核心操作仍然同步
order = await process_order(data)
# 改造 3:异步化(第 5 章)
r.lpush("notification_queue", json.dumps({
"type": "sms",
"phone": order.user_phone,
"content": f"订单创建成功: {order.id}"
}))
# 不再等 200ms 发短信
return order阶段二的改造效果:
改造前(阶段一) 改造后(阶段二)
════════════════ ════════════════
QPS 上限:500 QPS 上限:5000
商品详情 RT:50-500ms 商品详情 RT:5ms(缓存命中)
下单 RT:300ms 下单 RT:100ms(异步发短信)
DB QPS:500 DB QPS:50(90% 被缓存挡住)
⚠️ 新的瓶颈出现:
→ 大促时 QPS 到 8000 → 单台 DB 开始吃力
→ 某个商品上了热搜 → 热点 Key 问题
→ 2 台 Web 服务器 CPU 到 90%
→ 结论:需要读写分离 + 水平扩展 → 进入阶段三10.3 阶段三(10 万 QPS):分布式架构全家桶
阶段三架构——全面分布式:
用户 ──▶ CDN(静态资源)
──▶ API 网关(限流 + 路由)
│
▼
┌───────────────────────────────┐
│ Web 服务集群(6 台,HPA 弹性) │
└───────┬──────────┬────────────┘
│ │
▼ ▼
L1 本地缓存 Redis Cluster(3 主 3 从)
│
╳ 未命中
↓
PostgreSQL(1 主 2 从,读写分离)
异步链路:
Web ──▶ RabbitMQ ──▶ 消费者集群
│
└──▶ 通知服务 / 统计服务 / 搜索索引阶段三使用的所有技术手段:
| 技术 | 解决的问题 | 对应章节 |
|---|---|---|
| Redis 缓存 | 数据库读压力 | 第 2 章 |
| 令牌桶 + Redis Lua 限流 | 入口流量保护 | 第 3 章 |
| 熔断器 + 降级 | 下游故障隔离 | 第 4 章 |
| RabbitMQ 消息队列 | 写操作削峰 + 异步化 | 第 5 章 |
| 读写分离(1 主 2 从) | 数据库读写压力分离 | 第 6 章 |
| 连接池调优 | 资源利用率 | 第 7 章 |
| 多级缓存 + Key 打散 | 热点数据 | 第 8 章 |
| Locust 压测 + 容量规划 | 上线前验证 | 第 9 章 |
三个阶段的演进路径总览:
阶段一(100 QPS) 阶段二(5000 QPS)
═══════════════ ═══════════════
单机 + 单 DB + Redis 缓存
直接查库 + 消息队列
同步处理 + 限流
──────────────────────────────────────────────
阶段三(10 万 QPS)
═══════════════
+ 读写分离
+ Redis Cluster
+ 多级缓存
+ 熔断降级
+ 弹性扩缩容
+ 全链路压测
核心演进逻辑(每次只解决当前最大瓶颈):
─────────────────────────────────────────
数据库慢了 → 加缓存
入口流量大 → 加限流
写操作慢了 → 加队列
读压力大了 → 读写分离
单点热点了 → 多级缓存
服务不稳了 → 熔断降级💡 架构演进的核心原则:不要过度设计。100 QPS 的系统不需要分布式架构——单机能搞定的事不要用集群。每次只解决当前最大的瓶颈,用最小的成本解决问题。
全书总结
恭喜你读完了!回顾一下从"扛不住"到"稳如磐石"的完整路径:
你的高并发知识体系:
第 1 章 核心指标 ─── QPS/TPS/RT + 瓶颈定位
第 2 章 缓存 ────── Redis 策略 + 穿透/雪崩/击穿
第 3 章 限流 ────── 四种算法 + Redis Lua 分布式限流
第 4 章 熔断降级 ── 三态熔断器 + P0-P3 降级分级
第 5 章 队列削峰 ── 同步→异步 + 秒杀实战
第 6 章 数据库 ──── 读写分离 + 慢 SQL 治理
第 7 章 连接池 ──── 池大小公式 + 连接泄漏防护
第 8 章 热点数据 ── 多级缓存 + Lua 原子扣减
第 9 章 压测 ────── Locust + 容量规划公式
第 10 章 演进实战 ── 100→5000→10 万 QPS 三阶段高并发的三条核心原则——记住这三条就够了:
- 挡:把流量挡在数据库前面(缓存 + 限流)
- 拆:把大任务拆成小任务(异步 + 队列 + 读写分离)
- 兜:出了问题有备选方案(熔断 + 降级 + 回滚)
剩下的,就是在实践中反复踩坑、反复优化,直到这些方案变成你的条件反射。祝你的系统稳如磐石!🚀