主题
高性能套路:异步、池化与水平扩展
本文是系统设计系统学习系列的 L2 核心篇。前置:29. building-blocks-lb-cache-queue-cdn。 学完可以配合面试题食用:02-flash-sale-system、25-realtime-data-pipeline-flink
响应时间公式:排队是最大敌人
任何系统的响应时间都可以拆成两部分:
RT = W (排队时间) + S (服务时间)W 是请求排队等资源的时长,S 是真正处理业务的时长。优化要么减 S,要么减 W。
减 S 靠的是更快的代码、更好的算法、更强的硬件。但大多数场景下,W 占比远大于 S。一个请求在 DB 连接池门口等 50ms,实际执行只花 5ms,优化 S 到 3ms 只能省 2ms,优化 W 到 10ms 却能省 40ms——这就是为什么高性能设计的第一优先级是减少排队。
排队产生的根本原因:资源有限(线程池、连接池、CPU 核数),请求突发。三条路:让请求不必等(异步),让资源复用更高效(池化),让资源变多(水平扩展)。
异步化三板斧:接口、线程池、队列
异步不是"把同步改成回调用 CompletableFuture",而是改变请求与响应的时间关系。
第一板斧:接口异步返回(202 + 轮询/推送)
同步接口要求调用方持续占用连接,直到结果返回。如果业务耗时 500ms(比如生成一份报表),这个连接就空转了 500ms。改成异步:提交任务后立刻返回 202 Accepted,附带一个 taskId。调用方通过 GET /task/{taskId} 轮询进度,或由服务端回调通知。
java
// 异步任务提交入口
@PostMapping("/api/report/generate")
public ResponseEntity<AsyncTaskResponse> submitReport(@RequestBody ReportRequest request) {
String taskId = UUID.randomUUID().toString();
// 任务状态机:PENDING -> PROCESSING -> SUCCESS/FAILED
taskStore.save(new AsyncTask(taskId, TaskStatus.PENDING, request));
// 扔给线程池异步执行
taskExecutor.submit(() -> {
taskStore.updateStatus(taskId, TaskStatus.PROCESSING);
try {
Report result = reportService.generate(request);
taskStore.complete(taskId, result);
} catch (Exception e) {
taskStore.fail(taskId, e.getMessage());
}
});
return ResponseEntity.accepted()
.body(new AsyncTaskResponse(taskId, "/api/task/" + taskId));
}
// 轮询接口
@GetMapping("/api/task/{taskId}")
public ResponseEntity<AsyncTask> queryTask(@PathVariable String taskId) {
AsyncTask task = taskStore.get(taskId);
if (task == null) {
return ResponseEntity.notFound().build();
}
if (task.getStatus() == TaskStatus.SUCCESS) {
return ResponseEntity.ok(task); // 前端拿到结果即可展示
}
return ResponseEntity.ok(task); // 前端继续轮询
}关键设计点:任务状态机必须收敛(PENDING / PROCESSING / SUCCESS / FAILED),不能出现死态。taskStore 用 Redis 存,带 TTL 自动清理,避免积压。
第二板斧:线程池隔离
一个线程池扛所有任务,一个慢任务就能拖死整个系统。按业务场景拆成独立线程池:CPU 密集型(线程数 = CPU 核数)、IO 密集型(线程数 = 核数 × 2)、异步回调(小容量,只做状态更新)。Tomcat 的请求线程池和业务线程池分离,Tomcat 只负责收请求,业务线程池跑具体逻辑。
第三板斧:MQ 削峰
流量峰值到 DB 层面就是不可控的。MQ 把高峰削成平缓的消费速率。细节在 MQ 系列 25 已讲透,此处只记住一句话:MQ 削峰的前提是业务能容忍异步延迟,不能容忍的走同步(比如扣库存)。
池化复用:连接池、线程池、对象池
池化的核心思想:复用创建成本高的资源,避免反复创建销毁。
连接池:TCP 三次握手 + TLS 握手 + 认证,一个连接至少 10ms 开销。池化后维持 10-20 个长连接,用完归还。常见陷阱:池大小配太大导致 DB 负载过高,建议 maxActive = Tomcat 线程数 × (1 + 等待因子)。Druid 的 keepAlive 和 testWhileIdle 要开,否则空闲连接会被 DB 断开。
线程池:ThreadPoolExecutor 的核心参数直接决定异步系统的吞吐量。
java
// 线程池参数:核心线程数、最大线程数、空闲存活时间、工作队列、拒绝策略
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, // corePoolSize:常驻线程,IO密集型推荐 2*CPU
20, // maxPoolSize:允许扩容上限
60L, TimeUnit.SECONDS, // 空闲线程存活时间
new ArrayBlockingQueue<>(1000), // 有界队列,防止 OOM
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:让调用线程自己跑
);参数陷阱:corePoolSize 配大后,线程池会先建到 core 才入队,所以 core 不要等于 max。队列不要用无界(LinkedBlockingQueue 默认无界),否则线程数永远不会超过 core,退化到队列不限。拒绝策略推荐 CallerRunsPolicy,比 AbortPolicy 少丢请求,但调用方自己扛压力,所以还能反压上游。
对象池(Apache Commons Pool2):连接工厂、池化对象、池配置三步。典型场景:Redis 连接池、FTP 连接池、gRPC Channel 池。跟连接池同理,对象池不适用于轻量对象(new 比池化更快)。
水平扩展:无状态改造是前提
加机器就能扛更多流量——前提是每台机器都是"无状态"的。有状态节点(session 存本地、缓存存本地、文件存本地)加机器不管用,因为新机器没有旧数据。
无状态改造三件套:
- Session 外置:Tomcat Session 存到 Redis,所有节点共享。Spring Session + Redis 实现,一行配置。
- 本地缓存上移:本地 Caffeine 缓存只存不可变配置数据,业务数据统一走 Redis 分布式缓存。
- 文件上对象存储:用户上传的文件存 OSS/MinIO,不落本地磁盘,NFS 也不要用(单点+性能瓶颈)。
粘性会话为什么不推荐:Nginx 配置 ip_hash 让同用户请求落到同一台机器,表面上解决了 session 问题。但机器宕机后该用户所有 session 丢失,扩缩容也会重新哈希,导致大量会话失效。外置 session 后,任何机器都能处理任何请求,这才是真水平扩展。
读写分离与一致性:主从部署后,写主库读从库,但主从延迟会导致读不到刚写入的数据。业务容忍方案:
- 写后读主:写入后 1 秒内的读强制走主库,用 ThreadLocal 标记"刚写完的用户"
- 会话粘性:同一个用户会话内读写都走主库(折中方案,但扩缩容也有问题)
- 版本号容忍:返回数据带版本号,前端发现版本不对就自动重试(适用于读多写少场景)
java
// 读写分离路由:Annotation + AbstractRoutingDataSource
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataSource {
String value() default "master"; // master / slave
}
// AOP 拦截,切换数据源
@Around("@annotation(dataSource)")
public Object route(ProceedingJoinPoint pjp, DataSource dataSource) throws Throwable {
try {
DataSourceContextHolder.set(dataSource.value());
return pjp.proceed();
} finally {
DataSourceContextHolder.clear();
}
}
// 使用:读方法标记走从库
@DataSource("slave")
public List<Order> queryOrders(Long userId) {
return orderMapper.selectByUserId(userId);
}常见误区与小结
- 异步不等于快:异步只释放了调用线程,总耗时没变,吞吐量提高了但单次延迟可能更高。判断标准:业务能否容忍延迟在几秒级。
- 线程池配大就是好:线程数超过 CPU 核数时的上下文切换开销会吃掉收益。IO 密集型可以超配,但也要有个上限(2×CPU 核数左右)。
- 无状态改造后什么都放 Redis:Redis 本身也是状态,全量依赖 Redis 会把瓶颈从应用层移到缓存层。合理分层的原则:能本地缓存的不走 Redis,能走 Redis 的不走 DB。
- 读写分离解决所有读问题:主从延迟在 100ms 以内,但批量导入或大查询可能把延迟拉到秒级。读写分离前先评估业务对一致性的要求。
- 水平扩展是银弹:无状态层可以水平扩展,但存储层不行。数据库连接数、磁盘 IOPS、网络带宽都有上限。水平扩展的上限取决于"最不能水平扩展的那个组件"。
小结:高性能套路的核心是"找准瓶颈,对症下药"。异步消排队,池化减开销,水平扩展加资源。没有万能模板,每个系统瓶颈不同,但分析框架一样:先算 RT 分拆,再找占 W 最大的环节,最后选对应的套路。
下一篇:32. high-availability-patterns 高可用套路:冗余、故障转移与降级,会讲怎么让系统在各种故障下还能提供服务。
参考
参考:Google SRE Book 第 4 章(SLO 与 SLI 的关系)、Martin Kleppmann《Designing Data-Intensive Applications》第 10 章(批处理与流处理)