4、数据存储选型:对象存储、列式存储与数据湖
数据存储选型这件事,说实话,是MLOps数据底座里最容易被低估的一环。很多人一上来就想着搭模型、调参数,结果数据量一上来,存储先崩了。我见过不止一个团队,因为选错了存储,最后整个pipeline重写——那滋味,真不好受。
今天咱们就把这块掰开揉碎聊清楚。我会从三个维度来讲:对象存储、列式存储和数据湖。它们不是互斥的,而是互补的。你想想看,一个完整的MLOps平台,这三样基本都得用上。
核心观点:没有万能的存储方案,只有适合你场景的组合拳。对象存储管原始数据,列式存储管在线查询,数据湖管分析治理——各司其职。
4.1 对象存储:MinIO 与 S3
对象存储,说白了就是把数据当对象来管。每个对象有个唯一的ID,存在扁平化的命名空间里。跟传统的文件系统不一样,它没有目录树的概念——虽然你看着像有目录,其实那只是前缀。
我个人习惯把对象存储叫做「数据沼泽」——什么东西都能往里扔。图片、日志、模型权重、训练数据,统统可以。而且它天生就是分布式、高可用的。
4.1.1 S3 兼容协议
AWS S3 是对象存储的事实标准。现在几乎所有对象存储都宣称兼容 S3 API。MinIO 就是其中最典型的开源实现。
| 特性 | S3(AWS) | MinIO |
|---|---|---|
| 部署方式 | 托管服务 | 自建 / 私有云 |
| API 兼容 | 原生 | 100% S3 兼容 |
| 性能 | 受限于网络 | 本地部署,低延迟 |
| 成本 | 按量付费 | 硬件成本 + 运维 |
| 适用场景 | 云原生、弹性伸缩 | 私有化、边缘、合规 |
我的经验:在MLOps场景里,我建议把MinIO作为本地缓存层,S3作为远程归档层。训练时从MinIO读数据,模型归档时推到S3。这样既快又省钱。
4.1.2 在 MLOps 中的典型用法
- 原始数据存储:日志、传感器数据、图片、视频,直接以对象形式存入 bucket
- 模型版本管理:每个训练产出的模型权重文件,按版本号命名存入
- 中间结果缓存:特征工程产出的 parquet 文件,临时存放
- 实验日志:MLflow 等工具的后端存储,直接对接 S3
# 使用 boto3 操作 S3 兼容存储
import boto3
client = boto3.client(
's3',
endpoint_url='http://minio:9000', # MinIO 地址
aws_access_key_id='minioadmin',
aws_secret_access_key='minioadmin'
)
# 上传模型文件
client.upload_file(
'model_v1.pt',
'ml-models',
'experiments/exp_001/model_v1.pt'
)
# 列出所有模型版本
response = client.list_objects_v2(
Bucket='ml-models',
Prefix='experiments/exp_001/'
)
for obj in response.get('Contents', []):
print(obj['Key'])
避坑指南:我曾经遇到过团队把 MinIO 当数据库用——频繁地小文件读写。对象存储对小文件的性能很差,因为每次操作都有 HTTP 开销。记住:对象存储适合大文件、批量读写,不适合高频小 IO。
4.2 列式存储:HBase 与 Cassandra
列式存储跟行式存储最大的区别,就是它按列族来组织数据。你想想看,在ML场景里,我们经常只需要读取某几个特征列,而不是整行数据。这时候列式存储的优势就出来了。
HBase 和 Cassandra 都是 NoSQL 数据库,但设计哲学完全不同。我简单对比一下:
| 维度 | HBase | Cassandra |
|---|---|---|
| 一致性模型 | 强一致性(CP) | 最终一致性(AP) |
| 架构 | 主从架构(HMaster + RegionServer) | 去中心化(Gossip 协议) |
| 查询语言 | API 操作(Scan/Get/Put) | CQL(类 SQL) |
| 适用场景 | 在线特征存储、时序数据 | 高可用写入、跨数据中心 |
| 运维复杂度 | 较高(依赖 HDFS、ZooKeeper) | 中等(无单点故障) |
4.2.1 HBase 在特征存储中的应用
在MLOps里,HBase 最常见的角色就是在线特征存储。模型推理时,需要根据实体ID快速获取特征向量。HBase 的随机读写能力正好满足这个需求。
// HBase 特征存储表设计示例
Table: feature_store
RowKey: user_id + timestamp
Column Family: features
- age
- gender
- click_count_7d
- purchase_amount_30d
Column Family: metadata
- update_time
- data_version
// Java API 示例
Get get = new Get(Bytes.toBytes("user_12345_20240101"));
get.addFamily(Bytes.toBytes("features"));
Result result = table.get(get);
byte[] age = result.getValue(
Bytes.toBytes("features"),
Bytes.toBytes("age")
);
关键设计原则:RowKey 的设计决定了查询性能。我习惯把高频查询的字段放在 RowKey 前缀,比如 user_id。避免使用单调递增的 RowKey,否则会导致 Region 热点。
4.2.2 Cassandra 的高可用写入
Cassandra 的优势在于写入性能。如果你有海量的实时特征数据需要写入,而且对一致性要求不那么苛刻,Cassandra 是更好的选择。
我记得有个项目,需要每秒写入10万条用户行为数据用于实时特征计算。HBase 扛不住这么高的写入压力,换成 Cassandra 后,问题就解决了。
-- Cassandra 表结构
CREATE TABLE ml_features.user_features (
user_id text,
feature_name text,
feature_value double,
update_time timestamp,
PRIMARY KEY (user_id, feature_name)
) WITH compaction = {
'class': 'LeveledCompactionStrategy'
};
-- 写入示例
INSERT INTO ml_features.user_features (
user_id, feature_name, feature_value, update_time
) VALUES (
'user_12345', 'click_count_7d', 42.0, toTimestamp(now())
);
避坑指南:我曾经踩过一个坑——把 Cassandra 当关系型数据库用,做了很多跨分区的范围查询。Cassandra 的查询必须带分区键,否则性能极差。设计表结构时,一定要先想好查询模式。
4.3 数据湖:Delta Lake 与 Iceberg
数据湖这个概念,说白了就是「把数据都放在一个湖里,想怎么分析就怎么分析」。但早期的数据湖有个大问题——没有事务支持,数据质量没法保证。
Delta Lake 和 Apache Iceberg 就是来解决这个问题的。它们给对象存储加上了事务层,让数据湖变得可靠。
4.3.1 Delta Lake
Delta Lake 是 Databricks 开源的,跟 Spark 深度集成。它的核心是事务日志——每次数据变更都会记录在日志里,支持 ACID 事务。
- 时间旅行:可以查询任意历史版本的数据
- Schema 演进:支持自动添加、修改列
- 数据版本管理:每个操作都有版本号
- 统一批流:批处理和流处理共用同一份数据
# Delta Lake 基本操作
from pyspark.sql import SparkSession
spark = SparkSession.builder \
.appName("DeltaLakeDemo") \
.config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") \
.config("spark.sql.catalog.spark_catalog", "org.apache.spark.sql.delta.catalog.DeltaCatalog") \
.getOrCreate()
# 写入数据
df = spark.range(1000)
df.write.format("delta").save("/data/delta/numbers")
# 时间旅行:查询历史版本
df_v1 = spark.read.format("delta") \
.option("versionAsOf", 1) \
.load("/data/delta/numbers")
# Schema 演进
df_new = df.withColumn("new_col", df.id * 2)
df_new.write.format("delta") \
.option("mergeSchema", "true") \
.mode("append") \
.save("/data/delta/numbers")
4.3.2 Apache Iceberg
Iceberg 跟 Delta Lake 类似,但更强调开放性和兼容性。它不绑定任何计算引擎,Spark、Flink、Trino 都能用。
| 特性 | Delta Lake | Apache Iceberg |
|---|---|---|
| 事务实现 | 事务日志(JSON) | Manifest 文件 + Snapshot |
| 引擎绑定 | 深度绑定 Spark | 引擎无关 |
| 分区演进 | 不支持 | 支持动态分区 |
| 隐藏分区 | 不支持 | 支持(自动分区裁剪) |
| 社区生态 | Databricks 主导 | Apache 基金会 |
我的建议:如果你的技术栈以 Spark 为主,选 Delta Lake 会更顺手。如果有多引擎需求,或者希望更开放,Iceberg 是更好的选择。我个人在混合架构里更倾向 Iceberg,因为它不绑架你用什么引擎。
4.3.3 数据湖在 MLOps 中的定位
数据湖在MLOps里主要干三件事:
- 训练数据管理:保证训练集和测试集的可追溯性,每次训练用的数据版本都能回溯
- 特征存储的底层存储:离线特征计算的结果写入数据湖,再同步到 HBase 等在线存储
- 实验数据归档:每次实验的输入数据、中间结果、最终模型,都按版本归档
# 使用 Iceberg 管理训练数据
CREATE TABLE ml_training.user_features_iceberg (
user_id STRING,
feature_name STRING,
feature_value DOUBLE,
event_time TIMESTAMP,
data_version STRING
) USING iceberg
PARTITIONED BY (days(event_time));
-- 插入训练数据
INSERT INTO ml_training.user_features_iceberg
SELECT
user_id,
feature_name,
feature_value,
event_time,
'v20240101' as data_version
FROM raw_features
WHERE event_time >= '2024-01-01';
-- 查询特定版本的数据
SELECT *
FROM ml_training.user_features_iceberg
WHERE data_version = 'v20240101'
AND user_id = 'user_12345';
核心原则:数据湖不是用来替代对象存储或列式存储的,而是在它们之上加了一层「治理层」。对象存储管原始数据,列式存储管在线查询,数据湖管版本和事务。三者配合,才是完整的MLOps数据底座。
4.4 选型决策矩阵
说了这么多,到底怎么选?我整理了一个决策矩阵,你可以根据自己场景对号入座:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 原始数据存储(图片、日志) | MinIO / S3 | 对象存储天然适合大文件、低成本 |
| 在线特征查询(毫秒级) | HBase | 强一致性,随机读写性能好 |
| 高并发特征写入 | Cassandra | 写入性能极佳,去中心化 |
| 训练数据版本管理 | Delta Lake / Iceberg | 支持时间旅行和事务 |
| 多引擎分析查询 | Iceberg | 引擎无关,兼容性好 |
| 私有化部署、合规要求 | MinIO + HBase + Iceberg | 全栈自建,数据不出域 |
最后提醒一句:不要为了用新技术而用新技术。我见过有人在小团队里硬上 Cassandra,结果运维成本比开发成本还高。选型之前,先想清楚你的数据量、查询模式、团队能力。合适的才是最好的。