上个月帮一个团队做存算分离架构改造的复盘,他们的经历特别典型:原来一套自建Hadoop集群,因为成本压力开始做存算分离,把HDFS里的数据全部拷到对象存储上,计算集群改成了弹性伸缩的形态。改造完第一个月,跑批任务从两个小时直接飙到五个小时,账单不但没降,对象存储的请求费用反而成了新的开销大头。
他们当时最想不通的一件事就是:不是说存算分离能降成本、提弹性吗?怎么我们搞完反而更难用了?
我把他们踩过的坑捋了一遍,发现很多问题不是理论上的存算分离有问题,而是大家在架构设计之初就带着几个根深蒂固的认知误区。这篇文章就把我实际观察到的5个误区拆开来聊,每一个我都会给出对应的排查思路和落地建议,希望后来者别在同样的地方栽跟头。
1. 误区一:存算分离就是把计算和存储分成两个系统就完事
1.1 拆开部署后,真正的瓶颈从磁盘转移到了网络
很多人的第一反应是:存算分离?那我只要把存储节点和计算节点分别独立部署,数据通过网络访问,不就完成了吗?
物理上的确是这样,但架构的本质变了。以前在HDFS架构里,数据本地性(Data Locality)是核心原则。一个Map任务在处理某个数据块时,调度器会优先把它分配到存储该数据块的节点上,任务直接从本地磁盘读数据,网络只传输shuffle结果和少量控制消息。现在计算和存储分开了,每个计算任务的数据都得通过网络从远端存储拉取,本地点读变成了网络读。
这带来的直接影响是:你以前只需要关心每台计算节点的本地磁盘IO,现在必须仔细规划计算节点到存储节点之间的网络带宽。
我帮那个团队估算过他们一个典型跑批的吞吐需求:每晚要扫描约3TB的累计数据,业务要求跑批窗口控制在1小时内。如果按3TB传输计算,平均吞吐需要大约850MB/s,这已经超过了千兆网的实际可用带宽,必须上万兆网络。而在万兆网络上,单个pull流的极限吞吐大约在1.1GB/s,看起来够用。但问题是,他们的跑批不是一个任务独享一个网卡,而是几百个并发任务同时抢。一旦网络有抖动,或者出现跨交换机跨机房的远端流量,吞吐会迅速掉到300MB/s以下。任务拖延,就是这么来的。
所以,存算分离的第一步,不是买存储,而是先给网络做个带宽预算。落地建议是:计算集群的每个节点至少配备25GbE网卡,核心交换机之间做链路聚合,存储访问链路尽量与业务网络隔离,避免日志传输、监控采集这些流量来抢带宽。
1.2 你以为省下的本地磁盘,变成了更危险的中间依赖
存算分离的另一个宣传点,是计算节点可以"无状态化"——本地磁盘不要了,实例坏掉直接丢掉重新拉一个。这个思路本身没错,但很多团队真的把计算节点的本地磁盘拆成了很小的系统盘,甚至完全不用本地盘。结果遇到Spark作业时,问题就出现了。
Spark的shuffle机制默认是将中间结果写到executor的本地磁盘,之后再被下游任务拉取。在存算一体时代,一块大容量磁盘既能存HDFS数据块,又能存shuffle数据,两者互不干扰。但存算分离后,如果你只为计算节点配了一块很小却很快的SSD,shuffle数据很容易把磁盘空间写爆。更糟糕的是,一旦本地盘满了,Spark会配置成把shuffle spill到外部存储,而外部存储就是那个遥远的对象存储。想象一下,几百个executor同时把几十GB的shuffle临时文件写到对象存储,然后下游再逐块拉回来——网络翻倍、延迟翻倍、对象存储请求费也翻倍。
我在这种场景下的建议是:计算节点本地磁盘不要省,至少留出单个节点内存2倍大小的NVMe SSD用于shuffle临时文件。同时把Spark的.shuffle服务配置成异步清理机制,保证任务结束后临时文件能及时回收。不要指望"无状态"等于"零磁盘",磁盘可以不带数据,但一定得带临时空间。
另外还有一类中间依赖容易被忽略:缓存和索引文件。很多计算框架为了加速会缓存远端数据的索引或统计信息,这些文件默认也放在本地临时目录。如果你频繁清理本地盘,每次cache miss都要重新从对象存储拉取索引,性能同样会打折扣。所以本地盘是要保留的,只是它的角色从"数据存储"变成了"计算缓存"。
2. 误区二:把HDFS的路径换成对象存储路径,应用代码不用改
2.1 目录树与对象键:看似能对应,实际语义差异巨大
HDFS是一个标准的文件系统,有目录树、有嵌套目录、有rename这种原子操作。而对象存储本质上就是一个扁平的键值存储,所谓的"目录"只是对象键里统一前缀的假象。很多团队在做迁移时,直接把hdfs://namenode/user/data这种路径映射成对象存储里的user/data/,应用代码里把路径前缀改一下就以为万事大吉。但实际跑起来,各种隐藏问题就冒出来了。
比如,HDFS的rename是原子的,而对象存储的rename操作本质上是先拷贝后删除,而且拷贝过程是逐对象的。如果你在作业中途用rename做数据的临时目录切换,读端可能看到目录里一半新文件、一半旧文件,甚至文件缺失。如果这个rename操作被用在类似"快照切换"的场景,结果就是下游读到了不一致的数据,跑出错误结果。
再比如,对象存储的"list"操作在对象数量非常大时有性能瓶颈。HDFS列出10万文件是毫秒级,对象存储做同样的prefix list可能变成秒级甚至分钟级,而且对请求频率也敏感。如果你的应用里频繁遍历目录来发现新文件,迁移后会发现任务卡在文件枚举阶段。
2.2 一致性模型不同:覆盖写、追加写的坑
HDFS支持追加写,也支持对文件的覆盖写,而且HDFS的写一旦成功,对后续读者是立即可见的。但很多对象存储产品在标准接口下只保证最终一致性,尤其是在修改对象后的短时间内,老的读者可能还会读到旧版本的数据。
我见过最典型的案例是一个实时数仓项目,数据写入程序每5分钟向对象存储上的某个路径覆盖写一次最新的全量分区文件,下游的查询服务靠时间来感知新数据。在HDFS时代没有出过问题,迁到对象存储后,偶尔会出现查询服务读到半小时前旧文件的情况,导致报表数据瞬间回退。后来排查,发现就是连续两次覆盖写之间的时间窗口,对象存储的索引还没更新完,而查询服务发出list请求时恰好命中了旧对象。
针对这个问题,有两条实战经验:
- 如果业务允许,尽量避免原地覆盖写,改成写入临时目录,然后一次性rename到目标位置(前提是存储产品支持原子rename,或者用数据湖事务机制)。
- 如果业务必须做覆盖写,就要在应用侧接受最终一致性,通过额外的版本号校验或者时间戳判断来屏蔽旧数据。
对于依赖目录遍历、rename操纵数据的传统Hive数仓,建议在计算引擎和数据中间加一层支持事务的湖格式(比如Iceberg或Hudi),让元数据层帮你管理文件可见性,而不是直接裸操作对象存储。这样至少能把一致性差异封装在框架层。
3. 误区三:存储越统一越好,一个桶放所有业务数据
3.1 冷热数据混在一起,存储成本和查询性能两头吃亏
很多团队做存算分离的时候,把"存储统一"理解为"所有人都用同一个对象存储桶"。于是,高频访问的订单表、日志流、中台报表、离线归档全都放进同一个bucket,前缀区分一下就觉得完事了。
这种一刀切的做法,短期看管理简单,长期看既费钱又伤性能。对象存储的定价通常分三块:存储容量、请求次数、流量。访问频率高的热数据,会产生大量GET/LIST请求,请求费甚至会超过容量费;而一年才读一次的冷数据放在标准存储上,存储单价又比归档存储贵出好几倍。
混合在一起之后,你给热数据预留的网络和请求配额会被冷数据巡检任务蚕食掉,热查询的延迟自然就上去了。这就像你把所有衣服放在同一个衣柜抽屉里,冬天找羽绒服翻半天,夏天找短袖还得把厚的拨开,互相拖累。
我的建议是至少按数据温度分成三档存储策略:
| 数据温度 | 典型场景 | 推荐存储 | 访问频率 |
|---|---|---|---|
| 热数据 | 实时报表、在线特征、最近7天明细 | 高性能分布式文件系统或本地SSD缓存 | 秒级/分钟级 |
| 温数据 | 近30天查询、批量分析 | 标准对象存储 | 小时级/天级 |
| 冷数据 | 历史归档、审计日志、日志备份 | 归档存储 | 月级/年级 |
数据分层不能靠运维手动搬家,要利用数据湖格式的Partition管理能力,按时间自动执行compaction和归档策略。比如设置分区保留期,超过30天的分区自动从标准存储迁移到归档存储,同时把对应元数据更新掉。这样既保证热查询命中高性能区,又让历史数据享受低成本存储。
3.2 小文件问题是对象存储的噩梦
对象存储对请求次数很敏感,1亿个1KB文件和一个100GB大文件,如果都要被全量扫描,请求费能差出几个数量级。很多从HDFS迁过来的老数仓,天然带着海量小文件问题,因为以前的Hive表经常按天按小时生成分区,每个分区内有很多几MB甚至几百KB的小文件。
把这些小文件原封不动搬到对象存储,后果就是:查询引擎每次扫描都要发起海量GET请求,对象存储的请求配额很快被耗尽,出现限流;同时小文件导致的碎片化读取,会让底层数据布局变成随机IO,有效吞吐断崖式下降。
所以,把数据搬进对象存储之前,一定要做一次文件整理(compaction)。最实在的做法是在写入层就控制文件大小:用Spark写表时,设置目标文件大小在256MB到1GB之间,不要默认生成上百个小文件。如果已经有很多小文件,就用Iceberg或Hudi的compaction功能定期合并。
实际跑过之后,我对小文件的容忍底线是:平均一个文件至少8MB,低于这个数的表,查询性能大概率会出问题。这也是为什么存算分离架构更适合搭配数据湖格式,因为湖格式把文件合并、分区裁剪、事务管理都标准化了,你不需要自己在应用层去处理这些小文件。
4. 误区四:存算分离之后,性能下降只能接受
4.1 列式格式与谓词下推:把传输量砍掉一个数量级
很多团队在做完存算分离后,发现全表扫描类任务的性能下降明显,就默认这是"分离的代价",只能忍。但事实上,绝大多数下降都源自不合理的访问方式——你在用以前HDFS时代"我本地什么都有的"思路,去远端拉取大量用不上的数据。
最简单的例子:一张宽表有200列,但查询只需要其中的10列。如果表存储格式是行式存储(比如SequenceFile或CSV),从对象存储读数据时必须把整行都读回来,每个查询都要从远处拉200列的数据量。而如果改成列式存储(比如Parquet或ORC),配合谓词下推和列裁剪,引擎只需要读那10列的对应数据块,传输量直接变成原来的1/20。
我的实际经验是,同样的业务查询,行式存储迁移到列式存储之后,远端数据传输量能降低60%到90%。加上列式格式自带的压缩(一般能压到原始数据的1/3到1/5),最终落到网络上的有效数据量已经非常小了。
另一个容易被忽视的点是谓词下推:如果你的查询有WHERE dt='2024-06-01'这样的条件,但表没有按dt分区,或者引擎没有开启分区裁剪,那远端存储仍然要把整个表的数据拉过来。所以存算分离下,表的分区设计比以往更重要。我的建议是:能分区就一定分区,最小粒度到天;分区裁剪条件务必覆盖到查询的90%以上。否则,网络仍然会被无谓的扫描打爆。
4.2 让调度器认识数据位置:延迟调度远没有过时
很多团队做存算分离,连调度器也不管了,任务随便在哪个节点跑都一样。如果存储是纯远端的,理论上的确每个节点访问速度一样。但现实中你为了性能一定会加缓存层——无论是本地SSD缓存、内存缓存,还是独立的缓存集群。这时候,数据位置又变得关键了。
如果你不加缓存,每次都远程读,性能就是网络+对象存储的延迟;如果你加了缓存,那任务落到哪个节点,决定了它是不是能命中缓存。快一点的方案是:计算引擎的调度器支持"延迟调度"(Delay Scheduling),当某个任务可以选择计算节点时,优先等待一小段时间,把它调度到拥有该数据的缓存副本所在节点,命中一次缓存就能省掉一次完整的网络访问。
我在实践中给Spark配置过这段参数:
// 开启延迟调度,复用缓存数据 spark.scheduler.mode=FIFO spark.locality.wait=30s spark.locality.wait.node=3s spark.locality.wait.rack=1s让任务尽量等一等本地缓存的节点,而不是随机扔到一个没有缓存的节点上。开了延迟调度后,我们一个核心报表查询的p99延迟从原来的2秒多降到了800毫秒左右,整体缓存命中率明显提升。
如果你用的是批处理引擎,建议认真思考一下缓存层该怎么设计。不是所有数据都需要缓存,通常只缓存那些被高频重复读取的维度表、小事实表、热点索引。我用过的模式是:计算节点本地放一块高速NVMe作为Alluxio的worker存储,热数据自动缓存到本地,引擎的location感知会自动帮助命中的任务落到本节点。这样既保留了存算分离的弹性,又把性能拉回到接近存算一体的水平。
5. 误区五:存算分离的TCO一定比存算一体低
5.1 你省了计算节点磁盘,却多付了网络和请求费用
不少团队直接拿"摆脱HDFS三副本的200%存储冗余"来作为存算分离的降本逻辑。的确,对象存储通常用纠删码或内部多级冗余,存储单价远低于HDFS。但如果你只看存储单价,而不算全链路账单,年底一算账还是会懵。
我见过一个团队的账单结构:存储容量费确实省了约40%,但网络流量费增加了三倍,对象存储请求费更是翻了五倍。原因是他们的报表任务每5分钟就要扫描一次全量分区,光GET请求每个月就有几千万次。请求费的单价虽然低,但那是一个按次数计费的模式,一旦你的任务频繁读取,费用会迅速累积。
所以,存算分离的成本评估,一定要做全链路模型,至少包括这些条目:
- 计算节点费用(CPU/内存,相比存算一体不变甚至更低)
- 计算节点本地盘费用(如果你按我前面的建议保留了SSD,这块不能省)
- 网络带宽/流量费用(跨可用区或跨地域流量可能单收)
- 对象存储容量费
- 对象存储请求费(GET/LIST/PUT次数)
- 额外的缓存集群、元数据服务、数据湖框架组件
- 运维人力成本
很多团队在前期评估时只列前四项,把最大的隐藏炸弹(请求费、网络费、缓存集群费)漏掉了。我强烈建议在做迁移之前,先按你现有的任务清单模拟一个月度成本,把每个任务的扫描频率、平均扫描数据量、list频率估算出来。不要按总量估,要按请求频率估。
5.2 元数据管理和权限控制变得分散,人力成本上升
存算一体时代,HDFS集群自带NameNode做元数据管理,权限模型(user/group/ACL)也集成在文件系统层。看起来"重",但没有额外部署负担。存算分离之后,存储上是对象存储,计算引擎有自己的一套队列和调度,数据湖格式还有一层独立的元数据服务(比如Hive Metastore或者数据湖Catalog),如果中间再插一个统一权限网关,整个链路就变成了三四套体系。
权限控制尤其麻烦。对象存储的桶策略、计算引擎的访问控制、数据湖里面的行级/列级权限,这三者要同步。一旦某个子账号需要临时开放一个表,你得分别在三个地方改配置,漏一处就出现"权限够但连不上"或者"能登录但看不到数据"的诡异问题。
我见过一个团队为了这个事专门配了一个元数据管理员,每周光是同步权限、排查数据可见性就占掉一大半时间。这部分的隐性成本,不比原来维护一套NameNode低。所以算TCO时,请务必把这类人力投入算进去。
另外,元数据访问本身也可能成为瓶颈。当所有任务都通过数据湖Catalog获取文件列表时,Catalog服务的查询QPS是个全新的性能指标。如果Catalog部署太弱,所有任务都会排队等文件列表,表现就是"集群明明很闲,作业却在等待元数据"。这个问题的排查还很隐蔽,因为监控面板上通常只有CPU、内存、IO,很少单独拉出元数据服务的QPS。我的习惯是在存算分离架构里,给元数据服务单独设置监控面板,并配置适当的连接池和查询并发限制,提前发现这类排队风险。
几个踩坑之后才想明白的核心原则
回到开头那个团队,后来我们帮他们重新设计了网络规划、shuffle盘保留、数据分层和缓存策略,又花了三周做性能调优,跑批时间才从五个小时降到一个半小时以内。账单也回归到和原先预期差不多的水平。
有几条原则是这次改造之后我们一直在用的:
第一,存算分离不是存储和计算分家,而是把数据访问路径重新设计一遍。网络规划、本地临时空间、元数据服务、一致性语义,这些听上去不性感但决定成败。
第二,不要用管理一套HDFS的心态去管理对象存储。它俩语义差别太大,应用层需要适配的地方远比你想象的多,借助数据湖框架来封装差异是最省力的路径。
第三,性能优化还有巨大的空间,千万别只靠增加硬件去硬扛。列式格式、谓词下推、压缩、缓存、延迟调度,一套组合拳下来,效果通常比加带宽更明显,也更便宜。
最后再说一个我自己的实操习惯:任何做存算分离改造的团队,我都建议先挑一个最简单的离线任务,完整跑通"数据迁移 -> 任务改写 -> 性能调优 -> 成本核算"这个最小闭环,再决定是不是要大规模推广。因为只有真的在小范围里体验过一遍对象存储的延迟特征和请求计费模式,你才能对前面那些"理论坑"建立体感。这种体感,比看一百篇文章都管用。