生产环境里大部分"偶发超时"其实都经不起细看:CPU 不高,内存正常,下游调用方一个个义正言辞说"我们这没慢",可真要拿出证据时,发现自己连请求在哪儿排队、卡了多久都说不清楚。我把 Apache HttpAsyncClient 换成同步用法的那几天,这个问题简直被无限放大——线程栈里抓不到阻塞点,日志里只有调用超时的告警,连接池剩多少、线程池忙不忙,全靠猜。后来我把 HttpAsyncClient 接进 Micrometer,再通过 Prometheus 把指标外露,才第一次看清了异步客户端内部的水位。这篇文章就以 HttpAsyncClient 与 Micrometer / Prometheus 的集成为主线,把完整的链路、代码和排障经验一次讲完。
1. 异步客户端最隐蔽的失明:连接池和线程池的内部水位,日志看不到
1.1 同步时代靠线程栈就能定位,异步时代线程栈失灵了
同步客户端最大的好处是"出问题时有现场"。业务线程发一个请求就阻塞在哪里,连接池满的时候,它能让你用 jstack 直接看到 BLOCKED 状态,栈上明明白白写着在等待连接租用;下游响应慢时,也能看到线程挂在 socket read 上。定位到具体线程,再对上时间戳,问题基本上就浮出水面。
但 HttpAsyncClient 的 NIO 模型把这一切都打破了。请求发出去之后,业务线程立即返回,真正等待响应、排队获取连接、处理超时的动作全部发生在 IOReactor 的内部线程里。此时你抓业务线程栈,看到的是它在干别的活,甚至是空闲;而 IOReactor 线程栈虽然能看到 select 调用,但你根本分不清它是在等新连接、等读写事件,还是在处理某个等了半天的下游响应。异步执行的回调往往又跑在另一组线程上,异常信息被包进 Future 或 Callback,日志里只剩下一句"request timed out"。
这种情况下,光靠日志和线程栈已经无法还原请求的全貌。唯一的出路是把内部状态变成可观测的指标,让连接池、线程池、请求耗时的分布都能通过 Micrometer 暴露出去,由 Prometheus 定期拉取。为什么强调"全链路可观测"?因为异步客户端内部的状态太容易被业务上下文遮蔽了,指标是唯一能稳定看到真相的窗口。
1.2 一个健康模型:请求总耗时应该拆开看
我把一次 HttpAsyncClient 调用总耗时拆成三段:排队获取连接的时间、从连接上发送请求到收到响应的网络往返时间、以及回调处理和反序列化的时间。同步客户端里,这三段几乎都能靠线程栈、事件时间和本地日志拼出来;异步客户端里,排队时间藏在连接管理器内部,回调处理时间藏在另一个线程池里,单看接口平均耗时,你只会觉得"偶尔慢",但不知道为什么慢。
所以集成指标时的第一原则就是:不要只埋一个"请求总耗时"的 Timer,要把连接池水位和线程池状态一并暴露出来,否则你只是把同步时代已经拥有的那点信息重新做了一遍。
在后续章节里,我会围绕三类核心指标展开:请求耗时、连接池租用情况、执行回调的线程池饱和度。这三类合在一起,才构成一个可解释的异步客户端监控模型。
2. 依赖装配与初始化:先让 Micrometer 手里有一个能吐 Prometheus 格式的 Registry
2.1 版本选型:别再被 HttpAsyncClient 4.x 的老配置带偏
网上搜索 HttpAsyncClient 的资料,很容易翻到 httpasyncclient 4.x 时代的代码,类名、包名跟现在的 httpclient5 完全是两个世界。我当前用的版本组合是 Java 17、Spring Boot 3.x、Apache HttpClient 5.4、Micrometer 1.13,这套组合在大多数项目里都能正常工作。
如果你使用 Maven,核心依赖如下:
<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> <version>1.13.6</version> </dependency> <dependency> <groupId>org.apache.httpcomponents.client5</groupId> <artifactId>httpclient5</artifactId> <version>5.4</version> </dependency>Spring Boot 3.x 会自动带上 micrometer-core,所以只要额外引入 prometheus registry 即可。很多人漏掉这一步:只引入 actuator,以为 /actuator/prometheus 会自动出现,但如果没有 registry 实现,这个端点根本不存在。
如果不在 Spring Boot 环境里,还需要手动引入 micrometer-core,并且自己创建 registry。
2.2 手动初始化 PrometheusMeterRegistry 的正确姿势
Spring Boot 环境里,类路径下存在 micrometer-registry-prometheus 时,Spring Boot 会帮你自动注册一个 PrometheusMeterRegistry 的单例,直接注入即可:
@Autowired private MeterRegistry meterRegistry;但如果你在一个遗留的非 Spring 模块里使用 HttpAsyncClient,就得自己创建一个 registry,并保证全局只有这一个单例:
PrometheusMeterRegistry registry = new PrometheusMeterRegistry( PrometheusConfig.DEFAULT ); // 所有 Timer / Gauge / Counter 都注册到这个 registry 上 // 之后用 registry.scrape() 拿到 Prometheus 文本格式的指标注意一点:如果同一个进程里出现了多个 PrometheusMeterRegistry,它们各自维护一套指标,暴露时会互相独立,很容易让人误以为"指标丢了"。最简单的做法是让 Spring 容器或一个静态持有者统一管理 registry。Spring Boot 下不要再手动 new,直接注入容器里的实例,否则会出现两个 registry 并行、指标分布不均的诡异现象。
2.3 命名规则:为什么最终出现在 /metrics 里的名字长这样
Micrometer 默认的 Prometheus 命名约定会把点号转成下划线,把 camelCase 转成蛇形,还会根据 Meter 类型追加后缀。比如我注册一个名为http.async.client.request的 Timer,实际暴露到 Prometheus 里会变成http_async_client_request_seconds_max、http_async_client_request_seconds_sum、http_async_client_request_seconds_count,以及如果开了直方图,还会有http_async_client_request_seconds_bucket。
连接池的 Gauge 名字相对直白,http.async.connections.leased会变成http_async_connections_leased。理解了这套转换规则,你在 curl 验证和配置 Grafana 面板时就不会对着陌生的指标名发愣。命名这件事我建议从一开始就定好前缀规范,比如所有跟客户端相关的指标都用http.async.开头,后续检索和维护会省很多事。
3. 把指标真正钉在 HttpAsyncClient 上:三类核心度量
3.1 请求耗时:用 Timer 还原"同步才有的视角"
Timer 是集成里最基础也最容易被用错的指标。最简单的做法是围绕一次完整的请求执行包一个计时器,在同步等待 Future 的场景下可以这么写:
Timer timer = Timer.builder("http.async.client.request") .tag("target", targetHost) .tag("outcome", "success") .publishPercentileHistogram(true) .publishPercentiles(0.5, 0.9, 0.99) .register(meterRegistry); Timer.Sample sample = Timer.start(meterRegistry); try { Future<HttpResponse> future = client.execute(request, callback, context); HttpResponse response = future.get(10, TimeUnit.SECONDS); sample.stop(timer); } catch (Exception ex) { // 注意:不要在预创建的 timer 里写死 outcome 后强行 stop }但这里有个典型设计错误:如果你在初始化 Timer 时就把 tagoutcome=success写死,那么失败请求来了没法复用同一个 Meter,因为 tag 组合变了。正确做法有两种:
一种是不带 outcome tag,只把 Timer 当作一个耗时分布来看;另一种是在请求结束时动态通过 registry.timer 获取带 outcome 的 Timer 再 stop。Micrometer 的 Timer 实例本身可以按 tag 组合从 registry 查询,不需要每个组合都预先创建:
Timer timer = Timer.builder("http.async.client.request") .tag("target", targetHost) .publishPercentiles(0.5, 0.9, 0.99) .register(meterRegistry);然后在 stop 时根据实际结果决定使用哪个标签。注意这种动态获取方式需要用同一个 name 和相同的基础 tags,只是 outcome 维度在运行时变化:
sample.stop(Timer.builder("http.async.client.request") .tag("target", targetHost) .tag("outcome", success ? "success" : "error") .register(meterRegistry));如果你的场景是纯异步回调,不能等 Future,那就把Timer.Sample存在请求上下文里,在回调的响应完成节点再 stop。Sample 对象本身不绑定线程,所以跨线程传递是安全的,真正容易丢的是线程上下文里的业务标识,这一点在后面的坑里会专门说。
3.2 连接池水位:用 Gauge 观察异步连接的真实状态
连接池指标是 HttpAsyncClient 集成里最有价值的监控对象,没有之一。我常用的是 PoolingHttpClientConnectionManager 上的三个状态:leased(当前被租用的连接数)、pending(等待租用连接的请求数)、available(空闲可用连接数)。
注册 Gauge 的典型代码:
PoolingHttpClientConnectionManager connectionManager; Gauge.builder("http.async.connections.leased", connectionManager, manager -> manager.getTotalStats().getLeased()) .description("当前被租用的连接数") .register(meterRegistry); Gauge.builder("http.async.connections.pending", connectionManager, manager -> manager.getTotalStats().getPending()) .description("等待租用连接的请求数") .register(meterRegistry); Gauge.builder("http.async.connections.available", connectionManager, manager -> manager.getTotalStats().getAvailable()) .description("空闲可用的连接数") .register(meterRegistry);这里有个设计细节:Gauge 的对象引用必须是 connectionManager 本身,而不是某个静态值,否则不会实时更新。getTotalStats()返回的是连接池全局状态,每次 Prometheus 抓取时 Micrometer 都会调用一次统计方法,因此你看到的是当前真实水位,而不是采样的历史快照。
这三个指标怎么读?leased 持续接近 maxConnPerRoute,说明上下游连接配额可能卡脖子了;pending 突然上升到几十,意味着大量请求在排队等连接,几乎可以断定下游某台实例响应变慢或连接没有及时释放;available 长期偏高则说明连接建立过多,keep-alive 策略或 maxConnPerRoute 可能配得太宽,白白占着资源。
如果想让指标更细,还可以通过ConnPoolControl按 host 获取每个路由的状态,加上 route 标签。但要注意后面章节讲的高基数问题,route 数量如果只有几个,没问题;如果有几十上百,要小心序列膨胀。
3.3 线程池饱和度:异步能不能扛住,看队列和活跃线程
最后一个核心指标是执行层。HttpAsyncClient 内部 IOReactor 的 IO 线程负责 NIO 事件循环,而业务回调往往跑在你传入的 Executor 或默认的响应回调线程上。当回调处理跟不上响应速度时,队列会堆积,这是"下游健康但客户端整体延迟仍高"的常见原因,只有线程池指标能揭穿它。
如果你给 HttpAsyncClient 配了一个外部 Executor,比如:
ExecutorService callbackExecutor = Executors.newFixedThreadPool(8);那么直接使用 Micrometer 的 ExecutorServiceMetrics 把它绑定进来:
ExecutorServiceMetrics metrics = new ExecutorServiceMetrics(callbackExecutor, "http-async-callback"); metrics.bind(meterRegistry);绑定之后,你会在 Prometheus 里看到executor_http_async_callback_active_threads、executor_http_async_callback_queued_tasks、executor_http_async_callback_pool_size等指标。active 线程打满、queue 持续上涨,说明回调侧成了瓶颈;queue 上涨的同时 active 线程不高,则说明任务调度或下游 IO 有问题。这套组合诊断下来,基本能覆盖大多数异步场景的延迟来源。
4. 从指标到 Prometheus:暴露端点、命名规则与抓取验证
4.1 三种暴露方式,我推荐哪一种
HttpAsyncClient 的指标最终要通过一个 HTTP 端点交给 Prometheus 去拉取。常见的路径有三种,我按省事程度排序:
| 暴露方式 | 适用场景 | 备注 |
|---|---|---|
| Spring Boot Actuator 端点 | 绝大多数 Spring Boot 服务 | 加依赖 + 改配置即可 |
| 自建 HttpServer | 遗留非 Spring 模块 | 代码量少,效果稳定 |
| 已有框架的 Controller | 已有 Web 框架但不想引入 actuator | 直接返回 registry.scrape() |
Spring Boot 环境里最稳妥的是 actuator。只需在 application.yml 里暴露 prometheus 端点:
management: endpoints: web: exposure: include: prometheus,health,info然后访问/actuator/prometheus就能看到所有指标。这个路径和 Prometheus 抓取配置里要写的 metrics_path 严格对应,别写错。
4.2 手动暴露端点,脱离 Spring 也能玩
如果你在一个不用 Spring Boot 的老模块里用 HttpAsyncClient,可以用 JDK 自带的 HttpServer 快速起一个指标端点,代码量非常小:
HttpServer server = HttpServer.create(new InetSocketAddress(8081), 0); server.createContext("/metrics", exchange -> { String body = registry.scrape(); byte[] bytes = body.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "text/plain; version=0.0.4; charset=utf-8"); exchange.sendResponseHeaders(200, bytes.length); try (OutputStream out = exchange.getResponseBody()) { out.write(bytes); } }); server.start();这样 Prometheus 只需要把 targets 指向这台机器的 8081 端口,metrics_path 设为 /metrics 就能拉到所有指标。这种做法的好处是零框架依赖,坏处是没有 Spring Boot 那套自动的端点管理和认证,是否需要加访问控制取决于你的网络环境。
4.3 prometheus.yml 抓取配置与验证
抓取配置很简单:
scrape_configs: - job_name: 'http-async-demo' scrape_interval: 10s metrics_path: '/actuator/prometheus' static_configs: - targets: ['localhost:8080']配置完成后,重启 Prometheus,等一两个周期,然后直接 curl 验证:
curl -s http://localhost:8080/actuator/prometheus | grep 'http_async_client'如果一切正常,你应该能看到类似这样的输出:
http_async_client_request_seconds_count{outcome="success",target="api"} 23 http_async_client_request_seconds_max{outcome="success",target="api"} 0.184 http_async_connections_leased 0 http_async_connections_pending 0验证指标是否在"翻转"有个小技巧:先记录当前_count的值,然后并发发几个请求,再刷新看 count 是否增长、max 是否变化。如果 count 在涨,说明 Timer 记录链路是通的;如果只有 count 涨而 max 一直不变,可能是同一个慢请求没有被采样到,或者 Timer 的 tag 组合不对。
5. 实际项目里躲不开的五个坑
5.1 Meter 重复注册与类型冲突
Micrometer 对同名的 Meter 是复用的,但如果名字相同、类型不同,或者 tags 组合不一致,会抛异常或打出难懂的警告。最常见的场景就是我在 3.1 里以 outcome 做 tag 的写法:如果你在多个方法里都用Timer.builder("http.async.client.request")但每次都带不同的 tag 集合,实际上会创建出多个 Meter 序列,数量一多就乱。
解决方案是固定 Meter 的基础结构:name、基础 tags、bucket 配置必须一致,只有少量业务维度可以变化。如果业务维度太多,不要塞进 tag,直接把维度拆成多个指标名,或者用日志补充。
5.2 ThreadLocal 上下文在异步回调中的丢失
这个坑几乎每个接异步客户端的人都会踩一次。同步场景下,你可以在 Filter 里把 TraceId、用户标识放进 ThreadLocal,业务代码随时能取;但 HttpAsyncClient 请求发完后,回调运行在别的线程上,ThreadLocal 里的东西已经没了。直接表现就是:sample.stop()时你拿不到业务标签,或者日志里 traceId 断链。
我的做法是:把需要在回调里使用的上下文全部收进请求闭包或一个 context 对象里,显式传给回调,绝不依赖线程变量。比如构建请求时把 Timer.Sample 和目标 tag 塞进一个 RequestContext 对象,回调完成的节点从 context 里取出来再 stop。这样虽然少了一些"透明"魔法,但至少指标不会断。
5.3 直方图桶配置不合理,QPS 一高内存就涨
开着publishPercentileHistogram(true)很爽,它会为 Timer 生成一组默认直方图 bucket,覆盖从几毫秒到几十秒的区间。但如果你给多个 tag 组合都开了直方图,指标序列数会成倍上涨,QPS 一高,Prometheus 抓取和存储的开销就上去了。更麻烦的是,很多 bucket 根本用不到,浪费资源。
我后来改成了固定 serviceLevelObjectives,只保留关心的几个阈值:
Timer.builder("http.async.client.request") .tag("target", targetHost) .serviceLevelObjectives( Duration.ofMillis(100), Duration.ofMillis(200), Duration.ofMillis(500), Duration.ofSeconds(1) ) .register(meterRegistry);这样 Prometheus 端只生成对应的四个 bucket,语义也更贴近业务告警:你想知道哪些请求超过 500ms,直接看 bucket 边界就能算。除非你真的需要任意分位数的精确值,否则不建议全量百分位直方图。
5.4 tags 高基数:别把 host 和 IP 放进 tag
我见过有人为了区分下游节点,把host、ip、甚至podId全放进了 HttpAsyncClient 的 Timer tag。初看没问题,一旦下游节点有几十个,指标序列就是几十倍地膨胀,Prometheus 的查询性能和内存直接报警。
正确做法是控制 tag 基数:只放有限集合的维度,比如target=api、target=core、region=cn-east这种枚举值。如果确实要区分到具体实例,不要把动态标识进 tag,宁可把它放到日志里,用 traceId 关联,也不要拿监控系统的命去换。
5.5 版本升级后的接口变化
HttpAsyncClient 从 4.x 到 5.x 改名幅度很大,连包名都换了;HttpAsyncClient 5.2 到 5.3、5.4 之间,部分配置方法和超时类型也在调整。最典型的是超时设置从 int 毫秒改成了Timeout.of(Duration),老代码直接编译不过。Micrometer 这边,ExecutorServiceMetrics 的构造方式也经历了一次调整,早期常见的ExecutorServiceMetrics.monitor(registry, executor, "name")现在更推荐 new 一个实例再 bind,两个方式都还能用,但要看你引的具体版本。
升级时我建议三步走:先升级依赖,跑一遍现有集成代码,把编译错误清掉;再用本地 prometheus.yml 指向测试实例,确认指标命名没有变化;最后比对升级前后的 Grafana 面板,防止某个指标名变化导致面板黑掉。
这套 HttpAsyncClient 指标接完之后,我最直观的感受是:以后再遇到"偶发超时",不用再靠猜,连接池的 pending 曲线和线程池的 active 曲线会把现场还原得明明白白。很多人问我探针到底该埋多少,我的回答永远是:先连接池,再线程池,最后才是请求耗时分布——前两个是异步客户端独有的观测窗口,也是排查问题最快的切入口。有一次下游一个实例悄悄变慢,整个集群的请求延迟都没超标,但 pending 在那一小时悄悄爬到了两位数,顺着这个指标往下查,半小时就把问题实例找了出来。这种经历多了之后,你会觉得集成这层指标不是可选项,而是异步客户端的标配。