BlockingQueue:异步批量上报
作者:苏渡苇
项目地址 :github.com/iweidujiang...(感谢 Star !)
本文要干什么
前几篇把 Span 采出来了 :进站拦截器、出站 Feign 马甲,结束时都会 reportSpan。
下一秒数据要离开业务进程,POST 到 insight-server。如果在 Tomcat 工作线程里当场发 HTTP:
- 监测 Server 慢一拍,用户接口跟着慢
- Server 挂了,业务线程被拖进超时、甚至被异常打脸
- 每个请求打一次 HTTP,QPS 一高,监测比业务还忙
监测工具的铁律:采点可以发生在请求线程上,送走不行。
所以这篇的知识点是 BlockingQueue。Insight 用它做一层缓冲:请求线程只负责往队列里丢,后台线程再攒一批送走。
请求线程里别打 HTTP。Span 进队列,守护线程按批上报;队列满了就丢,失败只打日志,绝不拖垮业务。
一、先看这条链路

请求线程的活,到 offer 为止。后面那截慢的、会失败的,都在名叫 spring-insight-reporter 的后台线程里。
一些参数目前是写死在代码里的:
代码在此:
io.github.iweidujiang.springinsight.agent.collector.AsyncSpanReporter
java
// 配置常量
private static final int DEFAULT_QUEUE_CAPACITY = 10000;
private static final int DEFAULT_BATCH_SIZE = 200;
private static final long DEFAULT_FLUSH_INTERVAL_MS = 5000; // 5秒
private static final long DEFAULT_OFFER_TIMEOUT_MS = 100;
// 队列与状态控制
private final BlockingQueue<Object> metricsQueue;
private final AtomicBoolean running = new AtomicBoolean(false);
private Thread flushThread;
// 服务标识
private final String serviceName;
private final String serviceInstance;
/**
* 延迟解析,避免与 Starter 中 BatchSink Bean 的初始化顺序竞态
*/
private final ObjectProvider<InsightBatchSink> batchSinkProvider;
// 统计信息
private final ReporterMetrics metrics = new ReporterMetrics();
| 参数 | 值 | 意思 |
|---|---|---|
| 队列容量 | 10000 | 最多囤这么多条,再多丢 |
| 入队等待 | 100ms | 满了先等一丁点,还进不去就放弃 |
| 批量大小 | 200 | 一次最多送 200 条 |
| 刷盘间隔 | 5s | 不够 200 也别让数据在队列里过夜 |
二、请求线程只做一件事:往队列里扔
拦截器、Feign 装饰器调用的都是 SpanReportingListener.reportSpan,里面立刻转到:
java
public boolean report(Object metric) {
if (!running.get()) {
metrics.incrementDropped();
return false;
}
boolean offered = metricsQueue.offer(metric, 100, TimeUnit.MILLISECONDS);
if (!offered) {
// 队列满:丢弃,不阻塞请求线程
metrics.incrementDropped();
return false;
}
return true;
}
几个选择都是故意的:
用 offer,不用 put。
put 会在队列满时把调用线程卡住,直到有空位。监测队列一堵,下单接口就堵,这锅背不起。offer(..., 100ms):给后台一个极短的喘息,还进不去就丢。丢比卡好。
有界队列 LinkedBlockingQueue(10000)。
无界队列在 Server 挂掉时会把 Span 堆到把堆内存吃光。有界 + 丢弃 = 背压。监测挂了,业务还在,只是控制台暂时少几条。
上报器没 start,直接丢。
启动中、关闭中,热路径上不要等。
所以 reportSpan 对业务几乎是「把对象塞进内存队列」。真正的网络,跟这次 HTTP 请求已经脱钩了。

三、后台线程:等到一批,或等到超时
构造时会 start() 一条 守护线程 (setDaemon(true))。进程要退了,它挡不住 JVM 退出;正常停机另有关闭钩子去 stop(),把队列里剩下的冲一把。
循环长这样:
java
while (running.get()) {
Object first = metricsQueue.poll(5000, TimeUnit.MILLISECONDS);
if (first != null) {
// 已经等到第一条,再非阻塞捞一批,最多凑到 200
metricsQueue.drainTo(remaining, 199);
flushTraceSpans(traceBatch);
}
}
这是很常见的「时间 + 数量」双条件:
- 5 秒内一条都没有:
poll超时,空转一圈(闲时几乎不打 HTTP) - 来了一条:立刻
drainTo把队列里现成的一并拿走,最多 200 - 流量暴:队列很快到 200,一批一批送,不会等到 5 秒
poll 等第一条,drainTo 捞后面的------比自己 take 一条循环 200 次要干净,也避免「只有 3 条还干等到凑满 200」。
刷盘时给 Span 补上 serviceName / serviceInstance(拦截器里不一定填了),再交给 InsightBatchSink。Sink 用 ObjectProvider 延迟取,跟第 7 篇同一个理由:上报器和 Sink 谁先创建不一定,热路径上再拿。

四、失败怎么处理:只打日志,继续跑
Sink 里发 HTTP 失败:不向调用方抛出,以免中断刷盘循环。
上报循环自己也套了大 try/catch:单次异常睡 1 秒,接着跑,监测 Server 重启那半分钟,业务进程不该跟着一起歇菜。
队列统计在 ReporterMetrics 里:接收、成功、失败、丢弃。
丢弃多了说明要么 Server 太慢、要么容量太小,该看的是监测侧,不是让调用接口超时。
关闭时:
java
flushThread.interrupt();
flushThread.join(3000);
flushRemainingSpans(); // drainTo 剩余的再送一次
JVM 关闭钩子会调 stop(),守护线程保证「钩子没赶上」时至少不会把进程卡死。
极端情况:杀 -9、钩子没跑完,队列里那批就没了。
内存监测都这样,别当消息队列用。
五、自己做「旁路上报」时可以记住的
- 热路径只碰内存。 日志、指标、链路,能
offer进有界队列就不要在请求线程里httpClient.send。 - 有界 + 丢弃。 无限堆积会先把你自己的服务 OOM。丢监测数据,保业务。
- 批量要有超时。 只按条数攒,低流量时数据会在队列里发霉;只按时间刷,高峰又太碎。
poll(超时)+drainTo(批量)够用。 - 后台失败不要冒泡。 监测挂了,用户不该看到 500。
- 线程设成 daemon,停机再补一刀 flush。 两个都要:平时别挡退出,平时停机尽量把尾巴送走。
六、小结
-
不是 Span 已结束就 POST 出去,而是 先入队,后台再发
-
不是队列满了卡住等空位,而是 队列满了直接丢掉,不堵请求线程
采点发生在业务线程,送走发生在 spring-insight-reporter,中间那条 BlockingQueue,就是监测工具和业务 QPS 之间的缓冲垫。
下一篇内容准备展示一下缓冲垫后面那一步:上报为啥用 JDK HttpClient,而不是 RestTemplate,Gateway 上没有 MVC,Starter 不能强绑 starter-web。
🌟 最后:欢迎围观 Spring Insight
如果你对「轻量监测 / Spring Boot Starter / 链路埋点」感兴趣,欢迎看看这个还在打磨的小项目:
🔗 GitHub :github.com/iweidujiang...
当前形态很朴素:业务服务加 spring-insight-agent-starter,旁边单独跑 insight-server 看拓扑和链路。能力有限,代码也还糙,适合当练手和对照。
你的 Star 是对我最大的鼓励。