You need to enable JavaScript to run this app. Spring Cloud Sleuth 如何导出客户环境链路数据至本地Zipkin
优惠活动
大模型
产品
解决方案
定价
更多

Spring Cloud Sleuth 如何导出客户环境链路数据至本地Zipkin

基于自定义Sender实现链路数据落盘持久化

核心思路是替换Spring Cloud Sleuth默认的zipkinSender Bean实现,完全跳过客户环境到Zipkin服务的网络上报动作,将原本要序列化上报的Span数据直接持久化到本地文件,后续离线导入本地Zipkin即可完成链路排查,整个过程不需要改动任何业务层的@NewSpan注解逻辑,也不需要在客户环境部署额外组件。

前置适配说明

Hoxton.SR10版本的Sleuth链路上报逻辑完全基于注入的zipkinSender Bean实现,只要替换这个Bean的逻辑,即可无缝接管所有链路数据的出口,同时保留原有的Span采集、上下文传播能力,和本地开发环境的采集逻辑保持一致。
通过@ConditionalOnProperty做环境隔离:客户环境开启自定义落盘逻辑,本地开发环境保持原有的网络上报到Zipkin的逻辑,不影响日常开发体验。

完整配置实现

补全原方案中缺失的两个核心方法,完整可运行的配置类如下:

@Configuration
@ConditionalOnProperty(name = "custom.sleuth.file-sender.enabled", havingValue = "true")
public class SleuthFileSenderConfiguration {

    // 可通过配置文件注入自定义的文件存储目录
    @Value("${custom.sleuth.file-sender.save-path:/data/sleuth/spans}")
    private String savePath;

    // 单文件最大大小,默认100MB
    @Value("${custom.sleuth.file-sender.max-file-size:104857600}")
    private long maxFileSize;

    @Bean("zipkinSender")
    public Sender zipkinFileSender() {
        return new Sender() {
            @Override
            public Encoding encoding() {
                // 保持和默认Sender一致的JSON编码格式,保证Span结构和Zipkin要求完全兼容
                return Encoding.JSON;
            }

            @Override
            public int messageMaxBytes() {
                // 无限制批量发送,减少文件IO次数
                return Integer.MAX_VALUE;
            }

            @Override
            public int messageSizeInBytes(List<byte[]> list) {
                // 无限制批量大小
                return Integer.MAX_VALUE;
            }

            @Override
            public Call<Void> sendSpans(List<byte[]> spans) {
                // 1. 转换为Zipkin可直接导入的JSON数组格式
                String spanJsonContent = convertByteArrayToZipkinFormat(spans);
                // 2. 持久化到本地文件
                saveToLocalFile(spanJsonContent);
                // 3. 返回成功的回调,避免Sleuth触发重试逻辑
                return new Call.Base<>() {
                    @Override
                    protected Void doExecute() {
                        return null;
                    }

                    @Override
                    protected void doEnqueue(Callback<Void> callback) {
                        callback.onSuccess(this, null);
                    }
                };
            }
        };
    }

    /**
     * 将批量Span的字节数组转换为Zipkin兼容的JSON数组格式
     */
    private String convertByteArrayToZipkinFormat(List<byte[]> spans) {
        StringBuilder jsonBuilder = new StringBuilder("[");
        for (int i = 0; i < spans.size(); i++) {
            String spanStr = new String(spans.get(i), StandardCharsets.UTF_8);
            jsonBuilder.append(spanStr);
            // 非最后一个元素追加逗号分隔
            if (i != spans.size() -1) {
                jsonBuilder.append(",");
            }
        }
        jsonBuilder.append("]");
        return jsonBuilder.toString();
    }

    /**
     * 将Span JSON内容持久化到本地文件,支持滚动切割避免单文件过大
     */
    private void saveToLocalFile(String content) {
        // 确保存储目录存在
        File targetDir = new File(savePath);
        if (!targetDir.exists()) {
            targetDir.mkdirs();
        }
        // 查找当前可写入的文件,若超过最大大小则生成新文件
        File currentFile = getCurrentWritableFile(targetDir);
        // 追加模式写入,加锁保证并发写入不混乱
        synchronized (this) {
            try (FileWriter writer = new FileWriter(currentFile, true)) {
                writer.write(content);
                writer.flush();
            } catch (IOException e) {
                // 写入失败只打日志,不抛出异常影响业务流程
                e.printStackTrace();
            }
        }
    }

    /**
     * 获取当前可写入的文件,实现按大小滚动
     */
    private File getCurrentWritableFile(File dir) {
        // 文件名格式:span-时间戳-序号.json
        String fileNamePrefix = "span-" + LocalDate.now().toString();
        File[] existFiles = dir.listFiles((f, name) -> name.startsWith(fileNamePrefix) && name.endsWith(".json"));
        // 无当日文件则新建
        if (existFiles == null || existFiles.length == 0) {
            return new File(dir, fileNamePrefix + "-001.json");
        }
        // 找最后一个文件判断大小,没超过阈值则继续写入
        File lastFile = Arrays.stream(existFiles).max(Comparator.comparingLong(File::length)).get();
        if (lastFile.length() < maxFileSize) {
            return lastFile;
        }
        // 超过阈值则生成新序号的文件
        int nextIndex = existFiles.length +1;
        return new File(dir, fileNamePrefix + "-" + String.format("%03d", nextIndex) + ".json");
    }
}

离线导入使用流程

  • 问题排查时,直接从客户环境的存储目录(默认/data/sleuth/spans/)拷贝所有生成的JSON文件到本地
  • 本地启动Zipkin服务后,调用Zipkin的/api/v2/spans接口,将每个文件的完整内容作为POST请求的Body传入,Zipkin会自动完成Span的解析、链路拼接
  • 打开本地Zipkin的Web UI即可查看完整的链路信息,和本地开发环境的使用体验完全一致

注意事项

  • 该实现没有额外依赖,原项目只要已经引入spring-cloud-starter-zipkin,所有需要的Sender、Encoding、Call类都已经在现有依赖中,不需要额外引入第三方库
  • 文件写入是追加模式,且通过异步线程触发,不会阻塞业务主流程的响应时间
  • 可根据客户环境的磁盘容量,调整文件保存路径、单文件大小、旧文件自动清理策略,避免长期运行占用过多磁盘空间
  • 不需要修改任何业务代码,原有的@NewSpan注解的自定义Span逻辑会被完整采集并落盘

内容的提问来源于stack exchange,提问作者Rokko_11

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.11.02 17:15:04