《企业实战分享 · Sleuth + Zipkin 实现链路追踪》

📢 大家好,我是 【战神刘玉栋】,有10多年的研发经验,致力于前后端技术栈的知识沉淀和传播。 💗
🌻 近期刚转战 CSDN,会严格把控文章质量,绝不滥竽充数,如需交流,欢迎留言评论。👍

写在前面的话

技术栈:后端 SpringCloud + 前端 Vue/Nuxt

企业开发中,若后端采用的是微服务架构,通常会搭配链路追踪进行全链路的日志记录和展示。
常用的链路追踪方案有Sleuth + ZipkinSkyWalking,这里先不赘述两个技术的对比,后面其他文章再加以说明。
博主所在公司,经过技术选型后,决定采用Sleuth + Zipkin作为链路追踪方案,下文以此展开介绍。


技术简介

Spring Cloud Sleuth
Sleuth 是一个用于分布式系统中的分布式追踪解决方案,集成了 Spring Boot 应用,使开发者可以轻松地在分布式系统中进行请求追踪。
Sleuth 主要负责生成和传播跟踪标识,并记录每个服务内部的请求处理信息,是跟踪数据的生产者。
Sleuth 可以通过配置,将跟踪数据直接发送到运行中的 Zipkin 服务器。

Zipkin
Zipkin 是一个开源的分布式跟踪系统,用于收集、存储、搜索和可视化跨多个服务的跟踪数据。
Zipkin 主要负责收集整合所有服务生成的跟踪信息,提供全局视图和跨服务的调用链分析功能。
Zipkin 接收由 Sleuth 生成的跟踪数据,并将这些数据存储在后端存储系统中(如 Elasticsearch、MySQL 等),并提供了一个用户界面用于查询和可视化这些数据。


基础使用

Step1、添加依赖
在 Spring Boot 项目中使用 Sleuth 和 Zipkin,需要在 pom.xml 文件中添加以下依赖:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-zipkin</artifactId>
</dependency>

Step2、相关配置

这里默认读者已完成了 Zipkin 的部署工作,如果仅为了单机测试,你可以使用 Docker 启动 Zipkin 服务器: docker run -d -p 9411:9411 openzipkin/zipkin

若是单体 SpringBoot 服务,直接在 application.yml 文件中配置 Sleuth 和 Zipkin 的相关信息,若采用 Nacos 作为配置中心,可以将这些配置放置在 Nacos 的全局配置中。

spring:
  zipkin:
    # 配置 Zipkin 服务器的基础 URL 地址。
    # Spring Cloud Sleuth 会将收集到的追踪数据发送到这个地址。
    base-url: http://localhost:9411/
    sender:
      # 指定追踪数据发送器的类型。
      # web 类型表示使用 HTTP 协议将数据发送到 Zipkin 服务器。
      # 可选值: web、rabbit、kafka 等,取决于你使用的消息传递机制。
      type: web
  sleuth:
    sampler:
      # 配置 Sleuth 的采样率,用于控制有多少比例的请求会被追踪。
      # 取值范围为 0 到 1.0。1.0 表示对所有请求进行追踪。
      probability: 1.0

Step3、使用 Sleuth (自动场景)
Spring Cloud Sleuth 会自动为所有的 HTTP 请求和异步任务(例如使用 RestTemplate 发出的请求)生成和传播 Trace ID 和 Span ID,并在日志中显示,你可以通过这些 ID 来追踪请求的整个链路。
在 Spring Boot 应用中, 自动链路追踪的场景包含但不限于:

  • 所有的 HTTP 请求。
  • 所有通过 RestTemplate、Feign 等客户端发起的 HTTP 调用。
  • 所有通过 @Async 执行的异步任务。
  • 所有通过 MessageChannel 发送和接收的消息(例如 Spring Cloud Stream)。

Step4、使用 Sleuth (手动场景)
尽管 Sleuth 会自动处理大部分的追踪场景,但在某些复杂或特定的业务逻辑中,你可能需要手动创建 Spans。

@RestController
public class ExampleController {

    @Autowired
    private Tracer tracer;

    @GetMapping("/sync")
    public String syncMethod() {
        Span newSpan = tracer.nextSpan().name("syncSpan").start();
        try (Tracer.SpanInScope ws = tracer.withSpanInScope(newSpan)) {
            return "OK";
        } finally {
            newSpan.end();
        }
    }
}

Step5、查看追踪结果
在浏览器中打开 Zipkin UI,通常是 http://localhost:9411,你可以在这里查看和分析链路追踪数据。
示例效果如下图:
image.png


示例 · SQL请求追踪

Tips:这里分享若干手动添加链路追踪的示例,读者可参考扩展。

实现思路:
自定义 MyBatis 拦截器,在 SQL 执行前后分别埋点,计算耗时等相关信息后,再使用Sleuth记录。

部分代码:(如需完整代码可联系)

public class MybatisSqlTraceLogInterceptor implements Interceptor {
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        // 获得SQL语句
        MappedStatement mappedStatement = (MappedStatement) args[0];
        String returnRows = "-1";
        String sqlResult = "";
        Span span = createNextTraceSpan(mappedStatement);
        try {
            Object proceed = invocation.proceed();
            returnRows = this.getReturnRows(proceed);
            sqlResult = this.getPageCountSqlResult(mappedStatement, proceed);
            if (span != null) {
                span.tag(TraceSpanConstant.SQL_ROWS, returnRows);
                span.tag(TraceSpanConstant.SQL_RESULT, sqlResult);
            }
            return proceed;
        } catch (Throwable ex) {
            Throwable cause = ex.getCause();
            if (span != null) {
                if (ex instanceof InvocationTargetException && cause != null) {
                    span.error(cause);
                } else {
                    span.error(ex);
                }
            }
            throw ex;
        } finally {
            if (span != null) {
                this.logSql(args, mappedStatement, returnRows, sqlResult, span);
                span.finish();
            }
            SqlExecuteContextHolder.remove();
        }
    }

    /**
     * 创建下一个Zipkin链路Span
     */
    private Span createNextTraceSpan(MappedStatement mappedStatement) {
        String sqlId = mappedStatement.getId();
        // 语法是否在白名单中
        boolean isPermit = PathMatchUtil.isPermit(sqlId, this.sqlLogProperties.getIncludeMethods(),
                this.loggingBlackListManagerAggregator.getBlackList(TraceSpanConstant.LogType.SQL), true, true, "SQL_TRACE");
        // 黑名单语法直接忽略
        if (!isPermit) {
            if (log.isDebugEnabled()) {
                log.debug("SQL处于日志黑名单中,已自动忽略. SQL_ID:{}", sqlId);
            }
            return null;
        }
        Span span = this.tracer.nextSpan().kind(Span.Kind.SERVER).start();
        String fullSqlId = mappedStatement.getId();
        String simpleSqlId = TraceLogUtil.abbreviator(fullSqlId);
        String spanName = StrUtil.format("[SQL] [{}] {}", mappedStatement.getSqlCommandType().name(), simpleSqlId);
        span.name(spanName);
        span.tag(TraceSpanConstant.LOG_TYPE, TraceSpanConstant.LogType.SQL);
        span.tag(TraceSpanConstant.SQL_ID, fullSqlId);
        span.tag(TraceSpanConstant.SQL_TYPE, mappedStatement.getSqlCommandType().name());
        return span;
    }
}

示例 · 事务链路追踪

实现思路:
自定义实现Druid的FilterEventAdapter,在事务的相关节点进行代码埋点,计算耗时等相关信息后,使用Sleuth记录。

展示效果:
image.png
image.png
部分代码:(如需完整代码可留言联系)

public class DruidTxMonitorFilter extends FilterEventAdapter {

    /**
     * 创建下一个Zipkin链路Span
     */
    private Span createNextTraceSpan(Long tranId, String flag, String status) {
        Span span = tracer.nextSpan()
                .kind(Span.Kind.SERVER)
                .start();
        String spanName = StrUtil.format("[TRAN] [事务ID:{},描述:{} ======= {}]", tranId, flag, status);
        span.name(spanName);
        span.tag(TraceSpanConstant.LOG_TYPE, TraceSpanConstant.LogType.TRAN);
        span.tag(TraceSpanConstant.TRAN_ID, StrUtil.toString(tranId));
        return span;
    }

    @Override
    public void connection_setAutoCommit(FilterChain chain, ConnectionProxy connection, boolean autoCommit) throws SQLException {
        super.connection_setAutoCommit(chain, connection, autoCommit);
        if (!autoCommit) {
            Long beforeId = TRAN_ID.get();
            if (beforeId != null) {
                // 子事务逻辑
                Span span = this.createNextTraceSpan(connection.getId(), "子事务Begin", "");
                span.tag(TraceSpanConstant.TRAN_BEGIN_TIME, DateUtil.formatDateTime(new Date()));
                span.tag(TraceSpanConstant.TRAN_INFO, JSON.toJSONString(connection.getTransactionInfo()));
                span.finish();
            } else {
                // 主事务逻辑
                monitorReady(connection);
            }
        }
    }

    @Override
    public void connection_commit(FilterChain chain, ConnectionProxy connection) throws SQLException {
        try {
            super.connection_commit(chain, connection);
            //提交完成后清理本次事务的开始时间、执行的sql等线程绑定的内容
            long id = connection.getId();
            monitorTransactionTime(id, "commit");
        } catch (Exception e) {
            log.error(ExceptionUtil.stacktraceToString(e, 300));
        }
    }

    /**
     * 监控事务持续时间
     */
    private void monitorTransactionTime(Long id, String status) {

        Span span = null;
        Long mainId = TRAN_ID.get();
        if (mainId == null) {
            return;
        }

        try {

            String requestUri = OnelinkContextHolder.getString("requestUri");

            if (!mainId.equals(id)) {
                // 子事务逻辑
                span = this.createNextTraceSpan(id, "子事务End", status);
                span.tag(TraceSpanConstant.TRAN_END_TIME, DateUtil.formatDateTime(new Date()));
                span.tag(TraceSpanConstant.TRAN_URL, StrUtil.emptyToDefault(requestUri, ""));
                span.finish();
                return;
            }

            if (TX_BEGIN_TIME.get() != null) {

                // 计算和存储时间
                long currentTime = System.currentTimeMillis();
                Long beginTime = TX_BEGIN_TIME.get();
                long timeCost = currentTime - beginTime;

                // 创建结束事务的链路
                Long tranId = TRAN_ID.get();
                span = this.createNextTraceSpan(tranId, StrUtil.format("主事务End,耗时:{}", timeCost), status);
                span.tag(TraceSpanConstant.TRAN_CONSUME_MS, StrUtil.toString(timeCost));
                span.tag(TraceSpanConstant.TRAN_BEGIN_TIME, DateUtil.formatDateTime(DateUtil.date(beginTime)));
                span.tag(TraceSpanConstant.TRAN_END_TIME, DateUtil.formatDateTime(DateUtil.date(currentTime)));
                span.tag(TraceSpanConstant.TRAN_URL, StrUtil.emptyToDefault(requestUri, ""));

                // 存储SQL
                if (sqlLogProperties.isTxMonitorSqlEnabled()) {
                    List<String> sqlList = TX_SQL_LIST.get();
                    span.tag(TraceSpanConstant.TRAN_SQL_NUM, StrUtil.toString(sqlList.size()));
                    span.tag(TraceSpanConstant.TRAN_SQL_LIST, JSON.toJSONString(sqlList));
                }

                // 事务状态
                span.tag(TraceSpanConstant.TRAN_STATUS, status);
            }
        } catch (Throwable e) {
            log.error(ExceptionUtil.stacktraceToString(e, 300));
        } finally {
            if (mainId.equals(id)) {
                TRAN_ID.remove();
                TX_BEGIN_TIME.remove();
                TX_SQL_LIST.remove();
                OnelinkContextHolder.remove("requestUri");
                if (span != null) {
                    span.finish();
                }
            }
        }
    }
}

扩展延申

上文介绍了Sleuth + Zipkin的基础使用,并且附带了两个手动记录Span的场景,实际开发中,能记录的链路信息远不止这些,架构封装人员可依照实际情况全面考虑。
实际开发中,通常还会将 Zipkin数据存储在 Elasticsearch中,后续还可以通过Kibana或自研日志页面,进行更全面详细的信息查询,这里篇幅受限,不一一展开了。

💗 后续会逐步分享企业实际开发中的实战经验,有需要交流的可以联系博主。

  • 35
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

战神刘玉栋

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值