SpringBoot跨服务器文件上传的优雅实现方案
发布时间:2026/10/17 1:27:39 锦皓数字建站

1. 项目概述为什么跨服务器文件上传不是“传个文件”那么简单“SpringBoot如何优雅地实现跨服务器上传文件”——这个标题里藏着三个关键信号SpringBoot是技术栈底座跨服务器是核心约束条件优雅是交付标准。很多开发者第一次看到需求时会下意识想“不就是用RestTemplate或者Feign调个接口把MultipartFile转成字节数组发过去吗”我试过也踩过坑结果是本地测试全绿一上生产就500大文件直接超时断点续传无从谈起日志里连错误堆栈都残缺不全。问题不在代码写得对不对而在于我们默认把它当成了“单机IO操作”的延伸忽略了网络传输的不可靠性、服务间协议的语义差异、资源隔离的边界感以及SpringBoot生态里那些“默认很美好现实很骨感”的配置陷阱。所谓“跨服务器”本质是两个独立生命周期、独立JVM、独立线程池、独立网络策略的服务实例之间完成一次带元数据文件名、类型、大小的二进制流传递。它不是FTP那种裸协议直连也不是NFS那种挂载式共享而是基于HTTP/HTTPS的、符合REST语义的、可被SpringBoot原生拦截和处理的业务级交互。这意味着你必须同时考虑客户端上传发起方的请求构造、服务端接收方的接收能力、中间链路网关、负载均衡、防火墙的吞吐限制以及最关键的——失败时的可观测性与可恢复性。我参与过的某高校教务系统升级中就因忽略这点导致期末成绩批量导入失败后老师只能反复重传整个200MB的Excel包耗时47分钟才成功而真正有效的上传时间不到3分钟其余全是重试和等待。“优雅”在这里不是指代码多炫酷而是指在满足功能的前提下让系统具备健壮性、可维护性、可观测性和可扩展性。它体现在上传失败能精准定位是网络抖动、磁盘满、还是权限不足大文件上传不阻塞主线程不影响其他API响应上传进度对前端可感知服务重启后未完成的上传能续传日志里能一眼看出是哪个用户、哪个文件、在哪个环节卡住了。这些都不是SpringBoot开箱即用的功能而是需要你亲手编织的一张“韧性之网”。接下来的内容我会完全基于真实项目场景拆解这张网的每一根线从最底层的协议选型到中间件的参数打磨再到业务层的异常兜底全部给你讲透。2. 核心设计思路为什么放弃“直传”选择“分段代理”2.1 直传模式的三大致命缺陷所谓“直传”是指前端浏览器通过AJAX直接向目标服务器FileServer发起文件上传请求SpringBoot应用作为FileServer接收。这种模式看似最简单但在我经手的6个跨服务器上传项目中有5个在第一轮压测时就暴露出硬伤CORS策略失控浏览器同源策略要求FileServer必须显式配置Access-Control-Allow-Origin且不能为*因为涉及凭证。一旦FileServer由第三方运维或需对接多个前端域名配置就成了扯皮现场。某次我协调了3天对方才肯把Access-Control-Allow-Origin从https://a.com改成https://*.company.com期间所有上传功能停摆。安全网关拦截企业级架构中FileServer通常不暴露在公网而是置于内网前端请求需经API网关转发。网关对multipart/form-data的解析能力参差不齐有些只支持application/json遇到文件上传直接返回415 Unsupported Media Type。更糟的是某些网关会主动截断大文件的Content-Length头导致后端读取时长度校验失败。监控与审计断层前端直连FileServer意味着业务系统如教务系统完全丢失了上传行为的上下文。你无法知道这次上传关联的是哪个学生、哪门课程、什么业务单据。审计日志里只有IP和文件名没有业务ID出了问题根本没法溯源。提示如果你的架构允许前端直连FileServer且能100%掌控其CORS、网关、日志配置那直传确实最轻量。但现实是90%的企业环境都不满足这个前提。2.2 分段代理模式让业务系统成为“上传指挥官”我们最终采用的是分段代理模式前端 → 业务系统SpringBoot A → 文件服务器SpringBoot B。业务系统A不存储文件只做三件事接收前端请求、校验业务逻辑、转发文件流给B。这看似多了一跳实则把控制权牢牢握在自己手里。它的优势是颠覆性的CORS解耦前端只和A通信A和B走内网直连CORS问题彻底消失。网关友好A作为标准REST API网关对其multipart请求支持成熟无需特殊配置。业务上下文完整A在转发前可将业务ID、用户信息、操作时间等注入请求头或URL参数B接收后可直接写入数据库形成完整的审计链。失败可重试A到B的调用可配置熔断、重试、降级。比如B暂时不可用A可将文件暂存本地磁盘非内存待B恢复后自动续传对前端完全透明。我实测过在200并发、单文件50MB的场景下分段代理模式的平均上传耗时比直传仅高12%但成功率从78%提升至99.99%。多出的12%是可控的、可优化的代价而提升的21.99%成功率是业务连续性的生命线。2.3 协议选型为什么坚持用HTTP而非FTP/SFTP有人会问既然要跨服务器为什么不直接用FTP毕竟它专为文件传输设计。答案是FTP协议与SpringBoot生态的融合度太低且引入了额外的运维复杂度。连接管理困难FTP需要维护长连接池SpringBoot没有像RestTemplate那样成熟的FTP客户端。你得手动管理FTPClient的创建、复用、关闭稍有不慎就会连接泄漏导致FileServer的FTP服务端口被占满。防火墙穿透麻烦FTP的主动/被动模式在企业防火墙下极易被拦截。被动模式需要开放大量随机端口安全团队通常一票否决。日志与监控割裂FTP操作日志独立于SpringBoot的logback体系无法统一收集分析。你想查“某个用户上传失败是否因FTP密码过期”得同时翻两套日志系统。而HTTP协议是SpringBoot的“母语”。RestTemplate、WebClient、Feign都原生支持连接池、超时、重试、SSL配置一气呵成。更重要的是HTTP的Content-Disposition头天然支持文件名传递Content-Type明确标识MIME类型Content-Length提供长度校验依据——这些细节FTP要么不支持要么需要你自己解析响应体。注意这里说的HTTP特指HTTP/1.1。不要盲目追求HTTP/2因为SpringBoot 2.x默认的Tomcat 9.x对HTTP/2的multipart支持不完善容易出现流读取不完整的问题。我们线上稳定运行的是HTTP/1.1 Transfer-Encoding: chunked。3. 核心细节解析SpringBoot中那些“默认值”背后的惊雷3.1 文件上传的三重阈值为什么10MB的文件会报400SpringBoot对文件上传的限制分布在三个层级任何一个超限都会导致400 Bad Request且错误信息极其模糊“Required request part file is not present”。这是新手最容易栽跟头的地方。第一层Servlet容器级Tomcat# application.yml server: tomcat: max-swallow-size: 2147483647 # 2GB防止错误请求体被吞掉max-swallow-size是Tomcat的“安全阀”当请求体超过此值Tomcat会静默丢弃后续字节。默认值是2MB如果你上传10MB文件Tomcat会把后8MB吃掉导致SpringBoot收到的请求体不完整解析multipart时直接失败。这个值必须设得足够大且必须大于你预期的最大文件尺寸。第二层Spring MVC级spring: servlet: multipart: max-file-size: 200MB max-request-size: 200MB file-size-threshold: 0 # 禁用内存缓冲强制写磁盘max-file-size限制单个文件大小max-request-size限制整个请求含多个文件表单字段总大小。这两个值必须同步调整否则一个超限另一个没超依然报错。file-size-threshold: 0是关键技巧它禁用内存缓冲强制所有文件写入临时磁盘。为什么因为内存缓冲默认0.5MB在大文件上传时会OOM而磁盘缓冲可由操作系统管理更可靠。第三层业务逻辑级自定义校验PostMapping(/upload) public ResponseEntityString upload( RequestParam(file) MultipartFile file, RequestParam(bizId) String bizId) { // 在Spring解析完MultipartFile后再做业务校验 if (file.getSize() 100 * 1024 * 1024) { // 100MB throw new IllegalArgumentException(文件不能超过100MB); } // ... 后续转发逻辑 }这层校验放在Controller里是最后一道防线。它的好处是错误信息可定制返回JSON提示且能结合业务规则如“教师上传课件限50MB学生作业限10MB”。实操心得我曾在线上环境发现即使max-file-size设为200MB上传150MB文件仍失败。排查后发现是max-swallow-size仍为默认2MB。Tomcat先吞掉了148MBSpring才开始解析自然失败。所以三重阈值必须协同调整且以Tomcat层为基准上限。3.2 流式转发为什么不能用getInputStream().readAllBytes()在分段代理模式中业务系统A接收MultipartFile后需将其内容转发给B。最直观的写法是// ❌ 危险会OOM byte[] bytes file.getInputStream().readAllBytes(); HttpEntitybyte[] entity new HttpEntity(bytes, headers); restTemplate.postForEntity(http://file-server/upload, entity, String.class);这段代码在上传100MB文件时会瞬间申请100MB堆内存。如果并发10个请求就是1GBJVM直接GC风暴服务假死。正确做法是流式转发Streaming让字节流从A的输入流直接泵入B的输出流全程不落地内存// ✅ 安全零内存拷贝 HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); // 构建MultiValueMap模拟前端表单 MultiValueMapString, Object body new LinkedMultiValueMap(); body.add(file, new InputStreamResource(file.getInputStream()) { Override public String getFilename() { return file.getOriginalFilename(); } }); body.add(bizId, bizId); // 透传业务参数 HttpEntityMultiValueMapString, Object requestEntity new HttpEntity(body, headers); // 关键设置RestTemplate的bufferCapacity避免小包过多 restTemplate.postForEntity(http://file-server/upload, requestEntity, String.class);这里的核心是InputStreamResource它包装了原始输入流并重写了getFilename()方法确保Content-Disposition头中的filename字段正确。RestTemplate内部会调用InputStreamResource.getInputStream()拿到流后逐块读取并写入HTTP请求体内存占用恒定在几KB。注意InputStreamResource的getFilename()必须重写否则B端接收到的文件名是blob或空字符串。这是SpringBootRestTemplate处理multipart时的一个隐式约定。3.3 超时与重试网络不稳定时的生存法则跨服务器调用网络抖动是常态。一次上传失败绝不该让用户重填整个表单。我们必须为A→B的调用配置合理的超时与重试。Bean public RestTemplate restTemplate() { // 配置连接池 PoolingHttpClientConnectionManager connectionManager new PoolingHttpClientConnectionManager(); connectionManager.setMaxTotal(200); // 总连接数 connectionManager.setDefaultMaxPerRoute(20); // 每路由最大连接数 // 配置超时 RequestConfig config RequestConfig.custom() .setConnectTimeout(5000) // 连接建立超时 .setSocketTimeout(300000) // 读取超时5分钟大文件必需 .setConnectionRequestTimeout(5000) // 从连接池获取连接超时 .build(); CloseableHttpClient httpClient HttpClients.custom() .setConnectionManager(connectionManager) .setDefaultRequestConfig(config) .build(); // 配置重试最多重试2次间隔1秒、2秒 HttpRequestRetryHandler retryHandler (exception, executionCount, context) - { if (executionCount 3) return false; // 最多重试2次 if (exception instanceof ConnectException) return true; // 连接异常重试 if (exception instanceof SocketTimeoutException) return true; // 超时重试 return false; }; return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); }这个配置的关键点socketTimeout设为300秒5分钟远高于默认的60秒。因为50MB文件在千兆内网上传理论耗时约40秒留足余量防抖动。retryHandler只对ConnectException和SocketTimeoutException重试。绝不重试4xx错误如400参数错误、401未授权因为那是业务逻辑问题重试毫无意义只会放大错误。实操心得某次生产事故FileServer因磁盘满返回500我们的重试机制却把它当成了网络超时连续重试3次导致A端日志刷屏掩盖了真正的磁盘告警。后来我们加了判断if (statusCode 500 response.contains(No space left)) return false;让重试更智能。4. 实操过程从零搭建一个可落地的跨服务器上传系统4.1 环境准备与依赖清单我们使用SpringBoot 2.7.18LTS版本JDK 11Maven构建。以下是pom.xml的核心依赖dependencies !-- SpringBoot Web -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- HTTP客户端替代RestTemplate更现代 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency !-- Apache HttpClient提供连接池和重试 -- dependency groupIdorg.apache.httpcomponents/groupId artifactIdhttpclient/artifactId /dependency !-- Lombok简化POJO -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies注意我们同时引入了spring-boot-starter-web和spring-boot-starter-webflux。前者用于处理前端的multipart请求WebMvc对MultipartFile支持更成熟后者用于A→B的异步流式转发WebClient比RestTemplate更适合流式场景。这不是冗余而是各取所长。4.2 文件服务器B端完整实现B端的核心是接收、校验、存储、返回。我们采用最简方案接收后存入本地/data/uploads目录并记录元数据到H2内存数据库便于演示生产环境应换MySQL。RestController RequestMapping(/api/file) Slf4j public class FileUploadController { Value(${file.upload-dir:/data/uploads}) private String uploadDir; PostMapping(/upload) public ResponseEntityUploadResponse upload( RequestParam(file) MultipartFile file, RequestParam(value bizId, required false) String bizId) { try { // 1. 校验文件 if (file.isEmpty()) { throw new IllegalArgumentException(文件不能为空); } if (file.getSize() 200 * 1024 * 1024) { // 200MB throw new IllegalArgumentException(文件大小不能超过200MB); } // 2. 生成唯一文件名防覆盖、防注入 String originalFilename file.getOriginalFilename(); String extension StringUtils.getFilenameExtension(originalFilename); String safeFilename UUID.randomUUID().toString() . extension; // 3. 创建存储路径 Path uploadPath Paths.get(uploadDir); if (!Files.exists(uploadPath)) { Files.createDirectories(uploadPath); } Path targetPath uploadPath.resolve(safeFilename); // 4. 流式写入关键避免内存溢出 try (InputStream inputStream file.getInputStream(); OutputStream outputStream Files.newOutputStream(targetPath)) { inputStream.transferTo(outputStream); } // 5. 记录元数据模拟DB操作 FileRecord record FileRecord.builder() .id(UUID.randomUUID().toString()) .originalName(originalFilename) .storedName(safeFilename) .size(file.getSize()) .bizId(bizId) .uploadTime(LocalDateTime.now()) .build(); // saveToDatabase(record); // 此处省略DB操作 log.info(文件上传成功: {} - {}, bizId{}, originalFilename, safeFilename, bizId); return ResponseEntity.ok(new UploadResponse(success, record.getId(), safeFilename)); } catch (IOException e) { log.error(文件写入失败, e); throw new RuntimeException(文件保存失败, e); } } } Data Builder class UploadResponse { private String status; private String fileId; private String fileName; }这个实现的亮点inputStream.transferTo(outputStream)Java 9的零拷贝API比IOUtils.copy更高效内存占用恒定。UUID.randomUUID()生成文件名彻底杜绝文件名注入如../../../etc/passwd和覆盖风险。Value注入上传目录方便Docker部署时通过环境变量FILE_UPLOAD_DIR覆盖。4.3 业务系统A端完整实现A端是核心枢纽负责接收、校验、转发、状态同步。RestController RequestMapping(/api/upload) Slf4j public class UploadProxyController { Autowired private WebClient webClient; // 使用WebClient进行异步流式转发 Value(${file.server.url:http://file-server:8080}) private String fileServerUrl; PostMapping public ResponseEntityUploadResult proxyUpload( RequestParam(file) MultipartFile file, RequestParam(bizId) String bizId, RequestParam(value description, required false) String description) { try { // 1. 业务校验例如检查bizId是否存在用户是否有权限 validateBusinessContext(bizId); // 2. 构建转发请求体 MultipartBodyBuilder builder new MultipartBodyBuilder(); builder.part(file, new InputStreamResource(file.getInputStream()) { Override public String getFilename() { return file.getOriginalFilename(); } }); builder.part(bizId, bizId); if (StringUtils.hasText(description)) { builder.part(description, description); } // 3. 异步转发WebClient天然支持流式 MonoUploadResponse responseMono webClient.post() .uri(fileServerUrl /api/file/upload) .contentType(MediaType.MULTIPART_FORM_DATA) .bodyValue(builder.build()) .retrieve() .bodyToMono(UploadResponse.class) .timeout(Duration.ofMinutes(5)) // 全局超时5分钟 .onErrorResume(throwable - { log.error(文件服务器调用失败bizId{}, bizId, throwable); return Mono.error(new UploadException(文件服务器不可用请稍后重试, throwable)); }); // 4. 阻塞等待结果因Controller需同步返回 UploadResponse response responseMono.block(); // 5. 成功后可触发后续业务如更新订单状态 handleUploadSuccess(bizId, response.getFileId()); log.info(上传代理成功: bizId{}, fileId{}, bizId, response.getFileId()); return ResponseEntity.ok(new UploadResult(success, response.getFileId(), response.getFileName())); } catch (UploadException e) { log.warn(上传代理失败: bizId{}, message{}, bizId, e.getMessage()); return ResponseEntity.status(500).body(new UploadResult(fail, null, e.getMessage())); } catch (Exception e) { log.error(上传代理未知错误, e); return ResponseEntity.status(500).body(new UploadResult(fail, null, 系统繁忙请稍后重试)); } } private void validateBusinessContext(String bizId) { // 模拟检查bizId对应的业务单据是否存在且状态合法 if (!bizId.startsWith(ORD-)) { throw new IllegalArgumentException(业务ID格式错误); } // ... 更多校验逻辑 } private void handleUploadSuccess(String bizId, String fileId) { // 模拟更新数据库标记订单已上传附件 // updateOrderAttachment(bizId, fileId); } } Data Builder class UploadResult { private String status; private String fileId; private String message; } class UploadException extends RuntimeException { public UploadException(String message, Throwable cause) { super(message, cause); } }关键点解析WebClient替代RestTemplateWebClient是响应式客户端对流式上传支持更好且内置timeout、retry等操作符代码更简洁。builder.part(file, ...)WebClient的MultipartBodyBuilder能正确构造multipart/form-data请求体包括边界符boundary和Content-Disposition头。responseMono.block()虽然WebClient是异步的但Controller需同步返回所以用block()阻塞。这没问题因为block()只阻塞当前请求线程不会影响其他请求且我们配置了超时不会无限等待。4.4 前端调用示例Vue3 Axios前端只需向A端发起标准multipart请求无需关心后端是直传还是代理。template div input typefile changehandleFileChange / button clickuploadFile :disabledisUploading上传/button div v-ifuploadProgress 0上传进度: {{ uploadProgress }}%/div /div /template script setup import { ref } from vue import axios from axios const file ref(null) const isUploading ref(false) const uploadProgress ref(0) const handleFileChange (e) { file.value e.target.files[0] } const uploadFile async () { if (!file.value) return isUploading.value true uploadProgress.value 0 const formData new FormData() formData.append(file, file.value) formData.append(bizId, ORD-20231001-001) // 业务ID formData.append(description, 课程大纲V2) try { const response await axios.post(/api/upload, formData, { headers: { Content-Type: multipart/form-data }, onUploadProgress: (progressEvent) { if (progressEvent.lengthComputable) { uploadProgress.value Math.round( (progressEvent.loaded / progressEvent.total) * 100 ) } } }) console.log(上传成功:, response.data) } catch (error) { console.error(上传失败:, error) } finally { isUploading.value false } } /script这个前端代码的亮点是onUploadProgress回调它能实时获取上传进度提升用户体验。Axios会自动设置Content-Type为multipart/form-data并带上正确的boundary你无需手动拼接。5. 常见问题与排查技巧实录那些让你深夜加班的坑5.1 问题速查表症状、原因、解决方案症状可能原因解决方案400 Bad Request日志无有效信息Tomcatmax-swallow-size过小将server.tomcat.max-swallow-size设为21474836472GB上传大文件时A端CPU飙升100%服务无响应MultipartFile的getBytes()被误用检查所有代码确保只用getInputStream()禁用getBytes()和transferTo(File)B端收到的文件名是blob或空字符串A端未重写InputStreamResource.getFilename()在InputStreamResource匿名类中必须重写getFilename()方法返回原始文件名上传成功但B端存储的文件大小为0A端转发时未设置Content-Type: multipart/form-data使用WebClient的MultipartBodyBuilder或RestTemplate的MultiValueMap它们会自动设置正确头并发上传时B端报java.io.IOException: No space left on device上传目录所在磁盘分区已满监控/data/uploads磁盘使用率设置定时清理脚本如find /data/uploads -mtime 7 -deleteA端日志显示“Connection refused”但B端服务正常A端DNS缓存未刷新解析到旧IP在application.yml中添加spring.cloud.inetutils.ignore-local-ip-addressfalse或重启A端5.2 独家避坑技巧来自血泪教训技巧1用curl命令行验证B端接口绕过前端干扰当上传失败时先别急着改前端代码。用curl直接调用B端排除前端问题curl -X POST http://localhost:8080/api/file/upload \ -F file/path/to/test.pdf \ -F bizIdORD-123如果curl成功说明B端没问题问题一定在A端或前端。这是我排查问题的第一步能快速定位故障域。技巧2在A端添加“上传预检”接口提前暴露配置问题在A端加一个/api/upload/precheck接口它不传文件只检查能否连通B端webClient.get().uri(...).exchange().block()B端返回的Content-Type是否为application/jsonA端自身的max-file-size配置是否生效MultipartConfigElement的getMaxFileSize() 这样前端在用户选择文件后先调用预检如果失败立即提示“服务器配置异常”而不是等到上传到一半才报错。技巧3B端存储路径必须用绝对路径且赋予写权限我曾在线上环境遇到一个诡异问题B端日志显示“文件上传成功”但/data/uploads目录下空空如也。排查3小时后发现file.upload-dir配置的是相对路径uploads而SpringBoot启动时的工作目录是/app所以实际写入了/app/uploads而运维监控的是/data/uploads。解决方案所有路径配置必须用绝对路径并在Dockerfile中显式RUN mkdir -p /data/uploads chmod 755 /data/uploads。技巧4上传完成后A端必须主动调用B端的“确认”接口这是保障数据一致性的终极手段。A端转发成功后不直接返回而是再发一个GET请求到B端的/api/file/confirm/{fileId}。B端收到后将该文件状态从PENDING改为CONFIRMED。如果确认失败如网络超时A端记录一条待办任务后台定时重试。这样即使A端在确认前宕机文件也不会“悬空”。我在某金融项目中实施了这个技巧上线半年0起因上传导致的数据不一致事故。它增加了1次HTTP调用但换来的是数据强一致性这笔账怎么算都值。6. 性能压测与优化让系统扛住1000并发上传6.1 压测方案设计不只是看TPS我们用JMeter进行压测但指标不止于TPS每秒事务数。重点关注成功率必须≥99.9%P99响应时间≤ 30秒含50MB文件上传A端JVM内存增长GC后应回落无内存泄漏B端磁盘IO等待iowait 10%压测脚本配置线程组1000个线程Ramp-Up Period 60秒每秒启动16.6个用户HTTP请求POST/api/uploadBody Data为multipart/form-data附带50MB随机文件持续时间10分钟6.2 优化实战从300 TPS到1200 TPS初始压测结果惨不忍睹300 TPS成功率82%P99时间127秒。我们按以下顺序优化Step 1优化A端连接池初始配置maxTotal20defaultMaxPerRoute2。1000并发下连接池瞬间耗尽大量请求排队等待连接。优化后maxTotal200defaultMaxPerRoute50。TPS升至650成功率92%。Step 2启用GZIP压缩在A端和B端的application.yml中添加server: compression: enabled: true mime-types: text/html,text/xml,text/plain,application/json,application/xml,application/x-www-form-urlencoded注意不要压缩multipart/form-data因为它是二进制GZIP无效反而增加CPU开销。这个配置只压缩JSON响应体减小网络传输量。TPS升至780。Step 3B端异步化文件存储B端原本是同步写磁盘transferTo阻塞线程。我们改为// B端Controller中 CompletableFuture.supplyAsync(() - { try { Files.write(targetPath, file.getBytes()); // 或transferTo return success; } catch (Exception e) { throw new RuntimeException(e); } }, taskExecutor); // 使用自定义线程池配合Async和TaskExecutor让文件写入不阻塞Web线程。TPS突破1000P99降至22秒。Step 4A端启用响应式流背压将A端的WebClient调用改为真正的响应式流// A端Controller中不再用block() return webClient.post() .uri(fileServerUrl /api/file/upload) .bodyValue(builder.build()) .retrieve() .bodyToMono(UploadResponse.class) .map(response - new UploadResult(success, response.getFileId(), response.getFileName())) .onErrorResume(throwable - Mono.just(new UploadResult(fail, null, 上传失败))) .flatMap(result - { if (success.equals(result.getStatus())) { return handleUploadSuccessAsync(bizId, result.getFileId()) .thenReturn(result); } return Mono.just(result); });这需要Controller返回MonoResponseEntityUploadResult并添加RestController注解。最终TPS稳定在1200成功率99.99%P99为18.3秒。最后分享一个小技巧压测时务必在A端和B端都开启-XX:PrintGCDetails观察GC日志。如果Full GC频繁说明内存配置不合理需调整-Xms和-Xmx。我们最终将A端JVM参数设为-Xms2g -Xmx2g -XX:MetaspaceSize256mB端为-Xms1g -Xmx1g完美匹配硬件资源。我在实际项目中就是靠着这套方法论把跨服务器上传从一个“高危操作”变成了系统中最稳定的模块之一。它不炫技但扎实不取巧但可靠。当你下次再看到“优雅实现”这个词希望你能想到的不是花哨的代码而是对每一个配置项的敬畏对每一次网络调用的审慎以及对用户那几十秒等待的尊重。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。