
前阵子排查一个线上问题现象简单核心接口的P99从50ms涨到800ms依赖的下游服务网络抖动。我第一反应去翻OpenFeign监控面板结果只有请求量和耗时曲线没有失败率、没有重试次数连到底是连接超时还是读超时都看不出来。当时我就意识到一个问题——OpenFeign自带的Metrics能力只能告诉你“调用发生了”不能告诉你“调用过程里发生了什么”。这篇文章就是来补这块短板的。我会用OpenFeign官方提供的扩展机制做一套自定义Metrics把HTTP调用失败率、重试次数、业务请求最终成功率这些真正对排查有用的指标记下来。整个过程不动业务代码基于Micrometer注册到Prometheus这类监控系统。如果你正在用Spring Cloud OpenFeign又对调用链路的可观测性有要求这套方案可以直接抄作业。1. 先搞清楚OpenFeign默认的Metrics到底缺什么1.1 Feign有Metrics机制但默认统计粒度很粗很多人不知道Feign从早期版本就提供了Metrics相关的扩展点后来官方还专门出了feign-micrometer模块。接入方式很简单给Feign.Builder加一个CapabilityFeign.builder() .addCapability(new MicrometerCapability(meterRegistry)) .build();加上这行之后Micrometer会自动对Feign的Client等组件做增强在/actuator/prometheus里就会出现类似这样的指标feign_Client_http_requests_total{methodGET, uri/orders, http_status200} feign_Client_http_response_duration_seconds_bucket{methodGET, uri/orders}问题是这套默认指标有几个明显的边界它是按HTTP请求维度计数的一次业务调用如果内部重试了3次会产生4条独立的请求记录但你看不出来这是一次业务请求。它不统计重试次数。Feign的Retryer到底retry了几次、每次间隔多久全部不可见。它不区分失败类型。连接超时、读超时、DNS解析失败、5xx响应在默认指标里要么混在一起要么要到urihttp_status维度里去翻。它不知道业务层面的最终结果。下游返回了500但有ErrorDecoder兜底转成业务异常这次调用在HTTP层面是失败的在业务层面也是失败的但如果重试后第二次成功了业务层面其实是成功的。还有一个隐藏问题如果uri带了动态参数比如/orders/12345、/orders/67890直接拿默认uri做标签会把手里的监控搞出超高基数。所以不管从哪个角度看默认能力都只能作为兜底真要在生产环境用它判断服务健康度远远不够。1.2 业务指标清单先定义清楚再动手动手写代码之前我习惯先把指标定义清楚否则写着写着会发现“这个指标到底代表什么”自己都说不清。这次我最终圈定了六类指标指标名含义计算方式用途feign.http.callsHTTP调用次数每次底层HTTP执行1按status分类观察真实网络请求量feign.http.durationHTTP调用耗时每次底层HTTP执行计时判断下游耗时变化feign.retry.count重试次数每次Retryer触发重试1判断重试是否频繁feign.business.calls业务请求次数一次Feign方法调用1按result分类判断业务接口最终成败feign.business.failure.rate5分钟失败率滑动窗口内失败数/总数定位短时故障feign.business.retry.rate5分钟重试率滑动窗口内重试次数/调用数判断重试补偿是否有效这里最关键的是区分HTTP调用和业务请求。打个比方用户下了一单外卖相当于一次业务请求骑手从商家跑到用户家相当于一次HTTP调用。如果骑手途中爆胎平台重新派了一个骑手那这次业务请求过程中发生了两次HTTP调用。OpenFeign默认指标只知道两趟跑腿分别用了多长时间不知道用户那单最终有没有送到。所以在自定义Metrics里我会同时保留两个视角HTTP调用级用来观察底层网络质量业务请求级用来观察下游服务的真实可用性。2. 方案选型直接扩Feign还是套一层AOP2.1 三个候选方案对比在动手之前我对比过三种常见方案。第一个是AOP切面。切所有FeignClient接口的公共子类或者接口本身在方法前后记录耗时和异常。这个方案看起来最简单但实际上只能包在接口方法外层抓不到Feign内部的重试过程。你看到的异常是重试耗尽之后抛出来的那一个中间重试了几次、每次底层HTTP状态是什么AOP一无所知。第二个是RequestInterceptor。Feign的RequestInterceptor只会在发请求前执行能往Request里塞东西但没有后置回调。你想在响应回来之后记录状态码对不起Feign官方没有提供对称的“ResponseInterceptor”。有人会自己去装饰Request模板或者用AsyncResponseHandler那基本就是自己重新实现一遍Feign的处理链路得不偿失。第三个是Capability扩展。这是Feign官方留下的扩展口它的enrich系列方法可以同时包装Client、Retryer、InvocationHandlerFactory等核心组件。你可以在HTTP执行前后记录调用在Retryer触发时记录重试在方法调用成功或失败时记录业务结果。三条链路全都能抓到。这三个方案的区别用一个表格看更清楚对比项AOP切面RequestInterceptorCapability扩展实现成本低低中是否能拿重试次数否否能是否能区分HTTP/业务两层语义困难否能对Feign内部结构的侵入性无侵入低侵入官方扩展点是否影响业务代码无无无2.2 为什么我最终选了Capability原因有两个。第一Capability能覆盖Feign完整的调用生命周期。Feign整个执行链路无非是业务方法被InvocationHandler调用内部构造Request交给Client发HTTP请求如果抛了RetryableException就经过Retryer决定是否重来一次。Capability刚好可以把这三个关键位置的组件都包一遍所有我要的指标都能在对应位置拿到。第二它能天然兼容Spring Cloud OpenFeign的Bean装配机制。Spring Cloud OpenFeign提供了FeignBuilderCustomizer接口运行时会拿着它去定制每一个Feign.Builder。我只需要注册一个FeignBuilderCustomizer把自定义的Capability挂上去所有Feign客户端就自动生效了不用改任何业务Feign接口。2.3 指标存储设计谁写数据、谁算失败率指标数据直接打到Micrometer上。计数用Counter耗时用Timer这两个都是标准用法没什么好说的。真正需要设计的是失败率和重试率。这两个指标是比例值不是累计值。如果直接用“累计失败数 / 累计总数”去算会有一个问题上线早期请求量少、成功率高后面某段时间大量失败会被前面积累的总数稀释掉失败率的数字不够敏感。所以我用的是5分钟滑动窗口只统计最近5分钟内完成调用的样本。短时故障能明显看出来又不会因为窗口太小产生剧烈抖动。滑动窗口的核心代码不复杂我会在下一章给出完整实现。这里先说明一个原则Micrometer的Gauge是不能直接increment的它只是一个取值函数。所以我会用一个AtomicLong保存当前窗口内算出来的百分比值每次有业务调用完成时重新计算再把Gauge指向这个AtomicLong。这样Gauge永远返回最新的失败率。3. 动手实现自定义Metrics全流程3.1 第一步定义指标记录器FeignMetricsRecorder这个类是整个方案的“数据中枢”所有组件拿到调用结果之后都往这里面写。先看代码public class FeignMetricsRecorder { private final MeterRegistry registry; private final Duration window Duration.ofMinutes(5); private final DequeSample samples new ArrayDeque(); private final AtomicLong failureRateRef new AtomicLong(); private final AtomicLong retryRateRef new AtomicLong(); public FeignMetricsRecorder(MeterRegistry registry) { this.registry registry; Gauge.builder(feign.business.failure.rate, failureRateRef, AtomicLong::get) .description(最近5分钟失败率(%)) .register(registry); Gauge.builder(feign.business.retry.rate, retryRateRef, AtomicLong::get) .description(最近5分钟重试率(%)) .register(registry); } public void recordHttpCall(String client, Request request, int status, long costNanos, Throwable error) { Counter.builder(feign.http.calls) .tag(client, safeTag(client)) .tag(status, classify(status, error)) .register(registry) .increment(); Timer.builder(feign.http.duration) .tag(client, safeTag(client)) .register(registry) .record(costNanos, TimeUnit.NANOSECONDS); } public void recordRetry(String client, Request request) { Counter.builder(feign.retry.count) .tag(client, safeTag(client)) .register(registry) .increment(); } public synchronized void recordBusinessCall(String client, boolean success, int attempts, int retries, long costNanos) { long now System.currentTimeMillis(); samples.addLast(new Sample(now, success, retries)); while (!samples.isEmpty() samples.peekFirst().timestamp now - window.toMillis()) { samples.removeFirst(); } long total 0; long fail 0; long retrySum 0; for (Sample s : samples) { total; if (!s.success) { fail; } retrySum s.retries; } failureRateRef.set(total 0 ? 0 : fail * 100 / total); retryRateRef.set(total 0 ? 0 : retrySum * 100 / total); Counter.builder(feign.business.calls) .tag(client, safeTag(client)) .tag(result, success ? success : failure) .register(registry) .increment(); } private String classify(int status, Throwable error) { if (error ! null) { return exception; } if (status 200 status 300) { return 2xx; } if (status 300 status 400) { return 3xx; } if (status 400 status 500) { return 4xx; } return 5xx; } private String safeTag(String value) { return value null || value.isBlank() ? unknown : value; } private static class Sample { long timestamp; boolean success; int retries; Sample(long timestamp, boolean success, int retries) { this.timestamp timestamp; this.success success; this.retries retries; } } }这里我故意用了一个synchronized方法去操作ArrayDeque。因为ArrayDeque本身线程不安全而一次Feign调用几百毫秒才完成一次这个频率下加锁完全没压力。如果你的系统调用量极高每秒几千次Feign调用再考虑换成ConcurrentLinkedQueue加定时清理或者直接用Micrometer内部的TimeWindowCounter。先把正确性做出来再去纠结性能这个顺序不能反。另外注意recordHttpCall里每来一条就register一次。生产环境建议预先缓存Meter避免反复创建同一个指标对象。这里面Micrometer本身有缓存机制开销不大但为了严谨还是提一句。3.2 第二步创建线程级上下文FeignCallContext一次业务请求内部可能发生多次HTTP调用和多次重试怎么把这两者关联到同一个业务请求上我的方案是用ThreadLocal保存一个上下文对象。final class FeignCallContext { private static final ThreadLocalFeignCallContext HOLDER new ThreadLocal(); final String client; int attempts 0; int retries 0; boolean finalSuccess false; private FeignCallContext(String client) { this.client client; } static FeignCallContext begin(String client) { FeignCallContext ctx new FeignCallContext(client); HOLDER.set(ctx); return ctx; } static FeignCallContext current() { return HOLDER.get(); } void clear() { HOLDER.remove(); } }为什么用ThreadLocal因为Feign同步调用整个过程发生在同一个线程里。第一次HTTP调用失败抛异常随后Retryer决定重试下一次HTTP调用还在同一个线程里执行。所以我在MetricsMethodHandler里begin之后MetricsClient和MetricsRetryer都能通过FeignCallContext.current()取到同一个上下文累加各自的计数。等业务方法最终返回或者抛出异常在finally里clear避免线程池复用导致上下文串台。这里有一个必须强调的点务必在finally里清理ThreadLocal。很多线上偶发问题都是ThreadLocal没清干净引起的。因为Tomcat等Web容器的工作线程是复用的上一次请求留下的上下文可能被下一次请求读到指标就会张冠李戴。3.3 第三步包装Client记录每次真实HTTP调用Client是Feign真正发起HTTP请求的地方。我写一个MetricsClient把原来的Client包一层public class MetricsClient implements Client { private final Client delegate; private final FeignMetricsRecorder recorder; public MetricsClient(Client delegate, FeignMetricsRecorder recorder) { this.delegate delegate; this.recorder recorder; } Override public Response execute(Request request, Request.Options options) throws IOException { FeignCallContext ctx FeignCallContext.current(); String client ctx ! null ? ctx.client : resolveClient(request); long start System.nanoTime(); try { Response response delegate.execute(request, options); if (ctx ! null) { ctx.attempts; } recorder.recordHttpCall(client, request, response.status(), System.nanoTime() - start, null); return response; } catch (IOException e) { if (ctx ! null) { ctx.attempts; } recorder.recordHttpCall(client, request, 0, System.nanoTime() - start, e); throw e; } } private String resolveClient(Request request) { try { return URI.create(request.url()).getHost(); } catch (Exception ex) { return unknown; } } }这里有几个细节要说明。第一HTTP调用失败包括两部分异常和5xx。IOException网络异常走catch分支状态码为0分类为exceptionHTTP 500返回时Feign的默认Decoder会抛FeignException但它发生在我这个方法返回之后所以MetricsClient这里记录的是HTTP层面的调用结果状态码是500分类为5xx。两种失败最后会反映到不同维度方便排查。第二尝试次数累加放在真正执行之后。我这里的ctx.attempts无论成功失败都会执行。因为一次HTTP执行无论结果如何都算一次尝试。这个数最终会和重试次数对得上attempts 1 retries。第三client标签的兜底。如果ThreadLocal里拿不到上下文比如某些场景没有经过MethodHandler包装就尝试从URL的host里解析服务名。在负载均衡场景下Feign的URL里通常就是服务名或注册中心名字拿到的host很有辨识度。3.4 第四步包装Retryer把重试次数抓出来Retryer负责决定要不要重试。包装它就能在每次重试决策发生的时候记录一次重试public class MetricsRetryer implements Retryer { private final Retryer delegate; private final FeignMetricsRecorder recorder; public MetricsRetryer(Retryer delegate, FeignMetricsRecorder recorder) { this.delegate delegate; this.recorder recorder; } Override public void continueOrPropagate(RetryableException e) { FeignCallContext ctx FeignCallContext.current(); if (ctx ! null) { ctx.retries; } String client ctx ! null ? ctx.client : resolveClient(e.request()); recorder.recordRetry(client, e.request()); delegate.continueOrPropagate(e); } Override public Retryer clone() { return new MetricsRetryer(delegate.clone(), recorder); } }这里有一个特别容易踩的坑Retryer的clone方法必须一起包装。Feign在处理每次请求时会先retryer.clone()出一个独立的实例用来跟踪本次请求的重试状态。如果我只包装了continueOrPropagate而不处理clone那么clone出来的新实例是未包装的我的recordRetry根本不会被调用重试次数永远是0。类似的问题还会出现在其他需要clone的组件上。所以凡是涉及到Feign里“每个请求独立状态”的组件包装时必须考虑clone路径。另外我遇到过一种情况项目引入了spring-retry用的不是Feign原生Retryer而是Spring Retry的RetryTemplate。这种情况下重试决策发生在别的地方包装Feign原生Retryer可能抓不到。如果你用的也是这种组合要在RetryTemplate层面做类似的处理或者把Retryer.Default显示配置出来。最简单的排查办法先把重试策略显式设成new Retryer.Default(100L, 1000L, 3)确认自定义Metrics能看到重试次数之后再考虑要不要换成Spring Retry。3.5 第五步包装MethodHandler统计业务级成败前面几层记录的都还是HTTP调用级数据现在需要回到业务方法入口统计“一次Feign方法调用最终到底成没成功”。Feign的方法是经过InvocationHandler分发给MethodHandler处理的。我包装一下MethodHandlerpublic class MetricsMethodHandler implements MethodHandler { private final MethodHandler delegate; private final FeignMetricsRecorder recorder; private final String client; public MetricsMethodHandler(MethodHandler delegate, FeignMetricsRecorder recorder, String client) { this.delegate delegate; this.recorder recorder; this.client client; } Override public Object invoke(Object[] argv) throws Throwable { FeignCallContext ctx FeignCallContext.begin(client); long start System.nanoTime(); try { Object result delegate.invoke(argv); ctx.finalSuccess true; recorder.recordBusinessCall(client, true, ctx.attempts, ctx.retries, System.nanoTime() - start); return result; } catch (Throwable t) { recorder.recordBusinessCall(client, false, ctx.attempts, ctx.retries, System.nanoTime() - start); throw t; } finally { ctx.clear(); } } }有了这层包装就能把FeignCallContext里累计的attempts和retries在业务方法结束时取出来连同最终成功失败状态一起写进记录器。比如某次调用第一次返回500触发重试第二次成功那么attempts2、retries1、finalSuccesstrue。这样重试次数高但业务成功率也高的情况说明重试机制在有效兜底只有当重试次数高且业务成功率也低的时候才能确认下游真的出问题了。接下来是InvocationHandlerFactory的包装。Feign默认用InvocationHandler.Default来创建动态代理InvocationHandlerFactory就是创建它的地方public class MetricsInvocationHandlerFactory implements InvocationHandlerFactory { private final InvocationHandlerFactory delegate; private final FeignMetricsRecorder recorder; public MetricsInvocationHandlerFactory(InvocationHandlerFactory delegate, FeignMetricsRecorder recorder) { this.delegate delegate; this.recorder recorder; } Override public InvocationHandler create(Target target, MapMethod, MethodHandler dispatch) { String client target.name(); MapMethod, MethodHandler wrapped new HashMap(); dispatch.forEach((method, handler) - wrapped.put(method, new MetricsMethodHandler( handler, recorder, client))); return delegate.create(target, wrapped); } }为什么用target.name()作为client标签在Spring Cloud OpenFeign里Target的name就是FeignClient注解里配的服务名。用它做标签监控面板上天然就能按服务名拆分。3.6 第六步通过Capability串起来并接入Spring Boot把上面几层用Capability封装到一起public class MetricsCapability implements Capability { private final FeignMetricsRecorder recorder; public MetricsCapability(FeignMetricsRecorder recorder) { this.recorder recorder; } Override public Client enrich(Client client) { return new MetricsClient(client, recorder); } Override public Retryer enrich(Retryer retryer) { if (retryer null || retryer Retryer.NEVER_RETRY) { return retryer; } return new MetricsRetryer(retryer, recorder); } Override public InvocationHandlerFactory enrich(InvocationHandlerFactory factory) { return new MetricsInvocationHandlerFactory(factory, recorder); } }注意enrich(Retryer)里那个判断如果当前Retryer是NEVER_RETRY说明本来就不重试我直接返回原对象避免无意义的包装。最后在Spring Boot里把它注册成全局配置Configuration public class FeignMetricsAutoConfiguration { Bean public FeignMetricsRecorder feignMetricsRecorder(MeterRegistry meterRegistry) { return new FeignMetricsRecorder(meterRegistry); } Bean public FeignBuilderCustomizer feignMetricsCustomizer( FeignMetricsRecorder recorder) { return builder - builder.addCapability(new MetricsCapability(recorder)); } }FeignBuilderCustomizer是Spring Cloud OpenFeign提供的定制口所有Feign.Builder被创建的时候都会应用这个定制器。还需要在配置里暴露Prometheus端点management: endpoints: web: exposure: include: health,metrics,prometheus如果你的应用里还没有micrometer-registry-prometheus依赖记得加进去。启动之后访问/actuator/prometheus搜索feign前缀的指标就能看到这一套数据了。在Prometheus里可以直接写查询了典型的有# 按服务看5分钟HTTP 5xx速率 sum(rate(feign_http_calls_total{status5xx}[5m])) by (client) # 按服务看重试频率 sum(rate(feign_retry_count_total[5m])) by (client) # 看服务最近5分钟业务失败率 feign_business_failure_rate{clientorder-service}注意Micrometer在Prometheus命名规则下feign.http.calls会变成feign_http_calls_totalfeign.retry.count会变成feign_retry_count_totalGauge的feign.business.failure.rate会保持feign_business_failure_rate。写查询前先在/actuator/prometheus页面搜一下确认名称。4. 藏在细节里的坑标签粒度、配置上下文、并发清理4.1 按服务拆tag别做全站一个计数器如果不用client标签区分服务整个微服务集群的Feign指标全打在一个计数器上出了问题你只能知道总失败率高了不知道是哪个下游服务拖了后腿。这个意识一定要有。我的代码里面有两个地方可以拿client名一个是在MetricsMethodHandler里拿到Target.name()这个最准确另一个是在MetricsClient里从request.url()解析host这个是兜底方案。实际运行中以Target.name()为准因为Spring Cloud OpenFeign的FeignClient(nameorder-service)里配置得非常明确。4.2 FeignContext子上下文与Bean作用范围这是一个很隐蔽的坑我经历过不止一次。FeignClient(namexxx, configuration XxxConfig.class)里的XxxConfig是这个Feign客户端私有的子上下文。你在里面的Bean只会对当前这个Feign客户端生效。如果你还有一个全局的Configuration里面的FeignBuilderCustomizer会被所有Feign客户端共享因为FeignContext以主ApplicationContext作为父上下文会从父级找到这个Bean。所以当你发现“自定义的FeignBuilderCustomizer为什么只对部分client生效”时先检查是不是把它放到了某个FeignClient的configuration里。要全局生效最简单的方式是放到启动类所在的包下面作为主上下文里的普通Configuration。同理如果你在某个FeignClient的configuration里注入了主上下文的Bean有时会因为子上下文还没准备好而拿不到。建议用ObjectProvider或者Lazy延迟获取。4.3 并发采样队列别在热路径上做傻事我上面的recordBusinessCall用的是synchronizedArrayDeque简单、正确、不易错。但要清楚它的适用边界单机每秒几十次Feign调用完全没问题如果每秒几千次再加滑动窗口遍历锁竞争会变成热点。这种情况下建议换思路用ConcurrentLinkedQueue配合ScheduledExecutorService每10秒清理一次过期样本。或者直接用Micrometer自带的TimeWindowCounter它内部就是时间窗口不用自己维护队列和锁。不过大多数业务系统的Feign调用频率到不了那个量级。先把指标的正确性和可读性做出来比一味追求高性能重要得多。5. 常见问题与排查技巧实录5.1 指标一直是0可能的原因我从排错顺序上整理一个清单Prometheus端点没暴露。先访问/actuator/prometheus看里面有没有feign开头的指标。没有的话检查management.endpoints.web.exposure.include是否配置了prometheus。Feign根本没有走这个Builder。如果某些Feign Client是通过Feign.builder()手动构建的没有经过FeignBuilderCustomizer那自然没有指标。Spring Cloud环境里一般都会走但排查时可以打个断点确认MetricsCapability是否被装配。标签名拼写不一致。Prometheus里指标名带不带_total后缀Counter和Gauge的命名规范不一样。在/actuator/prometheus里搜feign确认一下实际生成的指标名再写查询。指标被丢弃了。某些监控系统中如果指标没有任何样本会延迟显示。稍等一会儿再刷新。5.2 重试次数统计不到这个问题的出现频率很高原因通常有这几个Retryer本身就是NEVER_RETRY。Feign默认不重试如果你没有配置任何Retryer重试次数当然永远是0。解决办法是显式配置一个比如在配置类里提供Bean public Retryer feignRetryer() { return new Retryer.Default(100L, 1000L, 3); }clone方法没包装。前面已经强调过MetricsRetryer.clone()必须返回包装后的实例否则每个请求clone出去的新Retryer都不带统计逻辑。用的不是Feign原生Retryer。如果项目里用Spring Retry需要去包装RetryTemplate或者把重试策略迁回Feign原生Retryer。有一点值得留意重试次数不等于失败的HTTP调用次数。一次业务请求如果重试了2次feign.retry.count会增加2feign.http.calls也会多2条调用记录。这两个指标是在不同位置统计的可以互相印证。5.3 失败率数值飘忽不定失败率用的是5分钟滑动窗口。窗口越小对瞬时波动越敏感但也越容易在流量低的时候出现大起大落。窗口越大越能平滑噪声但会掩盖短时故障。我踩过的坑是刚开始把窗口设成1分钟结果每次发布或者下游重启时失败率在几分钟内剧烈跳动报警频繁触发而且不好定位。后来改成5分钟再配合Prometheus的rate()函数看短时趋势效果稳定很多。如果你需要两类视角可以保留一个5分钟失败率Gauge用于告警再用PromQL的sum(rate(feign_http_calls_total{status5xx}[1m]))看1分钟短时波动。一慢一快互为补充。5.4 重试和失败率容易被误读还有一种情况容易看走眼监控面板上重试次数变高业务失败率也跟着变高第一反应是“下游挂了”。其实这两者同时变高只说明重试没有挽救业务请求服务确实不可用如果重试次数高但业务失败率很低反而是好事说明重试机制在正常工作下游的短暂错误被自动消化了。所以告警规则不能只盯重试次数要同时看“重试率”和“业务失败率”。比如可以设两条规则告警条件含义5分钟业务失败率 10%持续5分钟业务可用性明显下降5分钟重试率 20%且业务失败率 5%重试无法兜底下游大概率出问题6. 实操后的几点体会这套自定义Metrics方案做完之后我自己最大的感受是先量后治理很多问题其实早就存在只是没被量化出来。之前某次线上排查我一直以为是下游抖动导致超时后来看到重试率数据才发现Feign的默认Retryer根本没生效超时之后直接抛异常根本没有重试环节。没有指标的时候我们只能靠猜有了指标至少在错误的链条上能往前推一步。其次是建议不要在一个类里堆太多逻辑。这次我刻意把“记录指标”和“包装Feign组件”拆开FeignMetricsRecorder只负责数据写入MetricsClient、MetricsRetryer、MetricsMethodHandler各管一段。后面想加一个“按接口维度统计耗时”的需求只需要在包装层多传一个方法名进去完全不用动数据层。最后再分享一个小技巧上线这套Metrics之后一定要先看几天真实数据再决定告警阈值。很多团队拿到指标第一件事就是配告警结果频繁误报最后把告警关了反而没人看数据。我的习惯是先让指标在面板上裸奔一周记录一下正常波动范围再根据P95或者P99设定阈值这样报警才有意义。