服务从mysql迁移达梦数据库

134 阅读5分钟

一、背景

根据信创国产适配要求,针对业务系统迁移至达梦数据库,保持系统稳定、可靠。 当前系统技术栈:springboot+mybatis(3.5.9)+mybaits plus 我们采用的是拦截器方案,结合JavaAgent字节码增强技术,实现最小化代码改动。

二、迁移方案

  • 通过拦截器实现
  • 通过mybatis自带的databaseId 多数据源机制

三、梳理现有使用方式

使用方式demoMapperXmldemoMapperAnnodemoMapperdemoBeanServicequeryWrap

四、方案实现

3.1 拦截器方式实现

主要实现两个拦截器:

拦截器 作用 备注 mapper调用方法级别拦截器 拦截复杂sql的执行方法 精确到单个方法的拦截,仅对达梦不支持的方法进行更改 sql函数拦截器 简单的函数替换例如(​group_concat->WM_CONCA )

3.1.1 方法拦截器

实现原理:

在MyBatis调用Mapper方法时进行拦截 自动将原方法调用重定向到对应的DM版本方法 示例:

// 原方法
@Select("SELECT * FROM user WHERE id = #{id}")
User selectById(@Param("id") Long id); 
// DM专用方法(仅当原SQL不兼容时需实现)
@Select("SELECT * FROM user WHERE id = #{id}") // 修改后的DM兼容SQL
User selectByIdDM(@Param("id") Long id);

拦截逻辑:

检查是否存在原方法名+DM后缀的方法 存在则调用DM版本方法,否则调用原方法

3.1.2 sql函数拦截器

对sql的进行拦截并处理,处理简单函数转换

例如:

group_concat → WM_CONCAT 迁移SQL分类处理

类别处理方式描述示例
无需更改直接执行DM 数据库原生兼容的 SQL 语法,无需任何转换。SELECT * FROM employees WHERE id = 1;
仅替换函数函数拦截器处理仅需将源数据库特有的函数名一对一映射为 DM 的等效函数。GROUP_CONCAT(name) → WM_CONCAT(name)
需要重写SQL方法拦截器 + DM 方法SQL 的语义或语法结构不同,需用 DM 的特定语法重写整条语句。REPLACE INTO table ... → MERGE INTO table ...
重写SQL + 替换函数方法拦截器 + DM 方法复杂的 SQL 语句,其结构需要重写,并且其中包含需要替换的特殊函数包含 GROUP_CONCAT 和 LIMIT 分页的复杂查询,需重写为使用 WM_CONCAT 和 ROWNUM 的 DM 语法。

企业微信截图_1770255416733.png

代码整体情况

方法拦截器代码

import cn.hutool.json.JSONUtil;
import java.util.Properties;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
 
// 定义拦截器签名
@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}),
        @Signature(
                type = Executor.class,
                method = "query",
                args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})
})
//@Component
@Slf4j
public class MethodInterceptor implements Interceptor {
  @Value("${task.dbType:mysql}")
  private String dbType; // 数据库类型配置
 
  @Override
  public Object plugin(Object target) {
    return Plugin.wrap(target, this); // 包装目标对象
  }
 
  @Override
  public void setProperties(Properties properties) {} // 设置属性方法
 
  @Override
  public Object intercept(Invocation invocation) throws Throwable {
    System.out.println("intercept-开始执行方法-" + invocation.getMethod().getName());
    // 获取StatementHandler
    Object[] args = invocation.getArgs(); // 方法参数
    MappedStatement ms = (MappedStatement) args[0]; // 映射语句
    String methodId = ms.getId(); // 方法ID
    RowBounds rowBounds = (RowBounds)args[2];
    // 如果是DM数据库且方法名不带Dm后缀
    if ("dm".equals(dbType) && !methodId.endsWith("DM")) {
      try {
        // 尝试查找对应的Dm方法
        Configuration configuration = ms.getConfiguration();
        // 处理 namespace 逻辑
        String fullMethodId = methodId;
        String noFullMethodId = methodId;
        if (!methodId.contains(".")) {
          // 如果不包含点(.),说明是简短的方法ID,需要加上namespace
          String namespace = ms.getResource().replace(".", "/"); // 获取namespace
          fullMethodId = namespace + "." + methodId;
        }else{
           noFullMethodId = methodId.substring(methodId.lastIndexOf(".") + 1);
        }
 
        String dmFullMethodId = fullMethodId + "DM";
        String dmNoFullMethodId = noFullMethodId + "DM";
        String dmMethodId = dmFullMethodId;
        MappedStatement dmMs = getDmMappedStatement(configuration, dmFullMethodId);
        if(dmMs == null){
          dmMethodId = dmNoFullMethodId;
          dmMs = getDmMappedStatement(configuration, dmNoFullMethodId);
        }
        Object parameter = args[1];
        // 如果找到Dm方法则替换执行
        if (dmMs != null) {
          BoundSql newBoundSql = dmMs.getBoundSql(parameter);
          log.info("intercept执行的方法是-" + dmMethodId);
          args[0] = dmMs; // 替换为DM方法
          if (args.length == 4) {
            args[2] = newBoundSql;
          } else {
            args[5] = newBoundSql;
          }
 
        }
      } catch (Exception e) {
        log.error("intercept-没有找到对应的Dm方法-继续执行原方法-或尝试使用sql替换-" + methodId,e);
      }
    }
    Object proceed = invocation.proceed(); // 执行拦截方法
    log.info(JSONUtil.parse(proceed).toStringPretty()); // 记录执行结果
    return proceed;
  }
  public MappedStatement getDmMappedStatement(Configuration configuration, String methodId) {
    return configuration.getMappedStatement(methodId);
  }
}

sql拦截器代码

@Intercepts({
        @Signature(
                type = StatementHandler.class,
                method = "prepare",
                args = {Connection.class, Integer.class})
})
@Log
public class ReWriteInterceptor implements Interceptor {
  @Override
  public Object intercept(Invocation invocation) throws Throwable {
    // 获取 StatementHandler
    StatementHandler handler = (StatementHandler) invocation.getTarget();
    BoundSql boundSql = handler.getBoundSql();
    String originalSql = boundSql.getSql();
    log.info("OriginalSql: " + originalSql.replaceAll("\n"," "));
    String rewrittenSql = rewriteSql(originalSql);
 
    // 用反射重新设置 SQL
    Field sqlField = boundSql.getClass().getDeclaredField("sql");
    sqlField.setAccessible(true);
    sqlField.set(boundSql, rewrittenSql);
    Object proceed = invocation.proceed();
    return proceed;
  }
 
  private String rewriteSql(String originalSql) {
    // mysql IFNULL(col1,col2) => NVL(col1,col2) dm
      originalSql = originalSql.replaceAll("(?i)GROUP_CONCAT", "WM_CONCAT");
      originalSql = originalSql.replaceAll("(?i)!JSON_CONTAINS", " not JSON_CONTAINS ");
      originalSql = originalSql.replaceAll("(?i)INSERT\\s+IGNORE\\s+INTO", "INSERT INTO ");
      originalSql = convertCastToToChar(originalSql);
      log.info("OriginalSql: " + originalSql+"\nRewrittenSql: " + originalSql);
      return originalSql;
  }
 
  /**
   * 将cast(... as char)转换为to_char(...)格式
   * @param sql
   * @return {@link String}
   */
  public static String convertCastToToChar(String sql) {
    // 正则表达式匹配CAST(... AS CHAR)模式
    Pattern pattern = Pattern.compile(
            "CONVERT\\s*\\(\\s*(.+?)\\s*,\\s*(?:CHAR|VARCHAR|VARCHAR2|TEXT)\\s*(?:\\(\\d+\\))?\\s*\\)",
            Pattern.CASE_INSENSITIVE
    );
 
    Matcher matcher = pattern.matcher(sql);
    StringBuffer result = new StringBuffer();
 
    while (matcher.find()) {
      // 获取CAST内部的表达式
      String innerExpression = matcher.group(1).trim();
      // 替换为TO_CHAR格式
      matcher.appendReplacement(result, "TO_CHAR(" + innerExpression + ")");
    }
    matcher.appendTail(result);
 
    return result.toString();
  }

mybatis2dmAutoConfiguration 代码

@Configuration
@ConditionalOnExpression("'${spring.datasource.url}'.startsWith('jdbc:dm:')")//必须是达梦的驱动才生效
public class mybatis2dmAutoConfiguration {
    @Bean
    public DmMethodRouterInterceptor dmMethodRouterInterceptor() {
        return new DmMethodRouterInterceptor();
    }
    @Bean
    public ReWriteInterceptor reWriteInterceptor() {
        return new ReWriteInterceptor();
    }
    @Bean
    public ConfigurationCustomizer mybatisConfigurationCustomizer() {
        return configuration -> {
            configuration.addInterceptor(new DmMethodRouterInterceptor());
            configuration.addInterceptor(new ReWriteInterceptor());
        };
    }
}

3.2 通过mybatis自带的databaseId 多数据源机制

话不多说,直接上代码。

MyBatisConfig 代码

@Configuration
public class MyBatisConfig {
    
    @Bean
    public DatabaseIdProvider databaseIdProvider() {
        VendorDatabaseIdProvider databaseIdProvider = new VendorDatabaseIdProvider();
        Properties properties = new Properties();
        properties.setProperty("MySQL", "mysql");
        properties.setProperty("DM DBMS", "dm");
        databaseIdProvider.setProperties(properties);
        return databaseIdProvider;
    }
}

xml代码

<!-- 通用SQL(所有数据库都会尝试使用) -->
<select id="selectUser" resultType="User">
  SELECT * FROM user
</select>
 
<!-- MySQL专用SQL(仅MySQL使用) -->
<select id="selectUser" resultType="User" databaseId="mysql">
  SELECT * FROM user LIMIT 1
</select>
 
<!-- DM专用SQL(仅达梦使用) -->
<select id="selectUser" resultType="User" databaseId="dm">
  SELECT TOP 1 * FROM user
</select>

在运行代码过程中,会根据databaseId来选择数据源运行对象的sql。

方案对比

方案特性拦截器方式原生 MyBatis DatabaseId
实现原理通过自定义 MyBatis 拦截器(及JavaAgent字节码增强)动态解析和重写SQL依赖 MyBatis 内置的 <databaseIdProvider> 和 XML 中 databaseId 属性
支持的映射方式
- XML 映射文件 (demoMapperXml)✅ 支持✅ 支持
- 注解方式 (demoMapperAnno)✅ 支持❌ 不支持
- MyBatis-Plus 等扩展 (demoMapper)✅ 支持❌ 不支持
- Service/Bean 中的直接SQL (demoBeanService)✅ 支持❌ 不支持
- 查询包装器 (queryWrap)✅ 支持❌ 不支持
灵活性:可处理任意位置的SQL,支持复杂逻辑和函数替换:仅限XML中定义的语句,无法处理运行时生成的SQL
侵入性低:对业务代码基本无侵入,通过配置和字节码增强实现中:需要在所有XML语句中显式定义 databaseId 属性
维护成本集中维护拦截规则,一改全改分散维护,每个XML语句都需要适配不同数据库的版本

实施步骤

  • 准备工作: 对SQL及其mapper方法进行分类标注
  • 开发拦截器: 实现方法级别拦截逻辑 配置函数替换规则
  • JavaAgent集成: 打包拦截器为JavaAgent 配置JVM启动参数加载Agent
  • 部署上线: 添加JavaAgent到生产环境启动参数 代码在最后面,具体的工程我后续会上传至:

github.com/qqinc/mybat…

优势

最小化代码改动:仅需为不兼容的方法添加DM版本 非侵入式:通过字节码增强避免直接修改业务代码 灵活可控:可精确控制哪些方法需要特殊处理 易于维护:DM相关修改集中管理,与主逻辑分离 补充点: 事务都是支持的

最终实现代码

image.png

整体工程结构

RewriteAgent

public class RewriteAgent {
    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("mysql2dm agent start");
 
        AgentBuilder agentBuilder = new AgentBuilder.Default()
                .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
                .with(AgentBuilder.Listener.StreamWriting.toSystemOut().withTransformationsOnly());
 
// 拦截 BaseExecutor.query 和 update
        agentBuilder = agentBuilder
                .type(ElementMatchers.named("org.apache.ibatis.executor.BaseExecutor"))
                .transform((builder, typeDescription, classLoader, module) ->
                        builder
                                .method(ElementMatchers.named("query").or(ElementMatchers.named("update")))
                                .intercept(Advice.to(ExecutorAdvice.class))
                )
 
                // ⚠️ 注意:下面这个拦截器是追加,而不是覆盖上面
                .type(ElementMatchers.named("org.apache.ibatis.executor.statement.RoutingStatementHandler"))
                .transform((builder, typeDescription, classLoader, module) ->
                        builder
                                .method(ElementMatchers.named("prepare"))
                                .intercept(Advice.to(SqlRewriteAdvice.class))
                );
        agentBuilder.installOn(inst);
    }
}

BoundSqlHolder

public class BoundSqlHolder {
    private static final ThreadLocal<BoundSql> holder = new ThreadLocal<>();
 
    public static void setBoundSql(BoundSql boundSql) {
        holder.set(boundSql);
    }
 
    public static BoundSql getBoundSql() {
        return holder.get();
    }
 
    public static void clear() {
        holder.remove();
    }
}

ExecutorAdvice

public class ExecutorAdvice {
    @Advice.OnMethodEnter
    public static void enter(@Advice.AllArguments Object[] args) {
        try {
            MappedStatement originalMs = (MappedStatement) args[0];
            String methodId = originalMs.getId();
            if (methodId != null && !methodId.endsWith("DM")) {
                Configuration config = originalMs.getConfiguration();
                String dmMethodId = methodId + "DM";
 
                if (config.hasStatement(dmMethodId)) {
                    MappedStatement dmMs = config.getMappedStatement(dmMethodId);
                    System.out.println("[ExecutorAdvice] 替换为 DM 方法: " + dmMethodId);
                    args[0] = dmMs;
                    Object parameter = args[1];
                    BoundSql newBoundSql = dmMs.getBoundSql(parameter);
                    BoundSqlHolder.setBoundSql(newBoundSql);
                    System.out.println("[ExecutorAdvice] 替换后的 SQL: " + newBoundSql.getSql());
                }
            }
        } catch (Exception e) {
            System.err.println("[ExecutorAdvice] 替换失败: " + e.getMessage());
        }
    }
}

SqlRewriteAdvice

public class SqlRewriteAdvice {
    @Advice.OnMethodEnter
    public static void enter(@Advice.This Object handlerObj,
                             @Advice.Origin String method,
                             @Advice.AllArguments Object[] args) {
        try {
            StatementHandler handler = (StatementHandler) handlerObj; // 手动强转(运行时)
            Field delegateField = handler.getClass().getDeclaredField("delegate");
            delegateField.setAccessible(true);
            StatementHandler realHandler = (StatementHandler) delegateField.get(handler);
            BoundSql boundSql = handler.getBoundSql();
            BoundSql newBoundSql = BoundSqlHolder.getBoundSql();
            if (newBoundSql != null) {
                String originalSql = newBoundSql.getSql();
                System.out.println("Original SQL: " + originalSql);
                // 重写 SQL
                String rewrittenSql = rewriteSql(originalSql);
                Field boundSqlField = realHandler.getClass().getSuperclass().getDeclaredField("boundSql");
                boundSqlField.setAccessible(true);
                boundSqlField.set(realHandler, newBoundSql);
                System.out.println("Rewritten SQL: " + rewrittenSql);
            }else {
                String originalSql = boundSql.getSql();
                System.out.println("Original SQL: " + originalSql);
                String rewrittenSql = rewriteSql(originalSql);
                Field sqlField = boundSql.getClass().getDeclaredField("sql");
                sqlField.setAccessible(true);
                sqlField.set(boundSql, rewrittenSql);
                System.out.println("Rewritten SQL: " + rewrittenSql);
            }
 
        } catch (Exception e) {
            System.out.println("SQL Rewrite Error: " + e);
 
        }finally {
            // 清理,防止内存泄漏
            BoundSqlHolder.clear();
        }
    }
    public static String rewriteSql(String originalSql) {
        originalSql = originalSql.replaceAll("(?i)GROUP_CONCAT", "WM_CONCAT");
        originalSql = originalSql.replaceAll("(?i)!JSON_CONTAINS", " not JSON_CONTAINS ");
        originalSql = originalSql.replaceAll("(?i)INSERT\\s+IGNORE\\s+INTO", "INSERT INTO ");
        originalSql = originalSql.replaceAll("(?i)GROUP_CONCAT", "WM_CONCAT");
        return originalSql;
    }

在启动方法参数上添加:

-javaagent:E:\\cbimWorkSpace\\mybatis2dm-agent\\target\\mybatis2dm-agent-1.0-SNAPSHOT.jar

完结,撒花!