Gin实现分片上传与断点续传:从本地存储到OSS预签名直传的完整方案

发布时间:2026/9/29 15:41:42
Gin实现分片上传与断点续传:从本地存储到OSS预签名直传的完整方案
做上传功能的人应该都有过这种体验一个文件传了半天进度条卡在 99%然后网络抖一下整个上传失败用户骂骂咧咧地重传。这个问题的根源在于普通上传是一次性把整个文件塞给服务端任何一个环节出问题之前的字节全部作废。我在用 Gin 做文件上传服务时从最初的单文件上传一路做到分片上传、断点续传再到把流量从应用服务器卸载到 OSS 直传中间踩了不少坑。这篇文章就把整套方案的链路设计、接口实现、并发边界和预签名直传的完整思路拆开讲清楚给准备做类似功能的人一个可以直接落地的参考。先说结论分片上传解决的是单次传输体量过大导致失败率高的问题断点续传解决的是失败后全部重来代价太大的问题而 OSS 直传预签名解决的是所有文件流量都过应用服务器带宽被打爆的问题。三者是层层递进的关系配合起来才能算一套抗造的上传方案。1. 为什么需要分片上传与断点续传普通上传的瓶颈与崩溃现场1.1 你传一个 2GB 视频失败时发生了什么先看普通上传在 Gin 里的典型实现。前端把整个文件放到 multipart/form-data 里一个 POST 请求发给服务端Gin 通过c.FormFile()拿到文件再c.SaveUploadedFile()保存到磁盘。这个流程对几十 MB 的小文件毫无压力但文件一旦到 GB 级别问题就全出来了。首先是 HTTP 连接超时。一个 2GB 的文件在普通家庭宽带上传按 5MB/s 算需要差不多 7 分钟。这 7 分钟内任何一次网络抖动、浏览器超时、代理断开都会让连接中断。连接一断服务端收到的文件不完整整个请求失败用户只能从头再来。其次是内存和磁盘的双重压力。如果服务端用io.ReadAll把 body 读完再处理2GB 文件会直接压爆进程内存。就算用流式接收存到临时文件服务端也要同时处理大量用户的上传请求每个请求占一个 goroutine 和一份文件句柄带宽和 IO 很容易被占满。最后是校验粒度太粗。整个文件传完后才能算 MD5一旦网络把中间某个字节传坏了你根本不知道是哪个位置出了问题只能整体重传。1.2 分片和断点续传分别解决了哪两类问题分片上传的思路很简单把一个大文件切成若干小块逐块上传全部传完后再合并。这样一来单次请求的体量被限制在一个可控范围比如 5MB 或 10MB就算某一片失败只需要重传这一片而不是整个文件。断点续传更进一步。它要求服务端能记录这个上传任务已经收到了哪些分片客户端在重新发起上传时先查询已有进度跳过已经上传成功的分片只补传缺失的部分。这就是标题里断点续传和完整上传的区别——完整上传是每次都从 0 开始断点续传是从上次中断的位置继续。这里要顺带澄清一个常见认知断点续传不等于秒传。秒传是文件在服务端已经存在直接跳过全部传输返回一个已存在的标识。断点续传是文件还没传完只把缺失的部分补上。两者依赖的元数据不同但可以共存在同一个上传体系里后面会讲到怎么联动。2. 整体链路设计四个接口组成的上传任务模型2.1 先想清楚客户端、Gin 应用、对象存储各干哪些活再来写代码我设计这套方案时给自己定了一个原则客户端只负责切分文件和调度传输Gin 应用只负责元数据管理、校验和合并触发对象存储OSS 或其他 S3 兼容存储只负责最终文件的存储和直传流量承接。三方分工明确后面每一步才不容易乱。如果让 Gin 应用去转发文件内容那 OSS 直传的优势就没了如果让客户端直接拼最终文件服务端就没法做统一校验和审计。所以三条链路各自独立但由 uploadID 这个任务标识串起来。整个上传流程由四个核心接口支撑InitUpload 初始化上传任务返回 uploadID 和分片参数 UploadChunk 上传单个分片携带 uploadID、分片索引和分片文件 QueryUpload 查询上传进度返回已上传完成的分片列表 MergeUpload 触发合并服务端校验完整后生成最终文件其中 UploadChunk 是唯一涉及文件二进制流传输的接口其余三个都只处理 JSON 元数据。2.2 为什么最终要回到服务端合并而不是边传边拼有人会问既然分片是一个个传上来的为什么不在每个分片到达时就追加写入最终文件省掉合并这步这个想法听起来合理实际做会很麻烦。网络请求的到达顺序是乱的分片 5 可能在分片 3 之前到达如果边传边拼最终文件就是乱的。你可以在每个分片到达时写到一个独立位置用随机写入最终文件但这就要求最终文件的占位空间预先分配好顺序还是乱的。随机读写在本地磁盘上不是大问题但对于后期要做完整性校验、分片重传、并发一致性检查的场景每个分片独立存储、独立校验反而更简单清晰。所以我选择了分片独立落盘、全部就绪后统一合并的模型。这样每个分片文件的生命周期很干净分片到达 - 校验大小和 MD5 - 标记完成 - 全部完成 - 按索引顺序合并 - 删除分片文件。后面讲并发控制和断点续传都建立在这个模型之上。3. 分片接收与临时落盘接口实现里的关键决策3.1 分片大小怎么定其实取决于你的用户网络分片大小是整个方案里最先要确定的参数。太小了不行比如 1MB 一片传一个 2GB 文件要发出 2000 个请求每个请求都有建立连接、传输、等待响应的开销总耗时反而更高。太大了也不行比如 100MB 一片断点续传的粒度就太粗网络稍有不稳就得重传一个很大的分片。我实际项目里的经验是分两类处理文件类型建议分片大小原因小于 1GB 的文件5MB - 10MB请求数可控失败重传代价小大于 1GB 的大文件20MB - 50MB减少总请求数配合 2-3 路并发足够稳定分片大小一旦确定整个上传任务就固定下来初始化接口里要记录这个值。这样在合并校验时服务端就知道这个任务的分片是 20MB 一片最后一个分片可能不足 20MB但其他分片必须严格等于 20MB。别小看这个细节合并时发现分片大小异常是经常遇到的事。最后一个分片的特殊处理也要提前约定最后一个分片通常比正常分片小校验逻辑里要允许最后一片的大小小于等于 chunkSize而不是强制等于。很多新手在这里会出 bug把最后一个分片当成异常拒绝了。3.2 初始化接口生成 uploadID把任务元数据落下来初始化接口做的事情很简单但它是整个上传任务的地基。前端把文件名、文件总大小、分片大小、总分片数、文件 MD5 一起 POST 过来Gin 这边校验参数合法后生成一个唯一的 uploadID把元数据保存下来。type InitUploadReq struct { FileName string json:file_name binding:required FileSize int64 json:file_size binding:required ChunkSize int64 json:chunk_size binding:required,min1048576 TotalChunks int64 json:total_chunks binding:required,min1 FileMD5 string json:file_md5 } func InitUploadHandler(c *gin.Context) { var req InitUploadReq if err : c.ShouldBindJSON(req); err ! nil { c.JSON(400, gin.H{error: err.Error()}) return } uploadID : generateUploadID(req.FileMD5, req.FileSize) // 元数据写入 redisttl 设置为 24 小时 key : uploadMetaKey(uploadID) pipe : rdb.TxPipeline() pipe.HSet(ctx, key, file_name, req.FileName) pipe.HSet(ctx, key, file_size, req.FileSize) pipe.HSet(ctx, key, chunk_size, req.ChunkSize) pipe.HSet(ctx, key, total_chunks, req.TotalChunks) pipe.HSet(ctx, key, file_md5, req.FileMD5) pipe.Expire(ctx, key, 24*time.Hour) pipe.Exec(ctx) c.JSON(200, gin.H{upload_id: uploadID, chunk_size: req.ChunkSize}) }这里用 Redis 而不是 MySQL是因为上传进度是高频写入数据分片完成状态的更新频繁且需要并发读写Redis 的 Hash 结构天然适合。如果后续要追查历史上传记录可以再用 GORM 建一张 upload_task 表做持久化归档但实时状态维护用 Redis 更顺手。uploadID 的生成我建议结合文件 MD5 和大小来做同一份文件重复初始化时可以直接复用已有的上传任务这也是后面秒传的伏笔。初始化接口还有一个容易被忽视的作用它把整个任务的规格固定在服务端了。后续每个分片上传时不需要信任前端传来的分片大小以服务端记录的 chunkSize 为准做校验。3.3 分片接收接口落盘、校验、记录状态三步走分片接收是整个上传链路里最核心的接口。每个分片上传时Gin 接口做三件事接收分片文件并落盘校验分片的大小是否合法更新 Redis 里的分片完成状态。func UploadChunkHandler(c *gin.Context) { uploadID : c.PostForm(upload_id) chunkIndex : c.PostForm(chunk_index) file, err : c.FormFile(chunk) if err ! nil { c.JSON(400, gin.H{error: chunk file required}) return } // 打开上传的分片文件准备写入临时目录 src, err : file.Open() if err ! nil { c.JSON(500, gin.H{error: err.Error()}) return } defer src.Close() chunkDir : filepath.Join(tmpDir, uploadID) os.MkdirAll(chunkDir, 0o755) dst : filepath.Join(chunkDir, fmt.Sprintf(chunk_%s.part, chunkIndex)) out, err : os.Create(dst) if err ! nil { c.JSON(500, gin.H{error: err.Error()}) return } defer out.Close() io.Copy(out, src) // 校验文件大小是否符合预期并更新进度 info, _ : out.Stat() if err : updateChunkStatus(uploadID, chunkIndex, info.Size()); err ! nil { c.JSON(500, gin.H{error: err.Error()}) return } c.JSON(200, gin.H{chunk_index: chunkIndex, status: ok}) }分片文件命名用chunk_{index}.part好处是后续合并时可以按文件名后缀的数字排序不用额外维护一个分片顺序表。临时目录按 uploadID 建一级目录这样同一个上传任务的分片聚在一起任务完成后整个目录直接删除清理成本低。这里有个操作细节os.Create(dst)如果分片已经存在会把旧文件直接截断所以重复上传同一个分片时天然是幂等的最后一次写入覆盖之前的内容。但要不要覆盖取决于你的校验策略。如果分片是完整傳過來的覆盖没问题如果网络传输过程中文件本身损坏了服务端需要结合 MD5 进一步校验。基础版本可以只校验分片大小生产环境建议每个分片额外带一个分片 MD5服务端算一遍比对不一致就直接拒绝并返回错误让客户端重新传输该分片。4. 并发控制与幂等机制多分片同时上传时的边界问题4.1 并发窗口期最容易出现的三种异常分片上传引入了并发也就引入了三个经典的边界问题。第一个是重复提交。客户端为了稳定通常会对每个分片做重试。分片 A 第一次传输超时但服务端其实已经写完了客户端重试后这个分片就会被重复写入。如果不做幂等轻则磁盘多写一份重则进度状态被错误重置。第二个是乱序到达。分片 7 比分片 3 先到这是完全正常的。但如果服务端在分片 3 还没到的时候就收到了合并请求那整个任务就白做了。合并接口必须做完整的数量校验这是最后一道防线。第三个是合并时机冲突。客户端并发上传还没结束就触发了合并而另一个 goroutine 还在写分片合并读到不完整的文件最终文件损坏。解决方式是合并前做一次严格检查已经确认所有分片完成并且保证合并流程中不再接受新的分片写入。4.2 Redis Hash 进度表与分片幂等返回我在前面用 Redis Hash 存上传元数据这里还要用它存分片完成状态。具体做法是同一个 key 下用 field 记为chunk_{index}value 记为分片文件的字节大小。func updateChunkStatus(uploadID string, chunkIndex string, size int64) error { key : uploadMetaKey(uploadID) field : chunk_ chunkIndex existed, err : rdb.HExists(ctx, key, field).Result() if err ! nil { return err } // 如果已经上传过也返回成功保证幂等 if existed { return nil } // 记录分片大小供后续完整性校验 return rdb.HSet(ctx, key, field, size).Err() }这一步非常关键HExists判断分片是否已存在如果存在直接返回 success客户端收到成功响应就不会重传。这样即便客户端因为超时连续重试多次服务端第二次都不会再重复写文件。配合前面说的os.Create截断机制相当于做了双保险。但这里有一个并发隐患如果同一个 uploadID 下的同一个分片索引被两个请求同时到达两个 goroutine 都通过 HExists 判断为不存在然后都执行了写文件。磁盘上会有两个 goroutine 同时写同一个文件这是数据竞争。解决方法是给文件写入加个进程内互斥锁或者用 Redis 的SETNX做分片级锁。对于单实例部署sync.Map 加互斥锁就够了多实例部署就要用 Redis 分布式锁。我是先用进程内锁跑通了全链路后续再按需扩展分布式锁。4.3 合并前的完整性校验最后一道防线不能省合并接口是整个分片流程的收尾动作。它要做四件事检查全部片是否存在检查每个分片大小合法按索引排序后合并删除临时目录。func MergeUploadHandler(c *gin.Context) { var req MergeUploadReq if err : c.ShouldBindJSON(req); err ! nil { c.JSON(400, gin.H{error: err.Error()}) return } meta : getUploadMeta(req.UploadID) completed : 0 var missing []int for i : 0; i meta.TotalChunks; i { field : fmt.Sprintf(chunk_%d, i) if rdb.HExists(ctx, metaKey, field).Val() { completed } else { missing append(missing, i) } } if completed ! meta.TotalChunks { c.JSON(409, gin.H{error: chunks not complete, missing: missing}) return } // 合并逻辑... }只有当一个分片都没有缺失时合并流程才允许启动。如果客户端在分片都没传完就调了合并接口这里会收到 409 和缺失分片列表客户端可以自动补传后再次合并。合并本身用流式写入避免把整个文件读入内存。按分片索引从 0 到 N-1 依次打开.part文件用io.Copy不断写入最终文件。finalFile : filepath.Join(finalDir, meta.FileName) dstFile, err : os.Create(finalFile) if err ! nil { c.JSON(500, gin.H{error: err.Error()}) return } defer dstFile.Close() for i : 0; i meta.TotalChunks; i { partFile : filepath.Join(chunkDir, fmt.Sprintf(chunk_%d.part, i)) src, err : os.Open(partFile) if err ! nil { c.JSON(500, gin.H{error: fmt.Sprintf(open chunk %d failed, i)}) return } io.Copy(dstFile, src) src.Close() } // 合并完成后删除分片目录释放磁盘空间 os.RemoveAll(chunkDir)合并完成后还可以做最终 MD5 比对。如果初始化时前端传了整文件的 MD5服务端在合并后再算一次比对不一致说明中间有分片损坏需要重新上传或重新合并。这一步看起来增加耗时但能避免把损坏的文件交付给业务方值得做。5. 断点续传的状态维护从服务端 Redis 到客户端队列的联动5.1 为什么进度状态用 Redis 而不是数据库上传进度的查询是高频操作客户端每传完一个分片就要更新一次状态断点续传时又要拉取整个分片完成列表。如果用关系型数据库频繁的 UPDATE 和 SELECT 会带来不必要的 IO 压力而 Redis 的 Hash 结构让记录分片状态和查询分片列表这两个操作都保持在亚毫秒级别。另外 Redis 能给上传任务设置 TTL这个特性对临时性的上传任务非常友好。初始化接口里把整个上传任务的 key 设置 24 小时过期意味着一个任务 24 小时内没完成会被自动清理不需要额外写一个定时任务去扫垃圾分片。当然临时分片文件还在磁盘上这部分的清理要单独做后面会讲到。5.2 客户端续传策略拿到已上传分片列表后怎么调度断点续传的流程是客户端重新上传时先调用 QueryUpload 接口服务端从 Redis 读出已上传的分片字段返回一个列表。客户端拿到这个列表生成一个 bool 数组长度为 totalChunks列表中包含的索引标记为 true。上传调度时循环每个分片如果对应索引已经是 true直接跳过否则进入上传队列。这样无论上次传到哪、失败了多少个分片恢复后都只需要补传缺失的那部分。QueryUpload 接口的实现也很简单func QueryUploadHandler(c *gin.Context) { uploadID : c.Query(upload_id) key : uploadMetaKey(uploadID) // 读取 Hash 中所有字段 all, err : rdb.HGetAll(ctx, key).Result() if err ! nil { c.JSON(500, gin.H{error: err.Error()}) return } var uploaded []int for field : range all { if strings.HasPrefix(field, chunk_) { idx, _ : strconv.Atoi(strings.TrimPrefix(field, chunk_)) uploaded append(uploaded, idx) } } sort.Ints(uploaded) c.JSON(200, gin.H{ upload_id: uploadID, total_chunks: all[total_chunks], uploaded: uploaded, }) }客户端这里的重试逻辑建议设计成局部重试整体断点恢复两层。局部重试是单个分片失败后延迟几秒重试同一分片最多重试三次三次都失败就停止记录当前进度。用户下次打开页面或重新点击上传时再走一遍 QueryUpload继续传。这种设计对弱网环境特别有效不会因为一个分片失败就把整个任务搞挂。5.3 中途文件变更、超时与垃圾分片容易被忽略的三个细节断点续传场景里有一种情况必须处理用户在上传还没完成时修改了源文件。文件大小变了或者内容变了已上传分片和后续分片对不上合并出来的文件就是坏的。我处理这个问题的办法是初始化接口存了文件大小和 MD5每个分片上传时记录的是实际落盘大小。合并前比对所有分片实际大小累加是否等于原始文件大小不等于就直接拒绝合并。客户端在初始化时也应当把本地文件的最后修改时间保存下来每次续传前重新检查如果 MD5 或 size 发生变化废弃原 uploadID重新初始化一个上传任务。另一个细节是超时清理。Redis 的 key 过期后纯粹的元数据会自动消失但磁盘上的分片目录还留着。如果不清理日积月累会占用大量磁盘空间。我在项目里是写了一个辅助的清理协程每小时扫描临时目录删除修改时间超过 6 小时的目录。清理前判断一下 Redis 里是否还有对应任务如果任务还在且正在进行就跳过。这是一个很实用的兜底方案推荐加上。6. 让并发真正可控前端并发数、合并互斥与信号量6.1 为什么不是并发越大越好服务端接口本身是高并发的Gin 的每个请求都在独立 goroutine 里处理不用担心多分片同时到达会被阻塞。真正需要控的是客户端的上传并发数。很多人的第一反应是分片有 40 片那我就 40 路并发一起传。实际上并发太大会适得其反。40 个并发请求同时发出去尤其是移动网络下反而造成 TCP 拥塞、路由器缓冲溢出整体吞吐量下降。而且浏览器和系统本身对同一域名的并发连接数也有限制HTTP/1.1 下通常是 6-8 个开更多并发只会请求排队。我实测下来比较合理的方案是 2 到 3 路并发。客户端用一个长度为 2 或 3 的请求队列每次从待上传分片里取一个索引发起上传完成后再取下。对于 20MB 一片的大文件3 路并发对带宽的利用已经很充分而且每路连接失败后重试成本也很低。6.2 服务端要不要再做一层并发闸门既然服务端接口天然支持并发是不是就不用管了不完全是。有两个点值得做。第一个点是合并动作的互斥。合并是一个读全部分片并写最终文件的过程如果两个合并请求同时到达或者合并过程中还有分片上传请求在写临时文件结果就会不可预期。我在 merge 接口入口增加了一个状态位用 Redis 的SETNX设置一个merging标记合并完成后删除。如果合并请求发现merging已存在直接返回 409让客户端稍后重试。第二个点是限制单实例上同一 uploadID 的并发写操作数。对于单机部署用一个进程内的信号量就够了因为同一个 uploadID 的分片文件目录只有一个过多并发写同一个目录的文件不会出错但可能有文件句柄和磁盘 IO 压力。对于多实例部署可以借助 Redis 的INCR做一个简单的并发计数器每次上传分片时 INCR超过阈值就拒绝。我最初跑通版本没有加这层后面在压测时发现单目录写并发太高时磁盘 IO 会成为瓶颈才补上的。这里给大家的建议是先不加等真遇到瓶颈再加过早优化意义不大。6.3 分片校验失败后的自动补传流程并发上传最怕的是某些分片因为网络问题反复失败。我的做法是客户端管理一个失败队列。每个分片上传失败后不立即抛错而是放进一个 retryChannel。主循环每轮先检查 retryChannel 里有没有待重试的分片有就优先补传最多重试三次。这样设计的好处是整个上传过程不会因为某一个分片失败而整体停摆。3 路并发的请求里1 路失败进入重试队列另外 2 路继续传其他分片最终全部完成的时间不会大幅变长。合并前服务端发现缺失分片时客户端拿到的 missing 列表也可以直接塞进重试队列。7. OSS 直传预签名把文件流量从 Gin 应用中卸载下来7.1 预签名 URL 的工作原理不碰文件流只碰授权走到这一步是因为我遇到了现实瓶颈。分片上传稳定是稳定了但所有分片都先落到应用服务器本地磁盘再由应用服务器合并。这意味着用户上传的所有文件流量都要过应用服务器的带宽。一个 2GB 的文件如果有 20 个人同时上传应用服务器的出口带宽瞬间被打满其他业务接口也跟着变慢。OSS 直传预签名就是解决这个问题的。核心思想是文件内容不再经过 Gin 应用客户端直接上传到对象存储服务商。预签名 URL 的原理可以这样理解服务端持有一个有权限访问 OSS 的密钥它用这个密钥对某个 object 的 PUT 请求生成一个带签名的临时 URL然后把 URL 交给客户端。客户端拿着这个 URL 直接向 OSS 发起上传OSS 验证签名有效并且在有效期内就允许写入。整个过程中Gin 应用只负责生成 URL 和处理元数据不碰文件二进制流。在 Go 里使用阿里云 OSS SDK生成预签名 URL 的代码大约是下面这样import github.com/aliyun/aliyun-oss-go-sdk/oss client, err : oss.New(endpoint, accessKeyID, accessKeySecret) bucket, err : client.Bucket(bucketName) // 直接生成 PUT 预签名 URL有效期 30 分钟 signedURL, err : bucket.SignURL(objectKey, oss.HTTPPut, 30*60)如果你们用的是 MinIO 或者 S3 兼容存储SDK 里通常叫PresignedPutObject思路完全一致。7.2 分片场景下的预签名直传完整改造方案把预签名直传和分片上传结合起来时有两种路径可以选择。第一种是用 OSS 自己的 Multipart Upload API。OSS 原生支持分片上传先调用 InitiateMultipartUpload 拿到 OSS 的 uploadID然后对每个分片生成 UploadPart 的预签名 URL客户端逐个直传传完后服务端调 CompleteMultipartUpload 合并。这条路的好处是分片上限高单文件最多 10000 片合并也是 OSS 内部完成不占应用资源。坏处是逻辑改造成本更大OSS 分片不会再落到你的磁盘临时文件的审计和回滚路径需要重新设计。第二种是保留现有流程客户端把分片直传到 OSS 的某个私有目录作为临时存储传完后让服务端去 OSS 逐个下载并合并或者直接用对象存储的服务端拷贝功能合并。这条路改造量小可以复用已有的分片校验逻辑。我实际采用的是第二种的变体——分片直传 OSS 临时目录服务端合并时用 OSS 的 Multipart Upload Copy 把分片拷贝成最终对象。改造后的上传流程是这样的前端调 InitUploadGin 生成 uploadID并向 OSS 发起 Multipart Upload 初始化拿到 OSS 的 uploadID。Gin 为每个分片生成一个 UploadPart 预签名 URL返回给客户端。客户端用预签名 URL 直接上传分片到 OSS每个分片一个 PUT 请求。全部上传完成后客户端调 MergeUploadGin 调 OSS 的 CompleteMultipartUpload 完成合并。如果中途失败查询进度时 GIn 调 OSS 的 ListParts获取已上传分片列表客户端只补传缺失的分片。这套流程最终文件的合并压力也在 OSS 侧Gin 应用只做了签名和调度连临时的分片文件都不在自己磁盘上。实际跑下来应用服务器的带宽压力几乎为零。7.3 预签名直传场景下断点续传的联动策略切换到 OSS 直传后断点续传的状态来源也变了。不再是读 Redis 的 Hash 字段而是直接向 OSS 查询。OSS 的 Multipart Upload 提供了 ListParts 接口能返回已经按 uploadID 上传成功的分片编号列表。客户端断点续传时通过 QueryUpload 接口服务端从 OSS 拉取这个列表返回给客户端剩下的逻辑完全复用之前的跳过已传分片策略。这里有几个坑是 OSS 直传特有的提醒一下。第一个是预签名 URL 的有效期。常规上传一个 2GB 文件可能要十几分钟如果客户端上传慢第一批拿到的 URL 到了第二十分钟才用可能已经过期。我的策略是预签名有效期设 30 分钟如果客户端上传某个分片时收到签名过期错误需要重新向服务端申请一个新的预签名 URL。所以接口设计上最好支持给指定分片单独签发 URL而不是只能初始化时一次性签完。第二个是 CORS 跨域问题。如果客户端是浏览器直传 OSS必须在 OSS Bucket 配置里设置 CORS 规则允许你的前端域名的 PUT、POST、GET 请求否则浏览器会拦截。我在第一次浏览器直传时就被这个坑卡了好久前端一直报跨域错误排查到最后才发现 OSS 的 CORS 配置没设置。第三个是分片大小限制。OSS Multipart Upload 要求除最后一片外其他分片都不得小于 100KB这个一般不会违反但如果分片大小设太小5MB 以上的分片完全没问题。我建议分片最小不要低于 1MB既方便预签名排期也适配 OSS 的约束。8. 把这些串联起来一份可以直接落的实现清单最后把整套方案的实施顺序整理一下方便你对照着写代码。这也是我自己实际跑通后总结出来的优先级顺序不要试图一次性全部实现按阶段来更稳。第一阶段先实现基础分片上传和合并纯本地存储。完成标准大文件能稳定上传并合并成功。这个阶段不做断点续传甚至不做进度查询接口先把链路跑通。第二阶段加 Redis 进度记录和 QueryUpload 接口完成断点续传。完成标准杀进程、断网、重启客户端后恢复任务能跳过已传分片继续传完。第三阶段加并发控制和幂等逻辑。完成标准客户端重试分片不会覆盖、重复上传同一片不会报错、合并前能校验缺失分片。第四阶段迁移到 OSS 直传预签名。完成标准上传大文件时应用服务器带宽占用接近零客户端直传 OSS断点续传逻辑仍然生效。在各阶段里有几个原则我始终没变一切以 uploadID 为核心标识所有元数据都挂在这个 ID 下。分片大小和总分片数在初始化后就是只读的后续所有校验都引用服务端记录。任何涉及并发的操作先问一句重复执行会不会出错不会出错才不做幂等保护。合并永远是最晚的动作永远在合并前校验分片完整性。这套方案我用到生产环境后用户上传大文件时的失败率从直接上传时代的挺高比例降到了极低水平。最直观的感受是以前客服经常收到用户传文件失败的投诉现在几乎没人反馈了。如果你现在还在用普通上传而且业务里确实有大量大文件传输的场景分片上传和断点续传这套组合拳值得尽快安排上。