cms项目细节-性能优化之异步化

共 2,467 字 约 8 分钟

很多接口真正的业务很短,但还要等日志落库、发通知、统计 PV。如果这些都是同步执行,那么只要一个旁路慢,整个接口就被拖慢。用户点一下“提交留言”,在后台却要等“发邮件”完成才返回。

Java
UTF-8|6 Lines|
public void submitMessage(...) {
    saveMessage(...);          // 主业务
    saveLog(...);              // 慢的旁路:等它写完才返回
    sendNotify(...);           // 更慢的旁路:调外部接口
    return;
}

异步化:把某些工作从当前请求线程挪到另一个线程执行,让主线程立刻返回

解决方案:@Async + 线程池

@Async 和 @Transactional、@RequireLevel 一样,本质都是 Spring AOP 动态代理。你写的:

Java
UTF-8|8 Lines|
@Service
public class NotifyService {

    @Async("mainAsyncExecutor")
    public void sendNotify(String content) {
        // 真正逻辑
    }
}

Spring 启动时会给 NotifyService 生成一个代理对象。外部调用 sendNotify() 时,实际走的是代理:

Text
UTF-8|3 Lines|
调用方 → NotifyService 代理
           → 把方法提交到线程池(立刻返回)
           → 线程池里的工作线程真正执行 sendNotify 方法体

所以主线程只是“提交任务”就返回了,真正的逻辑放到了线程池里的另一个线程。

前提:开启@EnableAsync

Java
UTF-8|4 Lines|
@Configuration
@EnableAsync
public class AsyncConfig {
}

没有它,@Async 注解会被忽略,方法还是在主线程同步执行——这是最常见的“@Async 不生效”原因之一。

不要使用默认线程池

如果没有自定义线程池,Spring 默认用 SimpleAsyncTaskExecutor。它的问题:每次执行都新建一个线程,用完不回收。高并发下会无限创建线程,直接 OOM 或把系统拖垮。

所以生产必须自定义 ThreadPoolTaskExecutor:

Java
UTF-8|17 Lines|
@EnableAsync
@Configuration
public class AsyncConfig {

    @Bean("mainAsyncExecutor")
    public ThreadPoolTaskExecutor executor() {
        ThreadPoolTaskExecutor pool = new ThreadPoolTaskExecutor();
        pool.setCorePoolSize(4);          // 核心线程数
        pool.setMaxPoolSize(8);           // 最大线程数
        pool.setQueueCapacity(100);       // 队列容量
        pool.setKeepAliveSeconds(60);     // 非核心线程空闲存活秒数
        pool.setThreadNamePrefix("async-"); // 线程名前缀,方便排查
        pool.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
        pool.initialize();
        return pool;
    }
}
参数含义常见取值
corePoolSize常驻线程数,即使空闲也保留CPU 密集型≈CPU 核数;IO 密集型可稍大
maxPoolSize线程数上限核心×2 或按需
queueCapacity排队任务上限视吞吐量,别设无限
keepAliveSeconds超过核心数的线程,空闲多久后回收60
threadNamePrefix线程名前缀async-,方便看日志
rejectedExecutionHandler队列满且线程到上限时怎么办默认 AbortPolicy 抛异常

新任务来了:

  1. 核心线程还没满 → 直接开一个核心线程执行
  2. 核心线程满了 → 放进队列
  3. 队列满了 → 再开新的(非核心)线程,但不能超过 maxPoolSize
  4. maxPoolSize 也满了、队列也满了 → 触发拒绝策略

拒绝策略

策略行为
AbortPolicy(默认)直接抛 RejectedExecutionException
CallerRunsPolicy让提交任务的线程自己执行(相当于退回主线程序列化)
DiscardOldestPolicy丢弃队列里最老的任务,再提交新任务
DiscardPolicy静默丢弃新任务

返回类型:void 还是 Future

  • 方法返回 void:主线程提交后立刻返回,不关心结果。适合“通知、日志”。
  • 方法返回 Future / CompletableFuture:可以拿到结果。适合“需要结果但不想阻塞”的场景。
Java
UTF-8|4 Lines|
@Async("mainAsyncExecutor")
public CompletableFuture<String> generateReport() {
    return CompletableFuture.completedFuture("...");
}

最小演示

Java
UTF-8|33 Lines|
@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean("mailExecutor")
    public ThreadPoolTaskExecutor executor() {
        ThreadPoolTaskExecutor pool = new ThreadPoolTaskExecutor();
        pool.setCorePoolSize(2);
        pool.setMaxPoolSize(4);
        pool.setQueueCapacity(50);
        pool.setThreadNamePrefix("mail-");
        pool.initialize();
        return pool;
    }
}

@Service
public class MailService {
    @Async("mailExecutor")
    public void send(String to) {
        // 被丢到线程池执行
    }
}

@RestController
class UserController {
    @Autowired MailService mailService;

    @PostMapping("/register")
    public String register() {
        mailService.send("a@b.com");   // 立即返回ok
        return "ok";
    }
}
位置现在做的事为什么适合异步
service/AuthService.saveLog()登录/改密/切换区域后同步写 ay_syslog日志是旁路,用户不关心,不该拖慢登录
service/PublicInteractionService.addMessage()提交留言后同步写 ay_message保存完就返回,通知/统计可异步
service/PublicInteractionService.addComment()提交评论后同步写 ay_member_comment同上
service/DynamicFormService.submit()表单提交同步插入保存完可异步发通知/埋点
service/PublicApiService.likes()/oppose()点赞/反对同步 update 计数计数可异步,接口更快
service/DatabaseService.exportSql()大表导出 SQL很重,可异步返回任务号(进阶)

收益:

  • 登录、留言、点赞等接口响应更快(真正等的东西变少了)。
  • 主线程负载降低,能处理更多请求。
  • 日志/通知这类偶发失败不影响主业务。

代价(一定要主动说):

  • 丢失内存:异常在子线程里,如果不处理会静默丢失。
  • 上下文丢失:子线程拿不到 SecurityContextHolder / RequestContextHolder(需手动传递)。
  • 事务隔离:异步方法最好不要依赖外层事务(它自己一个新的线程/事务上下文)。
  • 顺序/乱序:异步任务执行顺序不保证。
  • 幂等:通知/计数要防重复。
问题答案
@Async 原理基于 AOP 代理,把方法提交到线程池,主线程立即返回
为什么必须自定义线程池默认 SimpleAsyncTaskExecutor 每次 new 线程,不可控
线程池执行顺序核心线程 → 队列 → 非核心线程(≤max)→ 拒绝策略
返回类型限制void 或 Future/CompletableFuture
自调用会生效吗不会,同 AOP 自调用问题
异常怎么处理方法内 try/catch,或 AsyncUncaughtExceptionHandler
子线程有 SecurityContext 吗没有,需要主线程取出后当参数传入
能拿到外层事务吗不能,异步方法独立线程,可自行加 @Transactional
更适合 @Async 还是 MQ轻量旁路用 @Async;需要削峰/解耦/可靠投递用 MQ

6. 常见坑:为什么 @Async 不生效

坑 1:没写 @EnableAsync 最容易被忽略。没有它,@Async 被当成普通注解忽略,方法照常在主线程同步跑。

坑 2:自调用 和 AOP 一样:

Java
UTF-8|9 Lines|
@Service
public class NotifyService {

    public void a() {
        this.sendNotify("x");   // this 是真实对象,不走代理 → @Async 不生效
    }
    @Async
    public void sendNotify(String s) { ... }
}

解决:sendNotify 放到另一个 bean,由外部调用。

坑 3:private 方法 代理无法拦截 private 方法,@Async 必须放在可被代理调用的 public 方法上。

坑 4:返回类型不是 void/Future 如果方法是同步返回普通对象(如 String),Spring 会报错或不做异步。要么 void,要么 Future/CompletableFuture。

坑 5:异常没有处理 子线程抛异常,主线程感知不到。要么在方法内 try/catch,要么配置 AsyncUncaughtExceptionHandler 统一处理:

Java
UTF-8|9 Lines|
@Configuration
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (throwable, method, params) ->
                log.error("Async method {} failed", method.getName(), throwable);
    }
}

坑 6:上下文丢失 子线程里 SecurityContextHolder.getContext()、RequestContextHolder.getRequestAttributes() 都是空的,因为线程池线程不继承发起线程的上下文。需要在调用前取出,作为参数传入异步方法:

Java
UTF-8|2 Lines|
String username = SecurityUtils.currentUser().getUsername();  // 主线程先取
asyncService.saveLog(..., username);                          // 再传进去

坑 7:事务边界 @Async 方法自己在一个新线程执行,加在外层方法上的 @Transactional 不会覆盖到它。如果异步方法也要事务,就在异步方法自身加 @Transactional。

坑 8:线程池参数不合理 队列无限增长会 OOM;任务堆积说明消费跟不上,需要调大线程数或用 MQ 削峰。监控线程池指标(活跃线程数、队列长度)。

坑 9:拒绝策略没想清楚 任务爆了默认抛异常,如果没捕获就丢任务。生产常用 CallerRunsPolicy 做兜底,或把任务丢进 MQ。