第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 的关键操作:
- WriteParams 配置:影响 Fragment 大小和数量
- 批量优化:编码、压缩的自动选择
- 事务保证:ACID 属性的实现
- 并发处理:多 Fragment 的并行写入
- 性能调优:Batch 大小、并发数的选择
下一章将讨论数据更新和 Schema 演化。