前五篇把数据落到一个个 Parquet 文件里(见 列式编码与压缩)。但这些文件躺在哪?在 lakehouse 里,几乎都是 S3 这类对象存储,而不是本地磁盘或 HDFS。问题是:对象存储的语义和文件系统差得很远,很多在 POSIX 上理所当然的操作,在 S3 上要么很贵,要么根本不存在。
本文不教 aws s3 cp 怎么用,而是把表格式(第
7
章起)赖以工作的存储底座讲清楚,回答四个会直接决定提交协议设计的问题:
- S3 现在到底是强一致还是最终一致?一致性保证的边界在哪?
- 列出一个「目录」(前缀)下的文件,代价随对象数怎么变?
- 为什么对象存储「重命名一个目录」这么贵,贵在哪?
- 条件写(
If-None-Match/If-Match)凭什么能当原子提交的支点?
文末有一组本机实测:在本地文件系统上量化「按 copy
方式重命名一批文件」相对「元数据级
rename」的代价差,以及枚举开销随对象数的增长。本写作环境无法访问公网镜像仓库、未安装
boto3 / aws CLI / MinIO
客户端,因此 不给任何伪造的 S3
延迟数字;真正在 S3 / MinIO
上复现的步骤单列在第八节。
版本与口径锚定:S3 行为以 AWS S3 用户指南(Amazon S3 User Guide) 当前版本为准;涉及 2024 年新增的条件写特性会标注。兼容实现(MinIO、Ceph RGW、Cloudflare R2 等)大方向一致,但条件写、一致性、multipart 上限等细节按各自文档为准,文中差异处会点明。
一、对象存储不是网络文件系统
先把心智模型摆正。POSIX
文件系统给你的是一棵目录树加一组带
inode 的可变文件:可以 seek 到任意
offset 改几个字节,可以 append,可以
rename
一个目录而瞬间改变成千上万文件的路径,可以
flock。对象存储给你的是一个扁平的键值映射:key(一个字符串)→
object(一段不可改写的字节 + 元数据)。
| 维度 | POSIX 文件系统 | 对象存储(S3 语义) |
|---|---|---|
| 命名空间 | 真实目录树 + inode | 扁平 key→object 映射 |
| 「目录」 | 一等公民 | 只是 key 的公共前缀,不真实存在 |
| 部分写 | pwrite/seek 改任意区间 |
不支持;只能整对象覆盖 |
| 追加 | O_APPEND |
通用对象不支持追加 |
| 重命名 | rename(2),目录级原子 O(1) |
无;只能 CopyObject +
DeleteObject |
| 列目录 | readdir,按目录 |
ListObjectsV2,按 key 前缀分页扫描 |
| 锁 | flock/fcntl |
无文件锁;只有条件请求 |
| 一致性 | 本地强一致 | 强读写一致(详见第二节) |
| 访问 | 系统调用 | HTTP REST API |
最容易踩坑的是「目录」。S3 里
s3://bucket/db/table/date=2026-06-30/part-0001.parquet
看起来有层级,但 db/table/date=2026-06-30/
不是一个对象,也不是一个 inode,它只是这个
key
的前缀。所谓「列出目录内容」,本质是「扫描所有以某前缀开头的
key」。这条差异会一路传导到:planning 要枚举文件、提交要避免
rename、并发要靠条件写——正是表格式存在的根因(第 7
章展开)。
flowchart LR
subgraph POSIX["POSIX 文件系统"]
D1["/db/table/"] --> D2["date=2026-06-30/"]
D2 --> F1["part-0001.parquet (inode)"]
D2 --> F2["part-0002.parquet (inode)"]
end
subgraph OBJ["对象存储 (扁平 key 空间)"]
K1["db/table/date=2026-06-30/part-0001.parquet"]
K2["db/table/date=2026-06-30/part-0002.parquet"]
K3["db/table/date=2026-06-29/part-0007.parquet"]
end
图里右侧三个 key 之间没有真正的父子关系,「同一个分区目录」只是它们恰好共享了一段前缀。
二、一致性模型:S3 现在是强一致
很多老资料还在说「S3 最终一致、list 可能读不到刚写的对象」。这个结论 在 2020 年 12 月后已经过时。
AWS 在 2020 年底宣布并在用户指南中明确:Amazon S3 对所有 GET、PUT、LIST 以及对象元数据操作,提供强读写一致性(strong read-after-write consistency),且对所有 region、所有应用自动生效,不额外收费。(来源:AWS S3 用户指南,Amazon S3 data consistency model。)
具体到行为:
- 写后读(read-after-write):
PUT成功返回后,后续GET一定能读到这次写入的内容(包括首次创建的对象)。 - 覆盖后读:
PUT覆盖一个已存在 key 后,后续GET读到的是新版本(前提是PUT已返回成功)。 - 删除后读:
DELETE成功后,GET返回 404、LIST不再列出该 key。 - 列举一致:
PUT一个新对象后,LIST能立刻列出它;DELETE后立刻从LIST消失。
这一条对表格式极重要:Iceberg / Delta / Hudi 提交后,下一个 reader 通过元数据指针读到的文件集合,不会因为「list 还没收敛」而漏掉刚写的数据文件。强一致让「写完元数据立刻可读」成立,是原子提交能落地的前提之一。
强一致没有覆盖的部分
强一致解决的是「单个 key 的读写顺序可见性」,不是分布式事务,也不是并发互斥。下面这些仍然要靠别的机制:
- 并发写同一个 key:两个客户端同时
PUT同一个 key,S3 不做仲裁,结果是「last writer wins」(最后一次成功写入获胜),没有「写写冲突」报错。要做 compare-and-swap 必须用条件写(第五节)。 - 跨对象原子性:写多个对象不是一个事务。写到第三个对象时进程崩溃,前两个已经可见——这就是「部分提交(partial commit)」问题(第 7 章)。
- 强一致 ≠ 强隔离:reader 在你写一半时来扫前缀,会看到「写了一半」的文件集合。S3 不提供快照视图;快照隔离要由上层(表格式)构造。
兼容实现差异:MinIO 文档同样声明强一致;但部分自建网关 / 老版本 Ceph RGW 的一致性与条件写支持要按其文档核对,不能假定与 AWS S3 完全一致。版本相关结论必须落到具体实现与版本。
三、LIST 的代价:O(前缀下对象数)
ListObjectsV2
是对象存储里最容易被低估的成本来源。它的语义和代价模型决定了「靠
list 做 query planning」为什么在大表上崩溃。
3.1 分页与单页上限
ListObjectsV2 的关键事实(来源:AWS S3 API
参考,ListObjectsV2):
- 单次请求最多返回 1000 个
key(
MaxKeys默认且上限为 1000)。 - 结果按 key 的 字典序(UTF-8 binary) 返回。
- 超过 1000 个要靠
ContinuationToken翻页,翻 \(p\) 页就是 \(p\) 次往返请求。 prefix把扫描限制在某前缀内;delimiter(通常用/)让公共前缀折叠成CommonPrefixes,模拟「列目录」效果,但底层仍是按 key 顺序扫描。
于是列出一个前缀下全部 \(N\) 个对象,请求次数是:
\[ R(N) = \left\lceil \frac{N}{1000} \right\rceil \]
每次请求是一个 HTTP 往返,带网络 RTT 和服务端扫描成本。把单次往返延迟记为 \(t_{\text{rtt}}\),总耗时近似:
\[ T_{\text{list}}(N) \approx \left\lceil \frac{N}{1000} \right\rceil \cdot t_{\text{rtt}} \]
这是 O(N) 的:对象越多越慢,而且每页都是一次串行网络往返(除非并行多前缀)。一张有几十万个小文件的表,光「列出有哪些文件」就要几百次往返。这正是「小文件问题」在 planning 阶段的直接代价(第 17 章展开 compaction)。
3.2 为什么 delimiter 救不了大表
有人以为用 delimiter=/
按分区目录列就便宜了。它只是把「列出所有
key」换成「逐层列出前缀」:要拿到一个分区里的文件清单,最终还是要把那个前缀下的
key
全扫一遍。深层分区(year/month/day/hour)反而带来更多层
list 往返。分区裁剪如果靠 list
目录来做,省的是读数据,省不掉枚举元数据的往返。
表格式的做法是把文件清单和统计信息预先存进元数据文件,planning
时读固定几个元数据对象,而不是 list 数据前缀(第 8
章)。
3.3 本机实测:枚举耗时随对象数线性增长
S3 的 LIST 是网络分页 API,本写作环境无法访问公网或自建
MinIO(见第八节说明),因此 不给 S3
延迟数字。这里只用本地文件系统的目录枚举做一个同阶规律的旁证:枚举
\(N\) 个条目的耗时随 \(N\) 线性增长。注意这与 S3
的绝对延迟不可比——本地 scandir
是一次系统调用遍历,S3 是每 1000 个 key
一次网络往返,S3
的每对象成本要高几个数量级;这里只验证「O(N)」这条规律。
环境:Intel Core i9-12900K,31 GiB RAM,WSL2(Linux
6.6.87.2-microsoft-standard-WSL2,x86_64),Python
3.14.5,目标目录在本地 ext4 盘(非 tmpfs)。每个规模用
os.scandir 完整遍历,取 7
次中位数。空对象,隔离元数据枚举成本。
import os, time, statistics, tempfile
def make(d, n):
os.makedirs(d, exist_ok=True)
for i in range(n):
open(os.path.join(d, f"part-{i:06d}.parquet"), "wb").close()
def t_scandir(d, runs=7):
ts = []
for _ in range(runs):
t0 = time.perf_counter()
cnt = sum(1 for _ in os.scandir(d))
ts.append(time.perf_counter() - t0)
return statistics.median(ts), cnt
base = tempfile.mkdtemp()
for n in (1000, 4000, 16000, 64000):
d = f"{base}/list_{n}"; make(d, n)
med, cnt = t_scandir(d)
print(f"{cnt:>6} {med*1e3:>9.3f} ms {med/cnt*1e6:>7.3f} us/obj")实测结果(中位数):
| 对象数 \(N\) | 枚举中位耗时 | 单对象均摊 |
|---|---|---|
| 1000 | 0.375 ms | 0.375 µs |
| 4000 | 0.887 ms | 0.222 µs |
| 16000 | 3.647 ms | 0.228 µs |
| 64000 | 14.542 ms | 0.227 µs |
单对象均摊稳定在约 0.22 µs,总耗时随 \(N\) 近似线性(64000 比 16000 约慢 4 倍)。这是本地内核遍历的下界。 换成 S3:每 1000 个 key 一次 HTTP 往返,按典型同 region 个位数到十几毫秒 RTT 估,64000 个对象就是 64 次串行往返、几百毫秒到秒级——比本地枚举高约三到四个数量级。结论不靠绝对值,靠斜率:list 成本随对象数线性上升,而对象存储把每一步放大成网络往返。
四、没有原子 rename:commit-by-rename 在 S3 上崩了
POSIX
上有一个被大量数据系统当作「廉价原子提交」的原语:rename(2)。把数据写进临时目录
_temporary/,写完一次性 rename
到最终目录——目录改名在同一文件系统内是原子、O(1)
的,旧路径瞬间消失、新路径瞬间出现。Hadoop 的
FileOutputCommitter
正是基于这个假设(来源:Hadoop 文档,Committing work to
S3 with the S3A Committers,其中明确指出经典 committer
依赖目录 rename 的原子性与高效性)。
对象存储 没有 rename。要把
a/x.parquet 变成
b/x.parquet,只能:
CopyObject(服务端把源对象内容复制成新 key,按字节复制),DeleteObject删掉旧 key。
「重命名一个目录」于是退化成:对前缀下每个对象各做一次 copy + 一次 delete。代价从 POSIX 的 O(1) 变成 O(对象数),且 copy 要搬运全部字节(服务端复制不走客户端带宽,但仍是按字节的 IO 与时间)。更糟的是这个过程 不是原子的:copy 到一半失败,目标前缀里只有部分对象;reader 此刻来扫,看到的是半个表。
flowchart TB
subgraph FS["POSIX: commit = rename(dir)"]
T1["_temporary/ 写完"] -->|"rename O(1) 原子"| FIN1["final/ 全部可见"]
end
subgraph S3["对象存储: 没有 rename"]
T2["staging 前缀写完"] -->|"逐对象 CopyObject"| MID["复制中途可被 reader 看见 (非原子)"]
MID -->|"逐对象 DeleteObject"| FIN2["final 前缀"]
end
4.1 本机实测:copy 方式重命名 vs 元数据 rename
为了量化「按 copy 重命名一批文件」相对「元数据级目录
rename」的代价差,在同一台机器、同一块 ext4
盘上做对照实验。这里用本地文件系统:POSIX 的
os.rename(dir) 代表「文件系统才有的原子 O(1)
重命名」,而「逐文件 copy +
delete」代表「对象存储不得不做的搬字节式重命名」。这能直接说明:一旦底座没有目录
rename,把「重命名」当提交就会从常数成本变成与文件数、字节数成正比的成本。
环境同上(i9-12900K / WSL2 / ext4 / Python 3.14.5)。一个「表前缀」含 16000 个对象,每个 16 KiB,合计 250 MiB。POSIX 目录 rename 取 5 次中位数;copy+delete 全部对象取 3 次中位数。
import os, shutil, time, statistics, tempfile
base = tempfile.mkdtemp(); N, SIZE = 16000, 16*1024
src = f"{base}/tbl_src"; os.makedirs(src)
blob = b"x"*SIZE
for i in range(N):
open(f"{src}/part-{i:06d}.parquet","wb").write(blob)
# POSIX 目录 rename (原子 O(1))
ren=[]; cur=src
for i in range(5):
dst=f"{base}/ren_{i}"; t0=time.perf_counter(); os.rename(cur,dst)
ren.append(time.perf_counter()-t0); cur=dst
# 逐对象 copy+delete (对象存储模型)
cpd=[]
for i in range(3):
dst=f"{base}/copy_{i}"; os.makedirs(dst); t0=time.perf_counter()
for e in os.scandir(cur): shutil.copyfile(e.path, f"{dst}/{e.name}")
for e in list(os.scandir(cur)): os.remove(e.path)
cpd.append(time.perf_counter()-t0)
for e in os.scandir(dst): os.rename(e.path, f"{cur}/{e.name}")
print("rename us:", statistics.median(ren)*1e6)
print("copy+delete ms:", statistics.median(cpd)*1e3)实测结果(中位数):
| 操作 | 含义 | 耗时 |
|---|---|---|
os.rename(dir) |
文件系统原子目录改名(16000 文件一次完成) | 7.7 µs |
| copy + delete 全部对象 | 对象存储式「重命名」(搬 250 MiB / 16000 次复制 + 删除) | 915.5 ms |
差距约 十万倍量级(本次约
1.18×10⁵)。而且这还是本地 ext4、字节走内存/SSD
的乐观情况:换成 S3,每个对象的 CopyObject +
DeleteObject 都是网络往返,16000
个对象就是上万次 API
调用。结论很硬:在对象存储上,把「rename
当提交」既慢又非原子,必须换一种提交原语。
表格式给出的答案是「只改一个元数据指针」(第 7
章),把提交从「搬 N 个文件」压成「换 1 个指针」。
五、条件写:把 PUT 变成 compare-and-swap
既然不能靠 rename,原子提交的支点要换成别的东西。S3 在 2024 年补上了关键一块:条件写(conditional writes)。
S3 的条件请求基于 HTTP 的前置条件头(来源:AWS S3 用户指南,Conditional requests / Adding and using conditional writes):
If-None-Match: *:仅当目标 key 当前不存在 时才允许写入。已存在则返回412 Precondition Failed。语义等价于「创建即占位(put-if-absent)」。If-Match: "<etag>":仅当目标 key 当前的 ETag 等于给定值 时才允许写入(或删除)。ETag 不匹配返回412。语义等价于「compare-and-swap(按版本比较再写)」。
这两个头把「写一个对象」从「无脑覆盖」升级成「带前置条件的原子写」。在服务端它是原子判定:要么前置条件成立、写入生效,要么返回
412、什么都不改。两个并发客户端用
If-None-Match: * 写同一个
key,只有一个会成功,另一个收到
412——这正是分布式提交需要的「单赢家」语义。
5.1 为什么这对提交协议是关键
回顾第二节:S3 并发写同一 key 是「last writer wins」,没有冲突报错,无法直接做互斥。有了条件写就不同了:
- put-if-absent(
If-None-Match: *)让「创建下一个日志条目N.json」成为互斥操作。两个 writer 都想成为版本 \(N\),只有一个能创建成功,另一个 412 后必须读到新状态、基于 \(N\) 重试到 \(N{+}1\)。这正是 Delta 事务日志在 S3 上做原子提交所需要的原语(第 12 章)。 - compare-and-swap(
If-Match)让「把元数据指针从 \(v_k\) 换成 \(v_{k+1}\)」成为带版本校验的原子 swap:只有当前指针仍是我读到的那个版本时才换,否则失败重试。这正是乐观并发提交的核心(第 11 章)。
历史背景值得知道:在 S3 支持条件写之前,Delta Lake 在 S3 上做并发提交需要外部协调器(如 DynamoDB 的条件写)来提供 put-if-absent,因为裸 S3 给不了互斥(来源:Delta Lake 文档关于 S3 multi-cluster writes 的说明)。Iceberg 则把原子性交给 catalog(数据库行锁 / REST 后端的事务)。S3 原生条件写出现后,「直接在对象存储上做原子提交」才第一次不依赖外部组件(具体到各表格式 / catalog 的采用程度按其版本文档为准)。
sequenceDiagram
participant W1 as Writer 1
participant W2 as Writer 2
participant S3 as 对象存储
W1->>S3: PUT N.json (If-None-Match:*)
W2->>S3: PUT N.json (If-None-Match:*)
S3-->>W1: 200 OK (创建成功)
S3-->>W2: 412 Precondition Failed
Note over W2: 读到 N 已被占用<br/>基于新状态重试到 N+1
W2->>S3: PUT (N+1).json (If-None-Match:*)
S3-->>W2: 200 OK
兼容实现差异:MinIO 等也支持 If-None-Match
条件写,但 If-Match
写、以及与对象版本(versioning)的交互细节按各自文档核对。不能把「AWS
S3 支持」直接当成所有 S3 兼容存储都支持。
六、multipart upload 与对象不可改写
对象存储的「写」还有两条会影响文件布局与提交的硬约束:单次 PUT 有大小上限、对象一旦写成不可局部修改。
6.1 multipart upload
单次 PUT 的对象大小上限是 5
GiB(来源:AWS S3 用户指南,Uploading
objects)。要写更大的对象,或者想并行上传、断点续传,就用
分段上传(multipart upload):
CreateMultipartUpload拿到一个UploadId。- 多次
UploadPart,每段一个 part number(1–10000);除最后一段外,每段 至少 5 MiB。 CompleteMultipartUpload提交全部 part 列表,服务端把它们拼成一个对象。
关键约束(来源:AWS S3 用户指南,Multipart upload limits):
| 项 | 限制 |
|---|---|
| 单次 PUT 最大对象 | 5 GiB |
| multipart 最大对象 | 5 TiB |
| 每段大小 | 5 MiB – 5 GiB(最后一段可小于 5 MiB) |
| 段数上限 | 10000 |
对提交协议有两点直接影响:
CompleteMultipartUpload之前,对象不可见。各个 part 已经传上去,但在 complete 之前GET/LIST都看不到目标对象。这给了「先把数据文件传完、最后一步才让它出现」的能力——但它只保证 单个对象 的出现是一步到位,不保证多个对象一起出现。多文件原子提交仍要靠上层元数据指针。- 没 complete 的 multipart
会留下垃圾。中途失败的 upload
会占着存储不被自动清掉,要靠
AbortMultipartUpload或生命周期规则清理。这是 lakehouse 里「孤儿文件」的来源之一(第 20 章运维)。
6.2 对象不可改写(immutability)
对象存储里
对象一旦写成就不可局部修改:没有
pwrite 改第 1000 字节,没有 append
往末尾续写(通用对象语义;个别兼容存储有非标准 append
扩展,不可移植,不在此依赖)。要「改」一个对象,只能用新内容整体
PUT 覆盖整个
key——本质是写一个全新对象替换旧的。
这条约束塑造了整个 lakehouse 的设计:
- 数据文件不可变。Parquet 写完就不再改,行级 update/delete 不能原地改文件,只能写新数据文件或 delete 文件,再靠元数据合并(copy-on-write / merge-on-read,第 10 章)。
- 元数据也用「写新文件 +
换指针」,而不是改旧文件。Iceberg
的每次提交产生新的
metadata.json与新 manifest,旧的不动(第 8 章);Delta 追加新的N.json(第 12 章)。 - 时间旅行几乎是免费副产品。既然旧文件不被改写、不被原地删除,按旧指针就能读到历史版本(第 16 章)。
一句话串起来:不可改写 + 不可原子 rename + 强一致 + 条件写,这四条共同决定了「表格式只能是『一堆不可变文件 + 一个可原子切换的元数据指针』」。下一章正式回答:为什么需要这层表格式,它到底补上了目录式分区表缺的哪几件事。
七、与 POSIX 的关键差异速查
把前面散落的差异收成一张可查的表,作为读后续章节时的对照基准。
| 能力 | POSIX / HDFS | 对象存储 | 对 lakehouse 的影响 |
|---|---|---|---|
| 目录 rename | 原子 O(1) | 无(copy+delete,O(N) 字节) | 不能用 rename 当提交 → 元数据指针 swap |
| 部分写 / append | 支持 | 不支持 | 数据文件不可变 → CoW/MoR 做更新 |
| 列目录 | readdir 按目录 |
ListObjectsV2 按前缀分页(≤1000/页) |
planning 不靠 list → 元数据携带文件清单 + 统计 |
| 并发互斥 | 文件锁 | 无锁,仅条件请求 | 原子提交靠
If-None-Match/If-Match 或
catalog |
| 单写最大 | 受 FS 限制 | 单 PUT 5 GiB / multipart 5 TiB | 大文件走 multipart;complete 才可见 |
| 一致性 | 本地强一致 | 强读写一致(2020 起) | 提交后立即可读成立;但无快照隔离 |
| 跨对象事务 | 无(需上层) | 无 | partial commit 必须由表格式消除 |
读这张表的方式:右两列每一条「缺失或昂贵」,都对应表格式的一个设计决定。 把它当成第 7 章的问题清单。
八、实验环境与可复现步骤
本节交代实验边界,并给出在真实 S3 / MinIO 上复现的完整步骤,避免任何「看起来像实测」的伪造数字。
8.1 本写作环境的限制
- 已安装:Python 3.14.5、Docker(守护进程可用)、pandoc 3.6。
- 未安装 /
不可用:
boto3、awsCLI、mc(MinIO 客户端)、pip(No module named pip)。 - 网络受限:Docker
无法访问镜像仓库(
registry-1.docker.io拉取超时),因此无法在本机起 MinIO 容器。
所以第三、四节里给出的是 本地文件系统 的真实测量(标注了 CPU / 内存 / OS / 内核 / Python 版本 / 文件系统 / 对象规模 / 采样次数),用来验证「枚举与 copy-rename 随对象数线性增长」这条规律;不是 S3 的延迟数字,也没有冒充 S3。S3 的绝对延迟由网络 RTT 与服务端实现决定,必须在真实 S3 / MinIO 上测。
8.2 在 MinIO 上复现 LIST 与 rename 代价
有网络环境时,可如下复现。MinIO 提供 S3 兼容 API,单机即可。
# 1. 起 MinIO(需可拉取镜像)
docker run -d --name minio -p 9000:9000 \
-e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio:latest server /data
# 2. 配置 mc 客户端并建桶
mc alias set local http://127.0.0.1:9000 minioadmin minioadmin
mc mb local/lake用 Python + boto3 测「LIST 随对象数变化」与「copy 式 rename 代价」,全部用真实 API:
import boto3, time, statistics
s3 = boto3.client("s3", endpoint_url="http://127.0.0.1:9000",
aws_access_key_id="minioadmin",
aws_secret_access_key="minioadmin")
BKT = "lake"
def put_many(prefix, n, size=0):
body = b"x" * size
for i in range(n):
s3.put_object(Bucket=BKT, Key=f"{prefix}/part-{i:06d}.parquet", Body=body)
def time_list(prefix, runs=5):
ts = []
for _ in range(runs):
t0 = time.perf_counter(); n = 0
tok = None
while True:
kw = dict(Bucket=BKT, Prefix=prefix, MaxKeys=1000)
if tok: kw["ContinuationToken"] = tok
r = s3.list_objects_v2(**kw)
n += r.get("KeyCount", 0)
if not r.get("IsTruncated"): break
tok = r["NextContinuationToken"]
ts.append(time.perf_counter() - t0)
return statistics.median(ts), n
for n in (1000, 5000, 20000):
put_many(f"list/{n}", n)
med, cnt = time_list(f"list/{n}")
print(f"{cnt:>6} objs list median {med*1e3:.1f} ms "
f"reqs≈{-(-cnt//1000)}")模拟「rename 一个前缀」= 对每个对象
copy_object +
delete_object,并与「只写一个新指针对象」对照:
def rename_prefix(src, dst):
tok = None; moved = 0
while True:
kw = dict(Bucket=BKT, Prefix=src, MaxKeys=1000)
if tok: kw["ContinuationToken"] = tok
r = s3.list_objects_v2(**kw)
for o in r.get("Contents", []):
key = o["Key"]; newkey = dst + key[len(src):]
s3.copy_object(Bucket=BKT, Key=newkey,
CopySource={"Bucket": BKT, "Key": key})
s3.delete_object(Bucket=BKT, Key=key)
moved += 1
if not r.get("IsTruncated"): break
tok = r["NextContinuationToken"]
return moved
t0 = time.perf_counter(); n = rename_prefix("list/20000", "moved/20000")
print(f"copy-rename {n} objs: {time.perf_counter()-t0:.1f}s")预期会观察到:list 耗时随对象数线性增长且每 1000 个一次往返;copy-rename 20000 个对象要 4 万次 API 调用,耗时远高于单个对象的写入。具体数字取决于网络与 MinIO 部署,须实测后才能写入结论,本文不预填。
8.3 验证条件写语义
确认底座是否支持原子 put-if-absent(决定能否直接在其上做提交协议):
import botocore
key = "commit/000.json"
s3.put_object(Bucket=BKT, Key=key, Body=b"v0", IfNoneMatch="*") # 首次成功
try:
s3.put_object(Bucket=BKT, Key=key, Body=b"v1", IfNoneMatch="*")
except botocore.exceptions.ClientError as e:
print("second write rejected:", e.response["Error"]["Code"]) # 期望 412/PreconditionFailed两个进程并发跑第二个
put_object,应当只有一个成功,其余被拒——这就是提交协议要的互斥支点。AWS
S3 与较新版 MinIO 支持 IfNoneMatch;具体
IfMatch 写支持按所用存储与版本核对。
九、常见问题
9.1 既然 S3 强一致了,为什么还说 list 是问题?
强一致和 list 代价是两件事。强一致保证「写完的对象 list 一定能列到」(正确性);list 代价说的是「列出 N 个对象要 ⌈N/1000⌉ 次网络往返」(性能)。强一致不会让 list 变快。表格式回避的是 list 的性能问题——planning 读固定几个元数据对象,而不是 list 数据前缀(第 7 章)。
9.2 用
delimiter=/ 把 S3 当目录树用,行不行?
能模拟「列目录」的观感,但底层仍是按 key 前缀扫描,深层分区反而增加逐层 list 的往返。它救不了大表 planning,也给不了原子 rename、快照、文件级统计。把 S3 当文件系统用,迟早撞上第 6 章这几条约束。
9.3
multipart 的 CompleteMultipartUpload
算不算原子提交?
它只保证 单个对象 的出现是一步到位(complete 之前对象不可见,complete 之后可见)。它不保证多个对象一起出现。一次表提交往往涉及多个数据文件 + 元数据文件,多文件的原子可见性要靠表格式的指针切换,不是 multipart(第 7 章)。
9.4 对象不可改写,那 update/delete 怎么办?
不能原地改文件。要么重写包含该行的整个数据文件(copy-on-write),要么写一个独立的 delete 文件、读时再合并(merge-on-read)。两条路线的写放大/读放大取舍是第 10 章的主题。本章只需记住根因:对象不可局部修改,所以「改数据」永远是「写新文件 + 调整元数据」。
9.5 条件写在所有 S3 兼容存储上都有吗?
不能假定。AWS S3 与较新版 MinIO 支持
If-None-Match(put-if-absent);If-Match(CAS)的支持面更窄。能否「直接在对象存储上做原子提交」取决于此(否则要回退到
catalog 的事务/锁,第 11、15
章)。结论必须落到具体实现与版本。
十、小结
对象存储不是网络版 POSIX,它给的是「扁平 key→不可变 object 映射 + 强读写一致 + 条件写」,缺的是「目录、原子 rename、部分写、文件锁、跨对象事务」。四条要记住:
- 强一致(2020 起)让「写完立即可读」成立,但不提供并发互斥与快照隔离。
- LIST 是 O(N),每 1000 个 key 一次网络往返;靠 list 做 planning 在大表上不可行。
- 没有原子 rename,重命名退化成 copy+delete,O(N) 字节且非原子——本机实测 copy-rename 比元数据 rename 慢约十万倍量级。
- 对象不可改写 +
条件写:数据文件只能新写不能改,而
If-None-Match/If-Match提供了 put-if-absent 与 compare-and-swap,成为原子提交的支点。
这些约束不是缺陷清单,而是一组硬边界。把「一堆不可变文件 + 一个可原子切换的指针」当成结论倒推,下一章解释:目录式分区表在这种底座上缺了什么,表格式用什么补上。
返回 系列目录
上一篇:列式编码与压缩
下一篇:表格式为什么存在
附录、对象存储工程注记
这一节补一组会在后续章节反复用到的 S3 细节,按「与表格式提交 / planning 直接相关」筛选,每条锚定 AWS S3 文档;不展开运维全集。
ETag 与 multipart 的差异
ETag 是对象的实体标签,条件写
If-Match 用的就是它(来源:AWS S3
用户指南,Common Response Headers /
对象完整性相关章节)。要点:
- 单段 PUT 的对象,
ETag通常是对象内容的 MD5 十六进制串。 - multipart 上传
的对象,
ETag不是整体 MD5,而是「各 part 的 MD5 拼接后再取 MD5,外加-段数后缀」(形如"<hex>-12")。
后果:不能假定 ETag 等于「整对象
MD5」,跨实现校验完整性要用 S3
的附加校验和(CRC32/CRC32C/SHA 等,可选开启)而不是
ETag。对表格式而言,ETag
的价值在于它是 If-Match CAS
的版本标识,而不是数据校验。
对象版本控制(versioning)
桶可开启 versioning:同一 key
的多次写入保留为多个版本,DELETE
写一个「删除标记(delete marker)」而非真正抹除(来源:AWS
S3 用户指南,Using versioning in S3
buckets)。这给了存储层自己的「历史」。但
表格式不依赖桶版本控制做时间旅行——它用自己的不可变元数据与
snapshot 链管理版本(第 16
章),因为桶版本控制是「per-key」的,给不了「整张表某个时间点」的一致视图。
每前缀请求速率与 key 设计
S3 的吞吐按 前缀 横向扩展:官方给出每个分区前缀至少支持 3500 次 PUT/COPY/POST/DELETE 与 5500 次 GET/HEAD 每秒,通过把 key 分散到多个前缀可线性提升总吞吐(来源:AWS S3 用户指南,Best practices design patterns: optimizing Amazon S3 performance)。这条对 lakehouse 的含义:
- 海量小文件写入若集中在同一前缀,可能撞到单前缀速率上限;表格式 / 引擎会把数据文件名带随机/哈希前缀来分散(也与第 9 章 bucket 分区相关)。
- 「热前缀」是流式入湖(第 19 章)和 compaction(第 17 章)要考虑的工程约束。
强一致的边界:对象数据 vs 桶配置
第二节的强一致针对 对象数据与对象级元数据。桶级配置(bucket policy、ACL、生命周期、CORS 等)的传播是最终一致的(来源:AWS S3 文档对 bucket 配置一致性的说明)。表格式提交只涉及对象读写,落在强一致那一侧;但运维改桶策略后立即依赖它生效要留意这条边界。
批量删除与孤儿清理
DeleteObjects(批量删除)单次最多
1000 个 key(来源:AWS S3 API
参考,DeleteObjects)。这是 compaction
后清理被替换数据文件、以及清理过期 snapshot 引用文件(第
16、17 章)的常用接口。未 complete 的 multipart
上传不会自动消失,应配 生命周期规则 自动
AbortIncompleteMultipartUpload,否则累积成不可见的存储占用(孤儿,第
20 章)。
HEAD 取元数据
只要对象的大小 / ETag / 元数据而不要内容时用
HeadObject(HTTP HEAD),不传输对象体。planning
阶段若退化到要逐个探测对象(目录式表的劣势,第 7 章),HEAD
也是一次往返——这再次说明「把统计预存进表元数据」比「逐对象
HEAD/GET」省往返。
错误模型:412 与重试
条件写失败返回
412 Precondition Failed,这是提交协议要专门处理的「正常冲突」,不是异常错误:收到
412 应当读最新状态、重建提交、再试(乐观并发重试,第 11
章),而不是简单重发原请求。其他需区分的:404(key
不存在)、409(部分操作的冲突)、503 SlowDown(限速,应指数退避)。把
412 当致命错误是新手提交协议实现的常见 bug。
兼容存储的差异面
「S3 兼容」只保证 API 形状接近,语义细节按实现核对:
| 关注点 | 需逐实现确认 |
|---|---|
| 一致性 | 是否强读写一致(多数现代实现是,老网关存疑) |
If-None-Match 写 |
是否支持 put-if-absent |
If-Match 写 |
是否支持 CAS(支持面更窄) |
| multipart 上限 | 段大小 / 段数 / 最大对象 |
| 附加校验和 | 支持哪些算法 |
表格式 / catalog 在不同对象存储上的「能否直接做原子提交」取决于上表,尤其条件写支持度(第 11、15 章)。
术语表
| 术语 | 含义 |
|---|---|
| 对象(object) | 一段不可改写的字节 + 元数据,由一个 key 唯一标识 |
| key / 前缀(prefix) | 对象的字符串名;前缀是若干 key 共享的开头,「目录」只是前缀的观感 |
| 强读写一致 | PUT/DELETE 成功后,后续 GET/LIST 立即反映该变更(S3 自 2020 起) |
ListObjectsV2 |
列举前缀下的 key,单页 ≤1000、按字典序、靠 continuation token 翻页 |
CopyObject |
服务端按字节复制对象到新 key;对象存储「重命名」靠它 +
DeleteObject |
| 条件写 | 带前置条件的写:If-None-Match: *(put-if-absent)、If-Match(CAS) |
| 412 Precondition Failed | 条件写前置条件不成立时的返回;提交协议应视为正常冲突并重试 |
| multipart upload | 分段上传大对象;CompleteMultipartUpload
前对象不可见 |
| ETag | 对象实体标签;单段为内容 MD5,multipart
为特殊拼接值;If-Match 用它 |
| last writer wins | 无条件并发写同一 key 时,最后成功的写入获胜,无冲突报错 |
一次对象写入的可见性时机
把本章关键时机收成一条时间线,作为第 7 章「提交时机」的对照基准。
flowchart LR
S1["CreateMultipartUpload"] --> S2["UploadPart * N (对象仍不可见)"]
S2 --> S3["CompleteMultipartUpload"]
S3 --> V["对象可见 (强一致: 立即可 GET/LIST)"]
V --> C["可选: If-Match CAS / If-None-Match 占位"]
单个对象的可见性在 complete 这一步一步到位;但「一张表的多个文件一起可见」不在这条线上——那要靠表格式的指针切换(第 7 章)。
参考资料
- AWS, Amazon S3 User Guide — Amazon S3 data consistency model(强读写一致模型)。
- AWS, Amazon S3 API Reference — ListObjectsV2(单页 ≤1000 key、分页与字典序)。
- AWS, Amazon S3 User Guide — Uploading and copying objects using multipart upload(5 GiB 单 PUT、5 TiB multipart、段大小与段数上限)。
- AWS, Amazon S3 User Guide — Conditional requests /
Adding and using conditional
writes(
If-None-Match、If-Match语义与 412)。 - Apache Hadoop, Committing work to S3 with the S3A Committers(经典 FileOutputCommitter 依赖目录 rename 原子性,对象存储不满足)。
- Delta Lake Documentation, Set up storage / S3 multi-cluster writes(S3 上并发提交需 put-if-absent 支点的背景)。
- MinIO Documentation(S3 兼容性、一致性与条件写支持,按部署版本核对)。
- 本机实验,2026-06-30:Intel Core i9-12900K / 31 GiB / WSL2(Linux 6.6.87.2-microsoft-standard-WSL2)/ Python 3.14.5 / 本地 ext4;枚举与 copy-rename 微基准,规模与采样次数见正文。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【数据湖与开放表格式】Lakehouse 全景:从 Hive 表到开放表格式
Hive 目录式分区表把『表』等同于『一组目录加 metastore 里的分区行』,于是没有原子提交、planning 要 LIST 目录、schema 与分区演进常要重写。本文用这三个硬伤切入,讲清 lakehouse 把表拆成『不可变数据文件 + 可变元数据指针 + catalog』三层后各自解决了什么,并给出全系列的分层地图。
【数据湖与开放表格式】Parquet · Iceberg · Delta · Hudi 内核拆解
拆解 lakehouse 的两层基础:列式文件格式(Parquet/ORC/Arrow)与开放表格式(Iceberg/Delta/Hudi)。讲清没有数据库进程时,如何在对象存储上做 ACID、行级更新、快照与并发,以及 catalog、查询引擎、流式入湖如何拼成可运维的湖仓。面向数据平台工程师与从 OLAP/数仓转型的开发者。
【数据湖与开放表格式】表格式为什么存在
目录式分区表(Hive 表)在对象存储上有三处硬伤:并发写部分提交、list planning 太贵、缺快照隔离与原子提交。本文拆开放表格式补上的四件事——原子提交、快照隔离、文件级统计裁剪、schema 与分区演进,并抽象出三家共有的『元数据指针 + 不可变数据文件』骨架。
【数据湖与开放表格式】Parquet 文件格式深拆
拆 Parquet 的物理结构:file → row group → column chunk → page,footer 里的 FileMetaData(Thrift)与 PAR1 magic。讲清 PLAIN/RLE-bitpacking/字典/DELTA_BINARY_PACKED/BYTE_STREAM_SPLIT 各自压谁,Dremel 的 repetition/definition level 如何表达嵌套,column index/offset index 与 split-block bloom filter 怎样让谓词在读盘前裁掉 page。基于本机 pyarrow 24.0.0 真实 dump footer 与编码。