用户点一次提交按钮,页面转圈七八秒才有响应;数据量翻倍,接口耗时跟着翻倍;高峰期线程全部阻塞在数据库查询上,新请求排队甚至被直接拒绝。这类贯穿整个业务流程的卡顿,表面上是性能问题,本质上是代码执行结构出了问题:所有操作都挤在一条同步链路里串行执行,任何一步慢,整条链路就跟着慢。要解开这个结,方向只有两条:一是异步处理,把不必立刻完成的操作从主流程剥离出去;二是多线程,把彼此独立的任务并行跑起来。本文结合具体场景和可运行的代码,把这两条路径讲透,并指出落地时最容易踩的坑。

卡顿从哪里来:三类典型的同步阻塞场景
第一类是串行调用累积延迟。一个订单详情接口,先查用户信息花100毫秒,再查订单列表花200毫秒,再查积分花300毫秒,最后写一条操作日志花100毫秒,加起来700毫秒。这四个操作彼此之间没有依赖关系,却因为串行执行被迫排队,总耗时是四段之和而不是四段的最大值。业务链路越长,这个累积效应越明显,一个跨五个系统的流程,每个系统只慢200毫秒,用户感知到的就是一秒以上的等待。
第二类是阻塞IO长期占用线程。数据库查询、调用第三方HTTP接口、读写文件,这些操作执行期间线程几乎什么都不做,纯粹在等待对端响应。Web容器的线程池是有限的,比如Tomcat默认200个线程,如果每个请求都要同步等一个耗时2秒的外部接口,那么只要100个并发请求同时进来,线程池就会被耗尽,后续请求全部排队,整个服务看起来就像卡死了一样。这种卡顿的特点是雪崩式恶化:平时好好的,流量一上来就全面瘫痪。
第三类是单线程处理能力触顶。批量任务场景最典型,比如对账系统要逐条处理十万条数据,单线程一条条跑,每条50毫秒就要将近一个半小时。CPU有八个核,却只有一个核在干活,其余七个核全程空闲。这不是算法慢,是执行结构没有利用硬件的并行能力。
异步处理:把非核心操作从主流程剥离
异步处理的核心思想是给操作分级:用户必须立刻拿到结果的操作,留在主流程同步执行;可以稍后完成的操作,扔到后台去跑。以下单为例,扣库存、生成订单记录这两步决定了下单是否成功,必须同步完成;而发短信通知、发放优惠券、记录操作日志、上报埋点数据,这些即使晚几秒完成,用户也完全无感,它们就是异步化的最佳对象。主流程砍掉这些操作后,响应时间往往能直接砍掉一半以上。
落地方式按可靠程度分两档。轻量的一档是进程内异步,直接把任务丢给线程池执行,实现简单,但进程重启时未执行的任务会丢失;重量的一档是引入消息队列,主流程只负责发一条消息,由独立的消费者进程处理后续逻辑,即使消费端宕机,消息还在队列里,重启后继续消费,可靠性高一个量级。对通知、积分这类允许短暂延迟但不允许丢失的业务,消息队列是更稳妥的选择。
先看进程内异步的写法,借助Spring的线程池就能实现:
@Service
public class OrderService {
@Autowired
@Qualifier("orderExecutor")
private ThreadPoolTaskExecutor orderExecutor;
public OrderResult createOrder(OrderRequest request) {
// 核心链路:同步执行,用户必须立刻拿到结果
Order order = orderRepository.save(buildOrder(request));
stockService.deduct(request.getSkuId(), request.getQuantity());
// 非核心链路:异步执行,不阻塞主流程
orderExecutor.execute(() -> {
notificationClient.sendSms(request.getMobile(), "下单成功");
couponService.grantCoupon(request.getUserId());
orderLogRepository.insert(buildLog(order));
});
return OrderResult.success(order.getId());
}
}
再看消息队列的写法,主流程只发一条事件消息,耗时从原来的800毫秒降到150毫秒左右:
// 主流程:只做核心动作
@Transactional
public String submitOrder(OrderRequest req) {
Order order = orderRepository.save(buildOrder(req));
stockMapper.deduct(req.getSkuId(), req.getQuantity());
// 发消息,而不是直接发短信、发券
messageSender.send("order-created-topic", new OrderCreatedEvent(order.getId()));
return order.getId();
}
// 消费端:独立的消费者进程处理非核心逻辑
@KafkaListener(topics = "order-created-topic")
public void onOrderCreated(OrderCreatedEvent event) {
Order order = orderRepository.findById(event.getOrderId());
smsClient.send(order.getMobile(), "您的订单已创建");
couponService.grantCoupon(order.getUserId());
analyticsClient.report(event);
}
异步化的代价是链路变长了,排查问题不再能靠一次单步跟踪走完全程,需要依赖链路ID把分散在各处的日志串起来。另外,异步操作天然存在延迟和失败重试的问题,设计时要想清楚哪些业务允许最终一致,哪些必须强一致,不能为了快把不该异步的东西也异步掉。
多线程:让独立任务并行执行
异步解决的是要不要现在做的问题,多线程解决的是能不能同时做的问题。前面提到的订单详情接口,四个查询彼此独立,完全可以同时发出请求,总耗时从四段之和变成四段的最大值,700毫秒压到300毫秒。批量处理同理,十万条数据拆成八份分给八个线程,处理时间直接除以八。
多线程的第一条原则是不要手动创建线程。new Thread()的方式没有复用、没有上限控制,高并发下会创建出成千上万个线程,内存和上下文切换的开销足以把服务拖垮。正确的做法是使用线程池,所有任务提交给池子,由池子统一管理线程的创建、复用和回收。一个经过认真配置的线程池如下:
@Configuration
public class ThreadPoolConfig {
@Bean("orderExecutor")
public ThreadPoolTaskExecutor orderExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10); // 常驻线程数
executor.setMaxPoolSize(20); // 峰值线程数
executor.setQueueCapacity(500); // 等待队列容量
executor.setKeepAliveSeconds(60); // 空闲线程回收时间
executor.setThreadNamePrefix("order-");
// 拒绝策略:队列满且线程达到上限时,由提交任务的线程自己执行
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
有了线程池,编排并行任务最顺手的工具是CompletableFuture。它支持把多个异步任务组合起来,等待全部完成后再汇总结果,写法接近自然语言:
public UserDetail queryUserDetail(Long userId) {
// 三个查询彼此独立,并行执行
CompletableFuture<UserInfo> userFuture = CompletableFuture
.supplyAsync(() -> userClient.queryUser(userId), orderExecutor);
CompletableFuture<List<Order>> orderFuture = CompletableFuture
.supplyAsync(() -> orderClient.queryOrders(userId), orderExecutor);
CompletableFuture<Integer> pointFuture = CompletableFuture
.supplyAsync(() -> pointClient.queryPoints(userId), orderExecutor);
// 等待全部完成,总耗时约等于最慢的那一个查询
CompletableFuture.allOf(userFuture, orderFuture, pointFuture).join();
return new UserDetail(
userFuture.join(), orderFuture.join(), pointFuture.join());
}
这段代码里有一个细节值得注意:supplyAsync的第二个参数传入了自定义线程池。不传的话,任务会跑到全局公用的ForkJoinPool.commonPool()里,所有业务共享一个小容量的池子,一旦某个任务执行慢,会连带拖垮其他业务的异步任务。为不同业务划分独立的线程池,是隔离故障的基本手段。
两条路径如何配合:一个接口的完整改造
真实的优化往往是异步和多线程的组合拳。改造思路可以总结成一句话:核心链路并行化,非核心链路异步化。还是拿订单详情接口举例,用户信息、订单列表、积分属于核心数据,用户打开页面就要看到,这三步用CompletableFuture并行查询;推荐商品、浏览历史、运营位配置属于增强数据,晚一点加载完全不影响使用,这三步异步预取,先返回核心结果,增强数据走第二个请求或前端懒加载补齐。改造后接口的首屏耗时从700毫秒降到300毫秒以内,用户体感的流畅度提升非常明显。
批量任务场景则侧重多线程加任务拆分。十万条数据的对账,先按主键区间切成一百个分片,每个分片一千条,提交给线程池并行处理,八个核心的机器上理论耗时从八十分钟压到十分钟。如果单条处理本身还包含网络调用,可以在分片内部再用CompletableFuture做二级并行,把IO等待时间重叠起来。拆分粒度需要实测调整,分片太细会带来过多的任务调度开销,太粗则并行度不够,一般以单个任务执行几百毫秒到一秒为宜。
落地时最容易踩的四个坑
第一个坑是线程池参数拍脑袋定。核心线程数不是越大越好:CPU密集型任务线程数超过CPU核心数只会增加切换开销,设成核心数加一即可;IO密集型任务线程大部分时间在等待,可以适当放大,经验公式是核心数乘以二,再根据压测结果微调。更重要的参数其实是队列容量和拒绝策略,队列太长会导致任务积压、响应延迟不可控,拒绝策略选CallerRunsPolicy能让提交线程自己执行任务,起到天然的限流作用,但会拖慢提交方,需要结合业务权衡。
第二个坑是事务边界被异步打断。Spring的@Transactional基于线程绑定的事务上下文,如果在一个事务方法里把部分操作提交到另一个线程执行,那些操作既不在当前事务里,也拿不到事务上下文,主事务回滚了,异步操作照样执行,数据就不一致了。典型的错误是把@Async方法用在事务方法内部。正确做法是让事务方法只包含必须原子完成的同步操作,事务提交之后再触发异步任务,Spring的事件监听机制里可以通过@TransactionalEventListener的提交后阶段来实现这一点。
第三个坑是异步任务里的异常被无声吞掉。线程池里执行的任务抛出异常,调用方完全感知不到,日志里可能只有一行不起眼的堆栈,业务上表现为短信偶尔没发出去这类难以定位的偶发问题。解决办法是给每个异步任务包一层统一的异常处理,记录完整上下文并接入告警;使用CompletableFuture时则通过exceptionally或whenComplete兜底:
CompletableFuture
.supplyAsync(() -> riskyTask(), orderExecutor)
.exceptionally(ex -> {
log.warn("异步任务执行失败,走降级逻辑", ex);
alertClient.notify("async-task-failed", ex.getMessage());
return defaultValue;
});
第四个坑是上下文信息在异步线程里丢失。登录用户、链路ID、租户标识这些信息通常存在ThreadLocal里,线程一换就取不到了,异步任务里拿到的用户身份是空的,链路日志也断在了提交处。处理办法是在任务提交时把需要的上下文显式作为参数传入,或者使用支持跨线程传递的增强方案,比如TransmittableThreadLocal,配合线程池使用时它比InheritableThreadLocal可靠得多。
总结一下,全流程卡顿的治理有清晰的章法:先用链路追踪把耗时分布摸清楚,找出串行累积、阻塞IO、单线程瓶颈的具体位置;再按操作的核心程度分级,核心操作并行化,非核心操作异步化;最后守住线程池配置、事务边界、异常处理、上下文传递这四道防线。多数系统的响应时间经过这一轮改造,都能从秒级进入几百毫秒的区间,用户体验和系统吞吐量同步上一个台阶。