题干与适用场景
一张位于对象存储上的 Apache Iceberg 事件表有 20 TiB 活跃数据。流式任务以一分钟微批写入,每天新增约 200 GiB,却产生约 80,000 个 Parquet 数据文件,文件中位数只有 3 MiB。最近查询 p95 从 20 秒升至 95 秒,其中规划阶段从 4 秒升至 32 秒。业务要求继续近实时入湖、保留 7 天时间旅行,并且禁止直接删除对象存储中的表文件。
请说明怎样证明性能退化确实由小文件主导,找出产生小文件的写入和分区原因,阻止问题继续增长,再安全压实存量文件。还要给出并发控制、资源预算、回滚与验收方案。
以上容量、文件数、延迟和吞吐均为面试题设,不是通用基准。题目适合数据工程、分析平台、湖仓基础设施和数据平台 SRE 岗位。核心能力是数据布局与表维护,因此归为 data,而不是只考 Spark 参数记忆或对象存储运维。
面试官考察点
第一,能否先建立证据链。文件多不等于根因成立;要把活跃文件数量、大小分布、分区倾斜、清单读取时间、任务启动与文件打开开销,和查询规划、扫描阶段分别关联。
第二,能否区分治标与治本。压实只能处理存量;微批频率、写任务数量、数据分布和过细分区不改,文件会很快重新碎片化。
第三,能否尊重表格式事务边界。Iceberg 的压实是一次数据文件重写并提交新快照。旧快照仍可能引用旧文件,所以不能绕过目录元数据直接删对象。
第四,能否做有边界的取舍。bin-pack 主要调整文件大小;sort 或 Z-order 还改变数据聚簇,可能提高过滤效率,但会增加 shuffle、排序和临时空间成本。
第五,能否量化运维方案。强回答会估算每日改写字节、目标文件数、作业窗口、并发冲突概率,并用数据正确性和性能指标共同决定是否扩大范围。
回答前需要澄清的问题
- “小”以什么目标判断? 先读取表的
write.target-file-size-bytes,再结合查询选择性、分区日增量和引擎测试定阈值;不能把某个固定大小当成所有表的真理。 - 80,000 是当前快照的活跃文件,还是历史快照中的全部文件? 诊断查询路径应先看
files;容量与保留成本还要看all_files和快照引用。 - 延迟花在规划还是扫描? 若规划占比上升,要查清单和文件任务数量;若扫描吞吐下降,还要查数据倾斜、删除文件、压缩、列统计与下游资源。
- 分区规则和写入分布是什么? 按高基数字段或过细时间粒度分区,会让单个分区永远积不出目标文件;大量并行 writer 也会各自提交半空文件。
- 表采用 copy-on-write 还是 merge-on-read? 后者可能同时积累 position/equality delete 文件,只有合并数据文件还不够。
- 哪些分区仍接收迟到数据? 优先压实已经关闭的冷分区;热分区需要更小的 file group、受控并发与冲突重试。
- 7 天保留指什么? 要确认是快照可查询时间、分支/标签保留,还是对象存储生命周期;三者不能用一次目录删除替代。
30 秒回答框架
“我先查 Iceberg 当前快照的文件数、大小分位数和分区分布,并拆开规划与扫描耗时,确认小文件是主因。题设每天 200 GiB 却有 80,000 个文件;若候选目标为 512 MiB,理想量级约 400 个,需优先检查微批、writer 和分区粒度。
治理时先降低新碎片:扩大批次、按分区键分布并控制 writer;再对一个冷分区做受限并发的 bin-pack,只有过滤收益明确才排序。压实以新快照提交,旧文件按 7 天保留过期,绝不直接删对象。最后用数据对账、文件分位数、规划与查询 p95、积压和冲突率决定是否扩量。”
分步骤深入解答
第一步:用当前快照建立文件画像
先查 Iceberg 元数据,而不是递归列举对象存储目录。目录可能包含历史快照或孤儿对象,不能代表当前查询实际会读取的文件。以下 SQL 是 Spark + Iceberg 目录的示意,目录名和分位数函数需按实际引擎调整:
SELECT
partition,
COUNT(*) AS active_files,
SUM(file_size_in_bytes) AS active_bytes,
percentile_approx(file_size_in_bytes, array(0.5, 0.9, 0.99)) AS size_percentiles
FROM lakehouse.analytics.events.files
GROUP BY partition
ORDER BY active_files DESC;同时记录当前快照 ID、数据文件和删除文件数量、manifest 数量、每个分区的文件分位数,以及小于候选阈值的文件比例。平均值会掩盖长尾:一个分区可能有少量大文件和数万个 1 至 3 MiB 文件,所以至少看 p50、p90、p99 与直方图。
再把查询 p95 拆解为 catalog/manifest 解析、文件规划、任务调度、首字节和扫描时间。若文件数与规划时间同步增长,合并相同总字节的测试分区后规划显著回落,证据才接近闭环。若规划稳定而扫描变慢,应继续查选择性、列统计、删除文件、倾斜和计算资源。
第二步:从写入路径找出再生原因
题设每天 1,440 个一分钟批次。80,000 个文件相当于平均每批约 56 个文件;每个 writer 或分区组合数据不足时,设置 512 MiB 目标也不能把 3 MiB 输出凭空放大。write.target-file-size-bytes 是目标,不是“每个文件必定达到该值”的保证。
根因通常是组合问题:微批过密;上游并行度远高于每批数据量;写前没有按表分区键聚集;按小时、租户或用户等高基数组合过度分区;热点键造成倾斜;重试产生额外提交;merge-on-read 更新又积累删除文件。
应把“文件小”按分区解释。某个每天只有 40 MiB 的合法低流量分区,无论怎样等待都无法形成 512 MiB 文件;这时应接受较小目标、合并分区粒度,或用 bucket/隐藏分区,而不是无限增加压实频率。
第三步:先让写入侧不再制造同规模碎片
写入侧从最小改动开始灰度:在新鲜度 SLA 内将一分钟提交合并为更大的触发批次;让数据按 Iceberg 分区键做 hash 或 range 分布;按每批字节而非集群最大并行度决定 writer 数;避免把高基数字段直接变成分区。
目标文件大小要通过实际压缩率、行宽、查询选择性和单分区日增量测试。题设先用 512 MiB(536,870,912 字节)作为候选,是因为 Iceberg 默认配置可提供这个起点,并不排除 128、256 MiB 或更大值更合适。若单分区数据远小于目标,优先演进分区规则;若数据足够但 writer 各自只拿到少量行,修正分布和并行度。
先选择一个流量相近的分区做 A/B:比较新旧写入在相同数据量下的文件数、大小分布、提交延迟、流处理积压和失败恢复。只有新文件生成速率明显下降,存量压实才不会变成永久追赶。
第四步:按收益和冲突划定压实范围
第一轮选择迟到数据和业务修正窗口都已关闭的一个冷分区,至少避开当前正在写入的小时。分区是否还会更新与快照保留 7 天是两条独立时间线。默认先用 bin-pack,因为目标是减少元数据与文件打开开销;只有常用过滤列的范围重叠严重,且基准测试证明额外 shuffle 值得,才升级为 sort 或 Z-order。
CALL lakehouse.system.rewrite_data_files(
table => 'analytics.events',
strategy => 'binpack',
options => map(
'target-file-size-bytes', '536870912',
'min-input-files', '5',
'max-concurrent-file-group-rewrites', '3',
'partial-progress.enabled', 'true'
),
where => 'event_date = DATE ''2026-07-10'''
);where 选择的是“可能包含匹配数据的文件”,所以谓词应与分区边界一致并在执行前查看候选字节。file group 限制单次工作量;受控并发避免对象存储、shuffle 与查询集群同时饱和。部分进度允许分组提交,冲突时减少整轮重做,但代价是产生多个快照,监控和回滚必须按组处理。
热分区无法完全避开并发写入时,缩小时间范围和 file group,保证幂等调度,区分数据文件冲突与可重试的元数据提交冲突。不要同时运行两个范围重叠的压实作业。
第五步:估算目标文件数、I/O 和窗口
200 GiB 除以 512 MiB,理论上约为 400 个目标文件;分区边界、压缩率和尾部余量会让实际数略高。这个估算用于发现数量级错误,不是承诺精确产出 400 个文件。
一轮全量日分区压实至少读取约 200 GiB 并写出约 200 GiB,数据 I/O 约 400 GiB,另有 shuffle、临时空间、元数据与失败重试。若基准测试得到按输入字节计的端到端持续吞吐 100 MiB/s,理想时间为:
200 GiB * 1024 MiB/GiB / 100 MiB/s = 2,048 s ≈ 34.1 min生产计划应加入倾斜、并发查询和重试余量,例如给 60 至 90 分钟窗口,并设置最大候选字节、最大并发 file group、对象存储请求率与临时磁盘告警。若压实每天只能处理 150 GiB,而新增 200 GiB,积压必然扩大;要先提升可持续吞吐或进一步减少新文件。
第六步:把快照保留与物理清理解耦
压实成功后,新快照引用大文件,正在读取旧快照的查询仍可完成,7 天时间旅行也仍需旧文件。此时直接删除原 Parquet 文件会破坏快照和并发读。
先验证新快照并观察一个完整业务周期,再按策略执行 expiresnapshots,同时保留 7 天窗口、必要分支/标签和最少快照数。该操作只删除不再被任何保留快照需要的文件。孤儿文件是没有被任何表元数据引用的另一类对象,应单独运行 removeorphanfiles,先 dryrun,使用保守的 older_than,核对路径 scheme/authority 和在途写入最长持续时间后再删除。
快照过期不是压实本身,孤儿清理也不是回收旧快照引用文件的替代品。三者的调度、权限和审计记录应独立。
第七步:灰度验收并定义停止条件
先锁定压实前快照与候选分区,记录行数、去重键计数、关键金额/事件聚合、最小最大时间和 null 统计。压实后对同一逻辑范围复算;仅比较总行数会漏掉“少一行又多一行”的抵消,关键表可增加分桶 checksum 或按业务键采样对账。
性能侧比较活跃文件数、大小 p50/p90/p99、manifest 数、规划 p50/p95、端到端查询 p95、扫描字节、压实改写字节与资源成本。运维侧跟踪小文件生成率、压实积压、失败 file group、提交冲突、快照数量和可回收字节。
若正确性不一致、规划时间没有改善、查询成本转移到写入 SLA,或压实吞吐长期低于新碎片生成速度,就停止扩大范围并回到根因分析。表快照可用于回滚元数据指针,但已经执行的快照过期和物理删除不能靠同一动作恢复。
高质量示范回答
“我不会先跑压实,而会先证明瓶颈。对象存储目录混有历史和孤儿文件,我会从 Iceberg 当前快照的 files 元数据表按分区统计活跃文件数、总字节和大小 p50/p90/p99,再把查询的 catalog、manifest 解析、任务规划、文件打开和扫描阶段拆开。题设每天 200 GiB 却产生 80,000 个文件,中位数 3 MiB;若候选目标是 512 MiB,理想数量级约 400 个文件,所以写入布局值得优先调查,但仍需用同数据量的合并分区实验确认规划时间是否回落。
然后我会修再生原因。一天有 1,440 个一分钟微批,平均每批约 56 个文件。检查 writer 并行度、分区基数、写前分布、倾斜、重试和 merge-on-read 删除文件后,我会在新鲜度 SLA 内适度增大提交批次,让数据按分区键 hash 或 range 分布,并按每批字节控制 writer。512 MiB 只是起点;低流量分区永远积不满时,要调整分区粒度或单独降低目标。
存量先灰度一个冷分区,使用 rewritedatafiles 的 bin-pack、分区谓词、受限 file group 并发和最大候选字节。只有常用过滤列测试显示明显收益时才用 sort 或 Z-order,因为排序会增加 shuffle 和临时空间。热分区若必须处理,就缩小 file group、启用部分进度并做冲突重试;不运行重叠压实。
容量上,200 GiB 到 512 MiB 的理论目标约 400 个文件。一轮约读 200 GiB、写 200 GiB;若按输入字节计的端到端实测吞吐是 100 MiB/s,理想约 34.1 分钟,我会给 60 至 90 分钟窗口并限制并发,确认日处理能力高于日增量。
压实以新快照原子提交,旧查询和 7 天时间旅行继续引用旧文件,所以绝不直接删对象。新快照完成行数、业务聚合、分桶 checksum 和查询 p95 验收后,按 7 天策略过期快照;孤儿文件另做 dry run 和保守延迟清理。最终看正确性、小文件生成率、文件分位数、规划 p95、压实积压、冲突率和每 GiB 收益,任一核心指标恶化就停止扩量。”
常见错误
- 看到文件多就直接归因 → 历史文件数不等于当前查询文件数 → 从当前快照建立分区文件画像并拆解规划与扫描时间。
- 只跑一次压实 → 微批、writer 和过细分区仍会持续生成小文件 → 先降低新碎片生成率,再治理存量。
- 把目标大小当成强保证 → writer 只能写出自己收到的数据 → 把目标与每批字节、分布、分区日增量一起调整。
- 对整张 20 TiB 表无谓重写 → 成本与冲突范围过大 → 按冷分区、候选大小和收益分批处理。
- 默认使用 sort 或 Z-order → 它们增加 shuffle 与临时空间 → 先用 bin-pack,按查询过滤收益决定是否聚簇。
- 直接删除旧 Parquet 文件 → 保留快照或并发查询仍可能引用它们 → 通过快照过期回收,孤儿文件单独 dry run。
- 只验证总行数 → 错删和重复可能相互抵消 → 增加业务聚合、键计数、分桶 checksum 与抽样对账。
- 只算计算时间,不算可持续吞吐 → 日处理量低于日新增会让积压扩大 → 同时预算读取、写出、shuffle、重试和压实 lag。
- 用
coalesce(1)解决所有小文件 → 单 writer 会破坏并行度并制造超大瓶颈 → 按分区和目标字节计算合理 writer 数。
追问及应对
追问一:为什么把文件目标设成 512 MiB,而不是 128 MiB?
512 MiB(536,870,912 字节)是 Iceberg 默认目标,可作为题设中大表的实验起点,不是普适最优值。应比较 128、256、512 MiB 等候选在查询选择性、规划开销、任务并行度、压缩率和单分区日增量下的表现。选择性很高的查询可能偏好更小文件,吞吐扫描和巨大分区可能偏好更大文件。
追问二:为什么修改目标大小后,文件仍然只有几 MiB?
目标控制 writer 希望接近的输出上限,不能合并不同任务手中的数据。如果一分钟微批被拆给几十个 writer,或一个 writer 同时写许多低流量分区,每个文件都可能提前随任务提交关闭。要调整批次、写入分布、并行度和分区设计,而不是继续把目标值调大。
追问三:bin-pack、sort 和 Z-order 怎么选?
只想减少文件数和打开开销时先选 bin-pack。查询常按一个或有层级关系的列过滤,并且列统计能有效裁剪时,可测试 sort。过滤经常跨多个维度且组合变化较大时才评估 Z-order。后两者必须把额外 shuffle、临时空间、写放大和后续维护成本计入基准。
追问四:压实与流式写入冲突怎么办?
优先过滤掉仍活跃的分区。无法避开时,把工作拆成更小 file group,限制并发,启用部分进度并对提交冲突做有界重试;调度器保证同一表范围互斥。部分进度减少单组冲突导致整轮重做的代价,但会增加快照和部分成功状态,必须记录每组结果。
追问五:为什么压实后对象存储容量没有立刻下降?
压实只提交引用新文件的快照,旧文件仍被历史快照、分支或标签引用,以支持并发读和时间旅行。满足 7 天保留后运行快照过期,未被保留快照需要的文件才可回收。完全不在表元数据中的孤儿文件还需独立清理。
追问六:如何证明收益来自小文件减少,而不是缓存或更多资源?
用相同引擎配置、关闭或预热到一致缓存状态,对相同快照逻辑范围运行可重复查询;记录压实前后的规划时间、任务数、文件打开数、扫描字节和执行时间。保留未压实的相似分区作对照,并多次运行取分位数。只有文件布局改变且规划/打开开销随之稳定下降,因果证据才更强。