从”能跑”到”能稳定承载生产流量”,中间隔着一轮系统性的性能优化。最开始单机 80 个并发就把 CPU 打满,接口 P99 一度飙到 850ms,有用户直接留言”转圈的时间比看视频还长”。我没有急着堆机器,而是从下载链路、AI 推理、缓存、前端渲染、数据库与可观测性六个方向逐个拆解,每个方向都先定指标再谈方案。下面把完整的优化路径、换过的方案和实测数据写出来,给同样做”重 IO + 重推理”型服务的团队一个参考。
一、性能优化的目标
性能优化第一件事不是改代码,而是定指标。没有目标就动手,最后只会把数字从一个地方搬到另一个地方,改完也不知道有没有用。我为总结器定了 4 个核心指标,先跑一轮压测拿到基线,再定目标值:
| 指标 | 当前值 | 目标值 |
|---|---|---|
| 总结首字延迟 | 1.2s | < 0.5s |
| 并发下载数 | 80 | ≥ 300 |
| 接口 P99 | 850ms | < 300ms |
| 故障恢复 | 手动 | 自动,< 60s |
这几个指标不是拍脑袋定的。首字延迟直接决定用户愿不愿意等,P99 反映的是绝大多数用户的真实体感而不是平均值,故障恢复时间则是运维成本的分水岭——手动恢复一次至少 20 分钟,自动恢复只要 60 秒内。指标定好之后,每一轮优化都有明确的验收标准,团队也不会为了”看起来更快”去做无效改动。
二、下载链路
下载是典型的 IO 密集型链路,优化空间主要在并发与重试。最初我是逐条任务串行下载,一个平台慢,整条队列都堵住;后来改成四段式任务拆分:
- 用
asyncio + httpx把每个任务拆成”元数据 → 视频 → 音频 → 字幕”四段子任务; - 限速器按域名维度令牌桶,避免单平台封禁;
- 文件落地先写临时分片,写完再 rename,断点续传稳定;
- CDN 命中率监控,命中率低于 70% 时报警。
子任务拆分带来的隐性收益是失败隔离:音频段下载失败不需要重下整个视频,只重试这一段。这里也踩过一个坑——一开始用全局限速,一个平台重试时把其他平台的流量也拖慢了,后来改成按域名独立的信号量,平台之间互不干扰:
from asyncio import Semaphore
# 按域名分配独立的并发信号量,一个平台出问题不拖累其他平台
_sems = {}
def get_semaphore(domain: str, limit: int) -> Semaphore:
if domain not in _sems:
_sems[domain] = Semaphore(limit)
return _sems[domain]
实测效果:下载并发从 80 提到 300 以上,单平台失败不再拖垮全局,断点续传让大文件下载的失败率明显下降。到这里,下载链路的并发模型基本成型,后面只需要按平台调整限速参数。
三、AI 推理
推理延迟直接决定用户体验,也是六个方向里最难压缩的一环。模型的单次推理时间由参数量和硬件决定,短期改不动,所以我把精力放在”怎么把长任务切成短任务、把等待变成流式”上:
- 切换到流式输出,逐字返回;
- 段落级并发:把长字幕切成 5 段并送模型,合并后渲染;
- 模型分级:短片段用便宜模型,长片段用强模型;
- 提示词缓存:把系统提示与上下文缓存到 provider。
模型分级是收益明显的一步。长视频的字幕动辄上万字,全部塞给强模型,单次成本高、延迟也高;拆成短片段后,多数段落用便宜模型就能给出不错的结果,只有关键段落才动用强模型。我对比过两种做法,一种固定用强模型,一种做分级,结果后者成本降了六成,P99 也跟着降。踩过的坑是提示词缓存:一开始没开,同样的系统提示每次请求都重新编码,占了请求耗时的大头,开了之后每次请求能省几十毫秒。
四、缓存
缓存是投入产出比很划算的一环,几乎每个团队都会做,但分层做对的少。我按请求路径把缓存分成四层:
| 层 | 缓存对象 | TTL | 失效策略 |
|---|---|---|---|
| 浏览器 | 静态资源 | 7 天 | 文件名 hash |
| CDN | HTML | 60s | 主动 purge |
| Redis | 会话、限流、配额 | 1h | 主动失效 |
| 数据库 | 总结结果 | 永久 | 源文变化触发 |
容易被忽略的是”数据库层的总结结果缓存”。同一段视频经常被反复总结,第一次生成后缓存住,后面直接命中,省下的既是推理费用也是用户等待时间。失效策略我用的是源文变化触发——字幕或视频更新时才重建缓存,避免缓存永远不更新、内容过期。踩过的坑是缓存穿透:一个不存在的视频 ID 被反复请求,每次都落到数据库,后来对空结果也做了短 TTL 缓存,问题才消失。
五、前端渲染
后端再快,前端渲染跟不上,体感还是慢。首屏 JS 体积和长列表渲染是两个大头。我做三件事:
- 关键 CSS 内联,非关键 CSS 异步;
- 长列表虚拟滚动;
- 思维导图懒加载,进入视口再渲染。
把首屏 JS 控制在 180KB 以内,LCP 稳定在 1.8s 以下。这里有个容易踩的坑:虚拟滚动一开始只做了横向,字幕和总结的纵向长列表没做,滚动 2000 条时明显掉帧,补上纵向虚拟滚动后帧率才稳定下来。思维导图懒加载也值得做——它渲染耗时不低,但很多用户根本不看,等滚到对应区域再渲染,首屏负担小得多。
六、数据库
我一开始用 SQLite 顶着,单机开发没问题,但生产环境并发一上来就频繁报锁。后来迁移到 Postgres,顺带做了一轮索引优化:
- 常用查询加索引:userid、jobid、created_at;
- 大字段(字幕、译文)单独存表,避免反复扫描;
- 定期 VACUUM,避免碎片。
加索引不是无脑加,而是要对着慢查询日志加。最初我在所有外键上都加了索引,写入反而变慢,后来砍掉不常用的,只保留查询路径上真正用到的,P99 才降下来。大字段单独存表这个改动性价比很高:字幕和译文动不动几 KB 到几十 KB,和主记录挤在同一行,普通查询也要拖着它们扫描,拆表之后主表查询快了不少。
七、可观测性
性能优化的闭环靠数据,没有观测,前面的优化全是盲改。我做了三件事:
- 关键接口埋点上报 latency、status、user_id;
- 慢查询日志,超过 100ms 的 SQL 入库;
- 业务告警:失败率 > 1%、首字延迟 > 1s 持续 3 分钟。
告警阈值要谨慎设置。刚开始我把首字延迟告警设成 0.5s,结果一天告警几十次,全是瞬时波动误报,团队直接麻木了。后来改成”持续 3 分钟才告警”,噪音降下来,真正的问题反而一次都没漏。慢查询日志入库也很关键,数据库优化的所有决策都来自这张表,没有它就只能靠猜。
八、优化顺序
最后总结优化顺序,这是我自己走了弯路换来的经验:
- 先做监控:没有数据时优化只是猜测;
- 再做缓存:投入产出比高;
- 然后做并发:能提升上限;
- 最后做模型分级:成本相关、影响营收。
顺序反过来的代价是真实的。我一开始先做并发,把下载数提到 300,结果瓶颈变成数据库和推理,前面有一半工作要推翻重做。先把监控立起来,后面每一步都有数据说话,遇到瓶颈能直接定位,不用反复试错。
常见问题(FAQ)
Q1:性能优化该从缓存开始还是从并发开始?
从监控开始。知道瓶颈在哪再下手,否则只是把数字从一个地方搬到另一个地方。
Q2:AI 推理延迟怎么压?
用流式输出、分段并发、模型分级三件套组合。
Q3:SQLite 能撑多大并发?
单机写并发到 50 左右就需要切换到 Postgres。