全部学科
NodeJS全栈
nodejs
Python全栈
python
小程序首页
📅 2026-05-20 8 分钟 ✍️ juanwangdev

热部署与配置刷新

在生产环境中,修复 SQL 错误或调整查询逻辑通常需要重新编译、打包和部署整个应用。通过实现 MyBatis Mapper XML 文件的热加载机制,可以在不重启应用的情况下动态更新 SQL 语句,显著缩短修复时间并降低发布风险。

XML 热加载原理

MyBatis 配置加载流程

MyBatis 在启动时通过 XMLMapperBuilder 解析 Mapper XML 文件,将 SQL 语句注册到 Configuration 对象的 MappedStatement 缓存中:

Java
启动流程:
1. SqlSessionFactoryBuilder.build() 解析 mybatis-config.xml
2. XMLConfigBuilder 解析 <mappers> 元素
3. XMLMapperBuilder 解析每个 Mapper XML 文件
4. XMLStatementBuilder 解析每个 SQL 语句
5. 注册到 Configuration.mappedStatements(ConcurrentHashMap)
Java
// Configuration 内部结构
public class Configuration {
    // 存储所有已注册的 MappedStatement
    protected final Map<String, MappedStatement> mappedStatements =
        new StrictMap<>("Mapped Statements collection");

    // 存储所有已注册的缓存
    protected final Map<String, Cache> caches = new StrictMap<>("Caches collection");

    // 存储 Mapper XML 资源路径
    protected final Set<String> loadedResources = new HashSet<>();
}

热加载的核心思路

Java
热加载流程:
1. 检测 Mapper XML 文件变更(文件修改时间戳)
2. 清理 Configuration 中的缓存数据
   - 移除旧的 MappedStatement
   - 移除关联的 Cache
   - 清理 loadedResources 集合
3. 重新解析 XML 文件
4. 重新注册到 Configuration

实现 XML 热加载

文件变更监听器

Java
public class MapperXmlHotReloader {

    private static final Logger log = LoggerFactory.getLogger(MapperXmlHotReloader.class);

    private final SqlSessionFactory sqlSessionFactory;
    private final List<Path> mapperXmlPaths;
    private final Map<Path, Long> fileLastModifiedMap = new ConcurrentHashMap<>();
    private WatchService watchService;
    private volatile boolean running = false;
    private ExecutorService executorService;

    public MapperXmlHotReloader(SqlSessionFactory sqlSessionFactory,
                                List<String> mapperLocations) throws IOException {
        this.sqlSessionFactory = sqlSessionFactory;
        this.mapperXmlPaths = mapperLocations.stream()
            .map(Paths::get)
            .collect(Collectors.toList());

        // 初始化文件修改时间记录
        for (Path path : mapperXmlPaths) {
            if (Files.exists(path)) {
                fileLastModifiedMap.put(path, Files.getLastModifiedTime(path).toMillis());
            }
        }
    }

    public void start() throws IOException {
        running = true;
        watchService = FileSystems.getDefault().newWatchService();
        executorService = Executors.newSingleThreadExecutor(r -> {
            Thread t = new Thread(r, "mapper-hot-reloader");
            t.setDaemon(true);
            return t;
        });

        // 注册监听目录
        Set<Path> parentDirs = mapperXmlPaths.stream()
            .map(Path::getParent)
            .collect(Collectors.toSet());

        for (Path dir : parentDirs) {
            dir.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY,
                StandardWatchEventKinds.ENTRY_CREATE);
        }

        executorService.submit(this::watchLoop);
        log.info("[HOT-RELOAD] Mapper XML hot reloader started, watching {} files",
            mapperXmlPaths.size());
    }

    private void watchLoop() {
        while (running) {
            try {
                WatchKey key = watchService.poll(5, TimeUnit.SECONDS);
                if (key == null) continue;

                for (WatchEvent<?> event : key.pollEvents()) {
                    if (event.kind() == StandardWatchEventKinds.OVERFLOW) continue;

                    Path changedFile = ((WatchEvent<Path>) event).context();
                    Path absolutePath = ((Path) key.watchable()).resolve(changedFile);

                    if (mapperXmlPaths.contains(absolutePath)) {
                        handleFileChange(absolutePath);
                    }
                }
                key.reset();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            } catch (Exception e) {
                log.error("[HOT-RELOAD] Error watching file changes", e);
            }
        }
    }

    private void handleFileChange(Path xmlPath) {
        try {
            long currentModified = Files.getLastModifiedTime(xmlPath).toMillis();
            Long lastModified = fileLastModifiedMap.get(xmlPath);

            if (lastModified != null && currentModified > lastModified) {
                log.info("[HOT-RELOAD] Detected change in {}, reloading...", xmlPath);
                reloadMapper(xmlPath);
                fileLastModifiedMap.put(xmlPath, currentModified);
            }
        } catch (IOException e) {
            log.error("[HOT-RELOAD] Failed to handle file change for {}", xmlPath, e);
        }
    }

    public void stop() {
        running = false;
        if (executorService != null) {
            executorService.shutdown();
        }
        try {
            if (watchService != null) {
                watchService.close();
            }
        } catch (IOException e) {
            log.error("[HOT-RELOAD] Failed to close watch service", e);
        }
    }
}

核心重载逻辑

YAML
public class MapperXmlHotReloader {

    /**
     * 重新加载单个 Mapper XML 文件
     */
    private void reloadMapper(Path xmlPath) {
        Configuration configuration = sqlSessionFactory.getConfiguration();

        try {
            // 1. 获取 XML 文件名(不含扩展名),用于匹配 loadedResources
            String resource = xmlPath.getFileName().toString();

            // 2. 清理旧的 MappedStatement
            removeOldMappedStatements(configuration, resource);

            // 3. 清理关联的 Cache
            removeOldCaches(configuration, resource);

            // 4. 从 loadedResources 中移除,允许重新解析
            configuration.getLoadedResourceNames().remove(resource);

            // 5. 重新解析 XML
            try (InputStream inputStream = Files.newInputStream(xmlPath)) {
                XMLMapperBuilder xmlMapperBuilder = new XMLMapperBuilder(
                    inputStream,
                    configuration,
                    resource,
                    configuration.getSqlFragments()
                );
                xmlMapperBuilder.parse();
            }

            log.info("[HOT-RELOAD] Successfully reloaded mapper: {}", resource);
        } catch (Exception e) {
            log.error("[HOT-RELOAD] Failed to reload mapper {}: {}",
                xmlPath.getFileName(), e.getMessage(), e);
        }
    }

    /**
     * 清理指定 Mapper 的旧 MappedStatement
     */
    private void removeOldMappedStatements(Configuration configuration, String resource) {
        // XMLMapperBuilder 使用 resource 作为 key 存储在 loadedResources 中
        // MappedStatement 的 id 格式: namespace.statementId
        // 需要找到所有属于该 resource 的 MappedStatement 并移除

        // 通过解析 XML 获取 namespace
        String namespace = extractNamespace(resource);
        if (namespace == null) return;

        // 移除所有以该 namespace 开头的 MappedStatement
        Collection<String> statementIds = configuration.getMappedStatementNames();
        List<String> toRemove = statementIds.stream()
            .filter(id -> id.startsWith(namespace + "."))
            .collect(Collectors.toList());

        for (String id : toRemove) {
            configuration.getMappedStatementNames().remove(id);
            // StrictMap 内部使用 key 移除
            removeMappedStatement(configuration, id);
        }
    }

    private void removeMappedStatement(Configuration configuration, String id) {
        try {
            // MappedStatement 存储在 StrictMap 中,直接通过反射移除
            Field field = Configuration.class.getDeclaredField("mappedStatements");
            field.setAccessible(true);
            @SuppressWarnings("unchecked")
            Map<String, MappedStatement> map = (Map<String, MappedStatement>) field.get(configuration);
            map.remove(id);
        } catch (Exception e) {
            log.warn("[HOT-RELOAD] Failed to remove MappedStatement {}: {}", id, e.getMessage());
        }
    }

    private void removeOldCaches(Configuration configuration, String resource) {
        // 清理关联的二级缓存
        String namespace = extractNamespace(resource);
        if (namespace != null) {
            configuration.getCaches().remove(namespace);
        }
    }

    private String extractNamespace(String resource) {
        // 简单实现:读取 XML 获取 mapper namespace 属性
        try (InputStream is = Files.newInputStream(Paths.get(
            mapperXmlPaths.stream()
                .filter(p -> p.getFileName().toString().equals(resource))
                .findFirst().orElseThrow().toString()))) {

            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            DocumentBuilder builder = factory.newDocumentBuilder();
            Document doc = builder.parse(is);
            Element root = doc.getDocumentElement();
            return root.getAttribute("namespace");
        } catch (Exception e) {
            log.warn("[HOT-RELOAD] Failed to extract namespace from {}: {}", resource, e.getMessage());
            return null;
        }
    }
}

Spring Boot 集成

自动配置

Java
@Configuration
@ConditionalOnProperty(name = "mybatis.hot-reload.enabled", havingValue = "true")
public class MyBatisHotReloadConfig {

    @Bean
    public MapperXmlHotReloader mapperXmlHotReloader(
            SqlSessionFactory sqlSessionFactory,
            @Value("${mybatis.mapper-locations}") String mapperLocations) throws IOException {

        // 解析通配符路径(如 classpath*:mapper/*.xml)
        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        Resource[] resources = resolver.getResources(mapperLocations);

        List<String> xmlPaths = Arrays.stream(resources)
            .map(r -> {
                try {
                    return r.getFile().getAbsolutePath();
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            })
            .collect(Collectors.toList());

        return new MapperXmlHotReloader(sqlSessionFactory, xmlPaths);
    }

    @Bean
    public ApplicationListener<ContextRefreshedEvent> hotReloadStarter(
            MapperXmlHotReloader reloader) {
        return event -> {
            try {
                reloader.start();
            } catch (IOException e) {
                throw new RuntimeException("Failed to start hot reloader", e);
            }
        };
    }

    @Bean(destroyMethod = "stop")
    public MapperXmlHotReloader reloaderShutdown(MapperXmlHotReloader reloader) {
        return reloader;
    }
}

配置文件

Java
# application-dev.yml
mybatis:
  mapper-locations: classpath*:mapper/**/*.xml
  hot-reload:
    enabled: true  # 仅开发环境开启
    interval: 5000 # 检查间隔(毫秒)

# application-prod.yml
mybatis:
  hot-reload:
    enabled: false  # 生产环境禁用

配置动态刷新

MyBatis-Plus 配置刷新

如果使用 MyBatis-Plus,可以通过 GlobalConfig 实现配置刷新:

XML
@Configuration
public class MyBatisRefreshConfig {

    @Value("${mybatis.refresh:false}")
    private boolean refresh;

    @Bean
    public MybatisPlusPropertiesCustomizer propertiesCustomizer() {
        return properties -> {
            if (refresh) {
                GlobalConfig globalConfig = new GlobalConfig();
                globalConfig.setRefresh(true);
                properties.setGlobalConfig(globalConfig);
            }
        };
    }
}

动态修改 settings

Java
@Component
public class MyBatisDynamicConfig {

    private final Configuration configuration;

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

    /**
     * 动态修改全局设置
     */
    public void updateSetting(String key, String value) {
        switch (key) {
            case "cacheEnabled":
                configuration.setCacheEnabled(Boolean.parseBoolean(value));
                break;
            case "lazyLoadingEnabled":
                configuration.setLazyLoadingEnabled(Boolean.parseBoolean(value));
                break;
            case "defaultStatementTimeout":
                configuration.setDefaultStatementTimeout(Integer.parseInt(value));
                break;
            default:
                throw new IllegalArgumentException("Unsupported setting: " + key);
        }
    }
}

灰度发布方案

SQL 灰度切换

通过配置中心(Nacos/Apollo)实现 SQL 语句的灰度切换:

text
<!-- 使用 choose 实现灰度 -->
<select id="selectUsers" resultType="User">
    <choose>
        <!-- 新 SQL(灰度中) -->
        <when test="@com.example.FeatureToggle@isNewQueryEnabled()">
            SELECT id, username, email, created_at
            FROM users
            WHERE status = 'ACTIVE'
            ORDER BY created_at DESC
        </when>
        <!-- 旧 SQL(默认) -->
        <otherwise>
            SELECT id, username, email
            FROM users
            WHERE status = 'ACTIVE'
        </otherwise>
    </choose>
</select>
text
// 配置中心控制
public class FeatureToggle {

    // 通过 Nacos/Apollo 动态修改
    private static volatile boolean newQueryEnabled = false;

    public static boolean isNewQueryEnabled() {
        return newQueryEnabled;
    }

    public static void setNewQueryEnabled(boolean enabled) {
        newQueryEnabled = enabled;
    }
}

各方案对比

方案优点缺点适用场景
XML 文件监听热加载无需重启,自动检测变更需要文件访问权限,反射操作有风险开发/测试环境
配置中心 + choose支持灰度发布,集中管理需要提前写好分支逻辑生产环境灰度
API 动态注册 SQL完全动态,无需 XML实现复杂,维护成本高SaaS 多租户定制
数据库存储过程逻辑在数据库层与 MyBatis 无关,调试困难复杂业务逻辑

注意事项

  1. 生产环境谨慎使用:XML 热加载涉及反射和配置修改,生产环境建议禁用,使用标准部署流程
  2. 线程安全:热加载过程中修改 Configuration 可能影响正在执行的 SQL,建议使用读写锁保护
  3. 缓存清理:热加载后必须清理旧的二级缓存,否则可能读取到过期的缓存数据
  4. 回滚机制:热加载失败时应保留旧的配置,不能导致应用不可用
  5. 权限控制:热加载 API 应设置访问控制,防止未授权的 SQL 变更

要点总结

  • MyBatis 启动时解析 Mapper XML 并注册到 Configuration.mappedStatements,热加载通过清理旧配置并重新解析实现
  • 热加载流程:检测文件变更 -> 清理 MappedStatement 和 Cache -> 移除 loadedResources -> 重新解析 XML
  • Spring Boot 通过 @ConditionalOnProperty 控制热加载开关,开发环境启用,生产环境禁用
  • XML 文件监听使用 WatchService 注册目录监听,检测到变更后触发重新加载
  • 配置动态刷新可通过修改 Configuration 对象的 settings 实现,如 cacheEnableddefaultStatementTimeout
  • 灰度发布通过 <choose> 标签配合配置中心(Nacos/Apollo)实现 SQL 语句的平滑切换
  • 热加载涉及反射操作和并发修改风险,生产环境建议使用标准部署流程而非热加载

存放路径:D:\git2\jwdev\articles\MYBATIS\专家\生产环境最佳实践\热部署与配置刷新.md

📝 发现内容有误?点击此处直接编辑

← 上一篇 异常处理与重试
下一篇 → 监控与告警
想查看更多题目和详细解析?
小程序提供完整的题库、模拟考试和详细解析
马上就来

长按或扫描二维码,立即体验

扫码体验小程序
马上就来
使用微信扫描二维码
立即体验完整题库