S3 / Object Storage 在生产中常见的几个问题
关于 list-after-write 、multipart orphan part 的隐形账单、prefix partition throttling、lifecycle × versioning
Object storage 的 API 就那几个, PUT、GET、DELETE、LIST。可能是你碰到的最简单的存储抽象。
但跑到一定规模,出问题的方式不是 500 error 这种明确的crash。而是bill上多了一笔你找不到来源的cost,lifecycle rule 把你以为有版本保护的数据删了,或者高峰期偶尔 503 几分钟然后自己好了。
01 list-after-write, 一致性模型改了,但你的系统没跟着改
2020 年之前,object storage 的读写一致性是 eventual consistency。PUT 一个 object 后立刻 GET,能拿到。但 PUT 后立刻 LIST,不一定能看到。overwrite 或 DELETE 后立刻读,可能拿到旧版本。
2020 年底改成了 strong read-after-write consistency。很多老代码里为了绕过 eventual consistency 加的 sleep、retry、polling 逻辑,从那天起就成了 dead code。我见过一个写入 pipeline 每次 PUT 后 sleep 500ms 再 LIST,注释写着 "wait for eventual consistency"。代码 2016 年写的,跑到现在每次写入白等半秒,没人动过。
一致性保证的 scope 是 object storage 自身。你的系统大概率不是裸调 API,中间可能有 CDN cache、有 metadata DB、有 application 层 cache。任何一层缓存都能把这个一致性打破。
假设一个数据平台。pipeline 写完文件后更新一张 metadata 表记录路径,下游读表拿路径去 GET。偶尔 metadata 写入比 S3 PUT response 先完成——下游拿到路径时 PUT 还没 commit。404。概率万分之几,但每天几百万文件,每周冒几单。查了两天才定位——万分之几的 404 太容易被当网络抖动忽略了。
fix 是调操作顺序:S3 PUT response 200 确认后再写 metadata。一个 happens-before 的事。
另一个跟 LIST 相关的问题是性能。consistency 解决了,但 LIST 仍然是每页 1000 key 的 sequential pagination。百万级 key 的 bucket 全量 LIST 要上千次 API call。
我碰过一个 reconciliation job 用 LIST 遍历所有 key 做校验,bucket 涨到 500 万 key 之后从跑 3 分钟变成 40 多分钟——LIST 是 O(N/1000) 次请求,每次还有 rate limit。
很多团队到这个阶段开始维护独立的 metadata index 做 inventory,但这又绕回了上面那个一致性问题:index 和 S3 之间的 consistency 还是得你自己保证。
02 multipart upload 的 orphan part——每月的神秘成本增长
一个 data pipeline 每天写大概 500 个 parquet 文件,200MB 到 1GB 不等,用 multipart upload,十几个文件并发传。pipeline 偶尔 crash,每天两三次。retry 逻辑会重新发起上传,最终数据都写进去了,功能上没问题。跑了大半年。
用 ListMultipartUploads 一查:6000 多个 incomplete upload——每次 crash,所有 in-flight 的上传一起变孤儿,一次留十几个。平均每个传到一半、300MB 上下,累计约 1.8TB orphan part。按 Standard 单价每月多付约 $40。
multipart upload 是三步:Initiate 拿 upload ID,逐个上传 part,最后 Complete 拼起来。中间 crash 了,没 Complete 也没 Abort,已上传的 part 就永远留着。retry 是拿新 upload ID 从头传,旧 ID 下的 part 没人认领。它不出现在 ListObjects 结果里,不被普通的 expiration rule 处理,不自动过期。但账单照算。
加一行 lifecycle rule 就修好了:AbortIncompleteMultipartUpload,7 天。下个周期那 1.8TB 清干净了。
其实就一行配置的事。难的是发现:ListObjects 看不到它,bucket 总大小的 metric 里它混在里面但不单列——你只会隐约觉得 bucket 比预期大。要定位得主动跑 ListMultipartUploads,或者看 Storage Lens 里的 incomplete multipart upload bytes ,再或者从账单反推存储量对不上。
03 prefix throttling——3500 PUT/s 不是 bucket 上限,是单 partition 上限
Object storage 按 key prefix 做 internal partition。单 partition 上限大概 3500 PUT/s、5500 GET/s。你的 bucket 总吞吐可以远高于这个数——前提是写入分散在足够多 prefix 上。
日志场景最容易撞。假设key 是:
logs/2026-07-04/server-001/chunk-00001
同一秒的写入全打在:
logs/2026-07-04/
这个 prefix 上。低峰没感觉,高峰一来就 503 SlowDown。
auto-partition 机制会自动拆 hot prefix,但 ramp-up 需要几分钟到十几分钟。traffic spike来得快走得快的话,auto-partition 还没生效spike就过了。下次来又是一样。
key salting:key 最前面加 hash prefix:
hex(hash(filename))[0:2]/logs/2026-07-04/...
256 个 prefix 把写入散开,单 partition QPS 降到 1/256。
但你没法用 prefix filter 高效 LIST 某一天的全部日志了——散在 256 个 prefix 下,拿全量就是 256 次 LIST merge。
写多读少的日志场景这个换算通常值;下游频繁按天 LIST 的话就得另想方案了。
如果已经在被 throttle 需要立刻缓解,key salting 是个 schema 变更——存量数据都要 rename。短期只能 exponential backoff + retry,等 auto-partition 自己 ramp up。
04 lifecycle × versioning——你以为的备份,可能两个 region 一起丢了
开了 versioning 的 bucket,overwrite 创建新 version,DELETE 插 delete marker。看起来数据有版本保护了。
加一条 lifecycle rule:noncurrent version 90 天后 expire。你每天 overwrite 同一个 snapshot key。90 天后最早的 version 被 lifecycle 永久删了。
你以为有完整版本历史、可以回滚到任意时间点——其实回滚窗口永远只有 90 天,是滑动的。
这可能是你有意的设计,也可能只是把 “versioning” 理解成了”永久保留”。
再叠加 cross-region replication。很多人担心 source 的 lifecycle 删除会propagate到 destination,两边一起丢。这恰恰不会发生, S3 明确设计成 lifecycle 动作不参与 replication:lifecycle expiration 产生的 delete marker 不复制;对具体 version ID 的永久删除(包括 noncurrent expiration)在任何配置下都不复制。就是为了防止误删和恶意删除扩散到副本。
真正的问题是另一条链。用户发起的 DELETE(不带 version ID)产生的 delete marker,如果你在 source 的 replication rule 里开了 delete marker replication,会复制到 destination, 于是两边的 current version 同时变成 noncurrent。这时如果你图省事把同一份 lifecycle 也铺到了 destination,”两边配置保持一致嘛”——90 天后,两个 region 一起永久删。
删除保护不是 replication 白送的,是 destination 配置得不一样换来的:destination 不配 noncurrent expiration,或者窗口放宽得多。代价也是真的——destination 会堆积 source 早已清掉的旧版本,storage cost 涨,两套不对称的 retention 要分开管理。没有一劳永逸的配法。
还有个容易忘的细节:versioning 下 DELETE 不删数据,只插 delete marker。频繁 DELETE 的场景 marker 堆积,LIST 性能下降——每次 LIST 都要跳过这些 marker。lifecycle 的 ExpiredObjectDeleteMarker 可以自动清理,默认不开。不配的话 marker 越堆越多,LIST 越来越慢,而且你从 ListObjects 里看不出来为什么慢。
05 storage class 迁移——省钱方案怎么翻车的
Standard $0.023/GB/月,Infrequent Access $0.0125/GB/月。近半价。但 IA 有几个隐藏成本能把账算反。
retrieval fee $0.01/GB。一个 50GB 数据集每月读两次:
Standard 存储费:50 × 0.023 = $1.15/月
IA 存储加读取:50 × 0.0125 + 100 × 0.01 = $1.625/月
搬过去反而贵了 40%。不是所有”不常访问”的数据都适合 IA——得看多不常。一个月碰一次以上,Standard 可能还便宜。
IA 还有 minimum storage duration 30 天——写入后 3 天删了按 30 天收。以及 minimum billable size 128KB——一百万个 1KB 小文件在 Standard 占约 1GB,到 IA 按约 128GB 计费。高周转临时文件和海量小文件是 IA 的两个地雷。
Glacier 便宜一个数量级,但先分清是哪个 Glacier——2021 年之后它是三档:Instant Retrieval($0.004/GB/月,毫秒级取回,但 retrieval $0.03/GB)、Flexible Retrieval($0.0036/GB/月,取回分档:expedited 几分钟 $0.03/GB,standard 3-5 小时 $0.01/GB,bulk 5-12 小时免费)、Deep Archive($0.001/GB/月,取回 12-48 小时)。最低存储时长分别是 90、90、180 天。
假设一个团队把 30TB audit log 转到 Glacier Flexible,每月省约 $580,年省约 $7000。半年后审计要调历史数据,deadline 压着,没人事先想过取回路径,慌乱之下全量走 expedited:30TB × $0.03 = $900(还没算按对象数收的 restore request fee)。分析跑完发现要再来一轮,而 restore 出来的临时副本设的保留期已经过了——那份副本存续期间还要另付存储费——只好又取一次,再 $900。
$1800,一个季度的存储节省。而这批数据走 bulk 一分钱取回费都不用花,走 standard 也就 $300。问题不是 Glacier 的价格,是没人设计过”取的时候怎么取”。
搬之前先跑 access pattern 分析——读频率、object 生命周期、size 分布——再把取回路径写进 runbook:走哪一档、restore 副本保留几天、谁有权触发。不是拍脑袋”这个看起来冷”就往冷存搬。
Object storage 的 API 确实简单。但 consistency model、multipart lifecycle、partition 机制、versioning 交互、cost model 这几层叠加起来,就不是一个“PUT 进去 GET 出来”的事了。
出问题时不 crash 也不报错, 只会安静地花更多钱、安静地丢版本,高峰期偶尔 503 几分钟然后自己好了。














