3. 分块处理的技术栈概览

分块处理不是某一个软件的功能,而是一条由多层技术组件串起来的流水线。理解这条流水线最有效的方式,不是逐个背工具名称,而是先看清每一层在“数据从磁盘到结果”的过程中承担什么职责、彼此之间靠什么契约衔接。本节围绕四个层次展开:存储格式决定数据以什么粒度被切分和读取;计算框架决定分块如何被并行消费;调度工具决定任务在何时、何地、以什么顺序运行;编程接口则是科研人员真正书写分块逻辑的地方。四者不是替代关系,而是上下游配合关系。

存储格式:分块的第一道物理边界

分块处理的效率上限,往往在数据落盘的那一刻就已经被决定。行式文本(如 CSV、JSON Lines)虽然通用,但读取时无法跳过不需要的列,也无法按块定位,框架只能整文件扫描后再切分,分块粒度受文件大小限制。列式格式(Parquet、ORC)把同一列的值连续存放,并带有行组(row group)与页级统计信息,读取时可以只加载需要的列、只解压命中的行组,天然适合“按块并行”。而专为科学数据设计的格式更进一步:HDF5 与 NetCDF 支持多维数组的分块存储(chunking),Zarr 则把数组切成固定形状的小块,每块独立压缩、独立读取,甚至可以直接存放在对象存储上。

这里的关键参数是块形状。以一份形状为 (10000, 2048, 2048) 的三维遥感数据为例,若按 (1, 512, 512) 分块,则每块约 512×512 个 float32 值(约 1 MB),共 10000×4×4 = 160000 块;若按 (100, 2048, 2048) 分块,块数降到 100,但单块达 1.6 GB,无法被单个任务舒适地载入内存。块太小则元数据与调度开销占比升高,块太大则并行度不足、内存压力大。经验上应让单块压缩后落在几十 MB 到几百 MB 之间,并尽量让块形状与后续计算的访问模式对齐——按时间序列分析就沿时间轴切薄,按空间切片分析就沿空间轴切薄。

计算框架:把块变成并行任务

计算框架的职责是把“一组块”映射为“一组可并行执行的任务”,并处理任务间的依赖、失败重试与结果汇总。Dask 以延迟执行的图(task graph)描述计算,数组被划分为多个 chunk,每个 chunk 对应图中的一个节点,调度器按依赖关系推进;Spark 以 RDD/DataFrame 的分区为基本单位,适合结构化数据的批处理与 SQL 式聚合;Ray 更偏向通用分布式任务与 actor 模型,适合把自定义的分块函数直接分发到集群。对于单机多核场景,multiprocessing 或 joblib 的并行循环也完全够用,不必上集群。

选择框架时应关注三个匹配:数据形状是否匹配(多维数组选 Dask/Zarr 生态,表格选 Spark)、计算模式是否匹配(map-only 的分块预处理用轻量框架即可,需要多轮 shuffle 的聚合才值得上 Spark)、部署成本是否匹配(课题组几台机器用 Dask 本地集群往往比维护 Spark 集群更划算)。框架不是越重越好,能在一台工作站上把 8 核跑满,往往比勉强搭一个调度复杂的集群更快出结果。

调度工具:决定任务何时何地运行

调度工具解决的是资源与时间维度的问题:哪些任务可以同时跑、失败后如何重试、如何避免某个大块拖垮整批作业。轻量方案如 Dask 自带的分布式调度器、GNU Parallel、Slurm 的作业数组(job array),适合把成百上千个分块任务提交到已有集群;重量方案如 Kubernetes 上的 Argo Workflows、Airflow,适合需要跨天、跨阶段、带依赖关系的流水线。科研场景中一个常见做法是用 Slurm 数组作业:每个数组索引对应一个块编号,脚本内根据 SLURM_ARRAY_TASK_ID 计算该块的行列范围,独立读写,互不通信。

#!/bin/bash
#SBATCH --array=0-159999
#SBATCH --cpus-per-task=2
#SBATCH --mem=4G
python process_chunk.py --chunk-id ${SLURM_ARRAY_TASK_ID}

这种“一块一任务”的模式优点是隔离性好、失败只影响单块;缺点是任务数过多时调度器本身成为瓶颈,此时应把多个小块合并成一个任务批次(如每 50 块一个任务),在任务内部循环处理,减少调度开销。

编程接口:分块逻辑真正落地的地方

编程接口是四层中离科研人员最近的一层。它要回答的问题是:给定一个块标识,如何定位数据、如何计算、如何写出结果且不与其他任务冲突。以 Zarr 为例,读取一个块只需给出数组路径与索引切片,写入时每个任务写自己的区域,天然无冲突;以 Parquet 为例,通常按分区目录(如 year=2023/month=07/)组织,任务只写自己的分区文件,避免并发追加同一文件。结果汇总阶段再用一次轻量扫描或元数据清单合并。

判断技术栈是否选对,可以问三个问题:单块能否被独立读取而不扫描全量数据?单块任务能否独立失败并重试?结果写入是否存在多任务争抢同一文件的可能?三个答案都为“是/否”符合预期时,这条分块流水线基本是健康的。

把四层放在一起看,它们的关系可以用下面的信号链式结构表达:存储格式提供“可切分的物理单元”,计算框架把单元映射为“并行任务图”,调度工具把任务图落到“具体资源与时间槽”,编程接口则在每个任务内部完成“定位—计算—写出”的闭环。任何一层选择不当,都会在整条链路上放大为吞吐下降或结果不一致。

分块处理技术栈:四层职责与衔接契约 存储格式 Parquet / ORC HDF5 / NetCDF / Zarr 产出:可独立读取的块单元 计算框架 Dask / Spark / Ray multiprocessing / joblib 产出:并行任务图 调度工具 Slurm 数组作业 Dask 调度器 / Argo 产出:资源与时间槽 编程接口 块定位 / 计算 / 写出 分区目录 / 索引切片 产出:单块结果 层间契约 块形状与大小 → 并行度与内存;任务图 → 重试与依赖;资源槽 → 并发上限;块标识 → 无冲突读写 健康检查三问 ① 单块能否独立读取? ② 单块任务能否独立失败重试? ③ 写入是否存在多任务争抢同一文件?

最后需要强调一个常被忽视的点:四层之间的契约必须一致。如果存储按 (1, 512, 512) 分块,而计算框架按整幅影像划分任务,那么每个任务仍要读取大量无关块,分块存储的优势被抵消;如果调度器把 16 万个任务一次性提交,而集群只有 32 个核,排队与调度开销会吞掉并行收益。实际工程中,先确定块形状与块数量,再据此估算任务数与单任务内存,最后选择调度粒度(一块一任务还是多块一任务),这个顺序比“先选框架再想数据怎么切”要稳妥得多。技术栈的价值不在于组件多先进,而在于四层之间的粒度、标识与失败边界能够严丝合缝地对齐。