华为云 OBS 对象存储大文件分段上传失败与断点续传失效:一次 2TB 备份任务卡在 Part 5003 的完整排查
华为云 OBS 对象存储大文件分段上传失败与断点续传失效:一次 2TB 备份任务卡在 Part 5003 的完整排查
一、问题现场
我们有台备份服务器,每天凌晨两点把当天的日志与数据库快照打包后上传到华为云 OBS 的一个归档桶里。桶用的是 OBS 官方 SDK 的分段上传(multipart upload),分片大小 8MB,并发度设成 16。整套流程跑了半年多,一直稳定。
上周三开始,任务开始不稳定。前两天只是偶尔重试一次还能成功,到后面变成每天都失败,日志里的报错是 `EntityTooLarge: Your proposed upload exceeds the maximum allowed size` 和 `NoSuchUpload: The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted.` 交替出现。更奇怪的是,同样的分片大小和并发配置,别的几个桶一切正常,只有这个桶不行。
任务卡住的位置很固定:分片序号大约在 5000 附近,也就是文件上传到 40GB 左右的时候。5003 这个数字稳定得让人怀疑是某个阈值。
二、第一层排查:先确认是服务端拒绝还是客户端没发对
第一反应是配额问题,于是先去控制台和 API 侧确认:`ListMultipartUploads` 能列出进行中的分段上传,`ListParts` 能看到已经上传成功的分片,服务端侧的分片状态是正常的。所以不是整个上传被服务端中止,而是某个分片在重试过程中出了问题。
接下来看 SDK 侧的错误上下文。这里有个坑:一开始我们只打印了异常类型和 message,没有打印分片号、uploadId、请求的 Content-Length 和实际收到的字节数,于是所有错误看起来都长得一样。补上这些字段之后,错误被分成了两类:
第一类是 `EntityTooLarge`,对应的 Content-Length 明显大于分片大小,属于 SDK 重试时把整个分片重新组装了一遍;第二类是 `NoSuchUpload`,uploadId 是有效的但服务端说找不到,说明这个上传已经被 abort 了。
这两类错误混在一起,让最初的几轮排查完全失去了方向。
三、第二层排查:把客户端分片和服务端分片一一对上
改用 SDK 的低层接口自己管理分片,不依赖高层封装:先 `CreateMultipartUpload`,然后对每个分片自己算 offset,调 `UploadPart`,并把每次的返回(PartNumber、ETag、Size)落盘到一张本地表里。然后拿这张表去和 `ListParts` 的结果对比。
对比结果很说明问题:客户端记录的已上传分片是连续的 1..5002,然后直接跳到 5004,5003 缺失;而服务端 `ListParts` 返回的是 1..5002 加上 5004。也就是说,**分片 5003 从来没有真正上传成功过**。之前所有看起来「上传了一半」的进度,都是客户端本地状态给的假象。
四、第三层排查:定位分片 5003 有什么特殊
一个分片永远失败,通常说明这个分片的内容有问题,或者它的偏移量有问题。我们打印了 5003 的详细信息:
- PartNumber:5003
- - Offset:41943040(即 5002 × 8MB + 2 个 8MB,也就是 40GB 零头)
- - 实际 Content-Length:8388608(正好 8MB)
- - 读文件时的字节数:也是 8388608
看起来完全正常。既然文件内容和长度都对,那问题只能在「怎么发出去」这一步。
于是我们用 curl 直接对同一个文件、同一段字节发了一次 `UploadPart`:
```
curl -X PUT "https://<bucket>.obs.<region>.myhuaweicloud.com/<key>?partNumber=5003&uploadId=<id>" \
-H "Content-Type: application/octet-stream" \
--data-binary @part5003.bin
```
结果是 200 OK,ETag 正常返回。也就是说**用同样的数据、同样的接口,SDK 失败而 curl 成功**,问题必然出在 SDK 发请求的环节,而不是数据本身。
五、第四层排查:SDK 与代理链路的差异
我们前面还有一层 Nginx 做上传代理,proxy_request_buffering 开着,body 上限设的是 100MB。对比 SDK 和 curl 的实际请求头,差异集中在两处:
一是 SDK 在重试时走了 `Expect: 100-continue`,curl 不会。Nginx 对带 `Expect: 100-continue` 的请求,会先等上游返回 100 再转发 body,行为和普通请求不一样,retry 逻辑在这里被卡住后,SDK 侧把异常归类成了 body 过大。
二是 SDK 重试时用的是 `Transfer-Encoding: chunked`,而代理层对 chunked 请求的处理路径和 Content-Length 请求不同。
我们做了两个对照实验来确认:
实验一:把代理的 `proxy_request_buffering off`(改成流式转发),不重启业务,只是灰度一台。结果失败率从 100% 掉到 0。
实验二:直连 OBS 域名、不经过代理,SDK 上传完全正常。
两条实验一起说明:根因在代理层对大请求体 + 100-continue 的处理,而不是 OBS 本身,更不是分片大小或并发度。
六、根因与修复
定位到根因之后,修复分了三步:
第一步,去掉代理层的请求体缓冲,`proxy_request_buffering off`,同时把 `client_max_body_size` 提到 0(不限制),让大请求体直接流式转发。
第二步,把 SDK 的分片大小从 8MB 降到 16MB,单分片耗时变长,但因为每个请求体更小地落在一个缓冲窗口内,代理层的稳定性反而明显变好。
第三步,给上传任务补上了真正的断点续传能力。之前我们的续传逻辑有个致命错误:失败后重新 `CreateMultipartUpload` 拿了一个新的 uploadId,然后从第 1 片重新传。正确的做法是复用原来的 uploadId,只重传缺失的分片。改完之后,同一个 2TB 文件的续传从「整文件重传」变成了「只补 5003 这一片」。
七、验证结果
修复后的第一周,同一个 2TB 的备份任务连续七天全部成功,单次上传耗时从平均 96 分钟降到 71 分钟。关键的一点是:我们给每个分片做了 ETag 校验,只有全部 `ListParts` 与本地记录一致才判定任务成功,而不是像以前那样只看 SDK 有没有抛异常。
八、复盘:可以拿走的六条经验
第一,大文件上传失败时,不要一上来就调分片大小和并发度,先把客户端的分片账本和服务端的 `ListParts` 对齐,账本对不上的时候,客户端的进度条是没有参考价值的。
第二,同一份数据用 SDK 失败、用 curl 成功,就说明问题在客户端发请求这一侧,可以直接用 curl 做二分定位。
第三,代理层是排查长尾问题里最容易被忽略的一环。`Expect: 100-continue`、请求体缓冲、chunked 编码这三个组合,值得在任何「SDK 失败但服务端看起来正常」的场景里优先怀疑。
第四,断点续传必须复用同一个 uploadId,并且要有逐分片的 ETag 校验,否则「续传」只是重传。
第五,分片大小不是越小越好也不是越大越好,它应该和链路上最脆弱的那个环节(这里是代理的缓冲窗口)匹配。
第六,不要在证据不足时用「可能是平台问题」收尾。我们在这个问题上浪费了大半天,怀疑过 OBS 限流、怀疑过桶配额、怀疑过 SDK 版本,唯一没怀疑的就是自己部署的那层代理。
- 点赞
- 收藏
- 关注作者
评论(0)