性能问题的账,最后都要算到吞吐和成本上。仓库扫描、文件拉取、模型调用全串在一起,翻译一份 README 平均要 8 分钟,单任务成本 0.2 美元,API 错误率还时不时冲到 1.5%。一开始我想的是换更大的模型、加机器,后来才发现瓶颈根本不在模型本身,而在任务编排和缓存策略。于是我把优化拆成 API、模型、数据库、前端、缓存、可观测六层,逐层定位、逐层改进,任务平均耗时从 8 分钟压到 3 分钟以内。下面把每层的做法、对比和踩过的坑写清楚。
一、优化指标
没有量化目标之前,改动全凭感觉:今天调数据库,明天改前端,谁也说不清哪个改动真正起了作用。我先把四个核心指标定下来,挂在显眼位置,后面每一层改动都对着它验收。指标选多了团队盯不过来,选少了又覆盖不住,最终只留耗时、成本、错误率、首屏四样。
| 指标 | 当前 | 目标 |
|---|---|---|
| 任务平均耗时 | 8 分钟 | < 3 分钟 |
| 翻译成本/任务 | $0.20 | < $0.10 |
| API 错误率 | 1.5% | < 0.5% |
| LCP | 2.6s | < 1.5s |
这套指标不是给团队看数字用的,而是给每处改动定一个回退标准:某项优化如果让成本升回去或者错误率上去了,立刻回滚重做,不用争论。
二、API 层
最初的代码是串行拉取:先扫仓库列表,再逐个文件拉取,最后逐个调翻译。仓库一多,单仓库耗时直接等于所有文件耗时的总和,这也是任务平均 8 分钟的主要来源。我对比过两种改法:给每个仓库起独立线程池,或者按文件分片并行。线程池方案连接数容易失控,最终选了分片并行,片内并发、片间用限流器兜底。
- 串行改并行:仓库扫描、文件拉取用
Promise.all分片; - 限流器:用 Installation 维度的令牌桶,避免 GitHub 429;
- 重试退避:指数 + 抖动,仅对 5xx/429;
- GraphQL 一次拿多文件元数据,减少请求数。
第一点落地就是这段代码,分片加限流器一起解决”并发提速但不被限速”:
const chunks = chunk(fileList, 10);
const results = await Promise.all(
chunks.map(async (group) => {
await limiter.wait(1); // 每片拿一个令牌
return Promise.all(group.map(fetchFile));
})
);
这样改完,文件拉取阶段从十几分钟降到两分钟以内。注意限流器要按 Installation 维度做键,别按仓库,否则同一个组织下多个仓库同时跑还是会撞 429。重试只对 5xx 和 429 生效,4xx 重试只会放大问题。
三、模型层
模型层的优化直接砍成本。当初我把所有段落都送进主模型,README 摘要、普通段落、代码注释一视同仁,单任务成本居高不下。我对比过按字符数分流和按段落类型分流两种思路,最终选了后者——字符数阈值对中文不友好,一句话可能 200 字,一段代码也可能只有 50 字,按字符数切不准。分级之后,普通段落走快模型,README 和 API 概念这类需要理解上下文的才走主模型。
- 模型分级:普通段落快模型,README、API 概念用主模型;
- 提示词缓存:把系统提示与术语表缓存到 provider;
- 流式输出:前端边收边渲染;
- 分段并发:5 段一组送模型,合并后渲染。
效果:单任务成本下降 45%。
这个数字是分级上线一周后对账出来的。流式输出是另一个加分项,前端边收边渲染,用户的等待体感从盯着转圈变成看着字一行行出来。分段并发要注意顺序问题:合并时按原始片段序号排序,不能依赖接口返回顺序。
四、数据库层
数据库层常见的坑是慢查询。任务详情页要按 jobId 查所有片段,一开始没加索引,数据量过了几万条之后页面明显变慢。我对比过加索引、加只读副本、上 Redis 缓存三套方案,最终选了索引加连接池调整——副本和缓存解决的是读压力,当时的瓶颈是慢查询本身,索引收益明显,成本也可控。
- 常用查询加索引:
TranslationSegment(jobId, status)、User(githubId); - 大字段单独存:原文与译文分离;
- 事务短:避免长事务持锁;
- Prisma connection pool 调到与 Postgres 最大连接匹配。
索引不是越多越好,写多读少的表加多了反而拖慢写入。大字段分离这个改动回报很直接:查询只扫片段元数据,不把整段译文拖出来,磁盘 IO 和网络开销都降了。
五、前端层
前端第一个暴露的问题是首屏加载。翻译工具是重列表、轻交互的页面,原本所有组件都走客户端渲染,首屏 JS 包很大。我对比过纯 CSR、SSR、服务端组件三种写法,最终选了 App Router 的服务端组件:列表页几乎不下载业务 JS,客户端组件按需水合。
- App Router 默认服务端组件,列表页不下载 JS;
- 客户端组件按需水合;
- 图片走
next/image; - 字体 self-host;
- 关键 CSS 内联,剩余 CSS 异步加载;
- TanStack Query 缓存任务状态,避免重复请求。
图片交给 next/image 自动处理尺寸和懒加载,字体改成 self-host 后少了一次跨域请求,关键 CSS 内联、其余 CSS 异步加载。TanStack Query 缓存任务状态后,轮询接口不会因为组件重挂载而重复请求,LCP 从 2.6s 压到了 1.5s 以内。
六、缓存层
缓存是投入产出比很高的一层,但也容易做坏。我们按浏览器、CDN、Redis、数据库四个位置分别决定缓存什么、TTL 定多长。定 TTL 时踩过坑:最初翻译结果永久缓存,术语表一更新,老结果就出错。后来给翻译结果缓存加版本号,术语变更时主动失效。
| 层 | 缓存 | TTL |
|---|---|---|
| 浏览器 | 静态资源 | 7 天 |
| CDN | 静态资源 | 30 天 |
| Redis | Installation Token、术语表 | 50 分钟 / 24 小时 |
| 数据库 | 翻译结果 | 永久 |
这一层做完,重复任务的耗时从分钟级掉到秒级,成本几乎为零。浏览器和 CDN 的静态资源 TTL 可以拉长,因为文件名带 hash,变更后自然回源;Redis 里的 Installation Token 放短一点,术语表放长一点,前者失效代价是重新授权,后者只是多一次读取。
七、可观测层
没有埋点之前,出问题只能靠用户截图和日志大海捞针。我们把 trace_id 贯穿整个调用链,翻译任务的每个环节耗时、每段模型的 token 数都记录下来。慢调用和失败率告警按端点加用户维度区分,避免告警互相淹没;成本告警盯单日同比,超过 30% 就提醒——成本涨得太快,多半是死循环重试导致的。
- 关键路径埋点:trace_id 贯穿;
- 慢调用监控:超过阈值上报;
- 失败率告警:按端点 + 用户;
- 成本告警:单日成本同比 +30%。
八、增量与并发
并发策略决定系统上限。最初一个仓库一个任务,同一仓库短时间内多次 push 会重复翻译,白白烧钱。我们加了合并窗口:短时间内的多次 push 合并成一次任务。跨仓库并行默认 4 个,任务内片段并发默认 5 个,模型档位高的自动降低并发,避免把 provider 的配额打满。队列用 Redis Stream,消费成功 ACK 后才删消息,崩溃后还能重新消费,不会丢任务。
- 同一仓库短时间多次 push:合并到一次任务;
- 跨仓库并行:默认 4 个仓库并发;
- 任务内片段:默认 5 并发,模型档位高的并发降低;
- 队列用 Redis Stream,ACK 后再删。
九、内存与 CPU
超大仓库翻译时内存会涨。Node 默认堆只有 2GB,一次翻译上万文件的仓库,worker 进程会被 OOM 干掉。我对比过换 worker_threads 分担和调大堆内存两种方式,先用 --max-old-space-size=4096 顶住,同时把 Prisma client 做成全局单例,避免每个请求都新建连接。worker 进程数按 CPU 核数匹配,多了反而在上下文切换上浪费。
- Node 默认堆 2GB,超大任务调
--max-old-space-size=4096; - Prisma client 全局单例,避免重复连接;
- Worker 进程数与 CPU 核数匹配。
十、安全与性能的平衡
性能优化不能拿安全换。私钥解密是我纠结比较多的地方:缓存它性能好,但内存里多留一份私钥,就多一个泄露面,最终决定只在调用时解密、用完即弃。审计日志同步写会阻塞主流程,改成异步落盘。Webhook 校验一开始放在 Edge runtime,后来发现行为不稳定,挪回 Node runtime——校验逻辑一样,运行环境可控得多。
- 私钥解密只在调用时进行,结果不缓存;
- 审计日志异步落盘,避免阻塞主流程;
- Webhook 校验在 Node runtime 中进行,避免 Edge runtime 性能差异。
十一、回归测试
优化的一大风险是回归。没有压测基线,任何一次改动的效果都说不清楚。我们用 k6 跑固定 QPS 看 P99,用 e2e 跑完整翻译任务对比耗时,每次 PR 检查 bundle size 和 Lighthouse 分数。这套流程不是可选项,它是守住前面所有优化成果的底线。
- k6 跑固定 QPS,验证 P99;
- e2e 跑完整翻译任务,对比耗时基线;
- 每次 PR 检查 bundle size 与 Lighthouse。
十二、优化顺序
顺序错了,努力就白费。我们的顺序是监控先行、缓存次之、并行与限流、模型分级、前端体验、回归测试。理由很直接:没有数据时优化是猜测;缓存投入产出比高;并行与限流提升系统上限;模型分级砍成本;前端体验直接影响用户观感;最后用回归测试守住成果。
- 监控先行:没数据时优化是猜测;
- 缓存次之:投入产出比高;
- 并行与限流:提升系统上限;
- 模型分级:影响成本;
- 前端体验:用户感知最强;
- 回归测试:守住成果。
十三、给同类项目的建议
这几条建议是从返工里学来的。目标可量化,团队才知道改的是什么;监控先行,避免拍脑袋优化;优化是连续过程,突击式优化容易引入回归;改动前做基线、改动后做对比,才能量化收益。别的项目照搬不一定合适,但顺序大概率是通用的。
- 目标可量化,避免主观;
- 监控先行;
- 优化是连续过程,不要”突击”;
- 改动前做基线,改动后做对比。
常见问题(FAQ)
Q1:先优化 API 还是先优化模型?
看瓶颈在哪。用监控数据决定,不是猜。
Q2:缓存越多越好吗?
不是,缓存会带来一致性问题,按需配置 TTL。
Q3:性能优化要不要压测?
要。没有压测就没有 P99 数字。