Spring Insight 里收到的 Span 是怎么存下来的

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 把请求交给收集服务,收集服务洗完数据,调用:

java 复制代码
traceSpanPersistenceService.saveTraceSpans(cleanedSpans);

代码在此:io.github.iweidujiang.springinsight.storage.service.TraceSpanPersistenceService

配置类是 InsightServerStorageProperties,前缀 spring.insight.server.storage

默认 干什么
mode memory memoryfile
max-spans 50000 内存最多留多少条,超了挤最旧的
file-path ./data/spans.json 相对 Server 进程工作目录
flush-delay-ms 2000 file 模式下,连续写入合并成一次刷盘

两种模式共用同一份 ArrayListfile 并不是换了一套查询引擎,只是启动时把 JSON 读回来,写入后再抽空写回去。健康检查 GET /api/v1/health 会带上 storageModestoredSpans,想确认当前走哪一档,看这个就行。

二、写入那几步

saveTraceSpans 干的活不复杂,但每一步都有原因:

java 复制代码
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 万条:

java 复制代码
private void evictIfNeeded() {
    int max = Math.max(1, storageProperties.getMaxSpans());
    while (spans.size() > max) {
        spans.removeFirst();
    }
}

这就是个有界缓冲:新的进来,旧的出去。

实现上现在用的是 ArrayList。从头部 removeFirst() 后面的元素要往前搬,量再大就该换成真正的环形结构(当前这个规模,先让逻辑搞清楚)。

查询和写入抢同一把 lock,列表、拓扑、错误分析都是扫这一份数据再聚合,没有索引。

条数有上限,扫一遍还撑得住;以后真上数据库,查询这边得另写,不是把现在的 stream() 换个名字就完事。

三、想留过重启:file 模式

默认 memory,进程一关数据就没了。Server 要跟着一起重启、又想打开控制台还能看见刚才的调用,就换成 file

yaml 复制代码
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 的照样跳过,超上限照样挤:

java 复制代码
@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 条,紧接着可能还有下一批,每来一次就序列化整份列表,磁盘会很忙,控制台查询也会被拖住。

做法是打脏标记,再推迟刷:

java 复制代码
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,成功后再 movespans.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 重启别把刚才的调用弄丢」,不是「从此有了可观测性平台的存储层」。上限还在,淘汰还在,查询还是全表扫描。

五、自己写类似缓冲时可以用到的

  1. 查询和写入共用一份数据,就上一把锁。 并发上报和页面刷新会同时进来,没有锁的 ArrayList 扩容时很好看热闹。
  2. 入库存快照。 上游对象还活着、还可能被改,列表里必须是拷贝。可变的 map 尤其要新开一份。
  3. 必须有上限。 监测数据是流,不是「存多少是多少」。没有淘汰,内存会先于磁盘把进程打死。
  4. 落盘要防抖。 热路径上只改内存、打脏标记;磁盘 I/O 放定时任务。每条都 writeValue,上报一批就能把 Server 写盘写满。
  5. 先写临时文件再替换。 直接覆盖正在读的 JSON,写到一半崩溃,下次启动会解析失败,等于备份把自己搞丢了。
  6. 配置放在存数据的那一侧。 业务进程不该关心 Server 用内存还是文件。健康检查把 storageModestoredSpans 暴露出来,排障比翻日志快。

六、小结

  • 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、链路埋点感兴趣,欢迎看看这个还在打磨的小项目:

🔗 GitHubgithub.com/iweidujiang...

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

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

相关推荐
步行cgn3 小时前
Spring 的模块体系详解
java·数据库·spring
xcl09253 小时前
智慧健身场馆系统开发实战:从架构设计到核心模块落地指南
java·spring boot
vx-程序开发3 小时前
【计算机毕设】基于Spring Boot的古城景区管理系统88564
java·数据库·spring boot·后端·spring·elasticsearch·课程设计
vx-Biye_Design4 小时前
springboot中国传统节日宣传平台49078-计算机课程设计、毕业设计
java·前端·vue.js·spring boot·后端·课程设计·idea
野生技术架构师4 小时前
从向量检索到混合搜索优化:Spring AI 2.0.1 + pgvector RAG 实战
java·人工智能·spring
Wang's Blog5 小时前
Java框架快速入门: Spring Security+OAuth2之实现UserDetails与GrantedAuthority深度定制
java·spring·mybatis
代码调试师6 小时前
【毕设分享】springboot攀枝花生鲜电商平台58990
java·vue.js·spring boot·后端·架构·eclipse·课程设计
xcl09256 小时前
全民健身解决方案小程序系统开发实战:从架构设计到上线指南
java·spring boot
洛阳泰山6 小时前
别卷 Python 了:我用 Java 21 + Spring Boot 3 打造了一个企业级 RAG + 智能体工作流引擎(附架构与源码解析)
java·spring boot·后端