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 编码的证书/私钥构建内存中的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
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。
所以:
- 写一个自己的 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() 的省事设计
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/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.*
@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);
}
});
}
}
三个设计点:
bundleSupplier是一个Supplier而不是直接的值——因为热重载时要用同一段逻辑 重新构建 bundle。定义一次、执行多次。- 泛型 + 函数参数让 pem 与 jks 两条路共用同一段注册逻辑:
bundleFactory是PropertiesSslBundle::get(重载解析出对应版本),watchedPaths分别是watchedPemPaths/watchedJksPaths。 用高阶函数消除重复代码,这在本仓库并不常见(多数地方用模板方法), 这里用得很干净。 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 |
| 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 触发链路
文件变化
→ 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> -> 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 时用的是这种结构:
/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 自动配置的骨架
@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 = 两个内部条件任一成立即可:
- 没有用户定义的
Executorbean → 创建 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:
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 别名
@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 全景
| 属性 | 默认 | 说明 |
|---|---|---|
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 枚举:两个值,互相定义
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 与条件实现
@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.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
@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 出的行号与结构,方法体的完整签名以上述为准。)
三个条件的组合:
@ConditionalOnBean(name = SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME): 只有启用调度才创建——没有@EnableScheduling就不浪费一个调度器。@ConditionalOnMissingBean({ TaskScheduler.class, ScheduledExecutorService.class }): 两个类型都算替身。因为用户可能提供 JDK 原生的ScheduledExecutorService。@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-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:一个"属性 → 值对象"的桥
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字段是逃生舱:return (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 四个参数构成标准退避模型:
第 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-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
// 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(...)
关键点:
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 健康检查:证书过期会被报告出来
@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方法,观察日志输出。
无论细节如何,有一点是确定的:异步方法里的异常不会传播到调用方。 这是异步编程的固有性质——调用方早已返回,栈已不在。
实践建议:
@Async方法要么返回CompletableFuture(异常通过 future 传递), 要么内部 try/catch 并记录。- 需要统一处理时实现
AsyncConfigurer.getAsyncUncaughtExceptionHandler()—— 此时它会被包装进ApplicationTaskExecutorAsyncConfigurer并生效(§10.3 的 delegate 分支)。 - 注意
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
链路:
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 客户端共用同一份信任材料
配置:
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 + 上下文传播
配置:
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"的完整调用地图,
并标注哪些地方值得亲自读源码、哪些坑值得记住。