土法炼钢兴趣小组的算法知识备份

【数据湖与开放表格式】对象存储语义与代价

文章导航

分类入口
databasestorage
标签入口
#s3#object-storage#consistency#multipart#list#minio#lakehouse

目录

前五篇把数据落到一个个 Parquet 文件里(见 列式编码与压缩)。但这些文件躺在哪?在 lakehouse 里,几乎都是 S3 这类对象存储,而不是本地磁盘或 HDFS。问题是:对象存储的语义和文件系统差得很远,很多在 POSIX 上理所当然的操作,在 S3 上要么很贵,要么根本不存在。

本文不教 aws s3 cp 怎么用,而是把表格式(第 7 章起)赖以工作的存储底座讲清楚,回答四个会直接决定提交协议设计的问题:

文末有一组本机实测:在本地文件系统上量化「按 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。)

具体到行为:

这一条对表格式极重要:Iceberg / Delta / Hudi 提交后,下一个 reader 通过元数据指针读到的文件集合,不会因为「list 还没收敛」而漏掉刚写的数据文件。强一致让「写完元数据立刻可读」成立,是原子提交能落地的前提之一。

强一致没有覆盖的部分

强一致解决的是「单个 key 的读写顺序可见性」,不是分布式事务,也不是并发互斥。下面这些仍然要靠别的机制:

兼容实现差异:MinIO 文档同样声明强一致;但部分自建网关 / 老版本 Ceph RGW 的一致性与条件写支持要按其文档核对,不能假定与 AWS S3 完全一致。版本相关结论必须落到具体实现与版本。


三、LIST 的代价:O(前缀下对象数)

ListObjectsV2 是对象存储里最容易被低估的成本来源。它的语义和代价模型决定了「靠 list 做 query planning」为什么在大表上崩溃。

3.1 分页与单页上限

ListObjectsV2 的关键事实(来源:AWS S3 API 参考,ListObjectsV2):

于是列出一个前缀下全部 \(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,只能:

  1. CopyObject(服务端把源对象内容复制成新 key,按字节复制),
  2. 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):

这两个头把「写一个对象」从「无脑覆盖」升级成「带前置条件的原子写」。在服务端它是原子判定:要么前置条件成立、写入生效,要么返回 412、什么都不改。两个并发客户端用 If-None-Match: * 写同一个 key,只有一个会成功,另一个收到 412——这正是分布式提交需要的「单赢家」语义。

5.1 为什么这对提交协议是关键

回顾第二节:S3 并发写同一 key 是「last writer wins」,没有冲突报错,无法直接做互斥。有了条件写就不同了:

历史背景值得知道:在 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)

  1. CreateMultipartUpload 拿到一个 UploadId
  2. 多次 UploadPart,每段一个 part number(1–10000);除最后一段外,每段 至少 5 MiB
  3. CompleteMultipartUpload 提交全部 part 列表,服务端把它们拼成一个对象。

关键约束(来源:AWS S3 用户指南,Multipart upload limits):

限制
单次 PUT 最大对象 5 GiB
multipart 最大对象 5 TiB
每段大小 5 MiB – 5 GiB(最后一段可小于 5 MiB)
段数上限 10000

对提交协议有两点直接影响:

6.2 对象不可改写(immutability)

对象存储里 对象一旦写成就不可局部修改:没有 pwrite 改第 1000 字节,没有 append 往末尾续写(通用对象语义;个别兼容存储有非标准 append 扩展,不可移植,不在此依赖)。要「改」一个对象,只能用新内容整体 PUT 覆盖整个 key——本质是写一个全新对象替换旧的。

这条约束塑造了整个 lakehouse 的设计:

一句话串起来:不可改写 + 不可原子 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 本写作环境的限制

所以第三、四节里给出的是 本地文件系统 的真实测量(标注了 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、部分写、文件锁、跨对象事务」。四条要记住:

  1. 强一致(2020 起)让「写完立即可读」成立,但不提供并发互斥与快照隔离。
  2. LIST 是 O(N),每 1000 个 key 一次网络往返;靠 list 做 planning 在大表上不可行。
  3. 没有原子 rename,重命名退化成 copy+delete,O(N) 字节且非原子——本机实测 copy-rename 比元数据 rename 慢约十万倍量级。
  4. 对象不可改写 + 条件写:数据文件只能新写不能改,而 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 / 对象完整性相关章节)。要点:

后果:不能假定 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 的含义:

强一致的边界:对象数据 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 章)。


参考资料

  1. AWS, Amazon S3 User Guide — Amazon S3 data consistency model(强读写一致模型)。
  2. AWS, Amazon S3 API Reference — ListObjectsV2(单页 ≤1000 key、分页与字典序)。
  3. AWS, Amazon S3 User Guide — Uploading and copying objects using multipart upload(5 GiB 单 PUT、5 TiB multipart、段大小与段数上限)。
  4. AWS, Amazon S3 User Guide — Conditional requests / Adding and using conditional writesIf-None-MatchIf-Match 语义与 412)。
  5. Apache Hadoop, Committing work to S3 with the S3A Committers(经典 FileOutputCommitter 依赖目录 rename 原子性,对象存储不满足)。
  6. Delta Lake Documentation, Set up storage / S3 multi-cluster writes(S3 上并发提交需 put-if-absent 支点的背景)。
  7. MinIO Documentation(S3 兼容性、一致性与条件写支持,按部署版本核对)。
  8. 本机实验,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 微基准,规模与采样次数见正文。

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-30 · database / storage

【数据湖与开放表格式】Lakehouse 全景:从 Hive 表到开放表格式

Hive 目录式分区表把『表』等同于『一组目录加 metastore 里的分区行』,于是没有原子提交、planning 要 LIST 目录、schema 与分区演进常要重写。本文用这三个硬伤切入,讲清 lakehouse 把表拆成『不可变数据文件 + 可变元数据指针 + catalog』三层后各自解决了什么,并给出全系列的分层地图。

2026-06-29 · database / storage

【数据湖与开放表格式】Parquet · Iceberg · Delta · Hudi 内核拆解

拆解 lakehouse 的两层基础:列式文件格式(Parquet/ORC/Arrow)与开放表格式(Iceberg/Delta/Hudi)。讲清没有数据库进程时,如何在对象存储上做 ACID、行级更新、快照与并发,以及 catalog、查询引擎、流式入湖如何拼成可运维的湖仓。面向数据平台工程师与从 OLAP/数仓转型的开发者。

2026-06-30 · database / storage

【数据湖与开放表格式】表格式为什么存在

目录式分区表(Hive 表)在对象存储上有三处硬伤:并发写部分提交、list planning 太贵、缺快照隔离与原子提交。本文拆开放表格式补上的四件事——原子提交、快照隔离、文件级统计裁剪、schema 与分区演进,并抽象出三家共有的『元数据指针 + 不可变数据文件』骨架。

2026-06-30 · database / storage

【数据湖与开放表格式】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 与编码。


By .