1. 项目背景:这是一条什么样的产线数据通道
这个标题一看就是真实生产环境里折腾过的项目。我把它拆成三个关键词:分片上传、SpringBoot、国产加密芯片。这三样东西单独拿出来都不算难,但组合到一起,坑就来了。
国产加密芯片在我们系统里的角色不是"算力单元",而是"存储末端"。产线上要把密钥种子、数字证书、固件二进制、烧录批次信息写进芯片内部受保护区域。芯片通过串口或者 SPI/I2C 桥接,跑着一个精简的文件系统,对外暴露类似块设备的读写接口。这套东西在消费级安全芯片、加密存储卡、车规安全模块里非常常见。
SpringBoot 在这里承担的是内网上位机服务,处理来自产测工位、灌装台、老化房等多个客户端的分片上传请求。一次典型的上传目标是 8MB 到 64MB 不等的固件包,如果直接拿MultipartFile一把梭,超过 32MB 的请求丢给网关很容易超时,中途任何一个网络抖动都要整包重传。分片上传被拉出来解决的就是"大文件 + 弱网络 + 可断点续传"这三个老问题。
适合看这篇文章的读者,多半和我当时一样:后端要对接硬件固件升级、产线测试或者芯片初始化工具,手上有 SpringBoot 经验但没深入过芯片端的写入行为。不用急,这篇文章完全按照实际踩坑的顺序来写,不是拿着官方文档念经。
但真正让我恼火的,不是把文件切碎,而是切碎后的每一片在碰到加密芯片时,出现了"上传速度快、落盘速度慢"这种撕裂感。后面我会详细讲为什么。
2. 为什么普通分片上传碰到加密芯片会"翻车"
先从覆盖范围最小的差异说起。
普通分片上传的典型链路:客户端把文件切成若干片,一片一片 POST 给 Nginx 或者网关,网关转发到 SpringBoot,SpringBoot 直接落进服务器本地磁盘或者对象存储。这个链条里,每个环节的写入速度都远远快于网络传输,瓶颈通常在网络。
一旦末端换成加密芯片,链条就完全反转。芯片内的 Flash 写入速度通常只有几百 KB/s 到几 MB/s,而且很多国产芯片为了保证数据安全性,会在普通 Flash 操作前增加 MAC 校验、状态寄存器轮询、甚至先擦后写。我实测过某款安全系列芯片的 4KB 扇区写入,单次写完加状态轮询要 80ms 左右,换算下来也就是 50KB/s 的量级。这个速度比局域网传输慢了一两个数量级。
2.1 普通上传方式在这里的三宗罪
第一宗罪是整包上传导致芯片写入缓冲区溢出。芯片一般只有 32KB 到 128KB 的页缓冲区,你一口气丢 16MB 数据,芯片端无法一次性承接,只能缓存一部分然后回写,一旦超时或者握手断开,所有数据全部作废,还得从头再来。
第二宗罪是分片大小和芯片擦写单元不对齐。加密芯片的 Flash 最小擦除单位通常是 4KB,有的甚至到 64KB。如果你按普通文件上传习惯把分片切成任意大小,比如每个分片 100KB,那么映射到 Flash 上就会出现一个分片横跨多个擦除块,每次写入都要触发"读旧块-改数据-擦除-写回",写放大非常严重。实测同样一个 16MB 文件,错位分片写入时间是按 4KB 对齐写入的 3 倍以上。
第三宗罪是每个分片都重新建立安全会话。国产加密芯片在正式写入用户数据之前,需要先完成双向身份认证,建立内存中的安全通道。这个握手过程往往要消耗几百毫秒,如果在 HttpClient 没开连接复用的情况下,每传一片就重新做一次 HTTP 握手加芯片握手,开销会被放大到让人无法接受。
这些都是我前期没有仔细做芯片端硬件调研导致的问题,严格说不是 SpringBoot 的锅。但在软件侧完全可以化解一半的伤害。
2.2 换个视角:把芯片当慢速设备,而不是当磁盘
一旦意识到芯片端是一个典型的"慢速块设备",优化思路就从"怎么传得快"变成了"怎么传得稳、怎么减少无效握手、怎么让写序和芯片对齐"。
整个过程很像在一个水管很细的小区里做供水调度——管口细不是问题,问题是谁也不知道什么时候该停、什么时候该继续加压。软件侧的角色更像一个调度员,要做好三件事:控制并发写请求、保持通信会话、按芯片的写入粒度切数据。
到这里,整体设计思路就清晰了:SpringBoot 负责把"大文件上传"抽象成"芯片可接受的一连串小写入操作",再用 HTTP 协议的高层特性把这些操作合理地串起来。
3. 方案选型:哪些优化是在 SpringBoot 侧可以落地的
这一节先做技术选型。当时我在以下几个方案之间犹豫过:
| 方案 | 优点 | 缺点 | 最终选择 |
|---|---|---|---|
| 传统 MultipartFile 整包上传 | 实现简单 | 无断点续传、网关超时风险高 | 放弃 |
| Nginx 分片转发 + 后端合并 | 减轻后端 IO 压力 | 芯片写入仍需后端对接 | 边缘场景用 |
| WebClient + 连接复用 + 显式分片 | 可控性强、TLS 会话可复用 | 需要自己处理合并校验 | 主用 |
| 引入对象存储 + 回调合并 | 云端成熟 | 产线内网不适用 | 放弃 |
最终定下来的是:客户端本地先计算分片,逐个调用 SpringBoot 的/upload/parts,后端收到分片后先做完整性校验,再串行写入芯片;全部完成后调用/upload/complete触发芯片侧文件系统提交。
关于 HTTP 客户端,我一开始用的是RestTemplate,后来发现它在连接池和背压控制上不如 WebClient 顺手,尤其是想限制并发请求数、复用 HTTP/1.1 长连接时,WebClient 的ConnectionProvider配置更直观。如果你用的 SpringBoot 2.x 及以上,建议直接基于 WebClient 做客户端,服务端就还是普通的 MVC Controller 也可以。
芯片通信层我封装了一个ChipWriter接口,内部走串口或 SPI 的字节流协议。对接不同品牌芯片时只需替换实现,这层设计是后期排查问题最快的入口。
3.1 必须做的前置确认:芯片烧写序列与写放大小
做任何代码之前,强烈建议先做两件事:
- 拿到芯片手册里的擦除块大小、页缓冲区大小。这两个参数决定分片大小上限和对齐粒度。
- 实测量一下芯片写一个对齐块和错位块的时间差。直接写一段测试脚本,对着芯片连续写 100 个扇区,记录总耗时和失败率。我做过几次之后发现,不同厂家的芯片对错位写入的容忍度差别巨大,有些芯片错位写直接返回错误码。
这两个参数最终会成为后端接口的元数据。比如我可以让/upload/init返回:
{ "sessionId": "abc123", "chunkSize": 1048576, "alignSize": 4096, "maxConcurrentWrites": 1, "alreadyUploaded": [] }客户端拿到这个元数据后,自然会把分片对齐到 4KB 的整数倍,并且知道要串行提交。
3.2 引入"会话"概念,而不是裸传分片
芯片端的握手成本高,所以我设计了上传会话:客户端先调用/upload/init,服务端创建会话并完成一次芯片握手,把已建立的通道资源保存在会话上下文中。后续所有分片请求都携带sessionId,服务端根据会话复用芯片通道,避免反复认证。
这点和普通文件上传的区别很大。普通上传是"无状态"的,服务端不关心你传第几片;芯片场景必须"有状态",因为芯片内部的文件描述符、写偏移量、加密通道密钥都在会话里存着。
会话需要一个 TTL。芯片握手通道一般能保持几分钟到几十分钟,取决于芯片内部看门狗。我设置的默认 TTL 是 5 分钟,过期后客户端需要重新 init。实践证明,TTL 太短会导致大文件中途断链,太长则可能让芯片端会话资源耗尽,所以要根据芯片规格动态配置。
4. 核心实现:SpringBoot 分片上传落地代码
这一节给出核心代码片段和相关参数设计。为了不占篇幅,我只保留关键逻辑,聚焦在"如何控制并发、如何对齐芯片写入、如何做断点续传"上。
4.1 上传会话初始化接口
@RestController @RequestMapping("/api/upload") public class UploadController { private final UploadSessionManager sessionManager; private final ChipWriter chipWriter; @PostMapping("/init") public UploadInitResponse init(@RequestBody UploadInitRequest req) { // 1. 根据文件名和大小创建会话 UploadSession session = sessionManager.createSession(req.getFileName(), req.getFileSize()); // 2. 与芯片握手,建立安全通道 String chipSession = chipWriter.authenticate(req.getChipNo()); session.setChipSession(chipSession); // 3. 读取芯片块参数,并在服务端计算对齐后的分片大小 int alignSize = chipWriter.getAlignSize(req.getChipNo()); int chunkSize = alignChunkSize(req.getChunkSize() > 0 ? req.getChunkSize() : 1024 * 1024, alignSize); // 4. 返回已有的已上传分片索引(断点续传) List<Integer> doneIndexes = sessionManager.getCompletedPartIndexes(session.getId()); return UploadInitResponse.builder() .sessionId(session.getId()) .chunkSize(chunkSize) .alignSize(alignSize) .alreadyUploaded(doneIndexes) .build(); } }alignChunkSize的逻辑很简单:如果客户端没有主动指定分片大小,就取 1MB 并对齐到 4KB 的整数倍;如果芯片页缓冲区较小,则取一个不超过页缓冲区一半的值。这样做是为了保证服务端每次写入刚好对应芯片端的整数个块。
理由:如果分片太大,客户端和服务端的内存压力都大;如果分片太小,HTTP 请求数和芯片写状态轮询会翻倍。我在实际生产里的经验阈值是 256KB 到 2MB 区间内,可以根据网络环境和芯片速率调节。
4.2 分片上传与串行写入控制
分片上传接口的难点不在接收,而在写入。
@PostMapping("/parts") public UploadPartResponse uploadPart(@RequestParam("sessionId") String sessionId, @RequestParam("index") int index, @RequestPart("file") MultipartFile file) { UploadSession session = sessionManager.getSession(sessionId); if (session == null) { throw new SessionExpiredException(); } // 1. 校验分片索引是否合法、是否重复 session.checkPartIndex(index); // 2. 服务端先做 SHA-256 校验,防止网络层坏包 String clientDigest = file.getOriginalFilename(); // 自定义头传递 String serverDigest = DigestUtils.sha256Hex(file.getBytes()); if (!clientDigest.equals(serverDigest)) { throw new DigestMismatchException(index); } // 3. 关键点:服务端所有芯片写入都要走同一个串行槽 boolean acquired = session.getWriteSemaphore().tryAcquire(30, TimeUnit.SECONDS); if (!acquired) { throw new BusyException("chip write queue is full, retry later"); } try { chipWriter.write(session.getChipSession(), index * session.getChunkSize(), file.getBytes()); sessionManager.markPartCompleted(sessionId, index); } finally { session.getWriteSemaphore().release(); } return UploadPartResponse.ok(index, session.getCurrentOffset()); }这一步有个极其重要的实践:芯片写入绝不能并发。虽然 HTTP 层可以同时飞来 10 个分片请求,但芯片端同一时刻只能处理一次写操作,如果多个线程同时去写芯片,轻则状态寄存器读错,重则把芯片写挂。所以我给每个会话配了一个Semaphore(1),把并发的网络请求串行化到芯片写入层。这部分是 SpringBoot 优化里最具实际价值的一处。
注意,这里我用的是tryAcquire而不是阻塞获取。网络请求的线程不能无限等下去,否则前端会先超时。等不到写槽就返回BusyException,客户端收到之后延迟重试这一片。这比"在服务端排队等 3 秒再返回"体验好得多。
4.3 断点续传与已上传分片记录
断点续传需要服务端记录哪些分片已经成功写入。我最初的设计是放在内存 Map 里,后来发现重启丢失、还要考虑分布式部署,就改成了数据库表,核心模型只有三列:session_id、part_index、status。
在/upload/init返回alreadyUploaded列表后,客户端会跳过这些分片,直接从缺口继续传。对芯片场景尤其重要的是,芯片是追加写模型,重复写同一偏移量会导致数据错乱,所以断点续传不能靠"重传覆盖",必须靠"确实知道哪一片写过了"。
另外,我建议在分片上传完成后,服务端主动回读芯片对应区域,做一次端到端哈希比对。这个步骤会多花一点时间,但能拦截芯片端静默写错的问题。在产线上,这种兜底远比省几秒钟有价值。
4.4 合并与提交接口
全部片上传完成后,客户端调用/upload/complete:
@PostMapping("/complete") public UploadCompleteResponse complete(@RequestParam("sessionId") String sessionId, @RequestParam("totalSize") long totalSize, @RequestParam("totalSha256") String totalSha256) { UploadSession session = sessionManager.getSession(sessionId); session.validateAllPartsUploaded(); // 合并:对芯片数据做完整性校验 String computed = session.computeTotalHash(); if (!totalSha256.equals(computed)) { throw new IntegrityCheckException("total hash mismatch"); } // 提交芯片文件系统:关闭文件描述符,刷新索引 chipWriter.commit(session.getChipSession()); sessionManager.closeSession(sessionId); return UploadCompleteResponse.ok(); }这里有几个细节值得注意:
- 合并接口返回成功后,芯片会话会被关闭,再传分片就会被拒绝,这样可以防止客户端在 complete 之后又重传导致数据覆盖。
- 完整校验必须包含所有分片的哈希聚合,而不是只信任客户端给的哈希。我用的做法是客户端传一个
totalSha256,服务端把所有分片文件再算一遍,确保两端一致。 - 如果芯片文件系统支持
fsync,在 complete 里调用一次,可以防止掉电丢数据。
5. 调优实录:连接复用、并发窗口与超时配置
写代码只是第一步。真正让性能落地的是下面这些调优参数,这也是我认为这篇文章最值得收藏的部分。
5.1 WebClient 连接复用与 HTTP 连接池
客户端如果使用 WebClient,必须主动配置连接池。默认的连接池对长连接的支持并不激进,遇到高并发分片上传时容易反复建立 TCP 连接。
ConnectionProvider provider = ConnectionProvider.builder("chip-upload") .maxConnections(500) .maxIdleTime(Duration.ofSeconds(60)) .maxLifeTime(Duration.ofMinutes(10)) .pendingAcquireTimeout(Duration.ofSeconds(20)) .build(); HttpClient httpClient = HttpClient.create(provider) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000) .responseTimeout(Duration.ofSeconds(30)); WebClient webClient = WebClient.builder() .clientConnector(new ReactorClientHttpConnector(httpClient)) .build();这里有三个参数是实战中折腾了很久才调明白的:
maxIdleTime不能太短,如果芯片握手会话 5 分钟 TTL,但连接 60 秒就闲置关闭,下一个分片就得重新建连接,之前的 TLS 会话复用也就没意义了。pendingAcquireTimeout决定当池子里所有连接都被占用时,新请求是等待还是快速失败。我设置 20 秒,正好和分片重试策略匹配。responseTimeout要略大于芯片最慢一次写入时间。如果芯片单次写入 5 秒,响应超时别设 3 秒。
5.2 并发上传窗口:客户端不能一次性喷出全部分片
回到芯片慢速写入这个现实:如果客户端连续发送 64 个分片,服务端网络层可能瞬时全部收到,但写芯片只能一个个来,内存中就会积压大量分片数据。64MB 的文件分片成 512KB,全部堆在内存里也是 128MB 起步,多台设备同时上传就会 OOM。
我最终在客户端加了并发窗口限制,典型值是 2 到 4。也就是说同时最多只有 2 到 4 个分片请求在途,其余分片等前序完成后再发。这个思路有点像 TCP 的滑动窗口,简单有效。
服务端也可以做一层保护,通过信号量限制全局待写缓冲区大小。当待写数据超过 16MB 时,直接返回 503,让客户端降速。比盲目扩充 JVM 堆内存要健康得多。
5.3 服务端线程池与异步写入的取舍
SpringBoot MVC 默认使用 Tomcat 线程池处理请求,而写芯片的操作是阻塞的,会占用 Tomcat 工作线程。如果你用spring-boot-starter-webflux,则可以借助事件循环的响应式能力减少线程占用,但驱动芯片的底层通道依然是阻塞 IO,最终还是得交给一个独立线程池。
我的做法是:Controller 层方法接收分片后,把数据交给一个独立的写入线程池(大小对应芯片通道数),Controller 立即返回,客户端再通过轮询接口确认写入结果。异步化之后的吞吐量比同步阻塞高出不少,但实现复杂度也高了很多。如果团队人力有限,可以先同步实现,稳定后再优化吞吐。
5.4 实测数据:不同策略下的上传耗时
下面这些数字来自我们产线上的某国产加密芯片,芯片接口速率约 2MB/s,单次 4KB 块写入约 80ms,文件大小 16MB。
| 策略 | 总耗时 | 失败率 | 备注 |
|---|---|---|---|
| 整包上传(无分片) | 超时/失败 | 高 | 网关 30s 超时直接中断 |
| 100KB 任意分片 + 每片新连接 | 约 12 分钟 | 5% | 写放大严重,连接开销巨大 |
| 1MB 对齐分片 + 连接复用 | 约 45 秒 | 0.5% | 写入速度瓶颈已接近芯片极限 |
| 256KB 对齐分片 + 连接复用 + 串行写槽 | 约 38 秒 | <0.1% | 分片减小降低等待时间 |
| 512KB 对齐分片 + 会话复用 + 并发窗口 2 | 约 35 秒 | <0.1% | 最佳平衡点 |
可以看到,真正影响成绩的其实不是 SpringBoot 本身的性能,而是"是否保留了芯片会话 + 分片是否对齐 + 是否将网络并发转成芯片端串行"这三件事。连接复用带来的提升非常明显,从 12 分钟到 45 秒看似夸张,实操里一点不夸张。
6. 常见问题排查与避坑经验
这一节整理我在实际部署和维护中遇到的高频问题,可以直接当成排查手册用。
6.1 问题速查表
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 传完一片后一直 503 | 服务端写槽被占用,客户端重试策略不友好 | 检查 Semaphore 超时设置,客户端退避重试 |
| 第 N 片写入成功但扫码校验失败 | 分片索引错位或客户端重复传片 | 服务端严格记录已提交索引,complete 前校验分片连续性 |
| 上传速度突然变慢 | 芯片写入缓冲未对齐,触发跨块擦写 | 按 alignSize 重算分片大小 |
| 大文件传一半芯片会话失效 | TTL 过短或芯片看门狗 | 动态延长 TTL,或自动续期 |
| 同一分片反复重传后数据错乱 | 断点续传记录丢失 | 用数据库记录已提交分片,重启后不丢 |
| 客户端上传多个文件时互相卡顿 | 全局写槽设计成单例了 | 每个会话独立写槽,不同芯片通道可并行 |
6.2 三条最值得说的心得
第一,不要迷信"加大分片能减少请求数"。在芯片上,分片越大越容易撞上芯片内部缓存和擦写块边界,再加上重传成本成倍增加。宁可多传几十个请求,也要让每次写入是稳的。
第二,所有关于芯片的异常都要透出到客户端。最开始我习惯在服务端吞掉芯片异常,只返回"上传失败",前端根本不知道是网络问题还是芯片烧写问题。后来我在返回体里加上errorCode和chipStatus,产线工人和运维一眼就能定位是"端侧重试"还是"需要换芯片"。
第三,测试环境一定要用真实芯片,别用模拟器。模拟器只能验证 HTTP 链路,无法暴露芯片擦写时间、看门狗、状态轮询等真实行为。我在实验室用模拟器调出来的参数,上产线第一批就发现了分片错位问题。
最后再分享一个小技巧:在服务端给每个芯片会话生成一个单调递增的写序号,客户端可以把它回传给服务端做幂等判断。哪一片传重了、哪一片漏了,在 complete 时一次性对账,能省下大量排查时间。
我自己在把这个方案从实验室搬到产线的过程中,最大的体会不是学会了多少 SpringBoot 高级特性,而是明白了"后端性能优化"这件事要放到真实的写入端去检验。HTTP 分片上传的通用套路在网上到处都是,但如果末端是一个拥有自己性格的加密芯片,所有默认参数都得重新审视一遍。先把芯片手册读懂,再谈优化,这比什么都重要。希望这篇文章能帮你少走一些弯路。