对象存储数据迁移避坑指南:mtime 被覆写成迁移时间、Content-Type 全变二进制、200 万个 part 碎片在后台计费
先说结论:对象存储迁移里,"对象数对得上、总大小对得上"是最没用的两个指标。我见过太多团队拿 rclone size 跑出一致的结果就拍胸脯上线,结果三天后业务方找上门——图片全变下载、增量同步又跑了一整夜、月底账单比预期多出四位数。
下面这七条,全是迁移写入侧造成的不可逆损失,跟校验算法没关系,属于迁完就晚了。
一、mtime 不是原子上传的,它取决于你用哪个工具写
S3 协议里根本没有 mtime 字段。对象存储给你的是 Last-Modified,由服务端在收到 PUT 时打上。rclone 之所以能让 sync 正常工作,是因为它偷偷在对象上挂了一个用户元数据 X-Amz-Meta-Mtime。
问题来了:aws s3 cp、aws s3 sync、很多厂商自带的在线迁移服务、甚至部分网关的 server-side copy,都不会写这个 header。它们只设置 Last-Modified = 现在。
后果是这样的:你从本地文件系统迁到对象存储,本地文件 mtime 是 2023-05-11,对象存储上显示 2026-09-27。第二天你跑增量同步 rclone sync /data s3:bucket --update,rclone 读到目标端 modtime 比源端新,判定"目标已是最新",全部跳过。你以为同步成功了,其实一个大文件都没传。而如果你没加 --update,它会反向认定源端更旧,触发全量重传——400TB 再跑一遍。
迁移后立刻做一次元数据体检:
# 抽样 1000 个对象,看有没有 X-Amz-Meta-Mtime
rclone lsjson s3:bucket -R --files-only --metadata --max-depth 2 \
| jq -r '[.Path, (.Metadata.mtime // "MISSING")] | @tsv' \
| head -1000 \
| awk -F'\t' '$2=="MISSING"{c++} END{print "missing mtime:", c+0}'
如果 MISSING 数量不为 0,不要指望事后批量补。真正可用的做法是回滚这次迁移,换 rclone 重跑(它从 v1.5x 起对 S3 后端默认写 mtime 元数据),或者在你自己的迁移脚本里强制加:
rclone copy /data s3:bucket \
--transfers 16 --checkers 32 \
--s3-chunk-size 64M \
--header-upload "x-amz-meta-mtime: {modtime}" # 部分后端支持,实测前先单文件验证
还有一条铁律:一个 bucket 只允许一种工具写入。混用 aws-cli 和 rclone,你的 modtime 语义会彻底乱掉。
二、Content-Type 全变成 application/octet-stream,图片站直接崩
这是最容易被验收漏掉的一条。因为对象大小、对象数、甚至 MD5 都对,只有 Content-Type 丢了。
表现:浏览器请求一张 jpg,返回头是 Content-Type: application/octet-stream,Chrome 直接触发下载;CSS/JS 被当成二进制,全站白屏。
排查:
rclone lsjson s3:bucket/static -R --files-only --metadata \
| jq -r 'select(.Metadata["content-type"]=="application/octet-stream") | .Path' \
| wc -l
修复只能靠自复制 + REPLACE:
aws s3 cp s3://bucket/static/app.css s3://bucket/static/app.css \
--metadata-directive REPLACE \
--content-type text/css \
--cache-control "public, max-age=31536000"
批量做的话,从本地 /etc/mime.types 生成映射表再循环,别手搓。注意 --metadata-directive REPLACE 会把所有用户元数据一起替换掉,包括上一条的 mtime,所以顺序很重要——先修 Content-Type,最后统一处理时间戳。
三、存储类别丢失,账单直接翻倍
源端 bucket 里一堆 STANDARD_IA、GLACIER、低频访问的对象,迁完之后全变 STANDARD。迁移工具默认不读源端的 StorageClass,只写标准存储。
# 统计非标准存储的对象数
aws s3api list-objects-v2 --bucket mybucket \
--query 'Contents[?StorageClass!=`STANDARD`] | length(@)'
迁完发现是 0,而源端有 8000 万个低频对象——这就是几个月的成本。[注意:list-objects-v2 对超大桶同样有分页和一致性陷阱,迁移前先在源端统计好各 StorageClass 的基线数,迁完对比,别等账单出来才发现。]
四、看不见的 part 碎片:不在 ListObjects 里,但在计费
分片上传(Multipart Upload)中断后,已上传的 part 是独立计费的,而且不会出现在 ListObjects 的结果里。你 400TB 的迁移中间断了十几次,每次断在中途,残留几百个 part。
我见过一个真实的极端 case:一次跨云迁移反复重试了 3 周,最后 list-multipart-uploads 拉出来 200 多万个未完成分片,占了 60TB 的隐形空间,账单上完全看不出来是谁占的。
# 统计未完成的分片上传
aws s3api list-multipart-uploads --bucket mybucket \
--query 'Uploads | length(@)'
# 分批清理(单次最多 1000 个)
aws s3api list-multipart-uploads --bucket mybucket \
--query 'Uploads[].[UploadId, Key]' --output text \
| while read uid key; do
aws s3api abort-multipart-upload --bucket mybucket --key "$key" --upload-id "$uid"
done
更省事的办法是迁移开始前就挂上生命周期规则:
cat > lc.json <<'EOF'
{
"Rules": [{
"ID": "abort-incomplete-mpu",
"Filter": { "Prefix": "" },
"Status": "Enabled",
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 3 }
}]
}
EOF
aws s3api put-bucket-lifecycle-configuration --bucket mybucket \
--lifecycle-configuration file://lc.json
注意 DaysAfterInitiation 别设太小(比如 1 天),正常业务里跑 8 小时的大文件上传会被误杀。3 天是比较稳的值。
五、rclone 的内存是乘法,不是加法
新手最容易死在这。觉得 --transfers 调到 64 就快了,结果迁移中转机的 OOM Killer 开始杀进程。
内存公式(粗略上限):
峰值内存 ≈ --transfers × --s3-upload-concurrency × --s3-chunk-size
看这组"看起来很保守"的参数:
--transfers 8 --s3-upload-concurrency 4 --s3-chunk-size 32M
# = 8 × 4 × 32MB = 1GB,再加 checkers 和 hash 缓存,一台 4G 机器直接跪
更隐蔽的是 --s3-chunk-size 还有个硬约束:S3 协议规定单个对象最多 10000 个分片,最小分片 5MB。你要迁 10TB 的大文件,必须 chunk-size 拉到 2GB 以上,否则直接失败。而这个值一旦拉大,乘法的基数就爆了。
还有一个坑:--checkers 和 --transfers 是两套并发。checkers 负责列目录和比对,transfers 负责传。迁小文件场景下,checkers 往往才是真正的瓶颈,把 transfers 调再高也没用,反而把 checkers 的 64 个并发全耗在 ListObjects 上。
那次我们跑 400TB 用的中转机是轻云互联的大带宽机型,出口带宽确实没成为瓶颈,10G 口只用了 40%,但 CPU 全被 TLS 握手和 SigV4 签名吃掉了——迁移的瓶颈从来不在带宽,在单核签名和 PUT 的 TPS。后来把 --transfers 砍到 12、加了多台机器分 bucket 跑,整体反而快了 3 倍。
六、前缀热点:你的 64 并发全打在同一个 partition 上
对象存储是按 key 的字典序切分区(partition)的。如果你的 key 长这样:
data/2026/09/27/aaa0001.log
data/2026/09/27/aaa0002.log
...
data/2026/09/27/zzz9999.log
迁移工具按字典序遍历,同一时刻并发写的 key 前缀高度重合,全部落在同一个 partition 上。表现就是:带宽上不去,日志里疯狂刷 503 SlowDown 和 RequestLimitExceeded。
正确姿势是按目录树多进程并行,让并发散落在不同的 key 区间:
# 每个子目录一个 rclone 进程,天然打散 key 区间
for d in $(ls /data); do
rclone copy "/data/$d" "s3:bucket/$d" \
--transfers 8 --checkers 16 \
--retries 10 --retries-sleep 5s --low-level-retries 20 \
--log-file "/var/log/rclone-$d.log" &
done
wait
看到 503 不要慌,那是正常的退避信号。真正要命的是没配 --retries,rclone 默认重试次数有限,一次 503 就把文件标记为失败,最后你的迁移结果是"95% 成功",那 5% 你还得重新对一遍。
七、时钟漂移:批量迁移跑到一半全部 403
SigV4 签名要求客户端时间与服务端相差不超过 15 分钟。迁移机上如果没开 NTP,或者虚拟化环境下时钟漂移,表现是:前两小时一切正常,突然所有请求开始返回 RequestTimeTooSkewed,整个迁移任务雪崩。
chronyc tracking # 看 System time offset
timedatectl set-ntp true
# 迁移脚本开头加一道硬检查
offset=$(chronyc tracking | awk '/System time/{print $4}')
[ "${offset#-}" -gt 60 ] && echo "时钟偏移过大,先修时间" && exit 1
迁移收尾必跑的 5 条命令
别用"迁移工具没报错"当验收标准。这五条跑完再上业务:
# 1. 对象数与总大小基线对比(源端先跑一遍存档)
rclone size s3:bucket -R
# 2. 空对象扫描——迁移中断最典型的产物
rclone lsjson s3:bucket -R --files-only \
| jq -r 'select(.Size==0) | .Path' | wc -l
# 3. Content-Type 异常比例
rclone lsjson s3:bucket -R --files-only --metadata \
| jq -r 'select(.Metadata["content-type"]=="application/octet-stream") | .Path' | wc -l
# 4. 未完成分片残留
aws s3api list-multipart-uploads --bucket mybucket --query 'Uploads | length(@)'
# 5. 抽样 1% 下载比对(不是比 ETag,ETag 带分片后缀,比不了)
rclone check /data s3:bucket --download --checkers 32 --sample 1
顺带说一句第 5 条:--download 会把对象拉回来算真实哈希,代价大但唯一可信。ETag 对分片上传的文件是 md5(part-md5s)-partcount 格式,直接拿它跟本地的 md5sum 比,永远对不上——这是新手最常踩的"假失败",跑一晚上全是 mismatch,结果数据其实完好。
最后一句:迁移的参数、工具、机器,在第一次迁移前先用 1TB 数据完整演练一遍全流程,包括中断恢复、元数据检查、碎片清理。400TB 不是 1TB 的 400 倍,是 400 次故障机会。