最近在折腾一个异步HTTP转发服务逻辑本身不复杂但一压测就露馅——QPS看着还行P99却飙到天上连接池里的连接像幽灵一样时有时无。排查问题全靠猜显然是行不通的我决定把HttpAsyncClient的关键指标接到Prometheus上用Grafana把请求表现直接画出来。整个过程比想象中绕官方绑定器能覆盖一部分但异步请求的真实耗时没法直接躺赢得自己动手埋点。这篇就把完整思路和可复现的代码写出来给同样在异步HTTP这条路上踩坑的人一个参考。正文以HttpAsyncClientApache HttpClient 4.x异步版为主线演示Micrometer指标如何落地到Prometheus。适合正在维护异步HTTP网关、HTTP转发中间件或者想把内部HTTP客户端纳入可观测体系的开发者。1. 技术拆解异步HTTP客户端到底要暴露哪些指标1.1 异步不等于没有连接池连接池就是命门很多同学一看到Async就觉得事情变简单了——不用等响应线程不阻塞于是连接数可以随便开。这个理解会害了你。HttpAsyncClient底层依然是基于连接管理器在复用TCP连接连接池一旦被打满新请求只能进入pending队列排队表现就是接口吞吐没变但延时暴涨。所以连接池维度至少要盯四个数当前租用的连接数leased、正在等待连接的请求数pending、空闲可用连接数available、以及连接池上限max。这四个数一出来瓶颈在不在连接池就很清楚了。我在实际定位问题时的经验是如果leased长期贴近max且pending大于0基本可以断定连接池不够用如果available大量堆积、leased很低那多半是连接泄漏或者回收策略有问题连接建了不释放。1.2 请求维度的指标官方默认不会帮你统计HttpAsyncClient和同步HttpClient不一样它没有内置的请求耗时统计能力。同步请求还可以用拦截器包一层异步请求回调里才能拿到结果中间隔着Future和回调地狱。而且异步场景副产品是“线程不阻塞”于是平时监控里常用的ThreadPool指标也失效了——请求不会坐下来慢慢等你而是直接进了回调。因此请求维度的耗时、成功失败计数、在途请求数这些必须自己埋点。这部分是整篇里最需要动手的地方也是最容易做错的因为起点和终点的界定、并发下的计时安全都要处理好。1.3 三种指标类型的选型逻辑Micrometer把指标抽象成几类这里只用得着三种指标类型用途对应场景Gauge可变的瞬时值连接池当前租用数、在途请求数Counter单调递增的计数请求成功次数、失败次数Timer记录耗时分布单次请求耗时、连接获取耗时表里看起来简单实战里最要命的是选错场景。比如有人把用Gauge统计请求耗时导致Prometheus每次抓取只能看到当前快照完全拿不到P99分布。也有人用Counter统计在途请求数结果永远只增不减监控图上一条斜线拉到天上去。记住耗时必须上Timer瞬时水位必须上Gauge累加次数必须上Counter。这个原则放之四海而皆准。2. 连接池维度指标用Micrometer内置绑定器接入2.1 先确认依赖别在坐标上卡壳需要引入的核心依赖是micrometer-core以及后面要用的micrometer-registry-prometheus。如果你用的是Spring Boot里面已经带了micrometer-core但要单独引入prometheus registry。如果用Mavendependency groupIdio.micrometer/groupId artifactIdmicrometer-registry-prometheus/artifactId version1.12.5/version /dependency如果项目里还没有micrometer-core需要一起加上。注意版本要和你的Java版本兼容8以上的项目用1.7的Micrometer问题都不大。2.2 显式构造连接管理器这是后续所有指标的基础HttpAsyncClients.custom()如果不显式传入连接管理器会内部默认创建一个这样Micrometer绑定器拿不到引用指标自然无法注册。这个坑我第一天就踩了绑定器绑了一个连接管理器客户端实际用的是另一个监控面板上一片0。所以必须先自己创建连接管理器再传给客户端PoolingHttpClientConnectionManager connectionManager new PoolingHttpClientConnectionManager(); connectionManager.setMaxTotal(200); connectionManager.setDefaultMaxPerRoute(50); CloseableHttpAsyncClient httpClient HttpAsyncClients.custom() .setConnectionManager(connectionManager) .build(); httpClient.start();再说一次这里的PoolingHttpClientConnectionManager包名是org.apache.http.impl.nio.conn是异步NIO的实现类跟同步版org.apache.http.impl.conn同名但完全不同。两个都导入IDE里很容易混淆注意别引错。2.3 绑定连接池指标官方绑定器这么用Micrometer针对Apache HttpClient连接池提供了现成的绑定器。对于异步连接管理器建议先试官方绑定器代码一行new PoolingHttpClientConnectionManagerMetricsBinder(connectionManager, async).bindTo(meterRegistry);如果当前所用Micrometer版本对这个异步实现的支持不够直接构造器参数类型对不上那就退一步自己基于ConnectionPoolStats注册Gauge。两个方案本质一样因为绑定器内部就是读取ConnectionPoolStats。自己手写也就十几行ConnectionPoolStats poolStats connectionManager; registry.gauge(httpclient.pool.connections.leased, poolStats, s - s.getTotalStats().getLeased()); registry.gauge(httpclient.pool.connections.pending, poolStats, s - s.getTotalStats().getPending()); registry.gauge(httpclient.pool.connections.available, poolStats, s - s.getTotalStats().getAvailable()); registry.gauge(httpclient.pool.connections.max, poolStats, s - s.getTotalStats().getMax());这段代码最大的优势是不依赖具体实现类只要对象实现了ConnectionPoolStats就行。异步版的PoolingHttpClientConnectionManager恰好实现了这个接口所以能直接注册成功。如果你还想看某个具体路由比如只有某个host的池子不够了可以用getStatsByRoute(route)单独注册一版加一个route标签区分。2.4 连接池指标清单逐项解读Prometheus抓到的连接池指标大概长这样名字会被Prometheus约定转成下划线Prometheus指标名类型含义httpclient_pool_connections_leasedGauge当前已租给请求的连接数httpclient_pool_connections_pendingGauge等待空闲连接的请求数httpclient_pool_connections_availableGauge池中空闲可用连接数httpclient_pool_connections_maxGauge连接池最大连接数这块指标基本能回答“连接池有没有成为瓶颈”的问题。但光有连接池还不够一个请求从发出到拿到响应耗时到底是多少连接池指标看不出来。耗时、成功率、失败率恰好是下一节要解决的问题。3. 请求耗时与成功率异步场景下手动埋点3.1 为什么不能像同步请求那样套时间戳同步请求想统计耗时太简单了执行前后各打一次时间戳就行。异步请求就不行因为你调用execute()之后立即返回Future真正的响应到达发生在另一个线程的回调里。你再往后看时间戳就是空的或者拿到了Future但还没完成无论怎么算都是一笔糊涂账。正确的时间锚点是在execute()调用之前记录开始时间然后在FutureCallback的completed或failed回调里记录结束时间。这两点之间的差值才是真正意义下的完整请求耗时它天然包含了连接池排队时间、连接建立时间、发送请求和接收响应的时间这个口径对业务方最有参考价值。3.2 FutureCallback埋点给一个可以直接抄的包装类比较省事的方式是写一个包装类把整个异步客户端的execute()包一层这样业务代码不用到处改埋点逻辑集中在一处。下面这个封装我实测下来没问题public class MonitoredHttpAsyncClient { private final CloseableHttpAsyncClient delegate; private final Timer requestTimer; private final Counter successCounter; private final Counter failureCounter; private final LongTaskTimer inFlightRequests; public MonitoredHttpAsyncClient(CloseableHttpAsyncClient delegate, MeterRegistry registry) { this.delegate delegate; this.requestTimer Timer.builder(http.async.request.duration) .description(HTTP async request duration) .publishPercentileHistogram(true) .register(registry); this.successCounter Counter.builder(http.async.request.success) .register(registry); this.failureCounter Counter.builder(http.async.request.failure) .register(registry); this.inFlightRequests LongTaskTimer.builder(http.async.request.inflight) .register(registry); } public FutureHttpResponse execute(HttpUriRequest request, FutureCallbackHttpResponse userCallback) { long start System.nanoTime(); long taskId inFlightRequests.start(); return delegate.execute(request, new FutureCallback() { Override public void completed(HttpResponse result) { requestTimer.record(System.nanoTime() - start, TimeUnit.NANOSECONDS); successCounter.increment(); inFlightRequests.stop(taskId); if (userCallback ! null) { userCallback.completed(result); } } Override public void failed(Exception ex) { requestTimer.record(System.nanoTime() - start, TimeUnit.NANOSECONDS); failureCounter.increment(); inFlightRequests.stop(taskId); if (userCallback ! null) { userCallback.failed(ex); } } Override public void cancelled() { inFlightRequests.stop(taskId); if (userCallback ! null) { userCallback.cancelled(); } } }); } }这个包装类里有几个细节值得说。其一失败也要记Timer否则异常请求的耗时在监控里是空洞故障复盘的时候就少了一半证据。其二cancel不计入成功也不算失败单独停掉inflight计数就行因为主动取消意味着业务已经放弃这次请求统计成失败会虚高错误率。其三userCallback链式调用要保留不然业务方自己的回调被覆盖整个封装就变成破坏性改动。3.3 用LongTaskTimer统计在途请求数为什么用LongTaskTimer而不是Gauge统计在途请求LongTaskTimer是专门为“当前正在执行的任务”设计的指标类型底层用LongAdder记录活跃任务数和持续时间并发环境下不会因为计数竞争数据歪掉。而且Prometheus里LongTaskTimer会同时暴露当前活跃任务数和累计执行秒数看板设计起来非常灵活。使用方式非常简单请求开始前拿到taskIdlong taskId inFlightRequests.start()请求结束时inFlightRequests.stop(taskId)。Prometheus抓下来会得到两个指标一个表示当前活跃请求数另一个表示这些请求累积的秒数。用后者除以活跃数再乘以1000就能算出当前平均耗时有时候比Timer还直观。3.4 拦截器方案能不能用有人在社区问HttpAsyncClient有同步场景类似的拦截器吗答案是有的4.x异步客户端也能添加HttpAsyncRequestInterceptor和HttpAsyncResponseInterceptor。理论上可以在请求拦截器里打开始时间戳在响应拦截器里记录耗时。但我的实际建议是默认用FutureCallback方案别优先上拦截器。原因有两个第一拦截器介入点不包含连接池排队阶段它只在协议处理层生效这样采集到的“请求耗时”会漏掉排队时间口径反而更混乱第二FutureCallback方案和业务语义天然贴近成功失败取消三种终态清晰不容易在复杂异常链下丢指标。拦截器方案适合后面接入重试、鉴权这类横切逻辑监控埋点不需要硬挤进去。4. 把指标交给Prometheus注册与HTTP暴露4.1 创建PrometheusMeterRegistry并统一管理接入Prometheus之前先创建一个PrometheusMeterRegistry实例。Micrometer允许多个registry并存如果项目里同时用StatsD、InfluxDB每个注册表各管各的Prometheus这边只需要自己的实例PrometheusConfig config new PrometheusConfig() { Override public Duration step() { return Duration.ofSeconds(30); } }; PrometheusMeterRegistry prometheusRegistry new PrometheusMeterRegistry(config); prometheusRegistry.config().commonTags(app, async-http-gateway);commonTags是个好习惯把应用名、环境、机房这种常量标签统一挂上去后面Grafana做多实例聚合直接group by就行。注意不要放高基数的常量进去标签是恒定值才有意义。所有指标都往这个registry里注册就好绑定器bindTo(prometheusRegistry)自己new出来的Timer、Counter也用它。这样最后scrape()出来的文本里连接池指标、请求耗时指标、在途指标都在同一份输出里。4.2 轻量HTTP服务暴露/metrics不用Spring Boot也能玩如果你的项目不是Spring Boot可以直接用JDK自带HttpServer开个小端口把Prometheus文本吐出去。写起来很短HttpServer server HttpServer.create(new InetSocketAddress(9090), 0); server.createContext(/metrics, exchange - { String body prometheusRegistry.scrape(); byte[] bytes body.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set(Content-Type, text/plain;version0.0.4;charsetutf-8); exchange.sendResponseHeaders(200, bytes.length); try (OutputStream os exchange.getResponseBody()) { os.write(bytes); } }); server.start();这段代码里Content-Type必须设置成text/plain;version0.0.4;charsetutf-8Prometheus抓取器认这个格式。如果返回普通text或者JSON抓取端会直接报错。另外scrape()每次调用都会实时汇总当前所有指标生成本次快照不用担心数据缺失。4.3 Spring Boot场景直接用Actuator如果服务本来就在Spring Boot里连上面的HttpServer都不用写。引入micrometer-registry-prometheus之后在application.yml里打开端点management: endpoints: web: exposure: include: prometheus然后访问/actuator/prometheus就是同样格式的指标文本。需要注意如果你使用的MicrometerRegistry是Spring自动装配的直接Autowired MeterRegistry再bindTo即可不要自己new一个注册表出来绑否则指标会跑到新注册表里actuator端点抓不到。这一条是典型的“配置看起来都对面板一空到底”的坑。4.4 Prometheus抓取配置与Grafana看板建议抓取配置只要加一个jobscrape_configs: - job_name: async-gateway metrics_path: /metrics static_configs: - targets: [127.0.0.1:9090]这里target写服务实际监听地址。抓下来之后Grafana面板推荐至少放四块当前活跃请求数LongTaskTimer活跃任务数、请求耗时P99/P90/P50Timer的_max和直方图、连接池leased/max两条线叠在同一个面板里、成功/失败QPSCounter增长率用rate函数计算。这四个面板基本覆盖了日常运维和故障定位的核心视角。5. 实战中踩过的坑与排查速查5.1 连接池指标全是0先看客户端用的是哪个连接管理器最常见的原因就是文章开头提到的客户端没有使用你绑定的连接管理器。HttpAsyncClients.custom()什么都不传的时候内部会默认new一个连接池这个连接池没有任何MeterBinder在管监控自然一片空白。另一个常见原因是用错了包把同步版的PoolingHttpClientConnectionManager传给异步客户端运行时报ClassCastException或者行为诡异。排查方法很简单在绑定连接管理器之后把connectionManager打印出来再对比HttpAsyncClient内部的连接管理器是否是同一个对象。5.2 耗时指标把排队时间也包含了这算不算问题这其实是口径问题。FutureCallback方案记录的耗时天然包含“请求进入连接池排队”到“响应回来”的全部时间。这个口径对业务层最真实用户感受到的延迟就是它。如果只想看IO层面的耗时应在HttpAsyncResponseInterceptor里记录结束时间如果想区分排队和IO就把FutureCallback里的总耗时减去拦截器里算出的IO耗时。建议接口看板用总耗时口径连接池专项分析再看拆分两个口径别混在一个面板里。5.3 标签基数爆炸请求路径不能直接做标签监控数组最容易翻车的地方就是把/api/user/123456这个完整路径直接当成uri标签。每个userId一个时间序列Prometheus不出一小时就能把你的存储撑爆。正确做法是在请求进来的时候做路径模板化把路径中的数字ID替换成占位符比如/api/user/{id}。这个模板才是合理的标签值。凡是可能跟随业务数据增长的字段都别进标签。真要查某个具体的用户去日志链路里查不要指望监控帮你按用户分开。5.4 连接管理器关闭之后Gauge变成NaNHttpAsyncClient.close()之后连接管理器内部状态已经失效之前注册的Gauge回调函数如果还去取连接池状态Prometheus抓下来会发现指标变成NaN。规范做法是关闭连接管理器前先把对应Meter从注册表移除registry.removeByPreFilterId(id - id.getName().startsWith(httpclient.pool));如果你用的是现成的MeterBinder可以保留MeterBinder实例在关闭前调用close()方法新版本绑定器支持Closeable。这一点在长生命周期服务里尤其重要连接池重建往往伴随指标残留和NaN排查起来非常费劲。5.5 常见问题速查表现象根因解决办法连接池指标一直为0客户端用的不是绑定的连接管理器显式创建连接管理器并set到客户端指标里只有连接数没有耗时没有杀请求级的埋点用FutureCallback或包装类加Timer失败请求的耗时未记录completed/failed回调里漏了Timerfailed分支也执行recordGrafana看板出现NaN连接池已关闭Gauge回调失效移除或注销对应Meter请求路径标签爆炸完整URL作为标签路径模板化成 /xxx/{id}耗时比预期偏大很多埋点起点在execute之前确认统计口径有排队和无排队两种收尾这套方案我在线上跑了几个月最直接的收益是两件事一是不用再靠猜判断连接池够不够leased和pending两个数字一摆扩容还是调参一目了然二是P99异常抖动时能马上从Grafana看出是连接池排队还是对端响应慢省掉大量排查争吵的时间。如果你正在给异步HTTP客户端搭监控我建议顺序是先接连接池四指标再加请求耗时和三色计数最后补在途请求数。埋点粒度宁粗勿细先把大局看清楚再谈精细化拆分。