对象存储迁移的“最后一公里”:双标尺校验、删除墓碑与收敛窗口的架构设计

对象存储跨云迁移,存量数据全部 COPY 完只是上半场。真正决定迁移是否成功的,是增量追平阶段的“收敛判定”。很多团队的翻车现场都惊人地相似:迁移工具显示 100%,账单里目标端存储量却比源端多出几个 TB;或者增量任务跑了一周,每次都有新文件出现,永远等不到干净的一天。

本文按真实业务场景拆解对象存储迁移收尾阶段的架构设计。重点不是 rsync、不是挂载、不是缓存加速,而是三个最容易被忽略的工程课题:校验标尺删除墓碑传递收敛窗口定义

一、典型翻车场景:工具显示成功,验收却过不了

  • ETag 不一致导致没完没了补传:源端对象由多段上传产生,ETag 不是 MD5。目标端工具重传后重新分片,新 ETag 与源端完全不同。如果不加判断直接以 ETag 做同步标尺,每次增量任务都会把已传完的大文件挑出来重传一遍。
  • 生命周期策略污染 Last-Modified:源端对象被存储类型转换规则重写后,对象内容没变,但 Last-Modified 变了。增量观察窗口内全是“伪变更”,任务永远追不平。
  • 版本化目标桶存储成本翻倍:目标桶开启了版本控制,迁移工具反复 PUT 同一个 Key 会产生历史版本,账面上看目标端容量比源端多出几个 TB。更可怕的是源端的 DeleteMarker 没有被同步,源端删掉的对象在目标端还活着。

二、校验标尺的取舍:ETag 靠不住,Last-Modified 也靠不住

跨云对象存储迁移,不能把 ETag 或 Last-Modified 单独作为最终验收的唯一标准。这里分别说明原因。

2.1 ETag 不是内容指纹

标准 S3 中,单 PUT 上传的普通对象,ETag 等于对象内容 MD5。但以下场景 ETag 不是内容 MD5:

  • Multipart Upload 产生的对象:ETag 是“分片 MD5 拼接后再取 MD5”,且带分片数后缀。源端分片大小与目标端重建时的分片大小不同,ETag 必然不同。
  • SSE-KMS / SSE-C 加密对象:ETag 与明文 MD5 无关。
  • 部分兼容 S3 网关(如 SeaWeedFS 单实例模式)返回的 ETag 是随机 UUID,跟内容没有任何关系。

跨云迁移中,源端对象很可能来自业务系统的 SDK 分片上传,用“ETag 不一致就重传”的策略,等于把全量数据反复搬。

判断方法:不需要盲目相信工具,先 head 几个典型大文件看返回。

aws s3api head-object --bucket src-bucket --key uploads/2024/huge_file.zip --profile src-account

对比同样的 Key 在目标端返回的 ETag。如果不一致,先确认是否存在多段上传或加密属性,不要直接判定为校验失败。

2.2 Last-Modified 可能被系统动作污染

S3 的 Last-Modified 在对象被 CopyObject 或生命周期规则转换存储类型后会被重置为“操作执行时间”。增量迁移如果以时间戳为准,会出现以下问题:

  • 源端设了生命周期策略,90 天前的老对象被自动转为 Standard-IA,Last-Modified 全部刷新到当天。增量任务把这些对象全部判定为“新对象”,全量重传一轮。
  • 用 CopyObject 在桶内做数据重整的存储系统,也会出现“内容同一份,时间却变新”的副作用。

2.3 双标尺交叉决策

推荐采用尺寸强校验 + 时间戳弱校验 + ETag 可疑队列的三级架构:

  • 主标尺:Size。对象大小不同,无条件重传。
  • 次标尺:Last-Modified。源端比目标端时间新超过 1 秒,则进入补传队列。注意允许 1 秒误差,避免不同厂商服务时间精度差异带来误判。
  • 旁路:ETag。发现 ETag 不一致,不要立即重传,而是写入“可疑清单”。等主流程结束后,对可疑对象做抽样范围读取并本地计算 MD5。

时间戳误差处理,核心代码如下:

def needs_transfer(src, dst):
    if dst is None:
        return True
    if src["Size"] != dst["Size"]:
        return True
    delta = (src["LastModified"] - dst["LastModified"]).total_seconds()
    if delta > 1:
        return True
    if src["ETag"] != dst["ETag"]:
        # 不立即重传,交给抽样审计
        suspect_queue.append(src["Key"])
        return False
    return False

三、DeleteMarker:增量阶段最大的隐藏炸弹

源端桶如果开启过版本控制,用户删除对象时不会真正删除对象,而是产生一个 DeleteMarker。迁移工具如果只遍历当前版本列表,会默认该 Key 不存在,直接在目标端跳过。

结果:源端删除行为全部丢失,目标端数据无限膨胀。更糟的是,如果目标端也开了版本控制,迁移引擎反复 PUT 同一个 Key 会产生大量历史版本,账单飙升后才被发现。

架构决策:迁移期间目标端强制关闭版本控制。如果业务要求最终开启版本控制,必须等迁移验收完成并清理完所有历史版本之后,再打开。

DeleteMarker 同步逻辑不能依赖视觉上的“空目录”或“零字节对象”。直接用版本化 API 收集当前最新删除标记:

def collect_delete_markers(bucket, profile):
    s3 = boto3.Session(profile_name=profile).client("s3", region_name="...")
    paginator = s3.get_paginator("list_object_versions")
    markers = []
    for page in paginator.paginate(Bucket=bucket):
        for marker in page.get("DeleteMarkers", []):
            if marker["IsLatest"]:
                markers.append(marker["Key"])
    return markers

这些 Key 在目标端需要执行删除操作。同时要注意:如果目标端存在同 Key 的历史版本,必须先清空目标端该 Key 的全部版本,再执行删除,否则删除动作又会产生一个新版本,达不到清理效果。

四、收敛窗口:让增量迁移有一个可判定的终点

增量追平为什么有的团队跑了一两周还在跑?因为迁移引擎每轮全量 LIST 期间,业务新写入的对象会不断出现。只要源端还有写入,LIST 扫描就像追着自己尾巴的猫。

从工程角度定义一个收敛窗口 W:

W = max(单轮全量 LIST 扫描耗时, 60秒高水位保护) * 2

迁移是否完成,不再以“本批次迁移对象数为 0”为准,而是以连续两轮完整扫描结果完全相同为准。

完整收敛过程设计如下:

  • 第一轮 LIST:跑出源端全部 Key 与目标端差异,执行补传与删除。
  • 等待 W 时间窗口。
  • 第二轮 LIST:如果差异集为空,且与第一轮扫描集合一致,判定迁移收敛完成。
  • 只要第二轮出现任何新 Key 或任何变化,重新开始计时,进入下一轮迭代。

这个窗口解决了 S3 ListObjectsV2 分页导致的竞态边界问题。S3 每次返回 1000 个 Key,如果在 LIST 分页遍历过程中业务刚好写入了一个新 Key,且其排序落在游标之后,则本轮扫描读不到,必须靠下一轮兜底。

五、整体迁移架构

+-------------------+       +-------------------+       +-------------------+
|      源端 S3      |       |   迁移引擎        |       |     目标端 S3     |
|                   |       |                   |       |                   |
|  ListObjectsV2   | ----> |  分页游标管理器   | ----> |  ListObjectsV2   |
|  GetObject        | <---- |  双标尺判定器     | <---- |  HeadObject       |
|  ListObjectVersions |     |  任务队列/重试     |       |  PutObject        |
|                   |       |  SQLite 断点状态  |       |  DeleteObject     |
+-------------------+       +-------------------+       +-------------------+

迁移引擎建议部署在网络上联带宽充足、离源站和目标站延迟都不高的服务器上。这里提一个老操作实践:这种 S3 到 S3 的迁移任务本质是大量小请求,普通 VPS 的 1M 带宽会直接卡死在 GET/PUT 的往返时延上。如果你用的是高带宽服务器的云厂商——比如轻云互联的机器——把并发拉高后,往往能自然跑满对象存储的 QPS 阈值;但如果只是家用宽带走代理转发,加多少并发都是徒劳,瓶颈在网络路径上。

引擎内部用 SQLite 记录每个 Key 的状态。状态机建议这样设计:

  • PENDING:待补传。
  • COPIED:已完成尺寸 + 时间戳校验,待终验。
  • SUSPECT:ETag 不一致,等待范围读取哈希复核。
  • TOMBSTONE:源端删除标记,需要在目标端执行删除。
  • VERIFIED:通过最终抽验,参与整体收敛判定。

断点续跑不依赖工具自身日志,直接读 SQLite 中未完成状态即可。这样即使迁移引擎中途崩溃,重启后也能精准恢复。

六、最终验收实现:双轮比对代码

这是一个可落地的收敛判段脚本框架,核心是连续两轮比对集合完全一致:

def snapshot(bucket, profile):
    s3 = boto3.Session(profile_name=profile).client("s3")
    paginator = s3.get_paginator("list_objects_v2")
    objs = {}
    for page in paginator.paginate(Bucket=bucket):
        for obj in page.get("Contents", []):
            objs[obj["Key"]] = {
                "Size": obj["Size"],
                "LastModified": obj["LastModified"].replace(tzinfo=None),
                "ETag": obj["ETag"].strip('"')
            }
    return objs

def compare(src, dst):
    transfer_list, suspect = [], []
    for key, so in src.items():
        do = dst.get(key)
        if do is None:
            transfer_list.append({"op": "PUT", "key": key})
            continue
        if so["Size"] != do["Size"]:
            transfer_list.append({"op": "PUT", "key": key})
            continue
        delta = (so["LastModified"] - do["LastModified"]).total_seconds()
        if delta > 1:
            transfer_list.append({"op": "PUT", "key": key})
            continue
        if so["ETag"] != do["ETag"]:
            suspect.append({"op": "AUDIT", "key": key})
    for key in dst.keys() - src.keys():
        transfer_list.append({"op": "DELETE", "key": key})
    return transfer_list, suspect

# 运行第一轮
src1 = snapshot(SRC_BUCKET, SRC_PROFILE)
dst1 = snapshot(DST_BUCKET, DST_PROFILE)
first_diff = compare(src1, dst1)

# 等待 W 窗口后,再跑第二轮
sleep(WINDOW_SECONDS)

src2 = snapshot(SRC_BUCKET, SRC_PROFILE)
dst2 = snapshot(DST_BUCKET, DST_PROFILE)
second_diff = compare(src2, dst2)

if first_diff == second_diff and len(second_diff) == 0:
    print("CONVERGED: 迁移收敛完成")
else:
    print("NOT CONVERGED: 进入下一轮迭代")

这个设计的重点在于:第一轮结束后不立刻宣布完成,而是强制经过一个稳定窗口再跑第二轮,并且要求两轮结果一致。目的是给“LIST 分页游标之后新出现的对象”一个完整暴露周期。

七、性能推导:小对象海量迁移时的并发模型

对象存储迁移的性能瓶颈往往不是带宽,而是 QPS。当对象平均只有几十 KB 时,即使 10Gbps 带宽满跑,每秒也只能迁移几千个对象,实际远达不到,因为每个对象至少需要 HEAD + GET + PUT 三次请求,S3 列表接口每页 1000 个对象的网络往返也在消耗时间。

实际吞吐近似公式:

单请求耗时 = RTT + 对象存储服务端处理时间
单对象总耗时 ≈ 3 * (RTT + 服务端处理时间)
系统吞吐上限 = min(引擎并发度 / 单对象总耗时, 源端GET QPS配额, 目标端PUT QPS配额)

在跨地域迁移场景中,RTT 往往占大头。比如源站和目标站之间 RTT 为 20ms,单对象三次请求就是 60ms,假设引擎开 100 并发,理论 QPS 也才 1600 左右,约等于每秒处理 32MB(按 20KB/对象计算)。这不是带宽不够,而是请求往返太慢。

优化手段有两种:

  • 对小于 8MB 的小对象合并打包后并行传输,不做逐对象请求。
  • 迁移引擎尽量部署在离目标站近的机房,并且确认服务器带宽规格不小于 10Gbps 入口。我们在实操中往往选择对象存储同区域的高带宽云服务器跑引擎。如果是轻云互联这类提供大带宽的 VPS 主机,在源站点和目标站点 RTT 本来就低的情况下,把并发再调高,效果立竿见影。

八、迁移验收速查清单

  • 关闭目标端版本控制再开工;验收完成前不允许业务写入目标端。
  • 迁移源端若开启版本控制,必须自动收集 IsLatest=true 的 DeleteMarker,否则删除行为丢失。
  • 勿以 ETag 差异直接重传,先确认源端对象是否由多段上传产生。
  • 勿以 Last-Modified 精确等于判定一致,给 1 秒误差余量,并将 ETag 不一致对象加入抽验审计队列。
  • 连续两轮全量 LIST 集合完全一致才允许宣布迁移完成。
  • 目标端存储容量与源端偏差在 1% 以内且持续稳定,才算从存储侧完成验证。

对象存储迁移的复杂程度被太多工具类文章低估了。工具只是执行者,架构师必须为校验标尺、删除语义、收敛终态三件事写出可验证的判定逻辑。没有明确的收敛条件,任何对象存储迁移都只是一个“看起来完成了”的长期定时任务。