Administrator
发布于 2026-03-02 / 14 阅读
0
0

NoSuchUpload 复盘:为什么 Ceph RGW 在 Complete 阶段更不容易翻车

开头导语

最近线上出现一组很典型、也很容易误判的报错组合:

  1. unexpected EOF
  2. merge parts: Invalid upload id ...
  3. 客户端 NoSuchUpload(发生在 CompleteMultipartUpload

这类问题第一眼看起来像“客户端传错 uploadID”,但把日志、时序和源码串起来后,结论不是这么简单。

这篇是“故障复盘版”,不是泛化科普。目标是把本次事件拆成可验证证据链,回答下面 4 个问题:

  1. 这次错误到底发生在哪个阶段?
  2. InvalidUploadID 到底是不是“客户端传错 upload_id”?
  3. 为什么 Ceph RGW 在类似重试场景里更不容易暴露这个错误?
  4. 多次发送 CompleteMultipartUpload 时,Ceph RGW 实际会返回什么?

---

1. 事件摘要(Summary)

1.1 现象

同一时间窗内同时出现:

  1. 网关日志大量 unexpected EOF
  2. 随后出现 merge parts: Invalid upload id ... [CompleteMultipartUpload@gateway.go:1214]
  3. 客户端报错为 NoSuchUpload,且报错点在 CompleteMultipartUpload

1.2 初始误判风险

第一眼非常容易误判成“客户端 uploadID 填错/过期”,但后面代码会证明:

  1. 这类错误也可能来自服务端并发时序窗口(尤其是 complete/abort 竞争)。
  2. 在当前 gateway 代码里,ENOENT + uploadID 会被统一折叠成 InvalidUploadID,语义不够细。

1.3 先给结论(TL;DR)

这次问题更符合“并发时序 + 重试放大 + 错误映射粗粒度”的复合问题:

  1. 大文件 multipart 在抖动窗口内 complete 耗时拉长;
  2. 客户端超时与重试触发并发 complete/abort;
  3. gateway 缺少 upload 级串行化与“already completed”幂等识别;
  4. ENOENT 在网关里被统一折叠成 InvalidUploadID
  5. 客户端最终感知为 NoSuchUpload

---

2. 证据链(从日志到代码)

2.1 日志层证据:错误发生在 Complete 阶段

你给到的典型日志顺序是:

  1. 先有 error: unexpected EOF ... [jfsToObjectErr@gateway.go:136]
  2. 后有 merge parts: Invalid upload id ... [CompleteMultipartUpload@gateway.go:1214]
  3. 客户端报 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)
}

关键点:

  1. 这里只保证“检查时刻存在”;
  2. 不能保证后续 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
    }
}

关键点:

  1. 每个 part 都依赖文件可见性;
  2. 任一 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)
}

这意味着在并发窗口里可能出现:

  1. A 线程 complete 已通过 checkUploadIDExists
  2. B 线程 abort 删除 upload 目录
  3. 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;
}

这里最关键的是:

  1. 如果拿锁时遇到 -ENOENT(典型场景:第一次 complete 成功后元对象已删除);
  2. 它会尝试 check_previously_completed(parts)
  3. 匹配成功直接 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 来得太早(第一个还在处理)

  1. 锁拿不到;
  2. ERR_INTERNAL_ERROR
  3. 通常是 500 InternalError,并带 message:This multipart completion is already in progress

对应映射:


{ ERR_INTERNAL_ERROR, {500, "InternalError" }},

场景 B:第二个 Complete 是“第一次已成功后的重试”

  1. 可能看到 -ENOENT
  2. 如果 check_previously_completed(parts) 为 true;
  3. 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 路径(容易放大抖动)

  1. 客户端发 Complete #1,网关开始逐 part 合并。
  2. 客户端因为超时/上层策略发 AbortComplete #2
  3. upload 目录或 part 在窗口里被删除或暂时不可见。
  4. Complete #1CopyFileRange(part) 得到 ENOENT
  5. ENOENT + uploadID 被映射为 InvalidUploadID,客户端收到 NoSuchUpload

5.2 Ceph RGW 路径(更容易收敛)

  1. Complete #1 先拿 multipart meta object 的排它锁。
  2. Complete #2 来了,无法并发执行 complete 主流程。
  3. 如果 #1 已成功并清理 meta object,#2 可能拿锁见到 ENOENT
  4. RGW 触发 check_previously_completed(parts),ETag 匹配则按成功语义返回。

---

6. 根因结论(本次事件)

本次更符合“并发时序 + 错误映射粗粒度 + 重试放大”的复合问题:

  1. 大文件 multipart 在抖动窗口内 complete 耗时变长;
  2. 客户端超时与重试触发并发 complete/abort;
  3. gateway 缺少 Ceph RGW 那种 upload 级串行化 + previously completed 识别;
  4. ENOENT 被统一折叠成 InvalidUploadID
  5. 客户端最终感知为 NoSuchUpload

---

7. 落地修复建议(按优先级)

P0(必须)

  1. gateway 在 CompleteMultipartUpload/AbortMultipartUpload 增加 uploadID 级互斥。
  2. 为 complete 增加 already_completed(parts) 判定路径(类似 RGW)。
  3. InvalidUploadID 拆分统计:
  • upload 目录不存在
  • part 缺失/不可见
  • complete 并发冲突

P1(建议)

  1. 客户端 read timeout 调整到高峰 complete P99 之上。
  2. retry 从“短周期密集重试”改为指数退避。
  3. 限制同 uploadID 的并发 complete/abort 请求。

P2(可观测性)

  1. 增加 uploadID 维度的 lifecycle tracing:

create -> upload part -> complete -> abort

  1. 统计 ENOENT@CompleteInvalidUploadID@Complete 的日内分布。
  2. 建立“client timeout vs complete latency”关联图,识别重试风暴触发阈值。

---

8. 附录:关键代码位置(便于二次核对)

  1. Juice gateway
  2. checkUploadIDExistspkg/gateway/gateway.go
  3. CompleteMultipartUploadpkg/gateway/gateway.go
  4. AbortMultipartUploadpkg/gateway/gateway.go
  5. jfsToObjectErrENOENT -> InvalidUploadID):pkg/gateway/gateway.go
  6. Ceph RGW
  7. RGWCompleteMultipart::execute(加锁与重试分支):src/rgw/rgw_op.cc
  8. check_previously_completed(重算 ETag):src/rgw/rgw_op.cc
  9. MPRadosSerializer::try_lock(对象排它锁):src/rgw/rgw_sal_rados.cc
  10. 错误码映射(ERR_NO_SUCH_UPLOAD/ERR_INTERNAL_ERROR):src/rgw/rgw_common.cc
  11. S3 complete 成功响应:src/rgw/rgw_rest_s3.cc

---

9. 一句话结论

这次 NoSuchUpload 并不是一个“纯客户端传错 uploadID”的问题。

更准确地说,它是 multipart complete 在抖动和重试下的并发时序问题,而 Ceph RGW 通过“对象级排它锁 + 已完成识别”把这类伪失败显著收敛了。

---

10. 结尾 CTA

如果你线上也出现了 NoSuchUpload/InvalidUploadID,建议下一步立刻做三件事:

  1. 拉同一时间窗的 Complete/Abort 请求量与失败码分布,确认是否存在重试风暴。
  2. 在网关侧区分 upload目录不存在part缺失 两类 ENOENT,避免都折叠成 InvalidUploadID
  3. 评估并落地 uploadID 级互斥 + already completed 判定,优先收敛“超时后重复 complete”伪失败。

评论