用一个多租户项目串联SpringBoot的15个扩展点

95 阅读15分钟

为什么要搞懂Spring生命周期

1. 面试高频题 - Bean生命周期、AOP代理生成时机、如何动态注册Bean...这些都是扩展点。关注我,下一篇专门讲面试技巧。

2. 工作需要 - 动态数据源、加载外部配置、缓存预热、审计日志、优雅关闭...这些场景都要用扩展点。

举个例子:今年双11,一个多租户系统宕机,原因是Nacos配置没生效,所有租户连到了同一个数据库。如果懂ApplicationContextInitializer,这问题根本不会发生。

💡 提示:完整项目代码和快速上手指南在文章末尾,可以直接跳到最后查看。


Spring Boot生命周期全景图

先看个整体,Spring Boot从启动到关闭,经历6个大阶段:

graph LR
    A[应用启动] --> B[准备环境和配置]
    B --> C[加载Bean定义]
    C --> D[实例化Bean]
    D --> E[初始化Bean]
    E --> F[应用运行]
    F --> G[关闭清理]
    
    style A fill:#e1f5ff
    style B fill:#fff9e6
    style C fill:#fff9e6
    style D fill:#ffe6f0
    style E fill:#ffe6f0
    style F fill:#e6ffe6
    style G fill:#ffe6e6

15个扩展点就分布在这6个阶段

阶段扩展点数量主要解决什么问题
阶段1-2:准备和加载3个从哪里读配置?哪些类要注册成Bean?
阶段3:实例化Bean3个怎么new对象?如何解决循环依赖?
阶段4:初始化Bean4个属性怎么注入?AOP代理怎么生成?
阶段5:应用运行3个启动后做什么?如何预热缓存?
阶段6:关闭清理2个关闭前如何释放资源?

这篇文章用一个多租户系统,把15个扩展点串起来。看完你能知道:每个扩展点在什么时机执行,什么场景用什么扩展点。


不过为了降低运行门槛,我把PostgreSQL、Redis、Nacos都Mock掉了:

  • 数据库用H2内存数据库
  • Redis用ConcurrentHashMap
  • Nacos用本地JSON文件

这样你clone代码就能直接跑,不用安装一堆环境。

我们的设计思路

先看个整体架构图:

graph TB
    A1["[1.1] ApplicationContextInitializer<br/>加载Nacos配置<br/><small>TenantContextInitializer</small>"] --> A2["[1.2] BeanDefinitionRegistryPostProcessor<br/>注册数据源Bean<br/><small>TenantBeanRegistrar</small>"]
    A2 --> A3["[1.3] BeanFactoryPostProcessor<br/>解密数据库密码<br/><small>PropertyDecryptorPostProcessor</small>"]
    
    A3 --> B1["[2.3] FactoryBean<br/>创建H2数据源A<br/><small>MockDataSourceFactory</small>"]
    A3 --> B3["[2.3] FactoryBean<br/>创建H2数据源B<br/><small>MockDataSourceFactory</small>"]
    B1 --> B2["初始化表结构A<br/><small>执行schema.sql</small>"]
    B3 --> B4["初始化表结构B<br/><small>执行schema.sql</small>"]
    
    B2 --> C1["[3.3] BeanPostProcessor<br/>生成AOP审计代理<br/><small>AuditLogProxyCreator</small>"]
    B4 --> C1
    C1 --> C2["[3.4] Aware接口<br/>注入ApplicationContext<br/><small>TenantContextAware</small>"]
    
    C2 --> D1["[4.1] SmartInitializingSingleton<br/>预热租户路由<br/><small>TenantCacheWarmer</small>"]
    D1 --> D2["[4.3] ApplicationRunner<br/>预加载字典数据<br/><small>TenantDataPreloader</small>"]
    D2 --> D3["[4.3] CommandLineRunner<br/>启动定时任务<br/><small>ScheduledTaskStarter</small>"]
    
    style A1 fill:#fff9e6
    style A2 fill:#fff9e6
    style A3 fill:#fff9e6
    style B1 fill:#ffe6f0
    style B2 fill:#ffe6f0
    style B3 fill:#ffe6f0
    style B4 fill:#ffe6f0
    style C1 fill:#e6f3ff
    style C2 fill:#e6f3ff
    style D1 fill:#e6ffe6
    style D2 fill:#e6ffe6
    style D3 fill:#e6ffe6

可以看到:

  • 黄色区域是阶段1的3个扩展点,负责配置准备
  • 粉色区域是阶段2的扩展点,负责创建数据源
  • 蓝色区域是阶段3的扩展点,负责AOP代理和容器注入
  • 绿色区域是阶段4的扩展点,负责缓存预热和启动任务

看到这个流程,你可能会问:为什么要在这些时机做这些事?

这就要从Spring的生命周期说起了。

阶段1:容器准备 - 配置怎么加载?

我们要解决的问题

多租户系统的配置通常存在Nacos配置中心。但问题来了:

  • 本地有application.yml
  • Nacos有配置
  • 命令行还能传参数

Spring到底用哪个?优先级是什么?

我当时遇到的就是这个问题。Nacos配置明明改了,但应用还是用的旧配置。

扩展点1.1:ApplicationContextInitializer

后来发现,Spring容器刷新之前,有个ApplicationContextInitializer扩展点。这是最早能介入的地方,可以往Environment里塞配置。

sequenceDiagram
    participant App as 应用启动
    participant Init as Initializer
    participant Env as Environment
    participant Spring as Spring容器
    
    App->>Init: 执行initialize
    Init->>Env: 加载Nacos配置
    Init->>Env: addFirst设最高优先级
    Init->>Spring: 容器开始刷新

我们的实现是这样的:

public class TenantContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
    
    @Override
    public void initialize(ConfigurableApplicationContext context) {
        ConfigurableEnvironment env = context.getEnvironment();
        
        // 从Mock Nacos加载配置
        MockNacosConfigLoader loader = new MockNacosConfigLoader();
        Map<String, Object> nacosConfig = loader.loadTenantConfigAsProperties();
        
        // 添加为最高优先级配置源
        env.getPropertySources().addFirst(
            new MapPropertySource("nacos-tenant-config", nacosConfig)
        );
        
        log.info("从Nacos加载了{}个租户的配置", nacosConfig.size() / 4);
    }
}

注意这里用的是addFirst(),意思是把Nacos配置的优先级设为最高。这样即使本地yml和Nacos冲突,也会用Nacos的。

启动日志会打印:

[main] TenantContextInitializer : 从Nacos加载了2个租户的配置

你可能会问:怎么让Spring执行这个初始化器?

需要在META-INF/spring.factories里注册:

org.springframework.context.ApplicationContextInitializer=\
com.example.tenant.config.TenantContextInitializer

扩展点1.2:BeanDefinitionRegistryPostProcessor

配置加载完了,接下来要把租户数据源注册成Bean。

这里有个问题:租户数量是动态的,从配置中心读出来的。你不可能在代码里写死@Bean DataSource tenantA()

Spring提供了BeanDefinitionRegistryPostProcessor扩展点,可以在运行时动态注册Bean。

sequenceDiagram
    participant Spring
    participant Reg as Registrar
    participant Registry
    
    Spring->>Reg: Bean定义加载完成
    Reg->>Reg: 解析租户配置
    loop 每个租户
        Reg->>Registry: 注册数据源Bean
    end
    Spring->>Spring: 开始实例化Bean

代码大概是这样:

public class TenantBeanRegistrar implements BeanDefinitionRegistryPostProcessor {
    
    @Override
    public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
        // 遍历配置中的租户
        for (int i = 0; i < 10; i++) {
            String tenantId = env.getProperty("tenant.datasources[" + i + "].tenantId");
            
            if (tenantId != null) {
                // 构建Bean定义(使用FactoryBean)
                BeanDefinitionBuilder builder = BeanDefinitionBuilder
                    .genericBeanDefinition(MockDataSourceFactory.class)
                    .addPropertyValue("tenantId", tenantId)
                    .addPropertyValue("jdbcUrl", env.getProperty("...jdbcUrl"));
                
                // 注册到容器
                registry.registerBeanDefinition("dataSource_" + tenantId, 
                    builder.getBeanDefinition());
                
                log.info("注册数据源Bean: dataSource_{}", tenantId);
            }
        }
    }
}

日志会显示:

[main] TenantBeanRegistrar : 注册数据源Bean: dataSource_tenant-a
[main] TenantBeanRegistrar : 注册数据源Bean: dataSource_tenant-b

这就是MyBatis扫描Mapper接口的原理。MapperScannerConfigurer就是用这个扩展点,动态注册Mapper的Bean定义。

扩展点1.3:BeanFactoryPostProcessor

数据源Bean注册完了,但有个安全问题:数据库密码明文存在配置中心,不安全。

我们的做法是:配置中心存加密密码ENC(3DES:abc123...),启动时解密成明文。

BeanFactoryPostProcessor可以在Bean实例化之前,修改Bean的元数据(比如属性值)。

public class PropertyDecryptorPostProcessor implements BeanFactoryPostProcessor {
    
    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
        for (String beanName : beanFactory.getBeanDefinitionNames()) {
            if (beanName.startsWith("dataSource_")) {
                BeanDefinition bd = beanFactory.getBeanDefinition(beanName);
                
                // 获取password属性
                Object password = bd.getPropertyValues().get("password");
                if (password.toString().startsWith("ENC(")) {
                    // 解密
                    String decrypted = decrypt3DES(password.toString());
                    bd.getPropertyValues().add("password", decrypted);
                    
                    log.info("解密数据源密码: {}", beanName);
                }
            }
        }
    }
}

这样,等Bean实例化的时候,拿到的就是解密后的明文密码了。

到这里,容器准备阶段就结束了。此时:

  • 配置已加载(来自Nacos)
  • 租户数据源Bean已注册
  • 密码已解密

接下来Spring会开始实例化这些Bean。

阶段2:Bean实例化 - 对象怎么创建?

我们要解决的问题

现在要创建数据源对象了。但H2数据源的创建比较复杂:

  1. 配置JDBC URL
  2. 创建HikariCP连接池
  3. 执行schema.sql初始化表结构
  4. 预热连接

如果直接在代码里new,会很乱。Spring提供了FactoryBean,专门用来创建复杂对象。

扩展点2.3:FactoryBean

FactoryBean是个工厂Bean,它的getObject()方法返回的才是真正的Bean。

graph LR
    A[Spring容器] -->|getBean| B[DataSourceFactory]
    B -->|getObject| C[HikariDataSource]
    C --> D[初始化表结构]
    D --> E[返回数据源]
    
    style A fill:#e1f5ff
    style B fill:#fff9e6
    style C fill:#ffe6f0
    style D fill:#e6f3ff
    style E fill:#e6ffe6

代码是这样的:

public class MockDataSourceFactory implements FactoryBean<DataSource> {
    
    private String tenantId;
    private String jdbcUrl;
    
    @Override
    public DataSource getObject() throws Exception {
        log.info("开始创建H2数据源: {}", tenantId);
        
        // 创建HikariCP连接池
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(jdbcUrl);
        config.setUsername("sa");
        config.setPassword("");
        
        HikariDataSource ds = new HikariDataSource(config);
        
        // 初始化表结构
        try (Connection conn = ds.getConnection()) {
            ScriptUtils.executeSqlScript(conn, 
                new ClassPathResource("schema.sql"));
        }
        
        log.info("H2数据源创建完成: {}", tenantId);
        return ds;
    }
    
    @Override
    public Class<?> getObjectType() {
        return DataSource.class;
    }
}

当Spring需要dataSource_tenant-a这个Bean时,会调用FactoryBean的getObject(),返回的HikariDataSource就是真正的数据源。

日志会显示创建过程:

[main] MockDataSourceFactory : 开始创建H2数据源: tenant-a
[main] MockDataSourceFactory : H2数据库Schema初始化完成: tenant-a
[main] MockDataSourceFactory : H2数据源创建完成: tenant-a

扩展点2.1 和 2.2:两个不太常用的

还有两个实例化阶段的扩展点:

  • InstantiationAwareBeanPostProcessor:可以在new对象前后干预
  • SmartInstantiationAwareBeanPostProcessor:处理循环依赖、选择构造函数

这两个比较底层,一般业务代码用不到。主要是框架层面用,比如:

  • Spring的@Autowired注解就是用InstantiationAwareBeanPostProcessor实现的
  • 循环依赖的解决用到了SmartInstantiationAwareBeanPostProcessor的getEarlyBeanReference()

我们的项目里也简单实现了一下,主要是演示用。

阶段3:Bean初始化 - 属性怎么注入?代理怎么生成?

我们要解决的问题

现在Bean对象已经new出来了,但还是个"半成品":

  • 属性还没注入(@Autowired还没生效)
  • AOP代理还没生成(@Transactional还不起作用)

这个阶段就是要把Bean变成"成品"。

Bean初始化的完整流程

sequenceDiagram
    participant Spring
    participant Bean
    participant BPP as PostProcessor
    participant Init
    
    Spring->>Bean: new对象
    Spring->>Bean: 注入@Autowired
    Spring->>BPP: postProcessBefore
    Spring->>Init: afterPropertiesSet
    Spring->>Bean: 执行@PostConstruct
    Spring->>BPP: postProcessAfter
    BPP->>Spring: 返回代理对象

扩展点3.3:BeanPostProcessor - 生成AOP代理

我们的多租户系统需要审计日志:记录谁在什么时候调用了什么方法。

传统做法是在每个方法里写日志代码,太麻烦。我们用AOP代理:

public class AuditLogProxyCreator implements BeanPostProcessor {
    
    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        // 检查Bean上有没有@AuditLog注解
        if (bean.getClass().isAnnotationPresent(AuditLog.class)) {
            // 生成代理对象
            ProxyFactory factory = new ProxyFactory(bean);
            factory.addAdvice(new MethodInterceptor() {
                @Override
                public Object invoke(MethodInvocation invocation) throws Throwable {
                    // 方法执行前打印日志
                    log.info("【审计】{}.{} - 入参: {}", 
                        beanName, 
                        invocation.getMethod().getName(),
                        Arrays.toString(invocation.getArguments()));
                    
                    // 执行原方法
                    Object result = invocation.proceed();
                    
                    // 方法执行后打印结果
                    log.info("【审计】{}.{} - 返回: {}", 
                        beanName,
                        invocation.getMethod().getName(),
                        result);
                    
                    return result;
                }
            });
            
            Object proxy = factory.getProxy();
            log.info("为Bean生成审计代理: {}", beanName);
            return proxy; // 返回代理对象
        }
        
        return bean; // 不需要代理,返回原对象
    }
}

注意最后的return proxy,这里返回的不是原始Bean,而是代理对象。Spring容器拿到的就是这个代理。

当你调用TenantService的方法时,实际调用的是代理对象的方法,会先打印日志,再执行业务逻辑。

日志会这样显示:

[main] AuditLogProxyCreator : 为Bean生成审计代理: tenantService
...
[http-nio-8080-exec-1] AuditLogProxyCreator : 【审计】tenantService.listUsers - 入参: [tenant-a]
[http-nio-8080-exec-1] AuditLogProxyCreator : 【审计】tenantService.listUsers - 返回: [{id=1, name=张三}, ...]

这就是Spring AOP的原理。@Transactional@Async也是在BeanPostProcessor的这个时机生成代理的。

扩展点3.2:InitializingBean

还有个InitializingBean接口,可以在属性注入完成后做一些初始化工作。

public class TenantConfigInitializer implements InitializingBean {
    
    @Autowired
    private Environment environment;
    
    @Override
    public void afterPropertiesSet() throws Exception {
        // 校验租户配置
        String defaultTenant = environment.getProperty("tenant.default");
        if (defaultTenant == null) {
            throw new IllegalStateException("未配置默认租户");
        }
        
        log.info("租户配置校验通过,默认租户: {}", defaultTenant);
    }
}

这个方法会在@Autowired注入完成后执行,可以用来做配置校验、资源初始化之类的。

扩展点3.4:Aware接口 - 注入容器对象

有时候我们需要在Bean里获取Spring容器本身,比如动态获取其他Bean、发布事件等。

Spring提供了几个Aware接口:

@Component
public class TenantContextHolder implements 
    ApplicationContextAware,  // 注入ApplicationContext
    BeanFactoryAware,         // 注入BeanFactory
    EnvironmentAware {        // 注入Environment
    
    private static ApplicationContext applicationContext;
    
    @Override
    public void setApplicationContext(ApplicationContext context) {
        TenantContextHolder.applicationContext = context;
    }
    
    // 工具方法:根据租户ID获取数据源
    public static DataSource getDataSource(String tenantId) {
        return applicationContext.getBean("dataSource_" + tenantId, DataSource.class);
    }
}

这样我们就可以在任何地方通过TenantContextHolder.getDataSource("tenant-a")动态获取数据源了。

阶段4:启动完成 - 缓存怎么预热?

我们要解决的问题

Bean都初始化完了,但应用还不能对外服务。多租户系统需要:

  1. 把租户路由规则加载到Redis(避免每次请求都查数据库)
  2. 预加载字典数据
  3. 启动定时任务

这些逻辑要等所有Bean都初始化完成后才能执行。

扩展点4.1:SmartInitializingSingleton

Spring提供了SmartInitializingSingleton扩展点,在所有单例Bean初始化完成后执行。

graph LR
    A[Bean1初始化] --> D[所有Bean完成]
    B[Bean2初始化] --> D
    C[Bean3初始化] --> D
    D --> E[SmartInitializing执行]
    E --> F[缓存预热]
    E --> G[预加载数据]
    
    style A fill:#ffe6f0
    style B fill:#ffe6f0
    style C fill:#ffe6f0
    style D fill:#fff9e6
    style E fill:#e6f3ff
    style F fill:#e6ffe6
    style G fill:#e6ffe6

代码是这样的:

@Component
public class TenantCacheWarmer implements SmartInitializingSingleton {
    
    @Autowired
    private ApplicationContext context;
    
    @Autowired
    private MockRedisTemplate redis;
    
    @Override
    public void afterSingletonsInstantiated() {
        // 获取所有数据源Bean
        Map<String, DataSource> dataSources = context.getBeansOfType(DataSource.class);
        
        for (String beanName : dataSources.keySet()) {
            String tenantId = extractTenantId(beanName); // dataSource_tenant-a -> tenant-a
            
            // 预热租户路由到Redis
            redis.set("tenant:" + tenantId + ":route", beanName);
            log.info("预热缓存: tenant:{} -> {}", tenantId, beanName);
            
            // 预加载字典数据
            List<Dict> dicts = loadDictFromDB(dataSources.get(beanName));
            redis.set("tenant:" + tenantId + ":dict", dicts);
            log.info("预热字典数据: {} 共{}条", tenantId, dicts.size());
        }
    }
}

日志会显示预热过程:

[main] TenantCacheWarmer : 预热缓存: tenant:tenant-a -> dataSource_tenant-a
[main] MockRedisTemplate : 【Mock Redis】SET tenant:tenant-a:route = dataSource_tenant-a
[main] TenantCacheWarmer : 预热字典数据: tenant-a 共2条

扩展点4.2 和 4.3:ApplicationListener、ApplicationRunner

还有两个扩展点:

  • ApplicationListener:监听容器启动完成事件
  • ApplicationRunner:容器启动完成后执行任务
@Component
public class SystemReadyListener implements ApplicationListener<ContextRefreshedEvent> {
    
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        // 发送系统就绪消息到MQ
        rabbitTemplate.convertAndSend("system.events", "系统已启动");
        log.info("系统启动完成,已发送上线通知");
    }
}

@Component
@Order(1) // 控制执行顺序
public class TenantDataPreloader implements ApplicationRunner {
    
    @Override
    public void run(ApplicationArguments args) {
        // 预加载更多数据
        log.info("预加载tenant-a的字典数据: 2条");
        log.info("预加载tenant-b的字典数据: 2条");
    }
}

这两个扩展点的区别:

  • ApplicationListener是事件驱动的,可以监听多种事件
  • ApplicationRunner是启动后执行一次,适合做数据初始化

阶段5:关闭清理 - 资源怎么释放?

我们要解决的问题

应用要关闭了,需要清理资源:

  • 关闭每个租户的数据源连接
  • 清空Redis缓存
  • 关闭MQ连接

如果不清理,可能导致数据库连接泄漏。

扩展点5.1:DisposableBean

Spring提供了DisposableBean接口,在Bean销毁前执行。

@Component
public class ResourceCleaner implements DisposableBean {
    
    @Autowired
    private List<DataSource> allDataSources;
    
    @Autowired
    private MockRedisTemplate redis;
    
    @Override
    public void destroy() throws Exception {
        log.info("容器关闭,开始清理资源");
        
        // 关闭所有数据源
        for (DataSource ds : allDataSources) {
            if (ds instanceof HikariDataSource) {
                ((HikariDataSource) ds).close();
                log.info("关闭数据源: {}", ds);
            }
        }
        
        // 清空Redis缓存
        redis.clear();
        log.info("清空Redis缓存");
        
        log.info("资源清理完成");
    }
}

当你按Ctrl+C或者执行kill -15时,Spring会优雅关闭,调用这个destroy()方法。

日志会显示清理过程:

[main] ResourceCleaner : 容器关闭,开始清理资源
[main] ResourceCleaner : 关闭数据源: dataSource_tenant-a
[main] ResourceCleaner : 关闭数据源: dataSource_tenant-b
[main] MockRedisTemplate : 【Mock Redis】CLEAR 清空所有缓存
[main] ResourceCleaner : 清空Redis缓存
[main] ResourceCleaner : 资源清理完成

注意:如果你用kill -9强制杀进程,这个方法不会执行。所以生产环境一定要用优雅关闭。

完整的流程图

把所有扩展点串起来,整个流程是这样的:

graph TB
    Start[应用启动] --> Init1["[1.1] 加载Nacos配置<br/>TenantContextInitializer"]
    Init1 --> Init2["[1.2] 注册数据源Bean<br/>TenantBeanRegistrar"]
    Init2 --> Init3["[1.3] 解密密码<br/>PropertyDecryptorPostProcessor"]
    
    Init3 --> Create1["[2.3] 创建数据源A<br/>MockDataSourceFactory"]
    Init3 --> Create2["[2.3] 创建数据源B<br/>MockDataSourceFactory"]
    
    Create1 --> InitBean1["[3.1] 检查AuditLog<br/>AuditLogProxyCreator"]
    Create2 --> InitBean1
    
    InitBean1 --> InitBean2["[3.2] 校验租户配置<br/>TenantConfigInitializer"]
    InitBean2 --> InitBean3["[3.3] 生成AOP代理<br/>AuditLogProxyCreator"]
    InitBean3 --> InitBean4["[3.4] 注入容器对象<br/>TenantContextAware"]
    
    InitBean4 --> Startup1["[4.1] 预热缓存<br/>TenantCacheWarmer"]
    Startup1 --> Startup2["[4.2] 发送启动通知<br/>SystemReadyListener"]
    Startup2 --> Startup3["[4.3] 预加载数据<br/>TenantDataPreloader"]
    
    Startup3 --> Running[应用运行]
    
    Running --> Shutdown[收到关闭信号]
    Shutdown --> Cleanup["[5.1] 清理资源<br/>ResourceCleaner"]
    Cleanup --> End[容器关闭]
    
    style Start fill:#e1f5ff
    style Init1 fill:#fff9e6
    style Init2 fill:#fff9e6
    style Init3 fill:#fff9e6
    style Create1 fill:#ffe6f0
    style Create2 fill:#ffe6f0
    style InitBean1 fill:#e6f3ff
    style InitBean2 fill:#e6f3ff
    style InitBean3 fill:#e6f3ff
    style InitBean4 fill:#e6f3ff
    style Startup1 fill:#e6ffe6
    style Startup2 fill:#e6ffe6
    style Startup3 fill:#e6ffe6
    style Running fill:#f0f0f0
    style Shutdown fill:#ffe6e6
    style Cleanup fill:#ffe6e6
    style End fill:#d0d0d0

每个节点都标注了:

  • 扩展点编号(如[1.1]、[2.3])
  • 业务功能(加载配置、创建数据源)
  • 实现类名(TenantContextInitializer)

这样你看日志的时候,就能对应上是哪个扩展点在执行了。

几个要注意的地方

1. 扩展点的执行次数

不是所有扩展点都只执行1次。我整理了一下:

只执行1次(容器级别):

  • ApplicationContextInitializer
  • BeanDefinitionRegistryPostProcessor
  • BeanFactoryPostProcessor
  • SmartInitializingSingleton
  • ApplicationListener
  • ApplicationRunner

每个Bean都执行(Bean级别):

  • InstantiationAwareBeanPostProcessor
  • BeanPostProcessor(Before和After)

比如你有10个Bean,BeanPostProcessor的postProcessBeforeInitialization会执行10次。

2. 为什么AOP有时候不生效

我之前遇到过一个坑:在Service类里,方法A调用方法B,方法B上有@Transactional,但事务不生效。

原因是:方法A里调用的是this.methodB(),this不是代理对象,而是原始对象。

解决办法:

// 错误写法
public void methodA() {
    this.methodB(); // this不是代理
}

// 正确写法
@Autowired
private ApplicationContext context;

public void methodA() {
    TenantService proxy = context.getBean(TenantService.class);
    proxy.methodB(); // 通过代理对象调用
}

3. ApplicationContextInitializer怎么注册

这个扩展点比较特殊,需要手动注册。有三种方式:

// 方式1:META-INF/spring.factories(推荐)
org.springframework.context.ApplicationContextInitializer=\
com.example.TenantContextInitializer

// 方式2:启动类手动添加
public static void main(String[] args) {
    SpringApplication app = new SpringApplication(Application.class);
    app.addInitializers(new TenantContextInitializer());
    app.run(args);
}

// 方式3:application.yml配置
context:
  initializer:
    classes: com.example.TenantContextInitializer

我一般用方式1,因为把配置放在spring.factories,不用改启动类。

4. FactoryBean的坑

获取FactoryBean有个细节:

// 获取FactoryBean创建的对象(数据源)
DataSource ds = context.getBean("dataSource_tenant-a", DataSource.class);

// 获取FactoryBean本身
MockDataSourceFactory factory = context.getBean("&dataSource_tenant-a", MockDataSourceFactory.class);

注意Bean名称前面要加&符号。我第一次用的时候没加,拿到的是数据源而不是工厂,调试了半天。

实际运行效果

我把完整的启动日志贴一下,你可以看到扩展点的执行顺序:

// 阶段1:容器准备
2025-11-24 20:02:45 [main] TenantContextInitializer : 从Nacos加载了2个租户的配置
2025-11-24 20:02:46 [main] TenantBeanRegistrar : 注册数据源Bean: dataSource_tenant-a
2025-11-24 20:02:46 [main] TenantBeanRegistrar : 注册数据源Bean: dataSource_tenant-b
2025-11-24 20:02:46 [main] PropertyDecryptorPostProcessor : 解密数据源密码: dataSource_tenant-a

// 阶段2:Bean实例化
2025-11-24 20:02:46 [main] MockDataSourceFactory : 开始创建H2数据源: tenant-a
2025-11-24 20:02:46 [main] MockDataSourceFactory : H2数据库Schema初始化完成: tenant-a
2025-11-24 20:02:46 [main] MockDataSourceFactory : H2数据源创建完成: tenant-a

// 阶段3:Bean初始化
2025-11-24 20:02:46 [main] AuditLogProxyCreator : 检测到@AuditLog注解: tenantService
2025-11-24 20:02:46 [main] TenantConfigInitializer : 租户配置校验通过,默认租户: tenant-a
2025-11-24 20:02:46 [main] AuditLogProxyCreator : 为Bean生成审计代理: tenantService

// 阶段4:启动完成
2025-11-24 20:02:52 [main] TenantCacheWarmer : 预热缓存: tenant:tenant-a -> dataSource_tenant-a
2025-11-24 20:02:52 [main] TenantCacheWarmer : 预热字典数据: tenant-a 共2条
2025-11-24 20:02:52 [main] SystemReadyListener : 系统启动完成,已发送上线通知
2025-11-24 20:02:52 [main] TenantDataPreloader : 预加载tenant-a的字典数据: 2

// 应用运行中,处理请求
2025-11-24 20:02:58 [http-nio-8080-exec-1] AuditLogProxyCreator : 【审计】tenantService.listUsers - 入参: [tenant-a]
2025-11-24 20:02:58 [http-nio-8080-exec-1] AuditLogProxyCreator : 【审计】tenantService.listUsers - 返回: [{id=1, name=张三}, ...]

// 阶段5:关闭清理
2025-11-24 20:03:00 [main] ResourceCleaner : 容器关闭,开始清理资源
2025-11-24 20:03:00 [main] ResourceCleaner : 关闭数据源: dataSource_tenant-a
2025-11-24 20:03:00 [main] ResourceCleaner : 清空Redis缓存
2025-11-24 20:03:00 [main] ResourceCleaner : 资源清理完成

从日志可以清楚地看到每个扩展点的执行时机。

最后

这篇文章用一个多租户系统,把Spring Boot的15个扩展点串起来了。每个扩展点都有:

  • 执行时机
  • 业务场景
  • 代码实现
  • 日志验证

看完之后,你应该能理解Spring容器从启动到关闭的完整流程。

项目代码已经放在Gitee了,clone下来对着日志跑一遍,理解会更快。

后续会持续分享硬核干货,有兴趣的话,请关注我。


快速上手

项目用的是Mock方式,无需安装任何中间件。

# 克隆项目
git clone https://gitee.com/sh_wangwanbao/surfing-spring-extension/tree/main

# 启动应用
mvn spring-boot:run

# 测试租户切换
curl http://localhost:8080/demo/users?tenantId=tenant-a
curl http://localhost:8080/demo/users?tenantId=tenant-b

应用启动大概3-5秒,然后观察日志输出,可以看到15个扩展点的执行过程。


都看到这里了,点个赞呗,你的支持是我创作的最大动力。

Gitee项目gitee.com/sh_wangwanb…