批处理模型详解:从 MapReduce 到数据流引擎

双十一狂欢的余温散尽,消费者在回味秒杀的快感,而电商平台的数据工程师却盯着 50 亿条订单明细和 2 亿条用户画像,愁得像面对一屋子散落的乐高零件。老板要三样东西:每个商品的销售额 Top 100、各会员等级的总消费、以及高风险用户订单清单用于风控。用几十行 Shell 管道想在天亮前跑完?那不是写脚本,那是写遗书。于是,批处理 (Batch Processing) 从单机脚本一路进化到分布式数据流引擎,每一步都写着"让数据乖乖听话"。

flowchart LR A[订单日志文件<br>HDFS / 对象存储<br>50亿条订单明细] --> B[批处理引擎<br>Spark / Flink] B --> C[数据仓库 / 在线报表<br>结果写入] C --> D1[商品销售额 Top 100] C --> D2[各等级用户消费总额] C --> D3[高风险用户订单清单]

【图1:批处理总览------从原始订单日志到三份最终报表】

MapReduce:初代分治范式

当数据大到单机扛不住,第一个站出来的是 MapReduce 。它的思想朴素得就像工厂流水线:Map 阶段把任务拆成无数独立碎片,每个工人(节点)处理自己的那一份,产出键值对;Shuffle 阶段像个巨大的分拣中心,按 key 把中间结果打散、排序、路由;最后 Reduce 阶段拿到每个 key 的所有值,聚合成最终结果。

案例落地:商品销售额统计

拿 50 亿订单计算商品销售额,MapReduce 的做法分两轮。第一轮,Map 从每条记录抽出 <商品ID, 金额>Shuffle 按商品 ID 哈希分片,确保同一个商品的所有记录都落到同一个 Reducer。Reduce 拿到已经排好序的流水,跑一遍求和,得到 <商品ID, 总销售额>。第二轮作业再对结果排序,取 Top 100。

flowchart TD subgraph Map阶段 M1[Mapper 1] -->|输出键值对| MS1[(本地磁盘)] M2[Mapper 2] -->|输出键值对| MS2[(本地磁盘)] end subgraph Shuffle阶段 MS1 -->|按key分发| R1[Reducer 1] MS2 -->|按key分发| R1 MS1 -->|按key分发| R2[Reducer 2] MS2 -->|按key分发| R2 end subgraph Reduce阶段 R1 -->|写入| HDFS1[(HDFS 结果文件)] R2 -->|写入| HDFS2[(HDFS 结果文件)] end

【图2:一个具有两个映射器和两个归约器的MapReduce作业】

可惜,这套舞台剧的脚本太僵硬了:强制 Map → Shuffle → Reduce 三步走,中间结果每次都要写回 HDFS 磁盘,一个多阶段需求就得启动多个独立作业,启动开销和磁盘 I/O 让人抓狂。就好像你想做一道"洗菜---切菜---炒菜"的连贯料理,MapReduce 却逼你每完成一步就把菜放回冰箱,再重新打开炉灶。50 亿订单关联 2 亿用户这种事,MapReduce 能搞定,但天亮之前绝对跑不完。

数据流引擎:打破阶段壁垒

于是 数据流引擎 (Data Flow Engine) 登场了,代表选手如 SparkFlink 。它们把整个作业抽象成一张 有向无环图 (DAG) ,操作符(map、filter、groupBy、join 等)可以随意拼接,不再被 Map 和 Reduce 这两个僵硬的阶段捆住手脚。同一个 DAG 里,三个分析目标可以共享上游的订单读取和过滤,数据只读一次,多次消费,避免了重复劳动。

主线案例的 DAG 设计

面对"商品 Top 100 + 用户等级消费 + 高风险订单"三个需求,数据流引擎设计的 DAG 可以从订单源读取后,分三个分支并行处理。第一个分支做按商品聚合排序,第二个分支与用户表做连接后按等级聚合,第三个分支按规则过滤高风险交易并写出明细。实际生产中用 PySpark 可以很干净地写出这份 DAG,一份数据流,三条消费路径:

python 复制代码
# 一份订单流,三个 DAG 分支的高效写法
orders_df = (spark.read.parquet("hdfs://.../orders/event_date=2025-11-11")
             .filter("amount > 0")
             .cache())
users_df = spark.read.parquet("hdfs://.../users")

# 分支 1:商品 Top 100
top_products = (orders_df.groupBy("product_id")
                .sum("amount")
                .orderBy(desc("sum(amount)"))
                .limit(100))

# 分支 2 & 3:大表 Sort-Merge Join
enriched_orders = orders_df.join(users_df, on="user_id", how="inner")

member_spent = enriched_orders.groupBy("member_level").sum("amount")
high_risk_orders = enriched_orders.filter("reg_time >= '2025-11-01' AND amount > 10000")
flowchart TD S[读取订单源<br>50亿条] --> F[过滤双十一当天数据] F --> B1[按商品ID聚合] F --> B2[按用户ID分组] B1 --> T100[排序取Top 100] B2 --> J[Join with 用户画像<br>2亿条] J --> B3[按会员等级聚合] J --> B4[过滤高风险规则] B3 --> R2[消费总额输出] B4 --> R3[高风险订单输出] T100 --> R1[Top 100输出]

【图3:主线案例的DAG设计------一份订单源被三个分支共享,分别产出商品Top 100、会员等级消费和高风险订单】

数据流引擎的精妙之处还在于 流水线执行 (Pipelined Execution) :下游操作符不必等上游完全结束,数据边产出就边开始处理。中间状态能放在内存就不落盘,非写不可也只是落到本地磁盘而非分布式文件系统,I/O 负担大幅下降。更激进的是 存算分离 (Compute-Storage Separation) 的趋势------过去为了"数据本地性"必须把计算调度到数据所在节点,如今万兆网络加高性能对象存储(S3/OSS),再加上 Alluxio 这类分布式缓存层,计算集群可以独立弹性扩缩,从远程存储直接拉数据,计算完成即写回,调度器再也不用为本地数据碎片头疼了。

flowchart LR subgraph 计算集群 C1[Spark Executor] C2[Spark Executor] end subgraph 缓存层 A[Alluxio / 分布式缓存] end subgraph 持久存储 S3[(对象存储 S3 / OSS)] end C1 -->|读/写| A C2 -->|读/写| A A -->|异步刷盘 / 读回| S3

【图4:存算分离架构------计算集群通过分布式缓存层与远程对象存储交互,实现计算与存储的独立弹性伸缩】

Shuffle:分布式排序的搬山术

Shuffle 本质上是将分散在不同节点上相同 key 的数据,汇聚到同一个节点并排序。传统实现是 Pull-Based:每个 Mapper 把输出按目标 Reducer 切分成小文件写本地,Reducer 们再像饿狼一样并发去拉取数据。这带来了漫天的小文件和随机 I/O,性能损耗惨重。

现代引擎引入了 Push-Based Shuffle / Remote Shuffle Service。思路简单粗暴:别让 Reducer 到处捡破烂了,让 Mapper 主动把数据推给专门的 Shuffle Service 节点,服务端将所有 Mapper 的数据合并成一个大文件,Reducer 只需顺序读取这一个文件,小文件和随机 I/O 的噩梦瞬间消散。Spark 3.2+ 的 Push-Based Shuffle 就是这个思路,配上异步 Fetch 和零拷贝,大幅削减了网络和磁盘的波动。

flowchart TD subgraph Pull-Based M1[Mapper 1] -->|分区文件| D1[(本地)] M2[Mapper 2] -->|分区文件| D2[(本地)] R1[Reducer] -->|拉取片段| D1 R1 -->|拉取片段| D2 end subgraph Push-Based M3[Mapper 1] -->|推送数据| SS[Shuffle Service<br>合并为大文件] M4[Mapper 2] -->|推送数据| SS R2[Reducer] -->|顺序读取合并文件| SS end

【图5:Push-Based Shuffle与Pull-Based Shuffle对比】

连接与分组:大表关联的硬核实现

订单表 50 亿,用户表 2 亿,要关联出每个用户的等级,这是一场硬仗。可选的 Join 算法有三种:

  • Broadcast Hash Join:把小表广播到每个节点内存做哈希表,但 2 亿行用户数据压缩后也远超单机内存,OOM 风险直接劝退。
  • Partitioned Hash Join:两侧按 key 哈希分区后,每分区必须能装入内存,中等规模可以,面对 50 亿对 2 亿,一个分区内存也未必 hold 住。
  • Sort-Merge Join:两侧都很大且已按 key 排序,通过双指针归并完成连接,支持磁盘外排序,是大规模事实表的首选。

主线案例必须走 Sort-Merge Join 。两张表按 user_id 分片并在 Shuffle 阶段排序,每个 Reducer 拿到同一个 user_id的所有订单和用户信息。这里有一个关键技巧叫 二次排序 (Secondary Sort) :让用户表的记录在排序后总是先于订单表到达 Reducer。想象 Reducer 是民政局,用户记录是身份证,订单记录是办事申请。二次排序让身份证先到,工作人员(Reducer)把身份证往桌上一拍,只占几十字节内存;接下来每来一笔申请,核对身份证后立刻盖戳发出,桌上永远只摊一张身份证。如果不做二次排序,申请先蜂拥而至,工作人员就得把上万份申请堆满桌子,苦苦等待身份证出现,内存分分钟爆炸。

sequenceDiagram participant R as ------------Reducer------------ Note over R: 排序保证用户记录先到 R->>R: 收到 user_id=42 的用户记录<br>缓存在内存(几十字节) loop 处理该用户所有订单 R->>R: 收到订单记录 (user_id=42) R-->>R: 关联并输出结果 end Note over R: 用户记录释放,处理下一用户

【图6:Sort-Merge Join中的二次排序时序------用户表记录先到达Reducer并缓存,随后每条订单到达时即时关联输出,内存占用恒定】

不过,Sort-Merge Join 也不是万能药。如果双十一当天冒出了"企业代购大户"------某个 user_id 坐拥数百万笔订单,那这个 key 所在的分区依然会撑爆单节点内存,这就是臭名昭著的数据倾斜 (Data Skew) 。此时光靠二次排序不够,还得拿出更生猛的武器:两阶段聚合 先加随机前缀打散,或者直接对倾斜的 key 加盐 (Salting) ,强行拆成多份并行处理后再合并。这些手段就像给过载的办事窗口配了多个协办柜台,终于不会被超级大户堵死。

分组聚合本质上就是按分组字段作为 key 的 Reduce,加上 Combiner(Map 端局部聚合)还能进一步削减 Shuffle 数据量,让整个链路更轻快。

SQL 与 DataFrame:说人话,让引擎干活

手写 DAG 固然灵活,但对分析团队来说太硬核了。声明式接口SQLDataFrame API 允许你只描述"想要什么",优化器负责"怎么算"。以主线案例写两句 SQL:

sql 复制代码
-- 商品销售额 Top 100
SELECT product_id, SUM(amount) AS total_sales
FROM orders
WHERE event_date = '2025-11-11'
GROUP BY product_id
ORDER BY total_sales DESC
LIMIT 100;

-- 各等级用户消费总额
SELECT u.member_level, SUM(o.amount) AS total_spent
FROM orders o JOIN users u ON o.user_id = u.user_id
WHERE o.event_date = '2025-11-11'
GROUP BY u.member_level;

CatalystCalcite 这类优化器会自动施展 谓词下推 (Predicate Pushdown) 把日期过滤推到数据源,列剪枝 (Column Pruning) 只读取需要的字段,以及根据统计信息决定用 Sort-Merge Join 还是 Broadcast Join。你完全不用操心 Shuffle 的细节,只需面对一张大表写熟悉的查询。

graph LR SQL[SQL 查询] --> LP[逻辑计划<br>未优化的关系代数树] LP -->|谓词下推, 列剪枝, Join重排| OP[优化后的逻辑计划] OP --> PP[物理计划<br>选择Sort-Merge Join / Broadcast Hash Join] PP --> Exec[分布式执行]

【图7:SQL查询的优化过程------从逻辑计划经过谓词下推、列剪枝等规则优化,再到物理计划选择具体Join算法】

对于习惯 Pandas 的数据科学家,DataFrame API 提供了类似的操作,只不过执行是在分布式集群上,而且要注意分布式 DataFrame 通常不保证全局行序,别把本地的"顺序思维"带进集群。

总结:选型指南与批流合一

三种模型的对比如下:

模型 适用场景 性能 开发效率 代表系统
MapReduce 简单两阶段聚合、遗留系统 Hadoop
数据流引擎 复杂多阶段 DAG、迭代计算 Spark, Flink
SQL/DataFrame 分析报表、即席查询、ML 预处理 Spark SQL, Trino

回到主线案例,如果仅算商品销售额 Top 100,MapReduce 还能勉强一战;但要同时产出三份报表、共享数据源,MapReduce 需要多份独立作业,重复读取大量数据,而 Spark DAG 一份数据一套流水线就齐活,时间、资源全部碾压。如果团队以分析师为主,直接用 Spark SQL 更是明智之选,业务逻辑清晰,优化器替你负重前行。

更令人兴奋的是,批流一体 (Unified Batch and Streaming) 正在模糊批与流的边界。过去用 Lambda 架构维护两套代码,痛苦不堪;如今 Flink 和 Spark Structured Streaming 已将"批"视为"流"的一个特例------有界流,同一套 DAG 逻辑既能处理有界的历史数据,又能处理无界的实时增量。本文提到的 DAG、Shuffle、状态管理,恰好也是闯入实时流处理世界最好的敲门砖。

flowchart LR S1[历史订单<br>有界数据] --> Engine[批流一体引擎<br>Flink / Spark Structured Streaming] S2[实时订单流<br>无界数据] --> Engine Engine --> U[统一结果视图]

【图8:批流一体------同一引擎处理有界历史数据和无界实时流,输出统一结果】

相关推荐
zhangfeng11331 小时前
思维链 ,Anthropic Mythos模型的 Looped Transformer架构解析,claud为什么厉害性能优越的研究
深度学习·架构·transformer
数据知道1 小时前
主流指纹浏览器:AdsPower/Multilogin/GoLogin架构剖析
架构·数据采集·指纹浏览器·风控
zhangfeng11331 小时前
AlphaEvolve 进化式编程智能体 是 Google DeepMind 2025年5月 发布的
人工智能·深度学习·chatgpt·架构·transformer
weixin_397574092 小时前
企业级AI应用基座架构全景解析:从资源管理到智能体编排
人工智能·架构
caimouse2 小时前
Reactos 第 5 章 进程与线程 — 5.2 Windows 进程的用户空间
windows·架构
Warren2Lynch2 小时前
破局“伪敏捷”:UML诊断视角下的微服务转型与架构重构——以EcoStream为例
微服务·架构·uml
hz567892 小时前
实时音视频SDK发展趋势:TRTC、WebRTC与云端音视频服务融合路径
架构·音视频·webrtc·实时音视频
todoitbo2 小时前
Agent_Swarm_分布式协作的通信编排与节点发现机制分析
人工智能·分布式·ai·jiuwenswarm
珠海西格电力2 小时前
零碳园区的竞争力体现在哪些方面?
大数据·人工智能·算法·架构·能源