拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

云上存算分离架构实践:从Hadoop迁移到对象存储的成本与性能优化

云上存算分离架构实践:从Hadoop迁移到对象存储的成本与性能优化

1. 为什么存算分离在云上成了必答题——先从资源利用率的账算起

过去几年我一直在帮企业搭数据平台,早期最常用的套路就是自建Hadoop集群,HDFS存数据,YARN跑计算。这套方案在物理机房时代没什么大问题,但搬到云上之后,第一个让我意识到“必须改”的场景,是某次帮客户看集群账单。

那是一个12节点的CDH集群,配置不算低,SSD加机械盘混布,每季度都要扩容。我打开监控面板一看,CPU平均利用率不到25%,但存储已经从60%涨到接近90%。最尴尬的是,业务方不断提需求说“数据要保留更久”“历史数据不能删”,但存储扩容意味着CPU和内存也跟着扩——因为HDFS的DataNode和计算节点是绑在一起的。你只想买“硬盘空间”,却被迫连CPU、内存、机架带宽一起买单。这就是存算耦合最典型的资源浪费:存储需求拉动计算资源空转。

云环境把这个问题放大了。云上计费是按资源种类分开算的,对象存储每GB每月几毛钱,而计算节点按小时计费、按规格计费。如果还是沿用HDFS加物理机的思路,等于主动放弃了云平台最大的红利——按需伸缩。到了任务低谷期,集群空转,账单照扣;到了高峰期,集群扩容又受限于数据本地性,必须等数据平衡。所以越来越多的团队开始认真考虑一件事:把数据放在独立存储层,计算集群按需拉起,用完就释放。这就是存算分离最朴素的出发点。

从技术演进的时间线看,存算分离其实经历了几个阶段。最早是计算与存储进程分离,比如HDFS Federation、NameNode与DataNode分离管理;然后是物理部署分离,比如计算节点不挂大数据盘,数据统一走网络访问远端的存储集群;到了云原生阶段,就是彻底把存储抽象成对象存储服务,计算引擎通过S3协议或各云厂商的对象存储接口访问数据。现在的“存算分离最佳实践”,基本默认指最后这种形态:对象存储作为数据底座,Spark、Hive、Presto/Trino这些计算引擎无状态化地跑在临时集群上。

这里要澄清一个常见误解:存算分离不是说把HDFS删掉,换成对象存储就完事了。它是一整套架构模式的变化,涉及元数据管理、表格式选型、数据读写路径、缓存设计、权限模型、成本核算方式等等。很多人一上来就把Hive表路径指到OSS或者S3,跑几个查询发现“还行”,但没有意识到自己只是把HDFS换了个名字,真正的架构红利一点没吃到。后面我会逐步拆开讲。

2. 云环境下存算分离的核心架构拆解:计算层、存储层与元数据层

2.1 计算层:无状态化是一切的前提

存算分离之后,计算集群最重要的特征就是无状态。什么叫无状态?就是任何一个计算节点挂了,或者整个集群被销毁了,都不影响数据本身,也不影响后续任务重新拉起。传统Hadoop里DataNode既存数据又跑任务,节点挂了数据会丢副本,任务重试要等数据恢复。而存算分离下,计算节点全部是无状态的Worker,它们只负责读远端存储上的数据并执行计算逻辑,本地磁盘顶多放一些临时shuffle数据和缓存,任务一结束,这些临时数据就可以舍弃。

实际操作层面,无状态意味着两件事。

第一,集群可以“用完即弃”。比如每天晚上跑一次大规模离线ETL,那就每天定时拉起一个Spark集群,跑完释放。云厂商的EMR、Databricks、阿里云E-MapReduce这类服务都支持这种模式,最快几分钟拉起一个几十节点的集群,任务完事之后缩容到零。对于有固定SLA的生产任务,可以保留一个常驻小集群处理低延迟需求,大任务用弹性集群扛高峰。第二,扩缩容不需要再等数据重新平衡。传统HDFS集群扩容后,要等DataNode数据平衡完成才能充分发挥性能;存算分离下,新节点拉起就能直接从对象存储读数据,几乎零等待。

计算引擎的选择上,目前主流就是Spark、Hive/Tez、Presto/Trino这几类。Spark适合复杂ETL、机器学习特征工程;Hive适合纯SQL批处理,如果团队已经有很多HiveQL脚本,迁移成本最低;Presto/Trino适合交互式查询。这些引擎在存算分离架构下都做了对象存储适配,比如Spark的S3A connector、Hive的AWS S3集成、Trino的Hive connector直读对象存储。我觉得选型时不用太纠结,核心看团队的技术栈和任务类型。

2.2 存储层:为什么对象存储成了默认选择

存算分离的存储层,绝大部分团队最终都会倒向对象存储。原因很实在:便宜、容量无限、支持按量付费、自带多副本冗余。相比之下,自建HDFS需要考虑三副本、坏盘替换、集群扩容,运维成本高;云上的块存储EVS/ESSD虽然性能好,但价格比对象存储贵一个量级,存海量历史数据不划算。

对象存储有哪几个核心特性是存算分离必须依赖的?

  • 无限容量:不用担心扩容和分区上限,数据从TB级到PB级都能平滑承载。
  • 数据持久性:云厂商一般承诺11个9或更高的持久性,通过多可用区冗余实现,相当于底层帮你做了多副本。
  • 独立计费:只存数据就只付存储费,不绑定任何计算资源。
  • S3协议兼容:几乎所有大数据引擎都原生支持S3协议,跨云迁移的学习成本低。

但对象存储也有天然短板,最典型的是小文件性能差和延迟比本地磁盘高。对象存储的架构决定了它适合“大文件流式读写”,对“大量小文件随机访问”非常不友好。这个我在第3节会专门展开,因为它是从HDFS迁到对象存储后第一个暴雷的点。

另外,存储层的目录组织也很重要。很多人直接把Hive表的数据路径挂在对象存储的某个桶下面,但完全不规划桶的层级。我建议桶结构这样设计:

bucket-name/ ├── warehouse/ # Hive数仓表数据 │ ├── dwd/ │ ├── dws/ │ └── ads/ ├── logs/ # 原始日志 ├── tmp/ # 临时数据、任务中间结果 ├── backup/ # 冷备归档 └── exchange/ # 与外部系统交换数据

这样设计的好处是:成本管理清晰(可以按前缀设置生命周期规则,比如logs目录30天转低频、tmp目录7天清理),权限管控也更细粒度。

2.3 元数据层:表格式与Metastore的重新定位

存储层换成对象存储之后,元数据管理变得比以前更重要。因为HDFS本身有目录和文件的概念,NameNode负载了元数据;但对象存储没有“目录树”这种概念,它天然是KV结构。那么Hive表的分区、文件列表、Schema等元信息,必须由独立的元数据服务来管。这就是Hive Metastore(HMS)的职责。

于是问题来了:Hive表底层数据放在对象存储上,HMS是不是还够用?答案是:够用,但光靠HMS不够。

HMS解决的是“表结构和分区映射到哪个路径”的问题,比如查dwd_order_detail表,HMS告诉引擎这个表有哪几个分区、每个分区对应的对象存储路径前缀是什么。但HMS本身不知道每个分区下有哪些文件、每个文件有多大,这个信息是引擎在查询时动态list目录拿到的。传统HDFS下,NameNode能快速返回目录列表;对象存储不行,list操作对大量小文件是灾难级的慢。

所以存算分离架构里,表格式(Table Format)的选择变得至关重要。目前主流有三种:Apache Hudi、Apache Iceberg、Delta Lake。它们都提供了一层“文件级别的元数据管理”,把自己的元数据也存到对象存储上,查询引擎通过读这些元数据,直接定位需要读取的数据文件,而不用全量list目录。

用Iceberg举个具体例子:一张表的每个快照对应一个Manifest清单,Manifest里记录了所有数据文件的路径、列统计、行数等信息。Spark查询时,先读Manifest,根据过滤条件直接跳过不需要的文件,然后再去对象存储拉取目标文件。这就把“list海量文件”的操作变成了“读几个小清单”,性能提升非常明显。如果你在用Hive表直接挂在对象存储上,数据文件从几千个涨到几十万个之后,查询慢到你想砸键盘,解决方案十有八九是迁移到这类表格式。

表格式选型没有绝对标准,我个人的参考维度是:

维度HudiIcebergDelta Lake
流批一体支持得很好,有增量拉取能力偏批处理,流场景需要额外组件偏批处理,Databricks生态强
并发写支持乐观锁并发支持乐观锁并发支持,但多写冲突处理一般
小文件自动治理有Clustering有Compaction有Optimize
查询引擎支持Spark/Flink/Hive/TrinoSpark/Flink/Trino/StarRocksSpark(Delta Lake原生),其他需额外配置
社区活跃度成熟度高阿里云、各类云厂商支持多Databricks主导

如果你做的是以Spark SQL为主的离线数仓,Iceberg上手最平滑;如果你有大量Flink实时写入场景,Hudi更顺手。当然,这不是什么金科玉律,具体还要看贵司基础设施的云厂商是谁,很多云厂商的EMR已经把某个表格式做了一键集成,选择生态支持最好的那个就行。

2.4 权限与认证:从文件系统权限到云服务授权

存算分离还牵涉一个经常被忽略的环节:权限模型。HDFS时代权限一般是Linux文件权限加ACL,数据存储在集群内部,网络边界相对清晰。但对象存储是开放服务,访问它需要云账号的AK/SK或临时凭证。权限设计不好,要么出现安全漏洞,要么出现“任务跑不起来”。

我的推荐做法是三层权限设计:

  • 云账号层:用子账号或RAM角色,只授最小权限。比如计算集群绑定的服务角色,只允许访问指定的bucket前缀。
  • 引擎层:Spark/Hive作业通过使用角色的临时凭证访问对象存储,不使用硬编码的AK/SK。如果团队已经有Ranger或者类似的开源权限组件,可以把它接入HMS,控制的是SQL级表权限。
  • 存储层:对象存储的Bucket Policy做最后的兜底,禁止公共读写。

很多团队嫌麻烦,直接在作业代码里写死一个AK/SK。如果你只是内网实验,问题不大;但在生产环境,一旦AK/SK泄露,对象存储里的所有数据都裸奔。而且等被安全团队巡检到,整改时的迁移量远超你省下的那点功夫。从第一天就做好STS临时凭证的接入,是存算分离最佳实践里性价比最高的一步。

3. 计算与存储解耦后的三大关键技术问题:小文件、本地性与缓存

3.1 小文件问题:为什么对象存储“怕”小文件,以及怎么治理

小文件问题在HDFS时代就存在,但存算分离把它放大了。

先说原因。HDFS的NameNode把每个文件、每个目录都作为内存中的一个对象来管理,大量小文件会压垮NameNode,这个大家比较熟悉。对象存储没有NameNode,但也有自己的脾气:它的每个请求都有独立的开销,处理大文件时按顺序分段读,效率很高;但如果是成千上万个小文件,引擎要不停地发HTTP请求,每个请求的延迟可能在几十毫秒到几百毫秒,加在一起就是灾难。比如一张表有10万个小文件,Spark做一次全表扫描就要发10万次请求,光网络握手时间就让人崩溃。

最典型的是实时写入场景。Flink或Spark Streaming实时写Hive/Hudi表时,如果Checkpoint间隔很短,每个Checkpoint都会产生一批小文件,比如5分钟一个Checkpoint,一天下来就有288个分区目录,每个分区里又有很多几MB的小文件,日积月累,查询性能直线下降。

治理方案我给了两类。

第一类是在写入侧控制文件大小。Spark写数据时用repartition或coalesce控制输出文件数,目标文件大小尽量在256MB到512MB。比如一张表一天的数据量是200GB,如果设置每个文件256MB,输出文件就是800个左右。这个量级对对象存储比较友好。Flink实时写入Hudi时,可以把hoodie.parquet.small.file.limit参数设大一些,让Hudi自动把小的写入合并到已有文件组里。

第二类是定期做小文件合并。Iceberg/Hudi都提供了compaction或clustering能力,建议在每天的低峰期跑一次合并任务,把小于某个阈值(如128MB)的文件合并成256MB以上的大文件。我们项目的经验是:每天凌晨1点跑一次合并,白天的交互式查询性能能稳定保持在秒级;如果连续一周不治理,到了周四下午准时出现查询超时告警。

3.2 数据本地性失效:拿什么补偿网络IO

存算分离一个很大的心理落差是:以前Spark跑在HDFS上,有数据本地性调度,Task尽量调度到数据所在的节点上,读数据基本走本地磁盘;现在数据在远端对象存储上,每个Task读取数据都要走网络,延迟变高了。

这是不是意味着存算分离性能一定比HDFS差?不一定,但需要做三件事来补偿。

第一,列式存储加谓词下推。对象存储上建表尽量用Parquet/ORC格式,Spark或Trino读取时,通过Parquet的row group统计信息做谓词下推,只读取必要的列和行。这比“全量拉数据再过滤”省太多网络流量了。建表时把谓词下推相关参数打开,比如Spark的spark.sql.parquet.filterPushdown=true和spark.sql.parquet.aggregatePushdown。

第二,并行读与读放大控制。引擎从对象存储读大文件时,一般会按一定大小拆分成多个Range并行读。比如Spark读取一个512MB的Parquet文件,可能拆成16个32MB的块并发拉取。这样能充分利用带宽。要注意的是,块大小设置不能太小,比如小于8MB,否则请求数暴增反而更慢。

第三,网络与带宽规划。存算分离集群的节点建议和对象存储放在同一个Region,最好同一个可用区。跨地域访问对象存储,延迟和带宽成本都不可控。如果使用云厂商的EMR服务,控制台一般会直接默认同Region,这个基本不用额外操心。但如果你用的是自建的K8s集群,一定要注意把Pod调度到和对象存储同Region的节点,不要等到数据都跑起来再发现跨Region流量账单。

3.3 缓存层:Alluxio与轻量本地缓存

既然远端读取慢,能不能把热点数据缓存在计算节点的本地磁盘?能,这就是缓存层的角色。

最常被提起的组件是Alluxio。它提供的是一个分布式缓存层,介于计算引擎和底层存储之间,把经常读的数据文件缓存到集群本地或者内存中,下次读取直接命中缓存。它在HDFS时代就被用于加速数据访问,到了对象存储时代价值更明显,因为它把远端对象存储“伪装”成了一个类似HDFS的文件系统,引擎读它的时候,如果命中了缓存,能省掉几乎全部的网络IO。

但Alluxio带来的问题是引入了一个新的分布式系统,它的元数据服务也需要部署和维护,复杂度并不低。对中小团队来说,我先建议从轻量方案开始:利用计算引擎自带的缓存能力。

  • Spark 3.x 的DataSource V2支持自定义数据源,配合SSD本地盘可以启用缓存。
  • Trino的Hive Connector支持hive.cache.enabled=true和hive.cache.ttl等配置,把最近读过的文件缓存在本地。
  • Presto同样有类似配置。

这些轻量缓存的逻辑很简单:读一次远程文件后,在本地留一份副本,短时间内再次访问直接读本地。它们没有Alluxio那么智能、没有统一管理面,但对绝大多数场景已经够用了,尤其是数据报表、交互式分析的固定查询模式——每天早上业务方都会看同一组核心指标,这些指标的底层数据文件其实就那几个,缓存命中率非常高。

我的经验是:先跑业务,等观察到“某些查询反复读同一批大文件”、“每天同一个查询的调度次数很多”,再考虑引入专门的缓存层。一上来就上Alluxio,反而是给自己找运维负担。关于这个小结论,我和不少数据平台团队的同行聊过,大家体感基本一致。

4. 从传统Hadoop平滑迁移:存量数据迁移与双跑验证的实操路径

4.1 迁移前的存量摸底:目录清单与数据量级

存算分离改造本身不复杂,复杂的是存量系统的平滑迁移。我见过太多团队急着把Hive表路径切到对象存储,结果存量数据没迁完,增量也在写,两边数据对不上,最后回滚成一团粥。所以第一步一定是先摸清现状。

你需要做一张存量清单,至少包含这些字段:

  • 库名、表名
  • 表格式(内部表还是外部表,存储格式是Parquet还是ORC,是否分区表)
  • 各分区数据量、文件数量、平均文件大小
  • 表的最近访问时间(确定哪些是活表、哪些是死表)
  • 表的写入方式(离线调度写、实时流写、手动Insert)

这张表怎么来?可以写一个脚本扫描HMS元数据,也可以直接用SHOW TABLE EXTENDED逐个看,数据量大的话建议写个Python脚本连上HMS的内置数据库直接查。存量摸底的意义在于:你不可能一夜之间把所有表都迁过去,必须挑选合适的迁移批次。

4.2 全量迁移与增量同步:DistCp的进阶玩法

全量历史数据迁移最通用的工具是Hadoop自带的DistCp。传统上它用于HDFS集群间的数据复制,但Hadoop 3.x版本以及云厂商适配的版本,都支持把对象存储作为目标端。基本命令是:

hadoop distcp \ -Dfs.s3a.access.key=${ACCESS_KEY} \ -Dfs.s3a.secret.key=${SECRET_KEY} \ -Dfs.s3a.endpoint=${OSS_ENDPOINT} \ -Dfs.s3a.path.style.access=true \ hdfs://namenode:8020/user/hive/warehouse/dwd_order_detail \ s3a://bucket/warehouse/dwd_order_detail

这里有几个细节要注意:

  • 如果目标Bucket已经存在同名文件,默认会覆盖,建议先用-update参数做增量覆盖。
  • 大文件复制建议开启s3a.fast.upload,分段并行上传,速度更快。
  • 迁移期间要保证源端没有被“强一致写入”,否则中间态数据复制过去是不一致的。
  • 同一个目录建议起多个DistCp任务并行跑,用-numMaps参数控制并行度。但也要小心,如果把对象存储的写入限额打爆,所有任务一起失败,所以并行度要循序渐进地调。

全量迁移结束后,要做一个校验。DistCp自带-verify参数可以逐文件比对长度和校验和,但这在对象存储上比较耗时。一个更实用的做法是:全量迁移完成后,停掉写入任务(只读不写),让增量任务追平,再做一次关键分区的大小和行数校验,确认一致后再切换读流量。

增量同步这一块,要看你有多少流式写入任务。如果只是离线数仓,每天定时写一次增量分区,那很简单:全量迁移后,第二天正常调度跑增量任务,写的目标路径直接切到新的对象存储路径。如果有Flink实时写Hive/Hudi的任务,建议在切换前先把Flink作业停止,等全量迁移完成,Flink作业换新路径和新的Checkpoint状态再启动。千万不要尝试“新数据写新路径、老数据写老路径”同时并行,让引擎自己解决——大部分实时管道都不支持这种透明合并,会丢数据。

4.3 计算引擎适配:三行配置让Spark/Hive读上对象存储

数据就位后,计算引擎的适配其实就是一套core-site.xml和Hive/Spark的配置改动。以Spark为例,核心是配置S3A文件系统实现:

<configuration> <property> <name>fs.s3a.impl</name> <value>org.apache.hadoop.fs.s3a.S3AFileSystem</value> </property> <property> <name>fs.s3a.endpoint</name> <value>oss-cn-hangzhou.aliyuncs.com</value> </property> <property> <name>fs.s3a.path.style.access</name> <value>true</value> </property> <property> <name>fs.s3a.fast.upload</name> <value>true</value> </property> </configuration>

然后Spark作业里的输入输出路径从hdfs://...换成s3a://...即可。Hive的适配更简单:建外部表的时候直接指定LOCATION 's3a://bucket/warehouse/xxx'。

但光改路径还不够,我强烈建议在切换前把一个典型的ETL任务在新路径上走一遍“影子跑”,看看有没有三类问题:

  1. 路径兼容性问题:比如Hive的UDF里写死了hdfs://前缀,或者某些配置项不支持S3A。
  2. 写并发问题:对象存储对高频写请求有限制,如果某个任务的输出文件数量特别大,容易触发限流,需要调高并发数或者合并输出。
  3. 延迟问题:首轮读对象存储的数据文件可能需要缓存热点,第一次跑会偏慢,第二次跑才能回到正常水平。别因为这个误判性能。

4.4 双跑验证与灰度切换:不搞“一夜转完”

迁移最忌讳的就是“大爆炸式切换”。我推荐的节奏是:

  1. 选一个核心报表表,先迁移它,做七天双跑:新老两套任务跑同样的逻辑,对比产出结果。如果每天结果完全一致,再切流量。
  2. 切流量时先切只读任务(报表、即席查询),观察一天,确认无问题再切写任务。
  3. 最后再批量迁移剩余表。这一批可以按热度排优先级,冷表直接一次性迁完,不用做双跑。

双跑期间会有两倍计算资源的开销,但相比一次迁移事故导致的半夜回滚,这点成本非常划算。等到核心任务全部切过来,再把老集群降级或释放,计算资源账单立刻大幅下降。

5. 成本账单里的真相:存算分离省在哪里、费在哪里

5.1 存储成本对比:三副本与分层存储的差距

传统HDFS默认三副本,意味着你存1TB的逻辑数据,实际占用3TB物理空间。加上HDFS的DataNode节点通常配比是“CPU+内存+大盘”,存储成本不单单是硬盘价格,还要摊上节点本身的CPU和内存。而对象存储底层虽然也做冗余,但对用户来说按实际逻辑大小计费,不用理解副本数。常驻Cluster按年付费,如果是低频访问,还能再往低级存储层迁移。

我拿一个真实案例算一笔账。一个客户大约存储了800TB逻辑数据。之前用自建HDFS,三副本加节点开销,每月存储相关成本在18万元左右。迁移到对象存储后,热数据存标准型,90天未访问转低频,180天未访问转归档,实际账单降到每月约6万元,存储成本直接砍掉三分之二。当然,不同云的定价策略不同,但趋势是一致的。

所以存算分离省下的第一笔钱,来自存储层。这笔钱不是省出来的,是“算”出来的——你得主动设计方案,让数据在不同的温度层级间流转。

5.2 计算成本:弹性集群与竞价实例的组合拳

计算侧,存算分离的最大省钱点是弹性。离线任务在夜间集中运行,就可以配置定时扩缩容:晚上8点扩到100个节点,凌晨2点缩回10个节点。传统HDFS集群做不到,因为缩容会导致数据块副本数下降,HDFS为了保证安全会拒绝缩容,或者疯狂后台复制数据,成为“缩容地震”。存算分离没有这个限制,节点说释放就释放。

更进一步,很多云厂商都提供竞价实例,价格是普通实例的2-3折。存算分离架构下,计算节点的无状态特性让竞价实例变得极其适用:节点被回收,任务自动重试,换一批新的竞价实例继续跑。对容忍失败重试的离线批处理任务来说,可以放心大胆地把大部分节点配成竞价实例,保留下来的少量按量实例用于跑核心SLA任务。

5.3 那些你会忽略的“隐性成本”

存算分离不是只有好处,有几处成本容易被忽视,我提醒每个做改造的团队都注意:

  • 跨AZ流量费:计算集群和对象存储最好在同一个可用区,跨AZ的数据访问需要收取流量费。这个费用虽然在对象存储场景下单价不高,但大数据常驻任务会产生巨额流量,一个月下来能到几万元。
  • 数据访问请求费:对象存储通常按请求次数收费,比如PUT/GET每万次多少钱。小文件越多,请求次数越多,费用越高。这又回到小文件治理——它不只是性能问题,还是成本问题。
  • 冷数据回热费:低频存储和归档存储的数据被访问时,除了读取费,还要付“数据取回费”。归档数据取回是按GB收费的,如果归档策略写得太激进的“热”,把经常要查的数据归档了,那每次查一次,账单都像割肉。

我自己踩过的坑就是:归档策略设置成了“60天未访问自动转归档”,结果业务方在月末做历史同比分析,一次性读取了大量归档数据,那个月的取回费直接让账单翻倍。之后我把归档策略调成“180天未访问”,并且对核心业务表的归档做了例外,不再全局一刀切。

6. 数据报表与可视化场景下的存算分离实践:把“快”建立在正确的地方

6.1 报表、大屏与存算分离的矛盾

现在很多业务方做数据大屏、BI报表,底层数据源就是大数据平台。存算分离架构对这类场景其实存在天然的“延迟矛盾”:报表期望秒级响应,而对象存储上的数据查询,即便是Trino,首次查询也需要秒级到十几秒的加载时间。

我接触过不少做网约车、零售、物流这类业务的数据团队,他们的数据大屏选型通常是Flask+ECharts这类轻量方案,大屏数据接口直接查数据仓库的聚合结果表。在存算分离架构下,正确的做法绝不是让Flask接口去实时扫描对象存储里的明细表,而是分层处理:

  • 底层明细数据放在对象存储,离线和准实时任务定时把结果写入聚合层。
  • 聚合层可以是一张存储在ClickHouse、Doris、StarRocks或MySQL中的结果表,这些结果表数据量小、查询延迟低。
  • Flask或Java后端只查询聚合层,不直接触碰对象存储。

这个分层看起来像是“又回到了以前的老路”,但它在存算分离架构下效率极高:明细层保留全部历史数据用于深度分析,聚合层只占很小存储,计算资源消耗低。换句话说,存算分离不等于让所有查询都对着对象存储跑,而是要把“扫描大数据”和“服务小请求”这两类事情拆开处理。

6.2 物化视图与查询加速的常用组合

对于报表场景,我常用的一套组合是:

  1. Iceberg表存明细:所有DWD层明细数据落到Iceberg表,存对象存储。
  2. Doris/StarRocks作为聚合查询层:每天凌晨从Iceberg表同步聚合结果到Doris,或者用Spark SQL直接写Doris。
  3. 大屏接口只查Doris:查询延迟控制在毫秒级,无论底层明细数据有多大。

这套组合既利用了对象存储的低成本,又规避了它查询慢的问题。当然你会问:为什么不直接把所有数据放Doris?原因很简单:成本。Doris这类OLAP引擎的多副本存储成本远高于对象存储,明细数据放Doris,存储费用会高出几个数量级。正确做法是:热数据、聚合数据放查询引擎,冷数据、明细数据放对象存储。

6.3 定时调度的容错设计:存算分离下任务挂了的救护措施

最后聊一个踩坑经验。存算分离架构下,计算集群如果完全弹性伸缩,任务调度失败的场景跟传统HDFS完全不同。传统集群上任务挂了可以原地重试,因为数据还在;弹性集群一旦缩容,挂掉的任务需要重新拉起一个集群才能重试。如果调度器没有设计好,会出现“重试时集群已经缩没了,任务卡死等集群”的尴尬。

我们的做法是给所有定时任务加一个“弹性集群唤醒”机制:调度器发现没有可用集群时,先调用云API拉起一个指定规格的临时集群,等集群Ready后,再把任务提交上去。这里有两个经验值可以分享:

  • 拉起一个10节点左右的EMR集群,从提交到Ready,大概3到5分钟。所以SLA要求10分钟内必须启动的任务,必须提前把集群Hot Standby,不能冷启动。
  • 如果大量任务都要“等集群”,尽量把任务的启动时间错开10到15分钟,避免同时唤醒多个集群,造成资源浪费。

对于报表和大屏这种固定SLA的任务,我甚至建议保留一个常驻小集群,专门服务核心报表,不参与弹性缩容。毕竟大屏挂了是直接面向领导的,宁可多花一点常驻成本,也不能让大屏在关键时刻转圈圈。

7. 从改造中提炼的经验清单:哪些坑不必重复踩

做完整套存算分离改造,我有几条比较实用的经验,写在这里当备忘,也分享给准备踩这条路的团队。

第一,先在“影子环境”验证,再动生产。影子环境不用真的复制一份完整数据,可以只复制几张核心表,关键是验证S3A配置、Hive Metastore指向、权限策略这几个“高危点”。我们当时跳过这步直接在生产环境小范围试跑,结果权限策略配错了,整个部门的人查询全部报403,排查了整整一个下午。

第二,把对象存储访问参数调到生产级别。默认的S3A连接参数对大数据量并不友好,比如连接池大小、最大连接数、上传分块大小这些,建议按数据量级预先调整。举几个常用参数:

<property> <name>fs.s3a.connection.maximum</name> <value>2048</value> </property> <property> <name>fs.s3a.threads.max</name> <value>2048</value> </property> <property> <name>fs.s3a.fast.upload.buffer</name> <value>disk</value> </property> <property> <name>fs.s3a.multipart.size</name> <value>128M</value> </property>

如果连接池太小,数据量大时会出现“连接被拒绝”“超时重试”之类的诡异报错,日志里看起来像是网络问题,其实是参数没调到位。

第三,不要所有表都套同一个生命周期策略。不同表的数据热度和访问模式差异很大,统一用“90天转低频”这种策略一定会在某个表上出事。建议按业务域拆分成不同的生命周期分组,比如订单域热数据保留长一点、日志域热数据保留短一点。宁可运维上多写几条策略,也别把所有表一锅炖。

第四,把“缓存预热”纳入调度日常。存算分离之后,每天第一波查询是最慢的,因为缓存都是空的。我们后来做了一个简单的“预热任务”:每天凌晨在低峰期,用Trino把当天会用到的主流报表查询跑一遍,把热点文件拉到本地缓存。这样早上一上班,业务方查数基本都是秒出,体验稳定了很多。

我做过好几套存算分离的改造,实话讲,这套架构确实能省下很可观的成本,也让计算集群的弹性变得丝般顺滑,但前提是按照它的脾气来设计任务和数据组织方式。如果只是把数据换个地方存,查询反而更慢,账单也不一定好看。最关键的还是回到架构本身:让明细数据安安静静躺在低成本存储里,让计算资源按需起落,让查询引擎只碰它该碰的结果集——这样改造完,整个平台的运行状态会清爽很多。

最后再补充一个个人体会:存算分离改造不是一次性的,而是一个持续优化的过程。云厂商的对象存储性能、计算引擎的对接能力都在不断变化,每隔半年把新特性拉通试一遍,往往能再次省下一大笔成本。比如我们后来开了对象存储的生命周期和缓存预热能力,改造后第一年只做了存储迁移,第二年做了分层存储,第三年又引入了物化视图,每一轮优化都有实实在在的账单回报。如果你们正处在一个数据规模快速增长、机房成本压力越来越大的阶段,存算分离是值得投入的长期方向。

返回列表