做大数据这些年,最绕不开的一个词就是“存算分离”。早年在机房里搭 Hadoop 集群,一边调 Spark 参数一边被 HDFS 的磁盘占用卡脖子,扩容只能计算节点和存储节点一起加,数据本地性一失衡,任务跑得跟蜗牛一样。后来在云上把存储换成了对象存储,计算集群做成无状态,才真正体会到什么叫“存储归存储、计算归计算”。这篇文章我就结合自己折腾过的架构、跳过的坑,把大数据存算分离的技术趋势掰开揉碎聊一遍,重点讲清楚它解决什么问题、有哪些落地选型、迁移时怎么做,以及最容易让人翻车的几个细节。
1. 存算分离:从“绑定部署”到“按需组合”
1.1 传统存算一体架构的痛点在哪
早期大数据框架比如 Hadoop,天然把计算和存储绑定在一个集群里。HDFS 负责存数据,MapReduce 或 Spark 的 Executor 跑在 DataNode 上,任务调度时尽量“数据本地性优先”,也就是把计算搬到持有数据的节点上,减少网络传输。这套设计在机器规模不大、以内网为主的时代非常合理,因为磁盘 IO 远比网络 IO 快,数据不挪窝效率最高。
但业务一大,问题就来了。数据增长要求扩容存储,可存储扩容必然要加计算节点,否则数据块副本数、负载均衡都会出问题。反过来,某次大促或定时报表任务需要临时增加算力,你却发现节点加多了之后数据还没均匀分布,新节点的计算资源白白闲置。更难受的是,HDFS 的三副本机制直接把存储成本抬高三倍,冷数据也得占三份空间,预算吃紧时看着磁盘监控就头疼。
另一个隐藏问题是计算集群的“有状态”属性。HDFS 的 NameNode 负责元数据,DataNode 承担数据读写,节点挂了会影响数据可用性;Spark 或 Flink 的中间结果如果落到本地磁盘,任务重试时要重新算。云时代大家习惯了故障转移和弹性伸缩,这种“绑定部署”的模式就显得笨重,而存算分离正好把这个问题拆解开。
1.2 存算分离的本质是什么
存算分离的核心思路很简单:把数据持久化放到一个独立、可靠、弹性伸缩的存储系统里,计算集群只负责跑任务,不长期保存业务数据。计算资源可以按需创建,用完释放;存储资源则独立扩展,数据由存储系统保证多副本或纠删码冗余。
这套架构里,计算和存储之间通过高速网络连接。任务跑起来后,计算节点直接向存储系统发起远端读取,不再依赖节点本地磁盘。听起来性能会变差,对吧?但实际上现代对象存储和分布式文件系统的吞吐能力已经很强,再加上缓存层、列式文件格式、预取优化,完全可以把远端的访问延迟隐藏掉,代价就是网络带宽和缓存设计要做足功夫。
存算分离的本质其实是一种“状态转移”。计算集群退出后,所有状态都保存在存储系统里;下次启动时,计算集群可以从零开始装入数据,不再需要漫长的数据副本同步。这让计算集群变成了真正的“无状态资源池”,秒级启动、随时销毁,弹性伸缩才成为可能。
1.3 为什么这几年它突然成了趋势
这一趋势的爆发,我觉得有三个决定性因素。
第一是云计算基础设施的普及。对象存储(比如 AWS S3、阿里云 OSS)的价格不断降低,带宽和每秒请求数(QPS)越来越高,还提供跨区冗余、生命周期管理、冷热分层这些能力。企业上云后,数据放在对象存储里几乎是零成本起步,天然适合做中央存储。
第二是大数据计算引擎的适配成熟。Spark 从 2.x 开始就支持直接读写 S3、OSS 等对象存储,Hive 也能通过外部表访问,Flink 在流批一体场景下同样把存储抽象成可插拔的 FileSystem。计算框架不再依赖 HDFS 提供文件系统语义,而是能用对象存储作为原始数据与中间结果的位置。
第三是数据湖技术逐步落地。Hudi、Iceberg、Delta Lake 这类表格式管理工具,把 ACID 事务、快照隔离、增量读取带到了对象存储之上,解决了“多引擎同时写同一张表”的冲突问题。存算分离 + 数据湖,让数据仓库和数据科学既可以共用一个数据底座,又不会互相锁死。正是这三点叠加,让存算分离从“少数大厂的玩物”变成了普通团队也可以认真考虑的架构方案。
2. 存算分离的几种主流形态与选型判断
2.1 存储侧:对象存储、分布式文件存储和 HDFS 的取舍
谈到存算分离的具体选型,首先要确定“分离到哪去”。目前主流有三大类。
对象存储是最常见的落点。S3、OSS、MinIO 这类系统主打海量存储、按需计费、无限扩展,支持 HTTP 协议和简单的目录结构(实际上是扁平 key)。对象存储的读写在吞吐量上很猛,但单请求延迟比本地磁盘高好几个数量级,元数据操作(比如列目录)比较慢。适合以批处理为主、对延迟不敏感的场景,比如离线数仓、日志分析、机器学习样本存储。
分布式文件存储则是另一个方向,代表是 JuiceFS 或者基于 Ceph 的 RGW。这类系统通常提供 POSIX 或近 POSIX 接口,兼容 HDFS 语义,同时把数据切块后放到对象存储中,元数据单独维护。它们既保留了“像 HDFS 一样使用”的体验,又具备对象存储的无限容量和成本优势。比如 JuiceFS 会把文件切碎成固定大小的块,上传到对象存储,元数据存到数据库或 KV 引擎,客户端本地再缓存热数据。对于 Hadoop 老用户来说,这类方案的迁移成本最低。
HDFS 本身也不一定完全退出。很多团队采用“分级存储”:热分区仍放 HDFS,冷数据和归档数据迁移到对象存储,计算节点仍和 HDFS 共存,但业务主体数据已经可以脱离计算节点。这种折中模式适合还没有全面上云、又不想一次性大改的团队。
我这边更推荐中小团队直接选对象存储 + 合适的数据湖表格式,因为运维成本低,弹性最好。但有强一致性和随机写需求的话,JuiceFS 这类系统会更稳,它本身解决的就是对象存储在元数据和一致性上的短板。
2.2 计算侧:无状态集群怎么设计
存算分离后,计算集群的设计理念要彻底改变。以前是“节点越多,存储越多”,现在则是“节点越多,算力越多”,数据中心不再被数据占着地方。
无状态集群有几个明显好处。一是启动快,集群拉起时不需要加载本地数据块,只需要向存储系统拉取元数据和少量缓存;二是故障恢复简单,任务失败后重新拉起一批新节点即可,不用追数据副本;三是资源利用率高,不同团队或不同任务可以共享同一个存储池,按队列和时间申请计算资源,高峰时扩容,低峰时缩容,HDFS 的副本平衡问题基本消失。
在具体实现上,Spark、Flink 跑在 Kubernetes 上和存算分离很搭。咱们只需要把数据量信息告诉调度器,Executor 或 TaskManager 会动态申请 Pod,每个 Pod 都不需要挂载持久卷,直接通过 SDK 连对象存储。这样一来,集群的“扩”和“缩”只在秒级到分钟级完成,之前运维值班时最怕的磁盘扩容操作可以彻底丢到脑后。
不过要注意,无状态不代表没有本地盘。计算节点的本地磁盘仍然很重要,用来做 shuffle 的中间文件,以及热数据的缓存。所以节点类型通常选择“计算优化型 + 大容量本地 SSD”,而不是普通云主机。
2.3 中间层:为什么必须有数据缓存加速
很多人刚接触存算分离时最担心的问题就是:“Spark 每次读数据都从对象存储拉,会不会慢得离谱?”这个担心合理,所以几乎每一个落地可靠的方案里,都会加一个中间缓存层。
缓存层的作用是让热数据贴近计算节点。常见做法有三个层级:计算节点本地 SSD 缓存、集群内分布式缓存、独立缓存服务(比如 Alluxio、JuiceFS FUSE 缓存)。当任务反复读取同一份数据时,第一次从对象存储拉取到本地,后续直接从缓存读,性能可以接近本地文件系统。
以 Alluxio 为例,它把底层存储抽象成 namespace,应用读写 Alluxio 时,底层真正连的是 S3 或 OSS。数据块可以按 LRU 策略缓存到内存或 SSD,缓存命中率高的时候,读写带宽直接翻几倍。JuiceFS 也类似,它的客户端有本地缓存目录,可通过参数调整缓存空间,命中率上去了,线上查询 OOM 和慢任务问题能缓解不少。
选缓存方案时要关注两个关键指标:命中率和缓存预热时间。缓存不等于全部,有些报表任务每天只跑一次,每次用新分区,缓存命中率必然低,此时不如关闭缓存,直接用存储系统的预取机制。所以中间层不是必选项,要根据访问模式动态判断,盲目堆缓存反而浪费成本。
3. 从传统架构迁移到存算分离的实操路径
3.1 迁移前一定要做的三个评估
迁移不能拍脑袋,必须用数据和业务逻辑说话。
首先是容量与吞吐评估。统计现有数据总量、日增量、表数量和平均文件大小。如果一张大表每天新增 10 亿行,但文件是几 KB 级别的小文件,直接搬到对象存储会出现大量 HTTP 请求,既慢又贵。这种情况要先做文件合并,转换成列式格式,并重新分区。
其次是网络带宽评估。存储和计算之间跨机房跨 VPC,带宽和延迟都是直接影响因素。通常需要计算高峰期并发查询时需要的理论吞吐,公式大概是这样:并发数 × 平均单任务读数据量 ÷ 预期 SLA 时间,然后再留出 30% 的冗余。例如一个 SQL 任务要读 100GB,要求 5 分钟跑完,那么至少需要 100GB / 300s ≈ 340MB/s 的持续吞吐,这已经压到万兆网卡的流量上限了。
最后是成本模型评估。传统 HDFS 的成本包括磁盘成本、机房机位成本、计算节点 CPU 内存成本、运维人力成本。存算分离后的成本则包括存储费用、网络流量费用、请求次数费用、计算实例费用、缓存空间费用。很多存储产品会额外按读请求和写请求收费,有时候“记得没读多少数据,结果请求费用特别高”,就是因为文件太碎或查询模式太随机。
3.2 数据怎么搬:文件布局、格式和分区策略
数据迁移推荐用“离线批量 + 增量同步”双轨制。离线批量先从 HDFS 把历史数据导出为 Parquet / ORC 格式,按日期或业务维度分区,写入对象存储,目标路径建议做成分层的标准化结构,比如:
s3://data-lake/warehouse/ods/order/dt=2025-01-01/ s3://data-lake/warehouse/dwd/order_detail/dt=2025-01-01/这样做的好处是方便后续按分区做增量识别,也让 Hive 或 Spark 的分区裁剪能够直接命中。文件大小也很有讲究,对象存储对单文件最大大小有限制,但更关键的是要避免太多小文件。我一般控制单文件在 128MB 到 512MB 之间,Parquet 块大小与 HDFS block 对齐,既能提升并行度,又能减少请求次数。
增量同步可以用 Flink CDC 或定时的 Spark 批任务,把业务库的变更记录写入列式表。需要特别注意,增量任务运行频率不能太高,否则小文件问题会重新出现。建议至少累计一小时后刷一次,或者用 Hudi/Iceberg 的 compaction 功能自动合并小文件。
3.3 计算任务适配:以 Spark 读写 OSS/S3 为例
Spark 连接对象存储的核心是通过 Hadoop FileSystem API 适配器。拿阿里云 OSS 举例,在 Spark 配置里最关键的几项如下:
<property> <name>fs.oss.impl</name> <value>org.apache.hadoop.fs.aliyun.oss.AliyunOSSFileSystem</value> </property> <property> <name>fs.oss.accessKeyId</name> <value>your-ak</value> </property> <property> <name>fs.oss.accessKeySecret</name> <value>your-sk</value> </property> <property> <name>fs.oss.endpoint</name> <value>oss-cn-hangzhou.aliyuncs.com</value> </property> <property> <name>fs.oss.connection.maximum</name> <value>200</value> </property>如果容器环境有权限扮演角色,最好用临时凭证,不要写死 accessKey。连接池数量要根据 Executor 数量调整:比如 100 个 Executor,并发 16 个线程,连接池大小设置在 200~300 之间比较合适,太小会互相排队,太大会占用过多本地 socket。
任务代码里尽量使用 DataFrame API,避免 RDD 里面有太多 FileSystem 调用。Spark 引擎已经对列式读取做了高度优化,直接走 DataFrameReader 就可以:
val df = spark.read.parquet("s3://data-lake/warehouse/ods/order/dt=2025-01-01")底层可能会产生成百上千个小请求,但 Spark 会通过批量提交和预取来减少往返,前提是你不要手动加repartition(1)这种强行打散 / 汇聚的操作。
3.4 数据湖与事务:Hudi/Iceberg 让存算分离更可靠
存算分离后,最怕的是多任务并发写同一张表时互相覆盖。传统 Hive 表没有事务保护,两个 Spark 任务同时写一个分区,大概率出现脏数据。这时就需要 Hudi、Iceberg 或 Delta Lake 这类数据湖表格式。
以 Iceberg 为例,它引入了“元数据文件和 manifest 文件”的概念。表的每次提交都会生成新的元数据快照,读任务通过快照隔离看到的是某个时间点的完整数据,写任务之间则由乐观并发控制避免冲突。这样多个引擎(Spark、Flink、Trino)可以在同一份存储数据上做读写,不会把表搞坏。
落地时,建表语句变为类似这样:
CREATE TABLE iceberg_db.orders ( order_id BIGINT, user_id BIGINT, amount DECIMAL(10,2), ts TIMESTAMP ) USING iceberg PARTITIONED BY (days(ts)) LOCATION 's3://data-lake/warehouse/icberg_orders'日常使用中,Hudi 的 mor 表在合并压缩时会有写放大,Iceberg 的 copy-on-write 则会影响写放大但读速快。选型没有绝对标准,主要看团队熟悉度和实时更新频次。但无论选哪个,都在存算分离架构里填补了最关键的一致性缺口。
4. 存算分离实践中的高频问题与排错经验
4.1 查询慢、任务卡住,先查网络与并发限制
我遇到过很多次的线上故障都和网络有关。对象存储一般对每个桶或每个账号都有 QPS 限制,某个突发任务疯狂发起小请求时,超出配额后就会间歇性失败,表现就是 Spark 任务卡在某个 Stage 一直 fetch 失败。
解决办法首先是调整 Spark 动态资源策略,增加 Executor 数量但降低每个 Executor 的并发线程。另外要检查是不是本地读取太多导致网络争抢:如果多个任务同时跑,建议错峰执行或者给重要任务设置资源队列优先级。最隐藏的一个点是,很多对象存储 SDK 默认连接超时时间只有 10~20 秒,大文件读取时如果链路抖动,很容易触发超时重试。可以调大fs.oss.timeout这类参数到 60 秒以上,同时开启重试机制。
4.2 数据“看见”慢,元数据和一致性的坑
存算分离后,数据从写入到可查询通常会有一点延迟,这个大多不是因为存储慢,而是元数据或缓存的问题。Hive Metastore 依然承担表结构、分区位置的元数据,如果分区数很多,查询时拉取分区列表也会变慢,需要做好分区粒度的平衡,不能过度细分。
对象存储本身有一致性问题,尤其是旧版 S3 对“先列出再读取”的列表一致性有延迟。好在现代各大云厂商都已经优化到强一致或至少最终一致,但写后立刻读还是可能拿到旧文件。最好的规避使用是靠数据湖表格式的快照机制,每次通过元数据判断可读文件列表,而不是直接扫描桶路径。如果你只用原生 Hive 外部表,那么写任务进行中产生的临时文件要用固定的.tmp前缀标记,任务完成后 rename 到正式路径,避免中间产物被读到。
4.3 成本核算陷阱:为什么系统慢但账单还贵
之前一个项目做存算分离后,存储账单下降了很多,但网络流量费用涨得厉害,当时差点以为算错了。细查才发现,问题出在 shuffle 数据也走了对象存储。Spark 的 Shuffle 默认写到本地磁盘,但在某些“无状态”配置下,有人会把中间临时目录也指到对象存储,结果一个 join 就产生几 TB 流量。
正确做法是:shuffle 和临时数据必须留在本地磁盘,永远不要放对象存储。当你用 Kubernetes 原生部署时,为每个 Pod 申请 ephemeral-storage 来承载 shuffle,任务结束自动释放。如果集群有 Spark Dynamic Allocation,还要注意 Executor 频繁退出后重新读数据的流量,尽量复用同一个缓存节点或利用外部 shuffle service。
另外还要警惕小文件带来请求费用。对象存储通常按请求次数计费,一个 1MB 的小文件和 1GB 的大文件,单次请求收费差不多,但读取同样总量时小文件的请求次数要多出 1000 倍。把数据合并到至少 64MB 以上,请求费用和查询速度都能改善。
4.4 面试时如何把存算分离讲清楚
存算分离也是大数据面试里的高频题目,面试官其实不是想听你背概念,而是想判断你在架构选型上有没有真正的权衡能力。我建议从三个点展开:一是什么问题需要存算分离,重点说 HDFS 扩容耦合和资源利用率;二是拆分后的新瓶颈,比如网络带宽、元数据、缓存、成本模型;三是为什么对象存储 + 数据湖成为主流,举一个具体的迁移案例说明。
还可以补充一个细节:存算分离并不代表 HDFS 完全没有价值。在没有云化压力的机房里,小规模数据量下 HDFS 的稳定性和性能依旧能打,关键指标是每 TB 存储成本、每 GCU 算力和运维复杂度。能把这些指标摆清楚,就算过了这题。
我个人在实际操作中体会到,存算分离最多的工作量其实不在迁移本身,而在于对数据访问模式的重构。以前 HDFS 时代可以无视大量重复读,因为数据在本地;分离后就要时刻考虑:哪些数据是热数据、哪些可以离线预取、缓存空间给多大、请求频率如何控制。每踩一个坑都对应着架构上新的取舍。如果你正在做迁移,我的建议是先把最核心的两张表搬到对象存储,跑通性能数据和成本账单,再全局铺开。这套架构带来的弹性收益非常可观,但前提是你把网络、缓存和元数据这三个命门都看住了。