Boot 运行:日志、JSON、TLS、资源预算与在途停机
一个服务可以同时出现“端口能连、任务在排队、数据库连接借不到、停机后请求没响应”。这不是同一个超时参数能解决的问题。本篇固定 JDK 21 / Boot 4.0.9 / Framework 7.0.10,用真实 loopback Tomcat、TLS 和本机 MySQL 将运行配置接到实际消费它的对象。配置来源和绑定失败的权威讲解见Boot 配置,启动事件/ready见Boot 启动,不把三条链混在一起。
运行输入与观察对象
实验工程和依赖在实验说明;公开命令为:
JAVA_HOME=$(/usr/libexec/java_home -v21) python3 labs/boot-runtime/run.py按需复跑可用 --group tasks、http、multipart、tls、shutdown、scheduler;--skip-build 只用于明确复验当前固定制品,默认全入口会重新构建。证据区分本次调用与历史结果,不能把不同归档/不同源码的片段拼成同一轮。全部端口为本机独有服务;MySQL只读SELECT1和借用连接,不创建或改写业务表;证书位于自有临时目录,不加入系统信任库。实际版本解析为 Tomcat 11.0.27、Jackson 3.1.7、HikariCP 7.0.2、Connector/J 9.7.0,适用与限制见U23运行证据。
本实验 /lab/state 只返回合成计数、实际 connector 参数、mapper 类型/版本;它不是通用配置导出端点,不包含凭据。BOUND行确认本进程取得的端口,入口再等ready后发请求;不能把BOUND单独当业务依赖可用。生产就绪探针/管理端点授权归U24,不由这个教学接口替代。
日志要在正确时点配置
starter带入的常规日志实现是Logback。logging.level.root设基础级别,logging.level.study.runtime只影响这个logger树;--debug主要打开Boot选定的诊断logger,不能等同于把所有业务logger设为DEBUG。logging配置在Context refresh前读取,普通@PropertySource进入环境太晚,不能可靠控制这一步。需要扩展原生日志配置时用支持Spring扩展的logback-spring.xml并按启动时点核查,不依赖某个业务Bean在初始化时再设置。
早期日志并非通过业务Bean初始化才启用。Boot v4.0.9的监听器在environment事件取得环境并调用initialize,连续节选:
private void onApplicationEnvironmentPreparedEvent(ApplicationEnvironmentPreparedEvent event) {
SpringApplication springApplication = event.getSpringApplication();
if (this.loggingSystem == null) {
this.loggingSystem = LoggingSystem.get(springApplication.getClassLoader());
}
initialize(event.getEnvironment(), springApplication.getClassLoader());
}日志实现按类加载器选取,再将本阶段环境交给初始化;此处没有等待用户配置类扫描/实例化。因此排查日志配置时要查早期源和LoggingSystem,而非只查业务Bean是否有某个字段。
本例默认root=WARN、study.runtime=INFO,应用只记录合成U23_LOG_PROBE。这条独立命令让console采用Logstash结构:
java -jar labs/boot-runtime/target/boot-runtime-1.jar \
--logging.structured.format.console=logstash
# 改logger级别后,配置读取仍成功,而该INFO日志应消失
java -jar labs/boot-runtime/target/boot-runtime-1.jar \
--logging.structured.format.console=logstash --logging.level.study.runtime=error入口实际解析输出的JSON日志行,确认INFO标记存在;改变级别后标记不存在,两次都有配置结果。RESULT/EVENT是实验诊断输出,不是Logback日志,不能要求整个stdout每一行都是结构化JSON。正式服务要约束所有日志出口和采集协议;logstash/ECS/GELF的字段、体积与兼容性也是成本,不是格式名越多越好。
调试不应输出整个配置对象或全部Environment:record自动生成的toString也可能包含token。此例仅按白名单报告非秘密属性,入口确认合成token不出现在正常结构化日志里;不能由一条字符串缺失推出所有异常、堆转储和第三方logger都已脱敏。生产需审查错误路径、保留期、访问权限和密钥轮换。固定来源:Boot v4.0.9 Logging。
HTTP JSON 与配置 JSON 是两个协议
SPRING_APPLICATION_JSON是应用配置来源;@RequestBody的HTTP实体则经过消息转换器和JSON mapper。将前者设置成功不能证明后者反序列化、安全校验或兼容性正确。
Boot4默认采用Jackson3的tools.jackson.databind.json.JsonMapper;Jackson2兼容模块和spring.jackson2.*用于迁移,不能把旧com.fasterxml.jackson.databind.ObjectMapper当作默认3的实现类。两套库同时存在时还要核对实际HTTP converter选中的mapper。此例读取实际Context的JsonMapper类/版本,并用真实HTTP对照,不用单独new出来的mapper冒充MVC集成。
spring.jackson.deserialization.fail-on-unknown-properties=true
spring.jackson.default-property-inclusion=non_nullPOST /lab/json使用Content-Type: application/json,输入{"value":"ok","unexpected":1}在显式strict配置下返回400;删除多余字段返回200和{"value":"ok"},空optional未序列化。默认配置进程对同一extra字段输入返回200,并包含optional:null。这证明本组合配置与响应形状,不保证所有客户端都能接受忽略字段;悄悄吞掉拼写错误可能使客户端以为它的参数生效。
这两项属性落到mapper builder的分支在Boot v4.0.9的JacksonAutoConfiguration,连续节选:
Include propertyInclusion = this.jacksonProperties.getDefaultPropertyInclusion();
if (propertyInclusion != null) {
builder.changeDefaultPropertyInclusion((handler) -> handler.withValueInclusion(propertyInclusion)
.withContentInclusion(propertyInclusion));
}
if (this.jacksonProperties.getTimeZone() != null) {
builder.defaultTimeZone(this.jacksonProperties.getTimeZone());
}
configureVisibility(builder, this.jacksonProperties.getVisibility());
configureFeatures(builder, this.jacksonProperties.getDeserialization(), builder::configure);
configureFeatures(builder, this.jacksonProperties.getSerialization(), builder::configure);属性为null时不强行设置inclusion;非空时同时配置值/内容的默认包含规则。deserialization/serialization分别进入feature map应用,不会因为设置日志JSON格式就改变这份HTTP mapper。这些是自动配置builder的动作,用户替换mapper或converter后仍须核对实际链路。
反序列化配置不替代业务约束和授权:未知字段拒绝不等于value内容有效,更不等于该用户可操作对象。跨版本升级应固定输入的成功/失败集合,比较JSON字段、日期/数字格式、异常响应和消费者契约,而不是只换包名。机制与旧Jackson2消息转换回查U19正文。官方范围与配置形状见JSON固定章节及JacksonProperties。
真实容器:线程、连接、请求限制各管一层
本例显式参数与实际AbstractProtocol对象读取结果一致:
| 参数 | 显式值/单位 | 限制对象与误区 |
|---|---|---|
| server.tomcat.threads.max / min-spare | 4 / 1条平台线程 | HTTP工作线程,不是数据库池或applicationTaskExecutor |
| server.tomcat.max-connections | 16条连接 | 包含空闲keep-alive;不是“同时执行16条SQL” |
| server.tomcat.accept-count | 2 | 接纳边界外的连接排队配置;OS backlog还受平台约束,不能当应用请求队列长度 |
| server.tomcat.connection-timeout | 2s | 建连后读取请求相关的容器等待,不是Controller端到端截止时间 |
| spring.servlet.multipart.max-file-size | 1KB | 单文件限制,作用于真实Servlet解析 |
| spring.servlet.multipart.max-request-size | 3KB | 整体multipart请求,含边界和各part开销,不等于文件长度之和 |
Boot4对应TomcatServerProperties的固定默认是maxConnections8192/acceptCount100、threads.max200/minSpare10;本实验主动设成小值,避免把默认上限当已做容量规划。固定属性类还区分keep-alive、请求头、form、part等限制;没覆盖的限制不能用这张表宣布全部容器安全配置已验收。代理的读超时、HTTP连接超时、任务排队、DB连接等待与SQL执行超时必须各自有名字和预算。
真实multipart验证:128字节文件返回200且字节数一致,2048字节文件返回413,记录MaxUploadSizeExceededException,upload Controller计数仍为1;大文件未进入目标方法。与U19 MockMvc中64KiB仍可解析的结果不冲突,两者执行边界不同。另一次sendError(500)记录REQUEST /lab/error后发生ERROR /error original=/lab/error,这是实际容器分派,不能由MockMvc的异常解析成功代替。
本次未主动触发Tomcat maxSwallowSize造成的连接重置;413并不证明所有大请求都能收到完整错误响应。反向代理可能先拒绝、客户端可能已断开、容器可能关闭连接,生产导入接口还需按目标拓扑验证。源实现就是实际运行的Controller/Filter,不复制第二套伪代码:
package study.runtime;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import org.apache.coyote.AbstractProtocol;
import org.springframework.boot.tomcat.TomcatWebServer;
import org.springframework.boot.web.server.context.WebServerApplicationContext;
import org.springframework.boot.web.server.context.WebServerInitializedEvent;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import tools.jackson.databind.json.JsonMapper;
@RestController
@RequestMapping("/lab")
class ServerProbe {
static final List<String> dispatches=new CopyOnWriteArrayList<>();
private final ConfigurableApplicationContext context;
private final AtomicInteger active=new AtomicInteger(),completed=new AtomicInteger(),uploads=new AtomicInteger();
ServerProbe(ConfigurableApplicationContext context){this.context=context;}
@EventListener void bound(WebServerInitializedEvent event){System.out.println("BOUND "+event.getWebServer().getPort());}
@GetMapping("/state") Map<String,Object> state(){
var web=(TomcatWebServer)((WebServerApplicationContext)context).getWebServer();
var protocol=(AbstractProtocol<?>)web.getTomcat().getConnector().getProtocolHandler();
var settings=new LinkedHashMap<String,Object>();
settings.put("maxThreads",protocol.getMaxThreads());settings.put("minSpareThreads",protocol.getMinSpareThreads());settings.put("maxConnections",protocol.getMaxConnections());settings.put("acceptCount",protocol.getAcceptCount());settings.put("connectionTimeoutMillis",protocol.getConnectionTimeout());
return Map.of("active",active.get(),"completed",completed.get(),"uploads",uploads.get(),"connector",settings,"mapper",context.getBean(JsonMapper.class).getClass().getName(),"mapperVersion",context.getBean(JsonMapper.class).version().toString());
}
public record Input(String value){}
public record Reply(String value,String optional){}
@PostMapping(path="/json",consumes=MediaType.APPLICATION_JSON_VALUE) Reply json(@RequestBody Input input){return new Reply(input.value(),null);}
@PostMapping(path="/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) Map<String,Object> upload(@RequestParam("file") MultipartFile file)throws IOException{
byte[] bytes=file.getBytes();uploads.incrementAndGet();return Map.of("bytes",bytes.length,"accepted",true);
}
@GetMapping("/error") void error(HttpServletResponse response)throws IOException{response.sendError(500,"synthetic-error");}
@GetMapping("/trace") List<String> trace(){return List.copyOf(dispatches);}
@GetMapping("/slow") Map<String,Object> slow(@RequestParam(defaultValue="1200") long millis)throws InterruptedException{
if(millis<0||millis>10000)throw new IllegalArgumentException("millis must be within 0..10000");
active.incrementAndGet();System.out.println("SLOW ENTER millis="+millis);
try{Thread.sleep(millis);completed.incrementAndGet();System.out.println("SLOW COMPLETED");return Map.of("completed",true);}
finally{active.decrementAndGet();System.out.println("SLOW EXIT");}
}
@Configuration(proxyBeanMethods=false)
static class DispatchProbe {
@Bean FilterRegistrationBean<Filter> dispatchFilter(){
Filter filter=(request,response,chain)->{
var http=(HttpServletRequest)request;
dispatches.add(http.getDispatcherType()+" "+http.getRequestURI()+" original="+http.getAttribute(RequestDispatcher.ERROR_REQUEST_URI));
chain.doFilter(request,response);
};
var registration=new FilterRegistrationBean<>(filter);registration.setDispatcherTypes(DispatcherType.REQUEST,DispatcherType.ERROR);registration.addUrlPatterns("/*");return registration;
}
}
}Boot 执行器:有界队列才让最大线程数有意义
未提供用户Executor且平台线程模式下,Boot4可自动配置名为applicationTaskExecutor的ThreadPoolTaskExecutor。默认core=8、queueCapacity和maxSize均为Integer.MAX_VALUE,core可超时、keepAlive=60s;这组便利默认不等于有界接纳。显式设置max-size=4但保留无界队列,core=1时第二、第三任务进队列,池并不扩到4。
spring.task.execution.pool.core-size=1
spring.task.execution.pool.max-size=1
spring.task.execution.pool.queue-capacity=1
spring.task.execution.shutdown.await-termination=true
spring.task.execution.shutdown.await-termination-period=3s这组配置的运行顺序是:闩锁确认首任务占一个worker,第二个占一个队列槽,第三次提交抛TaskRejectedException;释放后已接纳的两次副作用完成,拒绝的第三次未执行。对照只设core1/max4、保留无界队列时,池仍为1、排队2、最终副作用3。观察接纳/拒绝、队列数和完成数,比“没有报错”更能解释资源预算。
Boot v4.0.9 builder将属性落到真实执行器,连续节选:
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;这段把queue/core/max分别交给builder,还单独设置close之后接纳策略和await。没有写自定义Bean的情况下属性才控制这份自动配置对象;随意new ThreadPoolTaskExecutor()或提供用户Executor后仍只改application.properties,可能改不到实际执行者。
实验提供一个用户Executor时默认自动配置退让;spring.task.execution.mode=force后应用执行器仍可生成并与用户Executor并存。本例验证Bean存在性与所取具名执行器的行为,未把同步user Executor当作生产隔离池。@Async最终选择还与具名参数、AsyncConfigurer、primary等规则相关;MVC/WebFlux等集成要求的名字/接口也有条件,回查固定任务章节。
在途任务的异常/取消和ThreadLocal恢复仍由异步执行正文权威解释。Boot配置只决定选什么对象及默认参数,不把线程绑定事务、安全身份或持久交付凭空加上去。
虚拟线程与调度器必须重新判定实现
JDK21上开启spring.threads.virtual.enabled=true,Boot执行器可换成SimpleAsyncTaskExecutor,pool.*不再是它的并发上限。实验故意保留pool.core-size=9,同时显式设置:
spring.threads.virtual.enabled=true
spring.task.execution.simple.concurrency-limit=1
spring.task.execution.simple.reject-tasks-when-limit-reached=true首任务在真实虚拟线程上运行,第二次提交因simple并发限制拒绝,完成数1;这不是“虚拟线程池有9个worker”。如果不选择拒绝策略,达到并发上限可能阻塞提交方,这会把压力传回入口;选择前应明确调用线程能否承受等待。平台pool的queue/max与simple的concurrency/reject是两套接纳机制。
Boot v4.0.9 的选择分支连续节选:
@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();
}调度器也会变化:平台模式为ThreadPoolTaskScheduler,默认poolSize=1,本次显式改成3并读取真实配置确认;虚拟模式观察为SimpleAsyncTaskScheduler。但观察实现类不等于已实测所有周期任务并发。Framework v7.0.10固定实现把fixedRate/cron触发交给异步执行器,任务实际运行可与后续触发重叠,受所选执行器/限制影响;fixedDelay则在单独的scheduler线程上执行任务,以保留完成后再等间隔的语义。
原码连续节选,SimpleAsyncTaskScheduler v7.0.10:
private Runnable scheduledTask(Runnable task) {
return () -> execute(new DelegatingErrorHandlingRunnable(task, this::shutdownAwareErrorHandler));
}该包装返回的是触发时调用execute的Runnable;调度器的trigger future不等于每个已交付业务任务的完成句柄。fixedDelay另走fixedDelayExecutor的直接任务路径,见同版本L318–338。本段是固定源码推演,当前实验验证实现类/资源配置/虚拟执行线程,没有运行周期重叠矩阵。
因此旧调度正文的“同一注册fixedRate任务不重叠”只针对其ThreadPoolTaskScheduler→ScheduledThreadPoolExecutor路径,不能外推所有Boot虚拟调度器。cron+initialDelay在本次Boot4注解处理实测启动失败,关闭该错误配置类的开关后恢复;这与旧6.1.12固定源码分支一致。集群重复执行仍需持久状态/协调,不因线程模型改变而消失。
连接池:借用等待不是 SQL 执行超时
Hikari默认配置与Tomcat线程无关;Boot在类路径/缺Bean/type条件通过后创建DataSource,再把spring.datasource.hikari.*绑定到对象。固定入口见DataSourceConfiguration v4.0.9 L98–121。实验数据库模式解除base文件的排除,并显式设置maximum-pool-size=1、minimum-idle=0、connection-timeout=300毫秒。
自动配置创建Hikari对象的方法带有独立的属性绑定前缀,Boot v4.0.9连续节选:
@Bean
@ConfigurationProperties("spring.datasource.hikari")
HikariDataSource dataSource(DataSourceProperties properties, JdbcConnectionDetails connectionDetails,
Environment environment) {
String dataSourceClassName = environment.getProperty("spring.datasource.hikari.data-source-class-name");
HikariDataSource dataSource = createDataSource(connectionDetails, HikariDataSource.class,
properties.getClassLoader(), dataSourceClassName == null);
if (StringUtils.hasText(properties.getName())) {
dataSource.setPoolName(properties.getName());
}
return dataSource;DataSourceProperties/ConnectionDetails负责连接基础信息,后续Hikari前缀绑定池专有参数;自定义DataSource后这条默认创建分支可退让。读取有效属性值与读取真实池对象必须对上,避免“文件写了1条,实际用户池仍10条”。
真实本机MySQL的验证轨迹:SELECT1成功→持有唯一连接→第二次借用超时,根因为SQLTransientConnectionException→释放后再次SELECT1成功。超时发生在等待池连接,SQL甚至未开始;把SQL索引改好不一定修复“连接泄漏/长事务占池”,把池调大也可能将压力转给数据库。
本例URL的sslMode=DISABLED仅用于loopback合成连接,不能作为远程数据库的证书策略;HTTP TLS实验也不证明这条JDBC链已加密。池上限是连接数,connection-timeout是毫秒;驱动connect/socket timeout和Statement.queryTimeout又限制不同阶段。单位与对象要在同一张配置审查表里,不要只写一个“超时3秒”。
package study.runtime;
import java.sql.*;
import java.util.*;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.context.ConfigurableApplicationContext;
class DatabaseProbe {
static Object run(ConfigurableApplicationContext context)throws Exception{
var pool=context.getBean(HikariDataSource.class);var output=new LinkedHashMap<String,Object>();
output.put("maxPoolSize",pool.getMaximumPoolSize());output.put("borrowTimeoutMillis",pool.getConnectionTimeout());
try(var first=pool.getConnection()){
try(var statement=first.createStatement();var rs=statement.executeQuery("SELECT 1")){
if(!rs.next()||rs.getInt(1)!=1)throw new AssertionError("SELECT 1");
}
output.put("mysql",first.getMetaData().getDatabaseProductVersion());output.put("driver",first.getMetaData().getDriverVersion());
long start=System.nanoTime();boolean failed=false;
try(var unexpected=pool.getConnection()){throw new AssertionError("pool size1 did not limit borrowing");}
catch(SQLTransientConnectionException expected){failed=true;output.put("failure",expected.getClass().getName());}
long elapsed=TimeUnitMillis.since(start);if(!failed||elapsed<250||elapsed>3000)throw new AssertionError("unexpected borrow wait "+elapsed);
output.put("observedWaitMillis",elapsed);
}
try(var recovered=pool.getConnection();var st=recovered.createStatement();var rs=st.executeQuery("SELECT 1")){
if(!rs.next()||rs.getInt(1)!=1)throw new AssertionError("recovery failed");
output.put("recovered",true);
}
return output;
}
static class TimeUnitMillis{static long since(long start){return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-start);}}
}池等待过长时先查active/idle/pending、借还连接时序、长事务/慢SQL和调用截止时间,再决定减少并发、缩短持有或扩池;修复后复跑相同借用/释放轨迹。这里只证明本机借用和恢复,不证明MySQL锁、事务恢复、故障转移或生产容量。
SSL bundle:材料、应用与客户端校验三步
named SSL bundle保存key/trust材料;server.ssl.bundle把指定bundle应用到嵌入式服务;客户端仍需信任链与主机名校验。只有keystore文件或只有bundle Bean,都不能证明HTTPS已在服务上生效。实验由keytool临时生成PKCS12与导出的自签名证书PEM(供客户端作为本地信任锚),SAN包含localhost/127.0.0.1,不改系统信任库:
server.ssl.bundle=u23
spring.ssl.bundle.jks.u23.keystore.location=file:/本次临时目录/server.p12
spring.ssl.bundle.jks.u23.keystore.type=PKCS12
spring.ssl.bundle.jks.u23.keystore.password=本次合成密码这些路径/密码由公开入口按本次临时目录传入,不是仓库中可直接照抄的真实密钥。完整生成/客户端调用见run.py。默认信任拒绝自签名证书,客户端cafile显式信任本次自签名证书后HTTP200,信任锚相同但使用wrong-host.example仍因主机名失败;错误keystore密码导致启动失败,修正密码后重新请求成功。材料目录最终删除,进程均回收。
服务工厂必须同时取得SSL设置与bundle注册表。Boot v4.0.9 ServletWebServerFactoryCustomizer连续节选:
map.from(this.serverProperties::getSsl).to(factory::setSsl);
map.from(this.serverProperties.getServlet()::getJsp).to(factory::setJsp);
map.from(this.serverProperties::getCompression).to(factory::setCompression);
map.from(this.serverProperties::getHttp2).to(factory::setHttp2);
map.from(this.serverProperties::getServerHeader).to(factory::setServerHeader);
map.from(this.serverProperties.getServlet()::getContextParameters).to(factory::setInitParameters);
map.from(this.serverProperties.getShutdown()).to(factory::setShutdown);
map.from(() -> this.sslBundles).to(factory::setSslBundles);setSsl带来server侧的bundle选择,setSslBundles提供已登记材料;接下来服务器创建连接器时才使用它们。配置来源、材料加载、服务器应用和客户端校验是连续但可分别失败的步骤,因此用错密码和错误主机名两个负例,分别定位服务端材料与客户端身份校验。
跳过校验的-k只能遮蔽失败,不能当正常路径;只测证书信任而不测主机名,也可能把错误目标当合法服务。服务端需要私钥/证书,客户端通常需要信任材料;双向TLS还需客户端证书与服务器授权策略,本次不宣称已验证mTLS、生产轮换或代理终止TLS后的身份传递。PEM/JKS两种配置形状及使用边界见Boot v4.0.9 SSL,本机只运行PKCS12分支。
优雅停机有预算,不等于所有后台工作完成
Boot4的server.shutdown默认为GRACEFUL;本工程仍显式声明以便审查,旧Boot3.3.3基线不能无声套用该默认。spring.lifecycle.timeout-per-shutdown-phase是每个生命周期phase的等待预算,不是进程全关闭过程总上限。HTTP服务器、业务执行器、调度器、数据源也不是一个共同的“优雅”开关。
Tomcat路径先暂停connector/关闭接纳,再等在途请求或中止,Boot v4.0.9连续节选:
private void doShutdown(GracefulShutdownCallback callback, CountDownLatch shutdownUnderway) {
try {
List<Connector> connectors = getConnectors();
connectors.forEach(this::close);
GracefulShutdownResult result = awaitInactiveOrAborted(shutdownUnderway);
if (result == GracefulShutdownResult.REQUESTS_ACTIVE) {
logger.info("Graceful shutdown aborted with one or more requests still active");
}
else {
logger.info("Graceful shutdown complete");
}
callback.shutdownComplete(result);
}
finally {
shutdownUnderway.countDown();
}关闭接纳不回滚已经发生的业务;入口层还可能有keep-alive/代理缓存的路由状态。实验主动确认/lab/slow已经进入handler,再发真实SIGTERM,观测客户端结果、handler完成标记、新连接和进程回收:
| 场景 | 输入 | 实际观察与限度 |
|---|---|---|
| 预算足够 | handler耗时1200ms、phase预算4s | 既有请求200且completed=true,handler完成;新连接被拒绝;进程正常处理TERM并有界退出 |
| 预算不足 | handler耗时4500ms、phase预算300ms | phase预算耗尽/aborted,客户端RemoteDisconnected,未见完成标记;不能按“优雅”二字宣称成功 |
每次耗时以证据实际值为准,不拿本机一次约1秒的停机宣称生产SLA。TERM后的143是信号相关进程状态,入口将它与场景断言一起判定,不把任意非零都当成功。IDE停止/kill -9可能绕过同一协议;容器编排的termination窗口还要覆盖摘流传播、所有phase和资源关闭的总成本。
调度/执行器的两个开关也要分开:waitForTasksToCompleteOnShutdown选择继续已提交工作还是shutdownNow中断/清队列;awaitTermination只决定本阶段调用最多等多久。true但无等待时间,工作可与其他资源销毁并行;false加await,也不会神奇地保留已清队列。旧Framework6.1.12的准确分支和四任务实验证据见调度正文。后台任务若已提交SQL/消息,HTTP断连不能证明其结果,必须有幂等身份/持久意图和恢复查询;可靠交付归U61/U79–81。
将配置变成一份可审查的资源预算
合成负载:100请求/秒、每请求平均占用数据库连接50ms,在稳定条件下平均占用约5条连接。这是工作负载算例,非本机压测;突发、尾延迟、事务等待和故障会提高需求。HTTP maxThreads=200、applicationTaskExecutor=8、DB池=10并非天然协调:若200个HTTP线程都等10条DB连接,更多线程可能只增加等待与deadline超限。
方案一放大所有池并使用长等待,短期少拒绝但下游、内存和排队年龄更难约束;方案二按DB/下游预算限制入口和异步并发,采用小有界队列与明确拒绝,用户会更早看到失败,但故障边界与恢复成本可控。用有效配置来源、active/pending、队列年龄、接纳/拒绝/完成与真实业务不变量评估取舍,不以“CPU没满”判断可以无限扩池。
部署阶段先停止新接纳,在合理窗口内完成已接纳操作;窗口不足的工作需可重试且幂等,或记录结果未知并由恢复流程核对。发布团队负责制品/配置身份和回滚窗口,应用负责在途语义,平台负责TERM到强杀的总预算,数据库团队负责可分配连接。多副本和滚动额外实例使总连接上限相加;改变virtual/thread/queue模式是行为变化,应重跑拒绝、取消和停机,不能把“线程更轻”作为取消配额的理由。
实测、源码推演与待验证分开
公开入口与证据记录2026-10-26本机真实Boot/Tomcat/Jackson3/MySQL/TLS及两组TERM;依赖解析、命令输入、退出码、关键观测与清理可定位。固定源码解释任务实现选择、参数消费与关闭分支;虚拟调度周期重叠、生产容量、Jetty/Netty、K8s摘流、数据库事务恢复、生产证书轮换未运行。不以结构检查或本机HTTP200抵扣这些范围。
闭卷验收
本页 14 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。