背景
最近一年内,trino借助Tardigrade项目对批处理能力进行了优化,使其可处理ETL任务。然而,目前社区exchange shuffle实现采用了基于Hash-Based的设计方案。该方案存在一个重要缺陷:随着集群规模的增大,shuffle partition配置的数量也会增加,从而导致更多的exchange中间数据文件被产生。
例如,当partition数为N时,每个map task都将产生N个partition shuffle文件。因此,当map task的数量为M时,就会产生M*N个文件。当M和N较大时,就会导致大量海量的小文件。这些文件最终会导致大量低效的IO操作,并且内存中需要保存大量文件操作句柄和临时buffer,这很容易导致OOM,从而给性能和稳定性带来问题。为此,必须采用更有效的设计方案来优化这一问题。
这个问题在Tardigrade项目中也重点描述到,目前社区版本的Tardigrade能力只适合于在20个节点以内的小集群使用(https://github.com/trinodb/trino/wiki/Fault-Tolerant-Execution)
当前,主流批处理引擎不断演进,针对shuffle阶段存在的问题,逐步采用sort-base shuffle技术解决。为此,我们通过调研各引擎的演进方案,结合trino现状,巧妙地设计了一套自己的sort-base shuffle实现方案。
Spark Shuffle演进
Spark采用了Hash-Based Shuffle作为早期的数据分片策略。然而,随着1.1版本的推出,Sorted-Based Shuffle得以引入,后续在2.0版本中删除了Hash-Based Shuffle。Sorted-Based Shuffle的应用,让Spark由原本只能用于中小规模数据处理的平台,重新定位为可处理大规模数据处理的平台,进一步稳固了它在数据处理领域的领导地位。
说一下spark Sorted-Based Shuffle实现,下面的图很好的展示这种模式下的shffle write和shuffle read。
shuffle write的大致流程:
- ShuffleWriter迭代式地读取每一个原始数据计算并确定其所属的分区(partition id),并将其存储于内存中的Shuffle buffer中。
- 当Spark Executor内存管理机制发现内存不足时,就会触发Spill操作,将Shuffle buffer中的数据写入到磁盘的Spill数据文件中。同时,为了方便后续的读取操作,还需要记录每一个分区在Spill文件中的位置(offset和length信息)。
- 在Shuffle Write过程中,Spill文件会不断地增多。因此,在任务执行完毕后,需要将所有Spill数据文件进行合并(merge)成一个Data数据文件,并按照分区进行排序。同时,还需要为Data文件生成一个Index文件,用于记录每一个分区在Data文件中的位置(offset和length信息)。
- ShuffleWriter消费完row数据后,就需要对多个spill数据文件进行merge成一个data数据文件(也是按partition id排序),然后再生成一个index文件记录各个partition id在data数据文件的位置[offset, length]
Shuffle read的流程比较简单,其主要目的是按照确定的分区进行读取,具体流程如下:
- ShuffleReader首先读取Index文件,分析出每一个分区的位置信息。
- 然后根据所得到的位置信息,去读取Data数据文件中的Shuffle数据。
Flink Shuffle演进
先看一下flink shuffle演变:
- 在2020年的Flink 1.12版本中,基于sort的批处理shuffle实现被引入,随后持续进行了性能和稳定性优化。
- 在2021年的Flink 1.13版本中,sort-shuffle已经实现并且可用于生产系统中。
引入 Sort-Shuffle 的意义(来自flink社区的说明)
我们之所以要在 Flink 中引入 sort-shuffle 的实现,一个重要的原因是 Flink 原本的基于 hash 的实现对大规模批作业不可用。这个也是被现有的其他大规模分布式计算系统所证明的:
- 稳定性方面:对于高并发批作业,基于 hash 的实现会产生大量的文件,并且会对这些文件进行并发读写,这会消耗很多资源并对文件系统会产生较大的压力。文件系统需要维护大量的文件元数据,会产生文件句柄以及 inode 耗尽等不稳定风险。
- 性能方面:对于高并发批作业,并发读写大量的文件意味着大量的随机 IO,并且每次 IO 实际读写的数据量可能是非常少的,这对于 IO 性能是一个巨大的挑战,在机械硬盘上,这使得数据 shuffle 很容易成为批处理作业的性能瓶颈。
Flink Sort-Shuffle实现
Flink Sort-Shuffle实现的整体设计思路与Spark相似,但在细节上存在差异。
Flink通过对shuffle数据的文件结构进行优化,采用多个数据区域的方式实现按partition id排序,而不是全局排序。相比于Spark的设计,这种方法避免了spill文件的合并,减少了数据文件的读写和复制过程。然而,需要注意的是,由于索引文件中记录的信息相对较多,这也会导致索引文件的大小相对较大。
trino如何实现
方案选择
相较于Spark和Flink的实现方式,Trino结合自身的shuffle实现最终设计上参考了Flink的Sort-based Shuffle。具体实现方式为将Shuffle数据文件根据Partition ID进行局部排序,从而实现如下优势:
- 在性能上表现更优秀。一方面,避免了一次Spill和Merge的IO读写过程,另一方面Trino Shuffle采用基于Remote Storage的存储,如果选择Spill and Merge方案,则无法应用本地Disk复制的Zero-copy等提升性能的技术。
- 功能实现方面也更加简单,无需Spill过程。
实现
目前,Trino的exchange shuffle实现逻辑主要集中在plugin/trino-exchange-filesystem模块中。shuffle的读写入口主要在FileSystemExchangeSink和FileSystemExchangeSource。本文不会深入讨论相关实现代码,而是重点强调核心逻辑。
具体而言,shuffle写操作先将Page数据依据Partition ID划分,并缓存在内存中。接着,基于Trino的Memory Revoke机制,触发内存数据排序和写入shuffle文件,并建立index索引信息文件。相比之下,shuffle读操作只需要依据index索引查找对应Partition ID的shuffle数据即可。需要注意的是,目前Trino的Memory Revoke机制仅支持operator算子级别,如果要支持FileSystemExchangeSink的Memory Revoke,需要进行一定的扩展。
下面展示了sort-based shuffle生成的shuffle数据文件,并与hash-based shuffle进行了比较。
-- 以下是sort-based shuffle产生的shuffle数据文件
/tmp/exchange2/00448f.20230209_032131_00002_9gph8.external-exchange-19.16/0/shuffle.data
/tmp/exchange2/00448f.20230209_032131_00002_9gph8.external-exchange-19.16/0/shuffle.index
/tmp/exchange2/00448f.20230209_032131_00002_9gph8.external-exchange-19.16/0/committed
-- 以下是hash-based shuffle产生的shuffle数据文件
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/0_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/10_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/11_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/12_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/13_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/14_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/15_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/16_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/17_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/18_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/19_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/1_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/20_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/21_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/22_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/23_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/2_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/3_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/4_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/5_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/6_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/7_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/8_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/9_0.data
/tmp/exchange2/9782b8.20230216_070725_00000_ayw3f.external-exchange-9.5/0/committed
测试
接下来我们对trino的sort-base shuffle进行测试。
稳定性测试
测试环境:
- 1000G数据集tpcds,iceberg表。
- 批处理模式。retry-policy=TASK。
- query11
- trino worker实例:8core32G,数量为3个。
- exchange storage为hdfs存储
测试结果:
- 对于Hash-based shuffle,当shuffle partition数量超过500后,其稳定性开始下降,大概率出现失败情况。
- 而对于Sort-based shuffle,由于shuffle文件数不会随着shuffle partition数线性增加,因此即使在partition数量较大的情况下,其稳定性表现仍然较好。
场景 | Hash-based shuffle | Sort-based shuffle |
---|---|---|
fault_tolerant_execution_partition_count=50 | success | success |
fault_tolerant_execution_partition_count=100 | success | success |
fault_tolerant_execution_partition_count=500 | failed | success |
fault_tolerant_execution_partition_count=1000 | failed | success |
以下是Hash-based shuffle场景下造成查询失败的异常堆栈,主要原因是在hdfs上并发打开大量shuffle文件以进行读写操作,最终导致了hdfs瓶颈问题的出现。
Query 20230207_110508_00004_s3xnj failed: org.apache.hadoop.ipc.RemoteException(java.io.IOException): File /tmp/exchange2/477088.20230207_110508_00004_s3xnj.external-exchange-19.9/4/52_0.data could only be replicated to 0 nodes instead of minReplication (=1). There are 3 datanode(s) running and 3 node(s) are excluded in this operation.
at org.apache.hadoop.hdfs.server.blockmanagement.BlockManager.chooseTarget4NewBlock(BlockManager.java:1832)
at org.apache.hadoop.hdfs.server.namenode.FSDirWriteFileOp.chooseTargetForNewBlock(FSDirWriteFileOp.java:265)
at org.apache.hadoop.hdfs.server.namenode.FSNamesystem.getAdditionalBlock(FSNamesystem.java:2586)
at org.apache.hadoop.hdfs.server.namenode.NameNodeRpcServer.addBlock(NameNodeRpcServer.java:880)
at org.apache.hadoop.hdfs.protocolPB.ClientNamenodeProtocolServerSideTranslatorPB.addBlock(ClientNamenodeProtocolServerSideTranslatorPB.java:517)
at org.apache.hadoop.hdfs.protocol.proto.ClientNamenodeProtocolProtos$ClientNamenodeProtocol$2.callBlockingMethod(ClientNamenodeProtocolProtos.java)
at org.apache.hadoop.ipc.ProtobufRpcEngine$Server$ProtoBufRpcInvoker.call(ProtobufRpcEngine.java:507)
at org.apache.hadoop.ipc.RPC$Server.call(RPC.java:1034)
at org.apache.hadoop.ipc.Server$RpcCall.run(Server.java:1003)
at org.apache.hadoop.ipc.Server$RpcCall.run(Server.java:931)
at java.security.AccessController.doPrivileged(Native Method)
at javax.security.auth.Subject.doAs(Subject.java:422)
at org.apache.hadoop.security.UserGroupInformation.doAs(UserGroupInformation.java:1926)
at org.apache.hadoop.ipc.Server$Handler.run(Server.java:2854)
性能对比
测试环境:
- 100G数据集tpcds,iceberg表。
- 批处理模式。retry-policy=TASK;fault-tolerant-execution-partition-count=24
- trino worker实例:8core32G,数量为3个。
- exchange storage为hdfs存储
测试结果:
- 针对测试结果进行分析,发现Sort-based shuffle存在大约10%的性能退化,相较于Hash-based shuffle来说。
- Hash-based shuffle在shuffle partition较小的情况下表现出性能优势,因此非常适合小型群集的使用场景。
场景 | 总耗时(单位:秒) |
---|---|
Hash-based shuffle | 2919 |
Sort-based shuffle | 3262 |
总结
本文针对Trino在处理ETL任务中shuffle阶段存在的问题进行研究,结合Spark和Flink的Sort-based Shuffle实现,提出了一套针对Trino的sort-base shuffle方案。
与Hash-based Shuffle相比,Sort-based Shuffle在处理大规模数据时表现更优,稳定性也更好。但在性能方面存在一定的退化,需要根据具体使用场景进行权衡。
本文主要介绍了Sort-based Shuffle的实现流程和Trino的具体实现方式,同时对稳定性和性能进行了测试分析。通过本文的研究,可以给Trino处理ETL任务时的shuffle阶段提供一种更为有效的优化方案。