☰
存算分离架构避坑指南:四大技术瓶颈与优化实践
2026/10/15 4:43:56 网站建设 项目流程

1. 从“存算一体”到“存算分离”,我们到底在折腾什么

大概从五年前开始,“存算分离”这个四个字在大数据圈子里出现的频率越来越高。无论是云厂商的宣讲PPT,还是开源社区的Roadmap,几乎人人都在喊。

但喊了这么久,真正能把存算分离落地并且跑得舒服的团队其实并不多。我见过不少公司,兴冲冲地把HDFS上的数据全部迁到对象存储上,把计算引擎换成弹性伸缩的容器集群,结果跑了几个月发现:账单没降多少,任务倒是经常卡在某个奇怪的地方。回头一看,瓶颈根本不在CPU、也不在磁盘,而是卡在那些当初没想清楚的细节上。

这篇文章我就想认认真真聊一下,存算分离这条路上,到底有哪些技术瓶颈是绕不过去的。我会把架构层面的取舍、实操中踩过的坑、以及一些还算靠谱的优化思路,全部摊开来讲。

先说清楚一个基础问题:存算分离到底分离的是什么。

传统的Hadoop大数据架构,DataNode是既存又算的。数据存在本地磁盘上,计算任务调度到数据所在的节点上执行,这就是所谓的“数据本地性”(Data Locality)。这种设计的核心逻辑是——移动计算比移动数据便宜。你把计算任务推到数据身边,就不用把几个TB的数据在网络上搬来搬去了,IO开销最小。

但存算分离不一样。存算分离把存储层独立出去,通常用对象存储(比如云上的对象存储服务,或者开源的MinIO、Ceph)作为统一的数据底座。计算引擎(Spark、Flink、Trino这些)变成无状态的资源池,按需拉起、用完释放。数据从远端存储读取,计算节点本地只留临时缓存。

这么做好处很明显:存储和计算可以独立扩容。存储不够了扩存储,计算不够了扩计算,不用像以前那样为了扩存储被迫买了一堆CPU。计算引擎可以做到极致弹性,业务低谷时缩到零,节省成本。多个计算集群可以共享同一份数据,不用像HDFS时代那样每个集群复制一份。看起来确实很美。

但问题也恰恰出在这些“美”上。当你真的把数据放到远端,把计算变成无状态之后,一系列从前不需要面对的技术瓶颈就会浮出水面。下面我从四个维度来拆解。

2. 技术瓶颈拆解:四个绕不过去的硬骨头

2.1 网络IO:从本地磁盘到万兆网,延迟和带宽的天花板

存算分离第一个要面对的硬骨头就是网络。

以前在HDFS上跑任务,数据就在本地磁盘上,读取一个数据块的开销大概是毫秒级别,走的是SATA或者NVMe总线,几乎不存在网络延迟的问题。现在数据放到远端对象存储上,每次读取都要走网络。哪怕是内网,万兆网卡的理论带宽也就10Gbps,折合每秒1.25GB左右的传输能力。而一块普通NVMe固态盘的顺序读速度轻松跑到2GB/s甚至更高。也就是说,单一节点的数据读取带宽直接砍半。

更麻烦的是延迟。对象存储的API走的是HTT协议,每次请求都有建连、鉴权、寻址的过程。本地磁盘读一个4KB的小文件可能只要几十微秒,而从对象存储上读同样大小的小文件,网络来回延迟轻松上百毫秒,差了三个数量级。

我见过一个比较典型的案例:某团队用Spark跑一个数仓ETL任务,数据量其实不大,大概只有几百GB。但任务里涉及大量的维度表关联,这些维度表被切成成千上万个小文件存放在对象存储上。结果任务跑起来之后,大部分时间都花在等待网络返回上,CPU利用率连20%都不到。整个任务跑了将近两个小时,同样的数据量在HDFS上只用四十分钟。

这就是IO模型变化带来的直接冲击。数据本地性没了,计算节点访问数据的方式从“本地读盘”变成了“远端拉取”,响应速度和带宽都受制于网络基础设施。所以在做存算分离架构规划的时候,第一件事就是认真评估你的网络环境。千兆网就不要想了,万兆是底线,如果条件允许,25G甚至100G内网才是比较从容的配置。别想着用公网跑存算分离,那基本是灾难。

网络层面的第二个问题是拥塞和抖动。多个计算节点同时并发读取大量数据时,网络交换机可能成为瓶颈。尤其是当任务集中在某个时间段爆发式运行时,瞬时流量会把网络打满,产生延迟抖动,拖慢整个作业。这在大规模的存算分离集群里是非常常见的现象——日常跑着没问题,一到月底报表高峰期就开始卡顿。

2.2 小文件与元数据:对象存储的“冷启动地狱”

存算分离另一个容易踩坑的点,是小文件问题。

对象存储在设计上就是面向大文件的。它擅长处理数百MB甚至数GB级别的对象,顺序读写的吞吐量非常漂亮。但对于大量小文件,情况就完全不同了。每个小文件在对象存储里都是一个独立对象,每个对象的访问都要经历完整的HTTP请求生命周期——DNS解析、TCP建连、TLS握手、发送请求、等待响应。这里面的每一环都是有时间成本的。

更麻烦的是元数据操作。你在对象存储里列出一个目录下的所有文件,其实就是调用一次ListObjects接口。但当文件数量达到百万级别时,List操作会非常慢,因为对象存储的元数据索引通常是按照键前缀来组织的,大范围的前缀扫描非常消耗资源。更别说有些对象存储对List接口有速率限制,每秒只能处理几百次请求。

在大数据场景下,这个问题会被进一步放大。比如Hive表的分区目录结构是/table/dt=20240101/part-00001.parquet,如果分区多、文件多,Spark在启动stage的时候需要遍历大量目录来获取文件列表。这个阶段叫“文件枚举”,在HDFS上可能只需要几秒钟,但在对象存储上,如果文件数量到了十万级别,文件枚举可能就要花好几分钟。整个作业还没开始跑,就已经慢了一截。

我有个建议:如果决定走存算分离路线,文件数量必须作为一等公民来管控。Hive或者Spark写数据的时候,尽量用动态分区合并,把输出文件数量控制在合理范围内。比如一个GB级别的小表,就不要让它输出成几千个小文件了,配置一下coalesce或者repartition,把输出文件压到几个大文件,效果立竿见影。

另一个缓解措施是启用元数据缓存服务。像Alluxio或者JuiceFS这类方案,本质上就是在计算层和存储层之间加了一层缓存和元数据加速。文件列表和目录结构可以缓存在本地或分布式缓存里,避免每次作业启动都去打满对象存储的元数据接口。

2.3 数据本地性丧失:缓存层是不是必备品

存算分离之后,数据本地性从“必选项”变成了“可选项”。计算节点本地没有数据了,每次计算都要去远端读。于是缓存层成了整个架构里最微妙的存在。

有团队觉得,既然数据在对象存储上,那我直接在计算节点上挂一块本地SSD做缓存不就行了,常见的方案就是Alluxio。逻辑上是对的:热数据在本地缓存命中,冷数据才去远端拉取,这样既保留了弹性,又能享受本地读的快感。

但缓存的维护成本并不低。首先要考虑缓存一致性——对象存储上的数据如果被别的集群改了,本地缓存怎么感知?Alluxio的默认策略是定期检查元数据变更,但检查本身也有延迟。如果你的业务场景是多个集群并发写同一份数据,缓存的一致性维护就是一场噩梦。其次要考虑缓存命中率。分析类业务的访问模式通常比较分散,如果缓存空间不够大,命中率可能只有百分之三四十,这时候缓存层反而成了负担——多了一层转发逻辑,数据没省多少,延迟倒是增加了。

举个例子:某团队部署了一套Alluxio作为Spark和对象存储之间的加速层,缓存盘用的是每节点4TB的NVMe固态。上线初期跑得很欢,热数据命中率一度达到70%。但运行了一段时间之后发现,集群的作业模式发生变化,原来集中的热数据变成了分散的扫描式访问,缓存命中率掉到了30%以下。结果Spark任务的耗时甚至比不用缓存还慢,因为Alluxio的Worker节点本身也消耗CPU和内存资源。

所以我的看法是:缓存层不是存算分离的必需品,而是业务模式匹配时的增强组件。如果你的查询模式有明显的热点(比如报表场景反复读取最近几天的分区),缓存能带来非常可观的加速效果。但如果你的任务是全表扫描型的分析作业,那缓存的意义就很有限了,不如把资源直接用在大规模并行拉取上。

比较务实的路线是先用对象存储自带的能力(比如云厂商的加速层、或者对象存储的带宽预留)撑住基础性能,等到了需要进一步压榨性能的阶段,再考虑引入独立的缓存层。不要一上来就把整个链路搞得很复杂,分布式系统的复杂度是逐步叠加出来的,每加一层都要付出运维代价。

2.4 计算弹性与任务调度的矛盾

存算分离让人觉得最香的一点,就是计算资源的弹性伸缩。业务低谷时缩容,业务高峰时扩容,按需付费。听起来非常理想,但实际操作中,计算弹性和大数据任务调度之间存在不少冲突。

第一个问题:Spark或者Flink这类引擎的作业启动是有开销的。Executor要拉起、要注册、要拉取任务元数据,这个过程通常需要几十秒到几分钟不等。如果集群是从零开始扩容的,冷启动时间会更长——容器要调度、镜像要拉取、JVM要初始化。所以你在业务高峰时扩起来的计算资源,可能前几分钟都在空转。

弹性伸缩需要和调度器紧密配合,比如Kubernetes上的Spark Operator可以根据队列长度自动扩容工作节点。但自动扩容的触发条件怎么设?设得太激进,任务一来就扩一堆节点,结果任务五分钟后结束了,扩出来的节点白白计费。设得太保守,任务排队等待资源的时间变长,SLA保不住。这里面的调优,本质上是在成本和延迟之间找平衡。

第二个问题:集群伸缩导致的任务分布不均。存算分离场景下,任务调度到哪个节点其实无所谓,因为数据都在远端,每个节点的访问成本是一样的。但如果你加了缓存层,情况就变了——缓存是分布在不同节点上的,如果任务被调度到了缓存不命中的节点,命中率就会下降。所以有些团队在调度层面做了缓存亲和性绑定,尽量把任务调度到持有对应缓存分片的节点上。但这种绑定机制和弹性伸缩又形成矛盾:节点频繁扩缩容的情况下,缓存分布也会被打散。

之前有位同行和我聊过,他们团队为了同时兼顾弹性和缓存的亲和性,最终放弃了自动伸缩,改成每天固定时间手动扩缩容一次,按照业务日报和夜报的节奏来配置集群大小。虽然弹性差了一些,但整个系统的稳定性和性能表现反而更好了。

所以计算弹性这件事,虽然很诱人,但它不是银弹。你得结合自己的业务节奏,想清楚到底需要多“弹”,以及能不能接受弹起来之后的各种副作用。

3. 实操环节:从HDFS迁移到对象存储的完整路径

3.1 迁移前的数据评估和存储规划

如果你决定了要走存算分离的路线,第一步不是去配集群,而是先把数据摸底做了。

我会建议你先统计一下当前HDFS上的数据量和文件分布情况。重点看几个指标:总存储量、平均文件大小、文件数量、小文件占比(比如小于128MB的文件有多少)、数据更新时间频度、表的访问热度分区。这些数据直接决定了你在存算分离架构里需要做哪些适配。

比如文件数量这个指标,如果集群里有几千万个小文件,不治理直接迁到对象存储,那基本等于把一堆难题打包转移到新的环境里。迁移前先做一轮小文件合并是值得的。常见的做法是利用Spark或者Hive跑一次轻量级的重写作业,把同一分区下的小文件合并成更大的文件,目标文件尽量控制在256MB到1GB之间。

然后要规划存储桶的结构。对象存储的桶(Bucket)规划和大数据目录设计是两套逻辑。数据湖的方案通常建议一套存储底座对应一个湖,按业务域划分目录前缀,比如s3://data-lake/ods/、s3://data-lake/dwd/、s3://data-lake/ads/,上层计算引擎统一挂载这个基础路径。这里有一个注意点:对象的命名前缀会影响元数据索引的效率,尽量让前缀分布均匀,避免所有文件都塞在同一个前缀下,造成目类容量热点。

另外一定要设置好生命周期策略。对象存储的成本是阶梯的,热数据、温数据、冷数据的价格差异很大。结合业务访问规律设置自动转冷和过期清理规则,能在长期运行中省下一大笔费用。比如数仓的ODS层临时表可以保留30天后自动转冷,超过90天自动清理,这些规则在HDFS时代全得靠人工脚本,在对象存储上是平台能力,不用白不用。

3.2 计算引擎的适配配置

数据迁到对象存储之后,计算引擎的配置需要针对远程IO做一轮专门的调优。我以Spark为例,列几个之前实践下来比较关键的配置项。

spark.hadoop.fs.s3a.fast.upload要打开,这个参数控制S3A文件系统使用多部分上传机制,写大文件时的性能会好很多。spark.sql.adaptive.enabled也要打开,自适应执行框架会动态调整shuffle分区数,减少因分区不合理导致的小文件输出问题。

比较容易被忽略的是spark.sql.files.openCostInBytes和spark.sql.files.maxPartitionBytes这两个参数。它们控制了Spark读取文件时的分片策略。在HDFS场景下,默认值还行,但在对象存储场景下,由于每个文件打开一次都要付出网络开销,把openCostInBytes调大一些,比如从默认的4MB调整到64MB甚至128MB,可以减少任务打开文件的次数,让每个分区读更大的数据块,整体性能会有明显改善。

还有一个重点是针对对象存储的并发控制。Spark读取对象存储时,默认每个分区一个连接去拉数据。如果分区数很多,并发请求数就会很高,可能触发对象存储的限流机制,导致请求被拒或者延迟增大。这时候需要合理控制spark.sql.shuffle.partitions和文件读取的并行度,避免出现太多细碎的小任务。根据我自己的经验,单节点并发拉取对象存储数据的连接数控制在10到20之间,是比较稳妥的范围。

Flink的适配思路类似,重点在checkpoint的存储路径上。Flink的checkpoint原本默认写在HDFS上,迁移之后要改成对象存储兼容路径。有一点需要特别注意:对象存储的写入是最终一致性的,而Flink的checkpoint机制对一致性要求很高。所以要选择支持强一致性的对象存储服务,或者启用对象存储的事务性写能力。这个细节如果不处理好,会出现状态恢复时读到不完整数据的情况,非常隐蔽。

3.3 验证和灰度上线

迁移上线不要搞“决战式”的一次性切换。稳妥的做法是先选一两个核心表或者一个非核心业务域,做小流量的灰度迁移。

先迁数据到对象存储,保留HDFS上的原数据作为对照,然后用计算引擎分别跑同一批查询,对比耗时、资源利用率和结果一致性。这个阶段能暴露问题,而且因为原数据还在,相当于有个随时可回退的兜底方案。

等灰度稳定了,再逐步扩大迁移范围。整个过程中建议把元数据迁移放在数据迁移之后。Hive的元数据(表结构、分区信息)本身可以保留在Hive Metastore里不变,只需要改表的位置信息(Location)指向到新的对象存储路径。这一步操作要非常谨慎,改之前一定备份Metastore数据库,改完之后立刻抽查几张表的读写链路是否正常。

3.4 监控和成本追踪

上线之后,监控是唯一能让你安心的东西。存算分离架构需要重点监控的指标包括:对象存储的请求量(读请求QPS、写请求TPS)、网络出入带宽、平均请求延迟、缓存命中率(如果用了缓存层)、计算集群的资源利用率、数据扫描量。尤其是“数据扫描量”这个指标——对象存储是按量计费的,数据扫描量直接关联成本。很多团队存算分离之后成本不降反升,就是因为在监控缺失的情况下,任务频繁地全表扫描,把网络和存储的账单打爆了。

成本追踪要做到按业务线分摊。对象存储的成本构成是存储容量+请求次数+数据扫描量+流量费,几项叠加起来,必须能在任务级别拆分清楚。比如给每个业务队列打上任务标签,日志里记录每个任务的对象存储读取字节数和请求次数,月底汇总对账。这一步做得好,后续优化才有数据支撑,否则就是一笔糊涂账。

4. 性能优化的进阶策略

4.1 文件格式与压缩算法的重新选择

在HDFS时代,数据格式的选择往往比较随意。Parquet、ORC、Avro都有各自的使用场景,但存算分离之后,文件格式的选择对性能的影响会被放大。

一个很容易理解的逻辑:对象存储是按扫描的数据量计费的。文件格式的压缩率越高、列式存储越紧凑,扫描时读出来的字节数就越少,网络耗时和费用都会降低。所以强烈建议统一使用Parquet格式,并且开启列式压缩。Parquet在列存、谓词下推、压缩率方面都表现得很均衡,是大数据存算分离场景下的一个比较稳妥的默认选择。

压缩算法的选择也是同理。Gzip的压缩率高但解压慢;Snappy快但压缩率一般;Zstd是近几年的新选择,压缩率和速度都比较理想。我自己的经验是:ODS层全量数据用Zstd,能省存储也能省扫描量;ADS层的结果集数据量小,追求查询快,用Snappy加上列裁剪就够了。压缩算法的选择没有银弹,建议拿自己的真实数据做一个压缩率和解压性能的对比小实验,用数据说话。

4.2 数据布局与分区裁剪的配合

存算分离架构下,分区裁剪的重要性比HDFS时代还要高。原因不用多解释——每次扫描都直接变成钱和时间的消耗。所以表的生命周期管理、分区粒度设计、以及查询语句的编写习惯,都要围绕“尽量少读数据”这个原则来推进。

分区粒度的设计要注意一个度。分区太细(比如按小时分区),会导致文件数量过多,元数据压力大;分区太粗(比如按年分区),查询时无法有效裁剪数据,扫描量大。一般来说,按天分区是数仓场景的标准做法。如果业务查询经常跨天统计,可以再增加一个按月或者按周的分区字段辅助裁剪,但不要搞太多层级,否则路径过长也会拖累别列操作的效率。

给一个实际的优化案例:我们之前有个订单明细表,按天分区,每天的数据量大约1TB。有一次某同事写报表SQL,查询条件里没有带分区字段,直接全表扫了90天的数据,整个查询跑了将近40分钟。后来优化了一下SQL,加上dt BETWEEN条件,数据扫描量缩小到原来的三十分之一,查询时间降到3分钟以内。这个例子听起来很简单,但在大规模集群上,类似的“扫全表”问题每天都会出现。所以任务级别最好搞一个扫描量阈值告警,一旦某个查询扫描的数据量超过设定值就报警,及时干预。

4.3 计算引擎本身的优化空间

存算分离之后,计算引擎自身的优化方向也会发生变化。以前Spark调优的重点是磁盘IO和DataNode的并发,现在重点是网络调度和连接池管理。

一个可以尝试的方向是使用Spark的“Staging”机制,把频繁访问的小维表预先物化到本地缓存目录,避免每个Task都去对象存储拉取。这和缓存层的思路一致,但粒度更细,不需要引入额外的组件。

另一个方向是可以考虑引入向量化执行引擎。像Gluten(把原生Spark的执行引擎替换为Velox或ClickHouse的向量化引擎)这类方案,在纯CPU密集型的计算中能带来显著加速。存算分离之后,CPU计算和IO等待的比例发生了变化,压缩和序列化的开销占比更高,向量化执行能够让单节点的计算吞吐量翻倍,间接减少对网络IO的依赖。

但这些优化手段都有各自的复杂度和运维成本。我的建议是先用比较基础的优化把性能吃到80分,然后再针对真正的性能热点做专项优化。不要一上来就上重型武器,否则你可能会花大量时间在调试分布式问题而不是优化业务上。

5. 常见问题与排查技巧实录

5.1 任务频繁报错“Connection timed out”

这应该是存算分离场景下最常见的报错之一。排查时先分几个层面来看:是单点超时还是大面积超时?如果是单点,大概率是某个计算节点到对象存储的网络链路有问题,可能是安全组规则、防火墙或者交换机端口异常。如果是大面积超时,那就是网络带宽或者对象存储的并发连接数被撑满了。

解决办法:先检查监控面板上的网络流量是否达到了交换机或者网卡的上限;再检查对象存储侧的限流配额是否被触发。如果确实跑满了,就不要硬扛了,降低任务并行度,或者把任务拆到多个时间段错峰执行。有时候解决方案就是这么朴素。

5.2 小文件合并后任务反而变慢

有团队反馈过一个问题:把几万个小文件合并成几百个大文件之后,某些查询反而变慢了。原因在于合并后的单个Parquet文件过大,Spark读取文件时的默认分片逻辑(按文件大小除以maxPartitionBytes)会生成数量过多的分区,分区之间的数据倾斜和执行开销反而更大了。

这个问题需要综合看待。文件变大不是问题,问题是分区数要跟着调。合并文件后,检查一下spark.sql.files.maxPartitionBytes是否需要调大,让单个分区处理更大的数据块。比如说原来文件都是128MB,现在合并成1GB的文件,maxPartitionBytes也相应调大,分片逻辑才能保持合理。

5.3 数据加载到缓存后,查询一直读到旧数据

如果你用了类似Alluxio的缓存层,这个问题发生的概率不低。排查时先确认数据修改的时间点和缓存失效策略。Alluxio默认的TTL是可以配置的,如果数据更新的频率比TTL短,就可能读到过期数据。

另外一个比较隐蔽的原因是:多个计算集群共享同一个对象存储底座,集群A改写了数据,集群B的缓存没有感知。这种情况下,建议开启对象存储的事件通知功能,数据变更时向缓存层发送失效消息。如果平台能力不支持,那就只能牺牲一些性能,把缓存策略调整得更保守一些。

5.4 中小团队要不要做存算分离

最后想聊一个很多团队会纠结的问题:我们团队就二三十个人,数据量也不大,到底要不要追存算分离这波趋势。

我的建议是:存算分离不是银弹,中型以下团队如果现有HDFS架构跑得好好的,就没必要为了追概念去折腾。存算分离的优势主要体现在大规模场景下,存储独立扩容、计算按需付费、多集群共享数据这些收益,在集群规模不到一定程度之前并不明显。

如果你想尝试验证存算分离的思路,可以先从拆分一个业务域开始。比如把用户行为日志的数据迁到对象存储,用一套独立的Trino集群去查询,和生产HDFS集群隔离开来。这样既不影响主业,又能积累存算分离的实操经验。等踩完这一轮坑,再判断是否值得全量迁移。我个人是觉得,这种灰度验证的心态,比什么都好。

6. 写在最后的一点个人体会

做了这几年大数据基础设施,我慢慢发现一个规律:技术方案的选型,很少是纯粹的技术问题,更多的其实是业务匹配度和团队运维能力的综合考量。存算分离能带来弹性、省成本、统一存储这些好处,但它对网络基础设施、监控体系、文件治理的要求,比传统HDFS架构高出一大截。

如果团队连基础的网络监控和成本分摊都没做好,那就先别急着迁移,先把这些基础设施补上再说。数据领域的工程化能力,靠的是把每一个细节都做扎实。存算分离这场大迁徙,跑得快不算本事,跑得稳、能持续运行才叫真功夫。希望这篇文章里的经验,能让你在规划存算分离方案的时候少走一些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询