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 配置"里剥离出来, 成为一个可命名、可复用、可热更新的独立对象。
yaml
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 接口:五个方法,一个默认方法
java
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) { ... }
}
分层非常清晰:
scss
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 编码的证书/私钥构建内存中的KeyStoreJksSslStoreBundle:直接加载.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
java
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 只有两个方法:
java
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:
java
/**
* {@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。 所以:
- 写一个自己的 SPI(
AliasKeyManagerFactorySpi),包装真实的 factory; engineGetKeyManagers()里把真实 factory 的KeyManager[]逐个包成AliasX509ExtendedKeyManager(重写chooseClientAlias/chooseServerAlias返回指定别名);- 用
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() 的省事设计
java
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 选项"。 为什么需要它?因为消费方通常要做这种事:
java
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:写入与读取分离
java
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/updateBundleSslBundles(读取面):getBundle/getBundleNames/addBundleUpdateHandler
消费方只注入 SslBundles(读不到写方法),注册方只注入 SslBundleRegistry。 读写接口分离 是这里最重要的设计------自动配置里注入的是 SslBundles, 避免了"某个自动配置不小心改了别人的 bundle"。
三个细节:
registerBundle用putIfAbsent+Assert.state(previous == null): 不允许覆盖已有 bundle,重复注册直接失败。 为什么?因为热重载走的是updateBundle而不是重新 register------ 如果允许覆盖,热重载就会破坏"已注册"的语义(register handler 会重复触发)。 而且覆盖通常是配置错误的信号(两个地方配了同名 bundle)。RegisteredSslBundle.bundle是volatile(内部类字段)------ 热重载时替换引用,读方立即可见,无需加锁。getBundleNames()排序后返回不可修改列表------确定性输出 , 与第 27 章ExplodedArchive排序、第 28 章createSortedProperties(true)是同一条纪律。
集合类型的选择也很讲究: registeredBundles 用 ConcurrentHashMap(并发注册/读取), registerHandlers 与 updateHandlers 用 CopyOnWriteArrayList (写少读多,且遍历时不会被并发修改打断)。
2. 配置与注册:属性如何变成 bundle
2.1 SslProperties:spring.ssl.bundle.*
java
@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 内部类:
java
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
java
@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
java
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
java
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);
}
});
}
}
三个设计点:
bundleSupplier是一个Supplier而不是直接的值 ------因为热重载时要用同一段逻辑 重新构建 bundle。定义一次、执行多次。- 泛型 + 函数参数 让 pem 与 jks 两条路共用同一段注册逻辑:
bundleFactory是PropertiesSslBundle::get(重载解析出对应版本),watchedPaths分别是watchedPemPaths/watchedJksPaths。 用高阶函数消除重复代码,这在本仓库并不常见(多数地方用模板方法), 这里用得很干净。 catch (IllegalStateException ex)包一层带 bundle 名的消息再抛------ 错误信息里必须有"哪个 bundle" ,否则一堆 bundle 时无法定位。 注意它只捕获IllegalStateException(注册冲突、断言失败), 其它异常(比如文件不存在)原样抛出。
要监听哪些路径:
java
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 |
| RPC | GrpcClientAutoConfiguration、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 触发链路
csharp
文件变化
→ 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 的超时实现静默期
java
@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 更新是"先写临时文件再原子替换", 期间会产生多次文件系统事件。没有静默期就会在"半更新"状态下重载, 导致私钥与证书不匹配。静默期是对"多文件原子更新"的务实妥协。
线程的其它细节:
java
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() 的顺序:
java
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 监听什么:目录而非文件
java
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 的标准形态
这是本章最能体现"实战经验"的一段。
java
/**
* 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> -> stores
* +─ <em>keystore.jks</em> -> 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 时用的是这种结构:
bash
/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 递归地:
- 加入当前路径本身(转绝对路径)
- 如果父目录 是 symlink(
..data):加入父目录本身, 并递归处理父目录解析后 + 当前文件名 - 否则如果当前路径是 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 自动配置的骨架
java
@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 与注册阶段
java
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 = 两个内部条件任一成立即可:
- 没有用户定义的
Executorbean → 创建 spring.task.execution.mode=force→ 强制创建
ConfigurationPhase.REGISTER_BEAN 是关键: 条件评估必须在 REGISTER_BEAN 阶段 (而不是 PARSE_CONFIGURATION 阶段), 因为 @ConditionalOnMissingBean(Executor.class) 需要看到已注册的 bean 定义。 第 15 章讲过这两个阶段的区别。
mode 的语义(Mode 枚举,since 3.5.0):
java
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:
java
@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 同样有一串设置,但多了一个虚拟线程分支:
java
@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 的双定义
java
@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
java
@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:
AsyncConfigurerimplementation that delegates to the user-definedAsyncConfigurerinstance, if any. Consistently use the executor namedapplicationTaskExecutorin 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 别名
java
@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 上下文传播
java
@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 全景
| 属性 | 默认 | 说明 |
|---|---|---|
mode |
AUTO |
AUTO / FORCE |
propagate-context |
false |
上下文传播 |
thread-name-prefix |
task- |
线程名前缀 |
pool.queue-capacity |
Integer.MAX_VALUE |
队列容量 |
pool.core-size |
8 |
核心线程数 |
pool.max-size |
Integer.MAX_VALUE |
最大线程数 |
pool.allow-core-thread-timeout |
true |
核心线程是否可超时回收 |
pool.keep-alive |
60s |
空闲线程存活时间 |
pool.shutdown.accept-tasks-after-context-close |
--- | 上下文关闭后是否还收任务 |
simple.cancel-remaining-tasks-on-close |
--- | 关闭时取消剩余任务 |
simple.reject-tasks-when-limit-reached |
--- | 达到并发上限时拒绝 |
simple.concurrency-limit |
--- | 并发上限 |
shutdown.await-termination |
false |
关闭时等待任务完成 |
shutdown.await-termination-period |
--- | 等待上限 |
pool 与 simple 两套属性并存:只有当前生效的那种执行器会用到对应的一套 。 虚拟线程模式下 pool.* 全无意义,simple.* 生效;平台线程模式反之。 属性不因模式而隐藏------切换模式不会导致"属性不存在"的绑定错误。
5. 虚拟线程:一个属性如何改变十几个 bean
5.1 Threading 枚举:两个值,互相定义
java
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 的两个条件:
spring.threads.virtual.enabled=true- JDK 21+
第 28 章讲过 NativeImageRequirementsException.MINIMUM_REQUIRED_JAVA_VERSION = JavaVersion.TWENTY_FIVE------ 那是 native 的要求;这里是虚拟线程的要求(21)。两者独立。
JavaVersion.getJavaVersion() 的结果被缓存(第 28 章 §1.4 提过), 所以这里每次条件评估不会重复解析 java.version 系统属性。
5.2 ConditionalOnThreading 与条件实现
java
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional(OnThreadingCondition.class)
public @interface ConditionalOnThreading {
Threading value();
}
java
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.TaskExecutorConfiguration |
ThreadPoolTaskExecutor |
SimpleAsyncTaskExecutor |
TaskExecutorConfigurations.SimpleAsyncTaskExecutorBuilderConfiguration |
普通 builder | virtualThreads(true) 的 builder |
TaskSchedulingConfigurations |
ThreadPoolTaskSchedulerBuilder |
SimpleAsyncTaskSchedulerBuilder |
DefaultTaskSchedulerConfiguration |
ThreadPoolTaskScheduler |
SimpleAsyncTaskScheduler |
OtlpMetricsExportAutoConfiguration(第 26 章) |
平台线程 registry | VirtualThreadTaskExecutor("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-bootcore 的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
java
@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:
java
@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:与执行侧同构
java
@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 出的行号与结构,方法体的完整签名以上述为准。)
三个条件的组合:
@ConditionalOnBean(name = SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME): 只有启用调度才创建------没有@EnableScheduling就不浪费一个调度器。@ConditionalOnMissingBean({ TaskScheduler.class, ScheduledExecutorService.class }): 两个类型都算替身。因为用户可能提供 JDK 原生的ScheduledExecutorService。@ConditionalOnThreading:线程模型分支。
6.3 DefaultTaskSchedulerConfiguration:给第三方复用的配置
java
/**
* ...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 执行器与调度器的关系
两者是独立的两套:
java
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 里的落点不同:
java
// 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-termination | cancel-remaining | 说明 |
|---|---|---|---|
| 批处理任务 | true + 合理 period |
false |
让任务跑完 |
| 请求型异步 | true + 短 period |
true |
快速释放 |
| 虚拟线程 | 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:一个"属性 → 值对象"的桥
java
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();
}
}
设计要点:
-
所有默认值都从
RetryPolicy.Builder的常量推导 , 而不是自己写死数字。这样 Framework 改默认值时 Boot 自动跟随------ 默认值单点定义(第 24 章讲日志时见过同一条纪律)。 -
PropertyMapper而不是直接赋值 :map.from(...).to(...)只在源值非 null 时才调用目标设置方法。 这样"用户没配"就不会把 builder 的默认值覆盖成 null。PropertyMapper是 Spring Boot 内部的标准工具(第 8 章讲 Binder 时提过)。 -
factory字段是逃生舱:javareturn (this.factory != null) ? this.factory.apply(builder) : builder.build();如果用户设了
factory,就把它当"最终决策者"------ 拿到已经填好属性的 builder,想怎么改怎么改,甚至完全忽略属性自己造一个。 这是"配置属性 + 编程式覆盖"的标准组合 : 属性覆盖 90% 场景,Function<Builder, RetryPolicy>覆盖剩下 10%。 -
exceptionIncludes/exceptionExcludes/exceptionPredicate三种机制并存:- includes:只对列出的异常重试
- excludes:列出的异常不重试
- predicate:任意逻辑
三者语义有重叠,但适用场景不同:前两个适合配置驱动(
spring.rabbitmq.template.retry.???), 第三个适合代码驱动。同时提供"配置友好"与"代码友好"两种入口,是本仓库的一贯做法。 -
setExceptionIncludes的实现是this.exceptionIncludes = new ArrayList<>(includes)------ 防御性拷贝 ,防止调用方后续修改列表影响内部状态。 而 Javadoc 又明确说"Alternatively consider usinggetExceptionIncludes()to mutate the existing list"------ 既提供 setter 也提供可变 getter,两种风格都支持。
7.3 震颤(jitter)与指数退避
delay + multiplier + maxDelay + jitter 四个参数构成标准退避模型:
scss
第 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 的重试
java
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 是一个可变的设置载体:
java
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 的合成:
java
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 容器的重试
java
@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 里:
java
RetryPolicySettings retrySettings = retryProperties.initializeRetryPolicySettings();
两处共用同一个 initializeRetryPolicySettings()------ 属性 → settings 的转换逻辑在 RabbitProperties 里定义一次,两处复用。
ListenerRetry 额外的属性:
java
public static class ListenerRetry extends Retry {
/**
* Whether retries are stateless or stateful.
*/
private boolean stateless = true;
}
stateless(默认 true):无状态重试意味着重试在容器线程内 进行(不影响消息顺序); 有状态重试则要求消息重新入队(会破坏顺序但能跨消费者重试)。 默认无状态------因为破坏消息顺序的代价通常大于重试失败。
7.5 initializeRetryPolicySettings:属性名映射
java
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-retries |
maxRetries |
initial-interval |
delay |
multiplier |
multiplier |
max-interval |
maxDelay |
为什么要映射?因为 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 三块,它们的结构高度一致:
| SSL | Task | Retry | |
|---|---|---|---|
| 抽象 | SslBundle |
TaskExecutor / TaskScheduler |
RetryPolicy |
| 来源 | 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
java
// 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 注入链路
scss
SslAutoConfiguration → DefaultSslBundleRegistry(实现 SslBundles)
↓ ObjectProvider<SslBundles>
ServletWebServerConfiguration / ReactiveWebServerConfiguration
↓ 传给
ServletWebServerFactoryCustomizer / ReactiveWebServerFactoryCustomizer
↓ map.from(() -> this.sslBundles).to(factory::setSslBundles)
AbstractConfigurableWebServerFactory.setSslBundles(...)
关键点:
ObjectProvider<SslBundles>而不是直接注入SslBundles------SslAutoConfiguration可能被排除(用户不要 SSL 支持),此时 provider 为空。 用@Nullable SslBundles接收(customizer 构造器里就是@Nullable)。PropertyMapper做 null 安全赋值 :map.from(() -> this.sslBundles).to(factory::setSslBundles)------ 为 null 时不调用 setter(§7.2 讲过这个语义)。- servlet 与 reactive 两条对称的链路------第 18 章讲 web 服务器时见过同样的对称结构。
9.3 健康检查:证书过期会被报告出来
java
@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 各自的自动配置都是同一个模式:
java
// 伪代码
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 / 多 三种情况
java
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
java
@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:委托链
java
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 的那行代码:
java
@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方法,观察日志输出。
无论细节如何,有一点是确定的:异步方法里的异常不会传播到调用方。 这是异步编程的固有性质------调用方早已返回,栈已不在。
实践建议:
@Async方法要么返回CompletableFuture(异常通过 future 传递), 要么内部 try/catch 并记录。- 需要统一处理时实现
AsyncConfigurer.getAsyncUncaughtExceptionHandler()------ 此时它会被包装进ApplicationTaskExecutorAsyncConfigurer并生效(§10.3 的 delegate 分支)。 - 注意
ApplicationTaskExecutorAsyncConfigurer只保留处理器 , 执行器仍可能回退到applicationTaskExecutor------ 两个方法的委托策略不同(§10.3),这是有意的:处理器没有合理默认值,执行器有。
11. 三个端到端场景
理论讲完了,把三条线各走一遍完整的链路。每个场景都给出"配置 → 涉及的源码 → 会踩的坑"。
11.1 场景一:内嵌服务器用 PEM 证书 + 热重载
配置:
yaml
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
链路:
SslProperties绑定spring.ssl.bundle.pem.web(PemSslBundleProperties)SslAutoConfiguration创建FileWatcher(quietPeriod = 15s)、SslPropertiesBundleRegistrar、DefaultSslBundleRegistrySslPropertiesBundleRegistrar.registerBundles遍历 pem map, 调PropertiesSslBundle.get(props, resourceLoader)造 bundlereload-on-update=true→ 计算watchedPemPaths(4 个属性)→fileWatcher.watch(...)ServletWebServerFactoryCustomizer拿到SslBundles→factory.setSslBundles(...)- 启动时
AbstractConfigurableWebServerFactory按server.ssl.bundle取web, 按server-name-bundles建 SNI 映射 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 客户端共用同一份信任材料
配置:
yaml
spring:
ssl:
bundle:
jks:
internal-ca:
truststore:
location: classpath:internal-ca.jks
password: changeit
type: JKS
rabbitmq:
ssl:
enabled: true
bundle: internal-ca
链路:
spring.ssl.bundle.jks.internal-ca→JksSslBundleProperties→JksSslStoreBundle- 注册到
DefaultSslBundleRegistry(名字internal-ca) 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 + 上下文传播
配置:
yaml
spring:
threads:
virtual:
enabled: true
task:
execution:
propagate-context: true
simple:
concurrency-limit: 200
链路:
spring.threads.virtual.enabled=true+ JDK 21+ →Threading.VIRTUAL.isActive()为 trueSimpleAsyncTaskExecutorBuilderConfiguration的 VIRTUAL 分支生效 →builder().virtualThreads(true),但 bean 名仍是simpleAsyncTaskExecutorBuilderTaskExecutorConfiguration的 VIRTUAL 分支生效 →applicationTaskExecutor=SimpleAsyncTaskExecutor(注意:没有@Lazy,因为不预建线程,§4.4)TaskExecutorContextPropagationConfiguration因spring.task.execution.propagate-context=true创建ContextPropagatingTaskDecoratorgetTaskDecorator收到 1 个 → 直接返回(不包装)AsyncConfigurerConfiguration(用户没定义AsyncConfigurer) →ApplicationTaskExecutorAsyncConfigurer(delegate = null)→@Async用applicationTaskExecutorBootstrapExecutorConfiguration把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:
synchronizedpinning。 §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-keys |
false |
校验公私钥匹配 |
spring.ssl.bundle.pem.<name>.truststore.certificate |
--- | 信任的 CA |
spring.ssl.bundle.pem.<name>.protocol |
TLS |
SSLContext 协议 |
spring.ssl.bundle.pem.<name>.reload-on-update |
false |
热重载 |
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-period |
10s |
热重载静默期 |
12.2 Task
见 §4.8(执行)与 §6.4(调度)的表。核心几个:
| 属性 | 默认 |
|---|---|
spring.threads.virtual.enabled |
false |
spring.task.execution.mode |
AUTO |
spring.task.execution.propagate-context |
false |
spring.task.execution.thread-name-prefix |
task- |
spring.task.scheduling.thread-name-prefix |
scheduling- |
12.3 Retry(以 RabbitMQ 为例)
| 属性 | 映射到的 RetryPolicySettings 字段 |
|---|---|
spring.rabbitmq.template.retry.enabled |
---(总开关) |
spring.rabbitmq.template.retry.max-retries |
maxRetries |
spring.rabbitmq.template.retry.initial-interval |
delay |
spring.rabbitmq.template.retry.multiplier |
multiplier |
spring.rabbitmq.template.retry.max-interval |
maxDelay |
spring.rabbitmq.listener.simple.retry.stateless |
ListenerRetry.stateless(默认 true) |
13. 三条线的边界与交叉
13.1 SSL 与 ConnectionDetails 的交汇
第 22 章(数据源)与第 26 章(可观测)反复出现过 ConnectionDetails 抽象------ 自动配置不自己拼 URL,而是从 ConnectionDetails 拿。
本仓库中有三个类同时涉及两者:
PropertiesRabbitConnectionDetailsPropertiesDataRedisConnectionDetailsPropertiesKafkaConnectionDetails
它们都是"属性驱动的连接详情",且都引用 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.alias |
AliasKeyManagerFactory(绕 final 方法) |
| 从 Vault/KMS 加载证书 | 实现 SslBundleRegistrar 注册成 bean |
sslBundleRegistrars.orderedStream() |
| 想知道证书什么时候过期 | 看 /actuator/health 的 ssl 指示器 |
SslInfo + SslHealthIndicator |
| 开启虚拟线程 | spring.threads.virtual.enabled=true(需 JDK 21+) |
Threading.VIRTUAL |
让 @Async 用 Spring Boot 的执行器 |
默认就是这样 | ApplicationTaskExecutorAsyncConfigurer |
用户自定义了 Executor 但仍要 applicationTaskExecutor |
spring.task.execution.mode=force |
OnExecutorCondition |
| 异步任务丢失 traceId | spring.task.execution.propagate-context=true |
ContextPropagatingTaskDecorator |
懒加载但 @Scheduled 仍要生效 |
自动处理 | ScheduledBeanLazyInitializationExcludeFilter |
| 给线程池执行器加自定义逻辑 | 实现 ThreadPoolTaskExecutorCustomizer |
builder 的 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. 本章未逐行核实 / 待你实测的项
PemSslStoreBundle的 PEM 解析实现(支持的私钥格式:PKCS#1 / PKCS#8 / 加密私钥)。DefaultSslManagerBundle的getKeyManagerFactory/getTrustManagerFactory方法体。PropertiesSslBundle.get(...)如何区分 pem 与 jks 重载、如何处理key.alias。SimpleAsyncTaskExecutorBuilder.virtualThreads(boolean)的具体实现。ScheduledBeanLazyInitializationExcludeFilter判定"带@Scheduled"的方式(类级还是方法级)。TaskSchedulingProperties的完整字段与默认值。@ConditionalOnThreading全部 14 个使用点的逐项清单(本章列出了已知 6 类)。- 哪些类调用了
addBundleUpdateHandler(热重载的实际消费者)。 SslMeterBinder暴露的具体指标名与单位。server.ssl.server-name-bundles的准确属性名与匹配规则。org.springframework.core.retry是否完整替代spring-retry、Boot 是否仍支持后者。TaskSchedulingConfigurations两个方法的完整签名(本章按结构与 grep 结果描述)。ContextPropagatingTaskDecorator是否已支持ScopedValue。ApplicationResourceLoader.get(resourceLoader, true)第二个参数的准确语义。TaskExecutorConfigurations中runSafely的实现(推测为 try/catch + 日志)。- 各三方客户端(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"的完整调用地图, 并标注哪些地方值得亲自读源码、哪些坑值得记住。