---
url: /notes/知识库/多租户定时任务管理-示例源码/index.md
---
# 多租户定时任务管理 — 示例源码

> 配套 [多租户定时任务管理架构.md](./多租户定时任务管理架构.md) 的核心代码示例，术语已脱敏可直接参考。

***

## 一、ThreadLocal 数据源上下文

```java
package com.example.framework.datasource;

/**
 * 数据源上下文持有器，通过 ThreadLocal 存储当前线程的租户标识
 */
public class DynamicDataSourceContextHolder {

    private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();

    public static void setDataSourceType(String dsType) {
        CONTEXT_HOLDER.set(dsType);
    }

    public static String getDataSourceType() {
        return CONTEXT_HOLDER.get();
    }

    public static void clearDataSourceType() {
        CONTEXT_HOLDER.remove();
    }
}
```

***

## 二、动态数据源路由

```java
package com.example.framework.datasource;

import java.util.HashMap;
import java.util.Map;
import javax.sql.DataSource;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

/**
 * 动态数据源，根据 ThreadLocal 中的租户标识路由到对应连接池
 */
public class DynamicDataSource extends AbstractRoutingDataSource {

    private Map<Object, Object> targetDataSourcesMap;

    public DynamicDataSource(DataSource defaultTargetDataSource,
                             Map<Object, Object> targetDataSources) {
        super.setDefaultTargetDataSource(defaultTargetDataSource);
        this.targetDataSourcesMap = new HashMap<>(targetDataSources);
        super.setTargetDataSources(this.targetDataSourcesMap);
        super.afterPropertiesSet();
    }

    @Override
    protected Object determineCurrentLookupKey() {
        return DynamicDataSourceContextHolder.getDataSourceType();
    }

    /**
     * 运行时动态注册租户数据源
     */
    public synchronized void addTargetDataSource(String dsKey, DataSource dataSource) {
        this.targetDataSourcesMap.put(dsKey, dataSource);
        super.setTargetDataSources(new HashMap<>(this.targetDataSourcesMap));
        super.afterPropertiesSet();
    }

    public boolean containsDataSource(String dsKey) {
        return this.targetDataSourcesMap.containsKey(dsKey);
    }
}
```

***

## 三、HTTP 过滤器（JWT → 租户标识 → ThreadLocal）

```java
package com.example.framework.datasource;

import java.io.IOException;
import javax.servlet.*;
import javax.servlet.http.*;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

/**
 * 租户上下文过滤器：从请求 JWT 中提取租户标识并设置数据源路由
 */
@Component
@Order(1)
public class TenantContextFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {
        try {
            HttpServletRequest httpRequest = (HttpServletRequest) request;

            // 1. 提取 token（优先 Authorization 头，其次 Cookie）
            String token = extractToken(httpRequest);
            if (token == null) {
                chain.doFilter(request, response);
                return;
            }

            // 2. 从 JWT 中解析租户标识
            String tenantId = parseTenantIdFromJwt(token);
            if (tenantId == null) {
                chain.doFilter(request, response);
                return;
            }

            // 3. 确保数据源已注册（兜底重试）
            ensureDataSourceRegistered(tenantId);

            // 4. 设置 ThreadLocal，后续 MyBatis 操作自动路由到租户库
            DynamicDataSourceContextHolder.setDataSourceType(tenantId);

            chain.doFilter(request, response);
        } finally {
            // 5. 请求结束后清理，防止线程池复用时的上下文污染
            DynamicDataSourceContextHolder.clearDataSourceType();
        }
    }

    private String extractToken(HttpServletRequest request) {
        // Authorization: Bearer <token>
        String auth = request.getHeader("Authorization");
        if (auth != null && auth.startsWith("Bearer ")) {
            return auth.substring(7);
        }
        // Cookie: Admin-Token=<token>
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie c : cookies) {
                if ("Admin-Token".equals(c.getName())) {
                    return c.getValue();
                }
            }
        }
        return null;
    }

    /** 以下为伪代码，实际实现需依赖具体 TokenService 和配置 */
    private String parseTenantIdFromJwt(String token) { /* JWT 解码 → tenantId */ return null; }
    private void ensureDataSourceRegistered(String tenantId) { /* 兜底注册 */ }
}
```

***

## 四、数据源配置属性

```java
package com.example.framework.config.properties;

import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * 多租户数据源配置，映射 application-tenant.yml
 */
@Component
@ConfigurationProperties(prefix = "tenant")
public class TenantProperties {

    /** 默认租户标识 */
    private String defaultCode;

    /** 租户标识 → 数据源配置 */
    private Map<String, TenantDataSourceProperties> datasources = new LinkedHashMap<>();

    // getters / setters ...

    public static class TenantDataSourceProperties {
        private String name;     // 租户名称
        private String url;      // JDBC 连接地址
        private String username; // 数据库用户名
        private String password; // 数据库密码
        // getters / setters ...
    }
}
```

***

## 五、数据源注册事件

```java
package com.example.framework.event;

import javax.sql.DataSource;
import org.springframework.context.ApplicationEvent;

/**
 * 租户数据源注册完成事件，供 Quartz 等模块监听并创建对应的 Scheduler
 */
public class TenantDataSourceRegisteredEvent extends ApplicationEvent {

    private final String tenantId;
    private final DataSource dataSource;

    public TenantDataSourceRegisteredEvent(Object source, String tenantId,
                                           DataSource dataSource) {
        super(source);
        this.tenantId = tenantId;
        this.dataSource = dataSource;
    }

    public String getTenantId() { return tenantId; }
    public DataSource getDataSource() { return dataSource; }
}
```

***

## 六、SchedulerManager（核心）

```java
package com.example.quartz.config;

import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import javax.sql.DataSource;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import org.springframework.stereotype.Component;

/**
 * 多租户调度器管理器
 *
 * 每租户对应一个独立的 Scheduler 实例，各自使用租户库中的 QRTZ_* 表持久化。
 * 启动时预初始化所有配置的租户数据源及调度器，确保重启后定时任务立即恢复。
 */
@Component
public class SchedulerManager {

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

    /** 租户标识 → Scheduler */
    private final Map<String, Scheduler> schedulers = new ConcurrentHashMap<>();

    private final ApplicationContext applicationContext;

    public SchedulerManager(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }

    // ======================== 启动预初始化 ========================

    /**
     * 应用启动完成后，预初始化所有配置的租户调度器。
     * 失败跳过，等用户首次登录时兜底重试。
     */
    @EventListener(ApplicationReadyEvent.class)
    public void initAll() {
        TenantProperties props = getBean(TenantProperties.class);
        if (props.getDatasources() == null || props.getDatasources().isEmpty()) {
            log.info("[SchedulerMgr] 未配置租户数据源，跳过");
            return;
        }
        log.info("[SchedulerMgr] 开始预初始化 {} 个租户调度器", props.getDatasources().size());
        for (String tenantId : props.getDatasources().keySet()) {
            try {
                getBean(DruidConfig.class).registerTenantDataSource(tenantId,
                        props.getDatasources().get(tenantId));
            } catch (Exception e) {
                log.warn("[SchedulerMgr] 租户 [{}] 预初始化失败（库不可达），登录时重试: {}",
                        tenantId, e.getMessage());
            }
        }
        log.info("[SchedulerMgr] 预初始化完成，已注册 {} 个调度器", schedulers.size());
    }

    // ======================== 事件监听 ========================

    /**
     * 监听租户数据源注册事件，自动创建对应的 Quartz Scheduler
     */
    @EventListener
    public void onTenantDataSourceRegistered(TenantDataSourceRegisteredEvent event) {
        try {
            registerScheduler(event.getTenantId(), event.getDataSource());
        } catch (SchedulerException e) {
            log.error("[SchedulerMgr] 自动注册调度器失败: {}", event.getTenantId(), e);
        }
    }

    // ======================== Scheduler 注册 ========================

    /**
     * 为指定租户创建并启动一个独立的 Quartz 调度器
     *
     * @param tenantId   租户标识
     * @param dataSource 该租户对应的数据源（直连，非 DynamicDataSource）
     */
    public Scheduler registerScheduler(String tenantId, DataSource dataSource)
            throws SchedulerException {
        // 已存在且未关闭 → 复用
        Scheduler existing = schedulers.get(tenantId);
        if (existing != null && !existing.isShutdown()) {
            log.info("[SchedulerMgr] 租户 [{}] 调度器已存在", tenantId);
            return existing;
        }

        SchedulerFactoryBean factory = new SchedulerFactoryBean();
        factory.setDataSource(dataSource);
        factory.setSchedulerName("Scheduler_" + tenantId);
        factory.setQuartzProperties(buildQuartzProperties(tenantId));
        factory.setStartupDelay(1);
        factory.setOverwriteExistingJobs(true);
        factory.setAutoStartup(true);

        // ★ 关键：注入 ApplicationContext，确保 Quartz 工作线程能访问 Spring Bean
        factory.setApplicationContext(applicationContext);

        try {
            factory.afterPropertiesSet();
        } catch (Exception e) {
            throw new SchedulerException("初始化调度器失败: " + tenantId, e);
        }

        Scheduler scheduler = factory.getScheduler();

        // ★ 双重保障：同时存入 Scheduler 上下文
        scheduler.getContext().put("applicationContext", applicationContext);

        scheduler.start();
        schedulers.put(tenantId, scheduler);
        log.info("[SchedulerMgr] 租户 [{}] 调度器已注册并启动, scheduler={}",
                tenantId, scheduler.getSchedulerName());
        return scheduler;
    }

    // ======================== 查询方法 ========================

    public Scheduler getScheduler(String tenantId) {
        return schedulers.get(tenantId);
    }

    public boolean hasScheduler(String tenantId) {
        Scheduler s = schedulers.get(tenantId);
        if (s == null) return false;
        try { return !s.isShutdown(); }
        catch (SchedulerException e) { return false; }
    }

    // ======================== 内部方法 ========================

    private Properties buildQuartzProperties(String tenantId) {
        Properties prop = new Properties();
        prop.put("org.quartz.scheduler.instanceName", "Scheduler_" + tenantId);
        prop.put("org.quartz.scheduler.instanceId", "AUTO");
        prop.put("org.quartz.threadPool.class", "org.quartz.simpl.SimpleThreadPool");
        prop.put("org.quartz.threadPool.threadCount", "20");
        prop.put("org.quartz.threadPool.threadPriority", "5");
        prop.put("org.quartz.jobStore.class",
                "org.springframework.scheduling.quartz.LocalDataSourceJobStore");
        prop.put("org.quartz.jobStore.isClustered", "false");
        prop.put("org.quartz.jobStore.maxMisfiresToHandleAtATime", "1");
        prop.put("org.quartz.jobStore.misfireThreshold", "12000");
        prop.put("org.quartz.jobStore.tablePrefix", "QRTZ_");
        return prop;
    }

    private <T> T getBean(Class<T> clazz) {
        return applicationContext.getBean(clazz);
    }
}
```

***

## 七、AbstractQuartzJob（跨线程上下文恢复）

```java
package com.example.quartz.util;

import java.util.Date;
import org.quartz.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;

/**
 * Quartz Job 抽象基类
 *
 * 核心职责：
 * 1. before() — 从 JobDataMap 恢复租户标识到 ThreadLocal
 * 2. doExecute() — 子类实现具体业务
 * 3. after() — 写入任务日志 + 清理 ThreadLocal
 */
public abstract class AbstractQuartzJob implements Job {

    private static final Logger log = LoggerFactory.getLogger(AbstractQuartzJob.class);
    private static final ThreadLocal<Date> START_TIME = new ThreadLocal<>();

    // ======================== 模板方法 ========================

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        SysJob sysJob = parseJobFromContext(context);
        try {
            before(context);
            doExecute(context, sysJob);
            after(context, sysJob, null);
        } catch (Exception e) {
            log.error("任务执行异常", e);
            after(context, sysJob, e);
        }
    }

    // ======================== 钩子方法 ========================

    /**
     * 执行前：从 JobDataMap 恢复租户上下文
     */
    protected void before(JobExecutionContext context) {
        START_TIME.set(new Date());
        Object tenantId = context.getMergedJobDataMap().get("TENANT_ID");
        if (tenantId != null) {
            DynamicDataSourceContextHolder.setDataSourceType(tenantId.toString());
        }
    }

    /**
     * 执行后：记录日志 + 清理上下文
     */
    protected void after(JobExecutionContext context, SysJob sysJob, Exception e) {
        Date startTime = START_TIME.get();
        START_TIME.remove();

        // 构建日志对象
        SysJobLog log = new SysJobLog();
        log.setJobName(sysJob.getJobName());
        log.setJobGroup(sysJob.getJobGroup());
        log.setStartTime(startTime);
        log.setStopTime(new Date());
        long runMs = log.getStopTime().getTime() - log.getStartTime().getTime();
        log.setJobMessage(sysJob.getJobName() + " 总耗时：" + runMs + "ms");
        log.setStatus(e == null ? "SUCCESS" : "FAIL");
        if (e != null) {
            log.setExceptionInfo(truncate(e.getMessage(), 2000));
        }

        // ★ 写入日志（此时 ThreadLocal 仍持有租户标识，日志入正确的库）
        try {
            ApplicationContext appCtx = (ApplicationContext)
                    context.getScheduler().getContext().get("applicationContext");
            if (appCtx != null) {
                appCtx.getBean(JobLogService.class).addJobLog(log);
            }
        } catch (SchedulerException ex) {
            log.error("获取 Scheduler 上下文失败", ex);
        }

        // ★ 清理 ThreadLocal，防止线程池复用时的上下文污染
        DynamicDataSourceContextHolder.clearDataSourceType();
    }

    /**
     * 子类实现：具体业务逻辑
     */
    protected abstract void doExecute(JobExecutionContext context,
                                      SysJob sysJob) throws Exception;

    // ======================== 工具方法 ========================

    private SysJob parseJobFromContext(JobExecutionContext context) {
        SysJob job = new SysJob();
        copyProps(context.getMergedJobDataMap().get("TASK_PROPERTIES"), job);
        return job;
    }

    private static String truncate(String s, int maxLen) {
        return s != null && s.length() > maxLen ? s.substring(0, maxLen) : s;
    }
    private static void copyProps(Object src, Object dst) { /* BeanUtils.copyProperties */ }
}
```

***

## 八、ScheduleUtils（创建任务 + 存入租户标识）

```java
package com.example.quartz.util;

import org.quartz.*;

/**
 * Quartz 任务创建工具
 *
 * ★ 关键：创建 Job 时将当前线程的租户标识写入 JobDataMap，
 *         确保 Quartz 工作线程执行时能恢复正确的数据源上下文。
 */
public class ScheduleUtils {

    public static final String TASK_PROPERTIES = "TASK_PROPERTIES";
    public static final String TENANT_ID = "TENANT_ID";

    /**
     * 创建并调度一个 Cron 定时任务
     */
    public static void createScheduleJob(Scheduler scheduler, SysJob job)
            throws SchedulerException {

        // 1. 选择 Job 实现类（并发 / 禁止并发）
        Class<? extends Job> jobClass = "0".equals(job.getConcurrent())
                ? QuartzJobExecution.class           // 允许并发
                : QuartzDisallowConcurrentExecution.class; // 禁止并发

        // 2. 构建 JobDetail
        JobDetail jobDetail = JobBuilder.newJob(jobClass)
                .withIdentity(jobKey(job.getJobId(), job.getJobGroup()))
                .build();

        // 3. 构建 CronTrigger
        CronTrigger trigger = TriggerBuilder.newTrigger()
                .withIdentity(triggerKey(job.getJobId(), job.getJobGroup()))
                .withSchedule(CronScheduleBuilder.cronSchedule(job.getCronExpression())
                        .withMisfireHandlingInstructionDoNothing())
                .build();

        // 4. ★ 存入任务属性
        jobDetail.getJobDataMap().put(TASK_PROPERTIES, job);

        // 5. ★★ 存入当前租户标识（跨越 HTTP → Quartz 工作线程的上下文传递）
        String currentTenantId = DynamicDataSourceContextHolder.getDataSourceType();
        if (currentTenantId != null && !currentTenantId.isEmpty()) {
            jobDetail.getJobDataMap().put(TENANT_ID, currentTenantId);
        }

        // 6. 注册到 Scheduler
        if (scheduler.checkExists(jobKey(job.getJobId(), job.getJobGroup()))) {
            scheduler.deleteJob(jobKey(job.getJobId(), job.getJobGroup()));
        }
        scheduler.scheduleJob(jobDetail, trigger);

        // 7. 新任务默认暂停，需手动启动
        if ("PAUSE".equals(job.getStatus())) {
            scheduler.pauseJob(jobKey(job.getJobId(), job.getJobGroup()));
        }
    }

    public static JobKey jobKey(Long jobId, String jobGroup) {
        return JobKey.jobKey("TASK_" + jobId, jobGroup);
    }

    public static TriggerKey triggerKey(Long jobId, String jobGroup) {
        return TriggerKey.triggerKey("TASK_" + jobId, jobGroup);
    }
}
```

***

## 九、SysJobServiceImpl（业务层 — 当前租户 Scheduler 定位）

```java
package com.example.quartz.service.impl;

import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * 定时任务服务实现
 *
 * ★ 关键：每个操作都通过 currentScheduler() 获取当前租户对应的 Scheduler，
 *         保证任务操作作用在正确的租户调度器上。
 */
@Service
public class SysJobServiceImpl {

    private final SchedulerManager schedulerManager;
    private final SysJobMapper jobMapper;

    public SysJobServiceImpl(SchedulerManager schedulerManager,
                             SysJobMapper jobMapper) {
        this.schedulerManager = schedulerManager;
        this.jobMapper = jobMapper;
    }

    /**
     * ★ 获取当前租户的 Scheduler
     * 从 ThreadLocal 读取租户标识 → 从 SchedulerManager 获取对应实例
     */
    private Scheduler currentScheduler() throws SchedulerException {
        String tenantId = DynamicDataSourceContextHolder.getDataSourceType();
        if (tenantId == null || tenantId.isEmpty()) {
            throw new SchedulerException("当前线程未设置数据源上下文");
        }
        Scheduler scheduler = schedulerManager.getScheduler(tenantId);
        if (scheduler == null) {
            throw new SchedulerException("租户 [" + tenantId + "] 调度器未注册");
        }
        return scheduler;
    }

    @Transactional
    public int insertJob(SysJob job) throws Exception {
        job.setStatus("PAUSE");                     // 新任务默认暂停
        int rows = jobMapper.insertJob(job);        // 写入当前租户库的 sys_job 表
        if (rows > 0) {
            ScheduleUtils.createScheduleJob(currentScheduler(), job);
        }
        return rows;
    }

    @Transactional
    public int pauseJob(SysJob job) throws SchedulerException {
        job.setStatus("PAUSE");
        int rows = jobMapper.updateJob(job);
        if (rows > 0) {
            currentScheduler().pauseJob(ScheduleUtils.jobKey(job.getJobId(), job.getJobGroup()));
        }
        return rows;
    }

    @Transactional
    public int resumeJob(SysJob job) throws SchedulerException {
        job.setStatus("NORMAL");
        int rows = jobMapper.updateJob(job);
        if (rows > 0) {
            currentScheduler().resumeJob(ScheduleUtils.jobKey(job.getJobId(), job.getJobGroup()));
        }
        return rows;
    }

    @Transactional
    public int deleteJob(SysJob job) throws SchedulerException {
        int rows = jobMapper.deleteJobById(job.getJobId());
        if (rows > 0) {
            currentScheduler().deleteJob(ScheduleUtils.jobKey(job.getJobId(), job.getJobGroup()));
        }
        return rows;
    }

    /** 立即执行一次 */
    public void runOnce(SysJob job) throws SchedulerException {
        Scheduler scheduler = currentScheduler();
        JobKey jobKey = ScheduleUtils.jobKey(job.getJobId(), job.getJobGroup());

        // 如果任务尚未注册到 Scheduler，先创建
        if (!scheduler.checkExists(jobKey)) {
            ScheduleUtils.createScheduleJob(scheduler, job);
        }

        // 构建参数（包含租户标识，确保工作线程能恢复上下文）
        JobDataMap dataMap = new JobDataMap();
        dataMap.put(ScheduleUtils.TASK_PROPERTIES, job);
        String tenantId = DynamicDataSourceContextHolder.getDataSourceType();
        if (tenantId != null) {
            dataMap.put(ScheduleUtils.TENANT_ID, tenantId);
        }

        scheduler.triggerJob(jobKey, dataMap);
    }
}
```

***

## 十、DruidConfig（数据源创建 + 动态注册）

```java
package com.example.framework.config;

import com.alibaba.druid.pool.DruidDataSource;
import javax.sql.DataSource;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

/**
 * 多数据源配置
 *
 * 启动时创建 DynamicDataSource（含主库），
 * 运行时通过 registerTenantDataSource() 动态注册租户数据源。
 */
@Configuration
public class DruidConfig {

    private final ApplicationEventPublisher eventPublisher;

    public DruidConfig(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    /**
     * 动态数据源（主 Bean，@Primary）
     */
    @Bean
    @Primary
    public DynamicDataSource dataSource(DataSource masterDataSource) {
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put("MASTER", masterDataSource);
        return new DynamicDataSource(masterDataSource, targetDataSources);
    }

    /**
     * 运行时动态注册租户数据源
     *
     * 流程：创建 Druid 连接池 → 注册到 DynamicDataSource → 发布事件 → SchedulerManager 创建调度器
     */
    public void registerTenantDataSource(String tenantId,
                                          TenantProperties.TenantDataSourceProperties config) {
        DynamicDataSource dynamicDS = getBean(DynamicDataSource.class);

        // 已注册则跳过
        if (dynamicDS.containsDataSource(tenantId)) {
            return;
        }

        // 创建独立的 Druid 连接池
        DruidDataSource ds = new DruidDataSource();
        ds.setUrl(config.getUrl());
        ds.setUsername(config.getUsername());
        ds.setPassword(config.getPassword());
        ds.setInitialSize(1);
        ds.setMinIdle(2);
        ds.setMaxActive(10);
        ds.setMaxWait(60000);
        ds.setValidationQuery("SELECT 1");
        ds.setTestWhileIdle(true);
        // 初始化连接池
        try {
            ds.init();
        } catch (Exception e) {
            throw new RuntimeException("创建租户数据源失败: " + tenantId, e);
        }

        // 注册到动态数据源
        dynamicDS.addTargetDataSource(tenantId, ds);

        // ★ 发布事件，通知 SchedulerManager 创建对应的 Quartz 调度器
        eventPublisher.publishEvent(
                new TenantDataSourceRegisteredEvent(this, tenantId, ds));
    }

    private <T> T getBean(Class<T> clazz) {
        return SpringUtils.getBean(clazz);
    }
}
```

***

## 十一、配置文件

### application-tenant.yml

```yaml
# 多租户数据源配置
tenant:
  default: tenant-a
  datasources:
    T001:
      name: 租户一
      url: jdbc:mysql://your-db-host:3306/tenant_db_1?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8
      username: root
      password: xxxxxx
    T002:
      name: 租户二
      url: jdbc:mysql://your-db-host:3306/tenant_db_2?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8
      username: root
      password: xxxxxx
```

### application.yml（关键配置）

```yaml
spring:
  devtools:
    restart:
      enabled: false     # ★ 禁用热部署，避免 Quartz ClassLoader 冲突

mybatis:
  mapper-locations: classpath*:mapper/**/*Mapper.xml
```

***

## 十二、数据流全景

```
 ┌─ HTTP Thread ─────────────────────────────────────────────┐
 │ 1. TenantContextFilter                                     │
 │    token → tenantId → DynamicDataSourceContextHolder.set() │
 │                                                            │
 │ 2. SysJobServiceImpl.insertJob()                           │
 │    ├─ jobMapper.insert()         // MyBatis → 租户库       │
 │    └─ currentScheduler()         // ThreadLocal → 租户Scheduler │
 │                                                            │
 │ 3. ScheduleUtils.createScheduleJob()                       │
 │    └─ JobDataMap.put("TENANT_ID", tenantId)  // ★存入      │
 └────────────────────────────────────────────────────────────┘
                              │
                              ▼  (Quartz Worker Thread)
 ┌─ Quartz Thread ───────────────────────────────────────────┐
 │ 4. AbstractQuartzJob.before()                              │
 │    └─ JobDataMap.get("TENANT_ID") → ThreadLocal.set() ★恢复│
 │                                                            │
 │ 5. doExecute() → 反射调用业务 Bean                          │
 │    └─ MyBatis → 自动路由到正确的租户库                      │
 │                                                            │
 │ 6. AbstractQuartzJob.after()                               │
 │    ├─ ISysJobLogService.addJobLog()  // 日志入租户库        │
 │    └─ DynamicDataSourceContextHolder.clear() ★清理          │
 └────────────────────────────────────────────────────────────┘
```
