TTFB 减少优化:从 800ms 到 80ms 的实战拆解
首字节时间是衡量服务端响应能力最真实的指标,本文复盘一次电商详情页 TTFB 从 800ms 降到 80ms 的完整过程,回避一些「玄学调优」,给你一条可复制的链路优化清单。
为什么把 TTFB 当成核心指标
Web 性能领域有四大常用时间:TTFB、FCP、LCP、TTI。其中 TTFB(Time To First Byte)是服务端时延的核心反映。当用户点击一个链接,浏览器把请求送出去再拿回第一字节的时间,全靠 TTFB 决定后续的渲染时机。
如果 TTFB 是 800ms,再快的客户端优化也只能在用户感知层面抢到几十毫秒;但如果 TTFB 降到 80ms,FCP、LCP 几乎可以同比例下降。更关键的是,TTFB 是一个服务端单边可控的指标——不像 LCP 还要依赖用户网络与设备。这意味着只要团队下决心,总能拿到稳定的优化收益。
TTFB 不是天然的「快」或「慢」,它是链路各环节累加出的账面数字。
把 800ms 拆解成四个抽屉
用 Server-Timing 头与分布式追踪(trace),把单次请求切成四个时段:
- 网络接入:从用户 DNS 解析到 TLS 握手完成,通常 50–150ms。
- 网关转发:网关解析、Lua 路由、风控判定,通常 10–50ms。
- 应用处理:PHP / Java 业务逻辑 + 若干次 RPC + 缓存读取,通常 100–600ms。
- 数据库与外部依赖:MySQL、ES、HTTPS 第三方接口,通常 100–500ms。
我们的电商详情页案例中,四段分别是:120ms + 35ms + 380ms + 265ms,总和 800ms。其中「应用处理」与「外部依赖」贡献了 645ms,是真正值得开刀的地方。下面只列出这部分的优化项。
协议与网关层:能省 30ms 不止
TLS 1.3 比 1.2 在握手往返上少一次 RTT;HTTP/2 多路复用解决队头阻塞;最新的 HTTP/3(QUIC)更进一步把传输层从内核挪到用户态。我们当时在网关启用 TLS 1.3 + HTTP/2 + 0-RTT,对平均 TTFB 的提升是稳定的 25–35ms。
另一个常被忽略的细节是 Keep-Alive。短连接下每次请求都要重新建立 TCP,长尾请求很容易抖动。我们用 Nginx 的 keepalive_requests 1000 与上游连接的 keepalive 池,把 P99 抖动削掉 30% 以上。
upstream detail {
server 10.10.1.21:8080 max_fails=2 fail_timeout=10s;
server 10.10.1.22:8080 max_fails=2 fail_timeout=10s;
keepalive 64; # 关键:上游连接池
keepalive_requests 1000;
keepalive_timeout 60s;
}
server {
listen 443 ssl http2;
ssl_protocols TLSv1.3;
add_header Strict-Transport-Security "max-age=63072000" always;
}
应用层:把「链式调用」拉成「分层瀑布」
这是收益最大的一段。我们的详情页之前是一个看起来干净的同步调用链:参数校验 → 商品中心 → 库存 → 价格 → 推荐位 → 评价摘要 → 拼装返回。每一步同步 RPC,整链 380ms。
改造思路是把可以并行的部分拆出来,让调用关系变 fan-out → fan-in:
- 价格、库存、评价三个 RPC 没有数据依赖,并行调用;
- 推荐位依赖商品类目,将其调度到第二步;
- 拼装阶段只保留轻量内存计算。
改造后,应用处理耗时降到 95ms。这里有个经验数字:同步链路平均「RPC 跳数 × 30ms」接近应用层等待时间,所以减少跳数与把串行改并行的 ROI 极高。
缓存:唯一要把「读时延」做成「读吞吐」的模块
最开始的版本里,详情页每次都会调用一次商品中心 RPC 来拿 SKU 主数据。即使主数据命中 Redis,也还要走 4ms 网络。看起来不多,但当 QPS 高到 2 万时,Redis 连接池一样会抖动。
最终我们做了「本地缓存 + 集中缓存 + 反向缓存」三层结构:
| 层级 | 介质 | 命中延迟 | 更新方式 |
|---|---|---|---|
| L1:进程内 | Caffeine / sync.Map | < 0.1ms | MQ 主动失效 |
| L2:机房内 | Redis | 1–3ms | TTL + MQ 双删 |
| L3:穿透 | Nginx proxy_cache | < 1ms | Purge API |
这三级把 90% 的读请求拦截在数据库之外,剩余 10% 回源查询一次,时间约 5ms,平均读延迟从未命中 80ms 变成命中 1ms。这里关键不是「要缓存」,而是「更新一致性怎么做」。
一致性问题的本质是「缓存旧值的窗口期有多长」。把这个窗口期压缩到 1 秒之内,业务就基本无感。
数据库:从「每次都查」到「带缓存的查询」
主数据之外,详情页还常常伴随一次「店铺基本信息」查询。我们的数据原本走得是 MySQL 全表扫描 + 排序,单次 60ms。改造后做两件事:
- 把常用字段提取为「店铺基础卡」表,按 ID 唯一索引读取,下推到 5ms。
- 对配置类字段加 Redisson 分布式缓存,命中率 95% 以上。
到这里,对数据库的请求已经很少,但只要还有,就值得做 SQL 级别的 EXPLAIN 复查。我们发现有一处索引并不在工作状态,因为 %LIKE% 前缀模糊让索引失效。改为全文索引或反查结构,把 30ms 又挤了出来。
把这条链路「固化」到工程模板
把上面的步骤汇集到一份「TTFB 自检清单」,对团队所有读链路做体检:
- 是否启用 HTTP/2、TLS 1.3、0-RTT?
- 是否启用 Keep-Alive 上游连接池?
- 是否做了 trace 标注每一段耗时?
- 同步调用是否拆分为 fan-out?
- 是否建立了「本地+集中+边缘」三级缓存?
- 数据库链路是否有索引失效或回表问题?
我们用这份清单跑完 132 条链路,平均 TTFB 从 380ms 降到 90ms 左右,慢的 P99 也稳定在 240ms 以内。优化不是一次工程,而是一份可以反复跑的清单。
几个非技术但很关键的事
最后两件非技术的事,比任何技巧都更重要:
- 把指标分到每个工程师的看板。当 TTFB 的卡是每位开发者每次提交都能看到时,优化就变成了一种肌肉记忆。
- 建立一个「一升一降」的可视化。优化越多,新代码带来的退化也越多,要持续保证主导地位。
TTFB 的优化,本质上是「减少无用的等待」。而我们对这个世界最大的尊重,也是如此。