返回博客

时间旅行:Iceberg 快照的深度应用

Tags: #TimeTravel #Iceberg #Snapshot #VersionedQuery #TemporalData #智策平台

Coomia发布于 2025年7月19日15 分钟阅读
分享本文Twitter / X

系列:S3 数据基座 · 第 10 篇 | 难度:高级 | 阅读时间:20 分钟

时间旅行:Iceberg 快照的深度应用

Tags: #TimeTravel #Iceberg #Snapshot #VersionedQuery #TemporalData #智策平台

#TL;DR

时间旅行(Time Travel)是 Ontology 平台的核心能力之一——用户可以查询任意历史时刻的数据状态,而无需自己维护历史表。coomia-dip 基于 Apache Iceberg 的快照机制,结合 Nessie 的版本分支模型,实现了 OQL 的 AT TIMEAT BRANCH 子句。本文完整解析时间旅行的存储原理、快照管理策略、OQL 时间旅行语法到 Iceberg 查询的编译流程、跨快照 Diff 查询、性能优化(增量读取、快照剪枝)以及合规场景下的审计追踪应用。

#1. 为什么需要时间旅行

#1.1 业务场景

Code
时间旅行核心业务场景:

场景 1:审计合规
  "上个月月底,这批设备的状态是什么?"
  → FETCH Device AT TIME '2024-02-29T23:59:59Z'

场景 2:问题追溯
  "系统告警前 1 小时,风险指标是多少?"
  → FETCH RiskScore AT TIME '2024-03-01T13:00:00Z'
     WHERE entity_id = 'asset-001'

场景 3:变更对比
  "这个月修改了哪些客户的信用等级?"
  → DIFF Customer
     FROM TIME '2024-03-01' TO TIME '2024-03-31'
     SELECT credit_rating

场景 4:What-If 分析
  "如果在分支上修改参数,结果会怎样?"
  → FETCH Simulation AT BRANCH 'what-if-scenario-1'

场景 5:数据恢复
  "误操作删除了数据,需要恢复到昨天"
  → 回滚到昨天的快照

#1.2 传统方案的问题

Code
传统历史数据方案对比:

┌─────────────────┬────────────────┬────────────────┐
│ 方案              │ 优点             │ 缺点             │
├─────────────────┼────────────────┼────────────────┤
│ 手动 history 表  │ 简单直接          │ 存储翻倍、查询复杂 │
│ SCD Type 2       │ 标准化            │ 需要 ETL 维护    │
│ CDC 日志         │ 精确              │ 重放成本高        │
│ 数据库备份        │ 完整              │ 粒度粗、恢复慢    │
├─────────────────┼────────────────┼────────────────┤
│ Iceberg 快照      │ 零额外开发        │ 需要 Iceberg 生态 │
│                  │ 自动版本化        │                  │
│                  │ 任意时间点查询    │                  │
│                  │ 存储去重(COW/MOR)│                  │
└─────────────────┴────────────────┴────────────────┘

#2. Iceberg 快照机制

#2.1 Iceberg 表结构

Code
Iceberg 表文件结构:

s3://onto-data/warehouse/
└── entity_common/
    ├── metadata/
    │   ├── v1.metadata.json     ← 表级元数据
    │   ├── v2.metadata.json
    │   ├── snap-1001.avro       ← 快照 1 的 Manifest List
    │   ├── snap-1002.avro       ← 快照 2 的 Manifest List
    │   └── snap-1003.avro
    ├── manifests/
    │   ├── manifest-a.avro      ← Manifest File(文件列表)
    │   ├── manifest-b.avro
    │   └── manifest-c.avro
    └── data/
        ├── part-00001.parquet   ← 数据文件(Parquet 格式)
        ├── part-00002.parquet
        ├── part-00003.parquet
        └── part-00004.parquet

层级关系:
  Metadata → Snapshot → Manifest List → Manifest File → Data File

#2.2 快照链

Code
Iceberg 快照链示意:

时间轴:
  t1 (初始)    t2 (插入)    t3 (更新)    t4 (删除)
     │             │             │             │
     ▼             ▼             ▼             ▼
  Snap-1001    Snap-1002    Snap-1003    Snap-1004
  ┌──────┐    ┌──────┐    ┌──────┐    ┌──────┐
  │ F1   │    │ F1   │    │ F1   │    │ F1   │
  │ F2   │    │ F2   │    │ F2   │    │ F2   │
  │      │    │ F3+  │    │ F3   │    │ F3   │
  │      │    │      │    │ F4+  │    │ F4   │
  │      │    │      │    │      │    │ -F2  │
  └──────┘    └──────┘    └──────┘    └──────┘

  F1,F2: 初始数据文件
  F3+:   新增数据文件
  F4+:   更新后的数据文件(替换 F2 中部分行)
  -F2:   标记 F2 为删除

  关键:每个快照不是完整拷贝,而是增量引用
  → 存储效率:只存储变更部分

#2.3 COW vs MOR

Code
Copy-on-Write vs Merge-on-Read:

Copy-on-Write (COW):
  写入时:创建新的数据文件替换旧文件
  读取时:直接读取,无额外开销
  适用:读多写少场景

  写入前:[F1] [F2] [F3]
  更新 F2 中 5 行后:[F1] [F2'] [F3]  (F2' 是 F2 的完整重写)

Merge-on-Read (MOR):
  写入时:追加 Delete File + Insert File
  读取时:合并 Delete + Insert
  适用:写多读少场景

  写入前:[F1] [F2] [F3]
  更新 F2 中 5 行后:
    Data Files: [F1] [F2] [F3]
    Delete File: [D1]  (标记 F2 中 5 行为删除)
    Insert File: [I1]  (5 行新数据)
  读取时:F2 - D1 + I1 = 合并结果

coomia-dip 选择:
  entity_common → MOR(频繁属性更新)
  entity_edge   → COW(边关系变更较少)
  entity_event  → Append-Only(事件只追加)

#3. OQL 时间旅行编译

#3.1 AT TIME 编译

Python
class TimeTravelCompiler:
    """时间旅行 OQL 到 Iceberg 查询的编译"""

    def compile_at_time(
        self, entity_type: str, timestamp: datetime
    ) -> str:
        """编译 AT TIME 子句"""
        # 方式 1:Doris Iceberg Catalog 查询
        snapshot_id = self._find_snapshot_at(entity_type, timestamp)

        return f"""
        SELECT * FROM iceberg_catalog.onto_db.{entity_type}
        FOR VERSION AS OF {snapshot_id}
        """

    def compile_at_time_doris(
        self, entity_type: str, timestamp: datetime
    ) -> str:
        """使用 Doris 的 Iceberg 时间旅行语法"""
        ts_str = timestamp.strftime('%Y-%m-%d %H:%M:%S')

        return f"""
        SELECT * FROM iceberg_catalog.onto_db.entity_common
        FOR TIME AS OF '{ts_str}'
        WHERE entity_type = '{entity_type}'
        """

    def _find_snapshot_at(
        self, entity_type: str, timestamp: datetime
    ) -> int:
        """找到指定时间点最近的快照"""
        table = self._iceberg_catalog.load_table(
            f"onto_db.entity_common"
        )
        snapshots = table.snapshots()

        # 找到 timestamp 之前最近的快照
        target_snapshot = None
        for snap in sorted(snapshots, key=lambda s: s.timestamp_ms):
            if snap.timestamp_ms <= timestamp.timestamp() * 1000:
                target_snapshot = snap
            else:
                break

        if target_snapshot is None:
            raise TimeTravelError(
                f"No snapshot found before {timestamp}"
            )

        return target_snapshot.snapshot_id

#3.2 AT BRANCH 编译

Python
class BranchTimeTravelCompiler:
    """分支时间旅行编译器"""

    def compile_at_branch(
        self, entity_type: str, branch_name: str
    ) -> str:
        """编译 AT BRANCH 子句"""
        # 通过 Nessie 获取分支的 Iceberg 快照
        nessie_ref = self._nessie_client.get_reference(branch_name)

        return f"""
        SELECT * FROM iceberg_catalog.onto_db.entity_common
        FOR VERSION AS OF {nessie_ref.hash}
        WHERE entity_type = '{entity_type}'
        """

    def compile_branch_diff(
        self, entity_type: str,
        from_branch: str, to_branch: str
    ) -> str:
        """编译分支 DIFF 查询"""
        from_ref = self._nessie_client.get_reference(from_branch)
        to_ref = self._nessie_client.get_reference(to_branch)

        return f"""
        SELECT
            COALESCE(a.entity_id, b.entity_id) AS entity_id,
            CASE
                WHEN a.entity_id IS NULL THEN 'ADDED'
                WHEN b.entity_id IS NULL THEN 'DELETED'
                WHEN a.properties != b.properties THEN 'MODIFIED'
                ELSE 'UNCHANGED'
            END AS change_type,
            a.properties AS old_value,
            b.properties AS new_value
        FROM (
            SELECT * FROM iceberg_catalog.onto_db.entity_common
            FOR VERSION AS OF {from_ref.hash}
            WHERE entity_type = '{entity_type}'
        ) a
        FULL OUTER JOIN (
            SELECT * FROM iceberg_catalog.onto_db.entity_common
            FOR VERSION AS OF {to_ref.hash}
            WHERE entity_type = '{entity_type}'
        ) b ON a.entity_id = b.entity_id
        WHERE a.entity_id IS NULL
           OR b.entity_id IS NULL
           OR a.properties != b.properties
        """

#4. 快照管理策略

#4.1 自动快照策略

Python
class SnapshotPolicy:
    """快照管理策略"""

    def __init__(self, config: SnapshotConfig):
        self._config = config

    def should_create_snapshot(
        self, table: IcebergTable, last_snapshot_time: datetime
    ) -> bool:
        """判断是否应该创建新快照"""
        now = datetime.utcnow()
        time_since_last = now - last_snapshot_time

        # 策略 1:时间间隔触发
        if time_since_last >= self._config.snapshot_interval:
            return True

        # 策略 2:数据变更量触发
        pending_changes = table.pending_changes_count()
        if pending_changes >= self._config.change_threshold:
            return True

        # 策略 3:关键时间点(整点、日切)
        if self._is_checkpoint_time(now):
            return True

        return False

    def cleanup_snapshots(self, table: IcebergTable):
        """清理过期快照"""
        now = datetime.utcnow()
        snapshots = table.snapshots()

        keep_snapshots = set()

        for snap in snapshots:
            snap_time = datetime.fromtimestamp(snap.timestamp_ms / 1000)
            age = now - snap_time

            # 规则 1:保留最近 24 小时的所有快照
            if age < timedelta(hours=24):
                keep_snapshots.add(snap.snapshot_id)
                continue

            # 规则 2:最近 7 天保留每小时一个快照
            if age < timedelta(days=7):
                if snap_time.minute == 0:
                    keep_snapshots.add(snap.snapshot_id)
                continue

            # 规则 3:最近 90 天保留每天一个快照
            if age < timedelta(days=90):
                if snap_time.hour == 0 and snap_time.minute == 0:
                    keep_snapshots.add(snap.snapshot_id)
                continue

            # 规则 4:超过 90 天保留每月一个快照
            if snap_time.day == 1 and snap_time.hour == 0:
                keep_snapshots.add(snap.snapshot_id)

        # 永远保留最新快照
        keep_snapshots.add(snapshots[-1].snapshot_id)

        # 删除不需要保留的快照
        for snap in snapshots:
            if snap.snapshot_id not in keep_snapshots:
                table.expire_snapshot(snap.snapshot_id)

#4.2 快照保留配置

Code
快照保留策略配置:

┌──────────────────┬──────────┬──────────────────────┐
│ 时间范围            │ 保留粒度   │ 快照数量估算           │
├──────────────────┼──────────┼──────────────────────┤
│ 最近 24 小时       │ 每次提交   │ ~100 个(假设每 15 分钟)│
│ 最近 7 天          │ 每小时     │ ~144 个              │
│ 最近 90 天         │ 每天       │ ~83 个               │
│ 90 天以上          │ 每月       │ ~12 个/年            │
├──────────────────┼──────────┼──────────────────────┤
│ 总计               │           │ ~340 个/年           │
└──────────────────┴──────────┴──────────────────────┘

存储开销:
  每个快照的元数据开销 ~10KB
  340 个快照 ≈ 3.4 MB 元数据
  数据文件共享(去重),额外存储取决于变更量

#5. 性能优化

#5.1 增量读取

Python
class IncrementalReader:
    """增量读取:只读取两个快照之间的差异"""

    def read_changes(
        self, table: IcebergTable,
        from_snapshot: int, to_snapshot: int
    ) -> pa.Table:
        """读取两个快照之间的变更"""
        scan = table.scan(
            snapshot_id=to_snapshot
        ).use_ref(from_snapshot)

        added_files = []
        deleted_files = []

        for manifest in scan.plan_files():
            if manifest.status == ManifestEntryStatus.ADDED:
                added_files.append(manifest.file)
            elif manifest.status == ManifestEntryStatus.DELETED:
                deleted_files.append(manifest.file)

        added_data = self._read_data_files(added_files)
        deleted_data = self._read_data_files(deleted_files)

        return ChangeSet(
            added=added_data,
            deleted=deleted_data
        )

#5.2 快照剪枝

Code
快照剪枝优化:

问题:AT TIME 查询需要扫描所有快照找到目标
  1000 个快照的线性搜索 → 慢

优化:快照索引
  建立快照时间戳的 B-tree 索引
  O(log N) 查找目标快照

┌─────────────────────────────────────────┐
│           Snapshot Index (B-tree)         │
│                                          │
│  2024-01-01 → snap-1001                  │
│  2024-01-02 → snap-1002                  │
│  2024-01-03 → snap-1003                  │
│  ...                                     │
│  2024-03-15 → snap-1075                  │
│                                          │
│  查找 AT TIME '2024-02-15':              │
│  → 二分搜索 → snap-1045 (3 次比较)        │
└─────────────────────────────────────────┘

#5.3 分区级别时间旅行

Code
分区级别时间旅行优化:

标准时间旅行:读取整个表在时间 T 的状态
  → 即使只查一个 entity_type,也要处理所有分区的快照

优化:分区级别快照
  entity_common 按 entity_type 分区
  每个分区独立跟踪快照状态

  FETCH Person AT TIME '2024-01-01'
  → 只需要 entity_type='Person' 分区的快照
  → 跳过 Device, Document, Company 等分区

效果:查询时间从 2s → 200ms(减少 90%)

#6. Diff 查询实现

#6.1 变更类型识别

Python
class DiffQueryExecutor:
    """Diff 查询执行器"""

    async def execute_diff(
        self,
        entity_type: str,
        from_ref: TimeOrBranchRef,
        to_ref: TimeOrBranchRef,
        properties: list[str] | None = None
    ) -> DiffResult:
        # 获取两个时间点的数据
        from_data = await self._fetch_at(entity_type, from_ref)
        to_data = await self._fetch_at(entity_type, to_ref)

        # 构建 entity_id 索引
        from_index = {row['entity_id']: row for row in from_data}
        to_index = {row['entity_id']: row for row in to_data}

        changes = []

        # 检测新增和修改
        for eid, to_row in to_index.items():
            if eid not in from_index:
                changes.append(DiffEntry(
                    entity_id=eid,
                    change_type='ADDED',
                    old_value=None,
                    new_value=to_row
                ))
            else:
                from_row = from_index[eid]
                modified_props = self._find_modified_properties(
                    from_row, to_row, properties
                )
                if modified_props:
                    changes.append(DiffEntry(
                        entity_id=eid,
                        change_type='MODIFIED',
                        old_value={k: from_row[k] for k in modified_props},
                        new_value={k: to_row[k] for k in modified_props}
                    ))

        # 检测删除
        for eid in from_index:
            if eid not in to_index:
                changes.append(DiffEntry(
                    entity_id=eid,
                    change_type='DELETED',
                    old_value=from_index[eid],
                    new_value=None
                ))

        return DiffResult(
            from_ref=from_ref,
            to_ref=to_ref,
            entity_type=entity_type,
            changes=changes,
            summary=DiffSummary(
                added=sum(1 for c in changes if c.change_type == 'ADDED'),
                modified=sum(1 for c in changes if c.change_type == 'MODIFIED'),
                deleted=sum(1 for c in changes if c.change_type == 'DELETED'),
            )
        )

#6.2 Diff 查询示例

Code
Diff 查询完整示例:

OQL:
  DIFF Customer
  FROM TIME '2024-03-01' TO TIME '2024-03-31'
  SELECT credit_rating, risk_level

结果:
┌────────────┬────────────┬──────────────┬──────────────┐
│ entity_id   │ change_type│ old_value     │ new_value     │
├────────────┼────────────┼──────────────┼──────────────┤
│ cust-001    │ MODIFIED   │ {credit: A}  │ {credit: B}  │
│ cust-015    │ MODIFIED   │ {risk: LOW}  │ {risk: HIGH} │
│ cust-042    │ ADDED      │ null         │ {credit: A,  │
│             │            │              │  risk: LOW}  │
│ cust-088    │ DELETED    │ {credit: C,  │ null         │
│             │            │  risk: HIGH} │              │
└────────────┴────────────┴──────────────┴──────────────┘

摘要:
  新增: 1, 修改: 2, 删除: 1, 总变更: 4

#7. 合规与审计

#7.1 审计日志生成

Python
class AuditTrailGenerator:
    """基于时间旅行的审计日志自动生成"""

    async def generate_audit_trail(
        self,
        entity_type: str,
        entity_id: str,
        time_range: tuple[datetime, datetime]
    ) -> list[AuditEntry]:
        """生成指定实体的完整变更历史"""
        start, end = time_range

        # 获取时间范围内的所有快照
        snapshots = self._get_snapshots_in_range(entity_type, start, end)

        trail = []
        prev_state = None

        for snap in snapshots:
            current_state = await self._fetch_entity_at_snapshot(
                entity_type, entity_id, snap.snapshot_id
            )

            if prev_state is None:
                if current_state is not None:
                    trail.append(AuditEntry(
                        timestamp=snap.timestamp,
                        action='CREATE',
                        entity_id=entity_id,
                        changes=current_state
                    ))
            elif current_state is None:
                trail.append(AuditEntry(
                    timestamp=snap.timestamp,
                    action='DELETE',
                    entity_id=entity_id,
                    changes=prev_state
                ))
            else:
                diff = self._compute_diff(prev_state, current_state)
                if diff:
                    trail.append(AuditEntry(
                        timestamp=snap.timestamp,
                        action='UPDATE',
                        entity_id=entity_id,
                        changes=diff
                    ))

            prev_state = current_state

        return trail

#7.2 合规报表

Code
时间旅行合规报表示例:

设备状态审计报表 — 2024年3月
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

设备 ID: EQ-2024-001
设备名称: 1号生产线控制器

变更历史:
┌────────────────────┬──────────┬───────────┬───────────┐
│ 时间                 │ 操作      │ 属性       │ 变更内容    │
├────────────────────┼──────────┼───────────┼───────────┤
│ 2024-03-01 08:00   │ UPDATE   │ status    │ idle→run  │
│ 2024-03-05 14:30   │ UPDATE   │ firmware  │ v2.1→v2.2 │
│ 2024-03-12 09:15   │ UPDATE   │ status    │ run→maint │
│ 2024-03-12 16:45   │ UPDATE   │ status    │ maint→run │
│ 2024-03-20 11:00   │ UPDATE   │ location  │ A区→B区   │
│ 2024-03-28 10:30   │ UPDATE   │ status    │ run→idle  │
└────────────────────┴──────────┴───────────┴───────────┘

月度统计:
  总变更次数: 6
  状态变更: 4 次
  配置变更: 1 次
  位置变更: 1 次
  正常运行时间: 89.3%

#8. 测试策略

#8.1 时间旅行测试

Python
class TestTimeTravel:

    async def test_at_time_returns_historical_state(self):
        # 插入初始数据
        await insert_entity('Person', 'p1', {'name': 'Alice', 'age': 30})
        t1 = datetime.utcnow()

        # 更新数据
        await update_entity('p1', {'age': 31})
        t2 = datetime.utcnow()

        # 查询历史状态
        result_t1 = await execute(
            f"FETCH Person WHERE entity_id = 'p1' AT TIME '{t1}'"
        )
        assert result_t1[0]['age'] == 30

        # 查询当前状态
        result_t2 = await execute(
            f"FETCH Person WHERE entity_id = 'p1' AT TIME '{t2}'"
        )
        assert result_t2[0]['age'] == 31

    async def test_diff_detects_all_change_types(self):
        t1 = datetime.utcnow()
        await insert_entity('Item', 'i1', {'value': 100})
        await update_entity('i2', {'value': 200})
        await delete_entity('i3')
        t2 = datetime.utcnow()

        diff = await execute(
            f"DIFF Item FROM TIME '{t1}' TO TIME '{t2}'"
        )
        assert any(d.change_type == 'ADDED' for d in diff.changes)
        assert any(d.change_type == 'MODIFIED' for d in diff.changes)
        assert any(d.change_type == 'DELETED' for d in diff.changes)

    async def test_snapshot_cleanup_preserves_required(self):
        policy = SnapshotPolicy(config)
        # 创建 100 个快照
        for i in range(100):
            await create_snapshot()

        policy.cleanup_snapshots(table)

        # 验证最近 24 小时的快照全部保留
        recent = [s for s in table.snapshots()
                  if s.age < timedelta(hours=24)]
        assert len(recent) > 0

#Key Takeaways

  1. Iceberg 快照机制为 Ontology 平台提供了零成本的时间旅行能力:无需手动维护 history 表,每次数据变更自动创建快照,用户通过 AT TIME 子句即可查询任意历史时刻。

  2. COW 和 MOR 策略需要根据读写比例选择:entity_common(频繁更新)使用 MOR,entity_edge(少更新)使用 COW,entity_event(只追加)使用 Append-Only。

  3. 快照管理策略平衡了存储成本和时间精度:近期保留细粒度快照(每次提交),远期保留粗粒度快照(每天/每月),年存储开销仅 ~3.4 MB 元数据。

  4. Diff 查询是时间旅行的高级应用:通过比较两个时间点/分支的数据差异,支持变更追踪、审计合规和 What-If 分析。

  5. 增量读取和分区级时间旅行是性能关键:避免全表快照扫描,通过增量文件列表和分区剪枝将查询时间降低 90%。

#Next Article

下一篇 S3-11《Diff 查询:分支对比与变更追踪》 将更深入 Nessie 分支模型下的 Diff 查询,包括多分支合并冲突检测、Schema 演进下的兼容 Diff 和 Diff 结果的可视化展示。

Tags: #TimeTravel #Iceberg #Snapshot #VersionedQuery #TemporalData #COW #MOR #AuditTrail #DiffQuery #智策平台 #coomia-dip #数据基座