인셔셔RSS 관심 있는 블로그, 뉴스, 기술 정보를 효율적으로 추적하고 읽으세요
원문 읽기 InertiaRSS에서 열기

추천 피드

Google DeepMind News
Google DeepMind News
人人都是产品经理
人人都是产品经理
M
MIT News - Artificial intelligence
博客园 - 叶小钗
MyScale Blog
MyScale Blog
V
Visual Studio Blog
月光博客
月光博客
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
量子位
I
InfoQ
有赞技术团队
有赞技术团队
阮一峰的网络日志
阮一峰的网络日志
Jina AI
Jina AI
V
V2EX
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Blog — PlanetScale
Blog — PlanetScale
Last Week in AI
Last Week in AI
雷峰网
雷峰网
Stack Overflow Blog
Stack Overflow Blog
博客园 - Franky

博客园 - Aitozi

An Empirical Evaluation of Columnar Storage Formats 从本地目录理解 Lance Dataset:Manifest、Fragment 与 Blob 论文解读:Lance 如何通过自适应结构编码提升列式存储随机访问 中国最大广告机器简史 学习Facebook,超越Meta|字节跳动 第3集 Paimon merge into 实现原理 Paimon Deletion Vector Paimon lookup store 实现 Flink Batch Hash Aggregate 理解 Paimon changelog producer 笔记工具 FlinkSQL类型系统 二叉堆原理与实现 SkipList原理与实现 Lakehouse: A New Generation of Open Platforms that Unify Data Warehousing and Advanced Analytics Delta Lake: High-Performance ACID Table Storage over Cloud Object Stores Paimon Compaction实现 Paimon读取流程 Paimon的写入流程 Calcite sql2rel 过程 用rust 写一个jar包 class冲突检测工具 rust 中 str 与 String; &str &String 好奇心: 保持对未知世界用不停息的热情 Apache hudi 核心功能点分析
랜스가 체인에 입력: Merge Into, Compaction과 Stable Row ID
Aitozi · 2026-05-24 · via 博客园 - Aitozi

Lance의 쓰기 연결은 파일 레이아웃, 버전 제출, 삭제 표시, 인덱스 유지 보수 및 compaction과 관련이 있습니다. 전통적인 데이터베이스와 달리 Lance는 원본 파일에 직접 데이터를 수정하지 않고, 새로운 파일을 추가하고 메타데이터를 업데이트하여 새로운 테이블 버전을 생성합니다.

본 글에서는 몇 가지 구현 문제에 대해 논의합니다:

  • delete, update, merge insert가 파일과 메타데이터에 어떻게 적용되는지.
  • Deletion Vector가 Lance 쓰기 연결에서의 역할.
  • Deletion Vector가 어떻게 쓰기 충돌 검사에 참여하는지.
  • Lance와 Apache Paimon가 Deletion Vector 설계에서의 차이점.
  • Compaction이 fragment를 어떻게 선택하고 인덱스 리매핑이 필요한 이유.
  • Stable Row ID는 compaction 후 인덱스 유지 비용을 어떻게 낮출 수 있는가.

기본적인 판단.

Lance의 기본 쓰기 형태는 새로운 데이터 파일 또는 deletion file을 쓰고, 그 다음 transaction과 manifest를 통해 새로운 버전을 제출하는 것입니다.

Delete:
  不立刻重写 data file
  -> 记录 fragment 内被删除的 row offset
  -> 读时过滤 deletion vector

Update / Merge:
  写出更新后的新 rows 或新 columns
  -> 对旧 rows 写 deletion vector
  -> 提交 Operation::Update

Compaction:
  读取旧 fragments 的 live rows
  -> 写成新的 fragments
  -> 旧 row address 可能失效
  -> 索引需要 remap 或重建

Stable Row ID:
  让索引指向稳定逻辑行 ID
  -> compaction 后只需要维护 row_id -> row_address 映射
  -> 降低索引 remap 成本

Lance의 영구 메타데이터에서는 보통 DeletionFile로 불리며, 내부 처리 시 DeletionVector를 사용합니다:

DeletionVector:
  内存中的删除集合语义

DeletionFile:
  DeletionVector 在表目录下的持久化文件引用

쓰기 연결의 통일된 모델.

LanceDB API를 보면, 사용자가 호출하는 것은:

table.add(...)
table.delete("id = 42")
table.update(where="id = 42", values={"text": "new"})
table.merge_insert("id").when_matched_update_all().when_not_matched_insert_all()
table.optimize.compact_files()

하지만 Lance의 하부에서 보는 것은 "테이블의 몇 행을 수정한다"가 아니라 한 번의 dataset 상태 전환입니다:

读取当前 manifest
  -> 执行 scan / join / filter
  -> 写新的 data files / deletion files / index files
  -> 构造 Operation
  -> 写 transaction
  -> 提交新的 manifest version

한 버전의 manifest는 현재 테이블의 완전한 상태를 설명합니다:

Manifest version N
  schema
  fragments
  indices
  version metadata

Fragment 1
  data files
  deletion file
  row id metadata

transaction은 이번 커밋에 대한 테이블 상태 변경을 설명합니다. 따라서 인덱스 생성, 컴팩트 파일, 구성 업데이트는 행 수가 변경되지 않더라도 새로운 버전이 생성될 수 있습니다.

Delete: Deletion Vector를 사용하여 보이지 않는 행을 표시합니다.

Delete는 즉시 data file을 변경하지 않고, 명시된 행을 deleted로 표시합니다.

연결을 간소화하면 다음과 같습니다:

delete where id = 42
  -> scan + predicate 找到命中 rows
  -> 捕获 row address
  -> 按 fragment 聚合成 local row offsets
  -> 更新 fragment.deletion_file
  -> Operation::Delete
  -> CommitBuilder.with_affected_rows(...)
  -> 提交新 manifest

Fragment 7에 1000행이 있다고 가정합니다:

Fragment 7
  data file: 1000 physical rows
  deletion file: {3, 19, 42}

读取 Fragment 7 时:
  offset 3、19、42 被过滤
  其他行仍然可见

소스 코드에서, Fragment 메타 데이터에는 선택 사항인 deletion_file 필드가 하나 있습니다. 의미는 "이 fragment 내에서 삭제된 로컬 행 오프셋"입니다. DeletionFileType 현재 두 가지 유형이 있습니다:

Array:
  适合较稀疏的删除集合

Bitmap:
  适合较密集的删除集合

소스 코드 입구:

rust/lance-table/src/format/fragment.rs
  DeletionFile
  DeletionFileType
  Fragment.deletion_file

rust/lance-table/src/io/deletion.rs
  write_deletion_file
  read_deletion_file

rust/lance/src/dataset/write/delete.rs
  apply_deletions
  DeleteBuilder

는 다음과 같은 역할을 합니다:

  • 적은 수의 행을 삭제할 때, 전체 fragment를 다시 쓰지 않아도 됩니다.
  • 객체 저장소에서도 기존 data file을 다시 쓰지 않아도 됩니다.
  • 삭제된 컬렉션은 독립적으로 진화할 수 있으며, manifest는 새로운 deletion file을 가리키기만 하면 됩니다.
  • 이후 compaction을 통해 삭제된 항목을 다시 구현할 수 있습니다.

대응 비용은: 읽기 경로에서 deletion vector를 로드하고, 스캔 중 tombstoned 행을 건너뛰어야 한다는 것입니다.

Update: 새로운 행을 쓰고, 이전 행을 삭제합니다.

Update의 일반적인 구현은: 업데이트된 새로운 데이터를 쓰고, 이전 행을 tombstone 처리하는 것입니다.

일반적인 update의 주요 경로는 다음과 같습니다.RewriteRows

update set text = 'new' where id = 42
  -> scan where 条件命中的 rows,并带上 row id / row address
  -> 对 batch 应用更新表达式
  -> write_fragments_internal 写出新的 fragments
  -> 对旧 row address 应用 deletion vector
  -> Operation::Update { update_mode: RewriteRows }
  -> CommitBuilder.with_affected_rows(...)

는 10개의 리스트로 1개의 열만 업데이트하는 예를 들어:

更新前:
  Fragment 1
    row offset 42 = (c1, c2, c3, ..., c10)

UPDATE SET c3 = c3_new WHERE id = 42

更新后:
  Fragment 1
    deletion file 标记 offset 42 deleted

  Fragment 9
    新写入 row = (c1, c2, c3_new, ..., c10)

RewriteRows는 전체 기존 fragment를 다시 작성하는 것이 아니라, 일치하는 rows를 새로운 rows로 작성한다. 각 업데이트된 row에 대해, 이는 완전한 행을 작성한다.

소스 코드 입구:

rust/lance/src/dataset/write/update.rs
  UpdateJob::execute_impl
  scanner.with_row_id()
  write_fragments_internal(...)
  apply_deletions(...)
  Operation::Update { update_mode: RewriteRows }

Lance와 RewriteColumns 모드는 주로 일부 schema의 merge/update 시나리오에서 나타난다. 이는 "많은 행, 적은 열"의 업데이트를 견디도록 설계되었지만, fragment, 열 파일, 인덱스 커버 범위 및 충돌 검사 유지보수 비용을 증가시킨다.

Merge Insert:Upsert 의미는 주 키 제약 조건과 같지 않다

LanceDB의 merge_insert는 upsert에 사용할 수 있다:

(
    table.merge_insert("id")
    .when_matched_update_all()
    .when_not_matched_insert_all()
    .execute(source)
)

여기서id는 source와 target을 매칭하는 join key이며, 데이터베이스 내 강제 제약의 primary key가 아닙니다.

의 단순화된 과정은 다음과 같습니다:

source 与 target 按 key join
  matched:
    更新 target rows

  not matched:
    插入 source rows

  not matched by source:
    keep 或 delete

는 full schema 경로에서 merge insert의 처리 방식이 일반적인 update에 가깝습니다.

matched rows:
  写出更新后的完整 rows 到新 fragments
  对旧 target rows 写 deletion vector

not matched rows:
  作为新 rows 写入新 fragments

commit:
  Operation::Update { update_mode: RewriteRows }

는 partial schema 경로에서는 RewriteColumns를 따릅니다:

source 只包含部分列
  -> update_fragments(...)
  -> Operation::Update { update_mode: RewriteColumns, fields_modified }

소스 코드 입구:

rust/lance/src/dataset/write/merge_insert.rs
  MergeInsertBuilder
  update_fragments
  Operation::Update { update_mode: RewriteRows | RewriteColumns }

삭제 벡터와 쓰기 충돌 검사

삭제 벡터는 삭제된 행을 읽을 때만 필터링하는 것이 아니라, Lance가 일부 쓰기 충돌을 "fragment 수준 충돌"에서 "행 수준 충돌"로 낮추는 데도 도움을 줍니다.

먼저 Deletion Vector / 영향받은 행이 없을 때의 문제를 살펴보겠습니다:

T1:
  delete row (Fragment 1, offset 10)

T2:
  delete row (Fragment 1, offset 20)

Fragment 수준에서만 보면 두 트랜잭션이 Fragment 1을 수정했으므로 쉽게 충돌로 판단될 수 있습니다. 하지만 실제로는 서로 다른 행을 삭제하므로 병합할 수 있습니다:

T1 deletion vector: {10}
T2 deletion vector: {20}

rebase 后:
  Fragment 1 deletion vector: {10, 20}

Lance는 delete/update 커밋 시 맞는 행 주소를 커밋 레이어에 전달합니다:

CommitBuilder.with_affected_rows(RowAddrTreeMap)

이로 인해 충돌 검사는 다음과 같이 판단할 수 있습니다:

两个并发事务是否真的修改了同一批 rows?

동일한 Fragment의 다른 행을 수정하고 다른 트랜잭션이 데이터 파일을 변경하지 않고 deletion file만 변경하는 경우, 현재 트랜잭션은 rebase할 기회가 있습니다. 즉, 새로운 Fragment deletion file을 기반으로 병합된 deletion vector를 다시 작성할 수 있습니다.

일반적인 경우:

可 rebase:
  T1 delete F1.offset 10
  T2 delete F1.offset 20
  -> affected_rows 不重叠
  -> 合并 deletion vector

不可 rebase:
  T1 update F1.offset 10
  T2 delete F1.offset 10
  -> affected_rows 重叠
  -> 语义冲突

不可简单 rebase:
  T1 delete F1.offset 10
  T2 compaction/rewrite F1
  -> fragment 的 data files 被重写
  -> row address / fragment 状态发生大范围变化

소스 코드 입구:

rust/lance/src/dataset/write/commit.rs
  CommitBuilder.with_affected_rows

rust/lance/src/io/commit/conflict_resolver.rs
  TransactionRebase
  check_delete_txn
  check_update_txn

따라서 Deletion Vector는 모든 쓰기 충돌을 제거하지 않습니다. 이는 Lance가 fragment 수준의 가짜 충돌을 피할 수 있도록 row 수준의 영향받은 행을 표현할 수 있게 합니다.

대량의 샘플을 포함하는 데이터 세트에서 업데이트, 삭제, merge는 종종 소수의 행만 영향을 미칩니다. 동시성 제어가 fragment 수준의粒度로만 이루어질 경우, 많은 서로 다른 행 수준의 변경 사항이 충돌로 판단될 수 있습니다.

Lance와 Paimon의 Deletion Vector 차이

Lance와 Apache Paimon 모두 Deletion Vector를 가지고 있지만, 이들이 해결하려는 문제는 완전히 다릅니다.

차원 Lance Apache Paimon
주요 데이터 모델 Arrow / Lance fragment을 위한 열식 데이터 세트 물빙 테이블, 주 키 테이블, LSM, 버킷, 스냅샷을 위한
DV 입자 크기 fragment 내 로컬 행 오프셋 데이터 파일 내 행 위치
표준 용도 삭제, 업데이트, 병합, 충돌 검사, 읽을 때 필터링, 압축 물리적 삭제 주 키 테이블 MOW 모드에서 읽을 때 병합을 피하고, 쓰기 시 DV 파일 생성
와 업데이트의 관계 업데이트 일반적인 경로는 새로운 rows 작성 + 기존 rows의 tombstone 생성 주 키 테이블은 LSM을 통해 기존 데이터를 찾아 DV 생성
컬럼 수준의 진화와의 관계 Lance는 fragment 여러 data files과 row id 메타데이터를 가지며, 업데이트 후에도 fragment/row address를 중심으로 유지 Paimon Data Evolution은 컬럼별 overlay 방식을 사용하며, first row id의 파일 읽기 시 합산
충돌 검사와의 관계 affected_rows는 동시성 delete/update를 row 수준의 rebase로 처리할 수 있게 합니다 는 주 키 테이블 쓰기 연결과 파일 수준의 읽기 필터링에 더 치우쳐 있습니다.

Paimon의 DV 문서는 명확한 의미를 가집니다: 이는 삭제된 행 위치를 기록한 data file을 기록하며, 파일을 읽을 때 이러한 행을 필터링합니다. Paimon의 Merge On Write 모드는 LSM에 의존하며, 쓰기 단계에서 주 키를 쿼리하여 해당 data file의 deletion vector를 생성하여 읽을 때 완전한 merge를 더 이상 수행하지 않도록 합니다.

Paimon의 Data Evolution 테이블은 다른 경로를 취합니다: 업데이트된 열만 새 파일에 쓰고, 원본 데이터 파일은 변경하지 않으며, 읽을 때 동일한 first row id의 여러 파일을 병합하여 완전한 행을 만듭니다. 따라서 Paimon Data Evolution은 deletion vectors를 비활성화하고 일반적인 Delete / Update statement을 지원하지 않습니다.

아래는 Data Evolution과 DV를 조합할 때의 단순화된 예입니다:

base file:
  firstRowId = 100
  columns: id, a, b, c

update file:
  firstRowId = 100
  columns: b_new

读时:
  base file + update file
  -> 按 firstRowId 对齐
  -> 得到完整行

file-level deletion vector를 추가로 도입한다면:

删除 base file 的某一行:
  a / c 也被隐藏
  但 b_new 的 overlay 文件如何处理?

删除 update file 的某一行:
  b_new 不可见
  但 base file 的旧 b 是否应该恢复?

두 가지 메커니즘을 모두 지원할 때는 read/write 경로에서 "행 삭제"와 "열 overlay 합병"을 동시에 처리해야 합니다. Paimon은 Data Evolution과 Deletion Vector를 분리하여 두 가지 의미가 서로 영향을 주지 않도록 합니다.

Lance의 표준 업데이트 경로는 다음과 같습니다:

新 rows / 新 columns 写入新 fragment 或新 data files
旧 rows 通过 deletion vector tombstone
manifest 统一描述当前版本的 fragment 状态

따라서 Lance의 Deletion Vector는 단순히 read 필터링 도구가 아니라, 동시에 write rebase와 충돌 판단에 참여합니다.

Compaction: fragment를 선택할 때는 언제?

Deletion Vector는 delete/update의 write amplification을 낮추지만, 두 가지 문제를 남깁니다:

  • 작은 append 작업이 많은 작은 fragments를 생성할 수 있습니다.
  • delete/update를 여러 번 수행한 후, fragment 내에서 삭제된 행의 비율이 높아질 수 있습니다.

Compaction은 이러한 레이아웃 하락 문제를 처리합니다.

Lance의 compaction은 파일 크기만으로 선택하는 것이 아니라, 주로 fragment의 행 수와 삭제 비율을 보고 합니다:

if deletion_percentage > materialize_deletions_threshold:
  选中这个 fragment
  目的:把 deletion vector 物化掉,只写 live rows

else if physical_rows < target_rows_per_fragment:
  选中这个 fragment
  目的:和相邻的小 fragments 合并成更大的 fragment

else:
  不参与本轮 compaction

기본 구성에서:

target_rows_per_fragment = 1024 * 1024
materialize_deletions_threshold = 0.1

해당 동작은 다음과 같습니다:

  • 삭제 비율이 10%를 초과하는 fragment는, 자신이 단일 fragment일지라도 다시 작성할 가치가 있습니다.
  • 행 수가 목표 값보다 낮은 작은 fragment는 인접 후보 fragment와 합병을 시도합니다.
  • compaction은 행 수에 따라 계획을 세우지 않고, 직접 파일 바이트 수로 선택하지 않습니다.

소스 코드 입구:

rust/lance/src/dataset/optimize.rs
  CompactionOptions
  plan_compaction
  CompactionCandidacy::CompactItself
  CompactionCandidacy::CompactWithNeighbors

여기 인덱스 제약 조건이 하나 있습니다: compaction은 "특정 인덱스로 덮어쓴 fragment"와 "해당 인덱스로 덮어쓰지 않은 fragment"를 같은 rewrite 그룹에 혼합하지 않습니다.

이유는 Lance의 인덱스 메타데이터에 fragment_bitmap가 있기 때문입니다. 이것은 이 인덱스가 어떤 fragments를 덮어쓴다고 설명합니다. rewrite 그룹에 indexed와 unindexed fragments가 혼합되면, compaction 후 새로운 fragment는 indexed 또는 unindexed로 명확하게 분류될 수 없습니다.

따라서 compaction 플래너는 인덱스 커버리지에 따라 후보 fragments를 bin으로 분류합니다:

F1 indexed by vector_index
F2 indexed by vector_index
F3 not indexed

允许:
  compact(F1, F2) -> F10

不允许:
  compact(F2, F3) -> F11

Compaction이 왜 인덱스 리매핑을 유발하는가

컴팩션의 본질은 리라이트:

old fragments:
  F1, F2

new fragments:
  F10

인덱스에 행 주소가 기록되어 있으면 컴팩션 후에는 인덱스 내 주소를 유지해야 합니다.

Lance의 행 주소는 프래그먼트 ID와 행 오프셋으로 구성됩니다:

row_address = (fragment_id, row_offset)

컴팩션 전:

F1.offset 0
F1.offset 1
F2.offset 0

컴팩션 후:

F10.offset 0
F10.offset 1
F10.offset 2

논리적으로 여전히 동일한 라이브 행들일지라도 물리적 주소는 변경됩니다. 인덱스에 저장된 행 주소는 이에 맞게 업데이트해야 하며, 그렇지 않으면 인덱스가 오래된 프래그먼트를 가리킬 수 있습니다.

Lance는 컴팩션 후 인덱스에 대해 몇 가지 처리 방식을 가지고 있습니다:

1. 同步 remap index
   compaction 时生成 old row address -> new row address 映射
   重写受影响的 index files

2. defer_index_remap
   compaction 时不立即重写所有索引
   建立 fragment reuse index
   查询时或后续维护时再做 remap

3. stable row id
   索引不再直接依赖易变的 physical row address
   而是指向稳定 row id

트랜잭션 적용Operation::Rewrite 일 때, Lance는 두 가지 유형의 메타데이터를 처리합니다:

fragments:
  old fragments 从 manifest 移除
  new fragments 加入 manifest

indices:
  更新 fragment_bitmap
  必要时替换 index uuid 和 index files

소스 코드 입구:

rust/lance/src/dataset/transaction.rs
  Operation::Rewrite
  handle_rewrite_fragments
  recalculate_fragment_bitmap
  handle_rewrite_indices

rust/lance/src/dataset/optimize/remapping.rs
  fragment reuse index
  deferred index remap

다른 인덱스의 유지 방식은 다릅니다. remap이 가능한지 여부는 인덱스 내부에 old row address에서 new row address로 매핑할 수 있는 세부 정보가 저장되어 있는지 여부에 따라 달라집니다.

인덱스 내부 기록의 내용에 따라 구분할 수 있습니다:

较容易 remap:
  能逐条或逐 segment 映射到 row address 的索引

较难 remap:
  内部结构强依赖训练结果、聚类结果、posting layout 或 block layout 的索引

예를 들어, 벡터 인덱스는 단순히 key -> row address 매핑이 아닙니다. IVF와 같은 인덱스 내부에는 클러스터링 중심, 파티션, 벡터 리스트, row id 리스트와 같은 구조가 있습니다. compaction 후에도 벡터 값은 변하지만, row address는 변하므로 인덱스 내부의 row id/address 페이로드는 일관되게 업데이트해야 합니다. 인덱스 형식이 저렴하고, 지역적이며, 신뢰할 수 있는 remap 기능을 제공하지 않으면, 재작성하거나 fragment reuse index를 이용해 나중에 처리해야 합니다.

그래서, compaction의 어려움은 오직 데이터 파일 재작성에만 국한되지 않습니다:

데이터 파일 재작성 후에도, 인덱스는 여전히 동일한 논리 행을 찾을 수 있어야 합니다.

안정적인 행 ID: 인덱스를 물리적 주소에서 분리합니다

안정적인 행 ID는 각 논리 행에 안정적인 ID를 할당하여 인덱스가 변경되는 행 주소에 직접 의존하지 않도록 합니다.

안정적인 행 ID가 없을 때:

row id ~= row address

索引命中:
  vector index -> row address -> 回表

compaction:
  row address 改变
  -> index payload 需要 remap

안정적인 행 ID를 켜면:

row id = 稳定逻辑行 ID
row address = 当前物理位置

索引命中:
  vector index -> stable row id
  -> RowIdIndex 查 row id 当前对应的 row address
  -> 回表

compaction:
  stable row id 不变
  row address 改变
  -> 更新 row_id_meta / RowIdIndex
  -> 索引主体可以复用

그렇게 그릴 수 있습니다:

                 without stable row id

Index payload ------------------> RowAddress(F1, 42)
                                      |
                                      | compaction 后失效
                                      v
                                  RowAddress(F10, 8)


                 with stable row id

Index payload ---> StableRowId(10086)
                         |
                         v
                   RowIdIndex(version N)
                         |
                         v
                   RowAddress(F10, 8)

RowIdIndex의 실제 표현

RowIdIndex는 사용자가 수동으로 생성한 인덱스가 아니며, 각 행마다 독립적으로 저장되지 않습니다row_id -> row_address 기록입니다. stable row id가 활성화된 후 Lance가 필요할 때 fragment 메타데이터를 기반으로 구축한 버전별 메모리 인덱스로, 메타데이터 캐시에 캐싱됩니다.

구축 입구는:

rust/lance/src/dataset/rowids.rs
  get_row_id_index(dataset)
    -> 如果 manifest.uses_stable_row_ids()
    -> 从 metadata_cache 获取或构建 RowIdIndex

  load_row_id_index(dataset)
    -> 读取每个 fragment 的 row_id_meta
    -> 读取该 fragment 的 deletion vector
    -> 构造 FragmentRowIdIndex
    -> RowIdIndex::new(...)

각 fragment에는 row_id_meta이 저장되어 있으며, 이는 fragment 내 "물리적 행 순서에 해당하는 stable row id 시퀀스"를 설명합니다:

Fragment 10
  row_id_meta:
    physical offset 0 -> stable row id 100
    physical offset 1 -> stable row id 101
    physical offset 2 -> stable row id 105
    physical offset 3 -> stable row id 106

  deletion vector:
    {1}

RowIdIndex를 구축할 때 deletion vector가 적용됩니다:

offset 0 live     -> 100 -> RowAddress(F10, 0)
offset 1 deleted  -> 跳过
offset 2 live     -> 105 -> RowAddress(F10, 2)
offset 3 live     -> 106 -> RowAddress(F10, 3)

소스 코드의 핵심 구조는:

pub struct RowIdIndex(
    RangeInclusiveMap<u64, (U64Segment, U64Segment)>
);

이를 이해할 수 있습니다:

RowIdIndex
  key:
    这一段 row id 覆盖的范围

  value:
    row_id_segment:
      这一段实际存在的 row ids

    address_segment:
      与 row_id_segment 一一对齐的 physical row addresses

즉, RowIdIndex의 한 chunk이 하나의 매핑이 아니라 여러 매핑의 조각입니다:

coverage range:
  100..=106

row_id_segment:
  [100, 105, 106]

address_segment:
  [RowAddress(F10, 0), RowAddress(F10, 2), RowAddress(F10, 3)]

쿼리row_id = 105 때, 프로세스는 다음과 같습니다:

1. 用 105 到 RangeInclusiveMap 中找到覆盖它的 chunk
2. 在 row_id_segment 中找 105 的位置
3. 假设位置是 1
4. 从 address_segment 取第 1 个地址
5. 得到 RowAddress(F10, 2)

에 해당하는 소스 코드는:

rust/lance-table/src/rowids/index.rs
  RowIdIndex::get(row_id)
    -> self.0.get(&row_id)
    -> row_id_segment.position(row_id)
    -> address_segment.get(pos)
    -> RowAddress::from(address)

U64Segment는 여기서 압축 표현입니다. 이는 row id 시퀀스의 형태에 따라 다른 구조를 선택합니다:

Range:
  连续有序,例如 100..200

RangeWithHoles:
  大体连续,但有少量空洞

RangeWithBitmap:
  大体连续,使用 bitmap 标记哪些位置存在

SortedArray:
  有序但比较稀疏

Array:
  无序序列

는 다음과 같은 문제를 설명합니다: stable row id를 활성화한 후, Lance는 각 행에 대해 메타데이터를 하나씩 작성하지 않습니다. 일반적으로 연속적으로 삽입된 row id는 범위(range)로 표현할 수 있습니다; update, delete, compaction 후에 공백이나 혼란스러운 순서가 발생하면, 점진적으로 bitmap이나 array와 같은 표현으로 저하됩니다.

소스 코드 입구:

docs/src/format/table/row_id_lineage.md
  Row Address vs Row ID
  stable row id behavior

docs/src/format/index/index.md
  Stable Row ID for Index

rust/lance/src/dataset/rowids.rs
  get_row_id_index
  load_row_id_index

rust/lance-table/src/rowids/index.rs
  RowIdIndex
  FragmentRowIdIndex

rust/lance-table/src/rowids/segment.rs
  U64Segment

Stable Row ID의 역할에는:

  • compaction 후에 인덱스가 물리적 주소 변경으로 인해 전체적으로 다시 작성할 필요가 없습니다.
  • update 후에 indexed column이 변경되지 않으면, 인덱스 무효화 범위를 줄일 수 있습니다.
  • 는 장기적인 유지보수에 적합한 큰 테이블, 자주 compaction이 이루어지고, 인덱스 구축 비용이 높은 시나리오에 적합합니다.

하지만 그것도 비용이 있습니다:

  • 쿼리 시 한 번 더 stable row id -> row address의 매핑이 필요합니다.
  • 각 fragment는 row_id_meta을 유지해야 합니다.
  • 삭제와 업데이트는 row id sequence를 연속적인 범위에서 holes, bitmap, array 등의 형태로 진화시킵니다.
  • 이 기능은 dataset을 생성할 때 활성화해야 하며, 이미 활성화되지 않은 테이블에 나중에 추가할 수 없습니다.

그래서 Stable Row ID는 무차별적으로 기본적으로 켜는 데 적합하지 않습니다. 일회성 임포트, 낮은 빈도 업데이트, 인덱스 재구축을 수용할 수 있는 작은 테이블에는 그렇지 않을 수 있습니다. 인덱스 구축 비용이 높고 compaction이 자주 발생하며 장기간 유지보수가 필요한 데이터 세트에는 더 가치가 있습니다.

완전한 예시

Lance 테이블이 하나 있다고 가정해 봅시다.

schema:
  id: int64
  text: string
  vector: fixed_size_list<float32>[768]

indices:
  vector index on vector
  scalar index on id

초기 상태:

Manifest v1
  Fragment 1: rows 0..999
  Fragment 2: rows 1000..1999

Vector index:
  fragment_bitmap = {1, 2}

한 번 update 실행:

UPDATE table
SET text = 'new text'
WHERE id = 42;

처리 단계:

1. scan 找到 id = 42 的 row address
2. 写出更新后的新 row 到 Fragment 3
3. 给 Fragment 1 写 deletion file,标记旧 offset deleted
4. 提交 Operation::Update
5. affected_rows = {(Fragment 1, offset 42)}

그러나 동시에 다른 트랜잭션이 id = 43을 삭제하는 경우:

T1 affected_rows = {(F1, 42)}
T2 affected_rows = {(F1, 43)}

두 것은 같은 fragment에 속하지만 row는 겹치지 않습니다. 다른 data file rewrite가 없으면 Lance는 deletion vector를 병합하여 rebase를 완료할 기회가 있습니다.

이후 compaction을 수행합니다:

Fragment 1:
  deleted rows 超过 threshold

Fragment 2:
  小于 target_rows_per_fragment

planner는 검사합니다:

这些 fragment 是否相邻?
它们是否有相同 index coverage?
删除比例是否值得单独 materialize?

최종 rewrite가 발생하면:

old:
  Fragment 1
  Fragment 2

new:
  Fragment 10

인덱스는 처리해야 합니다:

fragment_bitmap:
  {1, 2} -> {10}

row address:
  old addresses -> new addresses

stable row id가 활성화되어 있으면:

index payload 仍然指向 stable row id
RowIdIndex 更新 stable row id 到新 RowAddress 的映射

이 예제는 Deletion Vector, Compaction, Index Remap 및 Stable Row ID의 관계를 설명합니다.

요약

Lance의 쓰기 연결은 전통적인 데이터베이스의 원자적 업데이트도, 단순한 append-only 로그도 아닙니다. 그것은 버전 관리된 열 형식 데이터 세트 쓰기 메커니즘입니다:

  • delete deletion vector를 통해 이전 행을 보이지 않게 합니다.
  • update 그리고merge 새로운 행/열을 작성하여 오래된 행에 토밍스톤을 추가하여 수정을 표현합니다.
  • 삭제 벡터는 읽기 필터링과 행 수준 쓰기 충돌 검사를 동시에 서비스합니다.
  • Paimon의 DV는 주 키 테이블 MOW와 파일 수준 읽기 필터링에 더 치우쳐 있습니다. Paimon Data Evolution은 열 오버레이 의미를 위해 DV를 비활성화합니다.
  • 컴팩션은 작은 프래그먼트를 병합하고 물리적 삭제를 수행하며 레이아웃을 개선합니다.
  • 컴팩션은 행 주소를 변경하므로 인덱스 재매핑이 발생합니다.
  • 안정적인 행 ID는 안정적인 논리 행 ID를 도입하여 인덱스를 물리적 행 주소에서 분리합니다.

Lance 쓰기 연결 설계의 부담을 요약하면 다음과 같습니다:

파일을 다시 쓰고 난 후에도 deletion, version, index 및 row identity 는 여전히 동일한 로직 행을 나타내야 합니다.