ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring @Async异步编程实战:线程池、事务与异常处理全解析

Spring @Async异步编程实战:线程池、事务与异常处理全解析 去年给一个电商团队做性能治理他们在排查一笔下单接口偶发超时的时候发现耗时大户竟然是同步调用的短信发送和邮件通知。后来把这两个动作改成Async接口响应直接从 900ms 降到 120ms。当时团队成员说了一句话原来 Spring 异步编程没想象中复杂。但等他们上线一周后才发现乱用Async比不用更可怕——线程池被打满、事务静默失效、子线程拿不到登录态全踩了一遍。所以想认真聊聊Async注解。这篇不只是讲怎么加一个注解而是把 Spring 异步编程从代理机制、线程池选型、事务与上下文传递、异常处理到测试压测这条链路完整的过一遍。适合正在用Async但不是很清楚背后原理的同学也适合那些准备把同步接口改成异步但心里没底的团队。1. 从一次接口超时说起Async 帮我卸掉了哪些包袱1.1 一个典型场景邮件发送拖垮下单接口我先还原一下当时那个问题。下单接口的逻辑并不复杂校验参数、写订单主表、写明细表、扣减库存、然后给用户发一封订单确认邮件、再给运营发一条短信通知。数据库操作加在一起也就 60ms但发邮件和发短信这两个动作在极端情况下的耗时能到 700ms 以上因为要经过网络、要等第三方网关响应。同步模型下这 700ms 是全部叠加在用户请求路径上的。用户点一次下单按钮后端线程就卡在这里等第三方反馈用户体验差Tomcat 线程被白白占用。高峰时线程池一旦打满后面的请求全部排队整个服务雪崩。Async要解决的就是这种“不需要立刻知道结果”的耗时操作。把发邮件、发短信、写操作日志、推送通知这类逻辑扔到独立的线程池里执行接口主线程只负责核心业务逻辑然后直接返回。1.2 Async 的定位让调用方不用傻等结果Async放在方法上Spring 会在调用这个方法的瞬间改变执行方式。原来你在orderService.submit()里调用noticeService.sendEmail()是同一个线程里一条路走到底加了注解后sendEmail()会被丢到另一个线程去执行调用方拿到的返回值要么是void要么是Future/CompletableFuture这类异步结果对象。这个设计对应到生活里就是“点外卖”。你下单后不需要站在店门口等炒菜骑手做好了你再吃着。如果你非得同步等那就是去食堂窗口排队打饭前面的人一个个点菜你只能干等着。1.3 适用与不适用不是所有方法都适合异步Async不是银弹我在项目里见过最典型的误用是把“必须要看到结果才能继续”的逻辑也标成了异步。比如用户注册后要返回账号 ID如果注册主流程里异步去生成 ID那返回的就是 null接口直接出错。适合异步的几类场景通知类邮件、短信、站内信、钉钉/企微机器人推送日志与埋点操作流水、点击日志、性能上报数据同步与缓存刷新同步数据到搜索引擎、预热本地缓存非核心强校验比如风控规则的事后标记不适合异步的调用方依赖返回值的业务判断需要和当前请求在同一事务里的数据变更对执行顺序有强依赖的连续操作非常轻量的方法比如 1ms 就能完成的 getter用异步反而增加了线程切换成本2. 注解背后的 AOP 魔法异步调用是怎么被“掉包”的很多同学在 XML 时代或者互联网早期接触过 Spring对注解的理解停留在“打个标记”上面。实际上Async不是标记一下那么简单它背后走的是 Spring AOP 代理机制。不明白这一层后面遇到的坑基本是猜来猜去。2.1 EnableAsync 是开关Async 是标记在 Spring Boot 项目里使用Async前必须先开启异步支持。开启方式有两种一种是在配置类上加EnableAsync另一种是直接在启动类上加。SpringBootApplication EnableAsync public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }EnableAsync会向容器中注册一个AsyncAnnotationBeanPostProcessor这个东西是实现异步的核心。它会扫描容器中所有 Bean凡是有Async注解的方法都会被认为是需要异步处理的方法。2.2 代理机制返回的其实是增强后的代理对象Spring AOP 通过两种方式生成代理如果类实现了接口默认使用 JDK 动态代理如果没有接口使用 CGLIB 生成子类代理。当容器初始化NoticeService的时候发现里面有Async方法于是不会直接返回原生对象而是返回一个包装过的代理对象。你在OrderService里注入NoticeService拿到的已经是代理了。调用sendEmail()的时候执行逻辑被拦截下来交给AsyncExecutionInterceptor处理。这个拦截器会从容器中找到一个线程池然后把方法调用封装成一个Callable任务丢进线程池执行。所以从表面看你写的是noticeService.sendEmail(order);但实际执行路径变了asyncTaskExecutor.execute(() - noticeService.sendEmail(order));2.3 返回值类型限制Future、CompletableFuture 与 voidAsync方法只能返回void或Future类型。如果你返回一个普通的字符串、对象、ListSpring 会怎样说实话它不会报错但返回值会变成null。因为实际方法在另一个线程里执行调用方线程拿不到结果代理也只能给一个空值。Spring 文档里写了支持三种voidFutureTjava.util.concurrent.FutureCompletableFutureT推荐支持链式回调Async public CompletableFutureString sendEmailAndReturn() { // 模拟耗时 Thread.sleep(100); return CompletableFuture.completedFuture(send ok); }用CompletableFuture的好处是调用方可以继续做其他事情最后再.get()或者.whenComplete()拿到结果。如果你是非要同步等待返回结果的场景那不如不加Async直接用线程池或者加一个Future。2.4 为什么自调用会失效代理绕过入口这是Async失效场景里最隐蔽的一个。我看过不少代码Service public class OrderService { Async public void processOrder(Order order) { // 耗时操作 } public void submit(Order order) { // 同步逻辑 processOrder(order); // 这里其实失效了 } }同一个类里submit()直接调用processOrder()看起来方法上有Async但执行的时候还是同步的。原因很简单submit()调用this.processOrder()这个this是原始对象不是注入进来的代理对象。AOP 拦截器只有在外部调用代理对象时才会介入自调用绕过了代理注解自然不生效。解决办法把异步方法拆到另一个 Service 里从外部注入调用。注入自己代理Autowired/Resource注入OrderService自己。用AopContext.currentProxy()需要开启exposeProxy。我实践中最推荐第一种拆 service 会让边界更清晰也方便单元测试 mock。3. 线程池配置给异步任务一个明确的家很多初学者是在这个方法上加注解就完事了线程池是什么、队列多大、拒绝策略怎么定从来不关心。这种代码在开发环境跑得很欢一上并发就出事。3.1 默认的 SimpleAsyncTaskExecutor 为什么危险如果只用Async而没有指定线程池Spring 默认会去找一个名为applicationTaskExecutor的 Bean如果找不到会回退到SimpleAsyncTaskExecutor。这个默认执行器的特点很坑它每次执行任务都会创建一个新线程不复用线程也不限制并发数。你压测时并发 50 个请求它就开 50 个线程并发 2000 个请求它可能直接开 2000 个线程最终把内存和 CPU 全部耗尽甚至把整个应用拖死。生产环境绝对不能依赖默认执行器。我见过一个数据同步服务因为线上流量突增默认线程池创建了几千个线程最后应用 OOM 的经历事后排查日志的时候满屏都是线程创建失败的堆栈。3.2 自定义 ThreadPoolTaskExecutor 的核心参数正确的做法是定义一个线程池 Bean并显式管理核心线程数、最大线程数、队列容量和拒绝策略。Configuration public class AsyncConfig { Bean(taskExecutor) public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数 executor.setCorePoolSize(5); // 最大线程数 executor.setMaxPoolSize(10); // 队列容量 executor.setQueueCapacity(100); // 线程名前缀方便日志排查 executor.setThreadNamePrefix(async-task-); // 等待所有任务结束后再关闭线程池 executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }这里ThreadPoolTaskExecutor是 Spring 对java.util.concurrent.ThreadPoolExecutor的封装。需要理解一个关键规则corePoolSize是线程池常驻的线程数当任务数超过核心线程数后新任务会先放进队列而不是直接创建新线程只有当队列也满了才会把线程数扩到maxPoolSize如果连最大线程数都跑满了才会触发拒绝策略。所以配置的时候核心线程数、最大线程数、队列容量三者是联动关系不能孤立地调一个参数。比如你把queueCapacity设成Integer.MAX_VALUE那maxPoolSize形同虚设因为任务永远不会超队列。反之队列设 0任务一来就直接创建新线程等于接近无界并发。3.3 完整示例与 Async(poolName) 指定线程池容器里可以定义多个线程池分别服务不同的业务。我一般按业务维度拆比如notifyExecutor、reportExecutor、dataSyncExecutor互不干扰。定义好之后在Async注解上指定线程池名Service public class NoticeService { Async(notifyExecutor) public void sendEmail(Order order) { // 发送邮件 } Async(dataSyncExecutor) public void syncToSearch(Order order) { // 同步到搜索引擎 } }这样就不会所有任务都挤到同一个池子里。比如通知类任务偶发高峰不会影响数据同步任务的执行。3.4 线程池满了怎么办拒绝策略与队列深度当线程池处于饱和状态新任务会被交给RejectedExecutionHandler。Spring 里常见的策略有策略行为适用场景AbortPolicy直接抛 RejectedExecutionException不推荐会顶掉业务异常CallerRunsPolicy由调用方线程直接执行任务我喜欢用的兜底策略能放慢主线程提交速度造成天然背压DiscardPolicy静默丢弃无提示不推荐消息会丢DiscardOldestPolicy丢弃队列中最老的任务适合“旧任务没意义”的场景比如日志最绝的是CallerRunsPolicy。它不会丢任务只是让主线程帮着跑好处是生产者会感受到一点阻塞从而降低提交速度坏处是如果业务上不允许阻塞主请求就要小心。我一般用CallerRunsPolicy作为异步任务的兜底因为“慢慢做完”总比“直接丢掉”要好。不过如果异步任务本身就是重计算主线程被拖住又会造成接口变慢这时候宁可丢弃或者降级。队列深度的经验值queueCapacity可以设为并发请求峰值乘以平均单任务耗时所需要积压的数量适当留一定余量。比如峰值 QPS200平均耗时 100ms要扛住 5 秒突刺积压任务数大概 1000那队列设置为 1000~2000 比较合理。设太小容易频繁触发拒绝策略设太大则异步延迟过高任务都堆在队列里无法体现“削峰”的实时性。4. 异步与事务、上下文、异常三笔必须算清的帐4.1 Transactional 与 Async 同处一墙时的事务边界这是最有争议的问题。你要是把一个方法同时标上Async和Transactional事务还能保证吗答案能但事务变成了异步线程里的事务和调用方事务已经没有任何关系了。Async Transactional(rollbackFor Exception.class) public void processOrder(Order order) { // 1. 更新订单状态 // 2. 扣减库存 }执行流程是异步线程提交任务后代理拦截到Transactional开启新事务然后执行业务逻辑。这里最容易误解的是如果调用方本身在一个事务里而异步方法也在操作同一张表两边不会共享同一个事务。异步方法锁定的资源调用方是感知不到的并发情况下可能出现超卖、状态覆盖等问题。我建议的做法是事务逻辑尽量拆到事务层异步方法只是做“事务完成后”的后续动作比如发送通知、写日志。如果你确实需要异步方法里开启一个独立事务请确保里面所有数据库操作都在这一个事务方法内不要跨 service 调用别的Transactional方法去合并事务因为异步线程里根本没有调用方的事务上下文跨调用只会各自开启各自的事务。还有一个高频异常是异步方法里抛出的异常不会回滚调用方的事务。因为调用方已经提交事务了异步方法在另一个线程里执行两者完全隔离。理解了这一点你才会明白为什么“先更新数据库、再异步发 MQ”这种模式要非常小心——如果异步线程的事务失败了数据库可能已经提交了。4.2 丢失的 RequestContextHolder 与 MDC 追踪 IDSpring 的RequestContextHolder里保存了当前请求的HttpServletRequest、HttpServletResponse等上下文。你在 Controller 或者普通 Service 里能拿到是因为它在请求线程里通过ThreadLocal保存。但Async把任务丢到了另一个线程ThreadLocal在新线程里是空的。所以你会在异步代码里遇到经典空指针Async public void sendNotify(Long orderId) { // 这里 request.getHeader(traceId) 直接 NPE String traceId ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()) .getRequest().getHeader(traceId); }同样的问题也出现在日志追踪上。MDCMapped Diagnostic Context本质也是ThreadLocal。如果你用 Logback 的%X{traceId}打印日志异步线程里没有 traceId输出的日志全是一堆-出了问题根本没法把异步日志串起来。解决办法有两个思路。一种是在提交任务前把需要的上下文参数显式传给异步方法Async public void sendNotify(Long orderId, String traceId) { MDC.put(traceId, traceId); try { // 执行逻辑 } finally { MDC.remove(traceId); } }另一种是使用TaskDecorator在线程池层面统一包装任务把主线程的上下文复制过去。这个方案更优雅不用每个方法都传参我后面专门讲。4.3 全局异常处理 AsyncUncaughtExceptionHandlerAsync方法本身返回void时方法内抛出的异常不会被调用方捕获也不会走 Spring MVC 的ControllerAdvice全局异常处理器。你会在控制台看到异常但如果你不处理它可能就被吞了有些日志框架还会在异常堆栈里完美隐藏业务逻辑的上下文。Spring 提供了AsyncUncaughtExceptionHandler接口专门处理“无返回值异步方法的异常”。实现方式Configuration EnableAsync public class AsyncExceptionConfig implements AsyncConfigurer { Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return new MyAsyncExceptionHandler(); } static class MyAsyncExceptionHandler implements AsyncUncaughtExceptionHandler { Override public void handleUncaughtException(Throwable ex, Method method, Object... params) { // 记录异常日志可以加上方法名和参数 log.error(异步方法执行异常: {}, method.getName(), ex); // 可以发送告警通知、埋点等 } } }这里要注意如果Async方法返回Future/CompletableFuture那么异常会包装在Future里调用方调用.get()时会抛出ExecutionException这个接口就不会触发。所以建议明确你当前是“要结果”还是“不要结果”不要结果就老老实实处理异步异常要结果就统一在调用方捕获处理。4.4 子线程中传递上下文TaskDecorator 的使用ThreadPoolTaskExecutor有一个setTaskDecorator()方法可以在任务提交前和实际执行前做一次包装。这是解决ThreadLocal丢失问题的通用方案。Bean(contextAwareExecutor) public ThreadPoolTaskExecutor contextAwareExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); executor.setThreadNamePrefix(ctx-task-); executor.setTaskDecorator(new ContextCopyingDecorator()); return executor; } static class ContextCopyingDecorator implements TaskDecorator { Override public Runnable decorate(Runnable runnable) { MapString, String contextMap MDC.getCopyOfContextMap(); RequestAttributes requestContext RequestContextHolder.getRequestAttributes(); return () - { try { if (requestContext ! null) { RequestContextHolder.setRequestAttributes(requestContext); } if (contextMap ! null) { MDC.setContextMap(contextMap); } runnable.run(); } finally { MDC.clear(); RequestContextHolder.resetRequestAttributes(); } }; } }注意RequestContextHolder.setRequestAttributes默认是inheritable false所以必须在这里显式传入。用了TaskDecorator之后异步线程里可以拿到原始请求的 Header、MDC traceId日志也能串起来。对于微服务调用链还可以把TraceId/SpanId塞进线程里这样链路追踪数据不会断。5. 测试与压测异步代码最容易“假绿”异步代码最让人头疼的是测试。同步代码写单元测试执行完就能断言异步代码测试线程往往比业务线程更早跑完断言执行的时候异步任务还没完成于是经常出现测试通过了但线上功能又偶尔有问题。这就是典型的“假绿”。5.1 单元测试中如何等待异步结果最简单的办法是在测试里等待异步方法完成。不推荐用Thread.sleep(1000)这种固定等待因为慢机器上不够快机器上浪费时间。用CountDownLatchTest void testSendEmailAsync() throws InterruptedException { CountDownLatch latch new CountDownLatch(1); NoticeService service mock(NoticeService.class); doAnswer(invocation - { // 异步逻辑 latch.countDown(); return null; }).when(service).sendEmail(any()); service.sendEmail(new Order()); // 等待最多 5 秒 boolean completed latch.await(5, TimeUnit.SECONDS); assertTrue(completed); }还有更优雅的 Awaitility 库专门用来等待异步条件成立await().atMost(Duration.ofSeconds(5)) .untilAsserted(() - { assertEquals(SENT, emailService.getStatus()); });用 Awaitility 的好处是轮询等待条件不用自己管理锁和超时断言失败还能明确告诉你等待超时。我在团队里要求所有异步方法测试都使用这种方式能少踩很多重试的坑。5.2 并发压测中观察线程池指标集成测试跑通了不代表上线没事。我建议对异步线程池做一次专门的并发压测重点观察四个指标活跃线程数队列积压数任务完成耗时拒绝次数Spring Boot 的 Actuator 对ThreadPoolTaskExecutor暴露了线程池指标可以在压测时观察。也可以直接在代码里用ThreadPoolExecutor的getPoolSize()、getQueue().size()、getActiveCount()打印日志。观察指标的目的是确认线程池配置是否符合流量预期以及是否出现了大量任务堆积或拒绝。5.3 几个调优参数经验值我没有绝对放之四海而皆准的参数但可以分享一套可操作的估算方法。假设异步任务平均耗时为 100ms你希望异步线程池在 3 秒内处理完一个突发批次批量任务数为 300。那么单线程每秒能处理约 10 个任务1000ms / 100ms要在 3 秒内处理 300 个任务需要 300 / 3 / 10 10 线程如果还要求有一定余量把核心线程设为 10~15队列容量设为 200~500。最大线程数一般不推荐设太大建议不超过核心线程数的两倍因为线程切换是有开销的。比如核心 10最大 20队列 500配合CallerRunsPolicy兜底是我比较常用的组合。线程名前缀一定要设置比如async-notify-。排障时看日志能立刻分清异步任务是在哪个池子里跑的这个习惯能让你少走很多弯路。另外注意 JVM 停机时的处理。如果不设置setWaitForTasksToCompleteOnShutdown(true)应用关的时候线程池中的任务直接被打断正在发的邮件、正在同步的数据就丢了。设置了之后再配合setAwaitTerminationSeconds(60)应用会等正在执行的任务完成后再退出最长等 60 秒。回到我开头提到的那个电商团队。他们把Async用起来之后又经历了两个多月的优化最后沉淀出一套内部的异步任务规范默认线程池统一由基础组件提供业务侧只能通过Async(xxxExecutor)指定所有异步方法禁止在同一类内自调用事务不要和异步混在一个方法上日志、透传、异常处理都通过TaskDecorator和AsyncUncaughtExceptionHandler统一解决。这套规范上线后异步这块的线上故障频次几乎归零。最后再分享一个小技巧如果你在新项目里使用Async可以顺手把ThreadPoolTaskExecutor的监控指标接到你现有的监控系统里队列积压量一旦超过阈值就报警。很多事故其实不是线程池配置错误而是流量突增时没人发现队列已经堆积到几千个任务等用户投诉的时候系统都快卡死了报警反而比任何优化都管用。这些经验都是用时间和告警换回来的希望这期内容能帮你少踩几个坑。
返回列表