首页
学习
活动
专区
圈层
工具
发布

SpringBoot + 虚拟线程,简直鸟枪换大炮~

前阵子把一个内部服务改了下线程模型,改动其实不大,压测结果挺直接:接口还是那个接口,SQL 还是那几条 SQL,真正变的是“等”的成本。

以前我们做 Web 服务,碰到大量 IO 请求,常规思路基本都一样:Tomcat 线程池调大一点,业务线程池再配一套,Feign 或 HTTP Client 再来一套,谁堵了就怀疑谁。线程配少了,请求排队;线程配多了,机器开始忙着切上下文。

虚拟线程出来以后,这件事就顺手多了。尤其是 SpringBoot 这类典型 CRUD + RPC + 缓存查询型服务,很多请求并不吃 CPU,时间都花在“等数据库”“等 Redis”“等下游接口”上。平台线程这时候就显得有点贵了。

先看最简单的开法。JDK 21 起,SpringBoot 项目里直接把执行器切成虚拟线程就行:

@Configuration

public class VtConfig {

  @Bean

  public AsyncTaskExecutor applicationTaskExecutor() {

      return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());

  }

}

如果你本身用了@Async,这一下就能吃到红利:

@Service

public class NotifyService {

  @Async

  public void sendSms(String phone, String content) {

      smsClient.send(phone, content);

  }

  @Async

  public void sendMail(String mail, String title) {

      mailClient.send(mail, title);

  }

}

这类代码以前最大的问题不是写法丑,而是线程得算着用。短信通道慢一点、邮件网关偶发抖一下,线程池就开始堆任务。换成虚拟线程后,一个阻塞任务占着的是虚拟线程,不再像以前那样死死绑住一个昂贵的平台线程。

再往前走一步,Web 容器也可以直接切:

spring.threads.virtual.enabled=true

这个配置开完,Tomcat 接请求时就能走虚拟线程。很多人第一反应是:那是不是线程数可以随便开了?不是这个意思。它解决的是“线程太贵”,不是“下游资源无限”。

这类问题在线上特别明显。比如订单查询接口,表面上看接口 RT 高,很多人先去调 Tomcat:

server.tomcat.threads.max=400

server.tomcat.accept-count=200

调完一轮,请求能多抗一点,但数据库连接池马上顶不住:

spring.datasource.hikari.maximum-pool-size=30

spring.datasource.hikari.connection-timeout=3000

这时候你会发现,虚拟线程把 Web 层的阻塞成本降下来了,但数据库连接还是 30 个,Feign 到下游的连接池还是那么多。也就是说,瓶颈从“线程不够”变成了“真实外部资源不够”,这反而更容易看清问题。

我自己比较喜欢拿一个聚合接口测试。一个请求里串 3 次下游调用,再查 2 次库:

@Service

public class UserProfileService {

  public UserProfileDTO query(Long userId) {

      UserDO user = userMapper.selectById(userId);

      AccountDTO account = accountClient.query(userId);

      CouponDTO coupon = couponClient.queryAvailable(userId);

      AddressDTO address = addressClient.defaultAddress(userId);

      return new UserProfileDTO(user, account, coupon, address);

  }

}

这段代码以前看着就不踏实。同步写法是最顺手的,但线程池压力大;改CompletableFuture吧,代码一下就碎了:

CompletableFuture<AccountDTO> a = CompletableFuture.supplyAsync(() -> accountClient.query(userId), pool);

CompletableFuture<CouponDTO> b = CompletableFuture.supplyAsync(() -> couponClient.queryAvailable(userId), pool);

CompletableFuture<AddressDTO> c = CompletableFuture.supplyAsync(() -> addressClient.defaultAddress(userId), pool);

能跑,但维护起来总觉得在跟框架较劲。虚拟线程的好处就在这儿:很多本来被迫异步化的代码,现在可以继续按同步思路写,结构简单,吞吐也不差。

不过也别上来就全量切,我建议先盯两类接口。

第一类是 IO 密集型接口。查库多、调 RPC 多、阻塞明显,这类提升最稳。

第二类反而别急着碰:CPU 密集型任务。比如大批量加解密、图片处理、复杂 JSON 转换,这种活儿本来就不是在“等”,虚拟线程帮不了太多,线程开多了照样把 CPU 打满。

还有个坑也得提前说。用了虚拟线程,不等于老代码天然安全。尤其是ThreadLocal用得多的项目,要多看两眼。像这种历史代码:

public class TraceHolder {

  private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

  public static void set(String traceId) { TRACE_ID.set(traceId); }

  public static String get() { return TRACE_ID.get(); }

  public static void clear() { TRACE_ID.remove(); }

}

逻辑上没问题,但你得确认上下游链路、日志 MDC、拦截器这些东西有没有跟着线程模型一起适配。不然接口功能正常,日志 trace 丢了,排查会很难受。

再补一个实际点的建议:先压测,再上线。重点看这几个指标,不用上来就盯 TPS。

平均 RT 和 P99 有没有明显下降

Tomcat 活动线程是不是还经常打满

数据库连接池等待时间有没有上升

下游接口超时是不是变多了

如果切完以后 RT 下来了,但 DB wait 飙升,那不是虚拟线程有问题,是以前线程层面把瓶颈盖住了,现在盖子打开了。

最后给一个比较稳的组合,适合先在内部服务试:

spring.threads.virtual.enabled=true

spring.datasource.hikari.maximum-pool-size=40

spring.datasource.hikari.connection-timeout=3000

server.tomcat.accept-count=300

server.tomcat.connection-timeout=2000

一句话讲,SpringBoot + 虚拟线程这事,最舒服的地方不是“更快”这两个字,而是终于不用为了抗一点 IO 并发,把代码写得满屏异步回调和线程池配置。

以前是平台线程硬扛阻塞,现在是虚拟线程把阻塞这件事做便宜了。

对这种请求多、等待多、CPU 压力没那么夸张的业务服务,确实有点鸟枪换大炮那意思。

  • 发表于:
  • 原文链接https://page.om.qq.com/page/Ofy16d067TuL5eVfbjxdzwkw0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。
领券