Spring Boot 4.1.1 深度源码解析-第 22 章 · 数据源、连接池与事务

Spring Boot 4.1.1 深度源码解析-第 22 章 · 数据源、连接池与事务

卷五(Web 栈)我们看了 Boot 怎么处理"一个请求进来"的完整链路。 卷六(数据访问)要回答一个不同的问题:Boot 怎么处理"几十个可选依赖"?

module/ 下与数据相关的模块有二十多个: spring-boot-jdbc、spring-boot-jpa、spring-boot-hibernate、spring-boot-jooq、 spring-boot-r2dbc、spring-boot-flyway、spring-boot-liquibase、 spring-boot-data-*(Cassandra、Couchbase、Elasticsearch、JDBC、JPA、LDAP、 MongoDB、Neo4j、R2DBC、Redis)......

本章只看其中最基础的一块 :JDBC 数据源与事务。 但它足以展示 Boot 应对"依赖矩阵"的完整手法------ 五选一的连接池判定、两级条件嵌套、BPP 覆盖绑定值、以及一条贯穿始终的 JdbcConnectionDetails 抽象。


0. 引子:一条 spring.datasource.url 引发的十步推断

最小配置:

yaml 复制代码
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mydb
    username: root
    password: secret
java 复制代码
@Autowired DataSource dataSource;   // 拿到的到底是什么?

答案是 HikariDataSource------但 Boot 为得出这个结论走了十步:

markdown 复制代码
 1. `spring-boot-starter-jdbc` 引入了 HikariCP(间接)
 2. `DataSourceAutoConfiguration` 检查:DataSource + EmbeddedDatabaseType 在 classpath?
 3. 检查:没有 R2DBC 的 ConnectionFactory(`@ConditionalOnMissingBean(type=...)`)
 4. PooledDataSourceCondition:spring.datasource.type 配了?或者能找到连接池?
 5. DataSourceBuilder.findType(classLoader) 按固定顺序试 6 种连接池
 6. Hikari 胜出 → @Import 顺序里 Hikari 排第一
 7. 但 url 已经配了 → EmbeddedDatabaseCondition 不匹配(不用嵌入式数据库)
 8. 创建 PropertiesJdbcConnectionDetails(把 DataSourceProperties 适配成接口)
 9. HikariDataSource bean 用 @ConfigurationProperties("spring.datasource.hikari") 二次绑定
10. HikariJdbcConnectionDetailsBeanPostProcessor 用 JdbcConnectionDetails 覆盖 url/user/password

注意第 9 与第 10 步的微妙关系 : dataSource(...) 方法里已经用 connectionDetails 设了 url/username/password(第 10 步的一部分), 然后 @ConfigurationProperties 绑定会再应用 spring.datasource.hikari.*, 最后 BPP 再按 JdbcConnectionDetails 覆盖一次。

为什么要在最后再覆盖一次? 因为 JdbcConnectionDetails 可能来自 Docker Compose / Testcontainers 等外部源 (§5 展开),那些值必须能覆盖配置文件里的值。 这是本章的核心设计。


1. 模块版图:9 条 imports 的分工

按第一定律,先看 spring-boot-jdbc 的 .imports:

复制代码
org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
org.springframework.boot.jdbc.autoconfigure.DataSourceInitializationAutoConfiguration
org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration
org.springframework.boot.jdbc.autoconfigure.JdbcClientAutoConfiguration
org.springframework.boot.jdbc.autoconfigure.JdbcTemplateAutoConfiguration
org.springframework.boot.jdbc.autoconfigure.JndiDataSourceAutoConfiguration
org.springframework.boot.jdbc.autoconfigure.XADataSourceAutoConfiguration
org.springframework.boot.jdbc.autoconfigure.health.DataSourceHealthContributorAutoConfiguration
org.springframework.boot.jdbc.autoconfigure.metrics.DataSourcePoolMetricsAutoConfiguration

9 条 ,可以按"是否创建 DataSource"分成两组:

组 自动配置 关系
创建 DataSource 的 DataSourceAutoConfiguration、JndiDataSourceAutoConfiguration、XADataSourceAutoConfiguration 三者互斥(§4 详述)
消费 DataSource 的 DataSourceTransactionManagerAutoConfiguration、JdbcTemplateAutoConfiguration、JdbcClientAutoConfiguration、DataSourceInitializationAutoConfiguration、health.*、metrics.* 都要求"已有 DataSource"

消费方一律用 @ConditionalOnSingleCandidate(DataSource.class) ------ 与第 21 章 gRPC client 的 @ConditionalOnSingleCandidate(GrpcChannelFactory.class) 是同一手法:"只有一个候选时才自动配置",有多个就交给用户决定。

1.1 三条"创建"路径的优先级

kotlin 复制代码
JndiDataSourceAutoConfiguration      @ConditionalOnProperty("spring.datasource.jndi-name")
XADataSourceAutoConfiguration        @ConditionalOnBean(XADataSourceWrapper.class) + @ConditionalOnMissingBean(DataSource)
DataSourceAutoConfiguration          兜底

JndiDataSourceAutoConfiguration 上有 @AutoConfiguration(before = { XADataSourceAutoConfiguration.class, DataSourceAutoConfiguration.class }) ------JNDI 排最前。

XADataSourceAutoConfiguration 上是 @AutoConfiguration(before = DataSourceAutoConfiguration.class, after = JtaAutoConfiguration.class)。

于是完整顺序:

复制代码
JTA → JNDI → XA → DataSourceAutoConfiguration

JNDI 为什么最优先? 因为它靠属性 触发(显式配了 jndi-name), 而 XA 靠 bean 触发(有 XADataSourceWrapper)。 显式属性 > 隐式 bean ------ 这与第 17 章讲的"属性覆盖编程式设置"是同一精神。


2. DataSourceAutoConfiguration:两个内部配置 + 三个自定义 Condition

java 复制代码
@AutoConfiguration(before = DataSourceInitializationAutoConfiguration.class)
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
@ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory")
@EnableConfigurationProperties(DataSourceProperties.class)
@Import({ DataSourcePoolMetadataProvidersConfiguration.class, DataSourceCheckpointRestoreConfiguration.class })
public final class DataSourceAutoConfiguration {

2.1 ★ @ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory")

这是全章最值得记住的一行。

spring-boot-jdbc 不能 引用 io.r2dbc.spi.ConnectionFactory 的类字面量 (R2DBC 是独立的模块,不是 JDBC 的依赖)。所以用 type = "..." 字符串形式。

语义 :如果应用里已经有 R2DBC 的 ConnectionFactory,就不要自动配置 JDBC 数据源。

为什么? 因为 spring-boot-starter-data-r2dbc 与 spring-boot-starter-jdbc 可能同时在 classpath 上(比如你既用 R2DBC 又用 Flyway 的 JDBC 迁移)。 此时如果两边都创建数据源,会:

  1. 浪费资源(两个连接池)
  2. 语义混乱(@Autowired DataSource 与 ConnectionFactory 指向不同的库?)

Boot 的策略:R2DBC 优先,JDBC 让路。

对称的设计 :R2dbcAutoConfiguration 那边应该也有对应的 @ConditionalOnMissingBean(type = "javax.sql.DataSource")。 ⚠️ 未核实 :我没有读 R2dbcAutoConfiguration 的类级注解(第 23 章会读)。 如果两边都写"对方存在就让路",会形成死锁(都不创建)。 推断是只有一边写了 (JDBC 让路),或者两边的其它条件不同。 自测方法:同时引入两个 starter,看创建了哪个。

2.2 两个内部配置的互斥

java 复制代码
@Configuration(proxyBeanMethods = false)
@Conditional(EmbeddedDatabaseCondition.class)
@ConditionalOnMissingBean({ DataSource.class, XADataSource.class })
@Import(EmbeddedDataSourceConfiguration.class)
protected static class EmbeddedDatabaseConfiguration {
}

@Configuration(proxyBeanMethods = false)
@Conditional(PooledDataSourceCondition.class)
@ConditionalOnMissingBean({ DataSource.class, XADataSource.class })
@Import({ DataSourceConfiguration.Hikari.class, DataSourceConfiguration.Tomcat.class,
        DataSourceConfiguration.Dbcp2.class, DataSourceConfiguration.OracleUcp.class,
        DataSourceConfiguration.Generic.class, DataSourceJmxConfiguration.class,
        LazyConnectionDataSourceConfiguration.class })
protected static class PooledDataSourceConfiguration {

    @Bean
    @ConditionalOnMissingBean(JdbcConnectionDetails.class)
    PropertiesJdbcConnectionDetails jdbcConnectionDetails(DataSourceProperties properties) {
        return new PropertiesJdbcConnectionDetails(properties);
    }
}

两个都用 @ConditionalOnMissingBean({DataSource, XADataSource}) ------ 用户自己定义了数据源,两个都失效。

真正的互斥靠各自的 @Conditional:

内部配置 条件 含义
EmbeddedDatabaseConfiguration EmbeddedDatabaseCondition 没有 url 且没有连接池且有嵌入式数据库
PooledDataSourceConfiguration PooledDataSourceCondition 配了 type 或能找到连接池

2.3 EmbeddedDatabaseCondition:Javadoc 里写明优先级

java 复制代码
/**
 * {@link Condition} to detect when an embedded {@link DataSource} type can be used.
 * If a pooled {@link DataSource} is available, it will always be preferred to an
 * {@code EmbeddedDatabase}.
 */
static class EmbeddedDatabaseCondition extends SpringBootCondition {

    @Override
    public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
        ConditionMessage.Builder message = ConditionMessage.forCondition("EmbeddedDataSource");
        if (hasDataSourceUrlProperty(context)) {
            return ConditionOutcome.noMatch(message.because(DATASOURCE_URL_PROPERTY + " is set"));
        }
        if (anyMatches(context, metadata, this.pooledCondition)) {
            return ConditionOutcome.noMatch(message.foundExactly("supported pooled data source"));
        }
        if (!ClassUtils.isPresent(EMBEDDED_DATABASE_TYPE, context.getClassLoader())) {
            return ConditionOutcome.noMatch(message.didNotFind("required class").items(Style.QUOTE, EMBEDDED_DATABASE_TYPE));
        }
        EmbeddedDatabaseType type = EmbeddedDatabaseConnection.get(context.getClassLoader()).getType();
        if (type == null) {
            return ConditionOutcome.noMatch(message.didNotFind("embedded database").atAll());
        }
        return ConditionOutcome.match(message.found("embedded database").items(type));
    }

★ 这是"第四定律"(顺序语义常藏在 Javadoc 里)的教科书例证 : "If a pooled DataSource is available, it will always be preferred to an EmbeddedDatabase" ------这句话只写在 Javadoc 里,代码里只能看到"如果有连接池就 noMatch"。

四个否决条件(按顺序):

css 复制代码
① 配了 spring.datasource.url(且有文本内容)  → 不用嵌入式
② 能找到连接池                                → 不用嵌入式(★ Javadoc 声明的优先级)
③ spring-jdbc 的 EmbeddedDatabaseType 不在 classpath → 没法用嵌入式
④ classpath 上找不到 H2/HSQL/Derby            → 没得用

① 的 hasDataSourceUrlProperty 有个细节:

java 复制代码
private boolean hasDataSourceUrlProperty(ConditionContext context) {
    Environment environment = context.getEnvironment();
    if (environment.containsProperty(DATASOURCE_URL_PROPERTY)) {
        try {
            return StringUtils.hasText(environment.getProperty(DATASOURCE_URL_PROPERTY));
        }
        catch (IllegalArgumentException ex) {
            // NOTE: This should be PlaceholderResolutionException
            // Ignore unresolvable placeholder errors
        }
    }
    return false;
}

为什么要 try-catch? 如果 spring.datasource.url=${DB_URL} 而 DB_URL 未定义, getProperty 会抛 IllegalArgumentException(占位符无法解析)。

Boot 的选择:忽略,当作"没配 url" → 于是走嵌入式数据库分支。

这个 catch 很危险也很重要。 危险:你以为配了 URL,实际没生效,应用静默用上了 H2 内存库。 重要:如果直接抛出,启动会失败在一个很难懂的地方。 Boot 选择"降级到嵌入式" + 后面的 DataSourceProperties 会给出清晰的失败分析(§7)。

注释 // NOTE: This should be PlaceholderResolutionException 是开发者留下的"技术债标记"------理想情况下应该只捕获 PlaceholderResolutionException, 而不是宽泛的 IllegalArgumentException。 (Framework 4.x 应该还没有这个异常类型,或者 Boot 不想依赖它。)

2.4 PooledDataSourceCondition:AnyNestedCondition 的 OR 语义

java 复制代码
static class PooledDataSourceCondition extends AnyNestedCondition {

    PooledDataSourceCondition() {
        super(ConfigurationPhase.PARSE_CONFIGURATION);
    }

    @ConditionalOnProperty("spring.datasource.type")
    static class ExplicitType {
    }

    @Conditional(PooledDataSourceAvailableCondition.class)
    static class PooledDataSourceAvailable {
    }
}

OR 语义:

  • 显式配了 spring.datasource.type(用户指定了实现类)
  • 或 classpath 上能找到支持的连接池

phase 是 PARSE_CONFIGURATION ------ 注意与第 21 章 gRPC 的 MissingNetworkGrpcServerCondition(REGISTER_BEAN)不同。

为什么这里可以用 PARSE_CONFIGURATION? 因为它的两个分支问的是属性和 classpath,都不是 bean:

  • @ConditionalOnProperty 问 Environment
  • PooledDataSourceAvailableCondition 问 DataSourceBuilder.findType(纯 classpath 探测)

第二十五定律的验证:条件问什么,phase 就配什么。

PooledDataSourceAvailableCondition 是手写的 SpringBootCondition:

java 复制代码
static class PooledDataSourceAvailableCondition extends SpringBootCondition {

    @Override
    public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
        ConditionMessage.Builder message = ConditionMessage.forCondition("PooledDataSource");
        if (DataSourceBuilder.findType(context.getClassLoader()) != null) {
            return ConditionOutcome.match(message.foundExactly("supported DataSource"));
        }
        return ConditionOutcome.noMatch(message.didNotFind("supported DataSource").atAll());
    }
}

它只是把 DataSourceBuilder.findType 包装成条件 ------ 把"能不能找到连接池"这个业务判断 委托给 DataSourceBuilder,条件层只做适配。

这是 Boot 的分层习惯 : 判断逻辑放在能独立测试的类里(DataSourceBuilder.findType 是 public static), Condition 只负责把结果转成 ConditionOutcome(带诊断消息)。


3. ★ DataSourceBuilder:六个连接池的固定顺序

DataSourceBuilder 是 @since 2.0.0 的公共 API(不是自动配置内部类), 用户可以自己用。它的核心是 findType。

3.1 findType 的两级查找

java 复制代码
public static @Nullable Class<? extends DataSource> findType(@Nullable ClassLoader classLoader) {
    MappedDataSourceProperties<?> mappings = MappedDataSourceProperties.forType(classLoader, null);
    return (mappings != null) ? mappings.getDataSourceInstanceType() : null;
}

forType(classLoader, null) 里 type 是 null,所以走"随便哪个都行"的分支:

java 复制代码
static <T extends DataSource> @Nullable MappedDataSourceProperties<T> lookupPooled(
        @Nullable ClassLoader classLoader, @Nullable Class<T> type) {
    MappedDataSourceProperties<T> result = null;
    result = lookup(classLoader, type, result, "com.zaxxer.hikari.HikariDataSource",
            HikariDataSourceProperties::new);
    result = lookup(classLoader, type, result, "org.apache.tomcat.jdbc.pool.DataSource",
            TomcatPoolDataSourceProperties::new);
    result = lookup(classLoader, type, result, "org.apache.commons.dbcp2.BasicDataSource",
            MappedDbcp2DataSource::new);
    result = lookup(classLoader, type, result, "oracle.ucp.jdbc.PoolDataSourceImpl",
            OraclePoolDataSourceProperties::new, "oracle.jdbc.OracleConnection");
    result = lookup(classLoader, type, result, "com.mchange.v2.c3p0.ComboPooledDataSource",
            ComboPooledDataSourceProperties::new);
    result = lookup(classLoader, type, result, "org.vibur.dbcp.ViburDBCPDataSource",
            ViburDataSourceProperties::new);
    return result;
}

★ 六种连接池的固定优先级:

顺序 连接池 类名 额外要求
1 HikariCP com.zaxxer.hikari.HikariDataSource ---
2 Tomcat JDBC Pool org.apache.tomcat.jdbc.pool.DataSource ---
3 Commons DBCP2 org.apache.commons.dbcp2.BasicDataSource ---
4 Oracle UCP oracle.ucp.jdbc.PoolDataSourceImpl 还需 oracle.jdbc.OracleConnection
5 C3P0 com.mchange.v2.c3p0.ComboPooledDataSource ---
6 Vibur DBCP org.vibur.dbcp.ViburDBCPDataSource ---

lookup 的短路逻辑:

java 复制代码
private static <T extends DataSource> @Nullable MappedDataSourceProperties<T> lookup(
        @Nullable ClassLoader classLoader, @Nullable Class<T> dataSourceType,
        @Nullable MappedDataSourceProperties<T> existing, String dataSourceClassName,
        Supplier<MappedDataSourceProperties<?>> propertyMappingsSupplier, String... requiredClassNames) {
    if (existing != null || !allPresent(classLoader, dataSourceClassName, requiredClassNames)) {
        return existing;                    // ★ 已有结果 or 类不全 → 返回 existing
    }
    MappedDataSourceProperties<?> propertyMappings = propertyMappingsSupplier.get();
    return (dataSourceType == null
            || propertyMappings.getDataSourceInstanceType().isAssignableFrom(dataSourceType))
                    ? (MappedDataSourceProperties<T>) propertyMappings : null;
}

existing != null 就短路 ------ 所以第一个匹配到的胜出。

requiredClassNames 的作用 :Oracle UCP 需要两个类 都在 (PoolDataSourceImpl + oracle.jdbc.OracleConnection)。 因为 UCP 通常随 ojdbc 一起出现,单独有 UCP 没驱动是没用的。

这是"第二十六定律"(可选依赖用字符串类名探测)的批量版 : 六个候选、七个类名,全部用 ClassUtils.isPresent 探测,没有任何编译期依赖。 spring-boot-jdbc 的 build.gradle 里这些连接池都是 optional。

3.2 还有一级:lookupBasic

java 复制代码
private static <T extends DataSource> @Nullable MappedDataSourceProperties<T> lookupBasic(
        @Nullable ClassLoader classLoader, Class<T> dataSourceType) {
    MappedDataSourceProperties<T> result = null;
    result = lookup(classLoader, dataSourceType, result,
            "org.springframework.jdbc.datasource.SimpleDriverDataSource", SimpleDataSourceProperties::new);
    result = lookup(classLoader, dataSourceType, result, "oracle.jdbc.datasource.OracleDataSource",
            OracleDataSourceProperties::new);
    result = lookup(classLoader, dataSourceType, result, "org.h2.jdbcx.JdbcDataSource",
            H2DataSourceProperties::new);
    result = lookup(classLoader, dataSourceType, result, "org.postgresql.ds.PGSimpleDataSource",
            PostgresDataSourceProperties::new);
    return result;
}

"Basic" = 非连接池的直接数据源 。四个: SimpleDriverDataSource、OracleDataSource、H2 JdbcDataSource、PGSimpleDataSource。

什么时候用 basic 而不是 pooled? forType 的逻辑是"先试 pooled,找不到再试 basic"(推断,因为 findType 只调 pooled; basic 应该是在显式指定 type 时用)。

⚠️ 未核实 :MappedDataSourceProperties.forType 的完整分支逻辑 (我只读了 lookupPooled 和 lookupBasic 两个方法,没读 forType 的 if-else)。 推断:type == null → 先 pooled 后 basic;type != null → 找该 type 对应的映射。

3.3 属性名映射:DataSourceProperty 枚举

DataSourceBuilder 要往任意 DataSource 实现上设 url/username/password, 但不同实现的 setter 名不同:

java 复制代码
private enum DataSourceProperty {

    URL(false, "url", "URL"),
    DRIVER_CLASS_NAME(true, "driverClassName"),
    USERNAME(false, "username", "user"),
    PASSWORD(false, "password");

    private final boolean optional;
    private final String[] names;

    @Nullable Method findSetter(Class<?> type) {
        return findMethod("set", type, String.class);
    }

    private @Nullable Method findMethod(String prefix, Class<?> type, Class<?>... paramTypes) {
        for (String name : this.names) {
            String candidate = prefix + StringUtils.capitalize(name);
            Method method = ReflectionUtils.findMethod(type, candidate, paramTypes);
            if (method != null) {
                return method;
            }
        }
        return null;
    }
}

每个属性有若干候选名:

  • URL → setUrl 或 setURL
  • USERNAME → setUsername 或 setUser
  • PASSWORD → setPassword

按顺序试,第一个找到的用 。这解释了为什么 HikariDataSource (有 setJdbcUrl 而非 setUrl)需要专门的映射类:

java 复制代码
// HikariDataSourceProperties
add(DataSourceProperty.URL, HikariDataSource::getJdbcUrl, HikariDataSource::setJdbcUrl);

每个连接池一个 MappedDataSourceProperties 子类,用方法引用声明 setter/getter。 这比反射"猜"更可靠,也比写六个 if-else 优雅。

DRIVER_CLASS_NAME(true, ...) 的 optional = true ------ driver class 是唯一可选的(可以不设,靠 URL 推断)。

3.4 derivedFrom:DataSourceUnwrapper.unwrapRoot

java 复制代码
public static DataSourceBuilder<?> derivedFrom(DataSource dataSource) {
    return new DataSourceBuilder<>(DataSourceUnwrapper.unwrapRoot(dataSource));
}

DataSourceInitializationAutoConfiguration 里用到了它(§6.2)。

unwrapRoot 的作用是"剥掉所有代理包装,拿到最里层的真数据源"。 因为数据源可能被 LazyConnectionDataSourceProxy、DelegatingDataSource、 事务代理等层层包裹,derivedFrom 需要拿到原始配置才能复制。

⚠️ 未核实 :DataSourceUnwrapper 的实现细节(它在本仓库,但我没读)。 从名字和用途推断是循环 unwrap。


4. DataSourceConfiguration:五个内部配置类的"抢椅子"

DataSourceAutoConfiguration.PooledDataSourceConfiguration 导入了 5 个:

java 复制代码
@Import({ DataSourceConfiguration.Hikari.class, DataSourceConfiguration.Tomcat.class,
        DataSourceConfiguration.Dbcp2.class, DataSourceConfiguration.OracleUcp.class,
        DataSourceConfiguration.Generic.class, DataSourceJmxConfiguration.class,
        LazyConnectionDataSourceConfiguration.class })

顺序:Hikari → Tomcat → Dbcp2 → OracleUcp → Generic。

每个都有 @ConditionalOnMissingBean(DataSource.class)------ 先注册的赢 (第 14 章讲过:@ConditionalOnMissingBean 的判定依赖注册顺序)。

4.1 四个"具体"配置的共同结构

以 Hikari 为例:

java 复制代码
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(HikariDataSource.class)
@ConditionalOnMissingBean(DataSource.class)
@ConditionalOnProperty(name = "spring.datasource.type", havingValue = "com.zaxxer.hikari.HikariDataSource",
        matchIfMissing = true)
static class Hikari {

    @Bean
    static HikariJdbcConnectionDetailsBeanPostProcessor jdbcConnectionDetailsHikariBeanPostProcessor(
            ObjectProvider<JdbcConnectionDetails> connectionDetailsProvider) {
        return new HikariJdbcConnectionDetailsBeanPostProcessor(connectionDetailsProvider);
    }

    @Bean
    @ConfigurationProperties("spring.datasource.hikari")
    HikariDataSource dataSource(DataSourceProperties properties, JdbcConnectionDetails connectionDetails,
            Environment environment) {
        String dataSourceClassName = environment.getProperty("spring.datasource.hikari.data-source-class-name");
        HikariDataSource dataSource = createDataSource(connectionDetails, HikariDataSource.class,
                properties.getClassLoader(), dataSourceClassName == null);
        if (StringUtils.hasText(properties.getName())) {
            dataSource.setPoolName(properties.getName());
        }
        return dataSource;
    }
}

三重条件:

条件 含义
@ConditionalOnClass(HikariDataSource.class) HikariCP 在 classpath
@ConditionalOnMissingBean(DataSource.class) 还没有人创建
@ConditionalOnProperty(..., havingValue = "com.zaxxer.hikari.HikariDataSource", matchIfMissing = true) 要么没配 type,要么 type 正好是 Hikari

★ 第三个条件是精妙之处:

matchIfMissing = true → 没配 spring.datasource.type 时通过。 配了的话,havingValue 必须完全等于 com.zaxxer.hikari.HikariDataSource。

所以:

  • 不配 type → Hikari 通过(如果 classpath 有它)
  • 配 type=com.zaxxer.hikari.HikariDataSource → Hikari 通过,其它不通过
  • 配 type=org.apache.commons.dbcp2.BasicDataSource → Hikari 不通过,Dbcp2 通过

havingValue 是精确字符串匹配 ------所以写 HikariDataSource(不带包名)不生效。 ⚠️ 这是一个很常见的踩坑点。

4.2 Generic:兜底

java 复制代码
@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(DataSource.class)
@ConditionalOnProperty(name = "spring.datasource.type")
static class Generic {

    @Bean
    DataSource dataSource(DataSourceProperties properties, JdbcConnectionDetails connectionDetails) {
        return createDataSource(connectionDetails, properties.getType(), properties.getClassLoader());
    }
}

唯一没有 matchIfMissing 的 ------必须显式 配 spring.datasource.type。

它的存在意义:spring.datasource.type 指向一个Boot 不认识 的实现类 (比如自定义的、或 C3P0/Vibur 这种 DataSourceConfiguration 里没列的)。

注意 C3P0 和 Vibur 的"半支持"状态 : DataSourceBuilder.lookupPooled 认识 它们(所以 findType 能返回它们, PooledDataSourceCondition 会匹配), 但 DataSourceConfiguration 没有 对应的内部配置类 (所以真正创建时落到 Generic)。

这是一个有意思的不对称 : DataSourceBuilder 支持 6 种,DataSourceConfiguration 只支持 4 种 + Generic。 差距的那 2 种(C3P0、Vibur)靠 Generic + DataSourceBuilder 的映射能力兜底。 我不知道这是否有意(可能是"C3P0/Vibur 用得少,不值得单独写配置类")。 ⚠️ 未核实 :C3P0 场景下 @ConfigurationProperties("spring.datasource.c3p0") 是否有效 (Generic 的 dataSource() 没有 @ConfigurationProperties 注解!)。 这意味着用 C3P0 时,spring.datasource.c3p0.* 不会被绑定。 这是一个真实的能力缺失,值得记一笔。

4.3 ★ createDataSource 的 applyDriverClassName 参数

java 复制代码
private static <T> T createDataSource(JdbcConnectionDetails connectionDetails,
        @Nullable Class<? extends DataSource> type, ClassLoader classLoader, boolean applyDriverClassName) {
    DataSourceBuilder<? extends DataSource> builder = DataSourceBuilder.create(classLoader).type(type);
    if (applyDriverClassName) {
        builder.driverClassName(connectionDetails.getDriverClassName());
    }
    return (T) builder.url(connectionDetails.getJdbcUrl())
        .username(connectionDetails.getUsername())
        .password(connectionDetails.getPassword())
        .build();
}

只有 Hikari 传了非默认的 false:

java 复制代码
String dataSourceClassName = environment.getProperty("spring.datasource.hikari.data-source-class-name");
HikariDataSource dataSource = createDataSource(connectionDetails, HikariDataSource.class,
        properties.getClassLoader(), dataSourceClassName == null);

为什么 Hikari 特殊?

因为 Hikari 有两种指定数据库的方式:

  1. jdbcUrl + driverClassName(传统)
  2. dataSourceClassName(Hikari 推荐,性能更好)

两者互斥 :Hikari 的 HikariConfig 里,如果同时设了 dataSourceClassName 和 driverClassName,会抛异常(或至少语义冲突)。

所以 Boot 的逻辑是: 如果用户配了 spring.datasource.hikari.data-source-class-name,就不设 driverClassName。

这是一个"用户选择优先"的处理 : Boot 不阻止你用 dataSourceClassName(那是 Hikari 的高级用法), 但会自动避开冲突。 ⚠️ 未核实 :dataSourceClassName 与 jdbcUrl 是否也冲突 (从代码看,url 总是设的,所以应该不冲突;Hikari 内部可能优先用 dataSourceClassName)。

4.4 Tomcat 的额外处理:validationQuery

java 复制代码
org.apache.tomcat.jdbc.pool.DataSource dataSource(DataSourceProperties properties,
        JdbcConnectionDetails connectionDetails) {
    Class<? extends DataSource> dataSourceType = org.apache.tomcat.jdbc.pool.DataSource.class;
    org.apache.tomcat.jdbc.pool.DataSource dataSource = createDataSource(connectionDetails, dataSourceType,
            properties.getClassLoader());
    String validationQuery;
    DatabaseDriver databaseDriver = DatabaseDriver.fromJdbcUrl(connectionDetails.getJdbcUrl());
    validationQuery = databaseDriver.getValidationQuery();
    if (validationQuery != null) {
        dataSource.setTestOnBorrow(true);
        dataSource.setValidationQuery(validationQuery);
    }
    return dataSource;
}

只有 Tomcat 池做了这件事 :根据数据库类型推断 validationQuery 并开启 testOnBorrow。

为什么 Hikari 不需要? 因为 Hikari 有更现代的连接检测机制 (connectionTestQuery 可选,默认用 JDBC4 isValid())。 Tomcat 池较老,需要显式配置。

又一次"多实现的差异点 = 底层 API 能力差"(第十八定律)。

4.5 poolName 的设置:Hikari 与 Oracle UCP 有,Tomcat/Dbcp2 没有

java 复制代码
// Hikari
if (StringUtils.hasText(properties.getName())) {
    dataSource.setPoolName(properties.getName());
}

// OracleUcp
if (StringUtils.hasText(properties.getName())) {
    dataSource.setConnectionPoolName(properties.getName());
}

spring.datasource.name 只在这两个池上生效。 ⚠️ 未核实:Tomcat/Dbcp2 是否有对应的 API(可能有但 Boot 没设)。

4.6 一个不对称:BPP 的 @ConditionalOnMissingBean

配置类 BPP bean 的条件
Tomcat @ConditionalOnMissingBean(PropertiesJdbcConnectionDetails.class)
Hikari 无
Dbcp2 无
OracleUcp 无

只有 Tomcat 的 BPP 有 @ConditionalOnMissingBean(PropertiesJdbcConnectionDetails.class)。

语义 :如果 JdbcConnectionDetails 是"来自属性"的 (PropertiesJdbcConnectionDetails),就不需要 BPP------ 因为属性值已经在创建时应用过了。

**但只有 Tomcat 写了这个优化。**其它三个的 BPP 无条件注册, 靠 JdbcConnectionDetailsBeanPostProcessor 内部 的 if (!(connectionDetails instanceof PropertiesJdbcConnectionDetails)) 判断跳过。

所以 Tomcat 的那个条件注解是冗余的 (内部已经判断了)。 ⚠️ 未核实 :是否有什么场景让这个条件有意义。 推断是遗留代码(早期版本的写法,后来把判断移到了基类里)。


5. JdbcConnectionDetails:本章的核心抽象

这是一个 @since 4.0.0 的接口,是 Boot 4.x 数据访问改造的关键。

5.1 接口定义

java 复制代码
public interface JdbcConnectionDetails extends ConnectionDetails {

    @Nullable String getUsername();

    @Nullable String getPassword();

    String getJdbcUrl();

    default String getDriverClassName() {
        String driverClassName = DatabaseDriver.fromJdbcUrl(getJdbcUrl()).getDriverClassName();
        Assert.state(driverClassName != null, "'driverClassName' must not be null");
        return driverClassName;
    }

    default @Nullable String getXaDataSourceClassName() {
        return DatabaseDriver.fromJdbcUrl(getJdbcUrl()).getXaDataSourceClassName();
    }
}

四个方法,两个有默认实现 (靠 DatabaseDriver.fromJdbcUrl 推断)。

它 extends ConnectionDetails ------ 这是 Boot 4.x 的通用"连接信息"抽象 (R2DBC、MongoDB、Redis、Elasticsearch 等各有自己的子接口)。 第 30 章讲 Docker Compose / Testcontainers 时会再遇到它。

5.2 为什么需要它:三种"连接信息"来源

来源 实现 优先级
spring.datasource.* 属性 PropertiesJdbcConnectionDetails 低
Docker Compose *DockerComposeConnectionDetailsFactory 产生的实现 高
Testcontainers *ContainerConnectionDetailsFactory 产生的实现 高

关键需求 :当 Docker Compose / Testcontainers 提供了连接信息时, 它必须覆盖 application.yaml 里写的值。

spring-boot-jdbc 里有 7 个 Docker Compose 工厂 (ClickHouse、MariaDB、MySQL、Oracle Free、Oracle XE、Postgres、SQL Server) 和 1 个 Testcontainers 工厂。

5.3 覆盖机制:JdbcConnectionDetailsBeanPostProcessor

java 复制代码
abstract class JdbcConnectionDetailsBeanPostProcessor<T> implements BeanPostProcessor, PriorityOrdered {

    private final Class<T> dataSourceClass;
    private final ObjectProvider<JdbcConnectionDetails> connectionDetailsProvider;

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        if (this.dataSourceClass.isInstance(bean) && "dataSource".equals(beanName)) {
            JdbcConnectionDetails connectionDetails = this.connectionDetailsProvider.getObject();
            if (!(connectionDetails instanceof PropertiesJdbcConnectionDetails)) {
                return processDataSource((T) bean, connectionDetails);
            }
        }
        return bean;
    }

    protected abstract Object processDataSource(T dataSource, JdbcConnectionDetails connectionDetails);

    @Override
    public int getOrder() {
        // Runs after ConfigurationPropertiesBindingPostProcessor
        return Ordered.HIGHEST_PRECEDENCE + 2;
    }
}

五个设计点:

① postProcessBeforeInitialization(不是 After) 在 bean 初始化之前 改属性------因为连接池可能在 afterPropertiesSet 里建连接。

② 三个判定条件

java 复制代码
this.dataSourceClass.isInstance(bean)          // 类型匹配
&& "dataSource".equals(beanName)                // ★ 名字必须是 "dataSource"
&& !(connectionDetails instanceof PropertiesJdbcConnectionDetails)   // 不是属性来源

按 bean 名判定 ------第 19 章讲过(第十九定律): Boot 约定数据源 bean 名为 dataSource,BPP 也按这个名字找。 用户自定义的数据源(名字不是 dataSource)不会被 BPP 改。

③ PriorityOrdered + getOrder() = HIGHEST_PRECEDENCE + 2

注释写明 "Runs after ConfigurationPropertiesBindingPostProcessor"。

这是本章最精妙的一处时序设计:

sql 复制代码
① @Bean 方法创建 HikariDataSource(此时 url 来自 PropertiesJdbcConnectionDetails)
② ConfigurationPropertiesBindingPostProcessor 绑定 spring.datasource.hikari.*
   (order = HIGHEST_PRECEDENCE + 1,推测)
③ JdbcConnectionDetailsBeanPostProcessor 覆盖 url/user/password(HIGHEST_PRECEDENCE + 2)

BPP 排在属性绑定之后 → 外部连接信息最终胜出。

⚠️ 未核实 :ConfigurationPropertiesBindingPostProcessor 的确切 order。 从 Boot 的源码惯例推断是 HIGHEST_PRECEDENCE + 1。 若真是如此,+2 就精确地"刚好在其后"。 自测方法:打印两个 BPP 的 order。

④ 为什么要 PriorityOrdered 而不是 Ordered?

PriorityOrdered 是 Spring 的一个更高优先级层级 : PostProcessorRegistrationDelegate 先注册所有 PriorityOrdered,再注册 Ordered。 所以 BPP 一定在普通 Ordered BPP 之前。

⑤ 四个子类的差异

BPP 目标类型 设置方式
HikariJdbcConnectionDetailsBeanPostProcessor HikariDataSource setJdbcUrl / setUsername / setPassword / setDriverClassName(判空)
TomcatJdbcConnectionDetailsBeanPostProcessor DataSource(org.apache.tomcat.jdbc.pool.DataSource) setUrl / setUsername / setPassword / setDriverClassName
Dbcp2JdbcConnectionDetailsBeanPostProcessor BasicDataSource setUrl / ...
OracleUcpJdbcConnectionDetailsBeanPostProcessor PoolDataSourceImpl setURL / setUser / setPassword / setConnectionFactoryClassName(driver)

Oracle UCP 的两个特殊点:

  • setURL(全大写)+ setUser(不是 setUsername)
  • 方法抛 SQLException → 被 catch 成 RuntimeException
java 复制代码
try {
    dataSource.setURL(connectionDetails.getJdbcUrl());
    ...
    return dataSource;
}
catch (SQLException ex) {
    throw new RuntimeException("Failed to set URL / user / password of datasource", ex);
}

只有 Hikari 对 driverClassName 判空:

java 复制代码
String driverClassName = connectionDetails.getDriverClassName();
if (driverClassName != null) {
    dataSource.setDriverClassName(driverClassName);
}

因为 JdbcConnectionDetails.getDriverClassName() 的返回类型是 String(非 @Nullable),但 PropertiesJdbcConnectionDetails 之外的实现 可能返回 null?不,接口声明是 String......

实际上这个判空是防御性的 :接口签名是非 null,但 Assert.state 在默认实现里。 自定义实现可能返回 null。Hikari 的 BPP 判空,其它三个不判(直接传给 setter, null 可能被 setter 接受或拒绝)。不一致,但无害。

5.4 PropertiesJdbcConnectionDetails:适配器

java 复制代码
final class PropertiesJdbcConnectionDetails implements JdbcConnectionDetails {

    private final DataSourceProperties properties;

    @Override public @Nullable String getUsername() { return this.properties.determineUsername(); }
    @Override public @Nullable String getPassword() { return this.properties.determinePassword(); }
    @Override public String getJdbcUrl() { return this.properties.determineUrl(); }
    @Override public String getDriverClassName() { return this.properties.determineDriverClassName(); }

    @Override
    public @Nullable String getXaDataSourceClassName() {
        return (this.properties.getXa().getDataSourceClassName() != null)
                ? this.properties.getXa().getDataSourceClassName()
                : JdbcConnectionDetails.super.getXaDataSourceClassName();
    }
}

它把 DataSourceProperties 的 determineXxx() 方法适配成接口。

determineXxx vs getXxx 的区分 (DataSourceProperties 里的惯例):

  • getUrl() ------ 用户配了什么就返回什么(可能为 null)
  • determineUrl() ------ 加上推断逻辑(嵌入式数据库、唯一名生成等),不会返回 null(失败则抛异常)

PropertiesJdbcConnectionDetails 用的是 determineXxx------正确。

getXaDataSourceClassName 的 JdbcConnectionDetails.super.xxx() : 调用接口默认方法 的语法。当 properties.getXa().getDataSourceClassName() 为 null 时, 回退到接口默认实现(从 URL 推断)。

这是 Java 8 的一个不太常见的语法:InterfaceName.super.method()。 在实现类里调用父接口的默认方法。

5.5 注册点:两处

java 复制代码
// DataSourceAutoConfiguration.PooledDataSourceConfiguration
@Bean
@ConditionalOnMissingBean(JdbcConnectionDetails.class)
PropertiesJdbcConnectionDetails jdbcConnectionDetails(DataSourceProperties properties) {
    return new PropertiesJdbcConnectionDetails(properties);
}
java 复制代码
// XADataSourceAutoConfiguration(同样的代码)
@Bean
@ConditionalOnMissingBean(JdbcConnectionDetails.class)
PropertiesJdbcConnectionDetails jdbcConnectionDetails(DataSourceProperties properties) {
    return new PropertiesJdbcConnectionDetails(properties);
}

@ConditionalOnMissingBean(JdbcConnectionDetails.class) ------ 如果 Docker Compose / Testcontainers 已经提供了实现,Boot 的就不注册。

这就是"外部连接信息优先"的完整机制:

dart 复制代码
① Boot 尝试注册 PropertiesJdbcConnectionDetails(属性来源)
   但只有【没有其它 JdbcConnectionDetails bean】时才注册
② Docker Compose 的 factory 注册了实现 → Boot 的不注册
③ 数据源 bean 创建时用 JdbcConnectionDetails(此时是 Docker Compose 的)
④ BPP 发现不是 PropertiesJdbcConnectionDetails → 再覆盖一次(双保险)

为什么 ③ 和 ④ 都要做? ③ 是主路径(创建时就用对的值)。 ④ 是保险(防止 @ConfigurationProperties 绑定在中间把值改回去)。 两道防线。

5.6 Docker Compose 与 Testcontainers:两种"外部来源"的实现

§5.2 说过 JdbcConnectionDetails 有三种来源。这里看两种外部来源的具体实现------ 它们是 Boot 4.x 的**服务连接(Service Connection)**能力,第 29 章讲测试时会再展开。

5.6.1 七个 Docker Compose 工厂的共性

spring-boot-jdbc 有 7 个 *JdbcDockerComposeConnectionDetailsFactory:

服务 工厂类 协议 容器端口
ClickHouse ClickHouseJdbcDockerComposeConnectionDetailsFactory --- ---
MariaDB MariaDbJdbcDockerComposeConnectionDetailsFactory --- ---
MySQL MySqlJdbcDockerComposeConnectionDetailsFactory mysql 3306
Oracle Free OracleFreeJdbcDockerComposeConnectionDetailsFactory --- ---
Oracle XE OracleXeJdbcDockerComposeConnectionDetailsFactory --- ---
Postgres PostgresJdbcDockerComposeConnectionDetailsFactory postgresql 5432
SQL Server SqlServerJdbcDockerComposeConnectionDetailsFactory --- ---

(⚠️ 未核实:我只读了 MySQL 与 Postgres 两个,其余五个的协议/端口是推断。)

以 MySQL 为例:

java 复制代码
class MySqlJdbcDockerComposeConnectionDetailsFactory
        extends DockerComposeConnectionDetailsFactory<JdbcConnectionDetails> {

    protected MySqlJdbcDockerComposeConnectionDetailsFactory() {
        super("mysql");                        // ★ 匹配 docker-compose.yml 里的服务名
    }

    @Override
    protected JdbcConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) {
        return new MySqlJdbcDockerComposeConnectionDetails(source.getRunningService());
    }

    static class MySqlJdbcDockerComposeConnectionDetails extends DockerComposeConnectionDetails
            implements JdbcConnectionDetails {

        private static final JdbcUrlBuilder jdbcUrlBuilder = new JdbcUrlBuilder("mysql", 3306);

        private final MySqlEnvironment environment;
        private final String jdbcUrl;

        MySqlJdbcDockerComposeConnectionDetails(RunningService service) {
            super(service);
            this.environment = new MySqlEnvironment(service.env());
            this.jdbcUrl = jdbcUrlBuilder.build(service, this.environment.getDatabase());
        }

        @Override public String getUsername() { return this.environment.getUsername(); }
        @Override public String getPassword() { return this.environment.getPassword(); }
        @Override public String getJdbcUrl() { return this.jdbcUrl; }
    }
}

四个结构要点:

① super("mysql") ------ 用服务名 匹配 docker-compose.yml 里的服务。 Boot 扫描正在运行的 compose 服务,按名字找对应的工厂。

② jdbcUrlBuilder 是 static final ------ 每个服务类的协议/端口是固定的, 做成静态常量(对比第 21 章 TestGrpcTransportAutoConfiguration 的 static address, 那里是"共享",这里是"常量")。

③ URL 在构造器里构建一次 ------ this.jdbcUrl = jdbcUrlBuilder.build(...)。 不是每次 getJdbcUrl() 都重建。因为 URL 依赖的是启动时的容器状态,不会变。

对比 PropertiesJdbcConnectionDetails :它每次调 getJdbcUrl() 都走 properties.determineUrl()(含 UUID 缓存等逻辑)。 一个"算一次存起来",一个"每次都算" ------差异来自"值会不会变": compose 服务的端口在启动时确定,之后不变; 而 DataSourceProperties 的 url 可能受 profile/占位符影响。

④ 环境变量由 *Environment 类解析

MySqlEnvironment(service.env()) ------ 从容器的环境变量里读 MYSQL_USER、MYSQL_PASSWORD、MYSQL_DATABASE 等。 每个数据库的环境变量名不同 ,所以每个服务有自己的 Environment 类 (MySqlEnvironment、PostgresEnvironment、OracleEnvironment......)。

5.6.2 JdbcUrlBuilder:端口映射的关键
java 复制代码
private String urlFor(RunningService service, @Nullable String database) {
    Assert.notNull(service, "'service' must not be null");
    StringBuilder url = new StringBuilder("jdbc:%s://%s:%d".formatted(this.driverProtocol, service.host(),
            service.ports().get(this.containerPort)));
    if (StringUtils.hasLength(database)) {
        url.append("/");
        url.append(database);
    }
    String parameters = getParameters(service);
    if (StringUtils.hasLength(parameters)) {
        appendParameters(url, parameters);
    }
    return url.toString();
}

★ service.ports().get(this.containerPort) ------ 这是核心。

Docker Compose 里的容器端口(如 MySQL 的 3306)会被映射到一个随机的宿主机端口 (如 32768)。JdbcUrlBuilder 用"容器端口 → 实际映射端口"的查询, 拼出宿主机能连的 URL。

这就是为什么你不能手写 jdbc:mysql://localhost:3306/... ------容器里的 3306 不一定映射到宿主机的 3306。

getParameters(service) 的扩展点:

java 复制代码
private static final String PARAMETERS_LABEL = "org.springframework.boot.jdbc.parameters";

private @Nullable String getParameters(RunningService service) {
    return service.labels().get(PARAMETERS_LABEL);
}

从 Docker label 读额外的 JDBC 参数 。在 docker-compose.yml 里:

yaml 复制代码
services:
  postgres:
    image: postgres:16
    labels:
      org.springframework.boot.jdbc.parameters: "?sslmode=disable&connectTimeout=10"

这是 Boot 给"JDBC 参数"留的后门------因为 URL 是 Boot 生成的, 用户没法往里加参数,于是用 label。

appendParameters 是 protected ------ 可以被子类覆写:

java 复制代码
/**
 * The default implementation appends a {@code ?} followed by the {@code parameters}.
 */
protected void appendParameters(StringBuilder url, String parameters) {
    url.append("?").append(parameters);
}

为什么要留这个扩展点? 因为不是所有数据库都用 ? 做参数分隔符 。 (比如某些数据库用 ; 或者要求参数在特定位置。) ⚠️ 未核实:哪些子类覆写了它(我只读了基类)。

5.6.3 Postgres 的 ApplicationName 特殊处理
java 复制代码
private static String addApplicationNameIfNecessary(String jdbcUrl, Environment environment) {
    if (jdbcUrl.contains("&ApplicationName=") || jdbcUrl.contains("?ApplicationName=")) {
        return jdbcUrl;                          // 已经有了就不加
    }
    String applicationName = environment.getProperty("spring.application.name");
    // ... 拼接
}

Postgres 独有 :自动把 spring.application.name 作为 ApplicationName 参数加到 URL 里。

作用 :DBA 在 pg_stat_activity 里能看到是哪个应用占着连接。 这对排查连接泄漏极有价值。

注意它先检查"是否已有" ------尊重用户通过 label 设置的 ApplicationName。 又是"不覆盖用户显式值"(第 17 章讲过的模式)。

5.6.4 Testcontainers:更简单的实现
java 复制代码
class JdbcContainerConnectionDetailsFactory
        extends ContainerConnectionDetailsFactory<JdbcDatabaseContainer<?>, JdbcConnectionDetails> {

    @Override
    protected JdbcConnectionDetails getContainerConnectionDetails(
            ContainerConnectionSource<JdbcDatabaseContainer<?>> source) {
        return new JdbcContainerConnectionDetails(source);
    }

    private static final class JdbcContainerConnectionDetails
            extends ContainerConnectionDetails<JdbcDatabaseContainer<?>> implements JdbcConnectionDetails {

        @Override public String getUsername() { return getContainer().getUsername(); }
        @Override public String getPassword() { return getContainer().getPassword(); }
        @Override public String getJdbcUrl() { return getContainer().getJdbcUrl(); }

        @Override
        public String getDriverClassName() {
            return getContainer().getDriverClassName();
        }
    }
}

比 Docker Compose 版简单得多 ------ 因为 Testcontainers 的 JdbcDatabaseContainer 自己就能提供 getJdbcUrl() / getUsername() / ...

所以 Boot 这侧只是转发 ,不需要 JdbcUrlBuilder(端口映射由 Testcontainers 做了)。

它是唯一覆写 getDriverClassName() 的实现------ 因为容器知道自己的驱动类,不需要从 URL 推断(接口默认方法做的事)。

对比 Docker Compose 版 :都没有覆写 getDriverClassName(), 所以走接口默认实现(DatabaseDriver.fromJdbcUrl(url).getDriverClassName())。

这是一个"能力差异决定代码量"的例子 : Testcontainers 的容器 API 已经提供了所有信息 → Boot 写 4 行转发; Docker Compose 只给原始 service 信息 → Boot 要写 JdbcUrlBuilder

  • *Environment + URL 拼接。

6. DataSourceProperties:determineXxx 体系与"三个默认"

DataSourceProperties 是 @since 4.0.0(4.x 包迁移后的新类),409 行。

6.1 getXxx vs determineXxx:一个贯穿全类的区分

方法对 getXxx determineXxx
URL 返回配置值(可能 null) 配置值 → 否则用嵌入式 URL → 否则抛异常
DriverClassName 返回配置值(可能 null) 配置值 → 从 URL 推断 → 嵌入式驱动 → 否则抛异常
Username / Password 返回配置值 配置值 → 嵌入式的默认值
DatabaseName --- generateUniqueName → UUID;name → 用它;嵌入式 → "testdb"

规则:

  • getXxx = "用户说了什么"(忠实反映配置,null 就是 null)
  • determineXxx = "最终应该用什么"(含推断与兜底,失败则抛明确异常)

这个区分很重要 ,因为失败分析(§9)需要知道"用户到底配了没有"。 看 DataSourceBeanCreationFailureAnalyzer:

java 复制代码
if (!StringUtils.hasText(cause.getProperties().getUrl())) {   // ★ getUrl,不是 determineUrl
    description.append("'url' attribute is not specified and ");
}

用 getUrl() ------ 因为要判断"用户没配"。 如果用 determineUrl(),永远不为 null(或已抛异常),就没法诊断了。

6.2 determineDatabaseName:三分支

java 复制代码
public @Nullable String determineDatabaseName() {
    if (this.generateUniqueName) {
        if (this.uniqueName == null) {
            this.uniqueName = UUID.randomUUID().toString();
        }
        return this.uniqueName;
    }
    if (StringUtils.hasLength(this.name)) {
        return this.name;
    }
    if (this.embeddedDatabaseConnection != EmbeddedDatabaseConnection.NONE) {
        return "testdb";
    }
    return null;
}

generateUniqueName 默认 true ------ 这是 Boot 的一个经典行为:

每个嵌入式数据库默认有一个随机唯一的名字 (UUID)。 所以同一 JVM 里跑多个测试,各自拿到独立的内存数据库,互不干扰。

这是"测试隔离"的基石 : @SpringBootTest 不配 spring.datasource.url 时, 每次都得到一个全新的 H2 内存库。

代价 :数据库名是 UUID,不好认。想要固定名字就设 spring.datasource.generate-unique-name=false + spring.datasource.name=mydb (或默认 testdb)。

uniqueName 被缓存 (if (this.uniqueName == null))------ 多次调 determineDatabaseName() 返回同一个值。

6.3 findDriverClassName 的三级回退

java 复制代码
@Nullable String findDriverClassName() {
    if (StringUtils.hasText(this.driverClassName)) {
        Assert.state(driverClassIsLoadable(this.driverClassName),
                () -> "Cannot load driver class: " + this.driverClassName);
        return this.driverClassName;
    }
    String driverClassName = null;
    if (StringUtils.hasText(this.url)) {
        driverClassName = DatabaseDriver.fromJdbcUrl(this.url).getDriverClassName();
    }
    if (!StringUtils.hasText(driverClassName)) {
        driverClassName = this.embeddedDatabaseConnection.getDriverClassName();
    }
    return driverClassName;
}
scss 复制代码
① spring.datasource.driver-class-name         (显式,且必须能加载)
② DatabaseDriver.fromJdbcUrl(url).getDriverClassName()   (从 URL 推断)
③ embeddedDatabaseConnection.getDriverClassName()        (嵌入式默认)

① 的 Assert.state(driverClassIsLoadable(...)) : 显式配了驱动类却加载不了 → 立即失败,不会静默回退到推断。

driverClassIsLoadable 的一个细节:

java 复制代码
private boolean driverClassIsLoadable(String driverClassName) {
    try {
        ClassUtils.forName(driverClassName, null);
        return true;
    }
    catch (UnsupportedClassVersionError ex) {
        // Driver library has been compiled with a later JDK, propagate error
        throw ex;
    }
    catch (Throwable ex) {
        return false;
    }
}

UnsupportedClassVersionError 被单独抛出 ,其它 Throwable 返回 false。

为什么? UnsupportedClassVersionError 意味着 驱动是用更高版本的 JDK 编译的 ------这不是"类找不到", 而是环境不匹配 。如果返回 false,Boot 会走推断路径, 然后可能报一个完全无关的错("Failed to determine a suitable driver class"), 掩盖了真正的问题。

★ 这是一个很好的错误处理范式 : 区分"找不到"(可降级)与"找到了但用不了"(必须暴露) 。 第 18 章 PortInUseException 靠消息区分"端口占用"与"地址无法分配"是同一思路。

6.4 determineUrl 与嵌入式 URL

java 复制代码
public String determineUrl() {
    if (StringUtils.hasText(this.url)) {
        return this.url;
    }
    String databaseName = determineDatabaseName();
    String url = (databaseName != null) ? this.embeddedDatabaseConnection.getUrl(databaseName) : null;
    if (!StringUtils.hasText(url)) {
        throw new DataSourceBeanCreationException("Failed to determine suitable jdbc url", this,
                this.embeddedDatabaseConnection);
    }
    return url;
}

EmbeddedDatabaseConnection.getUrl(databaseName) 用 %s 占位符:

java 复制代码
public enum EmbeddedDatabaseConnection {

    NONE(null),

    H2("jdbc:h2:mem:%s;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"),

    @Deprecated(since = "4.1.0", forRemoval = true)
    DERBY("jdbc:derby:memory:%s;create=true"),

    HSQLDB("org.hsqldb.jdbcDriver", "jdbc:hsqldb:mem:%s");

★ H2 的 URL 里有两个关键参数:

vbnet 复制代码
jdbc:h2:mem:{UUID};DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
参数 含义 为什么必要
DB_CLOSE_DELAY=-1 最后一个连接关闭后不销毁数据库 否则连接池空闲一段时间后库就没了
DB_CLOSE_ON_EXIT=FALSE JVM 退出时不关闭 配合上面

这两个参数是 H2 内存库能在连接池下工作的前提。 如果你自己手写 jdbc:h2:mem:testdb 而漏了 DB_CLOSE_DELAY=-1, 会遇到"连上后库是空的"这种诡异问题------连接池建连接、用完后关闭, H2 就把库销毁了,下次连接是全新的空库。

EmbeddedDatabaseConnection 只有 3 种 (NONE、H2、DERBY、HSQLDB), 且 Derby 已 @Deprecated(forRemoval):

java 复制代码
/**
 * Derby Database Connection.
 * @deprecated since 4.1.0 for removal in 4.3.0 as Derby is EOL.
 */

Derby 已 EOL(End of Life) ,Boot 4.1 标记废弃,4.3 移除。 DatabaseDriver.DERBY 同样标记了。

这是一个可以确定的"未来变更": Boot 4.3 会删掉 Derby 支持。如果你在用 Derby,现在就该迁移。

6.5 Xa 内部类的存在意义

java 复制代码
private Xa xa = new Xa();

spring.datasource.xa.* ------ 只在 XADataSourceAutoConfiguration 里用到 (PropertiesJdbcConnectionDetails.getXaDataSourceClassName())。

⚠️ 未核实 :Xa 的字段列表(我没读)。从用法看至少有 dataSourceClassName properties 等。第 23 章讲 JPA/XA 时如果涉及会补。

6.6 afterPropertiesSet 与 BeanClassLoaderAware

java 复制代码
public class DataSourceProperties implements BeanClassLoaderAware, InitializingBean {

    @Override
    public void setBeanClassLoader(ClassLoader classLoader) {
        this.classLoader = classLoader;
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        if (this.embeddedDatabaseConnection == null) {
            this.embeddedDatabaseConnection = EmbeddedDatabaseConnection.get(this.classLoader);
        }
    }
}

EmbeddedDatabaseConnection.get(classLoader) 在 afterPropertiesSet 里做------ 即绑定完成后才探测 classpath 上有哪些嵌入式数据库。

为什么要在这里而不是构造时? 因为要用到 classLoader(由 BeanClassLoaderAware 注入,晚于构造)。

它必须早于 determineXxx() 被调用 ------ @ConfigurationProperties bean 的 afterPropertiesSet 由 Spring 在属性绑定后调用, 所以没问题。


7. DatabaseDriver:346 行的数据库知识库

@since 1.4.0 的公共枚举。它编码了每种数据库的:

信息 用途
productName 从 DatabaseMetaData.getDatabaseProductName() 反查
driverClassName 驱动类
xaDataSourceClassName XA 数据源类
validationQuery 连接有效性检测 SQL
urlPrefixes 从 JDBC URL 反查

7.1 枚举常量(部分)

java 复制代码
public enum DatabaseDriver {

    UNKNOWN(null, null),

    @Deprecated(since = "4.1.0", forRemoval = true)
    DERBY("Apache Derby", "org.apache.derby.jdbc.EmbeddedDriver", "org.apache.derby.jdbc.EmbeddedXADataSource",
            "SELECT 1 FROM SYSIBM.SYSDUMMY1"),

    H2("H2", "org.h2.Driver", "org.h2.jdbcx.JdbcDataSource", "SELECT 1"),

    HSQLDB("HSQL Database Engine", "org.hsqldb.jdbc.JDBCDriver", "org.hsqldb.jdbc.pool.JDBCXADataSource",
            "SELECT COUNT(*) FROM INFORMATION_SCHEMA.SYSTEM_USERS"),

    SQLITE("SQLite", "org.sqlite.JDBC"),

    MYSQL("MySQL", "com.mysql.cj.jdbc.Driver", "com.mysql.cj.jdbc.MysqlXADataSource", "/* ping */ SELECT 1"),

    MARIADB("MariaDB", "org.mariadb.jdbc.Driver", "org.mariadb.jdbc.MariaDbDataSource", "SELECT 1"),

    ORACLE("Oracle", "oracle.jdbc.OracleDriver", "oracle.jdbc.xa.client.OracleXADataSource",
            "SELECT 'Hello' from DUAL"),

    POSTGRESQL("PostgreSQL", "org.postgresql.Driver", "org.postgresql.xa.PGXADataSource", "SELECT 1"),
    ...

几个有意思的 validationQuery:

数据库 validationQuery 为什么这样写
H2 SELECT 1 最简单
HSQLDB SELECT COUNT(*) FROM INFORMATION_SCHEMA.SYSTEM_USERS HSQLDB 早期版本不支持无 FROM 的 SELECT
MySQL /* ping */ SELECT 1 /* ping */ 是 MySQL 的特殊优化:带这个注释的查询走"轻量 ping"路径,不进查询缓存、不记慢日志
Oracle SELECT 'Hello' from DUAL Oracle 要求 FROM,用 DUAL 伪表
Derby SELECT 1 FROM SYSIBM.SYSDUMMY1 同 Oracle,伪表名不同
SQL Server SELECT 1 ---

★ /* ping */ 是本章最有价值的一个冷知识。 MySQL Connector/J 会识别这个注释,把查询当作 ping 处理------ 这是连接池做连接检测时的标准优化 。手写 SELECT 1 也行,但会走完整查询路径。

7.2 fromJdbcUrl:前缀匹配的细节

java 复制代码
public static DatabaseDriver fromJdbcUrl(@Nullable String url) {
    if (StringUtils.hasLength(url)) {
        Assert.isTrue(url.startsWith("jdbc"), "'url' must start with \"jdbc\"");
        String urlWithoutPrefix = url.substring("jdbc".length()).toLowerCase(Locale.ENGLISH);
        for (DatabaseDriver driver : values()) {
            for (String urlPrefix : driver.getUrlPrefixes()) {
                String prefix = ":" + urlPrefix + ":";
                if (driver != UNKNOWN && urlWithoutPrefix.startsWith(prefix)) {
                    return driver;
                }
            }
        }
    }
    return UNKNOWN;
}

三个细节:

① Assert.isTrue(url.startsWith("jdbc")) URL 必须以 jdbc 开头,否则抛异常 。 但注意:这个检查只在 StringUtils.hasLength(url) 为真时执行------ 空 URL 直接返回 UNKNOWN。

潜在陷阱 :spring.datasource.url=myhost:3306/db(漏了 jdbc: 前缀) 会在启动时抛 IllegalArgumentException: 'url' must start with "jdbc"。 这个错误信息比较直白,还算好排查。

② toLowerCase(Locale.ENGLISH) 大小写不敏感,且显式指定 Locale ------避免在土耳其语 locale 下 I → ı 的转换问题(经典的 Locale bug)。

③ ":" + urlPrefix + ":" 的前后冒号

jdbc:mysql://... → 去掉 jdbc 后是 mysql://... → 但比较用的是 :mysql:。 所以 mysql:// 不匹配 :mysql:?

不对 ------让我重新推: jdbc:mysql://localhost 去掉前 3 个字符(jdbc)→ :mysql://localhost。 比较 startsWith(":mysql:") → :mysql: ✓ 匹配。

所以 getUrlPrefixes() 返回的是 mysql 这种不带冒号的形式, 比较时前后各加一个冒号。 前后冒号保证完整匹配(不会把 mysql2 误判为 mysql)。

④ driver != UNKNOWN 的跳过 UNKNOWN 的 urlPrefixes 是空(构造参数是 null, null), 所以这个判断是防御性的(正常情况下遍历不到它,或它的 prefix 列表为空)。

⑤ values() 的顺序即优先级 Enum.values() 按声明顺序返回。 DatabaseDriver 的声明顺序里 Derby 在 H2 之前------ 如果两个驱动的 URL 前缀能匹配同一个 URL(不太可能),声明靠前的胜出。

⚠️ 未核实 :是否存在 URL 前缀重叠的数据库对。 (比如 jtds 与 sqlserver------JTDS 驱动可以连 SQL Server。 从代码看 JTDS 覆写了 getUrlPrefixes(),但它的前缀是啥我没读。)

7.3 fromProductName:另一条推断路径

java 复制代码
public static DatabaseDriver fromProductName(@Nullable String productName) {
    if (StringUtils.hasLength(productName)) {
        for (DatabaseDriver candidate : values()) {
            if (candidate.getProductName() != null && candidate.getProductName().equalsIgnoreCase(productName)) {
                return candidate;
            }
        }
    }
    return UNKNOWN;
}

从 DatabaseMetaData.getDatabaseProductName() 反查------ 用在不知道 URL 但能连上数据库的场景(比如 Actuator 健康检查、 SchemaManagementProvider)。

JTDS 的 productName 是 null:

java 复制代码
/**
 * jTDS. As it can be used for several databases, there isn't a single product name we
 * could rely on.
 */
JTDS(null, "net.sourceforge.jtds.jdbc.Driver"),

Javadoc 解释了原因:jTDS 能连多种数据库, 所以 getDatabaseProductName() 返回的是真实数据库 的名字,不是 "jTDS"。 因此 jTDS 无法从 productName 反查。

这是"元数据缺失"的诚实处理 : 与其猜,不如返回 null 让 fromProductName 跳过它。

7.4 HANA 覆写 getUrlPrefixes

java 复制代码
HANA("HDB", "com.sap.db.jdbc.Driver", "com.sap.db.jdbcext.XADataSourceSAP", "SELECT 1 FROM SYS.DUMMY") {
    @Override
    protected Collection<String> getUrlPrefixes() {
        return Collections.singleton("sap");
    }
},

枚举常量可以带方法体 (匿名子类)。 HANA 的 URL 是 jdbc:sap://...,所以前缀是 sap。

为什么它需要覆写而其它不需要? 因为默认实现大概是"从 productName 或某种约定推导"。 ⚠️ 未核实 :getUrlPrefixes() 的默认实现(没读)。 从 HANA 和 SQLSERVER 都覆写了来看,默认实现可能不够用。


8. 三个"特殊"数据源配置

8.1 LazyConnectionDataSourceConfiguration:用 BPP 替换 bean

java 复制代码
@Configuration
@ConditionalOnBean(DataSource.class)
@ConditionalOnProperty(name = "spring.datasource.connection-fetch", havingValue = "lazy")
class LazyConnectionDataSourceConfiguration {

    @Bean
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    static LazyConnectionDataSourceBeanPostProcessor lazyConnectionDataSourceBeanPostProcessor(
            ObjectProvider<MBeanExporter> mbeanExporter) {
        return new LazyConnectionDataSourceBeanPostProcessor(mbeanExporter);
    }

    static class LazyConnectionDataSourceBeanPostProcessor implements BeanPostProcessor, Ordered {

        @Override
        public @Nullable Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
            if (beanName.equals("dataSource") && bean instanceof DataSource dataSource) {
                this.mbeanExporter.ifAvailable((exporter) -> {
                    if (JmxUtils.isMBean(dataSource.getClass())) {
                        exporter.registerManagedResource(dataSource, beanName);
                    }
                });
                return new LazyConnectionDataSourceProxy(dataSource);
            }
            return bean;
        }

        @Override
        public int getOrder() {
            return 0;
        }
    }
}

spring.datasource.connection-fetch=lazy ------ 一个不常被提到的配置项。

作用 :把 dataSource bean 换成 LazyConnectionDataSourceProxy, 延迟到真正执行 SQL 时才从池里取连接。

解决什么问题? 考虑这个场景:

java 复制代码
@Transactional(readOnly = true)
public List<User> findAll() {
    // 如果没查缓存就直接返回了,这次调用根本不需要数据库连接
}

默认情况下,@Transactional 的拦截器会立即从连接池取连接(开启事务)。 如果方法体里很多分支不需要查库,就白白占用了连接。

LazyConnectionDataSourceProxy 让"取连接"推迟到第一次真正用 Connection。

三个实现细节:

① postProcessAfterInitialization(与 §5.3 的 Before 相反) 因为要替换整个 bean 对象(return new LazyConnectionDataSourceProxy(...)), 必须在 bean 完全初始化之后 。替换 = After,改属性 = Before。

② 用 postProcessAfterInitialization 替换 bean 是有风险的 如果这个 bean 已经被其它 bean 注入了,替换就不会生效 (其它 bean 拿到的是原对象)。 BPP 在 bean 初始化后立刻执行,早于 它被注入给别人,所以安全。 但如果有人用 @DependsOn 或者提前 getBean,就会拿到未包装的版本。

③ 顺手做了 JMX 注册

java 复制代码
this.mbeanExporter.ifAvailable((exporter) -> {
    if (JmxUtils.isMBean(dataSource.getClass())) {
        exporter.registerManagedResource(dataSource, beanName);
    }
});

为什么? 因为替换后,容器里的 dataSource bean 是 LazyConnectionDataSourceProxy, 它本身不是 MBean ,JMX 导出的会是代理对象(没有有用的指标)。 所以在替换之前 ,先把真实的数据源注册到 JMX。

这是一个"副作用补偿" :替换 bean 会破坏原有的 JMX 机制, 所以手动补上。注释里写得很清楚(类 Javadoc: "Also make sure to register the target DataSource in the JMX domain if necessary")。

对比 JndiDataSourceAutoConfiguration 里的反向操作:

java 复制代码
private void excludeMBeanIfNecessary(Object candidate, String beanName, ApplicationContext context) {
    for (MBeanExporter mbeanExporter : context.getBeansOfType(MBeanExporter.class).values()) {
        if (JmxUtils.isMBean(candidate.getClass())) {
            mbeanExporter.addExcludedBean(beanName);
        }
    }
}

JNDI 数据源要排除 JMX(因为容器可能已经注册过了,重复注册会冲突)。

一个注册、一个排除------都是为了让 JMX 与数据源的实际形态对齐。

8.2 DataSourceJmxConfiguration

被 PooledDataSourceConfiguration 导入。

⚠️ 未核实 :它的内容(没读)。从名字看是把支持 JMX 的连接池 (Tomcat、Hikari 等)注册到 MBeanExporter。 第 25 章讲 Actuator 时如果涉及 JMX 会补。

8.3 DataSourceCheckpointRestoreConfiguration 与 HikariCheckpointRestoreLifecycle

被 DataSourceAutoConfiguration 类级 @Import(无条件导入)。

spring-boot-jdbc 里有一个 HikariCheckpointRestoreLifecycle 类------ CRaC(Coordinated Restore at Checkpoint) 支持。 第 12 章讲过 CRaC:JVM 做 checkpoint 后恢复时, 数据库连接必须重建(socket 已经失效了)。

⚠️ 未核实 :这两个类的具体实现。 推断:HikariCheckpointRestoreLifecycle 在 onCheckpoint 时关闭池、 onRestore 时重建。自测方法:读这两个类(它们在本仓库,第 12 章未展开)。


9. 事务:DataSourceTransactionManagerAutoConfiguration

java 复制代码
@AutoConfiguration(before = TransactionAutoConfiguration.class,
        after = { DataSourceAutoConfiguration.class, TransactionManagerCustomizationAutoConfiguration.class })
@ConditionalOnClass({ DataSource.class, JdbcTemplate.class, TransactionManager.class })
@AutoConfigureOrder(Ordered.LOWEST_PRECEDENCE)
public final class DataSourceTransactionManagerAutoConfiguration {

    @Configuration(proxyBeanMethods = false)
    @ConditionalOnSingleCandidate(DataSource.class)
    static class JdbcTransactionManagerConfiguration {

        @Bean
        @ConditionalOnMissingBean(TransactionManager.class)
        DataSourceTransactionManager transactionManager(Environment environment, DataSource dataSource,
                ObjectProvider<TransactionManagerCustomizers> transactionManagerCustomizers) {
            DataSourceTransactionManager transactionManager = createTransactionManager(environment, dataSource);
            transactionManagerCustomizers.ifAvailable((customizers) -> customizers.customize(transactionManager));
            return transactionManager;
        }

        private DataSourceTransactionManager createTransactionManager(Environment environment, DataSource dataSource) {
            return environment.getProperty("spring.dao.exceptiontranslation.enabled", Boolean.class, Boolean.TRUE)
                    ? new JdbcTransactionManager(dataSource) : new DataSourceTransactionManager(dataSource);
        }
    }
}

9.1 JdbcTransactionManager vs DataSourceTransactionManager

java 复制代码
environment.getProperty("spring.dao.exceptiontranslation.enabled", Boolean.class, Boolean.TRUE)
    ? new JdbcTransactionManager(dataSource) : new DataSourceTransactionManager(dataSource);

默认用 JdbcTransactionManager (spring.dao.exceptiontranslation.enabled 默认 true)。

两者的区别:

  • DataSourceTransactionManager:Spring 的经典实现
  • JdbcTransactionManager(@since 5.3):子类,额外提供异常转译

JdbcTransactionManager 实现 SQLExceptionTranslator 接口, 能把 SQLException 转成 Spring 的 DataAccessException 体系 (DuplicateKeyException、DataIntegrityViolationException 等)。

为什么这很重要? 因为在 @Transactional 方法里, 异常转译默认发生在 DAO 层 (JdbcTemplate 内部)。 但如果你直接使用 Connection(比如 JOOQ、MyBatis 的某些用法), 异常转译需要事务管理器来做。

实践含义 : 用 JdbcTemplate 时,spring.dao.exceptiontranslation.enabled 影响不大 (JdbcTemplate 自己会转译)。 但用 JOOQ / MyBatis / 裸 JDBC 时,把它设成 false 会让你拿到原始 SQLException 而非 Spring 的 DataAccessException。

⚠️ 未核实 :JdbcTransactionManager 的异常转译具体在哪个环节生效 (它在 Framework 里)。我只能确认 Boot 的选择逻辑。

9.2 @AutoConfigureOrder(Ordered.LOWEST_PRECEDENCE)

自动配置类级别的 @AutoConfigureOrder ,不是 @Order。

第 14 章讲过:自动配置类的排序用 @AutoConfigureOrder / @AutoConfigureBefore/After, 普通 bean 的排序用 @Order。

LOWEST_PRECEDENCE = 排在最后。

为什么事务管理器要排最后? 因为它要求 @ConditionalOnSingleCandidate(DataSource.class)------ 必须等所有可能创建 DataSource 的自动配置都跑完, 才能确定"是否只有一个候选"。

9.3 @ConditionalOnSingleCandidate(DataSource.class)

第 21 章 gRPC client 用过同一个条件。这里是它的经典用途:

DataSource bean 数量 结果
0 不匹配(没有数据源,不需要事务管理器)
1 匹配 ✓
2+,其中一个是 @Primary 匹配(用 @Primary 那个)
2+,无 @Primary 不匹配(Boot 不替你猜)

"多个数据源时不自动配置事务管理器" ------ 这是正确的: 多数据源场景下,用户必须自己指定每个事务管理器绑哪个数据源。

9.4 TransactionManagerCustomizers

java 复制代码
transactionManagerCustomizers.ifAvailable((customizers) -> customizers.customize(transactionManager));

一个定制器链(第 19/20 章反复见到的模式)。 来自 TransactionManagerCustomizationAutoConfiguration (本自动配置 after 它)。

⚠️ 未核实 :TransactionManagerCustomizers 的定义与内容 (在 spring-boot-tx 或类似模块,本章未读)。


10. 数据库初始化:DataSourceInitializationAutoConfiguration

java 复制代码
@AutoConfiguration
@ConditionalOnMissingBean(ApplicationScriptDatabaseInitializer.class)
@ConditionalOnSingleCandidate(DataSource.class)
@ConditionalOnClass(DatabasePopulator.class)
@Import(DatabaseInitializationDependencyConfigurer.class)
@EnableConfigurationProperties(SqlInitializationProperties.class)
@ConditionalOnSqlInitialization
public final class DataSourceInitializationAutoConfiguration {

    @Bean
    ApplicationDataSourceScriptDatabaseInitializer dataSourceScriptDatabaseInitializer(DataSource dataSource,
            SqlInitializationProperties properties) {
        return new ApplicationDataSourceScriptDatabaseInitializer(
                determineDataSource(dataSource, properties.getUsername(), properties.getPassword()), properties);
    }

    private static DataSource determineDataSource(DataSource dataSource, @Nullable String username,
            @Nullable String password) {
        if (StringUtils.hasText(username) && StringUtils.hasText(password)) {
            return DataSourceBuilder.derivedFrom(dataSource)
                .username(username)
                .password(password)
                .type(SimpleDriverDataSource.class)
                .build();
        }
        return dataSource;
    }
}

10.1 ★ derivedFrom + SimpleDriverDataSource:一个精巧的"降级"

java 复制代码
DataSourceBuilder.derivedFrom(dataSource)      // ① 复制原数据源的配置
    .username(username).password(password)     // ② 换成 sql.init 的凭据
    .type(SimpleDriverDataSource.class)        // ③ 换成【非连接池】实现
    .build();

为什么要用 SimpleDriverDataSource(非连接池)?

因为初始化脚本只需要一个连接,用完就关。 如果用连接池:

  1. 初始化时机很早(可能连接池还没完全就绪)
  2. 白占一个连接
  3. 初始化完连接归还,池里多一个"已用过"的连接

用一个一次性数据源更干净。

为什么用 derivedFrom 而不是新建? 因为要继承原数据源的 url / driverClassName 。 derivedFrom 用 DataSourceUnwrapper.unwrapRoot 拿到最内层数据源, 再读它的属性(§3.4)。

10.2 凭据分离:sql.init.username / sql.init.password

yaml 复制代码
spring:
  datasource:
    username: app_user          # 应用运行时用(受限权限)
    password: ...
  sql:
    init:
      username: admin           # 执行 DDL 用(高权限)
      password: ...
      mode: always

这是一个安全最佳实践: 应用运行用的账号只有 DML 权限, schema 变更用另一个高权限账号(只在启动时用一次)。

Boot 支持这个模式 ,且只有同时配了 username 和 password 才启用:

java 复制代码
if (StringUtils.hasText(username) && StringUtils.hasText(password)) { ... }

只配 username 不生效 ------ 因为空密码在某些数据库上是合法的, Boot 不能假设"没配密码"等于"密码为空"。

10.3 @ConditionalOnSqlInitialization

一个自定义条件注解(@ConditionalOnXxx 家族)。 ⚠️ 未核实 :它的实现(应该在 spring-boot-sql 模块)。 从名字推断:检查 spring.sql.init.* 相关的脚本是否存在/启用。

10.4 DatabaseInitializationDependencyConfigurer

@Import(DatabaseInitializationDependencyConfigurer.class) ------ 名字里有 "DependencyConfigurer"。

它的作用 (推断):让其它需要"数据库已初始化"的 bean (JPA 的 EntityManagerFactory、JOOQ 的 DSLContext、Spring Batch 等) 依赖于 DatabaseInitializer。

spring-boot-jdbc 里有 SpringJdbcDependsOnDatabaseInitializationDetector、 spring-boot-jooq 里有 JooqDependsOnDatabaseInitializationDetector、 spring-boot-jpa 里有 JpaDependsOnDatabaseInitializationDetector------ 三个模块各有一个 "Detector",它们构成一个 SPI。

⚠️ 未核实 :DatabaseInitializationDependencyConfigurer 与这些 Detector 的协作机制 (在 spring-boot-sql 模块)。 这是 Boot 3.x 引入的"数据库初始化依赖排序"机制,第 23 章讲 JPA 时会再遇到。 自测方法 :读 spring-boot-sql 模块的 DatabaseInitializationDependencyConfigurer。


11. JNDI 与 XA:两条"非主流"路径

11.1 JndiDataSourceAutoConfiguration

java 复制代码
@AutoConfiguration(before = { XADataSourceAutoConfiguration.class, DataSourceAutoConfiguration.class })
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
@ConditionalOnProperty("spring.datasource.jndi-name")
@EnableConfigurationProperties(DataSourceProperties.class)
public final class JndiDataSourceAutoConfiguration {

    @Bean(destroyMethod = "")
    @ConditionalOnMissingBean
    DataSource dataSource(DataSourceProperties properties, ApplicationContext context) {
        JndiDataSourceLookup dataSourceLookup = new JndiDataSourceLookup();
        String jndiName = properties.getJndiName();
        Assert.state(jndiName != null, "'jndiName' must not be null");
        DataSource dataSource = dataSourceLookup.getDataSource(jndiName);
        excludeMBeanIfNecessary(dataSource, "dataSource", context);
        return dataSource;
    }

@Bean(destroyMethod = "") ------ 空字符串!

第 13 章讲过 @Bean 的 destroyMethod:

  • 默认 AbstractBeanDefinition.INFER_METHOD → 自动推断 close/shutdown
  • 设为 "" → 禁用 推断

为什么? 因为 JNDI 数据源归容器管 ------ 应用关闭时不能 调用它的 close(), 否则会关掉容器(如 Tomcat)自己管理的数据源, 其它应用或下一次部署会受影响。

这是本章最容易被忽略但很重要的一行 : "你不拥有它,就不要关闭它。" 第 18 章 WebServer 的 destroy() 是 @since 3.2.0 且默认空实现, 也是同样的"所有权"考量。

Assert.state(jndiName != null) ------ 但外层已经有 @ConditionalOnProperty("spring.datasource.jndi-name"), 所以理论上不会是 null。这是给 NullAway/IDE 看的冗余断言 (Boot 大量使用这种写法来通过静态分析)。

11.2 XADataSourceAutoConfiguration

java 复制代码
@AutoConfiguration(before = DataSourceAutoConfiguration.class, after = JtaAutoConfiguration.class)
@EnableConfigurationProperties(DataSourceProperties.class)
@ConditionalOnClass({ DataSource.class, TransactionManager.class, EmbeddedDatabaseType.class })
@ConditionalOnBean(XADataSourceWrapper.class)
@ConditionalOnMissingBean(DataSource.class)
public final class XADataSourceAutoConfiguration implements BeanClassLoaderAware {

    @Bean
    @ConditionalOnMissingBean(JdbcConnectionDetails.class)
    PropertiesJdbcConnectionDetails jdbcConnectionDetails(DataSourceProperties properties) {
        return new PropertiesJdbcConnectionDetails(properties);
    }

    @Bean
    DataSource dataSource(XADataSourceWrapper wrapper, DataSourceProperties properties,
            JdbcConnectionDetails connectionDetails, ObjectProvider<XADataSource> xaDataSource) throws Exception {
        return wrapper...  // 未读完
    }
}

触发条件是 @ConditionalOnBean(XADataSourceWrapper.class) ------ XA 数据源需要一个包装器 (把 XADataSource 包装成普通 DataSource 并接入 JTA 事务管理器)。

XADataSourceWrapper 是一个接口(在 spring-boot-jdbc):

java 复制代码
public interface XADataSourceWrapper {
    DataSource wrap(XADataSource dataSource) throws ...;
}

谁来提供实现? 推测是 Atomikos / Bitronix / Narayana 这类 JTA 实现。 ⚠️ 未核实 :Boot 4.1 里是否还有这些实现(本仓库没看到)。 从 after = JtaAutoConfiguration.class 看,JTA 是配套的。

注意它也注册 PropertiesJdbcConnectionDetails ------ 与 DataSourceAutoConfiguration.PooledDataSourceConfiguration 里完全一样的代码。 两处重复,因为两个路径都可能走到。

这是"复制而非抽取"的一个例子 : 把 3 行代码抽成一个公共方法或基类,收益不大, 反而引入耦合。Boot 在这类情况下选择复制 。 第 19 章 SpringBeanHandlerInstantiator 复制 Framework 的类也是同类决策。


12. 失败分析:两个 FailureAnalyzer

第 16 章讲过 Boot 有 37 个 FailureAnalyzer。spring-boot-jdbc 贡献了至少 2 个:

12.1 DataSourceBeanCreationFailureAnalyzer:Boot 最著名的错误提示

java 复制代码
private String getDescription(DataSourceBeanCreationException cause) {
    StringBuilder description = new StringBuilder();
    description.append("Failed to configure a DataSource: ");
    if (!StringUtils.hasText(cause.getProperties().getUrl())) {
        description.append("'url' attribute is not specified and ");
    }
    description.append(String.format("no embedded datasource could be configured.%n"));
    description.append(String.format("%nReason: %s%n", cause.getMessage()));
    return description.toString();
}

private String getAction(DataSourceBeanCreationException cause) {
    StringBuilder action = new StringBuilder();
    action.append(String.format("Consider the following:%n"));
    if (EmbeddedDatabaseConnection.NONE == cause.getConnection()) {
        action.append(String.format("\tIf you want an embedded database (H2, HSQL or Derby), please put it on the classpath.%n"));
    }
    else {
        action.append(String.format("\tReview the configuration of %s%n.", cause.getConnection()));
    }
    action.append("\tIf you have database settings to be loaded from a particular "
            + "profile you may need to activate it").append(getActiveProfiles());
    return action.toString();
}

这就是那句每个 Spring Boot 初学者都见过的错误:

vbnet 复制代码
Failed to configure a DataSource: 'url' attribute is not specified and no embedded
datasource could be configured.

Reason: Failed to determine a suitable driver class

Action:

Consider the following:
	If you want an embedded database (H2, HSQL or Derby), please put it on the classpath.
	If you have database settings to be loaded from a particular profile you may need to
	activate it (no profiles are currently active).

三个组成部分:

  1. 判断 url 是否配了 (用 getUrl() 而非 determineUrl(),见 §6.1)
  2. 判断 classpath 上有没有嵌入式数据库 (EmbeddedDatabaseConnection.NONE)
  3. 列出当前激活的 profile(这是最有用的诊断信息!)

第 3 点 是关键:很多人把数据库配置写在 application-prod.yaml 里, 却忘了激活 profile。这条提示直接告诉你"当前没有激活的 profile"。

这是"诊断能力提前布局"的最佳范例之一 : 一个可能让人困惑一小时的启动失败,被压缩成 8 行可操作的提示。 而且它的构造只需要 Environment (DataSourceBeanCreationFailureAnalyzer(Environment)) ------FailureAnalyzer 可以拿到 Environment,这是第 16 章讲过的机制。

12.2 HikariDriverConfigurationFailureAnalyzer

⚠️ 未核实 :这个类的内容(没读)。 从名字推断:处理 Hikari 的 driver 配置冲突 (比如同时配了 dataSourceClassName 和 driverClassName,见 §4.3)。 自测方法:读该类,或故意配冲突看启动输出。

13. DataSourcePoolMetadata:跨连接池的统一抽象

spring-boot-jdbc 支持 4+ 种连接池,每种的监控 API 完全不同:

连接池 监控入口
Hikari HikariDataSource 实现 HikariConfigMXBean / HikariPoolMXBean
Tomcat org.apache.tomcat.jdbc.pool.DataSource 实现 ConnectionPoolMBean
DBCP2 BasicDataSource 实现 BasicDataSourceMXBean
Oracle UCP PoolDataSource 有自己的 API

Boot 的做法 :定义统一的 DataSourcePoolMetadata 接口,为每个池写一个适配器。

java 复制代码
public interface DataSourcePoolMetadata {

    @Nullable Float getUsage();           // 使用率

    @Nullable Integer getActive();        // 活跃连接数

    default @Nullable Integer getIdle() { // 空闲连接数(有默认实现)
        // ...
    }

    @Nullable Integer getMax();           // 最大连接数

    @Nullable Integer getMin();           // 最小连接数

    @Nullable String getValidationQuery();

    @Nullable Boolean getDefaultAutoCommit();
}

★ 所有方法都返回 @Nullable ------ 这是关键设计。

为什么? 因为不是每个连接池都能提供所有指标。 比如某些池没有"最小连接数"的概念。

"缺失只降性能不降正确性"(前面章节提过的定律)在这里再次体现: 拿不到的指标返回 null,消费者(Actuator 端点、Micrometer binder)跳过即可, 不会因为"某个池不支持某指标"而整体失败。

getIdle() 有默认实现 ------ 大概是从 max - active 推算。 ⚠️ 未核实:默认实现的公式(没读方法体)。

13.1 DataSourcePoolMetadataProvider:lambda 形式的策略

java 复制代码
@Bean
DataSourcePoolMetadataProvider hikariPoolDataSourceMetadataProvider() {
    return (dataSource) -> {
        HikariDataSource hikariDataSource = DataSourceUnwrapper.unwrap(dataSource, HikariConfigMXBean.class,
                HikariDataSource.class);
        if (hikariDataSource != null) {
            return new HikariDataSourcePoolMetadata(hikariDataSource);
        }
        return null;
    };
}

它是一个函数式接口 (DataSourcePoolMetadataProvider), 实现是 "能处理就处理,不能就返回 null"。

DataSourceUnwrapper.unwrap(dataSource, HikariConfigMXBean.class, HikariDataSource.class) ------ §3.4 见过 unwrapRoot,这里是有目标的 unwrap: 剥掉代理,直到找到实现了 HikariConfigMXBean 且是 HikariDataSource 的那一层。

为什么要"直到找到某接口"? 因为数据源可能被 LazyConnectionDataSourceProxy(§8.1)或 DelegatingDataSource 包装, 直接 instanceof HikariDataSource 会失败。

13.2 CompositeDataSourcePoolMetadataProvider

java 复制代码
DataSourceHealthContributorAutoConfiguration(ObjectProvider<DataSourcePoolMetadataProvider> metadataProviders) {
    this.metadataProviders = metadataProviders.orderedStream().toList();
}

@Override
public void afterPropertiesSet() {
    this.poolMetadataProvider = new CompositeDataSourcePoolMetadataProvider(this.metadataProviders);
}

"Composite + 挨个试":多个 provider 排好序,第一个返回非 null 的胜出。

这与 gRPC 的 CompositeGrpcChannelFactory 是同一模式(第 21 章), 但更简单:这里是"找到第一个能处理的",那里是"按 target 委派"。


14. 健康与指标:两个 @ConditionalOnBean(DataSource.class)

14.1 健康:DataSourceHealthContributorAutoConfiguration

java 复制代码
@AutoConfiguration(after = DataSourceAutoConfiguration.class)
@ConditionalOnClass({ JdbcTemplate.class, AbstractRoutingDataSource.class, ConditionalOnEnabledHealthIndicator.class })
@ConditionalOnBean(DataSource.class)
@ConditionalOnEnabledHealthIndicator("db")
@EnableConfigurationProperties(DataSourceHealthIndicatorProperties.class)
public final class DataSourceHealthContributorAutoConfiguration implements InitializingBean {

@ConditionalOnEnabledHealthIndicator("db") ------ Boot 的健康指示器统一开关(management.health.db.enabled)。 第 25 章讲 Actuator 时展开。

★ 它处理了"多个数据源"与"路由数据源"两种情况:

java 复制代码
private HealthContributor createContributor(Map<String, DataSource> beans) {
    Assert.notEmpty(beans, "'beans' must not be empty");
    if (beans.size() == 1) {
        return createContributor(beans.values().iterator().next());
    }
    return CompositeHealthContributor.fromMap(beans, this::createContributor);
}

private HealthContributor createContributor(DataSource source) {
    if (isRoutingDataSource(source)) {
        return new RoutingDataSourceHealthContributor(extractRoutingDataSource(source), this::createContributor);
    }
    return new DataSourceHealthIndicator(source, getValidationQuery(source));
}

三种形态:

场景 结果
1 个普通数据源 DataSourceHealthIndicator
N 个数据源 CompositeHealthContributor(每个一个子节点)
AbstractRoutingDataSource RoutingDataSourceHealthContributor(递归检查它路由到的所有目标)

RoutingDataSourceHealthContributor 递归 很巧妙: 路由数据源本身连不上任何库,要检查的是它背后的每个目标数据源。

SimpleAutowireCandidateResolver.resolveAutowireCandidates(beanFactory, DataSource.class, false, true) ------ 用"自动装配候选解析"来找出所有数据源 bean, 这样只返回那些可被注入的(排除非候选的、内部的)。

ignoreRoutingDataSources 配置:

java 复制代码
if (dataSourceHealthIndicatorProperties.isIgnoreRoutingDataSources()) {
    Map<String, DataSource> filteredDatasources = dataSources.entrySet().stream()
        .filter((e) -> !isRoutingDataSource(e.getValue()))
        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
    return createContributor(filteredDatasources);
}

management.health.db.ignore-routing-data-sources ------ 默认不忽略(即会递归检查路由目标)。

14.2 指标:DataSourcePoolMetricsAutoConfiguration

java 复制代码
@AutoConfiguration(after = DataSourceAutoConfiguration.class,
        afterName = "org.springframework.boot.micrometer.metrics.autoconfigure.CompositeMeterRegistryAutoConfiguration")
@ConditionalOnClass({ DataSource.class, MeterRegistry.class })
@ConditionalOnBean({ DataSource.class, MeterRegistry.class })
public final class DataSourcePoolMetricsAutoConfiguration {

    @Configuration(proxyBeanMethods = false)
    @ConditionalOnBean(DataSourcePoolMetadataProvider.class)
    static class DataSourcePoolMetadataMetricsConfiguration {

        private static final String DATASOURCE_SUFFIX = "dataSource";

        @Bean
        DataSourcePoolMetadataMeterBinder dataSourcePoolMetadataMeterBinder(
                ConfigurableListableBeanFactory beanFactory,
                ObjectProvider<DataSourcePoolMetadataProvider> metadataProviders) {
            return new DataSourcePoolMetadataMeterBinder(
                SimpleAutowireCandidateResolver.resolveAutowireCandidates(beanFactory, DataSource.class, false, true),
                metadataProviders);
        }
    }
}

内层的 @ConditionalOnBean(DataSourcePoolMetadataProvider.class) : 如果没有任何一个连接池的元数据提供者,就不注册指标 binder。

这很重要:如果你用的是 Boot 不认识的数据源(比如自定义池), DataSourcePoolMetadataProvider 一个都没有 → 不注册指标(而不是注册一个空的)。

这是"第二十四定律"的反面应用: Boot 只在"能力可用"时才注册"能力组件"。 元数据提供者是"能力检测",指标 binder 是"能力组件"。

DATASOURCE_SUFFIX = "dataSource" ------ 指标名后缀, 最终指标大概是 hikaricp.connections.active 之类。 ⚠️ 未核实 :完整的指标命名(在 DataSourcePoolMetrics 里,第 26 章讲 Micrometer 时会展开)。


15. 实践配方

基于本章源码,几个可操作的配置建议。

15.1 切换连接池

默认:classpath 上有谁用谁(Hikari > Tomcat > DBCP2 > OracleUCP)。

强制指定:

yaml 复制代码
spring:
  datasource:
    type: com.zaxxer.hikari.HikariDataSource     # ★ 必须写全限定名

⚠️ havingValue 是精确匹配 ,写 HikariDataSource 不生效。

注意 :spring.datasource.type 只是选择 , 真正的池参数在 spring.datasource.hikari.* / .tomcat.* / .dbcp2.* / .oracleucp.*。

★ 用 C3P0/Vibur 时的陷阱 (§4.2 推断): Generic 配置类的 dataSource() 没有 @ConfigurationProperties, 所以 spring.datasource.c3p0.* 不会被绑定 。 如果需要池参数,得自己定义 DataSource bean。

15.2 多数据源

Boot 不会自动配置多数据源。标准做法:

java 复制代码
@Configuration
public class MultiDataSourceConfig {

    @Bean
    @Primary
    @ConfigurationProperties("app.datasource.primary")
    DataSource primaryDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @Bean
    @ConfigurationProperties("app.datasource.secondary")
    DataSource secondaryDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }
}

三个必须注意的点(都来自本章源码):

① bean 名不能叫 dataSource 因为 JdbcConnectionDetailsBeanPostProcessor 只处理名为 dataSource 的 bean(§5.3)。 你自己的多数据源不会受影响------但如果你给其中一个起名叫 dataSource, 它会被 BPP 覆盖。

② 用户定义 DataSource bean 后,Boot 的自动配置全部失效 @ConditionalOnMissingBean(DataSource.class) 在 EmbeddedDatabaseConfiguration / PooledDataSourceConfiguration / DataSourceConfiguration.* 上都有。

③ 事务管理器不会自动配置 @ConditionalOnSingleCandidate(DataSource.class)(§9.3)------ 两个数据源且没 @Primary 时不匹配。 有 @Primary 时会 匹配(用 primary 那个), 但第二个数据源就需要你自己定义 DataSourceTransactionManager。 DataSourceTransactionManagerAutoConfiguration 上的 @ConditionalOnMissingBean(TransactionManager.class) 意味着 你定义了任何一个 TransactionManager,Boot 的就不注册。

15.3 生产/开发切换

yaml 复制代码
# application.yaml(通用)
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: ${DB_USER}
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 20
yaml 复制代码
# application-test.yaml(测试)
spring:
  datasource:
    url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    driver-class-name: org.h2.Driver

或者干脆不配 url,让 Boot 用嵌入式数据库(§2.3、§6.2):

  • 需要 H2/HSQL 在 classpath
  • 会得到一个 UUID 名字的独立库(测试隔离)

★ 一个陷阱 (§2.3 的 catch): 如果 spring.datasource.url=${DB_URL} 而 DB_URL 未定义, hasDataSourceUrlProperty 会吞掉异常 ,当成"没配 url" → 静默使用嵌入式数据库。

自测方法:注释掉环境变量,看启动日志里的数据源类型。

15.4 延迟取连接

yaml 复制代码
spring:
  datasource:
    connection-fetch: lazy

只在"很多 @Transactional 方法实际不查库"的场景有价值(§8.1)。

副作用 :dataSource bean 变成 LazyConnectionDataSourceProxy, instanceof HikariDataSource 会失败。 Boot 的元数据提供者用 DataSourceUnwrapper 解决了这个(§13.1), 但你自己代码里的 instanceof 要小心。

15.5 SQL 初始化的权限分离

yaml 复制代码
spring:
  datasource:
    username: app_user        # 运行时(只给 DML 权限)
    password: ${APP_PWD}
  sql:
    init:
      username: migrate_user  # 只在启动时执行 schema.sql(有 DDL 权限)
      password: ${MIGRATE_PWD}
      mode: always

必须同时配 username 和 password(§10.2)。


16. 事实清单

本章从源码直接确认的事实(挑最容易被忽略的):

  1. spring-boot-jdbc 有 9 条 .imports,分成"创建 DataSource"(3 条,互斥) 与"消费 DataSource"(6 条,都用 @ConditionalOnSingleCandidate)两组。
  2. JNDI > XA > 普通 ,由 @AutoConfiguration(before = ...) 表达; JNDI 靠属性 触发、XA 靠 bean 触发。
  3. DataSourceAutoConfiguration 上有 @ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory") ------ 有 R2DBC 就不配 JDBC(用字符串类名,无编译期依赖)。
  4. EmbeddedDatabaseCondition 的 Javadoc 写明 "pooled DataSource always preferred to EmbeddedDatabase" ------ 代码里只有 noMatch(...),优先级信息只在 Javadoc(第四定律)。
  5. EmbeddedDatabaseCondition 对无法解析的占位符静默降级 为"没配 url" (catch IllegalArgumentException,注释说应该用 PlaceholderResolutionException)。
  6. DataSourceBuilder.lookupPooled 的六个池顺序: Hikari → Tomcat → DBCP2 → OracleUCP → C3P0 → Vibur ; OracleUCP 额外要求 oracle.jdbc.OracleConnection。
  7. DataSourceBuilder 还有 lookupBasic 四个非池实现 (SimpleDriverDataSource、Oracle、H2、Postgres)。
  8. DataSourceProperty 枚举给每个属性多个候选 setter 名 (url/URL、username/user),按顺序试。
  9. DataSourceConfiguration 里 5 个内部配置靠 @Import 顺序 + @ConditionalOnMissingBean 互斥, havingValue 是精确全限定名匹配。
  10. Generic 是唯一没有 matchIfMissing 的(必须显式配 spring.datasource.type), 且它的 dataSource() 没有 @ConfigurationProperties。
  11. Hikari 是唯一传 applyDriverClassName=false 的------ 因为配了 data-source-class-name 时不能同时设 driverClassName。
  12. Tomcat 池是唯一设置 validationQuery + testOnBorrow 的; Hikari 与 OracleUCP 是唯一设置 pool name 的。
  13. JdbcConnectionDetailsBeanPostProcessor 是 PriorityOrdered, getOrder() = HIGHEST_PRECEDENCE + 2,注释说"在属性绑定 BPP 之后"。
  14. BPP 只处理 bean 名为 dataSource 的 bean, 且跳过 PropertiesJdbcConnectionDetails(属性来源无需覆盖)。
  15. 四个池的 BPP setter 名各不相同:setJdbcUrl/setUrl/setURL + setUser。
  16. PropertiesJdbcConnectionDetails 用 determineXxx() 而非 getXxx()。
  17. DataSourceProperties.generateUniqueName 默认 true → 嵌入式库名是 UUID(测试隔离的基石)。
  18. H2 的 URL 带 DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE------ 手工写 URL 时漏了会导致"库是空的"。
  19. Derby 已 EOL :DatabaseDriver.DERBY 与 EmbeddedDatabaseConnection.DERBY 都是 @Deprecated(since = "4.1.0", forRemoval = true),4.3.0 移除。
  20. driverClassIsLoadable 单独抛出 UnsupportedClassVersionError, 其它 Throwable 返回 false------区分"找不到"与"用不了"。
  21. MySQL 的 validationQuery 是 /* ping */ SELECT 1------注释让驱动走轻量 ping 路径。
  22. DatabaseDriver.fromJdbcUrl 用 ":" + prefix + ":" 前后加冒号做完整匹配, 且 toLowerCase(Locale.ENGLISH)(避免土耳其语 locale bug)。
  23. JTDS 的 productName 是 null(Javadoc 说明原因:它能连多种库)。
  24. LazyConnectionDataSourceConfiguration 用 postProcessAfterInitialization 替换 bean,并顺手把真实数据源注册到 JMX(补偿)。
  25. JndiDataSourceAutoConfiguration 用 @Bean(destroyMethod = "") 禁用销毁推断 ------JNDI 数据源归容器管,应用不能关。
  26. 事务管理器默认用 JdbcTransactionManager(spring.dao.exceptiontranslation.enabled 默认 true),它在 DataSourceTransactionManager 基础上加了异常转译。
  27. DataSourceTransactionManagerAutoConfiguration 有 @AutoConfigureOrder(LOWEST_PRECEDENCE)------等所有数据源配置跑完再决定。
  28. SQL 初始化用 derivedFrom + SimpleDriverDataSource 建一次性 数据源, 且必须同时配 sql.init.username 和 password 才启用凭据分离。
  29. DataSourcePoolMetadata 的所有方法返回 @Nullable------ 不是每个池都能提供所有指标。
  30. DataSourceHealthContributorAutoConfiguration 处理三种形态: 单个、Composite、RoutingDataSource(递归检查所有路由目标)。
  31. DataSourcePoolMetricsAutoConfiguration 内层还有 @ConditionalOnBean(DataSourcePoolMetadataProvider.class)------ 没有元数据提供者就不注册指标 binder。
  32. DataSourceBeanCreationFailureAnalyzer 用 getUrl()(不是 determineUrl()) 判断"用户是否配了",并列出当前激活的 profile。

17. 本章未逐行核实 / 待你实测的项

以下条目我没有从源码确认(按验证成本排序):

本仓库内但未读 : DataSourcePoolMetadata.getIdle() 的默认实现公式; DataSourcePoolMetadataProvidersConfiguration 的 OracleUcp 分支(读了一半); DataSourceJmxConfiguration 的内容; DataSourceCheckpointRestoreConfiguration 与 HikariCheckpointRestoreLifecycle 的实现; HikariDriverConfigurationFailureAnalyzer 的内容; DataSourceHealthIndicatorProperties 的字段(ignoreRoutingDataSources 的默认值); getValidationQuery(source) 的方法体; DataSourcePoolMetrics 的指标命名; DataSourceProperties.Xa 的字段; DataSourceBuilder.MappedDataSourceProperties.forType 的完整分支; DatabaseDriver.getUrlPrefixes() 的默认实现; DataSourceUnwrapper 的实现。

其它模块(本章范围外) : R2dbcAutoConfiguration 是否也有对应的 @ConditionalOnMissingBean(type="javax.sql.DataSource") (§2.1 的对称性问题); TransactionManagerCustomizers 的定义; ConditionalOnSqlInitialization 与 DatabaseInitializationDependencyConfigurer 的实现 (应在 spring-boot-sql 模块); XADataSourceWrapper 的实现提供方(Atomikos/Bitronix/Narayana?Boot 4.1 是否还有); JtaAutoConfiguration 的位置; ConfigurationPropertiesBindingPostProcessor 的确切 order(推断 HIGHEST_PRECEDENCE + 1)。

推断但未验证的行为 : 用 C3P0/Vibur 时 spring.datasource.c3p0.* 是否真的不绑定(§4.2); spring.datasource.url 占位符未解析时是否真的静默降级到嵌入式(§2.3); MBeanExporter 相关的两处 JMX 处理是否覆盖所有场景。

遗留未解决 : 第 17--21 章累积的 WAR 部署 WebApplicationContextInitializer 两次调用问题; 第 19 章的 Jackson 3 WRITE_DATES_AS_TIMESTAMPS 默认值; 第 20 章的 WebClient.Builder 自动配置位置。


18. 方法论:本章沉淀的三条定律

18.1 第二十八定律:getXxx 与 determineXxx 的分离 = 诊断能力的地基

当一个配置值既有"用户配置"又有"推断兜底"两种来源时, Boot 会拆成两个方法:getXxx(忠实返回配置,可 null)与 determineXxx(含推断,失败则抛明确异常)。 失败分析器用 getXxx 判断"用户到底配了没有"。

DataSourceProperties 里四组方法都这样拆(§6.1), DataSourceBeanCreationFailureAnalyzer 靠 getUrl() 才能说出 "'url' attribute is not specified" 这句精准的诊断。

推广:任何"有默认值/推断"的配置,都要保留"原始值"的访问途径, 否则诊断时无法区分"用户没配"与"用户配了一个等于默认值的东西"。

18.2 第二十九定律:外部信息覆盖内部配置 = BPP 排在属性绑定之后

当"外部来源"(Docker Compose、Testcontainers、云环境)需要覆盖 "配置文件"时,Boot 的固定做法是一个 PriorityOrdered 的 BeanPostProcessor,order 恰好排在 ConfigurationPropertiesBindingPostProcessor 之后。

JdbcConnectionDetailsBeanPostProcessor.getOrder() = HIGHEST_PRECEDENCE + 2 (注释明说 "Runs after ConfigurationPropertiesBindingPostProcessor")。

为什么必须在之后? 因为属性绑定会重新把配置文件的值写进 bean。 如果 BPP 在之前,覆盖会被绑定冲掉。

完整的三层时序:

css 复制代码
① @Bean 方法创建(用 JdbcConnectionDetails,已是外部值)
② ConfigurationPropertiesBindingPostProcessor 绑定 spring.datasource.hikari.*
③ JdbcConnectionDetailsBeanPostProcessor 再覆盖一次(双保险)

为什么 ③ 与 ① 都做? ① 是主路径,③ 防 ② 覆盖。 两道防线------这不是冗余,是"属性绑定可能改写任何字段"的必然结果。

18.3 第三十定律:destroyMethod = "" 标记"所有权边界"

@Bean(destroyMethod = "") 的语义是"这个 bean 不是我创建的,我不负责关它"。 看到这一行,就该意识到该资源的所有权在别处(容器、外部系统)。

JndiDataSourceAutoConfiguration 的 @Bean(destroyMethod = "") 是标准例子: JNDI 数据源归应用服务器管,应用关闭它会影响其它应用。

对称的另一端 :GrpcServerHealthAutoConfiguration 的 @Bean(destroyMethod = "enterTerminalState")(第 21 章 §9.1) 是显式指定 销毁方法------因为 HealthStatusManager 的清理方法不叫 close/shutdown, Boot 的自动推断找不到它。

三种 destroyMethod:

写法 语义
默认(不写) 自动推断 close/shutdown
"" 禁用销毁(所有权在外)
"methodName" 显式指定(方法名不符合约定)

19. 下一章预告

第 23 章:JPA / Hibernate 与持久化模块。

本章讲的是连接 ,下一章讲对象映射 。 spring-boot-jpa + spring-boot-hibernate 只有 2 条 .imports (HibernateJpaAutoConfiguration、HibernateMetricsAutoConfiguration), 但背后的 JpaBaseConfiguration 是 Boot 里最"厚"的自动配置基类之一。

第 22 章留下的伏笔会在那里得到答案:

  • DatabaseInitializationDependencyConfigurer 与三个 DependsOnDatabaseInitializationDetector 的协作机制(§10.4)------JPA 是最典型的"必须等数据库初始化完"的消费者
  • SchemaManagementProvider 与 spring.jpa.hibernate.ddl-auto 的推断 (HibernateDefaultDdlAutoProvider 已经躺在 spring-boot-hibernate 里了)
  • spring-boot-hibernate 里还有 SpringImplicitNamingStrategy 与 SpringJtaPlatform ------两个"Boot 替 Hibernate 补的 Spring 集成件"

另外,spring-boot-data-* 那十几个模块(Cassandra、Couchbase、Elasticsearch、 MongoDB、Neo4j、Redis、R2DBC......)会留到后续章节, 那时会看到本章的 JdbcConnectionDetails 模式如何被推广到每一种 数据存储 ------ConnectionDetails 是 Boot 4.x 数据访问层的统一语言。

相关推荐
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 24 章 · 日志系统:`LoggingApplicationListener` 与结构化日志
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 17 章 应用类型推断与上下文工厂:`WebApplicationType`
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 23 章 · JPA / Hibernate 与持久化模块
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 12 章 · 事件总线与 Bootstrap 上下文
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 05 章 · `ApplicationProperties` 与 `spring.main.*`:启动配置
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 07 章 · ConfigData 体系深潜:位置解析、profile 迭代求解
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 20 章 · WebFlux 与 Reactor Netty:响应式栈与编解码
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 21 章 · gRPC 新纪元:`spring-boot-grpc-server`
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 13 章 · Bean 定义加载:`BeanDefinitionLoader`、组件扫描与 Bean 命名
java·后端·开源