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

0 阅读7分钟

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

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

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

二、写入那几步

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,成功后再 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 是对我最大的鼓励。