探秘新一代向量存储格式Lance-format (十二) 数据写入流程

第12章:数据写入流程

🎯 核心概览

写入是数据进入 Lance 的关键路径。本章详解 WriteParams 配置、批量写入优化、事务处理和提交机制。


📊 第一部分:WriteParams 配置

What - WriteParams 是什么?

rust 复制代码
pub struct WriteParams {
    /// 最大行数/Fragment
    pub max_rows_per_file: u32,
    
    /// 最大字节数/Fragment  
    pub max_bytes_per_file: u64,
    
    /// 写入模式
    pub mode: WriteMode,  // Create, Append, Overwrite
    
    /// 并发写入 Fragment 数
    pub max_concurrent_writes: usize,
    
    /// 编码配置
    pub encoding_config: EncodingConfig,
}

pub enum WriteMode {
    Create,      // 创建新数据集
    Append,      // 追加到现有数据集
    Overwrite,   // 覆盖现有数据集
}

Why - WriteParams 为什么重要?

makefile 复制代码
性能影响分析:

场景:写入 1 亿行数据

配置 A(默认):
max_rows_per_file: 1000000
max_bytes_per_file: 256MB

Fragment 数:~100 个
写入时间:~10 分钟
内存使用:~1GB

配置 B(小 Fragment):
max_rows_per_file: 100000
max_bytes_per_file: 25MB

Fragment 数:~1000 个
写入时间:~15 分钟(更多开销)
内存使用:~100MB(更低)
查询时间:更快(更小的缓存)

配置 C(大 Fragment):
max_rows_per_file: 10000000
max_bytes_per_file: 2.5GB

Fragment 数:~10 个
写入时间:~5 分钟(更快)
内存使用:~5GB
查询时间:可能更慢(缓存压力)

选择标准:
- 实时数据:小 Fragment(方便更新)
- 批量数据:大 Fragment(写入快)

🏗️ 第二部分:批量写入优化

写入流程

arduino 复制代码
用户提供数据(Pandas DataFrame / Arrow Table)
    ↓
转换为 RecordBatch 流
    ↓
启动 Transaction
    ↓
分配 WriteBuffer
    ↓
逐 batch 处理:
├─ 接收 batch(可能几千行)
├─ 累积到 buffer(1M 行或 256MB)
├─ buffer 满时
│  ├─ 编码数据
│  ├─ 压缩
│  ├─ 写入文件
│  ├─ 创建 Fragment 元数据
│  └─ 清空 buffer
└─ 继续接收
    ↓
所有数据处理完
    ↓
Flush 最后的 buffer
    ↓
创建 Manifest
    ↓
原子提交

编码优化

rust 复制代码
pub struct WriteBuffer {
    /// 每列的原始数据
    columns: Vec<Vec<u8>>,
    
    /// 累积的行数
    row_count: usize,
    
    /// 容量限制
    capacity: usize,
}

impl WriteBuffer {
    pub async fn flush(&mut self) -> Result<DataFile> {
        // 步骤 1:为每列选择编码
        let encoders = self.choose_encoders();
        
        // 步骤 2:并行编码每列
        let encoded_columns = futures::future::join_all(
            encoders.iter().enumerate().map(|(i, encoder)| {
                encoder.encode(&self.columns[i])
            })
        ).await;
        
        // 步骤 3:拼接编码后的数据
        let file_data = combine_encoded_columns(&encoded_columns)?;
        
        // 步骤 4:写入对象存储
        let file_path = format!("data/{}.lance", self.next_file_id);
        self.store.put(&file_path, file_data).await?;
        
        // 步骤 5:清空 buffer
        self.reset();
        
        Ok(DataFile { path: file_path, row_count: self.row_count })
    }
    
    fn choose_encoders(&self) -> Vec<Box<dyn ColumnDataEncoder>> {
        self.columns.iter().enumerate().map(|(i, col_data)| {
            // 分析列的统计信息
            let stats = analyze_column(col_data);
            
            // 选择最优编码
            match &self.schema.fields[i].data_type {
                DataType::Int64 => {
                    if stats.cardinality < 0.01 {
                        Box::new(DictionaryEncoder::new())
                    } else if stats.is_monotonic {
                        Box::new(DeltaEncoder::new())
                    } else {
                        Box::new(BitpackingEncoder::new(stats.bit_width))
                    }
                }
                DataType::Utf8 => {
                    if stats.cardinality < 0.1 {
                        Box::new(DictionaryEncoder::new())
                    } else if stats.is_sorted {
                        Box::new(PrefixEncoder::new())
                    } else {
                        Box::new(RawEncoder::new())
                    }
                }
                _ => Box::new(RawEncoder::new()),
            }
        }).collect()
    }
}

💫 第三部分:事务与提交机制

写入事务

bash 复制代码
Transaction 生命周期:

BEGIN WRITE
    ↓
创建临时工作空间
    ↓
写入数据文件到临时位置
    ├─ data/_tmp/fragment_0.lance
    ├─ data/_tmp/fragment_1.lance
    └─ data/_tmp/fragment_N.lance
    ↓
验证数据(可选)
    ├─ 检查数据完整性
    ├─ 检查 Schema 兼容性
    └─ 检查约束(如果有)
    ↓
创建新 Manifest
    ├─ 指向新的 Fragment 文件
    ├─ 更新版本号
    └─ 记录时间戳
    ↓
提交:
├─ 1. 将临时数据文件原子重命名
├─ 2. 写入新 Manifest
├─ 3. 更新 _latest 指针
└─ 4. 成功返回
    ↓
如果失败,清理临时文件

提交处理器

rust 复制代码
pub struct CommitHandler {
    store: Arc<dyn ObjectStore>,
    lock_manager: Arc<dyn LockManager>,
}

impl CommitHandler {
    pub async fn commit_write(
        &self,
        manifest: Manifest,
        temp_files: Vec<String>,
    ) -> Result<u64> {
        // 步骤 1:获取分布式锁
        let _lock = self.lock_manager.acquire_lock().await?;
        
        // 步骤 2:读取当前版本
        let current_version = self.read_current_version().await?;
        let new_version = current_version + 1;
        
        // 步骤 3:重命名临时文件
        for temp_file in &temp_files {
            let final_path = temp_file.replace("_tmp/", "");
            self.store.move_object(temp_file, &final_path).await?;
        }
        
        // 步骤 4:写入 Manifest
        let manifest_path = format!("_versions/{}.manifest", new_version);
        self.store.put(&manifest_path, manifest.serialize()).await?;
        
        // 步骤 5:更新最新版本指针
        self.store.put("_latest", new_version.to_string().into()).await?;
        
        // 步骤 6:释放锁
        drop(_lock);
        
        Ok(new_version)
    }
}

📊 写入性能优化

批量大小的影响

diff 复制代码
写入 1GB 数据的性能对比:

Batch 大小太小(1000 行):
- 频繁编码、压缩、写入
- 系统开销大
- 时间:2 分钟

Batch 大小合适(100K 行):
- 编码、压缩效率高
- 系统开销均衡
- 时间:10 秒

Batch 大小太大(10M 行):
- 内存占用高(可能 10GB+)
- 可能 OOM
- 时间:5 秒(但可能失败)

推荐:100K-1M 行/batch

并发写入

python 复制代码
import lance
import pandas as pd
from concurrent.futures import ThreadPoolExecutor

# 并发写入多个 Fragment
def write_partition(df_partition):
    dataset = lance.open("data.lance", mode="append")
    dataset.add(df_partition)

# 分割数据为 10 个分区
partitions = [df[i::10] for i in range(10)]

# 并发写入
with ThreadPoolExecutor(max_workers=4) as executor:
    futures = [executor.submit(write_partition, p) for p in partitions]
    for f in futures:
        f.result()

# 性能:
# 串行:10 × 1分钟 = 10 分钟
# 并发(4 workers):10 分钟 / 4 ≈ 2.5 分钟
# 但注意:并发写入需要事务隔离

💡 最佳实践

何时使用哪种 WriteMode

ini 复制代码
WriteMode::Create
用途:创建新数据集
示例:
  dataset = lance.write_dataset(df, "new.lance", mode="create")
性能:最快(无需读取现有版本)

WriteMode::Append
用途:添加新数据
示例:
  dataset = lance.open("existing.lance")
  dataset.add(new_df)
性能:较快(只需读取最新版本)
注意:自动创建新 Fragment

WriteMode::Overwrite
用途:替换所有数据
示例:
  dataset = lance.write_dataset(df, "data.lance", mode="overwrite")
性能:最慢(需要删除旧版本的文件)
注意:旧版本变成不可访问的垃圾数据(可配置清理)

处理大文件

python 复制代码
import lance

# 方法 1:分块读取和写入
def write_large_csv_incremental(csv_path, output_path):
    chunks = pd.read_csv(csv_path, chunksize=100000)
    
    for i, chunk in enumerate(chunks):
        if i == 0:
            dataset = lance.write_dataset(chunk, output_path, mode="create")
        else:
            dataset = lance.open(output_path)
            dataset.add(chunk)

# 方法 2:使用流式 API(如果支持)
def write_large_parquet_streaming(parquet_path, output_path):
    reader = pd.read_parquet(parquet_path, engine="pyarrow")
    dataset = lance.write_dataset(reader, output_path, mode="create")

📚 总结

数据写入是 Lance 的关键操作:

  1. WriteParams 配置:影响 Fragment 大小和数量
  2. 批量优化:编码、压缩的自动选择
  3. 事务保证:ACID 属性的实现
  4. 并发处理:多 Fragment 的并行写入
  5. 性能调优:Batch 大小、并发数的选择

下一章将讨论数据更新和 Schema 演化。

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