热部署与配置刷新
在生产环境中,修复 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 无关,调试困难 | 复杂业务逻辑 |
注意事项
- 生产环境谨慎使用:XML 热加载涉及反射和配置修改,生产环境建议禁用,使用标准部署流程
- 线程安全:热加载过程中修改 Configuration 可能影响正在执行的 SQL,建议使用读写锁保护
- 缓存清理:热加载后必须清理旧的二级缓存,否则可能读取到过期的缓存数据
- 回滚机制:热加载失败时应保留旧的配置,不能导致应用不可用
- 权限控制:热加载 API 应设置访问控制,防止未授权的 SQL 变更
要点总结
- MyBatis 启动时解析 Mapper XML 并注册到
Configuration.mappedStatements,热加载通过清理旧配置并重新解析实现 - 热加载流程:检测文件变更 -> 清理 MappedStatement 和 Cache -> 移除 loadedResources -> 重新解析 XML
- Spring Boot 通过
@ConditionalOnProperty控制热加载开关,开发环境启用,生产环境禁用 - XML 文件监听使用
WatchService注册目录监听,检测到变更后触发重新加载 - 配置动态刷新可通过修改
Configuration对象的 settings 实现,如cacheEnabled、defaultStatementTimeout等 - 灰度发布通过
<choose>标签配合配置中心(Nacos/Apollo)实现 SQL 语句的平滑切换 - 热加载涉及反射操作和并发修改风险,生产环境建议使用标准部署流程而非热加载
存放路径:D:\git2\jwdev\articles\MYBATIS\专家\生产环境最佳实践\热部署与配置刷新.md
📝 发现内容有误?点击此处直接编辑