对象存储自动化对账对不平的根因:ETag 的 multipart 编码、ListObjects 的非快照分页、秒级 Last-Modified

先说场景。你写了个巡检脚本,每周把本地 NAS 的 200 万文件跟对象存储做一次全量对账:拉 ListObjectsV2 出全量 key + ETag,跟本地 md5sum 结果比。跑完发现两边差了 3 万条,其中 2.9 万条本地有、对象存储"没有",还有几百条 ETag 对不上。你去控制台一翻,对象明明在。脚本没写错,错在你把 S3 的语义当成 POSIX 用了。

这篇把三层底层语义拆开讲,每一层都会让"想当然"的自动化脚本翻车。

坑一:ETag 只在"单分片 PUT"时才等于 MD5

S3 的 ETag 定义只有一句话:它是对象内容的实体标签,不承诺是任何特定摘要。真实行为是这样的:

  • 单次 PUT、无 SSE-KMS/SSE-C、无自动分片 —— ETag = 内容 MD5 的 32 位小写十六进制。
  • Multipart Upload —— ETag = MD5(各分片 MD5 的二进制拼接) + "-" + 分片数。注意这里是"二进制拼接后再做一次完整 MD5",不是把十六进制字符串拼起来再哈希,很多人第一步就错了。
  • SSE-KMS / SSE-C —— ETag 直接变成随机值或密文摘要,跟明文毫无关系。

最阴的是第三种:你以为自己发的是单次 PUT,SDK 却在背后帮你分片了。aws-cli / boto3 对超过 multipart_threshold 的 body、以及部分实现里超过服务端 chunk size 的流式上传,都会自动切成 multipart。Ceph RGW 侧还有 rgw_max_chunk_size,大对象即使客户端一次 PUT 进来,落盘也会被拆成多个 RADOS object,某些版本下 ETag 就带上了 -N 后缀。

所以对账脚本里判断 ETag 能不能用,第一件事是拆后缀:

# 拉一批 head,看有多少 ETag 带 -N
aws s3api list-objects-v2 --bucket mybucket --max-items 1000 \
  --query 'Contents[].[Key,ETag]' --output text \
| awk '{gsub(/"/,"",$2); if ($2 ~ /-[0-9]+$/) mpu++; else single++} END \
  {print "single="single, "mpu="mpu}'

Python 里更直接:

def etag_is_md5(head):
    etag = head['ETag'].strip('"')
    if '-' in etag:                      # multipart:结构不可逆
        return False
    if head.get('ServerSideEncryption'):  # KMS/C 都不等于明文 MD5
        return False
    return len(etag) == 32

只要返回 False,ETag 这一列必须整列丢弃,不能"降级使用",因为它连长度都不保证。

正确的做法:用 checksum,别用 ETag

S3 从 2022 年起支持额外的校验和字段,这才是为对账设计的。注意 ChecksumSHA256 是 base64,不是十六进制,本地算完必须转码:

# 本地算 base64 形态的 sha256
SUM=$(sha256sum payload.bin | awk '{print $1}' | xxd -r -p | base64)

aws s3api put-object --bucket mybucket --key payload.bin \
  --body payload.bin \
  --checksum-algorithm SHA256 \
  --checksum-sha256 "$SUM"

# 读回来验证(必须显式打开 checksum-mode)
aws s3api head-object --bucket mybucket --key payload.bin \
  --checksum-mode ENABLED \
  --query '[ChecksumSHA256,ChecksumType,ETag]' --output text

如果服务端实现还不支持 checksum 字段,退路是写 user metadata,比如 --metadata sha256=<hex>,返回时就是 x-amz-meta-sha256。这里有个坑必须记住:CopyObject 默认是 COPY 模式,会原样复制 metadata,你以为改掉了,其实没改,要覆盖得显式加 --metadata-directive REPLACE。另外 user metadata 有 2KB 的硬上限,塞不进就别塞。

坑二:ListObjects 的分页 token 不是快照游标

这是"本地有、远端说没有"这类误报的主要来源。

底层实现上,对象存储的 bucket 不是一张表,而是一组有序索引分片。以 Ceph RGW 为例,每个 bucket 的索引落在 <bucket_id>_index.<shard_no> 这个 RADOS object 的 omap 里,entry key 就是对象名,value 里塞着 size、mtime、content-type、以及版本列表。分片数在桶创建时定死(默认跟 pg_num 挂钩),扩容得靠 radosgw-admin bucket reshard。

ListObjectsV2 的执行过程是:给每个分片发一次 omap 顺序读,把 N 路有序流做一次归并,凑满 max-keys 就返回,NextContinuationToken 本质上编码的是"上次归并停在哪个位置"。它不是一个 MVCC 快照句柄。这意味着:

  • 翻页期间有并发 PUT/DELETE,读取位置前后会发生错位,出现漏读或重复读,概率不高但全量对账必中。
  • reshard 过程中,新旧两代索引同时存在,归并结果可能出现短暂重复。
  • 跨分片的字典序只在"每个分片内部有序 + 归并有序"成立时才正确。一旦有分片读超时被跳过后重试,顺序就乱了。

所以对账脚本的正确姿势是拿一个可重放的定位点,而不是一个游标:

# 用 versioning + list-object-versions 做一致性取样
aws s3api list-object-versions --bucket mybucket \
  --prefix logs/2026/ \
  --max-items 1000 \
  --query 'Versions[?IsLatest==`true`].[Key,VersionId,Size,ChecksumSHA256]' \
  --output text

VersionId 是不可变的,IsLatest + VersionId 组合起来才是真正的快照语义 —— 遍历过程中对象被覆盖,旧 VersionId 还在,你能识别出"这一条已经过期",而不是误判成内容不一致。没有 versioning 的桶,就只能靠"对账前后各跑一次 --max-keys 计数 + 用 checksum 逐条校验",接受少量重跑。

别为了省时间开 --allow-unordered

RGW 有个 radosgw-admin bucket list --allow-unordered,跳过归并排序直接分片并发读,百万级对象的列出能从分钟级降到秒级,做容量统计很香。但它的返回顺序不保证字典序,也就意味着你没法用 --start-after / --key-marker 稳定续传。统计可以用,对账绝对不能用。这两个场景的脚本一定要物理隔离,别共用一个函数。

坑三:Last-Modified 只到秒,而且是"写入时间"

三个反直觉点:

  • 它是 HTTP Date 头的精度,秒级,没有毫秒。你用本地文件 st_mtime(纳秒)去比对必然不等。
  • Multipart 上传的对象,Last-Modified 是 CompleteMultipartUpload 的时间,不是第一个分片开始上传的时间。传了 6 小时的大文件,时间戳是最后一刻。
  • 它的字段名是 Modified,但语义是对象版本写入时间,跟访问、跟元数据修改都无关。你改了 content-type 但内容没变,这个时间不会动。

Lifecycle 规则就是基于这个时间戳算的。所以当你写自动化清理脚本,用 NOW - LastModified > 30d 去筛"过期对象"时,跨时区、秒级舍入、multipart 时间偏移这三件事叠起来,边界上必然误删或多留。建议所有基于时间的自动化判断统一加 1 天缓冲,并且时间比较全部走 UTC,别在本地时区做减法。

顺手的:孤儿分片才是自动化真正该管的东西

对账脚本之外,最容易被忽略的是一堆"传了一半就断了"的 multipart upload。这些分片已经实打实占了存储,但在 ListObjects 里一条都看不见,因为对象根本还没完成,没有 index entry 指向它。它们只能通过独立的接口列出来:

aws s3api list-multipart-uploads --bucket mybucket --max-items 1000 \
  --query 'Uploads[].[Key,UploadId,Initiated]' --output text

注意这个接口同样有分页陷阱:它用 --key-marker + --upload-id-marker 双游标,只传 key-marker 会在同一 key 有多个未完成 upload 时死循环。清理靠 lifecycle 规则最省事:

<Rule>
  <ID>abort-stale-mpu</ID>
  <Filter><Prefix></Prefix></Filter>
  <Status>Enabled</Status>
  <AbortIncompleteMultipartUpload>
    <DaysAfterInitiation>3</DaysAfterInitiation>
  </AbortIncompleteMultipartUpload>
</Rule>

另外,索引跟实际对象的偏差可以主动修,不用自己写对账逻辑:

# 检查 bucket index 与底层对象的一致性,--fix 会尝试修复
radosgw-admin bucket check --bucket=mybucket --check-objects --fix
# 看分片数,分片太少会让并发 list 全部打到一个分片
radosgw-admin bucket stats --bucket=mybucket

分片数是 num_shards / index_pool 那一行。单桶几百万对象、shard 还是个位数,list 延迟会很丑;这时候做一次 reshard,比在客户端加并发线程有用得多。

把对账脚本重写一遍

定下三条铁律就够了:

  • 内容校验只用 checksum 字段(ChecksumSHA256 / x-amz-meta-*),ETag 只用来判断"是不是 multipart",不做任何相等比较。
  • 列表只用带 VersionId 的接口,或者接受"非快照"这个事实,把重跑逻辑写进脚本而不是靠运气。
  • 时间比较全部转 UTC 并留缓冲,秒级精度不允许做等值判断。

这套脚本我现在跑在轻云互联的云服务器上,巡检进程跟对象存储 endpoint 走内网,单次 list-object-versions 的 RTT 压到毫秒级,200 万对象的全量对账 40 分钟内跑完,因为省掉了公网来回,分页成本才可控。跑在外网 endpoint 上,光分页的往返延迟就能把整个窗口撑到几小时,中间撞上并发写,对账结果直接不可信。

对象存储的自动化运维,难点从来不是写脚本,是写脚本之前先接受"它不是你熟悉的那个文件系统"。