模型效果从 0.81 掉到 0.74,你回滚了代码,问题依旧——因为变的是数据,不是代码。这类事故在 ML 团队里反复上演:代码有 Git 管着,数据和模型却散落在各种路径与对象存储里,无人记得哪个版本对应哪次实验。
数据版本控制(Data Versioning) 解决「存哪一份」,数据血缘(Data Lineage) 解决「这份数据从哪来、影响了谁」。两者合起来,才让 ML 系统真正可复现、可审计。本文讲清 DVC 与 LakeFS 两套主流方案的原理与取舍。
为什么代码版本控制不够
要一起版本化的三样东西
一次可复现的实验,依赖三样东西的精确版本:
| 对象 | 典型载体 | 版本工具 |
|---|---|---|
| 代码 | .py / 配置 | Git |
| 数据 | CSV / Parquet / 图像 | DVC / LakeFS |
| 模型与环境 | 权重文件 / 依赖 | DVC / 容器镜像 |
三者任意一个漂移,结果就无法复现。Git 只能管小文件,把几十 GB 的数据塞进去会让仓库爆炸;把数据放对象存储又丢了版本语义。这就是专门工具存在的理由。
复现的三个层次
- 能重跑:有代码和数据,能跑出结果。
- 能重跑出相同结果:加上固定的随机种子、依赖版本、硬件环境。
- 能证明是相同结果:有内容哈希(content hash)作为指纹,可跨环境比对。
多数团队停在第一层,出问题才发现第二、三层缺失。数据版本工具的价值,是把后两层变成默认行为。
版本化的三种策略
在选工具之前,先想清楚用哪种策略存版本,它决定了存储成本与操作复杂度:
| 策略 | 做法 | 存储成本 | 检出速度 |
|---|---|---|---|
| 全量快照 | 每个版本复制一份完整数据 | 高(版本数 × 数据量) | 快 |
| 差异存储 | 只存与上一版的差异(如 Delta) | 低 | 中(需回放) |
| 指针/元数据 | 数据不复制,只记录逻辑引用 | 极低 | 快(零拷贝) |
DVC 走的是「内容寻址 + 指针」,LakeFS 走的是「元数据指针」。两者都属于第三类,因此新增版本几乎不占额外空间——只要数据内容不变,重复提交不增加存储。
差异存储(Delta Lake、Iceberg)则属于第二类,适合高频小批量追加的场景(如每日增量表),它的时间旅行(time travel)能力可以直接查询历史版本:
-- Delta Lake:查询某个版本的快照
SELECT * FROM events VERSION AS OF 12;
SELECT * FROM events TIMESTAMP AS OF '2026-10-01 00:00:00';
选择策略的判断依据很简单:数据是否整体替换、还是增量追加。整体替换用指针类(DVC/LakeFS),增量追加用差异类(Delta/Iceberg)。
DVC:把数据当代码管
DVC(Data Version Control) 的思路是:大文件不进 Git,Git 里只存一个指向数据的指针(.dvc 文件,内含内容哈希),真实数据放在远程存储(S3、GCS、NAS、SSH)。
基本工作流
# 初始化(在 Git 仓库内)
dvc init
# 配置远程存储
dvc remote add -d storage s3://my-bucket/dvc-store
dvc remote modify storage region cn-north-1
# 把大文件纳入 DVC 管理
dvc add data/raw/train.parquet
# → 生成 data/raw/train.parquet.dvc(指针,进 Git)
# → 数据本体进 .dvc/cache
git add data/raw/train.parquet.dvc data/raw/.gitignore
git commit -m "track training data v1"
# 推送数据到远程
dvc push
# 换台机器拉取
dvc pull
关键机制是内容寻址(content-addressable):缓存里的文件名是数据内容的哈希。同样的数据只存一份,天然去重;数据内容变了哈希就变,天然检测到漂移。
流水线:dvc.yaml
DVC 最强大的部分是流水线(Pipeline):把「数据 → 处理 → 模型 → 评估」定义为有向无环图,DVC 据此判断哪些步骤需要重跑。
stages:
prepare:
cmd: python src/prepare.py data/raw data/prepared
deps:
- data/raw/train.parquet
- src/prepare.py
outs:
- data/prepared
params:
- split.test_size
train:
cmd: python src/train.py data/prepared models/model.pkl
deps:
- data/prepared
- src/train.py
outs:
- models/model.pkl
params:
- train.n_estimators
- train.max_depth
evaluate:
cmd: python src/evaluate.py models/model.pkl data/prepared metrics.json
deps:
- models/model.pkl
- data/prepared
metrics:
- metrics.json
dvc repro # 只重跑输入变化的阶段
dvc dag # 查看依赖图
dvc metrics diff # 对比不同提交的指标
dvc params diff # 对比超参数变化
dvc repro 会比对每个阶段的依赖哈希与输出哈希,只重跑受影响的下游。改了 train.n_estimators,prepare 不重跑,train 与 evaluate 重跑——这正是「增量复现」的核心。
缓存与远程存储
.dvc/cache 是本地缓存,dvc push/pull 与远程同步。工程上要注意:
- 缓存目录别放 Git 仓库内的大盘上,容易撑爆磁盘,可用
dvc cache dir迁移。 - 远程存储加生命周期策略:旧版本数据可归档到低频存储,别一直放标准层。
- 哈希校验:
dvc status会校验本地文件与指针是否一致,是发现「有人偷偷改了数据」的第一道防线。
LakeFS:对象存储上的 Git
DVC 管的是文件级版本,适合单机/单团队的小规模数据。当数据量到 TB/PB 级、多人多任务并行读写对象存储时,需要另一套模型——LakeFS 把 S3/GCS/Azure Blob 变成一个「有分支和提交的数据湖」。
分支与提交模型
LakeFS 在对象存储之上维护一套元数据,数据文件本身不复制,只维护「逻辑路径 → 物理对象」的映射:
# 创建分支
lakectl branch create lakefs://repo/experiment-42 \
--source lakefs://repo/main
# 在分支上写数据(业务代码只需把 endpoint 指向 LakeFS)
# 提交
lakectl commit lakefs://repo/experiment-42 -m "add feature table v2"
# 合并回主干
lakectl merge lakefs://repo/experiment-42 lakefs://repo/main
# 回滚到某个提交
lakectl branch reset lakefs://repo/main --commit <commit-id>
核心价值是零拷贝分支(zero-copy branching):新建分支是 O(1) 操作,不复制任何数据文件,只是新建一套元数据指针。这让「每个实验一个分支」「每个 ETL 任务一个沙箱」变得廉价。
与 Spark / 数据湖集成
LakeFS 兼容 S3 API,Spark 只需改 endpoint:
spark = (SparkSession.builder
.config("spark.hadoop.fs.s3a.endpoint", "http://lakefs.example.com")
.config("spark.hadoop.fs.s3a.access.key", LAKEFS_ACCESS_KEY)
.config("spark.hadoop.fs.s3a.secret.key", LAKEFS_SECRET_KEY)
.config("spark.hadoop.fs.s3a.path.style.access", "true")
.getOrCreate())
# 读写带分支的路径:s3a://repo/branch/path
df = spark.read.parquet("s3a://mydata/experiment-42/features/user_profile/")
df.write.mode("overwrite").parquet("s3a://mydata/experiment-42/features/user_v2/")
写操作落在分支上,主干不受影响;验证无误再 merge。这解决了数据湖最大的痛点——没有隔离,一次错误的写入污染整个生产数据。
与 DVC 的定位差异
| 维度 | DVC | LakeFS |
|---|---|---|
| 版本粒度 | 文件/目录 | 对象存储整仓 |
| 分支代价 | 数据需复制或重新上传 | 零拷贝,O(1) |
| 适用规模 | GB~TB,单团队 | TB~PB,多团队并发 |
| 与计算引擎 | 本地/脚本 | Spark/Flink 原生 |
| 学习成本 | 低 | 中(需部署服务) |
实践中常见组合:用 LakeFS 管数据湖的宽表与原始数据,用 DVC 管训练集快照与模型文件,各司其职。
数据血缘:从哪来到哪去
版本控制回答「哪一份」,血缘回答「这份数据怎么来的、变了会影响谁」。
血缘的两个方向
- 上游血缘(Upstream):这份表由哪些源数据、经过哪些作业产生——用于根因定位。
- 下游血缘(Downstream):这份表被哪些下游消费——用于影响分析。
采集方式
| 方式 | 说明 | 覆盖度 |
|---|---|---|
| 解析 SQL / DAG | 从 SQL 语句或调度 DAG 静态解析 | 高,但动态 SQL 难覆盖 |
| 运行时埋点 | 在 ETL 执行时上报读写关系 | 最准,需侵入 |
| OpenLineage 标准 | 统一的血缘事件规范 | 生态兼容性好 |
| 日志解析 | 从作业日志中正则抽取 | 低,易漏易错 |
| 元数据 API | 从数仓 catalog 同步 | 中,依赖 catalog 完整度 |
| 手工登记 | 人工维护映射表 | 低,维护成本高 |
用 OpenLineage 标准上报,能让血缘在 Airflow、Spark、dbt 之间互通:
from openlineage.client import OpenLineageClient
from openlineage.client.run import RunEvent, RunState, Run, Job, Dataset
client = OpenLineageClient(url="http://marquez:5000")
client.emit(RunEvent(
eventType=RunState.COMPLETE,
run=Run(runId="a1b2c3"),
job=Job(namespace="etl", name="build_features"),
inputs=[Dataset(namespace="lakefs", name="repo/main/raw/events")],
outputs=[Dataset(namespace="lakefs", name="repo/main/features/user_profile")],
))
把事件汇总到 Marquez 之类的服务,就能查询「user_profile 的上游有哪些表」「改了 raw/events 会影响哪些模型」。
血缘的用途
- 影响分析:改一个字段前,先查下游依赖,避免误伤。
- 根因定位:模型指标下降时,沿血缘回溯到具体哪张源表、哪个作业出错。
- 合规审计:证明某个预测所用数据的来源与处理链路。
- 去冗余:识别无人消费的「僵尸表」,清理存储成本。
血缘图的存储与查询
血缘本质是一张有向无环图(DAG),节点是数据集与作业,边是读写关系。存储上有两种选择:
- 图数据库:把数据集与作业作为顶点、读写关系作为边,天然适合「查 N 跳上游/下游」。查询形如「
user_profile的 3 跳内下游」用图遍历一条语句即可。 - 关系表 + 递归 CTE:用
edges(from, to)表存边,递归查询实现多跳。实现简单,但深层递归性能差。
一个常见的查询需求是影响分析——改某个源字段会影响哪些模型:
-- 递归查出所有下游依赖(关系表实现)
WITH RECURSIVE downstream AS (
SELECT to_node FROM edges WHERE from_node = 'raw/events'
UNION
SELECT e.to_node FROM edges e
JOIN downstream d ON e.from_node = d.to_node
)
SELECT * FROM downstream;
血缘图还要记录时间维度:同一个作业在不同时间读写的表可能不同,只有带时间的血缘才能回答「上周那次训练用的是哪版数据」。
与 CI/CD 的集成
数据版本工具要嵌入自动化流程才有价值。典型模式是把数据校验与流水线重跑接进 CI:
# GitHub Actions:数据变更时自动重跑受影响阶段
name: dvc-pipeline
on:
push:
paths:
- 'data/**'
- 'src/**'
- 'params.yaml'
jobs:
reproduce:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: iterative/setup-dvc@v1
- run: dvc pull
- run: dvc repro
- run: dvc metrics diff origin/main --show-md >> $GITHUB_STEP_SUMMARY
把 dvc metrics diff 的结果贴到 PR 里,评审者就能直接看到「这次改动让准确率涨了 0.02、让训练时间多了 3 分钟」。这把数据与模型的变更纳入了和代码同等的评审流程——是数据科学走向工程化的标志。
数据质量与版本联动
光有版本还不够,还要知道每个版本的数据是否合格。常见做法是把质量检查绑定到版本提交:
# 提交前跑质量门禁
stages:
validate:
cmd: python src/validate.py data/prepared
deps:
- data/prepared
metrics:
- quality_report.json:
cache: false
质量报告随版本一起留存:行数、空值率、分布偏移(与上一版本对比)、主键唯一性。当某次提交的分布偏移超过阈值时,应阻断流水线或至少告警。这样血缘图上的每个节点都带着「健康状态」,回溯时能一眼看到问题源头。
工程实践与常见坑
- 指针文件必须进 Git,数据本体绝不进:
.dvc与.gitignore一起提交,否则协作者拉不到。 - dvc.lock 要提交:它锁定每个阶段的确切输入输出哈希,是「能重跑出相同结果」的保证。
- 别对频繁变动的小文件用 DVC:每次
dvc add都会产生新缓存,海量小文件会让缓存爆炸。 - LakeFS 的 GC 要配好:未被任何提交引用的对象需定期回收,否则存储持续增长。
- 血缘别只做「事后补」:等到需要时再补埋点,历史数据缺失,图是不完整的,要尽早接入。
- 敏感数据在血缘里脱敏:血缘元数据常含表名、字段名,涉及敏感信息时要过滤。
- 别把版本与备份混为一谈:版本是「可回滚的多个逻辑副本」,备份是「防硬件故障」,两者目标不同,都需要。
- 本地缓存要能重建:缓存丢了应能从远程
dvc pull恢复,不能成为唯一副本。 - 跨环境哈希一致性:换操作系统或文件系统时校验哈希算法一致,避免「同一份数据哈希不同」。
- 流水线幂等:同一版本重跑应产生相同输出,若含时间戳、随机数等非确定性因素,要显式固定。
- 元数据别写进数据文件:把版本号、来源写在旁路元数据里,而不是塞进数据内容,否则每次都会产生「新版本」。
团队协作中的版本约定
工具之外,还需要约定,否则版本管理会变成「人人都在用,但没人对得上」:
- 命名规范:数据版本号与 Git 提交关联,如
data-v2026.10.08-<短哈希>,一眼能追溯到代码。 - 实验即分支:LakeFS 上一个实验一个分支,实验结束要么合并要么删除,不留悬空分支。
- 只读快照:训练用的数据集一旦定版就设为只读,任何修改都必须走新版本。
- 版本进入实验记录:每次训练都记录「代码提交 + 数据版本 + 参数」三元组,缺一不可。
- 定期 GC:约定多久清理一次无引用的缓存与分支,避免存储无声增长。
这些约定应写进团队的工程规范,并在 CI 里做基础校验(如提交时检查 .dvc 指针是否与数据一致)。
小结
数据版本控制与血缘是 ML 可复现性的两根支柱:DVC 用内容哈希把文件与流水线纳入版本管理,LakeFS 用零拷贝分支让数据湖具备 Git 式的隔离与回滚,血缘则把分散的版本串成一张可查询的依赖图。三者配合,才能在指标异常时快速定位「是哪份数据、哪个版本、哪个上游作业」出了问题。它们与实验管理紧密相关——每次实验都应记录所用的数据版本,可进一步阅读 实验跟踪 ;在流水线编排层面,MLOps 流水线 说明了如何把版本与质量门禁嵌入自动流程;更完整的数据目录与血缘体系可参考 数据目录与血缘 ,整体生命周期管理见 MLOps 生命周期 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。