开头导语
最近线上出现一组很典型、也很容易误判的报错组合:
unexpected EOFmerge parts: Invalid upload id ...- 客户端
NoSuchUpload(发生在CompleteMultipartUpload)
这类问题第一眼看起来像“客户端传错 uploadID”,但把日志、时序和源码串起来后,结论不是这么简单。
这篇是“故障复盘版”,不是泛化科普。目标是把本次事件拆成可验证证据链,回答下面 4 个问题:
- 这次错误到底发生在哪个阶段?
InvalidUploadID到底是不是“客户端传错 upload_id”?- 为什么 Ceph RGW 在类似重试场景里更不容易暴露这个错误?
- 多次发送
CompleteMultipartUpload时,Ceph RGW 实际会返回什么?
---
1. 事件摘要(Summary)
1.1 现象
同一时间窗内同时出现:
- 网关日志大量
unexpected EOF - 随后出现
merge parts: Invalid upload id ... [CompleteMultipartUpload@gateway.go:1214] - 客户端报错为
NoSuchUpload,且报错点在CompleteMultipartUpload
1.2 初始误判风险
第一眼非常容易误判成“客户端 uploadID 填错/过期”,但后面代码会证明:
- 这类错误也可能来自服务端并发时序窗口(尤其是 complete/abort 竞争)。
- 在当前 gateway 代码里,
ENOENT + uploadID会被统一折叠成InvalidUploadID,语义不够细。
1.3 先给结论(TL;DR)
这次问题更符合“并发时序 + 重试放大 + 错误映射粗粒度”的复合问题:
- 大文件 multipart 在抖动窗口内 complete 耗时拉长;
- 客户端超时与重试触发并发 complete/abort;
- gateway 缺少 upload 级串行化与“already completed”幂等识别;
ENOENT在网关里被统一折叠成InvalidUploadID;- 客户端最终感知为
NoSuchUpload。
---
2. 证据链(从日志到代码)
2.1 日志层证据:错误发生在 Complete 阶段
你给到的典型日志顺序是:
- 先有
error: unexpected EOF ... [jfsToObjectErr@gateway.go:136] - 后有
merge parts: Invalid upload id ... [CompleteMultipartUpload@gateway.go:1214] - 客户端报
NoSuchUpload when calling CompleteMultipartUpload
这说明“业务失败最终发生在 Complete 合并阶段”,不是单纯 UploadPart 请求入口直接失败。
---
2.2 Juice gateway 代码证据:为什么会把 ENOENT 暴露为 InvalidUploadID
2.2.1 uploadID 存在检查只是一拍 Stat
func (n *jfsObjects) checkUploadIDExists(ctx context.Context, bucket, object, uploadID string) (err error) {
if err = n.checkBucket(ctx, bucket); err != nil {
return
}
_, eno := n.fs.Stat(mctx, n.upath(bucket, uploadID))
return jfsToObjectErr(ctx, eno, bucket, object, uploadID)
}
关键点:
- 这里只保证“检查时刻存在”;
- 不能保证后续 complete 全程使用同一个稳定视图。
2.2.2 Complete 按 part 逐个 CopyFileRange
func (n *jfsObjects) CompleteMultipartUpload(ctx context.Context, bucket, object, uploadID string, parts []minio.CompletePart, opts minio.ObjectOptions) (objInfo minio.ObjectInfo, err error) {
if err = n.checkUploadIDExists(ctx, bucket, object, uploadID); err != nil {
return
}
tmp := n.ppath(bucket, uploadID, "complete")
_ = n.fs.Delete(mctx, tmp)
f, eno := n.fs.Create(mctx, tmp, 0666, n.gConf.Umask)
if eno != 0 {
err = jfsToObjectErr(ctx, eno, bucket, object, uploadID)
logger.Errorf("create complete: %s", err)
return
}
defer func() {
_ = f.Close(mctx)
}()
var total uint64
for _, part := range parts {
p := n.ppath(bucket, uploadID, strconv.Itoa(part.PartNumber))
copied, eno := n.fs.CopyFileRange(mctx, p, 0, tmp, total, 5<<30)
if eno == syscall.ENOENT { // try lookup from old path
p = n.ppathFlat(bucket, uploadID, strconv.Itoa(part.PartNumber))
copied, eno = n.fs.CopyFileRange(mctx, p, 0, tmp, total, 5<<30)
}
if eno != 0 {
err = jfsToObjectErr(ctx, eno, bucket, object, uploadID)
logger.Errorf("merge parts: %s", err)
return
}
total += copied
}
}
关键点:
- 每个 part 都依赖文件可见性;
- 任一 part 在这个窗口里消失(ENOENT),complete 就失败。
2.2.3 Abort 会直接删 upload 目录
func (n *jfsObjects) AbortMultipartUpload(ctx context.Context, bucket, object, uploadID string, option minio.ObjectOptions) (err error) {
if err = n.checkUploadIDExists(ctx, bucket, object, uploadID); err != nil {
return
}
eno := n.fs.Rmr(mctx, n.upath(bucket, uploadID), true, meta.RmrDefaultThreads)
return jfsToObjectErr(ctx, eno, bucket, object, uploadID)
}
这意味着在并发窗口里可能出现:
- A 线程 complete 已通过
checkUploadIDExists - B 线程 abort 删除 upload 目录
- A 线程
CopyFileRange(part)遇到ENOENT
2.2.4 ENOENT + uploadID 被统一映射为 InvalidUploadID
func jfsToObjectErr(ctx context.Context, err error, params ...string) error {
...
switch {
case fs.IsNotExist(err):
if uploadID != "" {
return minio.InvalidUploadID{
UploadID: uploadID,
}
}
...
}
}
这就是为什么你看到的错误是 InvalidUploadID,但真实根因可能是“complete 期间 part/目录不可见”,而非“调用方 uploadID 字符串错误”。
---
3. Ceph RGW 对比:它为什么更不容易在重试时翻车
下面是关键机制,不是抽象描述,直接看代码。
3.1 RGW 在 Complete 上有 upload 级排它锁
/*take a cls lock on meta_obj to prevent racing completions (or retries)
from deleting the parts*/
int max_lock_secs_mp =
s->cct->_conf.get_val<int64_t>("rgw_mp_lock_max_time");
utime_t dur(max_lock_secs_mp, 0);
serializer = meta_obj->get_serializer(this, "RGWCompleteMultipart");
op_ret = serializer->try_lock(this, dur, y);
if (op_ret < 0) {
...
}
这段注释已经把设计意图写死:防并发 completion/retry 踩踏。
锁实现是 RADOS 上的 lock_exclusive:
int MPRadosSerializer::try_lock(const DoutPrefixProvider *dpp, utime_t dur, optional_yield y)
{
op.assert_exists();
lock.set_duration(dur);
lock.lock_exclusive(&op);
int ret = rgw_rados_operate(dpp, ioctx, oid, &op, y);
if (! ret) {
locked = true;
}
return ret;
}
不是单进程 mutex,而是对象级分布式锁语义。
3.2 RGW 对“超时后重复 complete”做已完成识别
if (op_ret < 0) {
ldpp_dout(this, 0) << "failed to acquire lock" << dendl;
if (op_ret == -ENOENT && check_previously_completed(parts)) {
ldpp_dout(this, 1) << "NOTICE: This multipart completion is already completed" << dendl;
op_ret = 0;
return;
}
op_ret = -ERR_INTERNAL_ERROR;
s->err.message = "This multipart completion is already in progress";
return;
}
这里最关键的是:
- 如果拿锁时遇到
-ENOENT(典型场景:第一次 complete 成功后元对象已删除); - 它会尝试
check_previously_completed(parts); - 匹配成功直接
op_ret = 0,按成功语义返回。
check_previously_completed(parts) 的核心做法是“重算 multipart etag 再比对对象 etag”:
bool RGWCompleteMultipart::check_previously_completed(const RGWMultiCompleteUpload* parts)
{
int ret = s->object->get_obj_attrs(s->obj_ctx, s->yield, this);
if (ret < 0) {
return false;
}
rgw::sal::Attrs sattrs = s->object->get_attrs();
string oetag = sattrs[RGW_ATTR_ETAG].to_str();
MD5 hash;
hash.SetFlags(EVP_MD_CTX_FLAG_NON_FIPS_ALLOW);
for (const auto& [index, part] : parts->parts) {
std::string partetag = rgw_string_unquote(part);
char petag[CEPH_CRYPTO_MD5_DIGESTSIZE];
hex_to_buf(partetag.c_str(), petag, CEPH_CRYPTO_MD5_DIGESTSIZE);
hash.Update((const unsigned char *)petag, sizeof(petag));
}
unsigned char final_etag[CEPH_CRYPTO_MD5_DIGESTSIZE];
char final_etag_str[CEPH_CRYPTO_MD5_DIGESTSIZE * 2 + 16];
hash.Final(final_etag);
buf_to_hex(final_etag, CEPH_CRYPTO_MD5_DIGESTSIZE, final_etag_str);
snprintf(&final_etag_str[CEPH_CRYPTO_MD5_DIGESTSIZE * 2], sizeof(final_etag_str) - CEPH_CRYPTO_MD5_DIGESTSIZE * 2,
"-%lld", (long long)parts->parts.size());
if (oetag.compare(final_etag_str) != 0) {
return false;
}
return true;
}
3.3 成功完成后会删除 multipart meta object
op_ret = upload->complete(...);
if (op_ret < 0) {
return;
}
// remove the upload meta object
int r = meta_obj->delete_object(this, s->obj_ctx, y, true /* prevent versioning */);
if (r >= 0) {
serializer->clear_locked();
}
这也是为什么重试请求可能看到 ENOENT,但 RGW 有“已完成识别”兜底。
---
4. “多次发送 Complete 请求”在 Ceph RGW 会返回什么?
不是单一答案,取决于时序:
场景 A:第二个 Complete 来得太早(第一个还在处理)
- 锁拿不到;
- 走
ERR_INTERNAL_ERROR; - 通常是
500 InternalError,并带 message:This multipart completion is already in progress。
对应映射:
{ ERR_INTERNAL_ERROR, {500, "InternalError" }},
场景 B:第二个 Complete 是“第一次已成功后的重试”
- 可能看到
-ENOENT; - 如果
check_previously_completed(parts)为 true; op_ret = 0,成功语义返回(S3 complete result)。
S3 complete response 成功分支:
void RGWCompleteMultipart_ObjStore_S3::send_response()
{
...
if (op_ret == 0) {
s->formatter->open_object_section_in_ns("CompleteMultipartUploadResult", XMLNS_AWS_S3);
...
}
}
场景 C:parts 清单真实不一致
会返回 InvalidPart(400)或 NoSuchUpload(404)等业务错误,而不是伪成功。
---
5. 文字时序图(把现场翻译成链路)
5.1 当前 gateway 路径(容易放大抖动)
- 客户端发
Complete #1,网关开始逐 part 合并。 - 客户端因为超时/上层策略发
Abort或Complete #2。 - upload 目录或 part 在窗口里被删除或暂时不可见。
Complete #1的CopyFileRange(part)得到ENOENT。ENOENT + uploadID被映射为InvalidUploadID,客户端收到NoSuchUpload。
5.2 Ceph RGW 路径(更容易收敛)
Complete #1先拿 multipart meta object 的排它锁。Complete #2来了,无法并发执行 complete 主流程。- 如果
#1已成功并清理 meta object,#2可能拿锁见到ENOENT。 - RGW 触发
check_previously_completed(parts),ETag 匹配则按成功语义返回。
---
6. 根因结论(本次事件)
本次更符合“并发时序 + 错误映射粗粒度 + 重试放大”的复合问题:
- 大文件 multipart 在抖动窗口内 complete 耗时变长;
- 客户端超时与重试触发并发 complete/abort;
- gateway 缺少 Ceph RGW 那种 upload 级串行化 + previously completed 识别;
ENOENT被统一折叠成InvalidUploadID;- 客户端最终感知为
NoSuchUpload。
---
7. 落地修复建议(按优先级)
P0(必须)
- gateway 在
CompleteMultipartUpload/AbortMultipartUpload增加 uploadID 级互斥。 - 为 complete 增加
already_completed(parts)判定路径(类似 RGW)。 - 把
InvalidUploadID拆分统计:
- upload 目录不存在
- part 缺失/不可见
- complete 并发冲突
P1(建议)
- 客户端 read timeout 调整到高峰 complete P99 之上。
- retry 从“短周期密集重试”改为指数退避。
- 限制同 uploadID 的并发 complete/abort 请求。
P2(可观测性)
- 增加 uploadID 维度的 lifecycle tracing:
create -> upload part -> complete -> abort
- 统计
ENOENT@Complete和InvalidUploadID@Complete的日内分布。 - 建立“client timeout vs complete latency”关联图,识别重试风暴触发阈值。
---
8. 附录:关键代码位置(便于二次核对)
- Juice gateway
checkUploadIDExists:pkg/gateway/gateway.goCompleteMultipartUpload:pkg/gateway/gateway.goAbortMultipartUpload:pkg/gateway/gateway.gojfsToObjectErr(ENOENT -> InvalidUploadID):pkg/gateway/gateway.go- Ceph RGW
RGWCompleteMultipart::execute(加锁与重试分支):src/rgw/rgw_op.cccheck_previously_completed(重算 ETag):src/rgw/rgw_op.ccMPRadosSerializer::try_lock(对象排它锁):src/rgw/rgw_sal_rados.cc- 错误码映射(
ERR_NO_SUCH_UPLOAD/ERR_INTERNAL_ERROR):src/rgw/rgw_common.cc - S3 complete 成功响应:
src/rgw/rgw_rest_s3.cc
---
9. 一句话结论
这次 NoSuchUpload 并不是一个“纯客户端传错 uploadID”的问题。
更准确地说,它是 multipart complete 在抖动和重试下的并发时序问题,而 Ceph RGW 通过“对象级排它锁 + 已完成识别”把这类伪失败显著收敛了。
---
10. 结尾 CTA
如果你线上也出现了 NoSuchUpload/InvalidUploadID,建议下一步立刻做三件事:
- 拉同一时间窗的
Complete/Abort请求量与失败码分布,确认是否存在重试风暴。 - 在网关侧区分
upload目录不存在与part缺失两类 ENOENT,避免都折叠成InvalidUploadID。 - 评估并落地 uploadID 级互斥 +
already completed判定,优先收敛“超时后重复 complete”伪失败。