Spring Insight 里收到的 Span 是怎么存下来的
作者:苏渡苇
项目地址:github.com/iweidujiang…(感谢 Star !)
本文要讲什么
上一篇里,后台线程用 JDK HttpClient 把一批 Span POST 到了 insight-server,走的是
POST /api/v1/spans/batch
数据到了 Server,按很多监测系统的习惯,下一步就该进数据库,Insight 现在没有这一步。
收集服务校验、补全之后,会交给 TraceSpanPersistenceService,默认是进程里的一份列表;不想重启清空,再开一个 JSON 文件落盘。
控制台查拓扑、查链路,查的都是这份内存,文件只是可选的备份。
这篇要讲的就是这条存储路径:Span 在 Server 里是怎么写入和淘汰的,以及开了 file 模式之后,刷盘是怎么防抖、怎么避免写到一半文件坏掉的。
一、先把问题说清楚
存储是 insight-server 自己的事,和业务微服务无关。微服务只负责上报,不需要去配 spring.insight.server.storage.*。
入口还是上一篇那个 POST,Controller 把请求交给收集服务,收集服务洗完数据,调用:
traceSpanPersistenceService.saveTraceSpans(cleanedSpans);
代码在此:
io.github.iweidujiang.springinsight.storage.service.TraceSpanPersistenceService
配置类是 InsightServerStorageProperties,前缀 spring.insight.server.storage:
| 项 | 默认 | 干什么 |
|---|---|---|
mode | memory | memory 或 file |
max-spans | 50000 | 内存最多留多少条,超了挤最旧的 |
file-path | ./data/spans.json | 相对 Server 进程工作目录 |
flush-delay-ms | 2000 | file 模式下,连续写入合并成一次刷盘 |
两种模式共用同一份 ArrayList。file 并不是换了一套查询引擎,只是启动时把 JSON 读回来,写入后再抽空写回去。健康检查 GET /api/v1/health 会带上 storageMode 和 storedSpans,想确认当前走哪一档,看这个就行。
二、写入那几步
saveTraceSpans 干的活不复杂,但每一步都有原因:
synchronized (lock) {
for (TraceSpan span : batch) {
if (span == null || span.getTraceId() == null || span.getSpanId() == null) {
continue;
}
spans.add(TraceSpan.snapshot(span));
added++;
}
evictIfNeeded();
}
if (storageProperties.isFileMode() && added > 0) {
scheduleFlush();
}
没有 traceId / spanId 的直接丢掉。 后面按 Trace 聚合、按 Span 画树,都靠这两个字段,缺了再存进去,控制台只会更乱。
入库存的是 snapshot(),不是原对象。 TraceSpan 是普通 JavaBean,tags 还是一张可变的 HashMap,收集侧洗数据时可能还在往上面挂字段;如果列表里拿的是同一份引用,过两毫秒再改,内存里那条就跟着变了。
快照会把字段拷一遍,tags 也 new HashMap<>(...),切断这条线。
超上限就从头部删。 默认 5 万条:
private void evictIfNeeded() {
int max = Math.max(1, storageProperties.getMaxSpans());
while (spans.size() > max) {
spans.removeFirst();
}
}
这就是个有界缓冲:新的进来,旧的出去。
实现上现在用的是 ArrayList。从头部 removeFirst() 后面的元素要往前搬,量再大就该换成真正的环形结构(当前这个规模,先让逻辑搞清楚)。
查询和写入抢同一把 lock,列表、拓扑、错误分析都是扫这一份数据再聚合,没有索引。
条数有上限,扫一遍还撑得住;以后真上数据库,查询这边得另写,不是把现在的 stream() 换个名字就完事。
三、想留过重启:file 模式
默认 memory,进程一关数据就没了。Server 要跟着一起重启、又想打开控制台还能看见刚才的调用,就换成 file:
spring:
insight:
server:
storage:
mode: file
max-spans: 50000
file-path: ./data/spans.json
flush-delay-ms: 2000
启动参数也行:--spring.insight.server.storage.mode=file。
启动时如果文件在,先读进内存,缺 ID 的照样跳过,超上限照样挤:
@PostConstruct
void init() {
if (storageProperties.isFileMode()) {
loadFromFile();
flushScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "insight-span-flush");
t.setDaemon(true);
return t;
});
}
}
写入之后不是立刻写盘,上报是按批来的,一批 200 条,紧接着可能还有下一批,每来一次就序列化整份列表,磁盘会很忙,控制台查询也会被拖住。
做法是打脏标记,再推迟刷:
private void scheduleFlush() {
dirty.set(true);
synchronized (this) {
if (pendingFlush != null && !pendingFlush.isDone()) {
pendingFlush.cancel(false);
}
long delay = Math.max(200L, storageProperties.getFlushDelayMs());
pendingFlush = flushScheduler.schedule(
() -> flushToFileNow(false), delay, TimeUnit.MILLISECONDS);
}
}
连续写入会把还没到期的任务取消掉,重新等 flush-delay-ms(默认 2 秒),停手之后才写一次,这就是 防抖,和输入框停止输入再搜是同一个思路。
真正落盘时先在锁里再 snapshot 一份,锁外再写文件,避免 Jackson 序列化时还占着写入锁。
写的是临时文件 spans.json.tmp,成功后再 move 成 spans.json,能原子替换就原子替换,不行再退回普通覆盖。这样写到一半断电,旧文件多半还在,不至于读到半截 JSON。
进程退出时 @PreDestroy 会取消未到期的任务,再强制刷一次,守护线程叫 insight-span-flush,不刷这次,最后两秒里进来的数据可能只在内存里,重启就丢了。
有两点别误会:
- 查询从不读文件。 文件只在启动加载、定时刷盘、关机 flush 时碰一下。控制台慢不慢,仍然取决于内存里有多少条、聚合扫得多勤。
- 这不是 WAL,也不是数据库。 每次刷盘都是把当前列表整份写成 JSON。5 万条还能接受;再往上堆,该换存储引擎了,而不是把
flush-delay-ms调得更短。
四、和「直接上数据库」的区别
可以对照着看:
| 内存 | JSON 文件 | MySQL / ES | |
|---|---|---|---|
| 控制台怎么查 | 扫 ArrayList | 还是扫内存 | SQL / 索引 |
| 重启 | 空 | 从文件捞回来 | 还在 |
| 安装成本 | 一个 jar | 多个文件路径 | 再装一套中间件 |
| 上限 | max-spans | 同样受内存上限约束 | 可以做历史库 |
| 适合 | 本地、Demo | 想留过重启的单机 | 真要长期留数据 |
Insight 现在选前两档,是为了 java -jar insight-server.jar 就能打开 http://localhost:9966。
文件这一档解决的是「Server 重启别把刚才的调用弄丢」,不是「从此有了可观测性平台的存储层」。上限还在,淘汰还在,查询还是全表扫描。
五、自己写类似缓冲时可以用到的
- 查询和写入共用一份数据,就上一把锁。 并发上报和页面刷新会同时进来,没有锁的
ArrayList扩容时很好看热闹。 - 入库存快照。 上游对象还活着、还可能被改,列表里必须是拷贝。可变的 map 尤其要新开一份。
- 必须有上限。 监测数据是流,不是「存多少是多少」。没有淘汰,内存会先于磁盘把进程打死。
- 落盘要防抖。 热路径上只改内存、打脏标记;磁盘 I/O 放定时任务。每条都
writeValue,上报一批就能把 Server 写盘写满。 - 先写临时文件再替换。 直接覆盖正在读的 JSON,写到一半崩溃,下次启动会解析失败,等于备份把自己搞丢了。
- 配置放在存数据的那一侧。 业务进程不该关心 Server 用内存还是文件。健康检查把
storageMode、storedSpans暴露出来,排障比翻日志快。
六、小结
- Span 到了 Server,先经过收集服务,再进
TraceSpanPersistenceService。控制台始终读内存里那份列表。 - 默认
memory:一把锁、snapshot入库、超 5 万条从头部挤掉。重启清空是预期行为,不是 bug。 mode=file是同一套内存,外加启动加载、防抖刷盘、关机再 flush。文件在 Server 工作目录下的./data/spans.json。- 这还不是数据库。没有索引、没有按 Trace 分片、刷盘是整份 JSON。够 Demo 和轻量排查,不够当历史库。
上一篇的 HttpClient 把数据送到了门口,这篇把数据放下了。门口和仓库之间还有一步:上报包字段不全、服务名缺失、耗时没算完,收集服务会先补一补再交给存储。
下一篇预告:看 TraceSpanCollectorService 是怎么校验、清洗、补全一批 Span 的,缺了这一步,内存里会堆进不少废数据。
最后:欢迎围观 Spring Insight
如果你对轻量监测、Spring Boot Starter、链路埋点感兴趣,欢迎看看这个还在打磨的小项目:
🔗 GitHub:github.com/iweidujiang…
当前形态很朴素:业务服务加 spring-insight-agent-starter,旁边单独跑 insight-server 看拓扑和链路。能力有限,代码也还糙,适合当练手和对照。
你的 Star 是对我最大的鼓励。