掘金 后端 ( ) • 2024-04-17 23:04

highlight: a11y-dark

前言

分布式链路追踪中,记录数据库的调用是必不可少的,但是数据库的分布式链路追踪,与调用下游服务或者发送Kafka有着显著的不同,那就是链路信息不需要传递到数据库服务端,所以就不需要将Span通过某种方式进行传递,而需要做的,就是把请求数据库服务端时的一些信息记录下来并作为链路日志输出。

这里选择基于MyBatis实现数据库链路追踪,实现的机制是基于MyBatis的拦截器,因此实际上MyBatis-Plus也是适用的。

github地址:honey-tracing

正文

一. 链路日志改造说明

之前的链路日志格式如下所示。

{
    "traceId": "testTraceId", // 当前节点所属链路的Id
    "spanId": "testSpanId", // 当前节点的SpanId
    "parentSpanId": "testparentSpanId", // 当前节点的父节点的SpanId
    "timestamp": "1704038400000", // 接收到请求那一刻的毫秒时间戳
    "duration": "10", // 表示接收请求到响应请求的耗时
    "httpCode": "200", // 请求的HTTP状态码
    "host": "127.0.0.1", // 当前节点的主机地址
    "requestStacks": [ // 请求堆栈
        {
            "subSpanId": "testSubSpanId", // 当前节点的子节点的SpanId
            "subHttpCode": "200", // 请求子节点的HTTP状态码
            "subTimestamp": "1704038401000", // 当前节点请求子节点的毫秒时间戳
            "subDuration": "5", // 表示发起请求到收到响应的耗时
            "subHost": "192.168.10.5" // 当前节点的子节点的主机地址
        }
    ]
}

其中requestStacks字段用于记录下游的Span信息,而由于数据库链路追踪中并不需要将链路信息传递给数据库服务端,所以requestStacks字段不再适用,我们新增加一个dbStacks字段来记录数据库操作的信息,示例如下。

{
    "traceId": "testTraceId", // 当前节点所属链路的Id
    "spanId": "testSpanId", // 当前节点的SpanId
    "parentSpanId": "testparentSpanId", // 当前节点的父节点的SpanId
    "timestamp": "1704038400000", // 接收到请求那一刻的毫秒时间戳
    "duration": "10", // 表示接收请求到响应请求的耗时
    "httpCode": "200", // 请求的HTTP状态码
    "host": "127.0.0.1", // 当前节点的主机地址
    "requestStacks": [ // 请求堆栈
        {
            "subSpanId": "testSubSpanId", // 当前节点的子节点的SpanId
            "subHttpCode": "200", // 请求子节点的HTTP状态码
            "subTimestamp": "1704038401000", // 当前节点请求子节点的毫秒时间戳
            "subDuration": "5", // 表示发起请求到收到响应的耗时
            "subHost": "192.168.10.5" // 当前节点的子节点的主机地址
        }
    ],
    "dbStacks": [
        {
            "dbServer": "127.0.0.1:3306", // 数据库服务端地址
            "dbName": "test", // 数据库名
            "sqlText": "SELECT * FROM book WHERE id=?", // SQL语句
            "sqlParams": "50", // SQL语句参数
            "sqlDuration": "5", // 数据库操作耗时
            "sqlTimestamp": "1704038402000" // 数据库操作时的毫秒时间戳
        }
    ]
}

新增字段说明如下。

  1. dbServer。表示数据库服务端地址,从url连接串中解析出;
  2. dbName。表示操作的数据库名,从url连接串中解析出;
  3. sqlText。表示执行的SQL语句信息,从MyBatisBoundSql中获取;
  4. sqlParams。表示执行的SQL参数,同样从MyBatisBoundSql中获取;
  5. sqlDuration。表示操作数据库的耗时,单位ms,由于MyBatis拦截器的拦截时机是先于从数据源中拿出连接的,所以这里的耗时包括等待获取数据库连接的时间;
  6. sqlTimestamp。表示开始操作数据库的时间点的毫秒时间戳。

二. MyBatis拦截器回顾

MyBatis的拦截器也就是常说的插件,可以作用于ExecutorParameterHandlerResultSetHandlerStatementHandler这四个组件,所有拦截器都需要实现org.apache.ibatis.plugin.Interceptor接口,一个简单的拦截器示例如下所示。

@Intercepts({
        @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class TestInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 获取被拦截的对象
        Object target = invocation.getTarget();
        // 获取被拦截的方法
        Method method = invocation.getMethod();
        // 获取被拦截的方法的参数
        Object[] args = invocation.getArgs();

        // 执行被拦截的方法前,做一些事情

        // 执行被拦截的方法
        Object result = invocation.proceed();

        // 执行被拦截的方法后,做一些事情

        // 返回执行结果
        return result;
    }

}

上面出现的@Signature注解的typemethodargs三个字段共同决定拦截器会作用于哪个组件的哪个方法上,例如上面示例中,就会作用于Executor组件的update() 方法和两个重载的query() 方法,而我们又知道,MyBatis执行SQL时,无论是增删改查,其实都是会调用到Executorupdate() 方法或者query() 方法,所以上面示例的拦截器,其实就可以拦截所有SQL的执行。

三. 数据库链路追踪MyBatis拦截器设计与实现

在开始前,需要在pom文件中先添加如下依赖。

<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.2.2</version>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.2.11</version>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>org.apache.tomcat</groupId>
    <artifactId>tomcat-jdbc</artifactId>
    <scope>provided</scope>
</dependency>

我们要实现数据库链路追踪,其实就是记录操作数据库的行为,所以需要拦截每一条SQL的执行,因此拦截器作用的目标组件就是Executor,作用的目标方法就是update() 和两个重载的query() 方法,拦截器实现如下所示。

/**
 * MyBaits作用于{@link Executor}用于记录{@link Span}的拦截器。
 */
@Intercepts({
        @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class HoneyDbExecutorTracingInterceptor implements Interceptor {

    private final Tracer tracer;
    private final List<HoneyDbExecutorTracingDecorator> decorators;

    public HoneyDbExecutorTracingInterceptor(Tracer tracer, List<HoneyDbExecutorTracingDecorator> decorators) {
        this.tracer = tracer;
        this.decorators = decorators;
    }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        if (null == tracer.activeSpan()) {
            return invocation.proceed();
        }

        Span span = tracer.buildSpan(HONEY_DB_NAME)
                .withTag(Tags.SPAN_KIND.getKey(), Tags.SPAN_KIND_CLIENT)
                .start();
        for (HoneyDbExecutorTracingDecorator decorator : decorators) {
            try {
                decorator.onExecute(invocation, span);
            } catch (Exception e) {
                // do nothing
            }
        }

        Object result;
        try (Scope scope = tracer.activateSpan(span)) {
            try {
                result = invocation.proceed();
            } catch (Exception e1) {
                for (HoneyDbExecutorTracingDecorator decorator : decorators) {
                    try {
                        decorator.onError(invocation, e1, span);
                    } catch (Exception e2) {
                        // do nothing
                    }
                }
                throw e1;
            }

            for (HoneyDbExecutorTracingDecorator decorator : decorators) {
                try {
                    decorator.onFinish(invocation, span);
                } catch (Exception e) {
                    // do nothing
                }
            }
        } finally {
            span.finish();
            tracer.activeSpan().log(DbStackUtil.assembleDbStack((JaegerSpan) span));
        }

        return result;
    }

}

上述实现的拦截器,主要干了下面几件事情。

  1. 针对当前操作数据库的行为创建Span。这里创建Span并不是要把Span传递给数据库服务端,而是通过Span来记录开始时间,执行耗时以及数据库操作的一些信息;
  2. SQL执行前,执行成功和执行失败时分别应用装饰器的逻辑。拦截器并没有写很重的逻辑,解析url,解析SQL等逻辑全部放到装饰器中,让拦截器和记录信息的行为解耦,方便后续扩充dbStack的内容;
  3. 在拦截器的最后生成dbStack并记录在当前节点的Span中。注意,生成dbStack是基于我们在拦截器中创建出来的Span,在dbStack生成出来后,在拦截器中创建出来的Span的使命就完成了,后续就需要把生成出来的dbStack记录在当前节点的Span中。

基于Span生成dbStack的工具类DbStackUtil实现如下。

/**
 * dbStack记录工具。
 */
public class DbStackUtil {

    /**
     * 生成操作数据库时的dbStack。
     */
    public static Map<String, Object> assembleDbStack(JaegerSpan span) {
        Map<String, Object> requestStack = new HashMap<>();
        requestStack.put(LOG_EVENT_KIND, LOG_EVENT_KIND_DB_STACK);
        requestStack.put(FIELD_DB_SERVER, span.getTags().get(FIELD_DB_SERVER));
        requestStack.put(FIELD_DB_NAME, span.getTags().get(FIELD_DB_NAME));
        requestStack.put(FIELD_SQL_TEXT, span.getTags().get(FIELD_SQL_TEXT));
        requestStack.put(FIELD_SQL_PARAMS, span.getTags().get(FIELD_SQL_PARAMS));
        requestStack.put(FIELD_SQL_DURATION, span.getDuration());
        requestStack.put(FIELD_SQL_TIMESTAMP, span.getStart());
        return requestStack;
    }

}
public class CommonConstants {

    ......

     public static final String LOG_EVENT_KIND = "logEventKind";
     public static final String LOG_EVENT_KIND_DB_STACK = "dbStack";

    ......

}

其中很关键的一点是在于将logEventKind设置为了dbStack,这样在打印链路日志时,可以根据logEventKind来知道当前要按照dbStack的格式来组装日志。

最后再看一下装饰器接口的定义,如下所示。

/**
 * 作用于{@link Executor}的装饰器。
 */
public interface HoneyDbExecutorTracingDecorator {

    void onExecute(Invocation invocation, Span span);

    void onFinish(Invocation invocation, Span span);

    void onError(Invocation invocation, Exception exception, Span span);

}

四. 数据库链路追踪装饰器设计与实现

提供一个HoneyDbExecutorTracingDecorator接口的实现类,在SQL执行前,完成记录数据库服务端地址,数据库名,SQL语句和SQL参数,这些记录的信息,全部存储在SpanTags字段中。

1. 数据库服务端地址和数据库名获取

装饰器实现如下。

/**
 * 作用于{@link Executor}的装饰器,装饰{@link Span}。
 */
public class HoneyDbExecutorTracingSpanDecorator implements HoneyDbExecutorTracingDecorator {

    private static final String QUESTION_MARK = "?";
    private static final String COMMA = ",";
    private static final String NULL_STR = "null";
    private static final char SPACE_CHAR = ' ';
    private static final String LINE_BREAK_REGEX = "\n";

    @Override
    public void onExecute(Invocation invocation, Span span) {
        // 设置数据库服务端地址信息和数据库名
        try {
            DataSource dataSource = ((MappedStatement) invocation.getArgs()[0]).getConfiguration().getEnvironment().getDataSource();
            assembleDbServerAndName(dataSource, (JaegerSpan) span);
        } catch (Exception e) {
            // do nothing
        }

        ......
    }

    @Override
    public void onFinish(Invocation invocation, Span span) {
        // do nothing
    }

    @Override
    public void onError(Invocation invocation, Exception exception, Span span) {
        // do nothing
    }

    private void assembleDbServerAndName(DataSource dataSource, JaegerSpan span) {
        String url = StringUtils.EMPTY;
        try {
            if (dataSource instanceof HikariDataSource) {
                url = ((HikariDataSource) dataSource).getJdbcUrl();
            } else if (dataSource instanceof DruidDataSource) {
                url = ((DruidDataSource) dataSource).getUrl();
            } else if (dataSource instanceof org.apache.tomcat.jdbc.pool.DataSource) {
                url = ((org.apache.tomcat.jdbc.pool.DataSource) dataSource).getUrl();
            } else {
                // 无法判断数据库连接池类型的情况下才通过连接拿url
                Connection connection = dataSource.getConnection();
                DatabaseMetaData metaData = connection.getMetaData();
                url = metaData.getURL();
            }
        } catch (Exception e) {
            // do nothing
        }
        if (StringUtils.isNotEmpty(url)) {
            // 从连接串中解析出数据库服务端地址信息
            int left = url.indexOf(SLASH_DOUBLE) + 2;
            int mid = url.indexOf(SLASH, left);
            int right = url.indexOf(QUESTION_MARK);
            span.setTag(FIELD_DB_SERVER, url.substring(left, mid));
            if (right == -1) {
                span.setTag(FIELD_DB_NAME, url.substring(mid + 1));
            } else {
                span.setTag(FIELD_DB_NAME, url.substring(mid + 1, right));
            }
        }
    }

    ......

}

上述装饰器首先会从拦截方法的参数中拿到MappedStatement,从而最终可以拿到当前使用的数据源DataSource,再然后判断数据源的类型,如果能够明确数据源的类型,那么就可以直接拿到数据库连接串url,如果无法判断出数据源类型,则可以选择先从数据源中获取一个数据库连接,然后再从数据库连接的元数据信息中拿到url

获取到url后,就按照如下两种url格式来解析出数据库服务端地址和数据库名。

jdbc:mysql://数据库服务端地址/数据库名
jdbc:mysql://数据库服务端地址/数据库名?配置项1=配置值1

2. SQL语句和SQL参数获取

继续在上一小节的装饰器中添加代码,完成SQL语句和SQL参数的获取,如下所示。

/**
 * 作用于{@link Executor}的装饰器,装饰{@link Span}。
 */
public class HoneyDbExecutorTracingSpanDecorator implements HoneyDbExecutorTracingDecorator {

    private static final String QUESTION_MARK = "?";
    private static final String COMMA = ",";
    private static final String NULL_STR = "null";
    private static final char SPACE_CHAR = ' ';
    private static final String LINE_BREAK_REGEX = "\n";

    @Override
    public void onExecute(Invocation invocation, Span span) {
        // 设置数据库服务端地址信息和数据库名
        try {
            DataSource dataSource = ((MappedStatement) invocation.getArgs()[0]).getConfiguration().getEnvironment().getDataSource();
            assembleDbServerAndName(dataSource, (JaegerSpan) span);
        } catch (Exception e) {
            // do nothing
        }

        // 设置SQL语句和参数信息
        try {
            assembleSqlTextAndParams(invocation.getArgs(), (JaegerSpan) span);
        } catch (Exception e) {
            // do nothing
        }
    }

    @Override
    public void onFinish(Invocation invocation, Span span) {
        // do nothing
    }

    @Override
    public void onError(Invocation invocation, Exception exception, Span span) {
        // do nothing
    }

    private void assembleDbServerAndName(DataSource dataSource, JaegerSpan span) {
        String url = StringUtils.EMPTY;
        try {
            if (dataSource instanceof HikariDataSource) {
                url = ((HikariDataSource) dataSource).getJdbcUrl();
            } else if (dataSource instanceof DruidDataSource) {
                url = ((DruidDataSource) dataSource).getUrl();
            } else if (dataSource instanceof org.apache.tomcat.jdbc.pool.DataSource) {
                url = ((org.apache.tomcat.jdbc.pool.DataSource) dataSource).getUrl();
            } else {
                // 无法判断数据库连接池类型的情况下才通过连接拿url
                Connection connection = dataSource.getConnection();
                DatabaseMetaData metaData = connection.getMetaData();
                url = metaData.getURL();
            }
        } catch (Exception e) {
            // do nothing
        }
        if (StringUtils.isNotEmpty(url)) {
            // 从连接串中解析出数据库服务端地址信息
            int left = url.indexOf(SLASH_DOUBLE) + 2;
            int mid = url.indexOf(SLASH, left);
            int right = url.indexOf(QUESTION_MARK);
            span.setTag(FIELD_DB_SERVER, url.substring(left, mid));
            if (right == -1) {
                span.setTag(FIELD_DB_NAME, url.substring(mid + 1));
            } else {
                span.setTag(FIELD_DB_NAME, url.substring(mid + 1, right));
            }
        }
    }

    private void assembleSqlTextAndParams(Object[] args, Span span) {
        MappedStatement mappedStatement = ((MappedStatement) args[0]);
        // 先获取SQL
        BoundSql boundSql;
        if (args.length == 6) {
            boundSql = ((BoundSql) args[5]);
        } else {
            boundSql = mappedStatement.getBoundSql(args[1]);
        }
        span.setTag(FIELD_SQL_TEXT, toPrettySql(boundSql.getSql()));

        // 再获取Params
        List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
        Object parameterObject = boundSql.getParameterObject();
        TypeHandlerRegistry typeHandlerRegistry = mappedStatement.getConfiguration().getTypeHandlerRegistry();
        if (null != parameterMappings) {
            String[] paramStrs = new String[parameterMappings.size()];
            for (int i = 0; i < parameterMappings.size(); i++) {
                ParameterMapping parameterMapping = parameterMappings.get(i);
                if (parameterMapping.getMode() != ParameterMode.OUT) {
                    Object value;
                    String propertyName = parameterMapping.getProperty();
                    if (boundSql.hasAdditionalParameter(propertyName)) {
                        value = boundSql.getAdditionalParameter(propertyName);
                    } else if (parameterObject == null) {
                        value = null;
                    } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
                        value = parameterObject;
                    } else {
                        MetaObject metaObject = mappedStatement.getConfiguration().newMetaObject(parameterObject);
                        value = metaObject.getValue(propertyName);
                    }
                    if (null == value) {
                        paramStrs[i] = NULL_STR;
                    } else {
                        paramStrs[i] = value.toString();
                    }
                }
            }
            String sqlParamsStr = String.join(COMMA, paramStrs);
            span.setTag(FIELD_SQL_PARAMS, sqlParamsStr);
        }
    }

    private String toPrettySql(String sql) {
        // 去除换行符
        sql = sql.replaceAll(LINE_BREAK_REGEX, StringUtils.EMPTY);
        // 去除多余空格
        StringBuilder sqlBuilder = new StringBuilder();
        boolean necessarySpace = true;
        for (int i = 0; i < sql.length(); i++) {
            if (sql.charAt(i) == SPACE_CHAR) {
                if (necessarySpace) {
                    sqlBuilder.append(sql.charAt(i));
                }
                necessarySpace = false;
            } else {
                sqlBuilder.append(sql.charAt(i));
                necessarySpace = true;
            }
        }
        return sqlBuilder.toString();
    }

}

先从拦截的方法的参数中拿到MappedStatement,然后再从MappedStatement中拿到BoundSql,我们需要的SQL语句和SQL参数,都在BoundSql中,其中SQL语句的获取比较简单,直接通过BoundSql就可以拿到SQL语句,我们需要做的就是把多余的空格和换行符给去掉,让SQL看起来好看一些。而参数要稍微麻烦一点,因为参数是不确定的,在BoundSql中是这样来表示参数的。

public class BoundSql {

    ......

    private final List<ParameterMapping> parameterMappings;
    private final Object parameterObject;
 
    ......

}

实际就是要使用parameterMappingsparameterObject共同来解析出SQL参数,这里的解析逻辑,直接参考的DefaultParameterHandler中的代码。

至此数据库链路追踪装饰器就实现完毕了,使用到的一些常量如下所示。

public class CommonConstants {

    public static final double DEFAULT_SAMPLE_RATE = 1.0;

    public static final String HONEY_TRACER_NAME = "HoneyTracer";
    public static final String HONEY_REST_TEMPLATE_NAME = "HoneyRestTemplate";
    public static final String HONEY_KAFKA_NAME = "HoneyKafka";
    public static final String HONEY_DB_NAME = "HoneyDb";

    public static final String FIELD_HOST = "host";
    public static final String FIELD_API = "api";
    public static final String FIELD_HTTP_CODE = "httpCode";
    public static final String FIELD_SUB_SPAN_ID = "subSpanId";
    public static final String FIELD_SUB_HTTP_CODE = "subHttpCode";
    public static final String FIELD_SUB_TIMESTAMP = "subTimestamp";
    public static final String FIELD_SUB_DURATION = "subDuration";
    public static final String FIELD_SUB_HOST = "subHost";

    public static final String FIELD_DB_SERVER = "dbServer";
    public static final String FIELD_DB_NAME = "dbName";
    public static final String FIELD_SQL_TEXT = "sqlText";
    public static final String FIELD_SQL_PARAMS = "sqlParams";
    public static final String FIELD_SQL_DURATION = "sqlDuration";
    public static final String FIELD_SQL_TIMESTAMP = "sqlTimestamp";

    public static final String HOST_PATTERN_STR = "(?<=(https://|http://)).*?(?=/)";

    public static final String SLASH = "/";
    public static final String SLASH_DOUBLE = "//";

    public static final String LOG_EVENT_KIND = "logEventKind";
    public static final String LOG_EVENT_KIND_REQUEST_STACK = "requestStack";
    public static final String LOG_EVENT_KIND_DB_STACK = "dbStack";

}

五. 注册MyBatis拦截器

MyBatis中注册拦截器,其实就是拿到MyBatisConfiguration后,调用其addInterceptor() 方法即可,所以有两种实现思路。

  1. 提供ConfigurationCustomizer并在其customize() 方法中添加拦截器。ConfigurationCustomizermybatis-spring-boot-starter中提供出来专门用于定制化Configuration的,所以如果有使用mybatis-spring-boot-starter,那么可以基于ConfigurationCustomizer来添加拦截器到Configuration中;
  2. 自定义BeanPostProcessor并处理所有SqlSessionFactory。因为MyBatis整合到Spring中后,很核心的一点就是SqlSessionFactory会作为bean被注册到Spring容器中,所以可以提供一个BeanPostProcessor来处理所有的SqlSessionFactory,通过SqlSessionFactory拿到其持有的Configuration,然后调用addInterceptor() 方法添加拦截器。

注意,在使用了mybatis-spring-boot-starter后,其实我们只需要将拦截器注册到Spring容器中即可,mybatis-spring-boot-starter提供的MybatisAutoConfiguration会获取到所有Spring容器中的拦截器,然后在构建SqlSessionFactory时会把拦截器都添加到Configuration中,所以此时我们再提供一个ConfigurationCustomizer,实际是会重复添加拦截器的,因此下面通过自定义BeanPostProcessor的方式来注册拦截器。

首先自定义一个BeanPostProcessor,如下所示。

public class SqlSessionFactoryBeanPostProcessor implements BeanPostProcessor {

    private final List<Interceptor> interceptors;

    public SqlSessionFactoryBeanPostProcessor(List<Interceptor> interceptors) {
        if (null == interceptors) {
            this.interceptors = new ArrayList<>();
        } else {
            this.interceptors = interceptors;
        }
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if (bean instanceof SqlSessionFactory) {
            for (Interceptor interceptor : interceptors) {
                ((SqlSessionFactory) bean).getConfiguration().addInterceptor(interceptor);
            }
        }
        return bean;
    }

}

然后提供一个自动装配类HoneyDbTracingConfig,如下所示。

@Configuration
@ConditionalOnClass(org.apache.ibatis.session.Configuration.class)
@AutoConfigureAfter(HoneyTracingConfig.class)
public class HoneyDbTracingConfig {

    @Bean
    public HoneyDbExecutorTracingInterceptor honeyDbExecutorTracingInterceptor(
            Tracer tracer, List<HoneyDbExecutorTracingDecorator> honeyDbExecutorTracingDecorators) {
        honeyDbExecutorTracingDecorators.add(new HoneyDbExecutorTracingSpanDecorator());
        return new HoneyDbExecutorTracingInterceptor(tracer, honeyDbExecutorTracingDecorators);
    }

    @Bean
    @ConditionalOnMissingClass("org.mybatis.spring.boot.autoconfigure.ConfigurationCustomizer")
    public BeanPostProcessor sqlSessionFactoryBeanPostProcessor(List<Interceptor> interceptors) {
        return new SqlSessionFactoryBeanPostProcessor(interceptors);
    }

}

最后在spring.factories文件中添加上述自动装配类,如下所示。

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
 com.honey.tracing.config.HoneyTracingConfig,\
 com.honey.tracing.config.HoneyTracingFilterConfig,\
 com.honey.tracing.config.HoneyRestTemplateTracingConfig,\
 com.honey.tracing.config.HoneyKafkaTemplateConfig,\
 com.honey.tracing.config.HoneyKafkaTracingConfig,\
 com.honey.tracing.config.HoneyDbTracingConfig

六. 链路日志打印

现在还需要在原有链路日志打印的基础上,把dbStack添加进去。

定义HoneyDbStack表示链路日志中的dbStacks字段,如下所示。

public class HoneyDbStack {

    private String dbServer;
    private String dbName;
    private String sqlText;
    private String sqlParams;
    private String sqlDuration;
    private String sqlTimestamp;

    private HoneyDbStack() {

    }

    // 省略getter和setter
    
    public static class HoneyDbStackBuilder {
        private LogData logData;

        private HoneyDbStackBuilder() {

        }

        public static HoneyDbStackBuilder builder() {
            return new HoneyDbStackBuilder();
        }

        public HoneyDbStackBuilder withLogData(LogData logData) {
            this.logData = logData;
            return this;
        }

        public HoneyDbStack build() {
            if (logData == null || logData.getFields() == null) {
                throw new HoneyTracingException();
            }
            Map<String, ?> logDataFields = logData.getFields();
            HoneyDbStack honeyDbStack = new HoneyDbStack();
            honeyDbStack.dbServer = (String) logDataFields.get(FIELD_DB_SERVER);
            honeyDbStack.dbName = (String) logDataFields.get(FIELD_DB_NAME);
            honeyDbStack.sqlText = (String) logDataFields.get(FIELD_SQL_TEXT);
            honeyDbStack.sqlParams = (String) logDataFields.get(FIELD_SQL_PARAMS);
            honeyDbStack.sqlDuration = new BigDecimal(String.valueOf(logDataFields.get(FIELD_SQL_DURATION)))
                    .divide(BigDecimal.valueOf(1000), SCALE , RoundingMode.DOWN).toString();
            honeyDbStack.sqlTimestamp = new BigDecimal(String.valueOf(logDataFields.get(FIELD_SQL_TIMESTAMP)))
                    .divide(BigDecimal.valueOf(1000), SCALE , RoundingMode.DOWN).toString();
            return honeyDbStack;
        }
    }

}

然后在HoneySpanReportEntity中做如下修改。

public class HoneySpanReportEntity {

    ......

    private List<HoneyDbStack> dbStacks = new ArrayList<>();

    public void addDbStack(HoneyDbStack honeyDbStack) {
        dbStacks.add(honeyDbStack);
    }

    // 省略getter和setter

    ......

    public static class HoneySpanReportEntityBuilder {

        ......
        
        private Consumer<LogData> handleLogData(HoneySpanReportEntity honeySpanReportEntity) {
            return new Consumer<LogData>() {
                @Override
                public void accept(LogData logData) {
                    if (LOG_EVENT_KIND_REQUEST_STACK.equals(logData.getFields().get(LOG_EVENT_KIND))) {
                        HoneyRequestStack honeyRequestStack = HoneyRequestStack.HoneyRequestStackBuilder
                                .builder()
                                .withLogData(logData)
                                .build();
                        honeySpanReportEntity.addRequestStack(honeyRequestStack);
                    } else if (LOG_EVENT_KIND_DB_STACK.equals(logData.getFields().get(LOG_EVENT_KIND))) {
                        HoneyDbStack honeyDbStack = HoneyDbStack.HoneyDbStackBuilder
                                .builder()
                                .withLogData(logData)
                                .build();
                        honeySpanReportEntity.addDbStack(honeyDbStack);
                    }
                }
            };
        }
    }

}

如此,在打印链路日志时,就会带上dbStack了。

七. 演示案例

改造example-service-1,进行数据库链路追踪的测试。

首先pom文件添加如下依赖。

<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.2.2</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
</dependency>

由于需要把映射文件打进jar包,pom文件还需要添加如下构建步骤。

<build>
    <resources>
        <resource>
            <directory>src/main/java</directory>
            <includes>
                <include>**/*.xml</include>
            </includes>
            <filtering>false</filtering>
        </resource>
    </resources>
</build>

然后使用如下DDL语句在MySQL数据库中创建一张表。

CREATE TABLE `people` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `p_name` varchar(255) NOT NULL,
  `p_age` int(11) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

这张表的映射接口,映射文件和对应实体对象如下所示。

public interface PeopleMapper {

    People selectOne(@Param("peopleName") String peopleName,
                     @Param("peopleAge") int peopleAge);

}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.honey.tracing.example.mapper.PeopleMapper">
    <resultMap id="peopleResultMap" type="com.honey.tracing.example.entity.People">
        <id property="id" column="id"/>
        <result property="peopleName" column="p_name"/>
        <result property="peopleAge" column="p_age"/>
    </resultMap>

    <select id="selectOne" resultMap="peopleResultMap">
        SELECT
            p.id,
            p.p_name,
            p.p_age
        FROM
            people p
        WHERE
            p.p_name=#{peopleName}
        AND
            p.p_age=#{peopleAge}
    </select>

</mapper>
public class People {

    private int id;
    private String peopleName;
    private int peopleAge;

    // 省略getter和setter

}

然后在配置文件中加入数据库相关配置。

spring:
  datasource:
    username: root
    password: root
    url: jdbc:mysql://192.168.101.5:3306/test
    type: com.zaxxer.hikari.HikariDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver

由于并没有通过mybatis.mapper-locations来指定映射文件位置,所以我们需要在启动类上添加@MapperScan注解来扫描得到映射接口和映射文件,如下所示。

@MapperScan
@EnableAsync
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}

最后提供一个MyBatisController来查询数据库,如下所示。

@RestController
public class MyBatisController {

    @Autowired
    private PeopleMapper peopleMapper;

    @GetMapping("/mybatis/select")
    public People selectOne(@RequestParam("peopleName") String peopleName,
                            @RequestParam("peopleAge") int peopleAge) {
        return peopleMapper.selectOne(peopleName, peopleAge);
    }

}

启动example-service-1,调用如下接口。

http://localhost:8080/mybatis/select?peopleName=Lee&peopleAge=20

链路日志打印如下。

{
    "traceId": "a33ddc958ecc4a46cf2a63b19a6fd064",
    "spanId": "cf2a63b19a6fd064",
    "parentSpanId": "0000000000000000",
    "timestamp": "1709298263420",
    "duration": "7",
    "httpCode": "200",
    "host": "http://localhost:8080",
    "requestStacks": [],
    "dbStacks": [
        {
            "dbServer": "192.168.101.8:3306",
            "dbName": "test",
            "sqlText": "SELECT p.id, p.p_name, p.p_age FROM people p WHERE p.p_name=? AND p.p_age=?",
            "sqlParams": "Lee,20",
            "sqlDuration": "4",
            "sqlTimestamp": "1709298263421"
        }
    ]
}

总结

本文基于MyBatis拦截器,实现了数据库链路追踪,核心思路就是通过拦截器,拿到本次SQL执行的相关信息,并输出到链路信息中。