文件、流式响应与 Servlet 异步:线程释放不等于任务失控
Servlet 异步能让容器线程提前归还,但不会凭空增加下游容量,也不会自动取消后台工作。文件下载、StreamingResponseBody 与 SSE 还会把异常从“写响应前”推迟到“响应已提交后”。因此容量、超时、断连和清理必须与普通 JSON 请求分开设计。
异步请求经历两次容器协作
Controller 返回 Callable、DeferredResult 或可完成的异步值时,RequestMappingHandlerAdapter 会启动 Servlet async,把原始容器线程交还线程池。后台结果完成后,请求以 ASYNC dispatcher type 再次分派,MVC 才继续执行返回值处理和消息转换。第一次退出不是响应完成,Filter 若只围绕第一次调用统计耗时,会把后台耗时漏掉;清理 ThreadLocal 若只放在后台回调,又会污染原容器线程。
异步执行器必须显式设置有界队列、最大线程数、拒绝策略和任务超时。使用无界队列只是把过载从 HTTP 连接搬到堆内存。请求超时、客户端断连与应用关闭都应触发取消信号;业务代码若忽略中断,超时只停止等待,任务仍会消耗数据库连接和线程。
文件上传先限制入口,再决定落盘语义
multipart 限额要同时存在于反向代理、Servlet 容器和应用层,最小的一层决定客户端观察到的行为。文件名属于不可信输入,不能直接拼接目标路径;内容类型也只是声明,安全场景还要检查魔数、解压倍率、病毒与租户配额。大文件应流式落到隔离临时区,完成校验后原子发布;数据库记录、对象存储上传与临时文件删除要有可恢复状态,而不是假装它们处于同一事务。
下载端在已知长度时设置 Content-Length,在支持范围请求时正确处理 Range、If-Range 与 206。不要先把整个文件读入 byte[];这会让并发下载的内存成本等于文件大小乘并发数。文件句柄必须在完成、失败和断连三条路径关闭。
流式响应的提交点改变了错误语义
普通 JSON 在序列化完成前通常还能选择错误状态。流式响应一旦写出头部或第一段数据,状态与多数 header 已提交,后续异常不能再由 ControllerAdvice 改成 ProblemDetail。协议应在首帧携带版本和流 ID,在数据帧中定义业务错误或终止帧;服务端日志则记录 committed 状态、已写字节、终止原因和客户端断连。
StreamingResponseBody 使用 MVC 配置的异步执行器执行写操作。若输出速度慢于生产速度,Servlet MVC 本身不提供响应式背压协议;应用必须限制缓冲区、分块读取并让写阻塞反向限制生产,或把真正需要端到端背压的场景放到响应式栈。SSE 也需要心跳、最大连接时长、每租户连接上限与重连游标。
超时必须形成单调收敛的预算
代理空闲超时、Servlet async timeout、业务 deadline、数据库超时和 HTTP 客户端超时不应相互独立。外层预算要略大于内层,并预留错误写回和清理时间。若数据库查询允许 30 秒,而 MVC 在 5 秒终止等待,之后 25 秒就是无人接收的资源消耗。把 deadline 放进显式上下文,向线程池任务与下游调用传递剩余时间,比在每一层复制固定毫秒数更可靠。
线程池容量可以先用近似关系检查数量级:并发中的后台任务约等于到达率乘平均任务时间。若每秒进入 200 个异步请求,后台阶段平均 0.5 秒,稳定态就需要约 100 个并发槽位;这还没计算长尾、下游连接池和突发。执行器线程数不能超过数据库连接、远程服务并发或 CPU 能承受的最小瓶颈。队列只吸收短暂突发,队列等待时间必须计入 deadline;拒绝时立即返回可识别的过载结果,不能回退到容器线程同步执行。
SSE 连接数与普通请求吞吐量是不同容量维度。每个连接即使很少发送事件,也占用连接状态、心跳调度和代理资源。事件 ID 应单调且能用于 Last-Event-ID 恢复,但恢复窗口必须有上限;客户端落后超过保留窗口时返回显式重建信号,不能无限缓存。心跳只维持空闲链路,不代表业务生产者健康,因此还需独立记录最近业务事件时间与发送失败原因。
优雅停机时先拒绝新流和上传,再给已存在的短任务一个受限完成窗口;长寿命 SSE 应发送服务端终止事件,引导客户端退避重连到其他实例。窗口到期后取消生产者、关闭文件和输出流,最后关闭执行器。若先停执行器再通知连接,完成回调可能永远得不到执行,活动请求计数也无法归零。
上线压测不能只测每秒请求数,还要覆盖慢客户端、半途断开、零字节文件、超大文件、超时边界和应用优雅关闭。验收指标包括活动 async 请求数、执行器队列深度、拒绝数、首字节延迟、流持续时间、写出字节、取消延迟与清理失败数。
用可运行模型固定边界
两个模型不依赖 Spring 运行时,而是把策略选择和状态迁移压缩成 Java 17 程序。它们不是框架源码替身;价值在于先固定不变量,再回到真实应用观察哪个扩展点破坏了不变量。
javac --release 17 -Xlint:all -Werror examples/backend-development/spring-mvc/file-stream-sse-async/AsyncRequestLifecycleDemo.java examples/backend-development/spring-mvc/file-stream-sse-async/StreamingCancellationDemo.java
java -cp examples/backend-development/spring-mvc/file-stream-sse-async AsyncRequestLifecycleDemo
java -cp examples/backend-development/spring-mvc/file-stream-sse-async StreamingCancellationDemophases=[servlet-enter, startAsync, container-thread-exit, worker-complete, async-dispatch, response-commit] containerThreadHeld=false
produced=3 clientDisconnected=true producerCancelled=true第一条输出验证正常路径,第二条刻意暴露分支或失败路径。把这两条输出放进持续集成,能防止重构只保持“接口能返回”,却改变匹配优先级、清理时机或错误契约。
状态图强调“选择先于执行、提交限制补救、所有分支最终清理”。真实框架包含更多策略对象,但任何自定义扩展都不应打破这三个约束。
把故障定位到第一个发生偏差的阶段
生产观测要控制基数。handler 模板可以作为指标标签,原始 URL、用户 ID、游标和异常消息不能直接成为标签。细节进入带采样和脱敏的日志或 trace;指标只回答哪一阶段、哪类结果、耗时分布是否偏离基线。
建立可回归的完成标准
当一个问题出现时,先判断它属于 Servlet 入口、MVC 策略选择、业务 handler 还是响应输出,再选择证据和修复点。这样扩展 Spring MVC 时增加的是明确策略,而不是更多互相覆盖的全局钩子。
