前阵子把一个内部服务改了下线程模型,改动其实不大,压测结果挺直接:接口还是那个接口,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 压力没那么夸张的业务服务,确实有点鸟枪换大炮那意思。