手撸一个动态 SQL 执行引擎:不重启服务,在线增删改查任意数据库

先说结论

把 SQL 方法和数据源配置存到数据库里,通过 REST API 动态执行。改了立刻生效,不用重启。

一张图看懂架构:

scss 复制代码
                        ┌──────────────────────────────────────────┐
                        │           REST API (/dynamic-sql)        │
                        │  execute | method/update | ds/create ... │
                        └──────────────────┬───────────────────────┘
                                           │
                                           ▼
                        ┌──────────────────────────────────────────┐
                        │          DynamicMethodEngine             │
                        │              (调度中心)                    │
                        └─────────┬─────────────────┬─────────────┘
                                  │                 │
                     ┌────────────▼──────┐  ┌───────▼──────────────┐
                     │  DynamicMethodCache│  │DynamicDataSourceManager│
                     │  (Caffeine 缓存)    │  │ (Caffeine + HikariCP)  │
                     └────────┬──────────┘  └────────┬──────────────┘
                              │                       │
                  ┌───────────▼──────────┐   ┌────────▼───────────┐
                  │  MyBatisXmlCompiler  │   │  JdbcTemplate       │
                  │  XML → SqlSource     │   │  (每库独立连接池)     │
                  └───────────┬──────────┘   └────────┬───────────┘
                              │                       │
                              └───────────┬───────────┘
                                          ▼
                                  ┌───────────────┐
                                  │  SqlExecutor   │
                                  │  安全校验 + 执行 │
                                  └───────────────┘

核心思路:MyBatis 的 SqlSource 只做 SQL 解析和参数绑定,JdbcTemplate 负责执行,两者结合实现了"动态 SQL + 多数据源"。


一、建两张元数据表

SQL 方法和数据源配置都存在 MySQL 里,应用启动时不需要提前知道有哪些库、哪些方法。

sql 复制代码
-- 数据源配置表
CREATE TABLE dynamic_datasource_config (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL COMMENT '数据源名称',
    db_type VARCHAR(30) NOT NULL COMMENT '数据库类型',
    jdbc_url VARCHAR(500) NOT NULL COMMENT 'JDBC连接地址',
    username VARCHAR(100) NOT NULL,
    password VARCHAR(200) NOT NULL,
    driver_class_name VARCHAR(200) NOT NULL,
    enabled TINYINT NOT NULL DEFAULT 1,
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- SQL 方法定义表
CREATE TABLE dynamic_sql_method (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    method_id VARCHAR(128) NOT NULL COMMENT '方法唯一标识',
    name VARCHAR(100) COMMENT '方法名称',
    command_type VARCHAR(20) NOT NULL COMMENT 'SELECT/INSERT/UPDATE/DELETE',
    xml_fragment TEXT NOT NULL COMMENT 'MyBatis XML片段',
    enabled TINYINT NOT NULL DEFAULT 1,
    version BIGINT NOT NULL DEFAULT 1,
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_method_id (method_id)
);

往里插一条数据源和一条方法,就能用了。


二、最核心的一行:执行入口

整个引擎的调度就这几行,核心逻辑非常清晰:

java 复制代码
@Service
public class DynamicMethodEngine {

    private final DynamicMethodCache methodCache;
    private final DynamicDataSourceManager dataSourceManager;
    private final SqlExecutor sqlExecutor;

    public Object execute(Long dataSourceId, String methodId, Map<String, Object> params) {
        // 1. 用 methodId 拿编译好的 SqlSource(带 Caffeine 缓存)
        CompiledDynamicMethod method = methodCache.get(methodId);
        // 2. 传入参数,让 MyBatis 动态生成最终 SQL(处理 if/where/foreach 等)
        BoundSql boundSql = method.getSqlSource().getBoundSql(params);
        // 3. 用 dataSourceId 拿 JdbcTemplate(带 Caffeine 缓存的独立连接池)
        JdbcTemplate jdbcTemplate = dataSourceManager.getJdbcTemplate(dataSourceId);
        // 4. 安全校验 + 执行
        return sqlExecutor.execute(jdbcTemplate, method.getCommandType(), boundSql, params);
    }
}

四个步骤,每个步骤拆成一个独立模块。下面逐个讲。


三、XML 编译:把 MyBatis XML 片段变成 SqlSource

这是引擎最关键的部分,也是踩坑最多的地方。

3.1 完整代码

java 复制代码
@Component
public class MyBatisXmlCompiler {

    private final Configuration configuration;
    private final XMLLanguageDriver languageDriver = new XMLLanguageDriver();

    public MyBatisXmlCompiler(SqlSessionFactory sqlSessionFactory) {
        this.configuration = sqlSessionFactory.getConfiguration();
    }

    public CompiledDynamicMethod compile(DynamicSqlMethod method) {
        // 校验 + 提取根标签内部内容
        String scriptContent = validateAndExtract(method.getXmlFragment(), method.getCommandType());

        // 关键:必须用 <script> 包裹,否则动态标签不生效
        String script = "<script>" + scriptContent + "</script>";

        SqlSource sqlSource = languageDriver.createSqlSource(configuration, script, Map.class);

        return new CompiledDynamicMethod(
                method.getMethodId(),
                method.getCommandType().toUpperCase(Locale.ROOT),
                method.getVersion(),
                sqlSource
        );
    }

    private String validateAndExtract(String xmlFragment, String commandType) {
        // 用 Jsoup 做格式校验(只校验,不序列化)
        Document document = Jsoup.parse(xmlFragment, "", Parser.xmlParser());
        Element root = document.children().first();

        if (root == null) {
            throw new IllegalArgumentException("XML 不能为空");
        }

        String tagName = root.tagName().toLowerCase(Locale.ROOT);
        if (!Set.of("select", "insert", "update", "delete").contains(tagName)) {
            throw new IllegalArgumentException("只支持 select/insert/update/delete");
        }

        if (!tagName.equalsIgnoreCase(commandType)) {
            throw new IllegalArgumentException("XML 标签类型和 commandType 不一致");
        }

        if (xmlFragment.contains("${")) {
            throw new IllegalArgumentException("禁止使用 ${},请使用 #{}");
        }

        // 从原始字符串截取根标签内部内容(不用 Jsoup 序列化,避免破坏动态标签属性)
        int tagEnd = xmlFragment.indexOf('>', xmlFragment.indexOf("<" + tagName));
        int closeStart = xmlFragment.lastIndexOf("</" + tagName + ">");
        return xmlFragment.substring(tagEnd + 1, closeStart);
    }
}

3.2 为什么要用 <script> 包裹?

这是整个引擎最大的坑。XMLLanguageDriver.createSqlSource() 有两种模式:

不加 <script> ------静态模式,只替换 #{} 占位符,所有 XML 标签原样输出

xml 复制代码
输入: "SELECT * FROM user WHERE id = #{id} <if test='name!=null'>AND name=#{name}</if>"
输出: "SELECT * FROM user WHERE id = ? <if test='name!=null'>AND name=?</if>"
                                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                                                  <if> 标签直接出现在 SQL 里!

加了 <script> ------动态模式,解析 <if><where><set><foreach> 等所有标签:

ini 复制代码
输入: "<script>SELECT * FROM user WHERE id = #{id} <if test='name!=null'>AND name=#{name}</if></script>"
name=null  时输出: "SELECT * FROM user WHERE id = ?"
name="张三"时输出: "SELECT * FROM user WHERE id = ? AND name = ?"

所以编译时要:提取根标签(<select>)内部的内容 → 包裹 <script> → 传给 MyBatis。

3.3 为什么不用 Jsoup 的 html() 做序列化?

第二个坑。<if test="name != null"> 经过 Jsoup 的 html() 输出,test 属性可能被破坏或丢失,因为 Jsoup 本质是 HTML 解析器,对非标准属性处理不可靠。

解法:Jsoup 只做校验(标签名、安全性),内容提取直接操作原始字符串,零篡改。


四、方法缓存:Caffeine + 编译结果

java 复制代码
@Component
public class DynamicMethodCache {

    private final DynamicMethodRepository repository;
    private final MyBatisXmlCompiler compiler;

    private final Cache<String, CompiledDynamicMethod> cache = Caffeine.newBuilder()
            .maximumSize(5000)                       // 最多缓存 5000 个方法
            .expireAfterAccess(Duration.ofHours(1))  // 1 小时未访问自动过期
            .build();

    public CompiledDynamicMethod get(String methodId) {
        // 缓存命中直接返回,未命中则从数据库加载 + 编译
        return cache.get(methodId, this::loadAndCompile);
    }

    public void invalidate(String methodId) {
        // 方法更新时手动失效,下次访问重新编译
        cache.invalidate(methodId);
    }

    private CompiledDynamicMethod loadAndCompile(String methodId) {
        DynamicSqlMethod method = repository.findEnabledByMethodId(methodId);
        return compiler.compile(method);
    }
}

为什么不用 ConcurrentHashMap?因为 Caffeine 有自动过期和淘汰,方法改了不用手动清理,过期后自动重新加载。


五、动态数据源:每个库一个 HikariCP 连接池

java 复制代码
@Component
public class DynamicDataSourceManager {

    private final DynamicDataSourceRepository repository;

    private final Cache<Long, JdbcTemplate> cache = Caffeine.newBuilder()
            .maximumSize(200)                           // 最多 200 个数据源
            .expireAfterAccess(Duration.ofMinutes(30))  // 30 分钟没访问自动关闭
            .removalListener((Long id, JdbcTemplate template, RemovalCause cause) -> {
                // 连接池过期时,主动关闭 HikariDataSource,不泄漏
                if (template != null && template.getDataSource() instanceof HikariDataSource ds) {
                    ds.close();
                }
            })
            .build();

    public JdbcTemplate getJdbcTemplate(Long dataSourceId) {
        // 缓存命中返回已有 JdbcTemplate,未命中则从数据库读配置创建新连接池
        return cache.get(dataSourceId, this::createJdbcTemplate);
    }

    public void invalidate(Long dataSourceId) {
        // 密码改了?调这个接口,旧连接池立刻关闭
        cache.invalidate(dataSourceId);
    }

    private JdbcTemplate createJdbcTemplate(Long dataSourceId) {
        DbConfig config = repository.findEnabledById(dataSourceId);

        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl(config.getJdbcUrl());
        ds.setUsername(config.getUsername());
        ds.setPassword(config.getPassword());
        ds.setDriverClassName(config.getDriverClassName());
        ds.setPoolName("dynamic-ds-" + dataSourceId);
        ds.setMaximumPoolSize(3);     // 动态数据源不需要大池子
        ds.setMinimumIdle(0);
        ds.setIdleTimeout(60_000);
        ds.setMaxLifetime(10 * 60_000);
        ds.setConnectionTimeout(5_000);

        return new JdbcTemplate(ds);
    }
}

关键设计:

  • 按需创建:第一次查询某个库时才建连接池,不用不建
  • 自动回收:30 分钟没访问,连接池自动关闭,释放数据库连接
  • removalListener :过期时主动调 HikariDataSource.close(),避免连接泄漏

六、SQL 执行器:参数绑定 + 安全校验

java 复制代码
@Slf4j
@Component
public class SqlExecutor {

    private final Configuration configuration;
    private final SqlSecurityValidator validator;

    public SqlExecutor(SqlSessionFactory sqlSessionFactory, SqlSecurityValidator validator) {
        this.configuration = sqlSessionFactory.getConfiguration();
        this.validator = validator;
    }

    public Object execute(JdbcTemplate jdbcTemplate, String commandType,
                          BoundSql boundSql, Map<String, Object> params) {
        String sql = boundSql.getSql();               // MyBatis 动态生成的最终 SQL
        Object[] args = buildArgs(boundSql, params);  // 按 ParameterMapping 顺序取参数值

        validator.validate(sql, commandType);          // 安全校验

        log.info("执行sql={}, args={}", sql, args);

        return switch (commandType) {
            case "SELECT" -> jdbcTemplate.queryForList(sql, args);          // → List<Map>
            case "INSERT", "UPDATE", "DELETE" -> jdbcTemplate.update(sql, args); // → 影响行数
            default -> throw new IllegalArgumentException("不支持的 SQL 类型:" + commandType);
        };
    }

    private Object[] buildArgs(BoundSql boundSql, Map<String, Object> params) {
        List<ParameterMapping> mappings = boundSql.getParameterMappings();
        MetaObject metaObject = configuration.newMetaObject(params);

        return mappings.stream()
                .map(mapping -> getParameterValue(boundSql, metaObject, mapping))
                .toArray();
    }

    private Object getParameterValue(BoundSql boundSql, MetaObject metaObject,
                                     ParameterMapping mapping) {
        String property = mapping.getProperty();
        // foreach 产生的内部变量走 additionalParameter
        if (boundSql.hasAdditionalParameter(property)) {
            return boundSql.getAdditionalParameter(property);
        }
        return metaObject.getValue(property);
    }
}

参数绑定的关键:MyBatis 的 BoundSql 已经根据 <if><foreach> 等标签动态生成了最终 SQL 和参数映射列表。SqlExecutor 只需要按映射列表的顺序从 params Map 中取值,填到 ? 占位符里。


七、安全校验

java 复制代码
@Component
public class SqlSecurityValidator {

    public void validate(String sql, String commandType) {
        String trimSql = sql.trim();
        String lower = " " + trimSql.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ") + " ";

        // 1. 禁止危险操作
        if (lower.contains(" drop ") || lower.contains(" truncate ")
                || lower.contains(" alter ") || lower.contains(" create ")
                || lower.contains(" grant ") || lower.contains(" revoke ")) {
            throw new IllegalArgumentException("SQL 包含危险操作");
        }

        // 2. SQL 首个关键字必须与声明的 commandType 一致
        String first = trimSql.split("\\s+")[0].toUpperCase(Locale.ROOT);
        if (!first.equals(commandType)) {
            throw new IllegalArgumentException("SQL 类型不匹配,期望:" + commandType + ",实际:" + first);
        }

        // 3. SELECT 禁止 for update
        if ("SELECT".equals(commandType) && lower.contains(" for update ")) {
            throw new IllegalArgumentException("SELECT 禁止使用 for update");
        }
    }
}

再加上编译期就禁止的 ${},三层防护:编译期防注入 → 运行期防危险操作 → 运行期校验类型一致性。


八、REST 接口

java 复制代码
@RestController
@RequestMapping("/dynamic-sql")
public class DynamicSqlController {

    private final DynamicMethodEngine engine;
    private final DynamicMethodRepository methodRepository;
    private final DynamicDataSourceRepository dataSourceRepository;

    // ── 执行 SQL ──
    @PostMapping("/execute")
    public Object execute(@RequestBody ExecuteRequest request) {
        return engine.execute(request.getDataSourceId(), request.getMethodId(), request.getParams());
    }

    // ── 方法管理 ──
    @GetMapping("/method/list")
    public List<DynamicSqlMethod> listMethods() { return methodRepository.findAll(); }

    @PostMapping("/method/update")
    public String updateMethod(@RequestBody UpdateMethodRequest request) {
        methodRepository.saveOrUpdate(request.getMethodId(), request.getCommandType(), request.getXmlFragment());
        engine.invalidateMethod(request.getMethodId());  // 更新后自动失效缓存
        return "ok";
    }

    @PostMapping("/method/{id}/toggle")
    public String toggleMethod(@PathVariable Long id) { methodRepository.toggleEnabled(id); return "ok"; }

    // ── 数据源管理 ──
    @GetMapping("/datasource/list")
    public List<DbConfig> listDataSources() { return dataSourceRepository.findAll(); }

    @PostMapping("/datasource/create")
    public String createDataSource(@RequestBody CreateDataSourceRequest request) {
        DbConfig config = new DbConfig();
        config.setName(request.getName());
        config.setDbType(request.getDbType());
        config.setJdbcUrl(request.getJdbcUrl());
        config.setUsername(request.getUsername());
        config.setPassword(request.getPassword());
        config.setDriverClassName(request.getDriverClassName());
        dataSourceRepository.save(config);
        return "ok";
    }

    @PostMapping("/datasource/{id}/refresh")
    public String refreshDataSource(@PathVariable Long id) {
        engine.invalidateDataSource(id);  // 关闭旧连接池,下次访问重建
        return "ok";
    }

    @PostMapping("/datasource/{id}/toggle")
    public String toggleDataSource(@PathVariable Long id) { dataSourceRepository.toggleEnabled(id); return "ok"; }
}

九、curl 调用示例

新增一个数据源

bash 复制代码
curl -X POST http://localhost:8080/dynamic-sql/datasource/create \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "用户中心库",
    "dbType": "MySQL",
    "jdbcUrl": "jdbc:mysql://10.0.0.5:3306/user_center?useUnicode=true&characterEncoding=utf8",
    "username": "readonly",
    "password": "readonly123",
    "driverClassName": "com.mysql.cj.jdbc.Driver"
  }'

新增一个带动态条件的查询方法

bash 复制代码
curl -X POST http://localhost:8080/dynamic-sql/method/update \
  -H 'Content-Type: application/json' \
  -d '{
    "methodId": "searchUser",
    "commandType": "SELECT",
    "xmlFragment": "<select id=\"searchUser\">SELECT id, name, phone, status FROM t_user<where><if test=\"name != null\">AND name LIKE CONCAT(\"%\", #{name}, \"%\")</if><if test=\"status != null\">AND status = #{status}</if></where>ORDER BY id DESC LIMIT 100</select>"
  }'

执行查询

按名字搜:

bash 复制代码
curl -X POST http://localhost:8080/dynamic-sql/execute \
  -H 'Content-Type: application/json' \
  -d '{"dataSourceId": 1, "methodId": "searchUser", "params": {"name": "张"}}'

不传条件查全部:

bash 复制代码
curl -X POST http://localhost:8080/dynamic-sql/execute \
  -H 'Content-Type: application/json' \
  -d '{"dataSourceId": 1, "methodId": "searchUser", "params": {}}'


十一、为什么不直接用 MyBatis 的 SqlSession?

每个动态数据源是独立的 HikariCP 连接池,不在 SqlSessionFactory 管理范围内。

这个引擎的解法是拆开用:

  • SqlSource:只做 SQL 解析和参数绑定(纯计算,不连数据库)
  • JdbcTemplate:负责真正的 SQL 执行(每库一个独立连接池)

这样既享受了 MyBatis 动态 SQL 的全部能力,又不被 SqlSession 的单数据源限制绑住。


十二、依赖

xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
        <version>3.5.12</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
    </dependency>
    <dependency>
        <groupId>org.jsoup</groupId>
        <artifactId>jsoup</artifactId>
        <version>1.17.2</version>
    </dependency>
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

Java 21 + Spring Boot 3.3.5,拿去就能跑。

相关推荐
用户8356290780511 小时前
用 Python 自动化 PowerPoint 演讲者备注添加
后端·python
神奇小汤圆2 小时前
科研神器再升级!Claude Code 全套 Skills,16 大科研场景全覆盖!
后端
tyung2 小时前
Go 手写有界 SPSC 环形队列:无 CAS、无锁、Cache 友好的无锁模型
后端·go
咕白m6252 小时前
使用 C# 在 Excel 中应用多种字体样式
后端·c#
Java编程爱好者2 小时前
放弃 Spring AI?这 3 个开源框架,才是让 SpringBoot 玩转 AI Agent 的正解
后端
二月龙2 小时前
伪类与伪元素深度解析:before/after 实用案例
后端
码事漫谈2 小时前
时序数据库2026盘点:国产数据库如何以“融合多模”走出差异化之路?
前端·后端
浮游本尊2 小时前
Java学习第42天 - Spring 事务传播、隔离级别、锁机制与并发一致性
后端
道友可好2 小时前
让 AI 自己验收,等于让学生自己批卷
前端·人工智能·后端