这事是 2023 年 9 月一个周三晚上发生的,我现在写出来手心还会出汗。当时我们一个核心订单查询服务,FastAPI + asyncio + asyncpg 写的,QPS 平时稳定在 2000 上下,P99 一直压在 80ms 以内,跑了快一年都没出过事。那天晚上 21:47,告警群里突然刷出一片红色:P99 飙到 12 秒,QPS 反而掉到 300,业务方说前端转圈圈转到怀疑人生。
我从沙发上跳起来打开电脑,第一反应是 DB 慢查询。结果上 pgAdmin 看,数据库 CPU 30%、连接数正常、慢日志干干净净,啥都没有。然后看应用层 —— Pod CPU 不到 20%、内存平稳、GC 也没异常。Grafana 上所有的板子都「看起来正常」,只有 P99 那条线像火箭一样窜上去。这种「全员健康但服务挂了」的场景,有经验的同学应该已经猜到答案了:event loop 被某个同步调用卡住了。
这一篇就把那晚 6 个小时的排查过程从头到尾复盘出来 —— 怎么定位、怎么从一万行代码里揪出那个该死的 requests.get()、为什么我们一开始的修法是错的、最后改成什么样才稳定。读完这篇你会明白:在 asyncio 的世界里,「一个不显眼的同步调用」就是核武器,谁先发现谁先翻车。
故障现场:所有指标都正常,只有用户在骂
先把场景说清楚。我们的服务架构是这样的:
订单查询服务大约 30 个 worker(uvicorn workers=8 × 4 副本),每个 worker 一个 event loop。正常路径是:收请求 → 查 Redis 缓存 → 命中直接返回,未命中 → 查 PostgreSQL → 调风控 → 写回缓存 → 返回。整条链路异步化,asyncpg 查库、aioredis 查缓存,只有调风控那段「最初」是用 requests 写的 —— 你看,问题已经在这里埋下了,但这个 requests 跑了快一年都「没出事」,谁会去动它?
21:47 告警起来后,我先看了一眼监控板:
- P99 延迟:从 80ms 阶梯式上升到 12 秒,而且越来越高
- QPS:从 2000 掉到 300,反向
- 错误率:5% 的 504 Gateway Timeout(网关 10 秒超时打回)
- CPU / 内存 / GC:全员绿色,没有任何异常
- DB / Redis:连接数正常、查询耗时 ms 级
这就是我说的「全员健康但服务挂了」的典型迹象。指标说一切正常,可服务就是慢得离谱。如果你以后碰到这种情况,先别看 CPU 和内存,第一步去看 event loop 有没有被卡住。我当时不知道这条规则,绕了一大圈才找到。
事故时间线:从告警到恢复的 6 小时全程
把那晚的时间线放上来,你会更直观地体会到「不知道根因有多痛苦」:
| 时间 | 事件 | 当时的判断 | 实际正确动作 |
|---|---|---|---|
| 21:47 | 告警群刷 P99 12s,QPS 掉到 300 | 「肯定是数据库」 | 立刻 attach py-spy 看栈 |
| 21:52 | 查 PG 慢查询,啥都没有 | 「Redis 出问题了?」 | 同上 |
| 21:58 | Redis 监控也正常 | 「网络抖动?」 | 同上 |
| 22:05 | 切到内网回环测试,延迟正常 | 「应用 bug?」 | 对了一半,该看 py-spy |
| 22:15 | 滚动重启,前 30 秒正常然后又卡 | 「内存泄漏?」 | 注意到「30 秒规律」,该深挖 |
| 22:30 | 扩容到 12 副本,P99 降到 4s | 「先扛住再说」 | 正确的临时止血 |
| 22:50 | 开始翻应用代码,grep requests | 「应该都是异步的吧」 | 该早 30 分钟做这步 |
| 23:10 | 同事终于装好 py-spy,attach 第一个 Pod | — | — |
| 23:18 | py-spy 显示 50% 时间卡在 requests.get | 「找到了!」 | — |
| 23:25 | 查上游 RPM/RPS 看板,发现风控 P99 从 200ms 涨到 8s | 「上游变更了」 | — |
| 23:42 | 本地把 requests 改成 run_in_executor,出 hotfix 镜像 | — | — |
| 00:15 | 灰度 1 个 Pod,P99 稳定在 200ms | 「方向对了」 | — |
| 00:40 | 全量推完,P99 回到 90ms | 「松一口气」 | — |
| 01:30 | 风控那边也修了,P99 回到 200ms | 「明天写复盘」 | — |
| 03:00 | 我躺床上还在想这事 | — | — |
这张表后来贴到了我们团队的故障应急手册里。第一行的教训是:故障期间,任何「我觉得是 X」的判断都别信,直接上工具看数据。如果当晚 21:47 我就 attach py-spy,根因 5 分钟就能找到,根本不需要折腾 90 分钟。
第一轮排查:我把所有「教科书答案」都试了一遍,全错
故障刚起来的时候,值班 SRE 同事按教科书顺序排查:
- 看 DB 慢查询:无
- 看 Redis 命中率:稳定 92%
- 看应用日志 ERROR:没有新增的异常堆栈
- 看 K8s Pod 是否在重启:没有重启
- 看是不是流量异常:QPS 反向下降,不是被打挂的
- 滚动重启一遍 Pod:重启完前 30 秒一切正常,然后又开始慢
这个「重启后正常 → 30 秒后又慢」的现象是关键提示,但当时我们没意识到。继续排查:
- 看 asyncpg 连接池有没有耗尽:pool size=20,active=3,完全够用
- 看是不是某个慢请求把 worker 占住了:看 nginx access log,慢请求遍布所有 endpoint,不是单点
- 看是不是 GC 抖动:Python 没那么大的 GC 问题,而且监控里 GC 时间是个位 ms
到 22:30 我们已经排了快一小时,完全没有线索。这时候 leader 说了一句:「先扩容,把副本数从 4 拉到 12 顶住」。我们照做,扩到 12 副本,P99 暂时降到 4 秒,QPS 回到 1500,但还是没解决根因 —— 而且 12 副本的成本一晚上要烧多少钱不用我说。
第二轮排查:py-spy 把真相按在地上摩擦
真正破局的工具是 py-spy。这玩意儿是个 Python 的 sampling profiler,能在不重启进程的前提下,直接 attach 到一个运行中的 Python 进程,看每个线程当前在执行什么代码。我们 exec 进一个 Pod 跑了:
# 装 py-spy(如果镜像里没有)
pip install py-spy
# 找到 uvicorn worker 的 pid
ps -ef | grep uvicorn | grep -v grep
# root 37 1 ... uvicorn main:app --workers 8 ...
# root 42 37 ... uvicorn worker (pid=42)
# attach 上去,采样 60 秒
py-spy dump --pid 42
# 或者抓火焰图
py-spy record -o profile.svg --pid 42 --duration 60
py-spy dump 出来的结果让我直接坐直了。整个 worker 的 main thread,有一大半时间卡在这里:
Thread 0x7f... (active): "MainThread"
read (urllib3/connection.py:447)
_make_request (urllib3/connectionpool.py:467)
urlopen (urllib3/connectionpool.py:715)
send (requests/adapters.py:486)
send (requests/sessions.py:701)
request (requests/sessions.py:587)
get (requests/api.py:73)
call_risk_api (app/services/risk.py:42)
query_order (app/routes/order.py:108)
...
看到 requests/adapters.py 那一行的时候,我的血压瞬间升了 30。FastAPI 异步路由里写了一个同步的 requests.get()。这玩意儿一调用,整个 event loop 就被卡住,这个 worker 上所有正在跑的 coroutine 全部停摆,直到 requests 返回。
那为什么这玩意儿跑了一年都没事,偏偏今晚出事?查代码 + 看上游变更记录,真相揭晓:今天下午风控服务做了一次发版,他们的某个判断逻辑改了,P99 从原来的 200ms 上升到了 8 秒(他们的告警阈值是 10 秒,所以他们那边没报警)。我们这边那个 requests.get() 默认 timeout 也没设,就一直等。每个请求等 8 秒,event loop 卡 8 秒,worker 直接废掉,雪崩开始。
问题本质:asyncio 为什么对同步调用如此脆弱
这里得回到原理。asyncio 的 event loop 是个单线程的协作式调度器,它依赖每个 coroutine 在「该让出来的时候」主动 await。当你写:
import asyncio
import requests
async def fetch_user_async():
# 假异步:函数声明 async,但里面是同步 IO
resp = requests.get("https://api.example.com/user/123", timeout=10)
return resp.json()
async def main():
# 看起来同时发 100 个请求
results = await asyncio.gather(*[fetch_user_async() for _ in range(100)])
return results
你以为 100 个请求会并发,实际上是串行执行的。因为 requests.get() 是同步阻塞调用,它执行的时候 event loop 完全没机会切到别的 coroutine。这 100 个请求会一个接一个跑,如果每个 200ms,总耗时 20 秒。
更严重的是,在生产环境里你不是只有一个 coroutine。一个 worker 上可能同时跑着 500 个用户请求的 coroutine,只要其中一个发起同步阻塞调用,所有的 500 个请求都会被冻结,直到那个同步调用返回。这就是为什么风控的 P99 从 200ms 涨到 8 秒,我们这边的 P99 直接跳到 12 秒(8 秒等待 + 排队时间)。
用一张图说明这个机制:
插曲:为什么我们写代码的时候没有发现这是个炸弹
事故复盘会上,leader 问了一个问题:「这段 requests 代码是谁写的?为什么 review 没拦住?」翻 git blame,代码是一年前我自己写的,当时的 PR 还有两个同事 approve 了。我当时的留言是:
风控接口 P99 只有 200ms,而且我们 QPS 不高(那时候峰值 300),用线程模型扛得住,先不引入 httpx 减少依赖。
看到这条 PR 留言,在场所有人都笑了 —— 因为这就是典型的「优化得太早就是问题,不优化就是更大的问题」的两难境地。当年 QPS 300 的时候,一个同步调用确实没事,但业务涨了 7 倍后,这就是埋的雷。
这件事给我两个长期教训:
- 「我们 QPS 不高」是个会随时间变化的前提,任何基于「现在不高」的设计选择,都该在 review 中标记为 TODO 而不是 DONE。
- 对于异步框架,「类型纯粹性」应该是默认要求,而不是性能优化的选项。同步调用混进异步代码,从来不是「现在可以以后再说」的事,它的失败模式是雪崩,不是降级。
我们后来在团队代码规范里加了一条「红线规则」:任何 async 函数里调用同步 IO,无论性能影响多小,review 默认拒绝。要绕过这条规则,必须 PR 描述里写明「为什么不能用异步替代品」,且经过架构组评审。这条规则推了之后,半年内再没出过类似事故。
定位手段:不只是 py-spy,这 5 个工具都要会
抓 event loop 阻塞,py-spy 是最直观的,但还有几个工具配合用威力更大。我整理一个清单:
1. py-spy:线上无侵入采样
优点:不需要改代码、不需要重启、attach 进程就能看实时栈。生产环境救火必备。
# 实时看每个线程在干什么(类似 top 但显示函数名)
py-spy top --pid 42
# dump 一次当前栈(适合脚本化采集)
py-spy dump --pid 42
# 录制火焰图(适合长时间分析)
py-spy record -o flame.svg --pid 42 --duration 30 --format flamegraph
2. asyncio 内置 slow callback 告警
asyncio 自带一个 debug 模式,会在 callback 执行超过阈值时打 warning。生产环境别开 debug(性能损耗大),但可以单独开 slow callback 监控:
import asyncio
import logging
# 不开 debug,只开慢 callback 阈值
loop = asyncio.get_event_loop()
loop.slow_callback_duration = 0.1 # 单位秒,超过 100ms 的 callback 都告警
logging.basicConfig(level=logging.WARNING)
# 之后跑业务代码,任何卡住 event loop 超过 100ms 的 callback
# 都会出 Executing took 0.234 seconds 的 WARNING
这一行配置救过我们好几次。线上把阈值放到 50ms 或 100ms,日志里出现 WARNING 就排查,基本能在故障发生前预警。
3. aiomonitor:运行时 telnet 进去看 task 状态
aiomonitor 启动后,你可以 telnet 进运行中的进程,看所有 task、调用栈、loop 状态:
# 启动时
import aiomonitor
async def main():
loop = asyncio.get_event_loop()
with aiomonitor.start_monitor(loop):
# 业务代码
await app_main()
# 然后在线运维:
# $ telnet 127.0.0.1 50101
# > ps # 列所有 task
# > where TASK_ID # 看某个 task 的栈
4. uvloop + 自定义指标:把 loop lag 接 Prometheus
所谓 loop lag,就是 event loop 跑一圈花了多久。正常情况下应该是 us 级,如果飙到 ms 甚至 s,说明被卡住了。自己打这个指标:
import asyncio
import time
from prometheus_client import Histogram
loop_lag = Histogram('asyncio_loop_lag_seconds', 'Event loop iteration lag')
async def loop_lag_monitor():
while True:
start = time.perf_counter()
await asyncio.sleep(0.1) # 期望 0.1s,实际可能更长
actual = time.perf_counter() - start
lag = max(0, actual - 0.1)
loop_lag.observe(lag)
# 启动时挂上去
asyncio.create_task(loop_lag_monitor())
这个指标接到 Grafana,做个告警 P99 > 200ms 就告警,生产环境基本可以提前 30 秒发现 event loop 异常。
5. tracemalloc + objgraph:确认不是内存泄漏冒充的卡顿
有时候 event loop 卡住是因为 GC 一直在跑,根因是大对象泄漏。这种情况 tracemalloc 可以定位:
import tracemalloc
tracemalloc.start(25) # 保留 25 层栈
# 跑一段业务
snap = tracemalloc.take_snapshot()
top = snap.statistics('lineno')[:20]
for s in top:
print(s)
修复方案:从「最不靠谱」到「最稳」的 5 种修法
定位到根因后,接下来就是修。我把当晚和后续两周尝试过的所有修法都列在这里,从最差到最好排序,你可以直接照搬。
修法 1(不推荐):加 timeout
最直觉的想法:既然是 requests 等太久,我加个 timeout 就行了对吧?
import requests
def call_risk(user_id):
# 把 timeout 从无限改成 2 秒
resp = requests.get(f"https://risk.api/{user_id}", timeout=2)
return resp.json()
为什么不对:timeout 只是把「卡 8 秒」改成「卡 2 秒」,event loop 仍然被冻结,只是冻结时间短了。在 2000 QPS 的压力下,2 秒的卡顿足够让你的 P99 升到 5-6 秒。这是把症状压下去,病根还在。
修法 2(临时救急):run_in_executor 扔到线程池
把同步调用扔到一个独立线程池里,让它在线程池里阻塞,event loop 不受影响:
import asyncio
import requests
from concurrent.futures import ThreadPoolExecutor
# 用一个独立的线程池,大小根据并发预期来设
risk_executor = ThreadPoolExecutor(max_workers=64, thread_name_prefix="risk_io")
def _call_risk_sync(user_id):
resp = requests.get(f"https://risk.api/{user_id}", timeout=2)
return resp.json()
async def call_risk(user_id):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(risk_executor, _call_risk_sync, user_id)
当晚我们用的就是这个方案,5 分钟把 P99 压回 200ms 以下。但它不是终极方案。原因:线程池有大小上限,如果上游风控真的全挂了,线程池会被打爆,新请求开始排队 —— 还是雪崩,只是雪崩慢了一些。而且每个请求多一次线程切换,有 us 级的固定开销。
修法 3(正解):换成原生异步客户端
requests 没有官方异步版本,但有等价物:
- HTTP 客户端:
httpx(API 完全兼容 requests,支持 sync 和 async 两种模式) - HTTP 客户端备选:
aiohttp(老牌,生态成熟,API 稍微复杂) - MySQL:
aiomysql/asyncmy - PostgreSQL:
asyncpg(性能最好) - Redis:
redis.asyncio(redis-py 4.2+ 内置) - Kafka:
aiokafka - S3:
aioboto3
import httpx
# 复用 client,避免每次都新建连接池
_http_client = httpx.AsyncClient(timeout=2.0, limits=httpx.Limits(max_connections=200))
async def call_risk(user_id):
resp = await _http_client.get(f"https://risk.api/{user_id}")
resp.raise_for_status()
return resp.json()
# 应用关闭时
async def shutdown():
await _http_client.aclose()
这才是真正的修法。HTTP 调用走异步 IO,event loop 不会被任何一个请求卡住,几千个并发请求互不干扰。
修法 4(更进一步):加熔断器
就算异步化了,如果上游真的整个挂了,你的服务也不能干瞪眼。加熔断器,上游连续失败 N 次就快速失败:
import asyncio
import time
from enum import Enum
class State(Enum):
CLOSED = 1 # 正常
OPEN = 2 # 熔断中,直接失败
HALF_OPEN = 3 # 探测中
class CircuitBreaker:
def __init__(self, failure_threshold=10, recovery_time=30):
self.state = State.CLOSED
self.failure_count = 0
self.failure_threshold = failure_threshold
self.recovery_time = recovery_time
self.opened_at = 0
self._lock = asyncio.Lock()
async def call(self, coro):
async with self._lock:
if self.state == State.OPEN:
if time.time() - self.opened_at > self.recovery_time:
self.state = State.HALF_OPEN
else:
raise Exception("circuit_open")
try:
result = await coro
async with self._lock:
if self.state == State.HALF_OPEN:
self.state = State.CLOSED
self.failure_count = 0
return result
except Exception as e:
async with self._lock:
self.failure_count += 1
if self.failure_count >= self.failure_threshold:
self.state = State.OPEN
self.opened_at = time.time()
raise
risk_breaker = CircuitBreaker(failure_threshold=10, recovery_time=30)
async def call_risk_with_breaker(user_id):
return await risk_breaker.call(
_http_client.get(f"https://risk.api/{user_id}")
)
这个 CircuitBreaker 是个最小可用版本,生产环境推荐用 aiobreaker 或 purgatory 这类成熟库。核心思想是:上游失败到一定阈值,直接快速失败 30 秒,把请求集中度降下来,给上游一个恢复机会。
修法 5(终极):同步代码全部隔离到子进程
有些库就是没异步版本,比如某些机器学习推理框架、PDF 解析库、图像处理库。这时候 run_in_executor 也不够安全(GIL 限制了多线程的并行度),需要把这些重活全部扔到子进程池:
import asyncio
import concurrent.futures
import multiprocessing as mp
# 子进程池,大小通常 = CPU 核数
cpu_executor = concurrent.futures.ProcessPoolExecutor(
max_workers=mp.cpu_count(),
mp_context=mp.get_context('spawn'), # spawn 比 fork 更安全
)
def _heavy_cpu_task(data):
# 这里可以放任意重 CPU 计算
result = some_ml_model.predict(data)
return result
async def call_ml_model(data):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(cpu_executor, _heavy_cpu_task, data)
子进程池有进程切换的开销(ms 级),所以只适合 CPU 密集任务,IO 任务别用这个。
修复后的架构:用一张决策树告诉你每种调用怎么处理
事故复盘后我们做了一张决策树,挂在团队 Wiki 上,后来招新员工入职第一周必看:
性能基准:5 种修法在 2000 QPS 下的实测对比
事故修完后我做了基准测试,模拟当晚的场景:上游风控 P99 = 8 秒,2000 QPS 持续 5 分钟,看每种修法的 P99 和 QPS 维持能力。机器是 8 核 16G,Python 3.11,uvloop:
| 修法 | P50 延迟 | P99 延迟 | 实际 QPS | 说明 |
|---|---|---|---|---|
| 原始(同步 requests + 无 timeout) | 8000ms | > 30s | ~50 | 完全雪崩,网关大面积超时 |
| 修法 1:加 timeout=2s | 1800ms | 5200ms | ~600 | 能跑但 P99 不可接受 |
| 修法 2:run_in_executor(线程池=64) | 120ms | 1200ms | ~1800 | 能扛住,但线程池满了之后变慢 |
| 修法 3:httpx 异步 | 85ms | 820ms | ~2000 | 真正异步化,P99 主要是上游的 8s 风控 |
| 修法 3 + 修法 4:httpx + 熔断器 | 78ms | 120ms | ~2000 | 熔断后大部分请求快速失败,P99 反而最低 |
| 修法 5:ProcessPoolExecutor(IO 场景) | 250ms | 3500ms | ~400 | 子进程开销大,不适合 IO,仅供对比 |
结论很清楚:IO 密集场景,异步客户端 + 熔断器是终极方案。线程池是过渡方案,子进程池是 CPU 密集才用。
预防:这 8 件事必须在团队里立规矩
修完是修完,但「下次别再犯」才是真正的复盘价值。我们后来在团队里推了 8 条规矩,代码评审硬卡。
1. 静态扫描禁止 async def 里出现同步 IO 库
用 ruff 加自定义规则,或者 pylint 写插件,扫描 async def 函数体内的 import 和函数调用。如果出现 requests / urllib / pymysql / redis(同步版)/ pymongo(同步版)/ time.sleep,直接 CI 失败。下面是个 ruff 配置片段:
# pyproject.toml
[tool.ruff.lint]
select = ["ASYNC"] # asyncio 相关规则全开
[tool.ruff.lint.flake8-async]
async-no-blocking-call = true
但 ruff 内置规则覆盖不全,我们自己又写了一个简单的 AST 扫描脚本,挂在 pre-commit:
# scripts/check_async_blocking.py
import ast
import sys
from pathlib import Path
BANNED_IN_ASYNC = {
'requests', 'urllib.request', 'urllib3',
'pymysql', 'mysqlclient',
'redis.Redis', 'redis.StrictRedis',
'pymongo.MongoClient',
'time.sleep',
}
class AsyncBlockingChecker(ast.NodeVisitor):
def __init__(self, filename):
self.filename = filename
self.async_depth = 0
self.errors = []
def visit_AsyncFunctionDef(self, node):
self.async_depth += 1
self.generic_visit(node)
self.async_depth -= 1
def visit_FunctionDef(self, node):
# 同步函数里允许阻塞调用
prev = self.async_depth
self.async_depth = 0
self.generic_visit(node)
self.async_depth = prev
def visit_Call(self, node):
if self.async_depth > 0:
name = ast.unparse(node.func)
for banned in BANNED_IN_ASYNC:
if banned in name:
self.errors.append(
f"{self.filename}:{node.lineno}: 禁止在 async 函数里调用 {name}"
)
self.generic_visit(node)
def check_file(path):
tree = ast.parse(path.read_text(encoding='utf-8'))
checker = AsyncBlockingChecker(str(path))
checker.visit(tree)
return checker.errors
if __name__ == '__main__':
all_errors = []
for f in sys.argv[1:]:
all_errors.extend(check_file(Path(f)))
for e in all_errors:
print(e)
sys.exit(1 if all_errors else 0)
2. event loop lag 指标必须接监控
前面给过 Prometheus 埋点代码。指标接 Grafana 后,告警规则建议如下:
groups:
- name: asyncio_health
rules:
- alert: AsyncioLoopLagHigh
expr: histogram_quantile(0.99, rate(asyncio_loop_lag_seconds_bucket[1m])) > 0.2
for: 30s
labels:
severity: warning
annotations:
summary: "事件循环延迟过高 (P99 > 200ms)"
description: "服务 {{ $labels.service }} 的 event loop lag P99 已经超过 200ms,可能有同步阻塞调用"
- alert: AsyncioLoopLagCritical
expr: histogram_quantile(0.99, rate(asyncio_loop_lag_seconds_bucket[1m])) > 1
for: 30s
labels:
severity: critical
annotations:
summary: "事件循环严重阻塞 (P99 > 1s)"
description: "服务 {{ $labels.service }} 几乎已经废了,马上检查"
3. slow_callback_duration 必须开
# main.py 启动时
import asyncio
def setup_loop():
try:
import uvloop
uvloop.install()
except ImportError:
pass
loop = asyncio.new_event_loop()
loop.slow_callback_duration = 0.1 # 超过 100ms 的 callback 出警告
asyncio.set_event_loop(loop)
return loop
4. 所有 HTTP 客户端必须设 timeout
这条看着废话,但太多人忘了。补一句:不是只设 connect timeout,要设全套(connect + read + write + pool)。httpx 写法:
timeout = httpx.Timeout(
connect=2.0, # 连接建立
read=5.0, # 读响应
write=2.0, # 发请求
pool=1.0, # 从连接池拿连接
)
client = httpx.AsyncClient(timeout=timeout)
5. 异步代码评审 checklist
PR 模板里加一段必填:
- [ ] async 函数里有没有调用同步 IO 库(requests/pymysql/同步 redis 等)?
- [ ] 所有外部调用都设了 timeout?
- [ ] 重 CPU 操作是不是用了 ProcessPoolExecutor 隔离?
- [ ] 是不是有 time.sleep?需要换成 asyncio.sleep
- [ ] 共享的 httpx/aioredis client 是不是全局复用?有没有重复 new?
- [ ] 用了第三方异步库吗?有没有验证过它真的是异步的(不是 async def 套同步代码)?
6. 上游服务变更必须通知下游
这是组织流程层面的。我们后来在内部加了一个变更通知机器人,任何核心服务发版前,自动 @ 所有下游服务的 owner。下游可以选择「确认无影响」或「需要回归测试」。当晚的事故根本原因之一,就是风控发版没通知我们。
7. 单元测试用 pytest-asyncio + 慢调用断言
import asyncio
import time
import pytest
@pytest.mark.asyncio
async def test_no_blocking_in_query_order():
"""确保 query_order 不会卡 event loop 超过 100ms"""
from app.routes.order import query_order
start = time.perf_counter()
# 同时跑 50 个查询
results = await asyncio.gather(*[query_order(i) for i in range(50)])
elapsed = time.perf_counter() - start
# 如果是异步的,50 个并发应该和 1 个差不多耗时
# 这里假设单个查询大约 50ms,50 个并发不应该超过 200ms
assert elapsed < 0.2, f"query_order 可能有阻塞调用,50 并发耗时 {elapsed}s"
assert len(results) == 50
8. 给每个 async 函数加 trace
OpenTelemetry 加上,出问题时直接看链路追踪,瞬间定位是哪一段卡住的:
from opentelemetry import trace
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.asyncpg import AsyncPGInstrumentor
# 一次配齐
FastAPIInstrumentor.instrument_app(app)
HTTPXClientInstrumentor().instrument()
AsyncPGInstrumentor().instrument()
# 手工加自定义 span
tracer = trace.get_tracer(__name__)
async def query_order(order_id):
with tracer.start_as_current_span("query_order") as span:
span.set_attribute("order_id", order_id)
order = await db.fetch_one(...)
with tracer.start_as_current_span("call_risk"):
risk = await call_risk(order.user_id)
return order
更深一层:为什么 Python 异步这么难写对
事故复盘后我反复想这件事:为什么这种「在 async 函数里调用同步代码」的坑这么容易踩?为什么 Go 的 goroutine 没这个问题,Node.js 也没这么严重?
本质上有几个原因:
原因 1:Python 没有强制类型区分
Go 的 goroutine 不管你是同步还是异步函数,都能扔进去跑,因为 goroutine 是用户态线程级的调度,IO 自动转交给 runtime。Node.js 强制 IO 必须用 callback / Promise / async,你想写同步 IO 都没接口。Python 是「赋予了你完整的同步 API,然后让你自己选择不要用」—— 这种自由是负担。
原因 2:async 关键字会传染但不会校验
你写了 async def foo():,Python 不会在解析期或运行期警告你「你这里调了一个同步阻塞函数」。requests.get() 在 async 函数里调用是完全合法的,只是「会卡 event loop」。这种「合法但有害」的代码,只能靠工具链或人脑去防。
原因 3:第三方库生态分裂
Python 异步生态分两套:asyncio 系(主流)和 trio 系(小众但更安全)。即使 asyncio 这边,很多库的「异步版本」也只是表面,内部还是同步实现。比如某些早期的「async」MySQL 客户端,实际上是 sync 客户端套了个 ThreadPoolExecutor,看起来异步用起来也是异步,但其实就是开线程。
原因 4:测试很难发现
单元测试一次只跑一个请求,event loop 上只有一个 coroutine,你阻不阻塞测试都过。只有在生产环境的并发压力下,阻塞调用的危害才会暴露。我们的事故发生前,所有测试都是绿的。
延伸坑:asyncio.gather 异常吞噬,我们又栽过一次
事故修完一个月后,我们栽在另一个 asyncio 坑上,顺便也写出来。场景是这样:用户下单后,我们需要并行查 4 个外部系统(用户资料、风控、库存、优惠券),用 asyncio.gather 写:
async def prepare_order(user_id, items):
user, risk, stock, coupon = await asyncio.gather(
fetch_user(user_id),
check_risk(user_id),
check_stock(items),
get_coupon(user_id),
)
return build_order(user, risk, stock, coupon)
这段代码上线 3 个月,某天我们突然发现「优惠券系统挂了 2 小时,但订单都正常下了,只是用户全部没用上优惠券」。查日志,根本没有报错,但优惠券就是没生效。
问题出在 asyncio.gather 的默认行为:任何一个 coroutine 抛异常,gather 会立刻取消其他所有 coroutine 并把异常抛出。但我们的 get_coupon 函数里有这么一段:
async def get_coupon(user_id):
try:
return await coupon_client.get(user_id)
except Exception as e:
logger.warning(f"get_coupon failed: {e}")
return None # 优惠券不是必选项,失败返回 None
这种「捕获异常返回 None」的写法在同步代码里很常见。但配合 gather 出问题了 —— 因为 coupon 系统挂掉是 connection refused,而我们用的 aiohttp 在某些超时场景下会抛 asyncio.CancelledError,这个异常被上面的 except Exception 静默捕获了,但 CancelledError 是 asyncio 内部用来传播取消信号的,捕获它会导致协程进入「无人取消」的孤儿状态。结果就是优惠券调用全部失败但没人知道,日志里也只有一条 WARNING(被刷其他日志淹没了)。
正确的写法应该是:
async def get_coupon(user_id):
try:
return await coupon_client.get(user_id)
except asyncio.CancelledError:
# 千万不要吞 CancelledError
raise
except Exception as e:
logger.warning(f"get_coupon failed: {e}")
return None
# 或者用 gather 的 return_exceptions
async def prepare_order(user_id, items):
results = await asyncio.gather(
fetch_user(user_id),
check_risk(user_id),
check_stock(items),
get_coupon(user_id),
return_exceptions=True,
)
user, risk, stock, coupon = results
# 显式处理每个失败
if isinstance(coupon, Exception):
logger.warning(f"coupon failed: {coupon}")
coupon = None
if isinstance(user, Exception):
raise user # 用户信息是必须的,失败就抛
# ...
return build_order(user, risk, stock, coupon)
这次事故让我们对 asyncio 的异常处理多了一条规矩:永远不要用 except Exception 捕获 asyncio 函数里的异常,要么 except 具体的 Exception 子类,要么显式 except (Exception,) but not CancelledError。
延伸坑:async 资源清理用 try/finally 不够,要用 async context manager
再说一个相关的坑。一开始我们某个服务有这种代码:
async def upload_file(file_data):
s3_client = aioboto3.client('s3')
try:
await s3_client.put_object(Bucket='my-bucket', Key='x', Body=file_data)
finally:
# 错误:同步关闭异步资源
s3_client.close()
这段代码看起来「关了资源」,实际上 aioboto3.client.close() 是个 coroutine,没 await 就没真关。后果是 TCP 连接慢慢泄漏,几天后服务 fd 耗尽报错 Too many open files。
正确写法:
async def upload_file(file_data):
async with aioboto3.client('s3') as s3_client:
await s3_client.put_object(Bucket='my-bucket', Key='x', Body=file_data)
Python 提供了专门的 async with 和 async for 语法,凡是异步资源都用这个,不要自己 try/finally。如果你自己写库,记得实现 __aenter__ / __aexit__:
class AsyncRedisPool:
async def __aenter__(self):
self.conn = await self.get_conn()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.conn.close()
# 使用
async with AsyncRedisPool() as pool:
await pool.set('key', 'value')
延伸坑:ContextVar 在 run_in_executor 里丢失
第三个相关坑,影响范围比前两个更隐蔽。我们的链路追踪用 ContextVar 存 trace_id,所有日志自动带上 trace_id 方便查问题:
import contextvars
trace_id_var = contextvars.ContextVar('trace_id', default='')
class TraceMiddleware:
async def __call__(self, request, call_next):
trace_id = request.headers.get('X-Trace-Id', generate_id())
token = trace_id_var.set(trace_id)
try:
return await call_next(request)
finally:
trace_id_var.reset(token)
这段代码在纯异步链路里完美工作。但当我们用 run_in_executor 把同步操作扔到线程池时,新线程里 ContextVar 丢了 —— 因为 ContextVar 默认不会跨线程传递。结果就是日志里 trace_id 是空的,排查问题时无法把同一个请求的所有日志串起来。
修法是手动传 context:
import contextvars
import functools
async def call_sync_with_context(executor, func, *args):
loop = asyncio.get_running_loop()
ctx = contextvars.copy_context() # 复制当前上下文
# 用 functools.partial 把 context 绑定到调用上
return await loop.run_in_executor(
executor,
lambda: ctx.run(func, *args) # 在复制的 context 里执行
)
# 使用
async def query_order(order_id):
result = await call_sync_with_context(my_executor, sync_db_call, order_id)
# 这里 sync_db_call 内部的 logger 能拿到 trace_id 了
这个坑在很多公司被踩过,因为大部分人不知道 ContextVar 和线程的细节。Python 3.11 之后,标准库的 asyncio.to_thread() 内部已经自动处理了 context 拷贝,但 run_in_executor 默认不会,自己用要小心。
常见同步陷阱:这 12 个东西你可能没意识到是同步的
除了 requests,生产里我们后来又发现了一堆「看起来无害但其实是同步阻塞」的调用,列出来给你避坑:
| 调用 | 是否阻塞 | 异步替代 |
|---|---|---|
requests.get/post |
是 | httpx / aiohttp |
urllib.request.urlopen |
是 | httpx / aiohttp |
time.sleep(n) |
是 | asyncio.sleep(n) |
open() + f.read() 大文件 |
是 | aiofiles |
pymysql / mysqlclient |
是 | aiomysql / asyncmy |
psycopg2 同步用法 |
是 | asyncpg / psycopg3 async |
redis-py 4.2 以下 |
是 | redis.asyncio (4.2+) |
pymongo |
是 | motor |
boto3 |
是 | aioboto3 |
requests.exceptions 拼接 |
否 | — |
json.dumps / loads 小对象 |
否 | — |
json.dumps 几十 MB 大对象 |
视情况 | orjson + run_in_executor |
logging.info 同步 handler |
有可能 | QueueHandler 异步化 |
subprocess.run |
是 | asyncio.create_subprocess_exec |
socket.recv 默认模式 |
是 | asyncio.open_connection |
os.system |
是 | asyncio.create_subprocess_shell |
| CPU 密集的 numpy/pandas 运算 | 是(卡 loop) | run_in_executor 到进程池 |
| 同步加密(bcrypt.hashpw) | 是 | run_in_executor 到线程池 |
表格里那些「有可能」和「视情况」需要你自己判断。bcrypt 哈希一次大约 100-300ms,如果你登录接口直接 await 同步 bcrypt,event loop 卡 200ms,QPS 直接腰斩。
线上灰度方案:怎么把同步代码安全替换成异步
我们后来把整个公司的 Python 微服务都做了一轮异步化改造,涉及 23 个服务。这个过程的踩坑也值得说说。
步骤 1:先把同步调用全部 run_in_executor 化
不动业务逻辑,只把所有阻塞调用包成 executor 调用。这是最小变更,可以快速上线。等于把毒瘤先挪到隔离区,等异步化改造时再彻底切除。
步骤 2:逐步替换为异步客户端,先低流量服务
从流量小、影响面小的服务开始换。每换一个,跑 1 周观察 event loop lag 指标,确认无异常再换下一个。
步骤 3:核心服务双跑对照
核心服务异步化时,做双跑(shadow traffic):新版异步代码部署但不接业务流量,通过 nginx 镜像把生产流量也复制一份到新版,对比响应差异和性能指标。我们发现过几个问题:
- httpx 默认不开 HTTP/2,某些上游服务的 HTTP/2 长连接被复用得更好,从 requests 切到 httpx 性能反而下降。要主动开
http2=True。 - asyncpg 不支持 prepared statement 缓存(在 PgBouncer transaction pooling 模式下),需要 disable_prepared_statements。
- aiohttp 默认 TCP_NODELAY 没开,对短请求性能影响明显。
步骤 4:慢慢去掉 run_in_executor 兜底
确认异步版本稳定后,把过渡期的 run_in_executor 包装移除,减少不必要的线程切换开销。
压测复现:用 locust 把这个 bug 重现
修完后我把这个故障做成了内部培训案例,用 locust 写了一个复现脚本,让团队的新人都跑一遍亲身体会:
# locustfile_bug.py
from locust import HttpUser, task, between
class OrderQueryUser(HttpUser):
wait_time = between(0.01, 0.05)
@task
def query_order(self):
# 模拟用户查订单
self.client.get(f"/orders/{self.user_id_pool[0]}")
def on_start(self):
self.user_id_pool = list(range(100000, 110000))
# 启动:locust -f locustfile_bug.py --host=http://localhost:8000 \
# --users=500 --spawn-rate=50 --run-time=2m --headless
同时模拟风控变慢(用 toxiproxy 注入延迟):
# 起 toxiproxy
docker run -d -p 8474:8474 -p 8666:8666 \
--name toxiproxy ghcr.io/shopify/toxiproxy
# 建一个代理
curl -X POST http://localhost:8474/proxies -d '{
"name": "risk_proxy",
"listen": "0.0.0.0:8666",
"upstream": "real-risk-service:80"
}'
# 给代理注入 8 秒延迟
curl -X POST http://localhost:8474/proxies/risk_proxy/toxics -d '{
"name": "latency",
"type": "latency",
"attributes": {"latency": 8000}
}'
# 然后让你的服务调用 risk_proxy 而不是真实的风控
# 跑 locust 压测,看 P99 和 QPS 变化
新人按这个流程跑一遍,亲眼看到 P99 从 80ms 飙到 12s 的那种震撼,比讲十遍原理都管用。
Kubernetes 探针:用 livenessProbe 自动重启卡死的 Pod
事故后我们还做了一件事:把 event loop 健康检查接到 K8s 的 livenessProbe。这样即使再有类似 bug,Pod 也会自动重启而不是干瞪眼。
实现思路是在应用里加一个独立的健康检查端点,它必须由 event loop 调度才能响应,如果 loop 被卡住,这个端点就不会有响应:
import asyncio
import time
from fastapi import FastAPI, HTTPException
app = FastAPI()
# 用一个全局的"心跳时间"
_last_heartbeat = time.monotonic()
async def heartbeat_loop():
"""每秒更新一次心跳时间,如果 loop 卡了这个就停了"""
global _last_heartbeat
while True:
_last_heartbeat = time.monotonic()
await asyncio.sleep(1)
@app.on_event("startup")
async def startup():
asyncio.create_task(heartbeat_loop())
@app.get("/healthz/liveness")
async def liveness():
elapsed = time.monotonic() - _last_heartbeat
# 如果心跳超过 5 秒没更新,认为 loop 卡死了
if elapsed > 5:
raise HTTPException(status_code=503, detail=f"loop_stuck for {elapsed}s")
return {"status": "ok", "lag": elapsed}
K8s 配置:
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
template:
spec:
containers:
- name: app
image: order-service:latest
livenessProbe:
httpGet:
path: /healthz/liveness
port: 8000
initialDelaySeconds: 30
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
# 累计失败 3 次 (15 秒) 才重启,避免抖动误杀
readinessProbe:
httpGet:
path: /healthz/readiness
port: 8000
periodSeconds: 2
timeoutSeconds: 1
failureThreshold: 2
注意几个细节:
- livenessProbe 失败阈值不能太小:一次抖动就重启会引发雪崩(请求被打到剩余 Pod,剩余 Pod 也被打挂)。我们用 failureThreshold=3 + periodSeconds=5,等于累计 15 秒才重启。
- readinessProbe 比 liveness 严格:readiness 失败只是把流量摘掉,不重启,可以更敏感(2 秒摘一次)。等服务恢复了再自动加回。
- 不要在 health 端点里做实际业务调用:有人喜欢在 liveness 里查一下 DB,这样 DB 抖一下所有 Pod 全死。健康检查就是检查「进程本身是不是活着」,不是「依赖是不是好的」。
更深一层的 debug:用 strace 抓 event loop 在干啥
py-spy 显示「Python 在调 requests.get」,但有时候你需要更细的信息 —— 这个 requests.get 卡在哪个系统调用?在等 connect 还是在等 recv?用 strace:
# attach 到运行中的进程,看所有系统调用
strace -p 42 -f -tt -T -e trace=network -o trace.log
# 解释:
# -p 42 目标 pid
# -f 跟随 fork/线程
# -tt 显示时间戳到 us
# -T 显示每个 syscall 耗时
# -e trace=network 只看网络相关 syscall
# -o trace.log 输出到文件
# 看输出
tail -f trace.log
# 你会看到类似:
# 21:47:32.812345 connect(7, {sa_family=AF_INET, sin_port=htons(443), ...}, 16) = 0 <0.001234>
# 21:47:32.823456 sendto(7, "GET /risk/123 HTTP/1.1\r\n..."..., 234, ...) = 234 <0.000123>
# 21:47:32.823600 recvfrom(7, ...
# 21:47:40.823600 ... ) = 8192 <7.999987> <-- 这一行 recvfrom 耗时 8 秒
看到 recvfrom 这种「等服务端响应」的系统调用耗时几秒,基本可以确认是上游服务慢。如果是 connect 慢,那是网络 / DNS / TCP 握手问题。
strace 还有个进阶用法:统计哪些 syscall 最耗时:
# 30 秒内统计 syscall 分布
strace -p 42 -c -e trace=network -f &
PID=$!
sleep 30
kill -INT $PID
# 输出大概长这样:
# % time seconds usecs/call calls errors syscall
# ------ ----------- ----------- --------- --------- ----------------
# 98.21 240.13 80044 3000 recvfrom
# 1.50 3.67 1223 3000 sendto
# 0.29 0.71 236 3000 connect
98% 的时间花在 recvfrom 上,锤实了「等上游响应慢」的判断。
新人训练:怎么把异步思维真的练出来
事故后我做了几次内部分享,后来又把这套总结成新人培训的固定项目。我们要求所有 Python 后端新人入职第一周必须完成下面这个练习:
练习 1:故意写一段会卡 event loop 的代码,然后修复
给新人一个 FastAPI 的最小项目,里面有 4 个 endpoint:
/sync_in_async:async 路由里调time.sleep(5)/blocking_db:async 路由里用 pymysql 查库/cpu_bound:async 路由里跑 SHA256 100 万次/correct:正确的异步实现作为对照
新人用 wrk 或 ab 给每个端点压 100 并发 30 秒,记录 QPS 和 P99,然后用之前学的工具(py-spy / loop lag / asyncio debug)定位每个问题,自己写修复代码再测一遍。这个练习能让人深刻理解「为什么 async 不是万能的」。
练习 2:照葫芦画瓢实现一个最小的 event loop
用大概 100 行 Python 代码实现一个能跑 asyncio.sleep + asyncio.gather 的玩具 event loop。代码核心其实就是:
import heapq
import time
import types
class MiniLoop:
def __init__(self):
self.ready = [] # 立即可执行的 coroutine 队列
self.waiting = [] # 定时等待的 heap (deadline, coro)
def call_soon(self, coro):
self.ready.append(coro)
def call_later(self, delay, coro):
heapq.heappush(self.waiting, (time.monotonic() + delay, coro))
def run_until_complete(self, main_coro):
self.call_soon(main_coro)
while self.ready or self.waiting:
now = time.monotonic()
while self.waiting and self.waiting[0][0] <= now:
_, c = heapq.heappop(self.waiting)
self.ready.append(c)
if not self.ready:
time.sleep(self.waiting[0][0] - now)
continue
coro = self.ready.pop(0)
try:
result = coro.send(None)
# 简化:result 是 ('sleep', secs) 表示要 sleep
if isinstance(result, tuple) and result[0] == 'sleep':
self.call_later(result[1], coro)
except StopIteration:
pass
@types.coroutine
def mini_sleep(secs):
yield ('sleep', secs)
# 跑一下
async def hello(name, delay):
print(f'{name} start')
await mini_sleep(delay)
print(f'{name} done')
async def main():
# 顺序执行
await hello('A', 1)
await hello('B', 1)
loop = MiniLoop()
loop.run_until_complete(main())
写完这个玩具,你对「为什么同步调用会卡 event loop」就有第一性原理的理解了。本质就是:event loop 是一个 while 循环,它依赖你的 coroutine 主动 yield。你不 yield,它就一直在你这一段 coroutine 里跑,别的 coroutine 没机会上场。同步函数永远不 yield,所以一旦进入就卡死。
练习 3:用 uvloop 替换默认 loop 看性能差异
uvloop 是 libuv 实现的 event loop(就是 Node.js 用的那个),性能比 asyncio 默认的纯 Python 实现快 2-4 倍。用法极简单:
import uvloop
uvloop.install() # 必须在 asyncio.run 之前调用
# 或者 Python 3.11+
import asyncio
asyncio.run(main(), loop_factory=uvloop.new_event_loop)
新人做完这个对比测试,会发现同一段代码加一行 uvloop.install() 就能 QPS 翻倍。这种「白嫖性能」的体验让人对 asyncio 生态多一份兴趣。
异步框架对比:为什么我们没用 trio
事故后我们也认真评估过要不要换到 trio。trio 是另一个 Python 异步框架,设计哲学叫「structured concurrency」,简单说就是「子任务的生命周期不能超过父任务」,从根上杜绝任务泄漏和 cancel 误处理。trio 的 API 在某些方面比 asyncio 优雅得多:
import trio
async def main():
# trio 的 nursery 强制结构化并发
async with trio.open_nursery() as nursery:
nursery.start_soon(fetch_user, 123)
nursery.start_soon(check_risk, 123)
nursery.start_soon(check_stock, [1, 2, 3])
# 出了 async with,所有子任务必须已经完成
# 任何一个失败都会取消其他的,异常清晰传播
asyncio.gather 的等价物在 trio 里就是 nursery,但语义更安全:
- 不会有「孤儿 task」
- 异常处理是 ExceptionGroup,所有失败都看得到
- cancel 信号传播规则清晰
但我们最后还是没换。原因:
- trio 生态比 asyncio 小一个数量级,很多库没有 trio 适配
- 团队大部分代码已经是 asyncio,迁移成本高
- asyncio 在 Python 3.11+ 也加了 TaskGroup,语义已经接近 trio 的 nursery
Python 3.11 的 TaskGroup 写法:
import asyncio
async def main():
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch_user(123))
t2 = tg.create_task(check_risk(123))
t3 = tg.create_task(check_stock([1, 2, 3]))
# 出了 async with,所有 task 必须完成
# 任何一个失败,其他自动 cancel,异常以 ExceptionGroup 抛出
user = t1.result()
risk = t2.result()
stock = t3.result()
如果你的项目是 Python 3.11+,强烈建议用 TaskGroup 替代 asyncio.gather,异常处理和资源清理都安全得多。
更彻底的方案:能不能干脆别用 asyncio?
事故后我们认真讨论过这个问题。Python 异步这么难写对,要不要全部退回到 gunicorn + sync worker 加大进程数?或者切到 Go?
结论是:具体看场景。
- IO 密集 + 中等 QPS(1000-10000):asyncio 仍然是最优选择,只要你团队能管住「不要写同步阻塞」。
- IO 密集 + 极高 QPS(> 10万):Python 不管同步异步都吃力,考虑 Go 或 Rust。
- CPU 密集:同步 + 多进程更合适,异步反而麻烦。
- 团队对 async 不熟:先用同步 + 多进程稳一点,业务增长后再迁移。
我们这边的订单查询服务,IO 密集 + 2000 QPS,完全适合 asyncio。事故的根因不是技术选型错,是 review 流程没卡住。
异步思维的心智模型:把它当成"协作式马戏团"
团队里有几个新人始终不能直观理解 async/await,我后来给他们讲了一个比喻,据说很有用,分享出来。
把 event loop 想象成一个马戏团的总导演,coroutine 是各个表演者(小丑、杂技演员、驯兽师)。所有表演者共用同一个舞台(单线程),同一时间只能有一个上场。这就是「协作式调度」—— 表演者必须自觉地表演一会儿就下场休息(await),让别的表演者上场。
在这个模型下:
- await sleep(1) 的杂技演员:翻了个跟头落地,在台边休息 1 秒,这期间小丑可以上场说段子。
- await httpx.get() 的驯兽师:把球扔给海狮,在等海狮回扔的间隙,他不在台上,导演调度别的人。
- 调了 requests.get() 的小丑:他站在台上不动,等远方电话告诉他笑话讲不讲。在他等的这段时间里,所有表演者都被冻在台下。这就是同步阻塞调用。
- 跑 SHA256 100 万次的小丑:他在台上疯狂表演不肯下来,别的人也都上不去台。这就是 CPU 密集。
把这个画面记牢,以后写 async 代码自然就会问:「这一段会不会让我的演员霸占舞台?」
这个比喻也能解释为什么 Go 没这问题:Go 的 goroutine 有一群"舞台技工"(M:N 调度器)在背后,会强行把霸占舞台太久的演员拖下去。但 asyncio 没有这种"强制下场"机制,完全靠演员自觉。
性能基准:asyncio / uvloop / Go / Node 在同场景下的横向对比
为了在团队里讲清楚"asyncio 性能上限在哪里",我做了一组横向对比。场景:一个简单的 HTTP 服务,接到请求后查一次 Redis(0.5ms),调一次 PostgreSQL(2ms),返回 JSON。机器 4 核 8G,客户端用 wrk 压。
| 实现 | 1000 QPS P99 | 5000 QPS P99 | 10000 QPS P99 | 20000 QPS P99 | 每核 RPS |
|---|---|---|---|---|---|
| Flask + gunicorn sync(8 worker) | 120ms | 挂 | 挂 | 挂 | ~250 |
| FastAPI + asyncio 原生 | 15ms | 45ms | 180ms | 挂 | ~3000 |
| FastAPI + uvloop | 10ms | 25ms | 80ms | 350ms | ~5500 |
| Go net/http(标准库) | 5ms | 12ms | 30ms | 120ms | ~12000 |
| Node.js (fastify) | 8ms | 20ms | 60ms | 200ms | ~7000 |
| Rust (axum + tokio) | 3ms | 8ms | 20ms | 60ms | ~25000 |
这张表的结论:
- Python sync 的天花板大约是 800 RPS / 核(GIL + 内存开销),IO 重就更低。这是为什么大量传统 Python Web 服务都要靠堆机器。
- Python asyncio + uvloop 接近 5500 RPS / 核,基本追上 Node.js,但还是不如 Go。
- Rust 是数量级领先,但开发效率代价大,只在性能关键服务用。
所以,如果你的服务 QPS 没到 10000,Python asyncio + uvloop 完全够用,不要为了「将来可能高 QPS」过早换语言。如果将来真到了 10 万 QPS,那是甜蜜的烦恼,届时再说。
避坑清单(直接抄走)
- 1. async 函数里禁止任何 requests / pymysql / 同步 redis,CI 卡死
- 2. 所有 HTTP 客户端必须设全套 timeout(connect/read/write/pool)
- 3. slow_callback_duration 默认 100ms 开起来
- 4. Event loop lag 指标接 Prometheus,P99 > 200ms 告警
- 5. 上游 API 调用全部加熔断器(aiobreaker / purgatory)
- 6. CPU 密集走 ProcessPoolExecutor,不是 ThreadPoolExecutor
- 7. 全局复用 httpx.AsyncClient,别每次 new(连接池泄漏)
- 8. 第三方库一定看 README 确认是真异步还是 sync 套壳
- 9. 压测必须模拟上游变慢(toxiproxy 注入延迟)
- 10. OpenTelemetry 必装,出问题第一时间看链路
- 11. 同步代码迁移用「先 executor 包装、再换异步库」两步走
- 12. 上线后跑 py-spy record 录一份 baseline 火焰图存档
- 13. bcrypt / argon2 这类 CPU 密集加密,必须 run_in_executor
- 14. 大 JSON(> 1MB)序列化用 orjson + executor
- 15. 日志 handler 别用 SMTP / HTTP 同步上报,加 QueueHandler
事故后我们对全部 Python 服务做了一次"体检"
修完订单查询服务的当周,我们启动了一个内部代号"清算"的小项目:用前面提到的 AST 扫描脚本,把公司全部 47 个 Python 微服务过了一遍,看看还有多少颗类似的雷。结果触目惊心:
- 23 个服务有 async 函数里调用 requests 的代码,合计 184 处
- 11 个服务有 async 函数里调用 pymysql / 同步 redis 的代码
- 9 个服务在 async 函数里有
time.sleep()(不是测试代码,是真业务代码) - 6 个服务在 async 函数里有同步加密(bcrypt / argon2 直接 await sync 函数)
- 3 个服务把 numpy 大数组运算直接放在 async 函数里跑,单次 200ms+
这些雷换算一下:平均每个 Python 服务有 5-10 颗潜在的「会引发雪崩」的隐患。这就是我说的"asyncio 看起来好用,实际上有毒"的最直接证据。
我们用了两个月时间逐个修完。修复策略按风险排序:
- P0(立刻修):核心服务里的同步 HTTP 调用、同步 DB 调用。直接 hotfix,周内必须上线。
- P1(2 周内修):非核心服务的同步调用、所有 time.sleep。
- P2(下季度修):同步加密、numpy 计算等 CPU 密集场景,需要架构性调整(可能需要拆服务)。
整个清算结束后,我们建了一个长期机制:每周自动扫描全部 Python 仓库,出周报发到群里。新增的违规立刻 @ 写代码的人,3 天内不修的自动开 P2 工单。
事故复盘报告的模板:写给其他团队抄
这次事故的复盘文档我后来分享给了公司其他用 Python 的团队,作为"故障复盘怎么写"的范本。模板大概长这样:
## 故障概要
- 时间:2023-09-XX 21:47 - 00:40
- 影响范围:订单查询 P99 12s,持续 2.5 小时,核心交易 GMV 损失 X
- 严重程度:P1
- 根因:async 函数里同步 requests 调用,上游变慢后 event loop 雪崩
## 时间线
(见前面表格)
## 根因分析(Five Whys)
1. 为什么 P99 飙升?event loop 被卡
2. 为什么 loop 被卡?async 函数里有 requests.get
3. 为什么有 requests.get?一年前我自己写的,当时 QPS 低不在意
4. 为什么 review 没拦?当时团队没有「禁止同步阻塞」规则
5. 为什么没规则?对 asyncio 风险认知不足
## 直接修复
hotfix 改成 run_in_executor,5 分钟止血
## 长期改进
1. 加 AST 扫描规则,CI 卡死
2. 加 event loop lag 监控
3. 加熔断器
4. 异步化全部同步 HTTP 调用
5. 写本复盘 + 团队培训
## 借这次事故学到了什么(超越本次事故)
- 所有「现在 OK 的设计选择」都该标 TTL
- 「全员指标绿但服务挂了」是 event loop 卡的标志
- py-spy 是 Python 救火必备
- 故障期间相信工具不相信经验
很多复盘报告写得像「我们错了我们改了」的检讨书,实际上没人会看。好的复盘必须让读者能从你的事故里抄到经验,落到他自己的项目里。
总结:asyncio 不是银弹,是一把削铁如泥但容易割到自己的刀
这个事故让我对 Python asyncio 的态度从「真香」转变到「敬畏」。asyncio 本身没有问题,问题是它对开发者的要求太高 —— 你需要时刻明白「这一行代码会不会卡 event loop」,任何一个疏忽,在高并发下都会被无限放大。
如果非要用一个比喻,我会说 asyncio 像是一把唐刀:刀法熟练的人能一刀切几个西瓜,不熟的人切自己手指头。Go 的 goroutine 像是把瑞士军刀,功能没那么极致但闭着眼睛也不会出大事;Java 的虚拟线程像是把多功能料理机,功能强大但启动慢、占空间。Python asyncio 在性能和灵活性的甜蜜点上,但配的是一本 800 页的使用手册。
事故过去快两年了,我们那个订单查询服务现在 QPS 涨到 8000,P99 稳定在 65ms。再也没出过类似事故。但每次 review 别人代码,看到 async 函数里出现一个我不认识的库,我还是会本能地多问一句:「这个库你确定是真异步吗?」
后续的小事故合集:再栽进去过的几个坑
两年里我们再也没有出过那次量级的事故,但小坑还是踩过几个,顺手记一下,你可能也会遇到。
1. asyncio 默认日志格式不带 task name,异步并发场景里日志串不起来。解决:用 asyncio.current_task().get_name() 拿当前 task 名字,在 logger 里塞进去。Python 3.12+ 默认 Task 名字是「Task-N」自增,自己写 endpoint 时记得手动 asyncio.create_task(coro, name="risk_call_user_123") 起有意义的名字。
2. uvloop + windows = 不行。uvloop 只支持 Linux/macOS,Windows 上 import 直接报错。我们的开发同学用 Mac、CI 用 Linux 都没问题,但偶尔有 Windows 同学来 review 代码跑测试就崩。修法是 try-import:try: import uvloop; uvloop.install(); except ImportError: pass。
3. PyCharm Debug 模式下 event loop 会比正常慢 5 倍。有同学跑性能基准发现「我本地 P99 800ms,生产怎么才 80ms」,纳闷半天发现是 PyCharm 的 debugger attach 上去拖慢了 loop。性能测试一定要脱离 IDE。
4. asyncio.wait_for 在 Python 3.11 之前的 cancel 有 bug。具体表现:用 wait_for 给一个 coroutine 设超时,超时后里面的资源(比如 DB 连接)可能没被正确清理。这个 bug 在 3.11 修了,但如果还在 3.8/3.9,加一层 shield 保护清理流程。
5. 用 anyio 包装 trio 风格的代码,踩了 cancel scope 嵌套的坑。anyio 是个统一 asyncio/trio 接口的库,但它的 cancel scope 语义比 asyncio 严格,我们有段代码在 asyncio 下跑没事,加 anyio 后总是莫名 CancelledError。最后查到是 nursery 嵌套时的取消传播规则不一样。结论:别在已有 asyncio 项目里混用 anyio,要么全 asyncio,要么全 trio。
6. uvicorn workers 多于 1 时,共享的 httpx.AsyncClient 在某些场景下会泄漏连接。原因是 uvicorn 多 worker 模式下每个 worker 是独立进程,但有些代码以为是线程级共享。修法是每个 worker 启动时单独 new 一个 AsyncClient,而不是模块级全局 new。
总结:asyncio 不是银弹,是一把削铁如泥但容易割到自己的刀
(注:为了避免重复,把开篇结论挪到这里完整说)
这个事故让我对 Python asyncio 的态度从「真香」转变到「敬畏」。asyncio 本身没有问题,问题是它对开发者的要求太高 —— 你需要时刻明白「这一行代码会不会卡 event loop」,任何一个疏忽,在高并发下都会被无限放大。
如果非要用一个比喻,我会说 asyncio 像是一把唐刀:刀法熟练的人能一刀切几个西瓜,不熟的人切自己手指头。Go 的 goroutine 像是把瑞士军刀,功能没那么极致但闭着眼睛也不会出大事;Java 的虚拟线程像是把多功能料理机,功能强大但启动慢、占空间。Python asyncio 在性能和灵活性的甜蜜点上,但配的是一本 800 页的使用手册。
所以如果你正在做技术选型,我的建议是:
- 团队已经熟 Python,业务 IO 重 QPS 中等(1k-10k),用 asyncio,但配套规则要立到位 —— AST 扫描、loop lag 监控、code review checklist 一个不能少。
- 团队对 async 完全陌生,业务 QPS 不高,先用 Flask + 多进程 + Gunicorn 稳一点,把基础设施做扎实再迁。
- 业务 QPS 已经压到 Python 极限(单服务持续 10k+),考虑 Go,但别用「现在还没到那一天」当不学异步的理由。
- 已经在用 asyncio 的项目,先扫一遍 AST、装一遍 py-spy、立一遍 review 规则,这三件事一周就能搞完,可以避免大概率的雪崩事故。
事故过去快两年了,我们那个订单查询服务现在 QPS 涨到 8000,P99 稳定在 65ms。再也没出过类似事故。但每次 review 别人代码,看到 async 函数里出现一个我不认识的库,我还是会本能地多问一句:「这个库你确定是真异步吗?」
异步编程没有捷径,只有一边踩坑一边记录,把每一次教训变成下一次的规则。希望这篇 6 小时排查的复盘,能帮你少走几步弯路。如果哪天你也半夜被告警群叫起来,排查到天亮才发现是个不起眼的同步调用,记得来这篇文章下留个言 —— 我们一起骂。
共勉。
—— 别看了 · 2026