1. 为什么要在Spark和Greenplum之间修一条高速路
数据平台做久了,你会发现一个特别拧巴的现象:离线计算明明已经跑在Spark上了,可一到数据服务层,业务方还是习惯性地往Greenplum里查。原因不复杂,Greenplum那套MPP架构在即席查询、多维聚合、BI报表对接上的响应速度,确实比直接怼着HDFS或对象存储扫要舒服得多。于是每个数据团队几乎都会遇到同一个动作:Spark算完的结果,得想办法塞进Greenplum。
这个"塞"的动作,就是今天要聊的核心。Spark读写Greenplum这件事,表面上看无非是读一张表、写一张表,但真正在生产环境里跑起来,坑多得能让人怀疑人生。默认的JDBC方式写Greenplum,数据量一大就变成单线程龟速插入,几千万行数据能跑上几个小时;读的时候如果不做分区切分,所有压力全压在一个task上,集群资源闲着也是闲着。而Greenplum-Spark Connector这个官方连接器,本质上就是给这条数据通路修了一条高速路——它利用Greenplum的gpfdist协议做并行数据传输,把原本单车道变成多车道,写入性能往往能提升一个数量级。
这篇文章适合谁看?如果你正在做数据仓库分层、离线结果回流、或者Spark与MPP数据库之间的数据同步,那这篇内容基本就是给你写的。不管你是刚接触Spark和Greenplum的新手,还是已经用JDBC硬扛过几亿行数据的老兵,下面这些关于连接器选型、并行度调优、分区策略、踩坑排查的细节,应该都能让你少走一些弯路。我会从整体设计思路讲起,把核心原理、参数配置、实操步骤和常见问题拆开揉碎,尽量做到看完就能照着搭、照着调。
2. 整体方案设计与技术选型拆解
2.1 两条路摆在面前:JDBC直连与Connector并行通道
Spark往Greenplum写数据,市面上主流就两条路。第一条是Spark自带的JDBC数据源,配置url、dbtable、user、password四个参数就能跑,代码写起来三行搞定,新手最爱。第二条就是Greenplum-Spark Connector,一个专门为这两个系统之间数据传输设计的连接器,底层走的是gpfdist协议。
这两条路的差别,用生活化的比喻讲:JDBC方式像是你一个人推着小推车往仓库里搬货,不管仓库有多少个卸货口,你就认准一个门,一趟一趟来回跑;Connector方式则是直接开了多条传送带,Greenplum的每个segment节点都参与接收,Spark这边多个partition并行推送,货从多个口同时进。
为什么JDBC会慢?因为标准JDBC写入本质上是把数据拼成INSERT语句,通过单一连接发给Greenplum的master节点,再由master分发到segment。master成了瓶颈,segment再多也发挥不出来。而Connector绕开了这个瓶颈,它通过gpfdist服务让数据直接落到各个segment上,master只负责协调元数据。这个架构差异决定了:小数据量(几十万行以内)两者差别不大,但一旦上了千万级,JDBC的耗时曲线会陡峭上升,Connector则相对平缓。
选型上我的建议很直接:数据量超过500万行,或者对写入时效有要求,直接上Connector,别在JDBC上浪费时间调优。JDBC那条路再怎么调batchsize、rewriteBatchedStatements,天花板就摆在那里。当然,如果只是一次性导个小维表,JDBC确实更省事,不用额外部署gpfdist服务。
2.2 Connector的核心工作原理:gpfdist协议到底干了什么
要理解Connector为什么快,得先搞明白gpfdist是什么。gpfdist是Greenplum自带的一个文件分发服务,原本的用途是让外部文件以并行方式加载进Greenplum——你启动一个gpfdist进程,它监听某个端口,Greenplum的segment节点主动来拉取数据文件,每个segment拉自己那一份,天然并行。
Greenplum-Spark Connector把这个机制反过来用了。写数据时,Connector在Spark Executor所在机器上启动gpfdist服务,Spark把每个partition的数据写成文件放到gpfdist能访问的目录,然后通知Greenplum的segment来拉。读数据时则相反,Greenplum通过gpfdist把数据推出来,Spark各partition并行接收。
这里有个关键点很多人忽略:gpfdist服务是跑在Spark Executor这一侧的,不是跑在Greenplum集群上。这意味着数据不需要先集中到master再分发,而是segment直接到Executor拉取,网络路径最短。这也是为什么Connector的写入性能跟Executor的分布、网络带宽关系很大——如果所有Executor挤在一台机器上,那并行度再高也白搭。
注意:gpfdist使用的端口范围需要提前在防火墙策略里放行,默认从
gpfdist.port开始往上找可用端口。生产环境经常因为端口没开导致连接超时,排查半天以为是认证问题,其实是网络策略卡住了。
2.3 版本匹配这件事,比想象中更要命
Connector的版本和Greenplum、Spark的版本之间有严格的对应关系,这一点我必须单独拎出来说。实际项目里遇到过太多次:Connector jar包版本不对,编译能过,运行时报ClassNotFoundException或者NoSuchMethodError,日志翻半天看不出所以然。
大致的原则是:Greenplum 6.x对应Connector 1.x系列,Greenplum 7.x对应Connector 2.x系列;Spark 2.4和Spark 3.x用的Scala版本不同(2.11 vs 2.12),jar包必须匹配。下表是我整理的一个粗略对照,具体以官方发布说明为准:
| Greenplum版本 | Spark版本 | Connector建议版本 | Scala版本 |
|---|---|---|---|
| 6.x | 2.4.x | 1.6.x - 1.8.x | 2.11 |
| 6.x | 3.x | 1.8.x及以上 | 2.12 |
| 7.x | 3.x | 2.x系列 | 2.12 |
版本对不上时,最典型的症状是任务提交后Executor端报类加载失败,或者连接建立后立刻断开。我的经验是:先把版本矩阵确认清楚再动手写代码,别急着调参数,版本错了调什么都没用。
3. 核心细节解析与实操要点
3.1 写Greenplum的三种模式:insert、update、upsert怎么选
Connector写数据支持几种模式,通过mode参数控制,常见的有insert和update。insert就是纯追加,目标表里已有的数据不动,新数据直接插进去;update则是根据指定的updateKey(可以多列组合)做匹配,匹配上就更新,匹配不上就插入,本质上就是upsert。
这个选择直接影响写入性能。insert模式最快,因为它不需要做匹配判断,数据直接灌进去。update模式因为要在Greenplum侧做join比对,会慢一些,而且updateKey的选择很讲究——如果选的列区分度低(比如状态字段只有几个值),匹配效率会非常差,甚至可能因为锁竞争导致写入卡住。
实操中我的做法是:能追加就别更新。如果业务上确实需要幂等写入,优先考虑在Spark侧先做去重,再用insert覆盖分区,而不是依赖update模式。如果非用update不可,updateKey一定要选高区分度的列组合,最好是主键或者唯一索引列。
// insert模式示例 df.write .format("greenplum") .option("url", "jdbc:postgresql://gp-master:5432/mydb") .option("dbtable", "public.target_table") .option("user", "gpuser") .option("password", "******") .option("driver", "org.postgresql.Driver") .mode("append") .save()上面这段是基础写法,format指定为greenplum,底层就会走Connector而不是普通JDBC。注意mode("append")是Spark的写入模式,和Connector内部的insert/update是两回事,别搞混了。
3.2 并行度与分区数的匹配关系
Connector的并行能力来自两方面:Spark侧的partition数量,和Greenplum侧的segment数量。理想情况下,Spark的partition数应该是Greenplum segment数的整数倍,这样每个segment能均匀分到数据,不会出现有的segment忙死、有的闲着。
举个例子:Greenplum集群有8个segment,Spark任务有16个partition,那每个segment平均处理2个partition的数据,负载均衡。如果partition数是10,那就有2个segment要多处理一份,整体耗时取决于最慢的那个segment,等于浪费了并行能力。
怎么控制partition数?写之前对DataFrame做repartition(n)或者coalesce(n)。repartition会触发shuffle,但能保证均匀;coalesce不shuffle,但可能不均匀。数据量大的时候我一般用repartition,虽然多一次shuffle,但换来的是写入阶段的均衡,总体是划算的。
提示:partition数也不是越多越好。每个partition对应一个gpfdist连接,partition太多会导致连接数暴涨,Greenplum侧可能触发连接数上限。一般控制在segment数的2到4倍比较稳妥。
3.3 数据类型映射的暗坑
Spark的DataFrame schema和Greenplum表的列类型之间,存在一些容易踩的映射问题。最典型的是Decimal类型:Spark里DecimalType(38,18)这种高精度小数,如果Greenplum目标列定义的是numeric(10,2),写入时可能报精度溢出或者被静默截断。还有时间类型,Spark的TimestampType默认带时区转换,写进Greenplum的timestamp without time zone列时,如果两边时区设置不一致,数据会偏移几个小时。
我的处理习惯是:写入前先做一次schema对齐检查。把DataFrame的schema打印出来,和目标表的\d+ table_name结果逐列比对,重点看decimal精度、timestamp类型、以及字符串长度(Greenplum的varchar(n)超长会报错)。这一步花五分钟,能省掉后面半小时的排查。
另外,Greenplum的分区表在写入时要注意:如果目标表是按某列做范围分区或列表分区,写入的数据必须能落到已有分区里,否则会报"no partition for row"错误。要么提前建好对应分区,要么用ALTER TABLE ... ADD PARTITION动态补,这个在自动化任务里需要额外处理。
4. 实操过程与核心环节实现
4.1 环境准备:jar包、配置、依赖一个不能少
动手之前,先把环境铺好。第一步是拿到Connector的jar包,把它放到Spark的classpath里。集群模式下,通常用--jars参数提交,或者直接丢进$SPARK_HOME/jars目录。同时Greenplum的JDBC驱动(postgresql-xx.jar)也要一并带上,Connector底层建立元数据连接时依赖它。
第二步是确认gpfdist相关配置。Connector需要知道gpfdist服务在哪个地址段启动,相关参数通过option传入:
val writeOptions = Map( "url" -> "jdbc:postgresql://gp-master:5432/mydb", "dbtable" -> "public.target_table", "user" -> "gpuser", "password" -> "******", "driver" -> "org.postgresql.Driver", "gpfdist.host" -> "10.0.0.0/24", // Executor所在网段 "gpfdist.port" -> "8081", "mode" -> "insert" )gpfdist.host这个参数容易被忽略,它告诉Connector在哪些地址上启动gpfdist服务。如果Executor分布在多个网段,这里要写清楚,否则Greenplum segment可能连不上某个Executor的gpfdist端口。
第三步是网络连通性验证。在Greenplum的segment节点上,尝试telnet或nc到Executor的gpfdist端口,确认能通。这一步别省,生产环境里网络策略复杂,提前验证比事后排查强。
4.2 完整写入流程:从DataFrame到Greenplum表
假设我们已经有一个清洗好的DataFrame,要写入Greenplum的dwd_order_detail表。完整流程分四步走。
第一步,读取源数据并做必要的转换。比如从Hive表读出来,过滤掉脏数据,调整字段类型和目标表对齐:
val spark = SparkSession.builder() .appName("gp-write-demo") .enableHiveSupport() .getOrCreate() val sourceDf = spark.sql(""" SELECT order_id, CAST(amount AS DECIMAL(18,2)) AS amount, from_unixtime(CAST(create_ts AS BIGINT)) AS create_time, status FROM ods.order_raw WHERE dt = '2024-01-01' """)第二步,根据目标表的segment数调整partition。假设集群8个segment,我们把partition调到16:
val targetDf = sourceDf.repartition(16)第三步,执行写入。这里用insert模式,因为目标表是按天分区的,我们只写当天分区,不存在更新需求:
targetDf.write .format("greenplum") .options(writeOptions) .mode("append") .save()第四步,写入后校验。别写完就完事,跑一条count对比源和目标:
SELECT count(*) FROM dwd_order_detail WHERE dt = '2024-01-01';和Spark侧targetDf.count()的结果比对,数量一致才算成功。我见过太多"任务成功但数据少了一半"的情况,都是没做校验。
4.3 读取Greenplum:分区切分是性能关键
从Greenplum读数据到Spark,性能瓶颈往往不在网络,而在读取时的分区切分。如果直接spark.read.format("greenplum").load(),默认可能只用一个partition去拉全表,几千万行数据全压在一个task上,慢得离谱。
正确的做法是指定partitionColumn、lowerBound、upperBound、numPartitions四个参数,让Connector按某个数值列做范围切分,生成多个并行查询:
val readOptions = Map( "url" -> "jdbc:postgresql://gp-master:5432/mydb", "dbtable" -> "public.dwd_order_detail", "user" -> "gpuser", "password" -> "******", "driver" -> "org.postgresql.Driver", "partitionColumn" -> "order_id", "lowerBound" -> "1", "upperBound" -> "10000000", "numPartitions" -> "16" ) val gpDf = spark.read .format("greenplum") .options(readOptions) .load()partitionColumn必须选数值型或者时间型且分布相对均匀的列,order_id这种自增主键最合适。如果选了个分布倾斜的列,切出来的partition大小差异巨大,并行效果打折扣。lowerBound和upperBound不要求精确,但范围要覆盖全表数据,否则会漏数据——这个坑很隐蔽,因为任务不报错,只是结果少了。
4.4 参数调优实录:batchSize与超时设置
Connector有几个影响性能的参数值得单独调。batchSize控制每次批量提交的行数,默认值偏保守,适当调大能减少往返次数。但也不是越大越好,太大可能导致Executor内存压力上升。我的经验值是5万到10万行一批,具体看单行宽度。
超时相关参数在数据量大时也要关注。gpfdist.timeout控制gpfdist连接的空闲超时,如果某个partition处理特别慢,其他连接可能因为等太久被断开。适当调大这个值(比如从默认的60秒调到300秒)能避免偶发的连接中断。
还有一个truncate参数,配合mode("overwrite")使用时,控制是truncate目标表还是drop重建。truncate通常更快,但要注意它不可回滚,生产环境用之前确认好。
5. 常见问题与排查技巧实录
5.1 连接类问题速查
连接问题占了实际排查量的一大半,我把高频的整理成表:
| 症状 | 可能原因 | 排查动作 |
|---|---|---|
| 任务卡在连接阶段无响应 | gpfdist端口未放行 | 在segment节点telnet Executor的gpfdist端口 |
| 报"connection refused" | gpfdist服务未启动或端口被占 | 检查Executor日志中gpfdist启动记录,换端口重试 |
| 认证失败 | 用户名密码错误或pg_hba未放行 | 用psql从Executor所在机器直连验证 |
| 写入中途断开 | 超时设置过短或网络抖动 | 调大gpfdist.timeout,检查网络质量 |
这里重点说下gpfdist端口的问题。Connector启动gpfdist时,如果指定的端口被占用,它会自动往上找可用端口,但Greenplum侧拿到的端口信息如果没同步更新,就会连错端口。所以指定端口时尽量选一个确定空闲的区间,别让它自动漂移。
5.2 数据一致性与重复写入
insert模式下如果任务失败重跑,很容易造成数据重复。因为已经写进去的那部分不会自动回滚。解决办法有两个:一是写入前先按分区做DELETE或TRUNCATE,保证幂等;二是用update模式配合主键做upsert,但前面说过update性能有代价。
我个人的习惯是:按分区覆盖。目标表按天分区,每次写入前先ALTER TABLE ... TRUNCATE PARTITION清掉当天分区,再执行insert。这样重跑多少次结果都一样,而且truncate分区比delete快得多。
5.3 性能不达预期的排查顺序
如果写入慢,按这个顺序排查:先看partition数是否匹配segment数,再看是否有数据倾斜(某个partition特别大),然后看gpfdist是否真的并行启动了(Executor日志里应该有多个gpfdist实例),最后看Greenplum侧是否有锁等待或者资源队列限制。
数据倾斜是最常见的隐形杀手。某个key的值特别多,repartition后全挤到一个partition里,其他partition早早跑完干等着。排查方法是写入前对partitionColumn做一次groupBy计数,看看分布是否均匀。不均匀的话,要么换切分列,要么加盐打散。
注意:Greenplum的资源队列(resource queue)如果配置了并发限制,Connector的并行写入可能被限流。表现是明明partition很多,但实际同时活跃的连接就那么几个。这种情况需要DBA配合调整队列的并发槽位。
5.4 几个反直觉的实操心得
第一个心得:小数据量用Connector反而可能更慢。因为启动gpfdist、建立并行连接本身有固定开销,几万行数据用JDBC秒级完成,用Connector可能要十几秒。所以别一刀切,按数据量分流。
第二个心得:Executor的本地磁盘空间要留够。gpfdist写数据时会先在Executor本地落临时文件,如果磁盘满了,任务会以各种奇怪的错误失败,比如"no space left on device"或者写入文件不完整。监控好Executor的磁盘使用率。
第三个心得:Spark的动态资源分配和Connector配合要小心。动态分配会在任务运行中增减Executor,而gpfdist服务是绑在Executor上的,Executor被回收可能导致正在传输的连接中断。写入阶段建议关掉动态分配,用固定资源跑。
6. 写在最后的一点个人体会
这套Connector方案我在几个不同规模的数据平台上都落地过,从几个segment的小集群到几十个segment的大集群,整体感受是:它把Spark到Greenplum这条路的性能天花板抬高了一个量级,但前提是你得把并行度、分区、版本这些基础工作做扎实。参数调优是锦上添花,架构对齐才是雪中送炭。
如果让我给刚上手的人一句建议,那就是:先用小数据量把整条链路跑通,确认版本、网络、权限都没问题,再逐步加数据量观察性能拐点。别一上来就怼全量数据,出了问题连是哪一环卡住都分不清。另外,写入后的数据校验一定要做成固定动作,宁可多花两分钟count一下,也别让错误数据悄悄流进下游。
后续如果还想深挖,可以研究一下Connector的源码里gpfdist的启动逻辑,以及它和Greenplum的external table机制是怎么配合的,理解了这一层,很多参数的含义就自然通了。