一、问题背景

事情起因很朴素:运营反馈商品详情页“转圈圈”。我先看了监控面板,接口 /api/v1/products/{id} 的P99延迟在两周内从1.2s爬到了2.3s,但服务器CPU使用率只有30%左右,内存也稳定。这种“CPU不高但慢”的形态,基本可以排除计算密集型问题,大概率卡在IO等待或者锁上。

再拆一下流量:这个接口QPS峰值约180,单实例部署(4C8G),前面挂Nginx。服务是历史遗留的混合栈——主站用Flask 2.2.5跑在gunicorn sync worker下,新业务用FastAPI 0.110.0跑在uvicorn上,两边共用一个PostgreSQL 14.9和Redis 7.2。商品详情这个接口恰好是Flask写的,而且它内部还通过HTTP调了一次FastAPI的商品推荐服务——一个典型的“同步调异步”的坑。

我的目标很明确:定位瓶颈,把它压到200ms以内,并且给出可复现的压测数据。

二、环境与版本

先把环境交代清楚,不然下面的数字没有参照意义:

  • Python 3.11.6
  • Flask 2.2.5 + gunicorn 21.2.0(--workers 4 --worker-class sync)
  • FastAPI 0.110.0 + uvicorn 0.29.0(--workers 2)
  • PostgreSQL 14.9,连接池用 asyncpg 0.29.0 / psycopg2-binary 2.9.9
  • Redis 7.2.4,客户端 redis-py 5.0.3
  • 压测工具:wrk 4.2.0,wrk -t4 -c200 -d60s
  • 监控/分析:py-spy 0.3.14、cProfile、pg_stat_statements

三、方案设计与瓶颈定位

3.1 先做profiling,别猜

第一步是用py-spy对gunicorn worker做采样,因为它对生产环境影响最小(attach上去就行,不用改代码):

# 找到 gunicorn worker 的 pid
ps -ef | grep gunicorn | grep -v master

# 采样 30 秒,生成火焰图
py-spy record -o profile.svg --pid 12345 --duration 30 --rate 100

火焰图一出来就很明显了:超过60%的采样落在 socket.recv 和 psycopg2 的等待上。也就是说,worker 大部分时间在等数据库和等下游HTTP响应,而不是在算。这和“CPU 30%”完全对得上——sync worker 被 IO 阻塞了,再多 worker 也只是堆进程。

为了拿到调用级别的细节,我又用 cProfile 在预发环境跑了一轮(生产上cProfile开销太大,不建议常开):

# profile_api.py
import cProfile, pstats, io
from app import create_app

app = create_app()

def run():
    with app.test_client() as c:
        for _ in range(50):
            c.get("/api/v1/products/10086")

pr = cProfile.Profile()
pr.enable()
run()
pr.disable()

s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats("cumulative")
ps.print_stats(25)
print(s.getvalue())

输出里 get_product_detail 内部有个 fetch_recommendations 占了近40%的累计时间,而它只是一次 requests.get 调用;另外 get_product_images、get_product_skus、get_product_attrs 三个查询各查一次库——典型的 N+1。

3.2 定位结论

三个瓶颈,按影响排序:

  1. 同步HTTP调用下游:Flask worker 用 requests 同步调 FastAPI 推荐服务,平均耗时 480ms,且没有超时设置(默认无限等),下游一抖动就全阻塞。
  2. N+1 查询:商品详情一次请求打了 4 次库(主表 + 图片 + SKU + 属性),单次往返约 15-25ms,加上连接池争抢,累计 120ms 以上。
  3. 无缓存:同一个热门商品每秒被查十几次,完全没缓存,数据库白扛。

3.3 方案

  • 把商品详情接口整体迁到 FastAPI(async),用 httpx.AsyncClient 异步调下游,设 300ms 超时 + 降级;
  • 四个查询合并为一条带 json_agg 的 SQL,一次往返拿全;
  • 加 Redis 两级缓存:本地 LRU(cachetools,TTL 5s,抗热点)+ Redis(TTL 60s + 随机抖动防雪崩);
  • 给 product_id、category_id 补索引,并检查 pg_stat_statements 确认执行计划走了索引。

四、核心实现

4.1 合并查询 + 索引

原来的四次查询改成一条:

-- 索引(先确认已有主键,这里补的是过滤和排序字段)
CREATE INDEX CONCURRENTLY idx_products_category_status
    ON products (category_id, status) WHERE status = 'on_sale';
CREATE INDEX CONCURRENTLY idx_product_images_pid_sort
    ON product_images (product_id, sort_order);

-- 合并查询
SELECT
    p.id, p.title, p.price, p.status, p.updated_at,
    COALESCE(imgs.images, '[]'::json) AS images,
    COALESCE(skus.skus, '[]'::json)   AS skus,
    COALESCE(attrs.attrs, '[]'::json) AS attrs
FROM products p
LEFT JOIN LATERAL (
    SELECT json_agg(json_build_object('id', id, 'url', url, 'sort', sort_order)
                    ORDER BY sort_order) AS images
    FROM product_images WHERE product_id = p.id
) imgs ON TRUE
LEFT JOIN LATERAL (
    SELECT json_agg(json_build_object('id', id, 'name', name, 'stock', stock)) AS skus
    FROM product_skus WHERE product_id = p.id
) skus ON TRUE
LEFT JOIN LATERAL (
    SELECT json_agg(json_build_object('key', attr_key, 'value', attr_value)) AS attrs
    FROM product_attrs WHERE product_id = p.id
) attrs ON TRUE
WHERE p.id = $1;

这条 SQL 在 8 万行商品表上实测 3.1ms(EXPLAIN ANALYZE,命中索引),比原来四次往返的 90ms+ 好太多。注意 LATERAL + json_agg 在子表数据量大时也要留意,如果单个商品图片上千张,建议分页或拆开,别一把梭。

4.2 FastAPI 异步接口 + 缓存

# app/api/product.py
import asyncio, json, random
from fastapi import APIRouter, HTTPException
import asyncpg, httpx
from cachetools import TTLCache

router = APIRouter()
local_cache = TTLCache(maxsize=2000, ttl=5)   # 本地热点缓存,5s

SQL = """..."""  # 上面那条合并查询

async def get_pool(app):
    return app.state.pg_pool

async def fetch_recommend(pid: int) -> list:
    """异步调下游推荐服务,带超时和降级"""
    try:
        async with httpx.AsyncClient(timeout=httpx.Timeout(0.3)) as client:
            r = await client.get(f"http://rec-svc/api/rec/{pid}")
            r.raise_for_status()
            return r.json().get("items", [])
    except Exception:
        return []          # 降级:推荐失效不影响主流程

@router.get("/api/v1/products/{pid}")
async def product_detail(pid: int, request):
    # L1 本地缓存
    if pid in local_cache:
        return local_cache[pid]

    # L2 Redis
    redis = request.app.state.redis
    cached = await redis.get(f"prod:{pid}")
    if cached:
        data = json.loads(cached)
        local_cache[pid] = data
        return data

    pool = await get_pool(request.app)
    async with pool.acquire() as conn:
        row = await conn.fetchrow(SQL, pid)
    if not row:
        raise HTTPException(404, "product not found")

    detail = dict(row)
    # 并行:推荐服务与数据组装同时进行
    rec_task = asyncio.create_task(fetch_recommend(pid))
    detail["recommend"] = await rec_task

    payload = json.dumps(detail, default=str)
    # TTL 加随机抖动,避免同一批 key 同时过期
    await redis.setex(f"prod:{pid}", 60 + random.randint(0, 10), payload)
    local_cache[pid] = detail
    return detail

连接池初始化用 asyncpg.create_pool(min_size=5, max_size=20),配合 PostgreSQL 的 max_connections=200,两个 uvicorn worker 各 20 条连接,压力完全可控。

4.3 缓存失效

写接口(更新商品)时删缓存,采用“先更新库、再删缓存”的常规策略,并给 key 加版本前缀 prod:v2:{pid},方便整体失效:

async def invalidate(pid: int, redis):
    await redis.delete(f"prod:v2:{pid}")
    local_cache.pop(pid, None)

多实例部署时本地缓存有 5s 的延迟窗口,业务上可接受(商品详情不是强一致场景),这点我在评审时明确了,避免后续扯皮。

五、踩坑与优化

坑1:httpx 每次请求新建 client。 第一版我图省事在函数里 async with httpx.AsyncClient(),压测发现连接建立开销占了推荐调用的 30%。改成应用级单例 client(在 lifespan 里创建、关闭),复用连接池,那部分耗时直接降到个位数毫秒。

坑2:asyncpg 的 json_agg 返回的是字符串。 json_agg 在 asyncpg 里默认映射成 str 而不是 dict,前端拿到的是转义字符串。解决方式是注册 codec:

async def init_conn(conn):
    await conn.set_type_codec(
        "json", encoder=json.dumps, decoder=json.loads, schema="pg_catalog"
    )
    await conn.set_type_codec(
        "jsonb", encoder=json.dumps, decoder=json.loads, schema="pg_catalog"
    )

pool = await asyncpg.create_pool(dsn, init=init_conn, min_size=5, max_size=20)

坑3:本地缓存把 404 也缓存了。 早期版本没判断空值,某个下架商品被缓存成 None,导致 5 秒内一直返回空。后来只缓存有效结果,空结果走短 TTL(2s)单独 key。

坑4:gunicorn sync worker 的迁移节奏。 我没敢一次性切全量,先按 10% 流量灰度到 FastAPI 实例,观察错误率和 P99 稳定两天后再全量。灰度期间两套服务并存,缓存 key 加了版本前缀隔离,避免脏读。

六、效果数据

压测条件统一:wrk -t4 -c200 -d60s,请求同一批 500 个热门商品 ID(模拟真实热点分布),单实例 4C8G。

指标 优化前(Flask) 优化后(FastAPI)
QPS 180 2100
P50 620ms 28ms
P99 2310ms 85ms
平均CPU 30% 62%
DB 查询次数/请求 4 1(缓存命中时0)
下游调用超时影响 全阻塞 300ms降级

缓存命中率方面,Redis 层约 92%,本地 LRU 再挡掉约 35% 的 Redis 请求(热门商品集中)。数据库 QPS 从峰值 720 降到 60 左右,pg_stat_statements 里那条合并查询平均 3.1ms。

线上观察一周:P99 稳定在 80-100ms 区间,错误率 0.02%(优化前 0.31%,主要是下游超时导致的 502)。

七、总结

这次调优没有用什么黑科技,核心就三件事:先用 py-spy 和 cProfile 把“慢在哪”钉死,再针对 IO 阻塞做异步化,最后用缓存和索引把重复劳动消掉。几个我认为值得记住的点:

  1. CPU 低但延迟高,优先怀疑同步 IO 阻塞,别急着加机器;
  2. sync worker 里调下游 HTTP 一定要设超时,否则一个抖动拖垮整个 worker;
  3. N+1 在 ORM 时代很常见,用 LATERAL + json_agg 合并查询,但要注意子表数据量;
  4. 缓存要设计失效路径和降级,热点 key 的 TTL 加随机抖动是低成本高收益的操作;
  5. 压测数据要在同一批请求、同一并发下对比,不然数字没意义。

如果你们也有类似的混合栈遗留服务,建议先做 profiling 再决定迁移范围——我们这次只迁了一个接口就拿到了 20 倍 QPS 提升,没必要为了“技术统一”一次性重写全部。