客户端优化:从连接池到缓存,把每一毫秒都抠出来
客户端优化,说白了就是让调用方别拖后腿。我见过太多团队,服务端优化到极致了,客户端却还在用最原始的方式发请求——每次新建连接、不设超时、失败就傻等。今天咱们就把客户端这五个关键点掰开揉碎。
HTTP连接池管理
每次请求都新建TCP连接?那性能基本就废了。三次握手加TLS握手,光建立连接就得几十毫秒。我优化过一个报表服务,光是把连接池从0调到合理大小,P99延迟就从800ms降到了200ms。
连接池的核心参数就三个:
| 参数 | 含义 | 我常用的推荐值 |
|---|---|---|
| maxTotal | 最大连接数 | CPU核心数 × 2 ~ 4 |
| maxIdle | 最大空闲连接数 | maxTotal的一半 |
| minIdle | 最小空闲连接数 | 2 ~ 4 |
嗯,这里要注意:连接池不是越大越好。我踩过一个坑,把maxTotal设成了200,结果服务端扛不住,直接打挂了。连接池大小要跟服务端的处理能力匹配。
// Java HttpClient 连接池配置示例
PoolingHttpClientConnectionManager connManager =
new PoolingHttpClientConnectionManager();
connManager.setMaxTotal(50);
connManager.setDefaultMaxPerRoute(20);
connManager.setValidateAfterInactivity(2000); // 2秒后验证连接是否可用
validateAfterInactivity,避免拿到已关闭的"死连接"。我习惯设成2000ms,既不过度验证,又能及时清理。
Keep-Alive长连接
连接池管的是"有多少连接",Keep-Alive管的是"连接活多久"。两者配合,才能让连接真正复用起来。
HTTP/1.1默认就是长连接,但很多框架默认超时设得太短。我见过一个项目,Keep-Alive超时设了5秒,结果连接频繁重建,连接池形同虚设。
配置Keep-Alive要注意两端对齐:
- 客户端:设置
Connection: keep-alive,并指定Keep-Alive: timeout=60 - 服务端:Tomcat的
keepAliveTimeout、Nginx的keepalive_timeout要大于客户端 - 中间件:负载均衡器的空闲超时也要同步调整
我曾经排查过一个诡异问题:客户端明明配了长连接,但每隔几分钟就会断一下。最后发现是Nginx的keepalive_timeout设了30秒,而客户端设了60秒。服务端先断了,客户端还在复用旧连接,自然报错。
请求合并与批量处理
单个请求的开销是固定的——DNS解析、连接建立、请求头传输。把多个小请求合并成一个批量请求,能大幅降低这些固定开销。
我常用的合并策略有三种:
- 时间窗口合并:攒够一定时间内的请求,批量发送。适合延迟不敏感的场景。
- 数量阈值合并:攒够N个请求再发。适合请求量稳定的场景。
- 混合策略:时间和数量谁先到就触发。最常用,也最灵活。
// 简单的请求合并器(伪代码)
class RequestBatcher {
private final int batchSize = 100;
private final int maxWaitMs = 50;
private final List<Request> buffer = new ArrayList<>();
public void submit(Request req) {
synchronized(buffer) {
buffer.add(req);
if (buffer.size() >= batchSize) {
flush();
}
}
}
// 定时任务每50ms检查一次
public void scheduledFlush() {
synchronized(buffer) {
if (!buffer.isEmpty()) {
flush();
}
}
}
}
但要注意:合并不是万能的。我做过一个实时推荐系统,合并后吞吐上去了,但P99延迟从10ms飙到了200ms。业务方直接炸了。合并只适合对延迟不敏感、但对吞吐有要求的场景。
超时与重试策略
没有超时的客户端,就像没有刹车的车。我见过一个服务因为客户端没设超时,线程池被打满,最终雪崩。超时设多少?这是个艺术。
| 超时类型 | 含义 | 推荐值 |
|---|---|---|
| ConnectionTimeout | 建立连接的超时 | 500ms ~ 2s |
| SocketTimeout | 等待数据的超时 | 服务端P99 × 2 ~ 3 |
| ConnectionRequestTimeout | 从连接池获取连接的超时 | 100ms ~ 500ms |
重试策略更讲究。我见过最蠢的重试——失败就立即重试,连续重试5次。服务端本来就扛不住了,客户端还疯狂补刀。正确的做法是:
- 指数退避:第一次等100ms,第二次200ms,第三次400ms
- 加抖动:在退避时间上随机加减20%~50%,避免惊群效应
- 限制次数:最多重试2~3次,再多就是浪费
- 区分错误:只有5xx和网络异常才重试,4xx别重试
// 带指数退避和抖动的重试策略
int retryCount = 0;
int maxRetries = 3;
long baseDelay = 100; // 100ms
while (retryCount < maxRetries) {
try {
return executeRequest();
} catch (RetryableException e) {
retryCount++;
long delay = (long) (baseDelay * Math.pow(2, retryCount)
* (1 + Math.random() * 0.5)); // 加50%抖动
Thread.sleep(delay);
}
}
客户端缓存
最好的请求,就是不用发出去的请求。客户端缓存能直接干掉大量重复请求。我优化过一个商品详情页,加了本地缓存后,QPS从5000降到了500,服务端压力骤减。
客户端缓存分两种:
- 本地缓存:用Caffeine、Guava Cache等,存在应用内存里。适合变化不频繁的数据。
- HTTP缓存:利用ETag、Last-Modified等头,让服务端告诉客户端"数据没变,别重新传了"。
缓存的核心是过期策略。我常用的几种:
| 策略 | 适用场景 | 注意事项 |
|---|---|---|
| TTL(固定过期) | 数据变化频率稳定 | 过期瞬间可能有大量请求穿透 |
| TTI(空闲过期) | 热点数据集中 | 冷数据自动淘汰 |
| 软引用/弱引用 | 内存紧张时优先淘汰 | GC频繁时可能失效 |
// 使用Caffeine构建本地缓存
Cache<String, Product> productCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats()
.build();
Product getProduct(String id) {
return productCache.get(id, this::fetchFromRemote);
}
recordStats(),上线后监控命中率。命中率低于80%说明缓存策略有问题,要么过期时间太短,要么缓存容量太小。
嗯,客户端优化这五个点,说白了就是"少建连接、多用长连、合并请求、设好超时、能缓则缓"。每个点单独拿出来都能写一篇文章,但真正的高手是把它们组合起来用。我见过一个日活千万的系统,客户端把这五点做到位后,服务端资源消耗直接降了60%。
记住,客户端优化不是一锤子买卖。上线后要持续监控连接池使用率、缓存命中率、超时重试次数。数据会告诉你哪里还有优化空间。我每次做优化,都会先拉一波监控数据,找到瓶颈再动手,而不是拍脑袋瞎改。