首页 / 资讯中心 / 文章详情

HttpAsyncClient集成Micrometer与Prometheus:异步客户端可观测性实践

HttpAsyncClient集成Micrometer与Prometheus:异步客户端可观测性实践 ★ FEATURED ARTICLE
生产环境里大部分偶发超时其实都经不起细看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 格式的 Registry2.1 版本选型别再被 HttpAsyncClient 4.x 的老配置带偏网上搜索 HttpAsyncClient 的资料很容易翻到 httpasyncclient 4.x 时代的代码类名、包名跟现在的 httpclient5 完全是两个世界。我当前用的版本组合是 Java 17、Spring Boot 3.x、Apache HttpClient 5.4、Micrometer 1.13这套组合在大多数项目里都能正常工作。如果你使用 Maven核心依赖如下dependency groupIdio.micrometer/groupId artifactIdmicrometer-registry-prometheus/artifactId version1.13.6/version /dependency dependency groupIdorg.apache.httpcomponents.client5/groupId artifactIdhttpclient5/artifactId version5.4/version /dependencySpring 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 { FutureHttpResponse future client.execute(request, callback, context); HttpResponse response future.get(10, TimeUnit.SECONDS); sample.stop(timer); } catch (Exception ex) { // 注意不要在预创建的 timer 里写死 outcome 后强行 stop }但这里有个典型设计错误如果你在初始化 Timer 时就把 tagoutcomesuccess写死那么失败请求来了没法复用同一个 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; version0.0.4; charsetutf-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{outcomesuccess,targetapi} 23 http_async_client_request_seconds_max{outcomesuccess,targetapi} 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 基数只放有限集合的维度比如targetapi、targetcore、regioncn-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 在那一小时悄悄爬到了两位数顺着这个指标往下查半小时就把问题实例找了出来。这种经历多了之后你会觉得集成这层指标不是可选项而是异步客户端的标配。
阅读完成 · 觉得有帮助?
咨询建站