数据量一上来就扩容,扩容完发现磁盘水位没降、任务还是排队,大家坐在会议室里面面相觑——这是某项目X负责人A同学跟我复盘时说的话,也是我过去一年里听到次数最多的一句话。存算分离这个词被反复提起,正是因为它试图用一种更彻底的思路解决这类问题:把存储和计算从物理绑定中拆开,让各自按需伸缩、自由组合。这篇文章我想把存算分离与传统架构的差异完整掰开,不光是概念层面的对比,还有真金白银的账、踩过坑的细节点,以及一套可以直接抄走的选型判断规则。
内容适合三类人:正在纠结要不要做架构改造的大数据团队,准备写技术方案但缺乏对比素材的同学,以及想了解存算分离落地后真实面目的决策者。整篇我会结合一个模拟项目X的迁移实测数据,讲清楚两种架构的底层逻辑差异,也会直说那些平时不太写进汇报的问题。
1. 一次扩容事故背后的架构困境
1.1 从“扩容升配”说起
A同学所在的某业务团队,跑着一个日增数据约20TB的离线数仓,底下一套由几十台裸金属机组成的集群。半年前某个大促活动上线后,数据量突然涨了一倍多,凌晨的跑批任务从原来的3小时拉长到9小时,业务方天天追问报表为什么没按时出。团队第一反应是扩容——这是传统架构下最本能的操作。采购了大批新机器,走流程、上架、装系统、加节点,前后花了快两周,投入进去之后,问题确实缓解了一些,但没过多久又打回原样。
为什么扩容治标不治本?因为传统架构里,计算和存储是绑在同一个节点上的。数据以block的形式分布在各个节点的本地磁盘上,任务调度时优先把计算调度到数据所在的节点,也就是所谓的本地性(locality)。新扩容的节点,磁盘是空的,没有数据分布,调度器不会优先把任务派给它,即便派了,也要从别的节点跨网络拉数据,效率反而更低。于是整个系统陷入一种奇特的状态:CPU和内存明明有富余,任务却跑不起来,瓶颈永远在磁盘水位和网络带宽上。
我后来帮他看了一眼集群监控,磁盘使用率已经超过88%,任务排队不是因为算力不够,而是因为能放数据的地方快满了。这就是传统架构最核心的困境:存储和计算被物理绑定,扩容任何一个维度都会被迫带上另一个维度,而数据增长速度往往远快于计算需求的增长速度,于是存储总是先爆,计算资源却在大部分时间闲置。
1.2 “数据本地性”为什么被当成金科玉律
很多从Hadoop时代过来的工程师,对“数据本地性”是有信仰的。当年大家的优化思路都围绕它展开:把数据切成block、多副本存放、元数据由NameNode管理、调度时尽量把task发到数据所在节点。这套设计在机器数量不多、网络带宽有限的年代确实是对的——跨网络拉几个TB数据和本地读相比,速度和稳定性差距太大了。
但进入云原生时代后,这个前提逐渐变了。万兆网卡、RDMA、对象存储的内网高带宽,让跨网络读数据的代价大幅下降,而机器数量增长带来的数据均衡成本、磁盘故障后的恢复成本、硬件淘汰替换成本却在持续上升。此时再去坚持“数据必须和计算在同一台机器上”,反而像抱着旧地图找新大陆——地图本身没错,但地形已经变了。
存算分离的思路很简单:数据统一放到远端共享存储上,计算层完全无状态,谁有空谁就能跑,数据不再属于任何一台机器。本地磁盘在计算节点上只是缓存,不再是持久化层。这一改,扩容就需要几分钟,而不是两周。
2. 存算分离与传统架构的底账:从四个维度算清差异
2.1 扩缩容粒度:以“天”和“分钟”为单位的差距
传统架构下,计算和存储同步扩容,需要经过采购、上架、系统配置、数据重平衡等环节,重平衡是把现有数据打散到新节点,这个过程本身就会占用大量磁盘和网络IO,有时还会拖垮正在跑的生产任务。一圈下来,少则两天,多则两周,等扩完,峰值往往已经过去了。
存算分离架构下,数据全在远端对象存储或分布式文件系统上,计算节点跑完任务后完全可以销毁或缩容。需要扩时,直接拉一批新实例,挂上相同的存储配置即可,节点的状态只有缓存数据和计算中间产物,丢掉也不影响数据安全。我们模拟项目X实测,从提交扩容申请到新节点开始承接任务,大概是7分钟左右,其中大部分时间花在镜像拉取和环境初始化上。
不过这里要泼一盆冷水:扩缩容快只代表“资源供给快”,任务能不能跑得快,仍然取决于数据扫描量、计算逻辑复杂度、以及缓存命中率。扩容速度解决的是供给弹性问题,不是性能问题。别把这两件事混为一谈,否则你会在扩完容后发现任务还是慢,然后陷入新一轮排查。
2.2 成本模型:三条命的副本和按量计费
传统HDFS架构默认三副本策略——同一份数据在三个不同节点上各存一份,防止单点故障。数据量越大,存储成本膨胀得越厉害。假设你有10PB原始数据,三副本就是30PB物理存储,其中每一天的写任务还伴随着副本同步的网络开销。即使数据极少被读取,这30PB的存储空间也得一直付费,因为它是集群的一部分,不能随意拆掉。
存算分离的存储层通常基于对象存储,默认是纠删码或者多可用区冗余,实际存储成本相当于原数据的1.1到1.5倍,相比三副本的3倍,账面上有明显优势。而且对象存储的计费模式是“按存储量+按请求次数”,算力资源单独按量计费,跑与不跑、跑多跑少,花费清晰透明。数据分析团队最怕的“凌晨两点空转的集群也在烧钱”的问题,在存算分离下基本不存在:不跑任务就不拉计算资源,自然没有计算费用。
但要注意成本模型里那个隐蔽的“请求次数”。对象存储的API调用是按次数收费的,频率再低也计费。如果你的任务频繁做小文件读写、反复List目录、不断Get元数据,请求费用可以高到让人怀疑人生。这一点后面避坑部分会专门展开。
2.3 数据一致性:从强一致到最终一致
传统HDFS里,写文件走流水线复制,写完才返回成功,目录rename是原子操作,元数据层面天然给你强一致的保证。这意味着你写完一个分区,下一个任务过来读,一定能看到完整数据。
对象存储的语义则不同,大部分对象存储产品提供的是最终一致或读写后一致,刚写入的对象“立即可读”未必靠谱,尤其是list操作和部分region的副本同步延迟场景下,可能出现“任务A刚写完,任务B立刻去读却读不到”的情况。这在离线跑批里通常无所谓,多等几秒就好,但如果你在做实时链路或者频繁的增量覆盖,就会踩到。
我们的处理方式是:所有落对象存储的数据,统一走一层数据湖表格式(比如Iceberg或Delta Lake这类),由表格式的提交机制管理数据可见性,保证在一个事务提交之前,查询端看不到半截数据。相当于在最终一致的对象存储之上,自己补了一层事务语义。这个折中方案实战下来很稳,也是我认为存算分离落地必须做的一步。
2.4 网络传输:一切问题都要过一遍IO
传统架构里,数据本地读是最快的路径,只有shuffle和跨节点交流才走网络。存算分离下,所有读操作都要从远端存储拉数据,也就是说,每一次读、每一次写、每一次shuffle落盘,都在消耗网络带宽。如果毫无规划地直接把任务切过去,你会发现集群网络被打满,任务反而比原来更慢。
针对这个问题,核心策略有两层。第一层是存储格式优化:尽量用Parquet/ORC这类列式存储格式,并开启压缩(snappy或zstd),同样一份数据,能减少过半的网络传输字节。第二层是缓存设计:给计算节点挂本地SSD或者内存做热数据缓存,同一份数据被多次读取时,第二次开始直接命中本地缓存,不再走网络。实测下来,缓存命中率能做到70%以上的话,查询时延基本能逼近甚至优于传统本地读。
另外还要留一个心眼:存算分离让跨地域读数据变得容易,但物理距离是绕不过的。如果计算节点和数据存储不在同一个可用区或地域,时延会明显上升,更别提可能产生昂贵的外网流量费。选型时最好强制要求:计算集群和存储集群必须在同一个区域内部署。
3. 一次模拟项目X的迁移实测
3.1 迁移前的状况与目标
A同学的团队最后决定做一次小范围迁移测试,把三个最核心的离线任务切到存算分离架构上跑,其余任务暂时留在原集群,新老并行,互不干扰。迁移前他们心里其实没底,担心最多的是两个点:一是查询时延会不会大幅劣化,二是对象存储写入性能能不能顶住凌晨的批量写入峰值。
迁移方案选的是业界最常见的一条路:计算引擎还是用Spark和Presto一族,数据从HDFS同步到对象存储,表结构挂在数据湖Catalog上,原先基于Hive Metastore的元数据保留,两边指向同一张表的不同存储路径。跑批脚本基本不用改,只调整了存储路径参数和认证方式。整个过程大概花了两天,其中数据处理格式转换和测试占了多数时间。
这个方案有个额外好处:回退成本低。如果实测不达标,只需要把任务重新指回原来的HDFS路径即可,不需要回滚数据。任何架构改造,如果能保留一条低成本的退路,心里的底气会完全不同。
3.2 汇总:五组核心指标的变化
我用一个月的时间窗口记录了两边的关键指标,对比结果如下表:
| 指标 | 传统架构(HDFS) | 存算分离(对象存储) | 变化情况 |
|---|---|---|---|
| 扩容耗时 | 约2周(含采购上架) | 约7分钟 | 大幅缩短 |
| 存储成本(每TB) | 约3倍原始数据量 | 约1.2倍原始数据量 | 节省约60% |
| 凌晨核心跑批耗时 | 约9小时 | 约7.2小时 | 缩短约20% |
| 即席查询P95时延 | 约700ms | 约900ms(缓存命中时约450ms) | 轻微波动 |
| 集群计算资源平均利用率 | 约43% | 约81% | 明显提升 |
跑批耗时变短,主要不是算力变强了,而是存算分离后资源可以按任务峰值配置,不需要像以前那样被存储容量绑住手脚;再加上列式格式和压缩,扫描量本身就降低了。资源利用率提升则是最直观的红利——再也没有“磁盘满了但CPU闲着”的纠结,计算集群想缩就缩、想扩就扩。
即席查询P95略变慢,集中在冷数据首次访问的场景。后来加了缓存层之后,热查询反而更快了。这个“优化前后P95从700ms变成900ms,再加缓存后变成450ms”的过程,正好印证了上一章说的:网络是瓶颈,但缓存能救回来。
3.3 没有写进汇报的三件事
第一次迁移看似顺利,但有三件事是我和A同学复盘时一致认为“必须单独记录”的。
第一件是运维习惯要改。以前HDFS出了问题,工程师会下意识去查NameNode日志、检查datanode状态;存算分离后,问题常常出在对象存储的权限策略、请求限流、跨账号访问配置上,排查思路完全不同。团队花了两周才适应新的故障定位路径,期间还有一次因为对象存储桶的权限配置错误,导致某张表所有人都读不了。
第二件是冷数据查询体验确实会变差。这点在汇报里容易被一笔带过,但对业务方来说很直观:第一次查一张很久没碰过的表,要等数据从远端拉过来,那个等待时间比在本地磁盘上读要明显。我们后来用预热任务和缓存策略缓解,才把体验拉回原来的水平。
第三件是数据权限体系需要重构。传统HDFS基于POSIX权限模型,可以细粒度控制目录和文件权限;对象存储的权限体系是基于桶、前缀和策略的,原有的一堆按目录授权的方式不能直接平移。权限梳理花的时间,比预想的多一倍。这三个坑,想上存算分离的团队最好提前有心理预期。
4. 存算分离落地时绕不开的四个坑
4.1 小文件是对象存储的头号杀手
文件系统擅长管理海量小文件,但对象存储的每个对象都对应一次独立的API调用,每次调用都有请求延迟和费用。当一张表有几万个小文件时,查询引擎光枚举文件列表就要发几万次请求,哪怕每个请求只要几十毫秒,累积起来就是几分钟的纯开销。这就是为什么很多团队把数据搬到对象存储后,发现“明明数据量没变,任务却慢了”。
对策没有捷径,就是治理小文件。两层手段同时做:写入端控制并行度,尽量生成大文件;定期跑合并任务,把小文件按分区合并到目标大小。以我们的经验,单文件大小128MB到512MB之间,对Spark扫描和对象存储请求都是比较友好的区间。写法上很简单,Spark任务里加两个参数就能控制:
spark.sql.shuffle.partitions=200 spark.sql.adaptive.coalescePartitions.enabled=true跑完这类任务后检查文件数变化,正常情况下文件总数应下降一个数量级,查询耗时立刻能看到改观。
4.2 缓存层几乎是查询体验的兜底
存算分离对查询时延的负面影响,完全靠缓存来拉平。缓存设计有两种路线:一种是在计算节点本地用SSD做单机缓存,实现简单,但跨节点不共享;另一种是分布式缓存层,所有计算节点共享同一份热数据,占用资源更多,但命中率更高。小规模场景用本地缓存就够,规模大了或者并发查询多了,建议上分布式缓存。
我们用的方式是:本地SSD设成Spark的spark.local.dir和Presto的cache目录,对象存储的数据读取路径上挂一层缓存,配合分区级别的统计信息判断哪些分区值得预热。A同学他们团队甚至写了一个简单的定时预热任务,每天凌晨把当天要跑的报表分区提前拉一遍,效果立竿见影。
但缓存不是银弹,它只适合有复用性的数据。那些一天只查一次的即席分析,缓存几乎帮不上忙,该有的网络时延还是会有。所以在选型阶段就要分清:业务里有多少查询是高频重复的,有多少是一次性的。如果一次性查询占大头,存算分离的查询体验可能永远追不上传统架构。
4.3 元数据服务不能“裸奔”
很多人以为存算分离就是把数据扔到对象存储,然后假装它还是原来的表。事实上,谁来管理“这个分区在哪、包含哪些文件、Schema是什么”这些元数据,决定了数据湖的可用性。直接用对象存储的自带工具去List目录,性能和体验都会非常糟糕。
正确做法是引入数据湖表格式(Iceberg、Delta Lake或Hudi)和对应的Catalog服务。它们会在对象存储的特定路径下维护元数据文件,查询引擎通过元数据文件快速定位数据文件,而不是全量列出目录。分区裁剪和快照隔离也都由表格式负责。这套组件配合好了,表规模达到亿级文件也不用慌;配不好,连几千个文件的表都会卡到怀疑人生。
我见过一些团队为了省事直接沿用Hive Metastore + Parquet,文件一多就频繁卡顿,最后被迫回头补表格式改造,反而多花了一倍时间。我的建议是:一旦决定走存算分离,第一件事就把表格式定下来,不要在裸文件上跑业务。
4.4 别把引擎自带的存储适配当成万能解
Spark、Flink、Presto这些引擎官方都支持读对象存储,但支持的成熟度差别很大。例如Spark的S3A连接器,写入时为了兼容性会走临时目录+rename的路径,在小文件多的场景下非常慢,需要调整committer配置才能提升效率。Presto对对象存储的读取支持比较成熟,但写入能力普遍偏弱,所以大多数团队的写入路径统一走Spark或Flink,Presto只负责查。
| 引擎 | 读取成熟度 | 写入成熟度 | 建议用途 |
|---|---|---|---|
| Spark | 成熟 | 需配合数据湖表格式 | 批量读写、数据加工 |
| Flink | 成熟 | 需开启checkpoint相关机制 | 实时写入 |
| Presto/Trino | 成熟 | 偏弱,一般不做主写路径 | 即席查询、BI报表 |
这个分工看起来很朴素,但能省掉大量调优时间。有些团队图省事让所有引擎都直连同一份对象存储,结果每个引擎都出现一些奇奇怪怪的兼容问题,最后全都绕回Spark先落一次中间表。认清“谁适合写、谁适合查”,比把每个引擎都调成全能选手划算得多。
5. 我最终用这套规则来做选型
5.1 一张表看懂什么时候该上存算分离
不是所有场景都适合存算分离。基于这次实测和过去几年的观察,我总结出一张决策对照表,可以当参考:
| 决策维度 | 适合存算分离 | 更适合传统架构 |
|---|---|---|
| 数据规模 | 成长很快,存储增速远超计算增速 | 数据量稳定,且长期低于可管理的规模 |
| 计算特征 | 有明显峰谷,夜间集中跑批 | 全天均匀跑批,资源利用率本就较高 |
| 查询模式 | 存在大量复用型查询,能榨干缓存的价值 | 大量一次性扫描大表的即席分析 |
| 团队运维能力 | 有云平台或对象存储运维经验 | 只熟悉HDFS运维,云产品经验为零 |
| 合规与网络限制 | 数据可放在同一区域内网 | 数据必须跨地域流动或网络条件受限 |
这条表的核心逻辑是:存算分离解决的是“资源弹性”和“成本解耦”问题,如果你的业务压根不care这两个点,就没有必要承担它的额外复杂度。我见过反例——某个小团队数据量只有几个TB,硬上了全套存算分离+数据湖,结果光运维成本就翻了两倍,性能还不如原来。技术选型最忌为了名词的先进性买单。
5.2 混合架构:两条路线同时存在的日常
很多团队会误以为存算分离和传统架构是二选一,实际上大型系统里两者共存才是常态。A同学他们最终也没有把所有任务都迁过去,而是保留了一套小规模的HDFS集群,专门跑那些对时延极度敏感、且数据规律性强的任务;离线海量数据的分析则全部走存算分离。
两条链路之间通过统一的数据湖Catalog做元数据层面的互通,冷热数据自动分层——热分区保留一份在本地HDFS上,冷分区只存在于对象存储。查询引擎自动根据分区属性选择读取路径,业务侧无感知。这种架构的好处是:既享受存算分离的弹性与成本优势,又保留传统架构的低延迟路径,风险被控制在单个任务级别,而不是整个系统级别。
混合架构带来的额外成本是两套运维体系都要养,但对中等规模以上的团队来说,这笔成本通常远小于“选错边”的风险。我更推荐大家从混合起步,而不是一步到位全量替换。
5.3 迁移顺序和回退设计
如果你决定动手迁移,我的建议是严格按“先冷后热、先读后写、先离线后在线”的顺序来。第一步只迁冷数据,存放超过90天、访问频率很低的分区,把成本红利先吃到;第二步迁高频读取的表,同时上缓存层;第三步才考虑把写入链路切过去,并且优先选离线任务,实时任务最后再动。
每一步都要设计好回退方案。最简单有效的回退就是“不改表名,只改存储路径”,新老两套路径同时在Catalog里注册,随时可以一键切换。A同学他们的提测环境就是这么跑的:新路径上验证一周,确认无误后再切换生产读写,一旦出现异常,把路径参数改回去就能恢复,整个过程不用重跑数据。这种灰度思想,比任何复杂的双写方案都靠谱。
最后聊一个我自己的观察:存算分离不是万能的,但它确实解决了传统架构里最让人头疼的“扩容困境”。真要给建议的话,我会说,别一上来就全量迁移,先选三个不核心的任务跑一个月,把成本账单、性能数据和运维手感都摸清楚,再决定要不要全面铺开。架构这件事,不怕慢,怕的是把未来押在未经测试的名词上。