探秘新一代向量存储格式Lance-format (十) Fragment 与数据分片

第10章:Fragment 与数据分片

🎯 核心概览

Fragment 是 Lance 数据集的逻辑分片单位,是在 Manifest 层面组织数据的关键。本章深入讲解 Fragment 的设计理念、组织方式和在实际中的应用。


📊 第一部分:Fragment 的设计理念

What - Fragment 是什么?

定义:Fragment 是 Lance 数据集中的逻辑分片,代表一个独立的、自包含的数据单元。

rust 复制代码
pub struct Fragment {
    pub id: u32,                    // Fragment ID
    pub physical_schema: Schema,    // 物理 Schema
    pub row_count: u64,            // 行数
    pub metadata: FragmentMetadata, // 元数据
    pub deletion_file: Option<String>, // 删除标记文件
}

Why - 为什么需要 Fragment?

问题:单文件的局限
复制代码
不分片方案:
数据集 = 单个 1GB 文件

问题:
✗ 文件太大,加载到内存需要 1GB+
✗ 删除行需要重写整个文件
✗ 并行读取困难
✗ 缓存不友好(无法只缓存部分列)
解决方案:Fragment 分片
复制代码
分片方案:
数据集 = 10 个 100MB Fragments

优势:
✓ 每个 Fragment 独立管理
✓ 删除只影响一个 Fragment
✓ 支持并行读取多个 Fragment
✓ 缓存以 Fragment 为单位
✓ Schema 演化更灵活

How - Fragment 的实现策略

分片大小的考虑
复制代码
Fragment 大小的权衡:

太小(10MB):
✗ 过多小文件,元数据开销大
✗ 列表操作频繁(创建、删除)
✓ 缓存更友好

太大(1GB):
✓ 文件数少,元数据开销小
✗ 不够灵活,删除成本高
✗ 缓存压力大

Lance 的建议:100-256MB
✓ 平衡文件数和单个文件大小
✓ 实践验证的最优大小

🏗️ 第二部分:数据文件组织

Fragment 的物理存储

bash 复制代码
一个 Dataset 的文件结构:

dataset/
├── _latest              # 指向最新版本
├── _versions/
│   ├── 0.manifest       # 版本 0 的 Manifest
│   ├── 1.manifest       # 版本 1 的 Manifest
│   └── 2.manifest       # 版本 2(当前)
├── data/
│   ├── 0.lance          # Fragment 0 数据文件
│   ├── 1.lance          # Fragment 1 数据文件
│   ├── 2.lance          # Fragment 2 数据文件(可能跨多个版本使用)
│   └── _deletions_2.txt # Fragment 2 的删除标记(v2 中)
└── indices/
    ├── embedding_0.idx  # Fragment 0 的索引
    └── embedding_1.idx  # Fragment 1 的索引

Fragment 跨版本共享

css 复制代码
关键特性:不同版本的数据集可以共享 Fragment

v0: Manifest {fragments: [F0, F1, F2], schema: {a, b, c}}
v1: Manifest {fragments: [F0, F1, F2, F3], schema: {a, b, c}}
    (F3 是新增数据)

v2: Manifest {
    fragments: [F0, F1, F2, F3], 
    schema: {a, b, c, d},
    F0_metadata: {new_column: d, default: 0}
}
    (添加列 d,F0-F3 的旧行自动填充默认值)

好处:
✓ 零拷贝列添加(只改元数据)
✓ 版本间共享数据文件(节省存储)
✓ 时间旅行无需数据重复

📝 第三部分:删除文件处理

删除机制

scss 复制代码
删除行的处理方式:

不使用删除文件:
row_id_list = [0, 5, 10, 15, ...]
扫描时遍历所有行,逐一检查是否在 row_id_list 中
性能:O(n),n 为行总数

使用删除文件(Bitmap):
deletion_file = Bitmap {
    bits: [1, 0, 0, 0, 1, ...]  // 1 表示已删除
    size: 8 bytes / 行
}
扫描时直接检查 bitmap
性能:O(1),1-2 cycles

Lance 的做法:
- 小删除量(<1%):inline 在 Fragment 元数据
- 大删除量(>1%):单独的删除文件

删除文件的实现

rust 复制代码
pub struct DeletionFile {
    pub fragment_id: u32,
    pub version: u64,
    pub bitmap: RoaringBitmap,  // 高效的位图实现
}

impl Dataset {
    pub async fn delete_rows(&mut self, row_ids: &[u32]) -> Result<()> {
        // 步骤 1:按 Fragment 分组
        let mut fragments_to_delete = HashMap::new();
        for &row_id in row_ids {
            let (frag_id, local_id) = resolve_row_id(row_id);
            fragments_to_delete.entry(frag_id)
                .or_insert_with(Vec::new)
                .push(local_id);
        }
        
        // 步骤 2:创建新 Manifest
        let mut new_manifest = self.manifest.clone();
        
        for (frag_id, local_ids) in fragments_to_delete {
            let mut bitmap = RoaringBitmap::new();
            for &id in &local_ids {
                bitmap.insert(id);
            }
            
            // 写入删除文件
            let del_file = DeletionFile {
                fragment_id: frag_id,
                version: new_manifest.version + 1,
                bitmap,
            };
            
            self.store.put(&del_file.path(), del_file.serialize()).await?;
            
            // 更新 Manifest 中的删除文件引用
            new_manifest.fragments[frag_id].deletion_file = Some(del_file.path());
        }
        
        // 步骤 3:原子提交新版本
        self.commit(new_manifest).await?;
        
        Ok(())
    }
}

扫描时的删除过滤

rust 复制代码
pub async fn scan_with_deletions(
    &self,
    fragment_id: u32,
) -> Result<impl Stream<Item = RecordBatch>> {
    let fragment = &self.manifest.fragments[fragment_id];
    
    // 加载删除 bitmap
    let deletion_bitmap = if let Some(del_file) = &fragment.deletion_file {
        let data = self.store.get(del_file).await?;
        DeletionFile::deserialize(&data)?.bitmap
    } else {
        RoaringBitmap::new()  // 空 bitmap,无删除
    };
    
    // 读取数据
    let reader = FragmentReader::open(self.store.clone(), fragment).await?;
    let batches = reader.read_all().await?;
    
    // 应用删除过滤
    Ok(batches.into_iter().map(move |batch| {
        filter_batch_by_deletion(&batch, &deletion_bitmap)
    }))
}

fn filter_batch_by_deletion(
    batch: &RecordBatch,
    deletion_bitmap: &RoaringBitmap,
) -> RecordBatch {
    let mut valid_indices = Vec::new();
    
    for i in 0..batch.num_rows() {
        if !deletion_bitmap.contains(i as u32) {
            valid_indices.push(i);
        }
    }
    
    batch.take(&valid_indices).unwrap()
}

💡 第四部分:Fragment 的实际应用

场景 1:时间序列数据

python 复制代码
import lance
from datetime import datetime

# 创建时间序列数据集,按天分片
dataset = lance.open("metrics.lance")

# 每天一个 Fragment
# 2024-01-01: Fragment 0
# 2024-01-02: Fragment 1
# ...
# 2024-12-31: Fragment 365

# 查询最近 7 天的数据
results = dataset.scan() \
    .where("timestamp >= '2024-12-25'") \
    .to_pandas()

# Lance 自动:
# 1. 识别涉及的 Fragment(只读 Fragment 359-365)
# 2. 并行读取 7 个 Fragment
# 3. 合并结果

场景 2:多源数据合并

python 复制代码
# 源 1:历史数据(1000万行)
historical = lance.open("historical_data.lance")

# 源 2:新增数据(100万行)
new_data = pd.DataFrame({...})

# 合并:创建新的 Fragment,添加到 Manifest
dataset = lance.open("merged.lance")
dataset.add(new_data)  # 创建新 Fragment

# 结果:
# Fragment 0: 历史数据(1000万行)
# Fragment 1: 新增数据(100万行)

场景 3:数据删除

python 复制代码
# 删除某个用户的所有数据
user_id_to_delete = 12345

dataset = lance.open("users.lance")

# 找出该用户的所有行
rows = dataset.scan() \
    .where(f"user_id = {user_id_to_delete}") \
    .to_pandas()

row_ids = rows.index.tolist()

# 删除这些行
dataset.delete_rows(row_ids)

# 内部操作:
# - 创建删除 bitmap
# - 写入删除文件
# - 更新 Manifest
# - 扫描时自动过滤这些行

📊 性能特征

Fragment 数量的影响

diff 复制代码
查询性能对比:

1000 万行数据集

配置 A:1 个 Fragment(1GB)
- 扫描时间:1000ms(加载 1GB)
- 缓存空间:1GB
- 删除成本:重写整个文件(高)

配置 B:10 个 Fragment(100MB 每个)
- 扫描时间:100ms(并行读 10 个)
- 缓存空间:100MB(单个缓存)
- 删除成本:只修改相关 Fragment(低)

配置 C:100 个 Fragment(10MB 每个)
- 扫描时间:50ms(并行读 100 个)
- 缓存空间:10MB(极小)
- 元数据开销:大
- 文件管理复杂度:高

推荐:10-50 个 Fragment

📚 总结

Fragment 是 Lance 的分片管理核心:

  1. 灵活分片:支持大数据集的高效管理
  2. 版本间共享:零拷贝的版本控制
  3. 独立删除:删除不影响其他 Fragment
  4. 并行处理:充分利用多核能力
  5. 缓存友好:支持精细粒度的缓存

在下一章中,我们将讨论 Scanner 和查询执行,即如何高效地从这些 Fragment 中读取数据。

相关推荐
语落心生2 小时前
探秘新一代向量存储格式Lance-format (九) 索引系统架构与向量搜索
架构
语落心生2 小时前
探秘新一代向量存储格式Lance-format (十二) 数据写入流程
架构
语落心生2 小时前
探秘新一代向量存储格式Lance-format (五) Lance 文件格式详解
架构
语落心生2 小时前
探秘新一代向量存储格式Lance-format (六) 编码与压缩技术
架构
语落心生2 小时前
探秘新一代向量存储格式Lance-format (七) 编码器与解码器实现
架构
语落心生2 小时前
探秘新一代向量存储格式Lance-format (四) 容器与缓存机制
架构
语落心生2 小时前
探秘新一代向量存储格式Lance-format (三) Lance 数据类型系统
架构
语落心生2 小时前
探秘新一代向量存储格式Lance-format (二) 项目结构与模块划分
架构
语落心生2 小时前
探秘新一代向量存储格式Lance-format (一)Lance 项目概览与设计理念
架构