BlockingQueue:异步批量上报

0 阅读5分钟

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

// 配置常量
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,里面立刻转到:

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(),把队列里剩下的冲一把。

循环长这样:

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 太慢、要么容量太小,该看的是监测侧,不是让调用接口超时。

关闭时:

flushThread.interrupt();
flushThread.join(3000);
flushRemainingSpans();  // drainTo 剩余的再送一次

JVM 关闭钩子会调 stop(),守护线程保证「钩子没赶上」时至少不会把进程卡死。

极端情况:杀 -9、钩子没跑完,队列里那批就没了。

内存监测都这样,别当消息队列用。

五、自己做「旁路上报」时可以记住的

  1. 热路径只碰内存。 日志、指标、链路,能 offer 进有界队列就不要在请求线程里 httpClient.send
  2. 有界 + 丢弃。 无限堆积会先把你自己的服务 OOM。丢监测数据,保业务。
  3. 批量要有超时。 只按条数攒,低流量时数据会在队列里发霉;只按时间刷,高峰又太碎。poll(超时) + drainTo(批量) 够用。
  4. 后台失败不要冒泡。 监测挂了,用户不该看到 500。
  5. 线程设成 daemon,停机再补一刀 flush。 两个都要:平时别挡退出,平时停机尽量把尾巴送走。

六、小结

  • 不是 Span 已结束就 POST 出去,而是 先入队,后台再发

  • 不是队列满了卡住等空位,而是 队列满了直接丢掉,不堵请求线程

采点发生在业务线程,送走发生在 spring-insight-reporter,中间那条 BlockingQueue,就是监测工具和业务 QPS 之间的缓冲垫。

下一篇内容准备展示一下缓冲垫后面那一步:上报为啥用 JDK HttpClient,而不是 RestTemplate,Gateway 上没有 MVC,Starter 不能强绑 starter-web

🌟 最后:欢迎围观 Spring Insight

如果你对「轻量监测 / Spring Boot Starter / 链路埋点」感兴趣,欢迎看看这个还在打磨的小项目:

🔗 GitHubgithub.com/iweidujiang…

当前形态很朴素:业务服务加 spring-insight-agent-starter,旁边单独跑 insight-server 看拓扑和链路。能力有限,代码也还糙,适合当练手和对照。

你的 Star 是对我最大的鼓励。