Spring Insight 里上报 Span 为什么用 JDK HttpClient 而不是 RestTemplate
作者:苏渡苇
项目地址 :https://github.com/iweidujiang/spring-insight(感谢 Star !)
本文要干什么
上一篇里,业务线程把做好的 Span 放到队列里就返回了,后台线程从队列取出一批数据之后,还要把它们发给 insight-server,走的是
POST /api/v1/spans/batch
很多 Spring 项目一说到发 HTTP 就会用 RestTemplate,Boot 3 里也可能用 RestClient 或 WebClient。
Insight 没有走这些,用的是 JDK 自带的 java.net.http.HttpClient。
这篇要讲的就是这个选择。不是 RestTemplate 不好,而是 Agent 这个库比较特殊:它既要进普通 MVC 服务,也要进 Gateway。如果为了上报再引入一套 Spring Web 依赖,Gateway 上就可能出现依赖冲突,甚至服务启动失败。
读完可以明白两件事:上报为什么钉在 JDK
HttpClient上,以及它和上一篇那条后台线程是怎么配合的。
一、先把问题说清楚
业务侧依赖的是 spring-insight-agent-starter,Starter 再依赖 insight-agent,真正发请求的代码在 insight-agent 的 HttpInsightBatchSink 里。这个模块是采集核心,不能默认宿主一定是 Spring MVC。
前面几篇其实已经把约束摆出来了:
Gateway 走 WebFlux,classpath 上常常没有 spring-webmvc;
Agent 里和 MVC 相关的依赖是 provided,不会跟着传到别人项目里;
同一份 Starter 既要在 order 这种服务上能用,也要在 Gateway 上能用。
如果 HttpInsightBatchSink 里直接写 RestTemplate,insight-agent 编译时就得依赖 spring-web,这个依赖还会顺着 Starter 进到宿主应用。前面刚用 DeferredImportSelector 让 Gateway 躲开 MVC,上报这里又把 Web 相关的包带回去,等于前面的分叉白做了。
WebClient 也有类似问题,会把 WebFlux 带进本来只用 MVC 的服务里。监测工具只是发几个 POST,却把宿主的 Web 形态搅乱,这个成本不值。

二、JDK 里本来就有客户端
从 Java 11 开始,标准库里就有 java.net.http.HttpClient。Insight 要求的是 JDK 21,用它不用再加依赖。
HttpInsightBatchSink 在构造时就会把客户端和目标地址准备好:
java
this.spansBatchUrl = base + "/api/v1/spans/batch";
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
base 来自配置项 spring.insight.server-url。没有配这个项时,上面的 @ConditionalOnProperty 会让这个 Bean 根本不创建;配了却是空字符串,启动阶段就会失败,避免应用跑起来了才发现数据没处可去。
真正发送时也很直接:先用 Jackson 把这一批 Span 写成 JSON,再同步 POST 出去。
java
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(spansBatchUrl))
.timeout(Duration.ofSeconds(5))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
这里容易误会的一点是:send 是同步阻塞的。
上一篇强调过不要在处理用户请求的线程里发 HTTP,所以这个 send 只能放在 spring-insight-reporter 这条后台线程上。
业务线程在 offer 进队列之后就已经回去处理请求了,阻塞发生在监测自己的线程里,最坏也只是上报变慢、队列变长,用户接口不该跟着等。
连接超时和请求超时都设成了 5 秒。监测 Server 如果卡住,这条后台线程最多停 5 秒,然后走失败分支,不会一直等下去。
三、失败了怎么办
发送过程中的异常都被接住了,只打 warn,不会抛回给调用方:
java
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("[HTTP上报] 被中断: size={}", spans.size());
} catch (Exception e) {
log.warn("[HTTP上报] Span 批次异常: size={}, error={}", spans.size(), e.getMessage());
}
HTTP 状态码不是 2xx 时也只打 warn,响应体截到大约 200 个字符,避免错误页把日志刷满。
我的原则就是:监测失败不能影响到业务,后台循环还要继续 poll,不能因为这一批 POST 失败就把上报线程停掉。
另外还有两件配套的小事:
-
每批数据会带一个随机
batchId,方便两边日志对上号,服务端目前没有按这个 ID 做去重,它更多是给人看的。 -
JVM 指标也会进队列,但 Sink 里只打了 debug,因为 Server 还没有对应接口,不要误以为指标已经存下来了。

四、和 RestTemplate 的区别
可以对照着看:
| RestTemplate / WebClient | JDK HttpClient | |
|---|---|---|
| 额外依赖 | spring-web 或 spring-webflux |
无,JDK 自带 |
| 对 Gateway 的影响 | 容易把不该出现的 Web 栈带进 classpath | 没有这个问题 |
| 和 Spring 的集成 | 有连接池、消息转换器、拦截器 | 自己拼 URL、自己设超时 |
| Insight 里够不够用 | 对监测上报来说偏重 | POST 一段 JSON 就够 |
Insight 并不需要 RestTemplate 那一套拦截器和错误处理。
上报要做的事情很窄:把 JSON 发过去,2xx 算成功,其余记日志,用标准库更干净。
以后如果要调连接池或 HTTP/2,HttpClient 也可以再配,现在这一步还没做,先把通道打通。
五、自己写 Agent 或上报端时可以用到的
- 先问这段代码会进哪种宿主。 库要同时给 MVC 和 Gateway 用,就不要在核心模块里编译依赖
RestTemplate。 - 能用 JDK 完成的就用 JDK。 Java 11 以后发一个 HTTP 请求,不必再拉一个客户端框架。
- 同步
send可以用,但线程要选对。 放在后台上报线程里,和有界队列一起用,不要放回处理用户请求的线程。 - 超时一定要设。 没有超时的同步调用,等于把监测 Server 的故障变成自己后台线程一直等。
- 失败只打日志。 这和队列满了就丢弃是同一类取舍:可以丢掉监测数据,不要拖垮业务进程。
六、小结
- Spring 项目里发 HTTP 并不等于必须用
RestTemplate。Agent 作为公共库,更在意不要把宿主的 Web 栈绑死。 HttpClient.send是同步的,并不等于不能用,只要把它放在后台上报线程上,而不是处理用户请求的线程上。- 上报失败时,当前实现是超时、打日志、丢掉这一批,然后继续循环,并没有重试到成功为止。
- Starter 里用什么客户端不是小事,依赖会传递到宿主,选错了 Gateway 上就可能启动失败。
BlockingQueue 解决的是不要在处理用户请求的线程里发数据,这篇的 HttpClient 解决的是发出去的时候不要把 Spring MVC 绑进来,两条加在一起,才是现在这条上报通道。
下一篇预告:会看到数据已经到了 Server,Server 端目前没用数据库,Span 先放在内存里,数量到了上限就把旧的挤掉,我们再看这个内存缓冲是怎么把控制台撑起来的。
最后:欢迎围观 Spring Insight
如果你对轻量监测、Spring Boot Starter、链路埋点感兴趣,欢迎看看这个还在打磨的小项目:
当前形态很朴素:业务服务加 spring-insight-agent-starter,旁边单独跑 insight-server 看拓扑和链路。能力有限,代码也还糙,适合当练手和对照。
你的 Star 是对我最大的鼓励。