Spring Boot 4.1.1 深度源码解析-第 30 章 · 基础设施能力:SSL Bundle、Task 与虚拟线程、Retry

27 阅读55分钟

Spring Boot 4.1.1 深度源码解析-第 30 章 · 基础设施能力:SSL Bundle、Task 与虚拟线程、Retry

本章的阅读对象:core/spring-boot/src/main/java/org/springframework/boot/ssl/、 .../thread/、.../retry/;core/spring-boot-autoconfigure/.../autoconfigure/ssl/、task/; 以及 61 个消费 SslBundles 的自动配置类。

第 29 章 §6.2 留过一个尾巴:ContainerConnectionSource 携带 SslBundleSource, Testcontainers 可以为容器连接配置 SSL。本章从 SslBundle 这个抽象本身讲起, 顺便回答"为什么 Spring Boot 要重新发明一套 SSL 配置"。


0. 三条看似无关的线

SSL Bundle、Task 执行器、Retry 策略——这三件事看起来毫无关系。它们被放在同一章, 是因为它们共享同一个定位:不属于任何特定技术栈,却被几乎所有技术栈需要。

  • SSL:HTTP 服务器要、HTTP 客户端要、Kafka 要、Redis 要、gRPC 要、MongoDB 要…… 本仓库有 61 个主源码文件引用 SslBundles。
  • Task 执行与调度:@Async 要、@Scheduled 要、WebFlux 要、gRPC 要、 bootstrap 阶段要(第 12 章讲过 BootstrapRegistry 的执行器)。
  • Retry:AMQP 要、任何"可能临时失败"的客户端都要。

这三个能力的共同设计约束是:必须能被所有模块以同一种方式消费。 于是它们都被做成"一个抽象 + 一个注册表/构建器 + 一套属性",而不是每个模块各配各的。

本章的另一个隐形主线是虚拟线程——spring.threads.virtual.enabled=true 会同时改变 Task 执行器、Task 调度器和若干 web 相关 bean 的实现类。 @ConditionalOnThreading 是这条线的开关,本章会把它讲透。


1. SSL Bundle:一套统一的信任材料抽象

1.1 它要解决什么问题

在 Spring Boot 3.1 之前,SSL 配置是碎片化的:

  • Web 服务器用 server.ssl.*
  • RabbitMQ 用 spring.rabbitmq.ssl.*
  • Redis 用 spring.data.redis.ssl.*
  • MongoDB 有自己的一套

每套配置的表达能力都不一样:有的支持 PEM,有的只支持 JKS; 有的能配密钥别名,有的不能;有的能热重载,有的不能。

SslBundle 的目标是:把"一份信任材料"从"某个技术的 SSL 配置"里剥离出来, 成为一个可命名、可复用、可热更新的独立对象。

spring:
  ssl:
    bundle:
      pem:
        myclient:
          keystore:
            certificate: classpath:client.crt
            private-key: classpath:client.key
          truststore:
            certificate: classpath:ca.crt
server:
  ssl:
    bundle: myclient        # 服务器用
spring:
  rabbitmq:
    ssl:
      bundle: myclient      # RabbitMQ 也用同一份

一份定义、多处引用——这是 Bundle 这个词的核心含义。

1.2 SslBundle 接口:五个方法,一个默认方法

public interface SslBundle {

    String DEFAULT_PROTOCOL = "TLS";

    SslStoreBundle getStores();

    SslBundleKey getKey();

    SslOptions getOptions();

    String getProtocol();

    SslManagerBundle getManagers();

    default SSLContext createSslContext() {
        return getManagers().createSslContext(getProtocol());
    }

    static SslBundle of(@Nullable SslStoreBundle stores) { ... }

    static SslBundle of(@Nullable SslStoreBundle stores, @Nullable SslBundleKey key) { ... }

}

分层非常清晰:

SslBundle                 ← 用户面对的入口
├── SslStoreBundle        ← 证书/密钥的"存储"层(KeyStore / TrustStore)
├── SslBundleKey          ← 用哪个别名、什么密码
├── SslOptions            ← 密码套件、协议
├── String protocol       ← TLS / TLSv1.3 ...
└── SslManagerBundle      ← 转成 JDK 的 KeyManager[] / TrustManager[]
    └── createSslContext() → SSLContext

为什么要有 SslManagerBundle 这一层? 因为 KeyManagerFactory / TrustManagerFactory 的初始化是昂贵且可缓存的:读文件、解析 PEM、构建 KeyStore 都要时间。 SslBundle 的生命周期是"配置对象",而 SslManagerBundle 才是"可用的 JDK 对象"。 分开之后,前者可以频繁重建(热重载时),后者按需创建。

DEFAULT_PROTOCOL = "TLS" 而不是写死某个版本——SSLContext.getInstance("TLS") 会由 JDK 选择当前最高支持的版本。默认不锁版本,让 JDK 决定, 用户想锁就配 spring.ssl.bundle.pem.xxx.protocol=TLSv1.3。

1.3 SslStoreBundle:PEM 与 JKS 两条路

core/spring-boot 下有 ssl/pem/ 与 ssl/jks/ 两个子包:

  • PemSslStoreBundle:从 PEM 编码的证书/私钥构建内存中的 KeyStore
  • JksSslStoreBundle:直接加载 .jks / .p12 文件

PEM 路径要多做一件事:把 PEM 转成 KeyStore。 因为 JDK 的 SSLContext 只认 KeyStore,而 PEM 是 OpenSSL 世界的格式。 Spring Boot 自己写了 PEM 解析(而不是依赖 Bouncy Castle), 理由是不引入额外的强依赖。

待核实:PemSslStoreBundle 的 PEM 解析实现(是否支持 PKCS#1 / PKCS#8 / 加密私钥) 本轮未读取。可自测:读 ssl/pem/ 下的解析器类。

1.4 SslManagerBundle 与 DefaultSslManagerBundle

class DefaultSslManagerBundle implements SslManagerBundle {

    private final SslStoreBundle storeBundle;

    private final SslBundleKey key;

    @Override
    public KeyManagerFactory getKeyManagerFactory() { ... }

    @Override
    public TrustManagerFactory getTrustManagerFactory() { ... }

}

(方法体本轮未展开读取,见 §11 的未核实项。)

关键点在构造器里就固定了 storeBundle 与 key—— 一个 SslManagerBundle 绑定一份材料,不做动态查找。

1.5 SslBundleKey:别名选择,以及一个 JDK 的 final 方法

SslBundleKey 只有两个方法:

public interface SslBundleKey {

    SslBundleKey NONE = of(null, null);

    @Nullable String getPassword();

    @Nullable String getAlias();

    default void assertContainsAlias(@Nullable KeyStore keyStore) {
        String alias = getAlias();
        if (StringUtils.hasLength(alias) && keyStore != null) {
            try {
                Assert.state(keyStore.containsAlias(alias),
                        () -> String.format("Keystore does not contain alias '%s'", alias));
            }
            catch (KeyStoreException ex) {
                throw new IllegalStateException(
                        String.format("Could not determine if keystore contains alias '%s'", alias), ex);
            }
        }
    }

}

assertContainsAlias 是在构建期就校验别名存在——别名写错的代价是运行期握手失败, 那种错误极难排查(表现为 "no cipher suites in common" 之类)。提前断言把问题暴露在启动阶段。

有意思的是 catch (KeyStoreException): KeyStore.containsAlias 抛受检异常,这里包装成 IllegalStateException 并保留原始消息格式("Could not determine if keystore contains alias"), 与成功路径的消息("Keystore does not contain alias")区分开。 区分"查不了"和"查了没有"——两种失败的处置完全不同。

真正的技术难点在 AliasKeyManagerFactory:

/**
 * {@link KeyManagerFactory} that allows a configurable key alias to be used. Due to the
 * fact that the actual calls to retrieve the key by alias are done at request time the
 * approach is to wrap the actual key managers with a {@link AliasX509ExtendedKeyManager}.
 * The actual SPI has to be wrapped as well due to the fact that
 * {@link KeyManagerFactory#getKeyManagers()} is final.
 */
final class AliasKeyManagerFactory extends KeyManagerFactory {

    AliasKeyManagerFactory(KeyManagerFactory delegate, String alias, String algorithm) {
        super(new AliasKeyManagerFactorySpi(delegate, alias), delegate.getProvider(), algorithm);
    }

    private static final class AliasKeyManagerFactorySpi extends KeyManagerFactorySpi {

        private final KeyManagerFactory delegate;

        private final String alias;

        @Override
        protected void engineInit(KeyStore keyStore, char[] chars)
                throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException {
            this.delegate.init(keyStore, chars);
        }

        @Override
        protected void engineInit(ManagerFactoryParameters managerFactoryParameters)
                throws InvalidAlgorithmParameterException {
            throw new InvalidAlgorithmParameterException("Unsupported ManagerFactoryParameters");
        }

        @Override
        protected KeyManager[] engineGetKeyManagers() {
            return Arrays.stream(this.delegate.getKeyManagers())
                ...
        }

    }

}

这段代码值得仔细读,因为它展示了如何绕过 JDK 的 final 方法。

需求:一个 keystore 里可能有多个密钥条目(多个别名),用户想指定用哪一个。

JDK 的限制:KeyManagerFactory.getKeyManagers() 是 final 的,无法覆写。 所以"拦截 getKeyManagers 的返回值"这条路走不通。

Spring Boot 的解法:从更底层的 SPI 入手。 KeyManagerFactory 的全部行为都委托给构造时传入的 KeyManagerFactorySpi。 所以:

  1. 写一个自己的 SPI(AliasKeyManagerFactorySpi),包装真实的 factory;
  2. engineGetKeyManagers() 里把真实 factory 的 KeyManager[] 逐个包成 AliasX509ExtendedKeyManager(重写 chooseClientAlias / chooseServerAlias 返回指定别名);
  3. 用 super(spi, provider, algorithm) 构造一个"看起来正常"的 KeyManagerFactory。

当高层 API 是 final 时,去包装它依赖的底层抽象。 这是一个通用的绕行策略。注释里那句 "The actual SPI has to be wrapped as well due to the fact that KeyManagerFactory#getKeyManagers() is final" 就是在解释这件事—— 如果只是想改别名选择逻辑,只包装 KeyManager 就够了; 但因为拿不到 getKeyManagers() 的覆写点,必须连 SPI 一起包装。

engineInit(ManagerFactoryParameters) 直接抛异常—— 这种初始化方式 Spring Boot 不支持,明确拒绝而不是静默忽略。

1.6 SslOptions:一个 isSpecified() 的省事设计

public interface SslOptions {

    SslOptions NONE = of(null, (Set<String>) null);

    default boolean isSpecified() {
        return (getCiphers() != null) || (getEnabledProtocols() != null);
    }

    String @Nullable [] getCiphers();

    String @Nullable [] getEnabledProtocols();

}

isSpecified() 让消费方可以一句话判断"用户到底配没配 SSL 选项"。 为什么需要它?因为消费方通常要做这种事:

if (sslBundle != null && sslBundle.getOptions().isSpecified()) {
    engine.setEnabledCipherSuites(sslBundle.getOptions().getCiphers());
    engine.setEnabledProtocols(sslBundle.getOptions().getEnabledProtocols());
}

如果直接调 setEnabledCipherSuites(null),不同 JDK/客户端实现的行为不一致 (有的抛 NPE,有的清空套件列表)。用一个 isSpecified() 把"没配"变成可判断的状态, 消费方就不会踩坑。

SslOptions.NONE 与 SslBundleKey.NONE 是配套的零值对象—— 与第 29 章第八十四定律("什么都不做"用空实现表达)一致。

1.7 SslBundleRegistry 与 SslBundles:写入与读取分离

public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles {

    private final Map<String, RegisteredSslBundle> registeredBundles = new ConcurrentHashMap<>();

    private final List<BiConsumer<String, SslBundle>> registerHandlers = new CopyOnWriteArrayList<>();

    @Override
    public void registerBundle(String name, SslBundle bundle) {
        Assert.notNull(name, "'name' must not be null");
        Assert.notNull(bundle, "'bundle' must not be null");
        RegisteredSslBundle previous = this.registeredBundles.putIfAbsent(name, new RegisteredSslBundle(name, bundle));
        Assert.state(previous == null, () -> "Cannot replace existing SSL bundle '%s'".formatted(name));
        this.registerHandlers.forEach((handler) -> handler.accept(name, bundle));
    }

    @Override
    public void updateBundle(String name, SslBundle updatedBundle) {
        getRegistered(name).update(updatedBundle);
    }

    @Override
    public SslBundle getBundle(String name) {
        return getRegistered(name).getBundle();
    }

    @Override
    public void addBundleUpdateHandler(String name, Consumer<SslBundle> updateHandler)
            throws NoSuchSslBundleException {
        getRegistered(name).addUpdateHandler(updateHandler);
    }

    @Override
    public void addBundleRegisterHandler(BiConsumer<String, SslBundle> registerHandler) {
        this.registerHandlers.add(registerHandler);
    }

    @Override
    public List<String> getBundleNames() {
        List<String> names = new ArrayList<>(this.registeredBundles.keySet());
        Collections.sort(names);
        return Collections.unmodifiableList(names);
    }

    private RegisteredSslBundle getRegistered(String name) throws NoSuchSslBundleException {
        Assert.notNull(name, "'name' must not be null");
        RegisteredSslBundle registered = this.registeredBundles.get(name);
        if (registered == null) {
            throw new NoSuchSslBundleException(name, "SSL bundle name '%s' cannot be found".formatted(name));
        }
        return registered;
    }

}

两个接口,一个实现:

  • SslBundleRegistry(写入面):registerBundle / updateBundle
  • SslBundles(读取面):getBundle / getBundleNames / addBundleUpdateHandler

消费方只注入 SslBundles(读不到写方法),注册方只注入 SslBundleRegistry。 读写接口分离是这里最重要的设计——自动配置里注入的是 SslBundles, 避免了"某个自动配置不小心改了别人的 bundle"。

三个细节:

  1. registerBundle 用 putIfAbsent + Assert.state(previous == null): 不允许覆盖已有 bundle,重复注册直接失败。 为什么?因为热重载走的是 updateBundle 而不是重新 register—— 如果允许覆盖,热重载就会破坏"已注册"的语义(register handler 会重复触发)。 而且覆盖通常是配置错误的信号(两个地方配了同名 bundle)。
  2. RegisteredSslBundle.bundle 是 volatile(内部类字段)—— 热重载时替换引用,读方立即可见,无需加锁。
  3. getBundleNames() 排序后返回不可修改列表——确定性输出, 与第 27 章 ExplodedArchive 排序、第 28 章 createSortedProperties(true) 是同一条纪律。

集合类型的选择也很讲究: registeredBundles 用 ConcurrentHashMap(并发注册/读取), registerHandlers 与 updateHandlers 用 CopyOnWriteArrayList (写少读多,且遍历时不会被并发修改打断)。


2. 配置与注册:属性如何变成 bundle

2.1 SslProperties:spring.ssl.bundle.*

@ConfigurationProperties("spring.ssl")
public class SslProperties {

    private final Bundles bundle = new Bundles();

    public static class Bundles {

        private final Map<String, PemSslBundleProperties> pem = new LinkedHashMap<>();

        private final Map<String, JksSslBundleProperties> jks = new LinkedHashMap<>();

        private final Watch watch = new Watch();

        public static class Watch {

            private final File file = new File();

            public static class File {

                /**
                 * Quiet period, after which changes are detected.
                 */
                private Duration quietPeriod = Duration.ofSeconds(10);

            }

        }

    }

}

pem 与 jks 都是 LinkedHashMap<String, ...>——保持配置书写顺序, 注册顺序也就稳定(报错信息、日志顺序可预期)。

watch.file.quietPeriod 默认 10 秒——这是热重载的静默期(§3.2 详述)。

PemSslBundleProperties 的 Store 内部类:

public static class Store {

    private @Nullable String type;

    private @Nullable String certificate;

    private @Nullable String privateKey;

    private @Nullable String privateKeyPassword;

    private boolean verifyKeys;

}

verifyKeys(默认 false)开启后会校验私钥与公钥匹配。 默认关闭是因为校验有成本且大多数场景不需要; 但一旦配错,握手失败的信息非常难懂,所以生产环境建议打开。

certificate / privateKey 既可以写路径也可以写PEM 内容本身 (BundleContentProperty 负责区分,见 §3.3)。

2.2 SslAutoConfiguration:三个 bean

@AutoConfiguration
@EnableConfigurationProperties(SslProperties.class)
public final class SslAutoConfiguration {

    private final ResourceLoader resourceLoader;

    private final SslProperties sslProperties;

    SslAutoConfiguration(ResourceLoader resourceLoader, SslProperties sslProperties) {
        this.resourceLoader = ApplicationResourceLoader.get(resourceLoader, true);
        this.sslProperties = sslProperties;
    }

    @Bean
    FileWatcher fileWatcher() {
        return new FileWatcher(this.sslProperties.getBundle().getWatch().getFile().getQuietPeriod());
    }

    @Bean
    SslPropertiesBundleRegistrar sslPropertiesSslBundleRegistrar(FileWatcher fileWatcher) {
        return new SslPropertiesBundleRegistrar(this.sslProperties, fileWatcher, this.resourceLoader);
    }

    @Bean
    @ConditionalOnMissingBean({ SslBundleRegistry.class, SslBundles.class })
    DefaultSslBundleRegistry sslBundleRegistry(ObjectProvider<SslBundleRegistrar> sslBundleRegistrars) {
        DefaultSslBundleRegistry registry = new DefaultSslBundleRegistry();
        sslBundleRegistrars.orderedStream().forEach((registrar) -> registrar.registerBundles(registry));
        return registry;
    }

}

三个 bean 依次是:文件监听器 → 属性注册器 → 注册表本体。

ApplicationResourceLoader.get(resourceLoader, true) 这行值得注意: 它把普通 ResourceLoader 包装成应用资源加载器。第二个参数 true 的含义(推测)是 "允许加载文件系统/绝对路径资源"。 这与 SSL 证书通常在 classpath 外(/etc/ssl/...)的现实有关—— 证书很少打进 jar,所以资源加载必须支持外部路径。

@ConditionalOnMissingBean({ SslBundleRegistry.class, SslBundles.class }) ——注意两个接口都算。因为用户可能自己提供一个只实现 SslBundles 的 bean (只读的 bundle 源,比如从 Vault 读)。条件判断要覆盖所有可能的替身类型。

sslBundleRegistrars.orderedStream():SslBundleRegistrar 是一个 SPI, 可以有多个实现(属性驱动的是一个,用户自定义的也可以有),按 @Order 依次注册。

2.3 SslBundleRegistrar:一行 SPI

public interface SslBundleRegistrar {

    /**
     * Callback method for registering {@link SslBundle}s with an
     * {@link SslBundleRegistry}.
     * @param registry the registry that accepts {@code SslBundle}s
     */
    void registerBundles(SslBundleRegistry registry);

}

一个方法,无返回值。想往注册表里加 bundle(比如从数据库或 KMS 加载), 实现它并注册成 bean 即可。

2.4 SslPropertiesBundleRegistrar:属性 → bundle

class SslPropertiesBundleRegistrar implements SslBundleRegistrar {

    private final SslProperties.Bundles properties;

    private final FileWatcher fileWatcher;

    private final ResourceLoader resourceLoader;

    @Override
    public void registerBundles(SslBundleRegistry registry) {
        registerBundles(registry, this.properties.getPem(), PropertiesSslBundle::get, this::watchedPemPaths);
        registerBundles(registry, this.properties.getJks(), PropertiesSslBundle::get, this::watchedJksPaths);
    }

    private <P extends SslBundleProperties> void registerBundles(SslBundleRegistry registry, Map<String, P> properties,
            BiFunction<P, ResourceLoader, SslBundle> bundleFactory, Function<Bundle<P>, Set<Path>> watchedPaths) {
        properties.forEach((bundleName, bundleProperties) -> {
            Supplier<SslBundle> bundleSupplier = () -> bundleFactory.apply(bundleProperties, this.resourceLoader);
            try {
                registry.registerBundle(bundleName, bundleSupplier.get());
                if (bundleProperties.isReloadOnUpdate()) {
                    Supplier<Set<Path>> pathsSupplier = () -> watchedPaths
                        .apply(new Bundle<>(bundleName, bundleProperties));
                    watchForUpdates(registry, bundleName, pathsSupplier, bundleSupplier);
                }
            }
            catch (IllegalStateException ex) {
                throw new IllegalStateException("Unable to register SSL bundle '%s'".formatted(bundleName), ex);
            }
        });
    }

}

三个设计点:

  1. bundleSupplier 是一个 Supplier 而不是直接的值——因为热重载时要用同一段逻辑 重新构建 bundle。定义一次、执行多次。
  2. 泛型 + 函数参数让 pem 与 jks 两条路共用同一段注册逻辑: bundleFactory 是 PropertiesSslBundle::get(重载解析出对应版本), watchedPaths 分别是 watchedPemPaths / watchedJksPaths。 用高阶函数消除重复代码,这在本仓库并不常见(多数地方用模板方法), 这里用得很干净。
  3. catch (IllegalStateException ex) 包一层带 bundle 名的消息再抛—— 错误信息里必须有"哪个 bundle",否则一堆 bundle 时无法定位。 注意它只捕获 IllegalStateException(注册冲突、断言失败), 其它异常(比如文件不存在)原样抛出。

要监听哪些路径:

private Set<Path> watchedPemPaths(Bundle<PemSslBundleProperties> bundle) {
    List<BundleContentProperty> watched = new ArrayList<>();
    watched.add(new BundleContentProperty("keystore.private-key", bundle.properties().getKeystore().getPrivateKey()));
    watched.add(new BundleContentProperty("keystore.certificate", bundle.properties().getKeystore().getCertificate()));
    watched.add(new BundleContentProperty("truststore.private-key",
            bundle.properties().getTruststore().getPrivateKey()));
    watched.add(new BundleContentProperty("truststore.certificate",
            bundle.properties().getTruststore().getCertificate()));
    return watchedPaths(bundle.name(), watched);
}

PEM 监听 4 个属性(keystore/truststore × 证书/私钥),JKS 监听 2 个(keystore/truststore 的 location)。

Bundle<P>(String name, P properties) 是一个 record—— 把"名字 + 属性"绑在一起传给 watchedPaths,这样报错时能带上名字。 用 record 而不是两个参数,是为了让函数签名只有一个入参(Function<Bundle<P>, Set<Path>>)。

2.5 谁在消费:61 个类

按技术栈分类(主源码,非测试):

类别代表
Web 服务器http/client、web-server 相关自动配置
消息RabbitAutoConfiguration、RabbitConnectionFactoryBeanConfigurer、RabbitStreamConfiguration、KafkaAutoConfiguration
数据CassandraAutoConfiguration、CouchbaseAutoConfiguration、DataMongoAutoConfiguration、DataRedisAutoConfiguration、ElasticsearchRestClientConfigurations
RPCGrpcClientAutoConfiguration、NettyGrpcServerConfiguration、ShadedNettyGrpcServerConfiguration
可观测OtlpMetricsExportAutoConfiguration、SslMeterBinder、SslHealthContributorAutoConfiguration
其它MailSenderPropertiesConfiguration、EmbeddedLdapAutoConfiguration、InfoContributorAutoConfiguration

SslHealthContributorAutoConfiguration 尤其值得一提: SSL bundle 有对应的健康指示器——证书过期会被报告为不健康。 第 25 章讲 HealthIndicator 时提到过 SslHealthIndicator,它的材料来源就是这里。

SslMeterBinder 则是第 26 章指标体系的对应物:证书过期天数作为指标暴露。

所以 SSL Bundle 不只是"配置复用",它还顺带统一了健康检查和指标—— 这正是"抽象带来的附加价值"。


3. 证书热重载:一个"静默期"算法与 Kubernetes 的 symlink

证书会过期,而重启应用来换证书在生产环境往往不可接受。 spring.ssl.bundle.*.reload-on-update=true 开启热重载。

3.1 触发链路

文件变化
  → FileWatcher.WatcherThread 检测到
  → 静默期结束(默认 10 秒无新变化)
  → 执行 Registration.action
  → registry.updateBundle(name, bundleSupplier.get())
  → RegisteredSslBundle.update(newBundle) 替换 volatile 字段
  → 所有 updateHandler 被通知

注意最后一步:updateBundle 而不是重新 registerBundle。 §1.7 说过 registerBundle 不允许覆盖,这里正好解释了为什么—— 热重载必须走"更新"路径,否则 registerHandlers 会重复触发。

3.2 FileWatcher:用 poll 的超时实现静默期

@Override
public void run() {
    logger.debug("Watch thread started");
    Set<Runnable> actions = new HashSet<>();
    while (this.running) {
        try {
            long timeout = FileWatcher.this.quietPeriod.toMillis();
            WatchKey key = this.watchService.poll(timeout, TimeUnit.MILLISECONDS);
            if (key == null) {
                actions.forEach(this::runSafely);
                actions.clear();
            }
            else {
                accumulate(key, actions);
                key.reset();
            }
        }
        catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
        }
        catch (ClosedWatchServiceException ex) {
            logger.debug("File watcher has been closed");
            this.running = false;
        }
    }
    logger.debug("Watch thread stopped");
}

这个算法非常巧妙,值得拆开看:

  • watchService.poll(timeout):带超时的 poll。
    • 超时时间内有事件 → 返回 WatchKey(非 null)→ 把对应的 action 累积进 actions。
    • 超时时间内没有事件 → 返回 null → 执行所有累积的 action 并清空。

于是"静默期"的语义自然成立:只要文件还在频繁变化,poll 就会不断返回非 null, action 只会累积不会执行;一旦安静了 quietPeriod 这么久,才真正执行一次。

这比"检测到变化就起一个 10 秒的定时器,期间有新变化就重置定时器"要简单得多—— 不需要额外的定时器与重置逻辑,一个循环搞定。 代价是首次变化后最多要等 quietPeriod 才生效(这是期望行为)。

actions 是 Set<Runnable>——去重:同一个 bundle 的多个文件同时变化时只执行一次。

runSafely(推测是 try/catch 包一层记日志)保证一个 action 抛异常不影响其它。

为什么要静默期?因为证书更新通常涉及多个文件(证书 + 私钥), 而且 Kubernetes 的 secret 更新是"先写临时文件再原子替换", 期间会产生多次文件系统事件。没有静默期就会在"半更新"状态下重载, 导致私钥与证书不匹配。静默期是对"多文件原子更新"的务实妥协。

线程的其它细节:

WatcherThread() throws IOException {
    setName("ssl-bundle-watcher");
    setDaemon(true);
    setUncaughtExceptionHandler(this::onThreadException);
}

private void onThreadException(Thread thread, Throwable throwable) {
    logger.error("Uncaught exception in file watcher thread", throwable);
}
  • 有名字(ssl-bundle-watcher):线程 dump 里可辨识。
  • 守护线程:不阻止 JVM 退出。
  • 未捕获异常只记日志不退出:文件监听失败不应该弄挂应用。

close() 的顺序:

synchronized (this.lock) {
    if (this.thread != null) {
        this.thread.close();        // 关 WatchService
        this.thread.interrupt();    // 打断 poll
        try {
            this.thread.join();     // 等线程结束
        }
        catch (InterruptedException ex) {
            Thread.currentThread().interrupt();   // 恢复中断标志
        }
        this.thread = null;
    }
}

join() 被中断时恢复中断标志——第 27 章 FileDataBlock 那里见过同一条纪律 ("不清中断状态")。中断是给上层的信息,不能吞掉。

3.3 监听什么:目录而非文件

void register(Registration registration) throws IOException {
    Set<Path> directories = new HashSet<>();
    for (Path path : registration.paths()) {
        if (!Files.isRegularFile(path) && !Files.isDirectory(path)) {
            throw new IOException("'%s' is neither a file nor a directory".formatted(path));
        }
        Path directory = Files.isDirectory(path) ? path : path.getParent();
        directories.add(directory);
    }
    for (Path directory : directories) {
        WatchKey watchKey = register(directory);
        this.registrations.computeIfAbsent(watchKey, (key) -> new CopyOnWriteArrayList<>()).add(registration);
    }
}

private WatchKey register(Path directory) throws IOException {
    logger.debug(LogMessage.format("Registering '%s'", directory));
    return directory.register(this.watchService, StandardWatchEventKinds.ENTRY_CREATE,
            StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE);
}

WatchService 只能监听目录,所以要监听一个文件就得监听它的父目录。 这里把"文件路径"换成"父目录",然后按 WatchKey 分组登记 registration。

三个事件类型都注册(CREATE / MODIFY / DELETE)—— 因为 Kubernetes 的 secret 更新会先删后建(或写临时文件再 rename)。

Files.isRegularFile(path) && !Files.isDirectory(path) 的检查很重要: 路径不存在时直接抛 IOException,让配置错误在启动期暴露 (而不是"监听一个不存在的目录,永远不触发")。

一个 WatchKey 可以对应多个 registration(同一目录下有多个 bundle 的文件)—— 用 computeIfAbsent + CopyOnWriteArrayList 建立一对多映射。

3.4 symlink:Kubernetes secret 的标准形态

这是本章最能体现"实战经验"的一段。

/**
 * Retrieves all {@link Path Paths} that should be registered for the specified
 * {@link Path}. If the path is a symlink, changes to the symlink should be monitored,
 * not just the file it points to. For example, for the given {@code keystore.jks}
 * path in the following directory structure:<pre>
 * +- stores
 * |  +─ keystore.jks
 * +- <em>data</em> -&gt; stores
 * +─ <em>keystore.jks</em> -&gt; data/keystore.jks
 * </pre> the resulting paths would include:
 * <ul>
 * <li>{@code keystore.jks}</li>
 * <li>{@code data/keystore.jks}</li>
 * <li>{@code data}</li>
 * <li>{@code stores/keystore.jks}</li>
 * </ul>
 */
private Set<Path> getRegistrationPaths(Set<Path> paths) throws IOException {
    Set<Path> result = new HashSet<>();
    for (Path path : paths) {
        collectRegistrationPaths(path, result);
    }
    return Collections.unmodifiableSet(result);
}

private void collectRegistrationPaths(Path path, Set<Path> result) throws IOException {
    path = path.toAbsolutePath();
    result.add(path);
    Path parent = path.getParent();
    if (parent != null && Files.isSymbolicLink(parent)) {
        result.add(parent);
        collectRegistrationPaths(resolveSiblingSymbolicLink(parent).resolve(path.getFileName()), result);
    }
    else if (Files.isSymbolicLink(path)) {
        collectRegistrationPaths(resolveSiblingSymbolicLink(path), result);
    }
}

private Path resolveSiblingSymbolicLink(Path path) throws IOException {
    return path.resolveSibling(Files.readSymbolicLink(path));
}

问题的来源:Kubernetes 挂载 secret 时用的是这种结构:

/etc/ssl/keystore.jks -> ..data/keystore.jks
/etc/ssl/..data -> ..2024_01_01_12_00_00.123456789
/etc/ssl/..2024_01_01_12_00_00.123456789/keystore.jks

更新 secret 时,K8s 创建一个新的带时间戳目录,然后把 ..data 这个 symlink 原子地指向新目录。也就是说:真实文件被替换了,但 symlink 路径不变。

如果只监听 keystore.jks 的父目录(/etc/ssl), 会看到 ..data 被修改(ENTRY_MODIFY)吗?会——但仅此而已, 无法知道"实际内容变了"。更麻烦的是:如果监听的是 symlink 解析后的真实目录 (那个带时间戳的),secret 下次更新后这个目录就不再被使用了,监听失效。

Spring Boot 的解法:把整条 symlink 链上的每个环节都加进监听集合。 collectRegistrationPaths 递归地:

  1. 加入当前路径本身(转绝对路径)
  2. 如果父目录是 symlink(..data):加入父目录本身, 并递归处理 父目录解析后 + 当前文件名
  3. 否则如果当前路径是 symlink:加入它并递归处理解析后的路径

resolveSibling(p) 用 path.resolveSibling(Files.readSymbolicLink(path))—— 因为 symlink 的目标通常是相对路径(..data), 要相对于 symlink 所在的目录解析,不是相对于 CWD。 resolveSibling 正是为这个场景准备的 API(相对兄弟路径解析)。

这个 Javadoc 用 ASCII 图画出了目录结构——在复杂逻辑前画一张图, 比用文字描述清楚得多。这是本仓库注释风格的优秀范例。

3.5 BundleContentProperty:路径还是内容?

certificate 属性既可以写路径也可以写 PEM 内容。判定逻辑在 BundleContentProperty: hasValue() 判断有值,toWatchPath(resourceLoader) 把它转成可监听的 Path, 转换失败抛 BundleContentNotWatchableException(带 bundle 名)。

如果内容是内联 PEM(不是文件路径),就无法监听—— 此时开启 reload-on-update 会失败,由专门的 BundleContentNotWatchableFailureAnalyzer 给出可读的错误信息 (第 16 章讲过 FailureAnalyzer 的机制)。

配了不可监听的内容却要求热重载 → 启动期明确失败,而不是"静默不重载"。 这是第 29 章第八十七定律(能力边界显式拒绝)的又一次体现。

3.6 谁来响应更新

addBundleUpdateHandler(name, handler) 注册的回调在 updateBundle 时触发。 典型消费者是那些持有 SSLContext 长连接的客户端—— 它们不能只换配置,必须重建连接或至少重建 SSLContext。

待核实:本仓库中具体哪些类调用了 addBundleUpdateHandler 本轮未统计。 可自测:grep -rn "addBundleUpdateHandler" --include=*.java .


4. Task 执行:applicationTaskExecutor 是怎么被选出来的

4.1 自动配置的骨架

@ConditionalOnClass(ThreadPoolTaskExecutor.class)
@AutoConfiguration
@EnableConfigurationProperties(TaskExecutionProperties.class)
@Import({ TaskExecutorConfigurations.TaskExecutorContextPropagationConfiguration.class,
        TaskExecutorConfigurations.ThreadPoolTaskExecutorBuilderConfiguration.class,
        TaskExecutorConfigurations.SimpleAsyncTaskExecutorBuilderConfiguration.class,
        TaskExecutorConfigurations.TaskExecutorConfiguration.class,
        TaskExecutorConfigurations.BootstrapExecutorConfiguration.class })
public final class TaskExecutionAutoConfiguration {

    /**
     * Bean name of the application {@link TaskExecutor}.
     */
    public static final String APPLICATION_TASK_EXECUTOR_BEAN_NAME = "applicationTaskExecutor";

}

这个自动配置类本身没有一个 @Bean 方法——所有 bean 都在 @Import 的五个内部配置类里。 为什么拆这么碎?因为每个内部配置类有自己的条件:

内部配置条件作用
TaskExecutorContextPropagationConfiguration@ConditionalOnClass(ContextSnapshot.class)上下文传播装饰器
ThreadPoolTaskExecutorBuilderConfiguration无线程池 builder
SimpleAsyncTaskExecutorBuilderConfiguration无simple builder(含虚拟线程分支)
TaskExecutorConfiguration@Conditional(OnExecutorCondition.class)applicationTaskExecutor 本体
BootstrapExecutorConfiguration无注册 bootstrap 别名

用嵌套 @Configuration 而不是在方法上加条件,是因为有些条件是类级的(比如 @ConditionalOnClass), 且需要控制 bean 的注册顺序。

4.2 OnExecutorCondition:AnyNestedCondition 与注册阶段

static class OnExecutorCondition extends AnyNestedCondition {

    OnExecutorCondition() {
        super(ConfigurationPhase.REGISTER_BEAN);
    }

    @ConditionalOnMissingBean(Executor.class)
    private static final class ExecutorBeanCondition {

    }

    @ConditionalOnProperty(value = "spring.task.execution.mode", havingValue = "force")
    private static final class ModelCondition {

    }

}

AnyNestedCondition = 两个内部条件任一成立即可:

  1. 没有用户定义的 Executor bean → 创建
  2. spring.task.execution.mode=force → 强制创建

ConfigurationPhase.REGISTER_BEAN 是关键: 条件评估必须在 REGISTER_BEAN 阶段(而不是 PARSE_CONFIGURATION 阶段), 因为 @ConditionalOnMissingBean(Executor.class) 需要看到已注册的 bean 定义。 第 15 章讲过这两个阶段的区别。

mode 的语义(Mode 枚举,since 3.5.0):

public enum Mode {
    /**
     * Create the task executor if no user-defined executor is present.
     */
    AUTO,
    /**
     * Create the task executor even if a user-defined executor is present.
     */
    FORCE
}

FORCE 的用途:你定义了一个自己的 Executor bean(比如给某个特定用途), 但仍希望 Spring Boot 的 applicationTaskExecutor 存在(供 @Async 使用)。 "我有别的 Executor"与"我要 Spring Boot 的 Executor"不再互斥—— 这是在 3.5.0 引入的,说明 AUTO 的默认行为确实误伤过用户。

4.3 两个 builder,两条路

ThreadPoolTaskExecutorBuilder:

@Bean
@ConditionalOnMissingBean
ThreadPoolTaskExecutorBuilder threadPoolTaskExecutorBuilder(TaskExecutionProperties properties,
        ObjectProvider<ThreadPoolTaskExecutorCustomizer> threadPoolTaskExecutorCustomizers,
        ObjectProvider<TaskDecorator> taskDecorator) {
    TaskExecutionProperties.Pool pool = properties.getPool();
    ThreadPoolTaskExecutorBuilder builder = new ThreadPoolTaskExecutorBuilder();
    builder = builder.queueCapacity(pool.getQueueCapacity());
    builder = builder.corePoolSize(pool.getCoreSize());
    builder = builder.maxPoolSize(pool.getMaxSize());
    builder = builder.allowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout());
    builder = builder.keepAlive(pool.getKeepAlive());
    builder = builder.acceptTasksAfterContextClose(pool.getShutdown().isAcceptTasksAfterContextClose());
    TaskExecutionProperties.Shutdown shutdown = properties.getShutdown();
    builder = builder.awaitTermination(shutdown.isAwaitTermination());
    builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod());
    builder = builder.threadNamePrefix(properties.getThreadNamePrefix());
    builder = builder.customizers(threadPoolTaskExecutorCustomizers.orderedStream()::iterator);
    builder = builder.taskDecorator(getTaskDecorator(taskDecorator));
    return builder;
}

每一步都重新赋值 builder = builder.xxx()——builder 是不可变的流式 API (每次返回新实例)。这与第 26 章讲的 Micrometer builder 风格一致。

customizers(...orderedStream()::iterator):把 ObjectProvider 转成 Iterator 传进去,让 builder 自己迭代——延迟求值,customizer bean 在真正构建时才被解析。

SimpleAsyncTaskExecutorBuilder 同样有一串设置,但多了一个虚拟线程分支:

@Bean
@ConditionalOnMissingBean
@ConditionalOnThreading(Threading.PLATFORM)
SimpleAsyncTaskExecutorBuilder simpleAsyncTaskExecutorBuilder() {
    return builder();
}

@Bean(name = "simpleAsyncTaskExecutorBuilder")
@ConditionalOnMissingBean
@ConditionalOnThreading(Threading.VIRTUAL)
SimpleAsyncTaskExecutorBuilder simpleAsyncTaskExecutorBuilderVirtualThreads() {
    return builder().virtualThreads(true);
}

两个方法,同一个 bean 名(第二个显式写了 name = "simpleAsyncTaskExecutorBuilder", 第一个靠方法名推导)。互斥的条件保证只有一个生效。 "同一个 bean 的多个候选定义,用互斥条件分开"——这是自动配置的标准手法 (第 26 章 OTLP 那两套 @ConditionalOnThreading 也是这样)。

4.4 applicationTaskExecutor 的双定义

@Configuration(proxyBeanMethods = false)
@Conditional(OnExecutorCondition.class)
@Import({ AsyncConfigurerWrapperConfiguration.class, AsyncConfigurerConfiguration.class })
static class TaskExecutorConfiguration {

    @Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
    @ConditionalOnThreading(Threading.VIRTUAL)
    SimpleAsyncTaskExecutor applicationTaskExecutorVirtualThreads(SimpleAsyncTaskExecutorBuilder builder) {
        return builder.build();
    }

    @Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
    @Lazy
    @ConditionalOnThreading(Threading.PLATFORM)
    ThreadPoolTaskExecutor applicationTaskExecutor(ThreadPoolTaskExecutorBuilder threadPoolTaskExecutorBuilder) {
        return threadPoolTaskExecutorBuilder.build();
    }

}

虚拟线程 → SimpleAsyncTaskExecutor(每任务一个新虚拟线程,不需要池化); 平台线程 → ThreadPoolTaskExecutor(线程池)。

@Lazy 只加在平台线程版本上——为什么?

因为 ThreadPoolTaskExecutor 一创建就会预初始化核心线程(默认 8 个), 而很多应用根本不用 @Async。加 @Lazy 让它真正被用到时才创建, 省掉 8 个线程的开销。

虚拟线程版本不需要 @Lazy,因为 SimpleAsyncTaskExecutor 不预创建线程。

这个差异说明:要不要懒加载,取决于"创建这个 bean 有没有副作用"。 线程池有(预建线程),simple 没有。

4.5 AsyncConfigurer 的包装:让 @Async 用上 applicationTaskExecutor

@Configuration(proxyBeanMethods = false)
@ConditionalOnBean(AsyncConfigurer.class)
static class AsyncConfigurerWrapperConfiguration {

    @Bean
    static BeanPostProcessor applicationTaskExecutorAsyncConfigurerBeanPostProcessor(
            ObjectProvider<BeanFactory> beanFactory) {
        return new BeanPostProcessor() {
            @Override
            public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
                if (bean instanceof AsyncConfigurer asyncConfigurer
                        && !(bean instanceof ApplicationTaskExecutorAsyncConfigurer)) {
                    return new ApplicationTaskExecutorAsyncConfigurer(beanFactory.getObject(), asyncConfigurer);
                }
                return bean;
            }
        };
    }

}

@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(AsyncConfigurer.class)
static class AsyncConfigurerConfiguration {

    @Bean
    ApplicationTaskExecutorAsyncConfigurer applicationTaskExecutorAsyncConfigurer(BeanFactory beanFactory) {
        return new ApplicationTaskExecutorAsyncConfigurer(beanFactory, null);
    }

}

两个互斥配置:

  • 用户定义了 AsyncConfigurer → 用 BPP 把它包装成 ApplicationTaskExecutorAsyncConfigurer。
  • 用户没定义 → 直接创建一个(内部 delegate 为 null)。

包装的目的是什么?ApplicationTaskExecutorAsyncConfigurer 的 Javadoc:

AsyncConfigurer implementation that delegates to the user-defined AsyncConfigurer instance, if any. Consistently use the executor named applicationTaskExecutor in the absence of a custom executor.

即:如果用户自定义了 AsyncConfigurer 但没指定 executor,就用 applicationTaskExecutor (而不是 Spring 默认的 SimpleAsyncTaskExecutor)。 这保证了"不管用户有没有自定义,@Async 用的执行器是同一个"。

beanFactory.getObject() 而不是直接注入 BeanFactory——因为 BPP 方法是 static 的, 不能访问实例字段,所以用 ObjectProvider 延迟获取。 BPP 定义为 static 方法 是为了尽早实例化(不需要依赖外部配置类的实例)。

&& !(bean instanceof ApplicationTaskExecutorAsyncConfigurer) 防重复包装 (BPP 会对自己产生的 bean 再次调用)。

4.6 bootstrap executor 别名

@Configuration(proxyBeanMethods = false)
static class BootstrapExecutorConfiguration {

    @Bean
    static BeanFactoryPostProcessor bootstrapExecutorAliasPostProcessor() {
        return (beanFactory) -> {
            boolean hasBootstrapExecutor = beanFactory
                .containsBean(ConfigurableApplicationContext.BOOTSTRAP_EXECUTOR_BEAN_NAME);
            boolean hasApplicationTaskExecutor = beanFactory
                .containsBean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME);
            if (!hasBootstrapExecutor && hasApplicationTaskExecutor) {
                beanFactory.registerAlias(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME,
                        ConfigurableApplicationContext.BOOTSTRAP_EXECUTOR_BEAN_NAME);
            }
        };
    }

}

ConfigurableApplicationContext.BOOTSTRAP_EXECUTOR_BEAN_NAME 是 Spring Framework 定义的 特殊 bean 名——启动早期阶段(BootstrapRegistry、AOT 处理、ApplicationContext 初始化) 需要的执行器。第 12 章讲 BootstrapRegistry 时提到过这个概念。

这里把 applicationTaskExecutor 注册成它的别名,于是启动早期也能用上配置好的执行器。 用别名而不是新定义 bean——同一个实例,避免多套线程池。

!hasBootstrapExecutor 检查:如果用户自己定义了 bootstrap executor,就不覆盖。 这是 @ConditionalOnMissingBean 的 BFPP 版本—— 因为 BFPP 看不到 @Conditional 的评估结果,只能手工 containsBean。

4.7 上下文传播

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(ContextSnapshot.class)
static class TaskExecutorContextPropagationConfiguration {

    @Bean
    @ConditionalOnProperty(name = "spring.task.execution.propagate-context", havingValue = "true")
    ContextPropagatingTaskDecorator contextPropagatingTaskDecorator() {
        return new ContextPropagatingTaskDecorator();
    }

}

spring.task.execution.propagate-context=true 开启后, ContextPropagatingTaskDecorator 会把当前线程上下文(MDC、Micrometer Observation 等) 传播到任务线程。第 26 章讲链路追踪时说过:异步是 traceId 丢失的头号原因, 这个开关就是解药。

注意条件里用的是 @ConditionalOnProperty(name = ..., havingValue = "true") 而不是 @ConditionalOnProperty(name = ..., matchIfMissing = true)—— 默认关闭,因为传播有成本。

4.8 TaskExecutionProperties 全景

属性默认说明
modeAUTOAUTO / FORCE
propagate-contextfalse上下文传播
thread-name-prefixtask-线程名前缀
pool.queue-capacityInteger.MAX_VALUE队列容量
pool.core-size8核心线程数
pool.max-sizeInteger.MAX_VALUE最大线程数
pool.allow-core-thread-timeouttrue核心线程是否可超时回收
pool.keep-alive60s空闲线程存活时间
pool.shutdown.accept-tasks-after-context-close—上下文关闭后是否还收任务
simple.cancel-remaining-tasks-on-close—关闭时取消剩余任务
simple.reject-tasks-when-limit-reached—达到并发上限时拒绝
simple.concurrency-limit—并发上限
shutdown.await-terminationfalse关闭时等待任务完成
shutdown.await-termination-period—等待上限

pool 与 simple 两套属性并存:只有当前生效的那种执行器会用到对应的一套。 虚拟线程模式下 pool.* 全无意义,simple.* 生效;平台线程模式反之。 属性不因模式而隐藏——切换模式不会导致"属性不存在"的绑定错误。


5. 虚拟线程:一个属性如何改变十几个 bean

5.1 Threading 枚举:两个值,互相定义

public enum Threading {

    /**
     * Platform threads. Active if virtual threads are not active.
     */
    PLATFORM {
        @Override
        public boolean isActive(Environment environment) {
            return !VIRTUAL.isActive(environment);
        }
    },
    /**
     * Virtual threads. Active if {@code spring.threads.virtual.enabled} is {@code true}
     * and running on Java 21 or later.
     */
    VIRTUAL {
        @Override
        public boolean isActive(Environment environment) {
            return environment.getProperty("spring.threads.virtual.enabled", boolean.class, false)
                    && JavaVersion.getJavaVersion().isEqualOrNewerThan(JavaVersion.TWENTY_ONE);
        }
    };

    public abstract boolean isActive(Environment environment);

}

PLATFORM 的定义是 !VIRTUAL.isActive(...)——而不是反过来。 这个方向很关键:它保证了两个枚举值永远互斥且必居其一。 如果各自独立判断(PLATFORM 判 !enabled,VIRTUAL 判 enabled && java21), 那么"enabled=true 但 JDK 17"时两个都为 false—— 没有任何 @ConditionalOnThreading 会匹配,一堆 bean 凭空消失。 用取反定义互斥状态,消除了"都不匹配"的可能。

VIRTUAL.isActive 的两个条件:

  1. spring.threads.virtual.enabled=true
  2. JDK 21+

第 28 章讲过 NativeImageRequirementsException.MINIMUM_REQUIRED_JAVA_VERSION = JavaVersion.TWENTY_FIVE—— 那是 native 的要求;这里是虚拟线程的要求(21)。两者独立。

JavaVersion.getJavaVersion() 的结果被缓存(第 28 章 §1.4 提过), 所以这里每次条件评估不会重复解析 java.version 系统属性。

5.2 ConditionalOnThreading 与条件实现

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional(OnThreadingCondition.class)
public @interface ConditionalOnThreading {

    Threading value();

}
class OnThreadingCondition extends SpringBootCondition {

    @Override
    public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
        Map<String, @Nullable Object> attributes = metadata
            .getAnnotationAttributes(ConditionalOnThreading.class.getName());
        Assert.state(attributes != null, "'attributes' must not be null");
        Threading threading = (Threading) attributes.get("value");
        Assert.state(threading != null, "'threading' must not be null");
        return getMatchOutcome(context.getEnvironment(), threading);
    }

    private ConditionOutcome getMatchOutcome(Environment environment, Threading threading) {
        String name = threading.name();
        ConditionMessage.Builder message = ConditionMessage.forCondition(ConditionalOnThreading.class);
        if (threading.isActive(environment)) {
            return ConditionOutcome.match(message.foundExactly(name));
        }
        return ConditionOutcome.noMatch(message.didNotFind(name).atAll());
    }

}

标准 SpringBootCondition 实现(第 15 章讲过这套基类提供的 ConditionMessage / ConditionOutcome 与条件报告集成)。

Assert.state(attributes != null) 与 Assert.state(threading != null)—— 对元数据做防御性断言。因为 getAnnotationAttributes 可能返回 null(注解不存在时), 而 value() 是必填属性,理论上不会为 null。 这两个断言的代价极低,收益是把"框架内部假设被打破"变成明确错误。

注意它没有做 @ConditionalOnClass 之类的 JDK 版本检查—— 版本判断已经在 Threading.VIRTUAL.isActive 里了。 把环境判断集中在一个地方,条件实现只做转发——职责清晰。

5.3 14 个使用点

全仓库有 14 个文件使用 @ConditionalOnThreading。已知的分布:

位置PLATFORM 分支VIRTUAL 分支
TaskExecutorConfigurations.TaskExecutorConfigurationThreadPoolTaskExecutorSimpleAsyncTaskExecutor
TaskExecutorConfigurations.SimpleAsyncTaskExecutorBuilderConfiguration普通 buildervirtualThreads(true) 的 builder
TaskSchedulingConfigurationsThreadPoolTaskSchedulerBuilderSimpleAsyncTaskSchedulerBuilder
DefaultTaskSchedulerConfigurationThreadPoolTaskSchedulerSimpleAsyncTaskScheduler
OtlpMetricsExportAutoConfiguration(第 26 章)平台线程 registryVirtualThreadTaskExecutor("otlp-meter-registry-")
其余 web / gRPC / 客户端相关各有对应各有对应

(完整 14 处的逐项清单本轮未统计,见 §10。)

一个共性:PLATFORM 分支几乎总是"池化"实现,VIRTUAL 分支总是"Simple"实现。 这不是巧合——虚拟线程的设计前提就是"便宜到不需要池化"。

5.4 为什么虚拟线程不需要池

传统线程池存在的理由是平台线程昂贵(默认栈 1MB,创建/切换需要内核参与)。 所以必须复用有限数量的线程,用队列缓冲任务。

虚拟线程由 JVM 在用户态调度,创建成本接近一个对象分配。 于是正确的用法是:每个任务一个新虚拟线程,跑完就丢。 池化反而有害——它限制了并发度,且让线程局部变量(ThreadLocal)在任务间串味。

这解释了 SimpleAsyncTaskExecutor 在虚拟线程模式下成为首选: 它的语义就是"每次提交都新建线程",恰好匹配虚拟线程的最佳实践。

SimpleAsyncTaskExecutorBuilder.virtualThreads(true) 的作用(推测)是 配置内部的 VirtualThreadTaskExecutor 或设置线程工厂为 Thread.ofVirtual().factory()。第 26 章见过 VirtualThreadTaskExecutor("otlp-meter-registry-").getVirtualThreadFactory()—— 同一个工具类。

待核实:SimpleAsyncTaskExecutorBuilder.virtualThreads(boolean) 的具体实现 本轮未读取(在 spring-boot core 的 task 包,非 autoconfigure)。 可自测:读 org.springframework.boot.task.SimpleAsyncTaskExecutorBuilder。

5.5 虚拟线程的两个陷阱

陷阱一:线程池属性失效。 spring.task.execution.pool.* 在虚拟线程模式下完全不生效(用的是 SimpleAsyncTaskExecutor)。 如果你从平台线程切到虚拟线程,这些配置会"静默失效"—— 不报错,但也不起作用。这是 §4.8 提到的"属性不因模式而隐藏"的反面代价。

陷阱二:synchronized 的 pinning。 在 JDK 21/22 中,虚拟线程在 synchronized 块内被阻塞时会钉住载体线程(pinning), 削弱虚拟线程的优势。JDK 23+ 改进了这一点(JEP 444 之后的调整)。 Spring Boot 无法解决这个问题——它是应用代码层面的事。

待核实:JDK 版本与 pinning 行为的具体关系本轮未核实(属 JDK 变更,非本仓库内容)。


6. Task 调度:@Scheduled 背后的执行器

6.1 TaskSchedulingAutoConfiguration

@ConditionalOnClass(ThreadPoolTaskScheduler.class)
@AutoConfiguration(after = TaskExecutionAutoConfiguration.class)
@EnableConfigurationProperties(TaskSchedulingProperties.class)
@Import({ TaskSchedulingConfigurations.ThreadPoolTaskSchedulerBuilderConfiguration.class,
        TaskSchedulingConfigurations.SimpleAsyncTaskSchedulerBuilderConfiguration.class,
        TaskSchedulingConfigurations.TaskSchedulerConfiguration.class })
public final class TaskSchedulingAutoConfiguration {

    @Bean
    @ConditionalOnBean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME)
    static LazyInitializationExcludeFilter scheduledBeanLazyInitializationExcludeFilter() {
        return new ScheduledBeanLazyInitializationExcludeFilter();
    }

}

@AutoConfiguration(after = TaskExecutionAutoConfiguration.class)—— 调度配置必须在执行配置之后,因为调度器可能需要执行器。

唯一一个 @Bean 是 LazyInitializationExcludeFilter:

@Bean
@ConditionalOnBean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME)
static LazyInitializationExcludeFilter scheduledBeanLazyInitializationExcludeFilter() {
    return new ScheduledBeanLazyInitializationExcludeFilter();
}

它解决一个很实际的矛盾:

  • spring.main.lazy-initialization=true 时,所有 bean 延迟创建。
  • 但 @Scheduled 的 bean 必须在启动时就被创建,否则定时任务永远不会注册 (ScheduledAnnotationBeanPostProcessor 只在 bean 创建时扫描 @Scheduled)。

于是这个 LazyInitializationExcludeFilter 把带 @Scheduled 的 bean 排除出懒加载。 懒加载与"必须早期实例化"的冲突,用排除过滤器解决。

@ConditionalOnBean(name = SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME)—— 只有真的启用了调度(@EnableScheduling 导致处理器 bean 存在)时才需要这个过滤器。

ScheduledBeanLazyInitializationExcludeFilter 的实现推测是检查 bean 类型上 是否有 @Scheduled 方法。

待核实:该过滤器如何判定"带 @Scheduled 的 bean"(是看方法注解还是类) 本轮未读取。可自测:读 ScheduledBeanLazyInitializationExcludeFilter.java。

6.2 TaskSchedulingConfigurations:与执行侧同构

@Configuration(proxyBeanMethods = false)
static class TaskSchedulerConfiguration {

    @Bean
    @ConditionalOnBean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME)
    @ConditionalOnMissingBean({ TaskScheduler.class, ScheduledExecutorService.class })
    @ConditionalOnThreading(Threading.PLATFORM)
    ThreadPoolTaskScheduler taskScheduler(ThreadPoolTaskSchedulerBuilder builder) { ... }

    @Bean
    @ConditionalOnBean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME)
    @ConditionalOnMissingBean({ TaskScheduler.class, ScheduledExecutorService.class })
    @ConditionalOnThreading(Threading.VIRTUAL)
    SimpleAsyncTaskScheduler taskSchedulerVirtualThreads(SimpleAsyncTaskSchedulerBuilder builder) { ... }

}

(本轮读取的是 grep 出的行号与结构,方法体的完整签名以上述为准。)

三个条件的组合:

  1. @ConditionalOnBean(name = SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME): 只有启用调度才创建——没有 @EnableScheduling 就不浪费一个调度器。
  2. @ConditionalOnMissingBean({ TaskScheduler.class, ScheduledExecutorService.class }): 两个类型都算替身。因为用户可能提供 JDK 原生的 ScheduledExecutorService。
  3. @ConditionalOnThreading:线程模型分支。

6.3 DefaultTaskSchedulerConfiguration:给第三方复用的配置

/**
 * ...exposed if the user enables virtual threads via
 * {@code spring.threads.virtual.enabled=true}, otherwise {@link ThreadPoolTaskScheduler}.
 * <p>
 * Configurations importing this one should be ordered after
 * {@link TaskSchedulingAutoConfiguration}.
 */
@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(name = DefaultTaskSchedulerConfiguration.DEFAULT_TASK_SCHEDULER_BEAN_NAME)
public class DefaultTaskSchedulerConfiguration {

    public static final String DEFAULT_TASK_SCHEDULER_BEAN_NAME = TaskSchedulerRouter.DEFAULT_TASK_SCHEDULER_BEAN_NAME;

    @Bean(name = DEFAULT_TASK_SCHEDULER_BEAN_NAME)
    @ConditionalOnBean(ThreadPoolTaskSchedulerBuilder.class)
    @ConditionalOnThreading(Threading.PLATFORM)
    ThreadPoolTaskScheduler taskScheduler(ThreadPoolTaskSchedulerBuilder threadPoolTaskSchedulerBuilder) {
        return threadPoolTaskSchedulerBuilder.build();
    }

    @Bean(name = DEFAULT_TASK_SCHEDULER_BEAN_NAME)
    @ConditionalOnBean(SimpleAsyncTaskSchedulerBuilder.class)
    @ConditionalOnThreading(Threading.VIRTUAL)
    SimpleAsyncTaskScheduler taskSchedulerVirtualThreads(
            SimpleAsyncTaskSchedulerBuilder simpleAsyncTaskSchedulerBuilder) {
        return simpleAsyncTaskSchedulerBuilder.build();
    }

}

这是 since 4.1.0 的新类,而且是 public 的(对比其它内部配置类都是 package-private)。

用途:给需要 taskScheduler 这个名字的其它模块复用。 TaskSchedulerRouter.DEFAULT_TASK_SCHEDULER_BEAN_NAME 是 Spring Framework 7 引入的常量—— Spring Framework 7 支持"按名字找默认调度器"的路由机制, Spring Boot 负责提供这个 bean。

条件用的是 @ConditionalOnBean(XXXBuilder.class): builder 存在就创建,builder 不存在(用户没引入对应自动配置)就不创建。 这比 @ConditionalOnClass 更精确——它反映的是"这个应用上下文里确实有这个 builder"。

类级 @ConditionalOnMissingBean(name = DEFAULT_TASK_SCHEDULER_BEAN_NAME)—— 如果用户已经定义了同名 bean,整个配置类都不生效。 用名字而不是类型,是因为这里关心的是特定名字的 bean(路由机制按名字找)。

这是"@ConditionalOnMissingBean 按名判断"的少见用例——多数地方按类型。 差异的原因正是消费方(Spring Framework 的路由)按名查找。

6.4 TaskSchedulingProperties

调度侧的属性与执行侧结构类似(pool / simple / shutdown), 但多了调度特有的项:

  • thread-name-prefix 默认 scheduling-
  • pool.size(注意:调度器的池大小语义不同——线程池调度器通常只需要很小的池)
  • shutdown.await-termination 等

调度器的线程池不需要大:定时任务数量有限,且 ThreadPoolTaskScheduler 默认池大小是 1(多个任务排队执行)。这与执行器的 core-size=8 形成对比。

待核实:TaskSchedulingProperties 的完整字段与默认值本轮未逐行读取。 可自测:读该类的字段声明(Javadoc 里有默认值说明)。

6.5 执行器与调度器的关系

两者是独立的两套:

TaskExecutionAutoConfiguration  → applicationTaskExecutor   (@Async、bootstrap)
TaskSchedulingAutoConfiguration → taskScheduler             (@Scheduled)

虚拟线程模式下,两者都变成 Simple* 实现,但仍然是两个不同的 bean。 TaskSchedulingAutoConfiguration 的 after = TaskExecutionAutoConfiguration.class 只是顺序约束,不是依赖。

一个常见误解是"虚拟线程下两者会合并"——不会, 因为它们的类型不同(TaskExecutor vs TaskScheduler),职责也不同。

6.6 与 AOT / native 的关系

虚拟线程在 native image 中的支持是 GraalVM 的一部分。 第 28 章讲过 NativeImageRequirementsException 要求 JDK 25—— JDK 25 > 21,所以 native 应用天然满足虚拟线程的 JDK 要求。 换句话说:native 应用可以无门槛开启 spring.threads.virtual.enabled=true。

待核实:GraalVM 对虚拟线程的具体支持状态(是否有额外 hint 需求)本轮未核实。 这属于 GraalVM 侧的能力,非本仓库内容。

6.7 优雅关闭:四个"关闭相关"属性的语义差异

Task 体系里有四个看起来很像、实则完全不同的关闭属性。混淆它们是生产事故的常见来源。

(一)spring.task.execution.shutdown.await-termination(默认 false)

上下文关闭时是否等待已提交的任务跑完。 它在 builder 里的落点不同:

// ThreadPoolTaskExecutorBuilder
builder = builder.awaitTermination(shutdown.isAwaitTermination());
builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod());

// SimpleAsyncTaskExecutorBuilder
if (shutdown.isAwaitTermination()) {
    builder = builder.taskTerminationTimeout(shutdown.getAwaitTerminationPeriod());
}

注意 Simple 侧的 if:只有在 awaitTermination 为 true 时才设 timeout。 为什么?因为 taskTerminationTimeout 的默认语义是"不等待", 传 null(未配置 period)进去可能导致非预期的立即终止。 用 if 把"开关"和"时长"绑定,避免出现"没开等待却设了时长"的矛盾状态。

(二)spring.task.execution.simple.cancel-remaining-tasks-on-close

关闭时是否取消还没跑完的任务。与"等待"是相反的策略: 等待是"让它跑完",取消是"立刻中断"。 对虚拟线程来说,取消意味着中断大量虚拟线程—— SimpleAsyncTaskExecutor 会逐个 interrupt()。

(三)spring.task.execution.simple.reject-tasks-when-limit-reached

达到 concurrency-limit 时是拒绝(抛异常)还是排队。 这不是关闭属性,但与"限流"相关: 虚拟线程模式下它是唯一的并发保护手段(§11.3 坑 2)。

(四)spring.task.execution.pool.shutdown.accept-tasks-after-context-close

上下文已关闭后是否还接受新任务。默认应该是不接受—— 这个属性存在的意义是允许某些"必须完成"的收尾任务被提交 (比如 Actuator 的优雅关闭期间)。第 25 章讲 Actuator 的优雅关闭时提到过类似需求。

四个属性的组合矩阵(生产建议):

场景await-terminationcancel-remaining说明
批处理任务true + 合理 periodfalse让任务跑完
请求型异步true + 短 periodtrue快速释放
虚拟线程true + period视情况中断成本低

关键提醒:await-termination=true 但没有 await-termination-period 时, 会无限等待。生产环境必须同时配置 period,否则 JVM 可能永不退出。


7. Retry:Spring Framework 7 的新 API 与 Boot 的属性桥接

7.1 背景:从 Spring Retry 到 spring-core

Spring Boot 4.1 的 retry 支持基于 org.springframework.core.retry—— 这是 Spring Framework 7 引入的新包(RetryPolicy、RetryTemplate、RetryListener)。

这是一个重要的变化:以前 Spring Boot 的重试靠独立的 Spring Retry 项目 (spring-retry,@Retryable、RetryTemplate in org.springframework.retry)。 现在核心能力下沉到了 spring-core。

带来的直接好处:

  • 无需额外依赖(spring-core 必然在 classpath)
  • API 更简洁(RetryPolicy 是不可变的值对象,用 builder 构造)
  • 与 @Retryable 的关系变了(Spring Framework 7 有自己的注解支持)

待核实:org.springframework.core.retry 是否完整替代 spring-retry、 以及 Spring Boot 是否仍提供对 spring-retry 的自动配置,本轮未核实。 本仓库主源码里没有 org.springframework.retry 的引用(grep 结果为空), 这是一个强信号但不足以作为结论。

7.2 RetryPolicySettings:一个"属性 → 值对象"的桥

public final class RetryPolicySettings {

    /**
     * Default number of retry attempts.
     */
    public static final long DEFAULT_MAX_RETRIES = RetryPolicy.Builder.DEFAULT_MAX_RETRIES;

    /**
     * Default initial delay.
     */
    public static final Duration DEFAULT_DELAY = Duration.ofMillis(RetryPolicy.Builder.DEFAULT_DELAY);

    /**
     * Default multiplier, uses a fixed delay.
     */
    public static final double DEFAULT_MULTIPLIER = RetryPolicy.Builder.DEFAULT_MULTIPLIER;

    /**
     * Default maximum delay (infinite).
     */
    public static final Duration DEFAULT_MAX_DELAY = Duration.ofMillis(RetryPolicy.Builder.DEFAULT_MAX_DELAY);

    private List<Class<? extends Throwable>> exceptionIncludes = new ArrayList<>();

    private List<Class<? extends Throwable>> exceptionExcludes = new ArrayList<>();

    private @Nullable Predicate<Throwable> exceptionPredicate;

    private Long maxRetries = DEFAULT_MAX_RETRIES;

    private Duration delay = DEFAULT_DELAY;

    private @Nullable Duration jitter;

    private Double multiplier = DEFAULT_MULTIPLIER;

    private Duration maxDelay = DEFAULT_MAX_DELAY;

    private @Nullable Function<Builder, RetryPolicy> factory;

    public RetryPolicy createRetryPolicy() {
        PropertyMapper map = PropertyMapper.get();
        RetryPolicy.Builder builder = RetryPolicy.builder();
        map.from(this::getExceptionIncludes).to(builder::includes);
        map.from(this::getExceptionExcludes).to(builder::excludes);
        map.from(this::getExceptionPredicate).to(builder::predicate);
        map.from(this::getMaxRetries).to(builder::maxRetries);
        map.from(this::getDelay).to(builder::delay);
        map.from(this::getJitter).to(builder::jitter);
        map.from(this::getMultiplier).to(builder::multiplier);
        map.from(this::getMaxDelay).to(builder::maxDelay);
        return (this.factory != null) ? this.factory.apply(builder) : builder.build();
    }

}

设计要点:

  1. 所有默认值都从 RetryPolicy.Builder 的常量推导, 而不是自己写死数字。这样 Framework 改默认值时 Boot 自动跟随—— 默认值单点定义(第 24 章讲日志时见过同一条纪律)。

  2. PropertyMapper 而不是直接赋值:map.from(...).to(...) 只在源值非 null 时才调用目标设置方法。 这样"用户没配"就不会把 builder 的默认值覆盖成 null。 PropertyMapper 是 Spring Boot 内部的标准工具(第 8 章讲 Binder 时提过)。

  3. factory 字段是逃生舱:

    return (this.factory != null) ? this.factory.apply(builder) : builder.build();
    

    如果用户设了 factory,就把它当"最终决策者"—— 拿到已经填好属性的 builder,想怎么改怎么改,甚至完全忽略属性自己造一个。 这是"配置属性 + 编程式覆盖"的标准组合: 属性覆盖 90% 场景,Function<Builder, RetryPolicy> 覆盖剩下 10%。

  4. exceptionIncludes / exceptionExcludes / exceptionPredicate 三种机制并存:

    • includes:只对列出的异常重试
    • excludes:列出的异常不重试
    • predicate:任意逻辑

    三者语义有重叠,但适用场景不同:前两个适合配置驱动(spring.rabbitmq.template.retry.???), 第三个适合代码驱动。同时提供"配置友好"与"代码友好"两种入口,是本仓库的一贯做法。

  5. setExceptionIncludes 的实现是 this.exceptionIncludes = new ArrayList<>(includes)—— 防御性拷贝,防止调用方后续修改列表影响内部状态。 而 Javadoc 又明确说"Alternatively consider using getExceptionIncludes() to mutate the existing list"—— 既提供 setter 也提供可变 getter,两种风格都支持。

7.3 震颤(jitter)与指数退避

delay + multiplier + maxDelay + jitter 四个参数构成标准退避模型:

第 n 次重试的延迟 = min(delay * multiplier^(n-1), maxDelay) ± jitter
  • DEFAULT_MULTIPLIER 的 Javadoc 说"uses a fixed delay"——即默认 multiplier 为 1, 默认行为是固定延迟重试而不是指数退避。这是一个保守的默认: 固定延迟在大多数场景下足够,且不会把延迟拉得过长。
  • DEFAULT_MAX_DELAY 是"infinite"(Long.MAX_VALUE 毫秒)。
  • jitter 默认 null(不加随机扰动)。

为什么需要 jitter? 在分布式系统里,如果大量客户端同时失败并以相同的退避序列重试, 会产生重试风暴(thundering herd)。加入随机扰动把重试时间打散。 这是一个"不配也能用,配了更好"的参数。

7.4 AMQP 集成:RetryPolicySettings 的实际用法

spring-boot-amqp 是目前唯一使用 RetryPolicySettings 的模块。两处:

(一)RabbitTemplate 的重试

protected RetryTemplate createRetryTemplate(RabbitProperties.Retry properties) {
    RabbitRetryTemplateSettings retrySettings = new RabbitRetryTemplateSettings(
            properties.initializeRetryPolicySettings());
    if (this.retrySettingsCustomizers != null) {
        for (RabbitTemplateRetrySettingsCustomizer customizer : this.retrySettingsCustomizers) {
            customizer.customize(retrySettings);
        }
    }
    RetryPolicy retryPolicy = retrySettings.getRetryPolicySettings().createRetryPolicy();
    RetryListener retryListener = createRetryListener(retrySettings.getRetryListeners());
    RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
    if (retryListener != null) {
        retryTemplate.setRetryListener(retryListener);
    }
    return retryTemplate;
}

五步:属性 → settings → customizer 回调 → RetryPolicy → RetryTemplate。

RabbitRetryTemplateSettings 是一个可变的设置载体:

public class RabbitRetryTemplateSettings {

    private final RetryPolicySettings retryPolicySettings;

    private final LinkedList<RetryListener> retryListeners;

    /**
     * Return the list of {@link RetryListener} instances. The returned list is mutable
     * and can be used to add listeners.
     * @return the retry listeners
     */
    public LinkedList<RetryListener> getRetryListeners() {
        return this.retryListeners;
    }

}

注意 retryListeners 用 LinkedList 且 Javadoc 明确说"the returned list is mutable"。 为什么用 LinkedList 而不是 ArrayList?推测是为了让自定义者可以 addFirst(优先级最高的监听器放最前)。 集合类型的选择暴露了使用意图——这是一个值得学习的细节。

多个 listener 的合成:

private @Nullable RetryListener createRetryListener(List<RetryListener> configuredListeners) {
    if (configuredListeners.size() > 1) {
        return new CompositeRetryListener(configuredListeners);
    }
    ...
}

(后续分支本轮未读取,推测是 size() == 1 返回唯一元素、0 返回 null。)

CompositeRetryListener 与 §4.5 的 CompositeTaskDecorator 是同族—— **"多个同类型扩展点合成一个"**是自动配置的通用模式(§8 会总结)。

(二)RabbitListener 容器的重试

@FunctionalInterface
public interface RabbitListenerRetrySettingsCustomizer {

    /**
     * Callback to customize the {@link RetryPolicySettings} to create the retry policy.
     * @param retrySettings the retry settings to customize
     */
    void customize(RetryPolicySettings retrySettings);

}

另一个 customizer,作用在监听器容器上。 AbstractRabbitListenerContainerFactoryConfigurer 里:

RetryPolicySettings retrySettings = retryProperties.initializeRetryPolicySettings();

两处共用同一个 initializeRetryPolicySettings()—— 属性 → settings 的转换逻辑在 RabbitProperties 里定义一次,两处复用。

ListenerRetry 额外的属性:

public static class ListenerRetry extends Retry {

    /**
     * Whether retries are stateless or stateful.
     */
    private boolean stateless = true;

}

stateless(默认 true):无状态重试意味着重试在容器线程内进行(不影响消息顺序); 有状态重试则要求消息重新入队(会破坏顺序但能跨消费者重试)。 默认无状态——因为破坏消息顺序的代价通常大于重试失败。

7.5 initializeRetryPolicySettings:属性名映射

RetryPolicySettings initializeRetryPolicySettings() {
    PropertyMapper map = PropertyMapper.get();
    RetryPolicySettings settings = new RetryPolicySettings();
    map.from(this::getMaxRetries).to(settings::setMaxRetries);
    map.from(this::getInitialInterval).to(settings::setDelay);
    map.from(this::getMultiplier).to(settings::setMultiplier);
    map.from(this::getMaxInterval).to(settings::setMaxDelay);
    return settings;
}

属性名的一次重命名:

AMQP 属性(旧命名)RetryPolicySettings(通用)
max-attempts / max-retriesmaxRetries
initial-intervaldelay
multipliermultiplier
max-intervalmaxDelay

为什么要映射?因为 spring.rabbitmq.*.retry.* 的属性名是历史遗留的, 不能改(改了就破坏兼容性);而 RetryPolicySettings 是新的通用抽象, 用的是 Framework 的术语。

在边界处做一次命名转换,让历史属性名与新抽象共存—— 这是兼容性与整洁性之间的标准妥协。

7.6 现状观察:为什么只有 AMQP

本仓库主源码里,RetryPolicySettings 只在 spring-boot-amqp 被使用。 这说明 Spring Framework 7 的 retry API 在 Spring Boot 4.1 中尚处于逐步接入阶段: AMQP 是第一个吃螃蟹的模块(因为 AMQP 的重试配置历史包袱最重,改造收益最大)。

可以预期后续版本会把 HTTP 客户端、@Retryable 支持等也统一到这套抽象上。

这是基于当前源码的观察与推测,不是官方文档声明。


8. 三条线的共同模式

回头看 SSL、Task、Retry 三块,它们的结构高度一致:

SSLTaskRetry
抽象SslBundleTaskExecutor / TaskSchedulerRetryPolicy
来源SslBundleRegistry(注册表)Builder(构建器)RetryPolicySettings(设置对象)
属性spring.ssl.bundle.*spring.task.*各模块自己(如 spring.rabbitmq.*.retry.*)
扩展点SslBundleRegistrar(注册新 bundle)*Customizer + TaskDecorator(改配置)*SettingsCustomizer(改设置)
动态能力热重载(updateBundle)无无

三条共同的设计纪律:

(一)抽象与来源分离。 SslBundle 不知道自己是来自属性、文件还是 Vault; TaskExecutor 不知道自己是 builder 造的还是用户定义的。 消费方只依赖抽象,来源可替换。

(二)扩展点用"回调改可变设置对象"而不是"继承覆盖方法"。 SslBundleRegistrar.registerBundles(registry)、 ThreadPoolTaskExecutorCustomizer.customize(builder)、 RabbitTemplateRetrySettingsCustomizer.customize(settings)—— 三者都是"给你一个可变对象,你改它"。 组合优于继承,且扩展点之间不冲突(可以叠加多个 customizer)。

(三)属性默认值从下游抽象推导。 RetryPolicySettings.DEFAULT_MAX_RETRIES = RetryPolicy.Builder.DEFAULT_MAX_RETRIES; Threading.VIRTUAL 的 JDK 要求与 Framework 一致; SSL 的 DEFAULT_PROTOCOL = "TLS". Boot 不重复定义默认值,避免两边漂移。


9. SSL 的落地:从注册表到服务器套接字

前面讲了 SslBundle 是怎么被造出来和注册的,这一节看它怎么被用掉。

9.1 Web 服务器持有的是 SslBundles,不是 SslBundle

// AbstractConfigurableWebServerFactory
private @Nullable SslBundles sslBundles;

/**
 * Return the configured {@link SslBundles}.
 * @return the {@code SslBundles} or {@code null}
 */
public @Nullable SslBundles getSslBundles() {
    return this.sslBundles;
}

public void setSslBundles(@Nullable SslBundles sslBundles) {
    this.sslBundles = sslBundles;
}

protected final Map<String, SslBundle> getServerNameSslBundles() { ... }

工厂持有的是整个注册表(SslBundles),而不是某一个 bundle。

为什么?因为 SNI(Server Name Indication)。 一个 HTTPS 服务器可能要为多个域名提供不同的证书—— 握手时客户端告知目标域名,服务器按域名选证书。 所以服务器需要的是"名字 → bundle"的映射能力,而不只是一份材料。

getServerNameSslBundles() 返回 Map<String, SslBundle>—— 这就是 SNI 映射。它的 key 来自 server.ssl.server-name-bundles.* 之类的配置 (具体属性名本轮未读取,见 §11)。

如果一开始把"服务器 SSL 配置"设计成"一个 bundle",SNI 就无从表达。 这个设计选择体现了 §1.1 说的"抽象要为未来留余地"。

9.2 注入链路

SslAutoConfiguration  → DefaultSslBundleRegistry(实现 SslBundles)
        ↓ ObjectProvider<SslBundles>
ServletWebServerConfiguration / ReactiveWebServerConfiguration
        ↓ 传给
ServletWebServerFactoryCustomizer / ReactiveWebServerFactoryCustomizer
        ↓ map.from(() -> this.sslBundles).to(factory::setSslBundles)
AbstractConfigurableWebServerFactory.setSslBundles(...)

关键点:

  1. ObjectProvider<SslBundles> 而不是直接注入 SslBundles—— SslAutoConfiguration 可能被排除(用户不要 SSL 支持),此时 provider 为空。 用 @Nullable SslBundles 接收(customizer 构造器里就是 @Nullable)。
  2. PropertyMapper 做 null 安全赋值: map.from(() -> this.sslBundles).to(factory::setSslBundles)—— 为 null 时不调用 setter(§7.2 讲过这个语义)。
  3. servlet 与 reactive 两条对称的链路——第 18 章讲 web 服务器时见过同样的对称结构。

9.3 健康检查:证书过期会被报告出来

@AutoConfiguration(before = HealthContributorAutoConfiguration.class)
@ConditionalOnClass(Health.class)
@ConditionalOnEnabledHealthIndicator("ssl")
@EnableConfigurationProperties(SslHealthIndicatorProperties.class)
public final class SslHealthContributorAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean(name = "sslHealthIndicator")
    SslHealthIndicator sslHealthIndicator(SslInfo sslInfo, SslHealthIndicatorProperties properties) {
        return new SslHealthIndicator(sslInfo, properties.getCertificateValidityWarningThreshold());
    }

    @Bean
    @ConditionalOnMissingBean
    SslInfo sslInfo(SslBundles sslBundles) {
        return new SslInfo(sslBundles);
    }

}

两个 bean:

  • SslInfo:从 SslBundles 提取所有证书的元信息(有效期、颁发者等)。 它依赖的是 SslBundles(注册表)—— 能自动发现所有 bundle,不需要逐个配置。这是注册表抽象的又一次红利。
  • SslHealthIndicator:把 SslInfo 转成健康状态, 用 certificateValidityWarningThreshold(证书有效期警告阈值)判断。

@AutoConfiguration(before = HealthContributorAutoConfiguration.class)—— 必须在健康端点聚合所有指示器之前注册,否则这个指示器不会被端点看到。 第 25 章讲健康端点时提到过这类顺序约束。

@ConditionalOnEnabledHealthIndicator("ssl") 是第 25 章讲过的统一开关 (management.health.ssl.enabled)。

"证书即将过期"变成了一个可被监控系统消费的健康状态—— 这是把运维经验固化进框架的典型例子。 没有它,证书过期只会表现为某天突然全线握手失败。

9.4 指标:SslMeterBinder

module/spring-boot-micrometer-metrics/.../autoconfigure/ssl/SslMeterBinder.java (本轮未读取实现)把证书有效期暴露为 Micrometer 指标, 与第 26 章的指标体系对接。

待核实:SslMeterBinder 暴露的具体指标名与单位未读取。 可自测:启动一个配了 SSL bundle 的应用,看 /actuator/metrics 列表。

9.5 三方客户端的落地

Kafka、Redis、MongoDB、Cassandra、Couchbase、Elasticsearch、gRPC、Mail、LDAP 各自的自动配置都是同一个模式:

// 伪代码
public SomeClientConfiguration(SslBundles sslBundles, SomeProperties properties) {
    SslBundle bundle = (properties.getSsl().getBundle() != null)
        ? sslBundles.getBundle(properties.getSsl().getBundle())
        : null;
    ...
}

即:属性里写 bundle 名,自动配置从注册表里取。 如果名字写错,getBundle 抛 NoSuchSslBundleException(§1.7)—— 启动期失败并指出名字,而不是运行期握手失败。

待核实:上述伪代码是模式归纳,各模块的实际取用方式(是否都是属性里写名字) 本轮未逐个核实。已知 PropertiesRabbitConnectionDetails、 PropertiesDataRedisConnectionDetails、PropertiesKafkaConnectionDetails 都引用了 SslBundles,说明"ConnectionDetails + SSL"是组合使用的。


10. Task 的装饰与编排:三个"多个变一个"

Task 体系里有三处"把多个同类型扩展点合成一个"的逻辑,值得放在一起看。

10.1 getTaskDecorator:0 / 1 / 多 三种情况

private static @Nullable TaskDecorator getTaskDecorator(ObjectProvider<TaskDecorator> taskDecorator) {
    List<TaskDecorator> taskDecorators = taskDecorator.orderedStream().toList();
    if (taskDecorators.size() == 1) {
        return taskDecorators.get(0);
    }
    return (!taskDecorators.isEmpty()) ? new CompositeTaskDecorator(taskDecorators) : null;
}

三种情况分别处理:

  • 0 个 → 返回 null(builder 接受 null,表示不装饰)
  • 1 个 → 直接返回它,不包装
  • 多个 → CompositeTaskDecorator

为什么 1 个时不包装?省一层调用。 TaskDecorator.decorate(Runnable) 在每次任务提交时被调用, 是热路径。虽然一层委托的开销很小,但在"每任务都调用"的位置, 能省就省是合理的。

这个"0/1/多"的三分支写法在本仓库反复出现—— 第 26 章的 ExemplarContextProvider、这里的 TaskDecorator、 §7.4 的 RetryListener 都是。 统一的模式:空则 null,单则直用,多则合成。

10.2 ContextPropagatingTaskDecorator

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(ContextSnapshot.class)
static class TaskExecutorContextPropagationConfiguration {

    @Bean
    @ConditionalOnProperty(name = "spring.task.execution.propagate-context", havingValue = "true")
    ContextPropagatingTaskDecorator contextPropagatingTaskDecorator() {
        return new ContextPropagatingTaskDecorator();
    }

}

ContextSnapshot 是 Micrometer Context Propagation 的类型 (io.micrometer.context.ContextSnapshot)。 ContextPropagatingTaskDecorator 在任务提交时拍一个上下文快照, 在任务线程里恢复它。

这解决的是异步场景下 traceId / MDC / 安全上下文丢失的问题—— 第 26 章讲链路追踪时说过,异步是 traceId 断链的头号原因。

注意 @ConditionalOnClass(ContextSnapshot.class) + @ConditionalOnProperty 双条件: 类在 classpath 上且用户显式开启才生效。

10.3 ApplicationTaskExecutorAsyncConfigurer:委托链

static class ApplicationTaskExecutorAsyncConfigurer implements AsyncConfigurer {

    private final BeanFactory beanFactory;

    private final @Nullable AsyncConfigurer delegate;

    @Override
    public Executor getAsyncExecutor() {
        Executor executor = (this.delegate != null) ? this.delegate.getAsyncExecutor() : null;
        return (executor != null) ? executor : getApplicationTaskExecutor();
    }

    @Override
    public @Nullable AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (this.delegate != null) ? this.delegate.getAsyncUncaughtExceptionHandler() : null;
    }

    private Executor getApplicationTaskExecutor() {
        return this.beanFactory.getBean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME,
                Executor.class);
    }

}

getAsyncExecutor 的优先级:用户定义的 → applicationTaskExecutor。 getAsyncUncaughtExceptionHandler 则只用用户定义的(没有就没有)。 两个方法策略不同——因为执行器有合理默认值,异常处理器没有。

getApplicationTaskExecutor() 用 beanFactory.getBean(name, type) 按名取—— 而不是按类型。因为可能有多个 Executor bean,按名取才能精确定位 applicationTaskExecutor。

延迟查找(每次调用才 getBean)而不是构造时注入—— 避免了 AsyncConfigurer 与 applicationTaskExecutor 之间的循环依赖风险。 AsyncConfigurer 通常在早期就被需要,如果构造时就要拿到执行器 bean, 可能触发过早初始化。

10.4 虚拟线程与 ThreadLocal

虚拟线程让 ThreadLocal 的代价变得微妙:

  • 好消息:虚拟线程数量可以极大(百万级), 每个虚拟线程有自己的 ThreadLocal 不再是内存灾难……吗? 实际上恰恰相反:如果每个虚拟线程都持有 ThreadLocal 副本, 百万个虚拟线程 × 每个几个 ThreadLocal = 内存压力巨大。
  • 坏消息:ThreadPoolTaskExecutor 时代"线程复用导致 ThreadLocal 串味"的问题 在虚拟线程下消失了(每个任务一个新线程,天然隔离), 但"ThreadLocal 内存占用"问题被放大。

JDK 的应对是 ScopedValue(JEP 446,预览特性)—— 它是为虚拟线程设计的"不可变、有作用域"的上下文载体。 Micrometer Context Propagation 与 Spring 的上下文传播机制 正在向 ScopedValue 演进。

待核实:Spring Boot 4.1 的 ContextPropagatingTaskDecorator 是否已支持 ScopedValue 本轮未核实。可自测:搜 ScopedValue 在本仓库的使用。

实践建议:虚拟线程下优先使用显式传参或 ScopedValue, 谨慎使用大对象的 ThreadLocal;spring.task.execution.propagate-context=true 虽然能解决正确性问题,但要注意传播的内容规模。

10.5 异步异常的静默丢失:AsyncUncaughtExceptionHandler

回到 §10.3 的那行代码:

@Override
public @Nullable AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
    return (this.delegate != null) ? this.delegate.getAsyncUncaughtExceptionHandler() : null;
}

用户没定义 AsyncConfigurer 时,异常处理器就是 null。

后果是什么?@Async 方法抛出的异常不会被记录(Spring Framework 的默认行为是 交给 SimpleAsyncUncaughtExceptionHandler,它会记一条日志—— 但前提是 AsyncConfigurer.getAsyncUncaughtExceptionHandler() 返回的处理器被使用; 返回 null 时 Framework 用自己的默认)。

待核实:返回 null 时 Spring Framework 的确切行为(是否回退到 SimpleAsyncUncaughtExceptionHandler)本轮未读取,属 Framework 侧逻辑。 可自测:写一个抛异常的 @Async 方法,观察日志输出。

无论细节如何,有一点是确定的:异步方法里的异常不会传播到调用方。 这是异步编程的固有性质——调用方早已返回,栈已不在。

实践建议:

  1. @Async 方法要么返回 CompletableFuture(异常通过 future 传递), 要么内部 try/catch 并记录。
  2. 需要统一处理时实现 AsyncConfigurer.getAsyncUncaughtExceptionHandler()—— 此时它会被包装进 ApplicationTaskExecutorAsyncConfigurer 并生效(§10.3 的 delegate 分支)。
  3. 注意 ApplicationTaskExecutorAsyncConfigurer 只保留处理器, 执行器仍可能回退到 applicationTaskExecutor—— 两个方法的委托策略不同(§10.3),这是有意的:处理器没有合理默认值,执行器有。

11. 三个端到端场景

理论讲完了,把三条线各走一遍完整的链路。每个场景都给出"配置 → 涉及的源码 → 会踩的坑"。

11.1 场景一:内嵌服务器用 PEM 证书 + 热重载

配置:

spring:
  ssl:
    bundle:
      pem:
        web:
          reload-on-update: true
          keystore:
            certificate: /etc/ssl/certs/server.crt
            private-key: /etc/ssl/private/server.key
            verify-keys: true
          truststore:
            certificate: /etc/ssl/certs/ca.crt
      watch:
        file:
          quiet-period: 15s
server:
  ssl:
    bundle: web
    server-name-bundles:            # SNI
      "*.other.example": other

链路:

  1. SslProperties 绑定 spring.ssl.bundle.pem.web(PemSslBundleProperties)
  2. SslAutoConfiguration 创建 FileWatcher(quietPeriod = 15s)、 SslPropertiesBundleRegistrar、DefaultSslBundleRegistry
  3. SslPropertiesBundleRegistrar.registerBundles 遍历 pem map, 调 PropertiesSslBundle.get(props, resourceLoader) 造 bundle
  4. reload-on-update=true → 计算 watchedPemPaths(4 个属性)→ fileWatcher.watch(...)
  5. ServletWebServerFactoryCustomizer 拿到 SslBundles → factory.setSslBundles(...)
  6. 启动时 AbstractConfigurableWebServerFactory 按 server.ssl.bundle 取 web, 按 server-name-bundles 建 SNI 映射
  7. SslHealthContributorAutoConfiguration 用同一个注册表建 SslInfo → 健康指示器

会踩的坑:

  • 坑 1:certificate 写成内联 PEM 时热重载失败。 BundleContentProperty.toWatchPath 无法把 PEM 文本转成 Path, 抛 BundleContentNotWatchableException,由 BundleContentNotWatchableFailureAnalyzer 转成可读错误(§3.5)。要热重载就必须用文件路径。
  • 坑 2:Kubernetes secret 挂载看不到更新。 这是 §3.4 讲过的 symlink 问题——已经被 collectRegistrationPaths 处理了。 但如果你把 secret 挂载成子路径(subPath),symlink 链不存在, K8s 也不会更新它——那是 K8s 的限制,不是 Spring Boot 的问题。
  • 坑 3:私钥与证书不匹配。 默认 verify-keys=false,不匹配也能启动,然后握手失败。 生产环境建议开 verify-keys: true(§2.1)。
  • 坑 4:热重载后已有连接不受影响。 updateBundle 只换配置,已建立的 TLS 连接仍用旧 SSLContext。 新连接才用新证书。这是 TLS 的固有行为,不是 bug。

11.2 场景二:RabbitMQ 与 HTTP 客户端共用同一份信任材料

配置:

spring:
  ssl:
    bundle:
      jks:
        internal-ca:
          truststore:
            location: classpath:internal-ca.jks
            password: changeit
            type: JKS
rabbitmq:
  ssl:
    enabled: true
    bundle: internal-ca

链路:

  1. spring.ssl.bundle.jks.internal-ca → JksSslBundleProperties → JksSslStoreBundle
  2. 注册到 DefaultSslBundleRegistry(名字 internal-ca)
  3. RabbitAutoConfiguration 注入 SslBundles, RabbitConnectionFactoryBeanConfigurer 按 rabbitmq.ssl.bundle 取 bundle, 用 getManagers().getTrustManagerFactory() 配置连接工厂

要点:

  • 同一个 bundle 可以被任意多个客户端引用,这是 §1.1 的核心价值。 CA 证书换一次,所有引用方同时生效。
  • truststore 只有信任材料(没有私钥),所以这个 bundle 不能用作服务器证书 ——服务器需要 keystore。
  • type: JKS 可以省略(JDK 会根据扩展名推断),但显式写更安全。

会踩的坑:

  • 坑 1:bundle 名写错。 getBundle 抛 NoSuchSslBundleException—— 启动期失败,消息里带名字(§1.7)。这是好事,比运行期握手失败强得多。
  • 坑 2:JKS 与 PKCS12 混用。 现代 JDK 默认 keystore 类型是 PKCS12, 但 type 属性默认是 null(让 JDK 推断)。如果文件实际是 JKS 而扩展名是 .p12, 会加载失败。显式写 type 是保险做法。

11.3 场景三:虚拟线程 + @Async + 上下文传播

配置:

spring:
  threads:
    virtual:
      enabled: true
  task:
    execution:
      propagate-context: true
      simple:
        concurrency-limit: 200

链路:

  1. spring.threads.virtual.enabled=true + JDK 21+ → Threading.VIRTUAL.isActive() 为 true
  2. SimpleAsyncTaskExecutorBuilderConfiguration 的 VIRTUAL 分支生效 → builder().virtualThreads(true),但 bean 名仍是 simpleAsyncTaskExecutorBuilder
  3. TaskExecutorConfiguration 的 VIRTUAL 分支生效 → applicationTaskExecutor = SimpleAsyncTaskExecutor (注意:没有 @Lazy,因为不预建线程,§4.4)
  4. TaskExecutorContextPropagationConfiguration 因 spring.task.execution.propagate-context=true 创建 ContextPropagatingTaskDecorator
  5. getTaskDecorator 收到 1 个 → 直接返回(不包装)
  6. AsyncConfigurerConfiguration(用户没定义 AsyncConfigurer) → ApplicationTaskExecutorAsyncConfigurer(delegate = null) → @Async 用 applicationTaskExecutor
  7. BootstrapExecutorConfiguration 把 applicationTaskExecutor 注册成 bootstrap executor 别名

会踩的坑:

  • 坑 1:pool.* 配置静默失效。 虚拟线程模式用 SimpleAsyncTaskExecutor, spring.task.execution.pool.core-size 等完全不起作用(§5.5)。 如果你想限流,用 simple.concurrency-limit。
  • 坑 2:concurrency-limit 与虚拟线程的理念冲突。 虚拟线程的优势是"不要人为限制并发"。设 concurrency-limit 会在达到上限时 (按 reject-tasks-when-limit-reached 决定)拒绝或排队, 部分抵消了虚拟线程的好处。只在确实需要保护下游(比如数据库连接池)时才设。
  • 坑 3:synchronized pinning。 §5.5 讲过—— 在 JDK 21/22 上, synchronized 块内的阻塞会钉住载体线程。 @Async 方法里如果有大量 synchronized,虚拟线程的收益会打折。
  • 坑 4:切换模式后 pool.* 属性不会报错。 属性仍然存在、仍能绑定,只是没人用(§4.8)。 这类"静默失效"是配置体系最难排查的一类问题—— 建议切换线程模型时同步清理相关配置。

11.4 三个场景的共同提醒

三个场景都涉及"启动期就应该失败的错误": bundle 名写错、证书不可监听、私钥不匹配(开启 verify-keys 时)。 Spring Boot 在这三处都选择了"启动期明确失败"而不是"运行期静默降级"—— 这是本仓库一以贯之的取舍(第 29 章第八十七定律)。

反过来,也有三处"静默失效":pool.* 在虚拟线程模式下、 server-name-bundles 在没有 SNI 的情况下、未开启 verify-keys 的密钥不匹配。 这三类是本章最值得记住的"坑",因为框架不会告诉你。


12. 三条线的属性速查

12.1 SSL

属性默认说明
spring.ssl.bundle.pem.<name>.keystore.certificate—证书(路径或 PEM 内容)
spring.ssl.bundle.pem.<name>.keystore.private-key—私钥
spring.ssl.bundle.pem.<name>.keystore.private-key-password—私钥解密密码
spring.ssl.bundle.pem.<name>.keystore.verify-keysfalse校验公私钥匹配
spring.ssl.bundle.pem.<name>.truststore.certificate—信任的 CA
spring.ssl.bundle.pem.<name>.protocolTLSSSLContext 协议
spring.ssl.bundle.pem.<name>.reload-on-updatefalse热重载
spring.ssl.bundle.pem.<name>.key.alias—密钥别名
spring.ssl.bundle.jks.<name>.keystore.location—keystore 文件
spring.ssl.bundle.jks.<name>.keystore.password—keystore 密码
spring.ssl.bundle.jks.<name>.keystore.type—JKS / PKCS12
spring.ssl.bundle.watch.file.quiet-period10s热重载静默期

12.2 Task

见 §4.8(执行)与 §6.4(调度)的表。核心几个:

属性默认
spring.threads.virtual.enabledfalse
spring.task.execution.modeAUTO
spring.task.execution.propagate-contextfalse
spring.task.execution.thread-name-prefixtask-
spring.task.scheduling.thread-name-prefixscheduling-

12.3 Retry(以 RabbitMQ 为例)

属性映射到的 RetryPolicySettings 字段
spring.rabbitmq.template.retry.enabled—(总开关)
spring.rabbitmq.template.retry.max-retriesmaxRetries
spring.rabbitmq.template.retry.initial-intervaldelay
spring.rabbitmq.template.retry.multipliermultiplier
spring.rabbitmq.template.retry.max-intervalmaxDelay
spring.rabbitmq.listener.simple.retry.statelessListenerRetry.stateless(默认 true)

13. 三条线的边界与交叉

13.1 SSL 与 ConnectionDetails 的交汇

第 22 章(数据源)与第 26 章(可观测)反复出现过 ConnectionDetails 抽象—— 自动配置不自己拼 URL,而是从 ConnectionDetails 拿。

本仓库中有三个类同时涉及两者:

  • PropertiesRabbitConnectionDetails
  • PropertiesDataRedisConnectionDetails
  • PropertiesKafkaConnectionDetails

它们都是"属性驱动的连接详情",且都引用 SslBundles。 也就是说:连接信息(主机、端口)来自 ConnectionDetails, 信任材料来自 SslBundles——两者正交组合。

这个组合在第 29 章 §6.2 已经出现过: ContainerConnectionSource 同时持有 containerSupplier(连接信息)与 sslBundleSource(信任材料)。 Testcontainers 与 Docker Compose 都能同时提供两者。

正交的两个维度(连哪里 / 用什么信任材料)各自独立演化—— 这是这套体系能同时支持"本地属性配置""容器""Docker Compose""生产 K8s secret"的原因。

13.2 三条线在 native image 下的表现

native 下的注意事项
SSL证书文件通常在镜像外(挂载),ApplicationResourceLoader 支持外部路径;热重载依赖 WatchService,native 下需要相关 hint(第 28 章讲过 hint 的重要性)
Task虚拟线程在 JDK 25(native 最低要求)下可用;SimpleAsyncTaskExecutor 不预建线程,对 native 启动时间友好
Retry纯 Java 逻辑,无反射,native 友好

待核实:SSL 热重载(WatchService)在 native image 下是否需要额外可达性元数据、 以及 Spring Boot 是否已注册相关 hint,本轮未核实。

13.3 本章没有覆盖的相邻能力

  • @Async 的代理机制与 @EnableAsync:属 Spring Framework。
  • Quartz / 其它调度框架:由 spring-boot-quartz 模块提供(不在本章范围)。
  • spring.task.execution 与 WebFlux 调度器:WebFlux 有自己的调度器配置 (第 20 章讲过 Reactor Netty)。
  • gRPC 的线程池与 TLS:第 21 章讲过 gRPC,本章只从 SSL 消费角度提及。

14. 决策速查表

你想做什么怎么做关键实现
一份证书给多个技术栈用定义 spring.ssl.bundle.*,各技术栈用 bundle: <name> 引用DefaultSslBundleRegistry
服务器按域名选证书(SNI)server.ssl.server-name-bundles.<pattern>: <bundle>getServerNameSslBundles()
换证书不重启reload-on-update: true + 文件路径(不能内联 PEM)FileWatcher + updateBundle
指定 keystore 里用哪个密钥key.aliasAliasKeyManagerFactory(绕 final 方法)
从 Vault/KMS 加载证书实现 SslBundleRegistrar 注册成 beansslBundleRegistrars.orderedStream()
想知道证书什么时候过期看 /actuator/health 的 ssl 指示器SslInfo + SslHealthIndicator
开启虚拟线程spring.threads.virtual.enabled=true(需 JDK 21+)Threading.VIRTUAL
让 @Async 用 Spring Boot 的执行器默认就是这样ApplicationTaskExecutorAsyncConfigurer
用户自定义了 Executor 但仍要 applicationTaskExecutorspring.task.execution.mode=forceOnExecutorCondition
异步任务丢失 traceIdspring.task.execution.propagate-context=trueContextPropagatingTaskDecorator
懒加载但 @Scheduled 仍要生效自动处理ScheduledBeanLazyInitializationExcludeFilter
给线程池执行器加自定义逻辑实现 ThreadPoolTaskExecutorCustomizerbuilder 的 customizers(...)
完全自定义重试策略RetryPolicySettings.setFactory(Function<Builder, RetryPolicy>)createRetryPolicy() 的逃生舱
把证书路径配到 classpath 外直接写绝对路径ApplicationResourceLoader.get(..., true)

15. 从本章提炼的定律

第九十一定律:抽象要为未来留余地——持有"集合"而不是"单个"。 Web 服务器工厂持有 SslBundles(注册表)而不是 SslBundle(单份材料)(§9.1), 于是 SNI 这种"按域名选证书"的需求无需改抽象就能支持。 如果当初传的是单个 bundle,SNI 就无从表达。

第九十二定律:高层 API 是 final 时,去包装它依赖的底层抽象。 KeyManagerFactory.getKeyManagers() 是 final,无法覆写; 于是 Spring Boot 包装了它的构造参数 KeyManagerFactorySpi(§1.5)。 "不能覆写"往往只是"这一层不能",往下一层总有办法。

第九十三定律:互斥状态用取反定义,不要用两个独立判断。 Threading.PLATFORM.isActive = !VIRTUAL.isActive(...)(§5.1)—— 保证了"必居其一"。两个独立判断会产生"都不匹配"的空洞, 在那里的表现是一堆 bean 凭空消失且无任何提示。

第九十四定律:多文件原子更新需要静默期,而 poll(timeout) 天然提供它。 FileWatcher 用"超时返回 null 就执行累积的动作"实现了 quiet period(§3.2), 不需要额外的定时器与重置逻辑。正确地选择阻塞原语,能让算法消失。

第九十五定律:扩展点用"回调改可变设置对象",而不是"继承覆盖方法"。 SslBundleRegistrar.registerBundles(registry)、 ThreadPoolTaskExecutorCustomizer.customize(builder)、 RabbitTemplateRetrySettingsCustomizer.customize(settings)(§8)。 组合优于继承,且多个扩展点可以叠加、互不冲突。

第九十六定律:属性默认值从下游抽象推导,不自己写死。 RetryPolicySettings.DEFAULT_MAX_RETRIES = RetryPolicy.Builder.DEFAULT_MAX_RETRIES(§7.2)。 下游改默认值时上游自动跟随,避免两处漂移。

第九十七定律:"0 / 1 / 多"三分支处理扩展点集合。 getTaskDecorator:0 返回 null、1 直接返回、多则 CompositeTaskDecorator(§10.1)。 单元素时不包装是热路径上的小额优化,但它也表达了"不为不需要的间接层付费"。

第九十八定律:注册表把读写接口分开。 SslBundleRegistry(写)与 SslBundles(读)由同一个类实现(§1.7), 消费方只注入读接口,从类型上杜绝"消费方改了别人的材料"。

第九十九定律:@ConditionalOnMissingBean 要覆盖所有可能的替身类型。 { SslBundleRegistry.class, SslBundles.class }(§2.2)、 { TaskScheduler.class, ScheduledExecutorService.class }(§6.2)、 { TaskScheduler.class, ScheduledExecutorService.class }—— 用户可能用 JDK 原生类型代替 Spring 类型。漏掉一个替身类型,就会出现两个 bean 打架。

第一百定律:懒加载与"必须早期实例化"的冲突,用排除过滤器解决。 ScheduledBeanLazyInitializationExcludeFilter(§6.1)把带 @Scheduled 的 bean 排除出全局懒加载——因为它们必须在启动时被创建,否则定时任务永远不注册。 全局开关遇到局部例外时,加一个"例外过滤器"而不是放弃全局开关。


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

  1. PemSslStoreBundle 的 PEM 解析实现(支持的私钥格式:PKCS#1 / PKCS#8 / 加密私钥)。
  2. DefaultSslManagerBundle 的 getKeyManagerFactory / getTrustManagerFactory 方法体。
  3. PropertiesSslBundle.get(...) 如何区分 pem 与 jks 重载、如何处理 key.alias。
  4. SimpleAsyncTaskExecutorBuilder.virtualThreads(boolean) 的具体实现。
  5. ScheduledBeanLazyInitializationExcludeFilter 判定"带 @Scheduled"的方式(类级还是方法级)。
  6. TaskSchedulingProperties 的完整字段与默认值。
  7. @ConditionalOnThreading 全部 14 个使用点的逐项清单(本章列出了已知 6 类)。
  8. 哪些类调用了 addBundleUpdateHandler(热重载的实际消费者)。
  9. SslMeterBinder 暴露的具体指标名与单位。
  10. server.ssl.server-name-bundles 的准确属性名与匹配规则。
  11. org.springframework.core.retry 是否完整替代 spring-retry、Boot 是否仍支持后者。
  12. TaskSchedulingConfigurations 两个方法的完整签名(本章按结构与 grep 结果描述)。
  13. ContextPropagatingTaskDecorator 是否已支持 ScopedValue。
  14. ApplicationResourceLoader.get(resourceLoader, true) 第二个参数的准确语义。
  15. TaskExecutorConfigurations 中 runSafely 的实现(推测为 try/catch + 日志)。
  16. 各三方客户端(Kafka/Redis/Mongo 等)取用 SSL bundle 的具体方式是否统一为"属性写名字"。

17. 小结与终章预告

本章把三条"不属于任何技术栈、却被所有技术栈需要"的能力走了一遍:

  • SSL Bundle(§1–§3、§9):把信任材料从"某技术的 SSL 配置"里剥离成可命名、 可复用、可热更新的对象。SslBundle 五层抽象(Store / Key / Options / Protocol / Managers)、 SslBundleRegistry 读写分离、61 个消费点、健康检查与指标的附赠价值。 技术高潮是 AliasKeyManagerFactory 绕 final 方法,以及 FileWatcher 用 poll 实现静默期 与对 Kubernetes symlink 的处理。
  • Task 与虚拟线程(§4–§6、§10):applicationTaskExecutor 是如何被 OnExecutorCondition + @ConditionalOnThreading 选出来的; Threading 用取反定义互斥;懒加载与 @Scheduled 的冲突用排除过滤器解决。
  • Retry(§7–§8):Spring Framework 7 的 core.retry 是新基础, RetryPolicySettings 做属性桥接,factory 字段是逃生舱, 属性名在边界处做一次重命名以兼容历史。

贯穿三者的是同一套模式:抽象 + 来源(注册表/构建器)+ 属性 + 回调式扩展点, 以及同一条纪律:默认值从下游推导、扩展点用组合而非继承、启动期该失败就失败。

终章(第 31 章)预告:安全与全景收官。 第 31 章会读 spring-boot-security* 系列模块: SecurityAutoConfiguration 的默认用户与密码生成(UserDetailsServiceAutoConfiguration、 SecurityProperties.User 的随机密码及其日志输出)、 SpringBootWebSecurityConfiguration 的默认过滤链、 OAuth2 客户端/资源服务器/SAML2 的自动配置分层, 以及 @imports 机制如何把安全自动配置"贡献"进 @WebMvcTest(第 29 章 §5.7 已经见过 spring-boot-security-test 给 AutoConfigureMockMvc.imports 贡献配置)。 之后是整套 31 章的回顾:把前面 30 章的定律按主题重排, 给出一张"从 main() 到 native image"的完整调用地图, 并标注哪些地方值得亲自读源码、哪些坑值得记住。