一、问题背景

事情是这样的。我们有个商品详情聚合接口 GET /api/v1/products/{id},返回商品基础信息、库存、价格、店铺信息和几个推荐位。这个接口被App首页、商品列表页和搜索页同时调用,日均请求量大概200万,峰值QPS在800左右。

上线初期没什么问题,但随着商品数据量涨到千万级、调用方越来越多,监控上P99延迟慢慢爬到了1.8s,高峰期还会冒出一批5s超时的告警。更难受的是,这个接口一慢,会拖累同一个uvicorn worker里的其他接口——因为它内部有同步阻塞调用,事件循环被卡住了。

我先做了几件“止血”的事:把uvicorn worker数从4调到8,加了限流。但这只是把问题往后推,根因没解决。于是决定认真做一次profiling。

二、环境与版本

先把环境交代清楚,不同版本的行为差异挺大的:

  • Python 3.11.7
  • FastAPI 0.110.0
  • Uvicorn 0.27.1(--workers 8,--loop uvloop)
  • SQLAlchemy 2.0.29(异步模式,asyncpg 0.29.0)
  • PostgreSQL 15.6,商品表约1200万行
  • Redis 7.2.4(单机,maxmemory 8GB,allkeys-lru)
  • 压测工具:wrk 4.2.0,机器 8C16G,压测机和被测机分开

三、定位:profiling 工具怎么选

线上服务不能随便挂调试器,我分了两层来做。

第一层,线上火焰图用 py-spy。 它通过 process_vm_readv 采样,不需要改代码、不需要重启,对性能影响很小:

# 采样 30 秒,输出火焰图
py-spy record -o profile.svg --pid $(pgrep -f "uvicorn app.main:app") --duration 30 --rate 200

看火焰图的时候我一眼就发现问题:time.sleep 和 requests.get 占据了大量采样。翻代码发现,接口里调用了两个第三方HTTP服务(运费模板、推荐位),用的是同步 requests,而且没设超时。在async函数里跑同步IO,等于把整个事件循环堵死——这就是为什么它一慢会拖累其他接口。

第二层,单请求级别的细粒度分析用 cProfile。 我在测试环境用中间件包了一层,只对慢请求采样:

import cProfile
import pstats
import io
from fastapi import Request

@app.middleware("http")
async def profile_slow_requests(request: Request, call_next):
    profiler = cProfile.Profile()
    profiler.enable()
    response = await call_next(request)
    profiler.disable()
    if response.headers.get("x-elapsed-ms") and int(response.headers["x-elapsed-ms"]) > 500:
        s = io.StringIO()
        pstats.Stats(profiler, stream=s).sort_stats("cumulative").print_stats(20)
        logger.warning("slow request profile:\n%s", s.getvalue())
    return response

cProfile 的结果里,除了那个同步HTTP调用,还暴露了第二个问题:SELECT ... FROM product_sku WHERE product_id = ? 这个查询被执行了 20 多次——典型的 N+1。代码里对SKU列表循环查库存和价格,每个SKU一次查询。

到这里两个瓶颈就很清楚了:

  1. async 路由里混用同步阻塞IO(requests + 无超时)
  2. SKU 维度的 N+1 查询,单请求最多打 20+ 次数据库

四、方案设计与核心实现

4.1 同步阻塞改异步 + 并发编排

把 requests 换成 httpx.AsyncClient,并给所有外部调用加超时(连接 0.5s、读 1.5s),然后用 asyncio.gather 把两个互不依赖的远程调用并发起来。原来串行是 2 × ~400ms,并发后约 400ms。

import asyncio
import httpx
from fastapi import APIRouter, HTTPException

router = APIRouter()

# 全局复用连接池,不要每次请求新建 client
_http_client = httpx.AsyncClient(
    timeout=httpx.Timeout(connect=0.5, read=1.5, write=1.5, pool=0.5),
    limits=httpx.Limits(max_connections=200, max_keepalive_connections=50),
    http2=True,
)

async def fetch_shipping_template(shop_id: int):
    r = await _http_client.get(f"http://shipping-svc/internal/templates/{shop_id}")
    r.raise_for_status()
    return r.json()

async def fetch_recommendations(product_id: int):
    r = await _http_client.get(f"http://rec-svc/internal/recs/{product_id}")
    r.raise_for_status()
    return r.json()

@router.get("/api/v1/products/{product_id}")
async def get_product(product_id: int):
    # 商品主信息、远程调用、SKU 批量查询三者互不依赖,一起并发
    product_task = asyncio.create_task(load_product_detail(product_id))
    shipping_task = asyncio.create_task(fetch_shipping_template(product_id))
    rec_task = asyncio.create_task(fetch_recommendations(product_id))
    product, shipping, recs = await asyncio.gather(
        product_task, shipping_task, rec_task, return_exceptions=False
    )
    return {"product": product, "shipping": shipping, "recommendations": recs}

这里有个坑后面会讲:asyncio.gather 里任何一个抛异常,整体就挂了,所以外部服务的降级得单独处理。

4.2 消除 N+1:批量查询 + 索引

原来的写法是循环 SKU 逐个查,改成一次性 IN 查询,同时在 product_sku(product_id) 上补了索引(之前只有主键索引,product_id 上没有索引,1200 万行的表上每次都走顺序扫描)。

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

async def load_product_detail(session: AsyncSession, product_id: int):
    stmt = (
        select(Product)
        .options(selectinload(Product.skus))          # 一条 SQL 批量加载所有 SKU
        .where(Product.id == product_id)
    )
    product = (await session.execute(stmt)).scalar_one_or_none()
    if product is None:
        raise HTTPException(status_code=404, detail="product not found")

    sku_ids = [sku.id for sku in product.skus]
    # 库存和价格一次 IN 查询搞定,替代原来的 for 循环
    stock_rows = await session.execute(
        select(SkuStock.sku_id, SkuStock.available).where(SkuStock.sku_id.in_(sku_ids))
    )
    stock_map = {row.sku_id: row.available for row in stock_rows}
    for sku in product.skus:
        sku.available = stock_map.get(sku.id, 0)
    return product

对应的索引变更:

CREATE INDEX CONCURRENTLY idx_product_sku_product_id ON product_sku (product_id);
CREATE INDEX CONCURRENTLY idx_sku_stock_sku_id ON sku_stock (sku_id);

用 CONCURRENTLY 是为了不锁表,线上千万级表直接 CREATE INDEX 会阻塞写入,这个后面踩坑里细说。

4.3 两级缓存

商品详情是典型的读多写少,非常适合缓存。我做了两级:本地 LRU(cachetools 5.3.3,TTL 30s,容量 5000)+ Redis(TTL 300s,加随机抖动防雪崩)。本地缓存挡掉热点 key,Redis 挡掉跨 worker 的重复查询。

import json
import random
from cachetools import TTLCache

_local_cache = TTLCache(maxsize=5000, ttl=30)

async def get_product_cached(redis, session, product_id: int):
    key = f"product:detail:{product_id}"
    if (hit := _local_cache.get(key)) is not None:
        return hit

    if raw := await redis.get(key):
        data = json.loads(raw)
        _local_cache[key] = data
        return data

    data = await load_product_detail(session, product_id)
    payload = serialize_product(data)
    # TTL 加 0~60s 抖动,避免同一批 key 同时过期造成缓存击穿
    await redis.set(key, json.dumps(payload), ex=300 + random.randint(0, 60))
    _local_cache[key] = payload
    return payload

写入侧在商品更新时主动删 Redis key,本地缓存靠 30s TTL 自然过期,接受最多 30s 的不一致(业务上可接受,价格敏感字段走单独接口实时查)。

五、踩坑与优化

坑1:asyncio.gather 没有降级。 改造后推荐服务偶尔抖动,直接让整个接口 500。后来给远程调用包了一层,失败时返回空推荐位而不是抛异常:

async def safe_fetch(coro, default):
    try:
        return await coro
    except (httpx.HTTPError, asyncio.TimeoutError) as e:
        logger.warning("remote call failed: %s", e)
        return default

坑2:CREATE INDEX CONCURRENTLY 在事务里不能执行。 我一开始用 alembic migration 跑,报 CREATE INDEX CONCURRENTLY cannot run inside a transaction block。解决办法是在 migration 里 op.execute("COMMIT") 后再执行,或者干脆手动在低峰期跑。另外 CONCURRENTLY 失败会留下 invalid 索引,记得查 pg_index.indisvalid 清理。

坑3:SQLAlchemy 异步 session 没有正确关闭。 压测时连接数飙到 300+,PG 报 too many clients。原因是依赖注入里手动 new session 忘了 close。改成 async with async_session() as session 之后连接数稳定在 50 以内。

坑4:uvloop 和某些 C 扩展不兼容。 我们有个老的日志库用了 gevent monkey patch,和 uvloop 冲突,表现是偶发卡死。最后是把这个库换掉才彻底解决。这也是我建议 profiling 一定要看火焰图的原因——这种问题看日志根本看不出来。

坑5:本地缓存的序列化开销。 一开始本地缓存存的是 ORM 对象,每次序列化 JSON 要 3~5ms,反而成了新瓶颈。改成缓存序列化后的 dict,直接命中返回,省了这步。

六、效果数据

压测条件:wrk 4.2.0,8 线程 200 连接,持续 5 分钟,数据为 3 轮取中位数。

指标 优化前 优化后 变化
QPS 780 4200 +438%
P50 620ms 38ms -94%
P95 1450ms 95ms -93%
P99 1820ms 120ms -93%
错误率 0.8%(超时) 0.00% -
DB 查询次数/请求 23 3 -87%
单请求远程调用耗时 ~800ms(串行) ~420ms(并发) -47%

线上灰度后观察一周,P99 稳定在 100~140ms,高峰期没有再出现超时告警,同 worker 下其他接口的延迟也顺带降了约 30%——这印证了之前同步阻塞拖累事件循环的判断。

缓存命中率方面,本地 LRU 命中约 62%,Redis 再命中约 28%,整体回源率不到 10%。

七、总结

这次调优最大的体会是:先定位,再动手。我一开始凭直觉调 worker 数、加限流,全是治标。真正用 py-spy 看火焰图、用 cProfile 看单请求调用栈之后,两个瓶颈(同步阻塞、N+1)十分钟内就暴露了。

几点可以复用的经验:

  1. FastAPI 的 async 路由里绝对不能出现同步阻塞 IO,requests、同步 DB driver、time.sleep 都是雷,火焰图上会非常明显。
  2. ORM 的 N+1 在 profiling 里表现为同一个 SQL 模板高频重复,看到就上 selectinload 或批量 IN 查询。
  3. 大表建索引一定用 CONCURRENTLY,并且提前确认不在事务里。
  4. 缓存做两级,TTL 加抖动,写入侧主动失效,本地缓存接受短时间不一致,这是性价比最高的组合。
  5. 压测数据和线上监控要对照着看,压测机和服务同机部署会互相干扰,数字会骗人。

如果你们也有类似的慢接口,建议先从火焰图开始,别急着改代码。