流式数据湖Paimon探秘之旅 (十) Merge Engine合并引擎

第10章:Merge Engine合并引擎

导言:同一主键的多个版本怎么合并

在之前的章节中,我们讲到LSM Tree会有多个版本的同一主键。比如:

ini 复制代码
Level 0: key=1, value="Alice_v2" (最新)
Level 1: key=1, value="Alice_v1" (旧)
Level 2: key=1, value="Alice" (最初)

问题:查询key=1时,应该返回哪个版本?如何合并?
答案:使用Merge Engine(合并引擎)

Merge Engine的职责 :定义如何合并同一主键的多个版本


第一部分:Merge Engine类型

1.1 Deduplicate(去重)

最常用的引擎,只保留最新版本

ini 复制代码
输入多个版本:
key=1: (seq=1, value="Alice"),
       (seq=2, value="Alice_update"),
       (seq=3, value="Alice_final")

合并规则:
按seq号(序列号)排序,选最大的seq
seq=3最大 → 返回"Alice_final"

输出:
key=1, value="Alice_final"

配置:
merge-engine: deduplicate

实现原理

java 复制代码
public class DeduplicateMergeFunction implements MergeFunction {
    
    @Override
    public void merge(
            InternalRow accumulator,
            InternalRow input) {
        
        // 比较序列号
        long accSeq = accumulator.getSequenceNumber();
        long inputSeq = input.getSequenceNumber();
        
        if (inputSeq > accSeq) {
            // 新版本,替换
            accumulator = input;
        }
        // 否则保持旧版本
    }
}

应用场景

  • 用户表、订单表(主要关心最新状态)
  • 主键表通用场景

1.2 PartialUpdate(部分更新)

只更新指定的列,保留其他列

ini 复制代码
场景:用户表有10列,但某次更新只改了"age"字段

初始:
key=1: {id=1, name="Alice", age=25, city="NYC", score=100, ...}

更新1(只改age):
key=1: {id=1, age=26}  <- 其他字段没有值

合并规则:
版本1: {id=1, name="Alice", age=25, city="NYC", score=100}
版本2: {id=1, age=26}  (其他字段为NULL)

结果:
key=1: {id=1, name="Alice", age=26, city="NYC", score=100}
       ^ 只更新age,保留其他字段

配置:
merge-engine: partial-update

实现原理

java 复制代码
public class PartialUpdateMergeFunction implements MergeFunction {
    
    @Override
    public void merge(
            InternalRow accumulator,
            InternalRow input) {
        
        // 逐字段检查
        for (int i = 0; i < numFields; i++) {
            if (input.isNullAt(i)) {
                // input此字段为NULL,保留accumulator的值
                continue;
            } else {
                // input此字段有值,更新
                accumulator.setField(i, input.getField(i));
            }
        }
    }
}

应用场景

  • 大宽表(列数多)
  • 某些列更新频繁,某些列很少改变
  • 减少更新的数据量

与Deduplicate的对比

ini 复制代码
Deduplicate:版本2会替换版本1的所有列
PartialUpdate:版本2只替换有值的列,保留版本1的其他列值

对于只改age的情况:
Deduplicate结果:{id=1, age=26, name=NULL, ...}(丢失了name!)
PartialUpdate结果:{id=1, age=26, name="Alice", ...}(保留了name)

1.3 Aggregation(聚合)

对多个版本的同一字段进行聚合

ini 复制代码
场景:计数表,记录访问次数

多个版本:
key="page_1": count=100 (seq=1)
key="page_1": count=50  (seq=2)
key="page_1": count=75  (seq=3)

如果用Deduplicate:
结果只是 count=75(丢弃了其他版本)

如果用Aggregation(SUM):
结果是 count=100+50+75=225(累加)

配置:
merge-engine: aggregation
aggregation.function: SUM

常见聚合函数

函数 说明 例子
SUM 求和 count累加
MAX 取最大 last_click_time
MIN 取最小 first_visit_time
AVG 求平均 average_score
FIRST 取第一个 initial_value
LAST 取最后一个 latest_value

实现原理

java 复制代码
public class SumAggregateMergeFunction implements MergeFunction {
    
    private long sum = 0;
    
    @Override
    public void merge(
            InternalRow accumulator,
            InternalRow input) {
        
        // 累加数值列
        long accValue = accumulator.getLong(valueFieldIndex);
        long inputValue = input.getLong(valueFieldIndex);
        
        accumulator.setLong(valueFieldIndex, accValue + inputValue);
    }
}

应用场景

  • 计数表、指标表
  • 需要累积的指标(如PV、UV)

1.4 FirstRow(取第一行)

保留第一个版本,忽略后续所有版本

ini 复制代码
多个版本:
key=1: "Alice" (seq=1)  <- 保留这个
key=1: "Alice_v2" (seq=2)  ← 忽略
key=1: "Alice_v3" (seq=3)  ← 忽略

结果:key=1: "Alice"

使用场景:
├─ 初值很重要,后续更新不关心
├─ 例如:注册时间(永远用注册时的时间)
└─ 减少合并开销

1.5 LastRow(取最后一行)

与Deduplicate类似,保留最新版本

ini 复制代码
多个版本:
key=1: "Alice" (seq=1)
key=1: "Alice_v2" (seq=2)
key=1: "Alice_v3" (seq=3)  <- 保留这个(最新)

结果:key=1: "Alice_v3"

与Deduplicate的区别:
├─ Deduplicate:按sequence字段比较
├─ LastRow:按时间戳或提交顺序
└─ 大多数情况下结果相同

第二部分:Merge Engine的选择

2.1 选择矩阵

表的特性 推荐引擎 原因
维度表、实时快照 Deduplicate 只关心最新状态
大宽表、列级更新 PartialUpdate 减少空值,保留历史
计数、指标表 Aggregation 需要累积值
不可变表 FirstRow 性能最优

2.2 性能对比

scss 复制代码
假设:1000万条记录,有5个版本

Deduplicate:
├─ 合并复杂度:O(1)(直接比较seq)
├─ 内存占用:低(每个key只需存储一个版本)
└─ 吞吐:最高(100MB/s+)

PartialUpdate:
├─ 合并复杂度:O(N)(需要逐字段检查)
├─ 内存占用:高(需要跟踪每个字段的来源)
└─ 吞吐:中(50MB/s)

Aggregation:
├─ 合并复杂度:O(1)(直接相加)
├─ 内存占用:低
└─ 吞吐:高(80MB/s+)

FirstRow:
├─ 合并复杂度:O(1)
├─ 内存占用:低
└─ 吞吐:最高(150MB/s+)

第三部分:生产级配置

3.1 电商订单表(Deduplicate)

yaml 复制代码
CREATE TABLE orders (
    order_id BIGINT PRIMARY KEY,
    user_id BIGINT,
    amount DECIMAL,
    status STRING,
    updated_at BIGINT,
    ...
) WITH (
    'merge-engine' = 'deduplicate',
    'sequence.field' = 'updated_at',
    'bucket' = '16'
);

原因:
├─ 订单状态不断变化(待支付→支付→发货→完成)
├─ 只关心最新状态
└─ updated_at作为版本号

3.2 用户维表(PartialUpdate)

yaml 复制代码
CREATE TABLE users (
    user_id BIGINT PRIMARY KEY,
    name STRING,
    age INT,
    city STRING,
    email STRING,
    phone STRING,
    last_login BIGINT,
    updated_at BIGINT,
    ...
) WITH (
    'merge-engine' = 'partial-update',
    'sequence.field' = 'updated_at',
    'bucket' = '8'
);

原因:
├─ 用户有10多个字段
├─ 有些字段(如phone)很少改变
├─ 有些字段(如last_login)频繁更新
├─ 不希望频繁更新丢失其他字段的值

3.3 PV/UV表(Aggregation)

yaml 复制代码
CREATE TABLE metrics (
    date_hour STRING PRIMARY KEY,
    page_id STRING PRIMARY KEY,
    pv BIGINT,
    uv BIGINT,
    updated_at BIGINT,
    ...
) WITH (
    'merge-engine' = 'aggregation',
    'aggregation.field.pv' = 'SUM',
    'aggregation.field.uv' = 'SUM',
    'sequence.field' = 'updated_at'
);

原因:
├─ 多个埋点会更新同一指标
├─ 需要累加,而非覆盖
└─ SUM可以处理多个版本的数据合并

第四部分:Merge Function的实现

4.1 自定义Merge Function

某些情况下,内置的引擎无法满足需求,可以自定义:

java 复制代码
public class CustomMergeFunction implements MergeFunction<KeyValue> {
    
    private InternalRow lastValue;
    
    @Override
    public void reset() {
        lastValue = null;
    }
    
    @Override
    public void add(KeyValue kv) {
        // 处理新版本
        if (lastValue == null) {
            lastValue = kv.value();
        } else {
            lastValue = mergeLogic(lastValue, kv.value());
        }
    }
    
    @Override
    public KeyValue getResult() {
        return new KeyValue(currentKey, lastValue);
    }
    
    private InternalRow mergeLogic(
            InternalRow prev,
            InternalRow curr) {
        // 自定义合并逻辑
        
        // 例如:name保留旧值,age更新新值
        if (curr.isNullAt(nameFieldIdx)) {
            curr.setField(nameFieldIdx, prev.getField(nameFieldIdx));
        }
        
        return curr;
    }
}

4.2 配置自定义函数

yaml 复制代码
CREATE TABLE custom_table (
    id BIGINT PRIMARY KEY,
    name STRING,
    age INT,
    ...
) WITH (
    'merge-engine' = 'custom',
    'merge-engine.factory' = 'com.example.CustomMergeFunctionFactory'
);

第五部分:实战案例

5.1 案例1:订单表从Deduplicate迁移到PartialUpdate

场景

  • 订单表有30个字段
  • 初始用Deduplicate,发现有问题

问题

ini 复制代码
订单初始化:
order_id=1: {
    id=1, user_id=100, amount=1000,
    status="paid", payment_time=123456,
    ship_address="NYC", ship_time=NULL,
    ...
}

后续只更新ship_time:
order_id=1: {
    ship_time=234567  ← 仅更新这个字段
}

Deduplicate结果(错误):
order_id=1: {
    id=1, user_id=100, amount=1000,
    status=NULL, payment_time=NULL,  ← 丢失了!
    ship_address=NULL, ship_time=234567,
    ...
}

PartialUpdate结果(正确):
order_id=1: {
    id=1, user_id=100, amount=1000,
    status="paid", payment_time=123456,  ← 保留了!
    ship_address="NYC", ship_time=234567,
    ...
}

迁移步骤

sql 复制代码
-- Step 1: 创建新表(PartialUpdate)
CREATE TABLE orders_new (
    order_id BIGINT PRIMARY KEY,
    ...
) WITH (
    'merge-engine' = 'partial-update'
);

-- Step 2: 复制数据(使用Flink CDC或批导)
INSERT INTO orders_new SELECT * FROM orders;

-- Step 3: 验证数据一致性
SELECT COUNT(*) FROM orders
UNION ALL
SELECT COUNT(*) FROM orders_new;

-- Step 4: 切换应用指向新表
-- Step 5: 删除旧表
DROP TABLE orders;

ALTER TABLE orders_new RENAME TO orders;

5.2 案例2:实时指标表的Aggregation配置

场景

  • 每秒有1000次事件
  • 每个事件可能更新同一个小时的指标
  • 需要准确计算PV、UV

配置

yaml 复制代码
CREATE TABLE hourly_metrics (
    hour STRING PRIMARY KEY,  # 2024010112
    page_id STRING PRIMARY KEY,
    pv BIGINT,     # Page View
    uv BIGINT,     # Unique Visitor
    revenue DECIMAL,  # 收入
    updated_at BIGINT
) WITH (
    'merge-engine' = 'aggregation',
    'aggregation.field.pv' = 'SUM',
    'aggregation.field.uv' = 'SUM',
    'aggregation.field.revenue' = 'SUM',
    'sequence.field' = 'updated_at'
);

写入流程

ini 复制代码
事件1:hour="2024010112", page_id="home", pv=1, uv=1
    → 写入 {pv=1, uv=1}

事件2:hour="2024010112", page_id="home", pv=1, uv=1
    → 合并:{pv=1+1=2, uv=1+1=2}

事件3:hour="2024010112", page_id="home", pv=1, uv=0
    → 合并:{pv=2+1=3, uv=2+0=2}

最终结果:
hour="2024010112", page_id="home", pv=3, uv=2
(准确反映了实际的PV和UV)

性能指标

指标 实际值
写入吞吐 50K事件/秒
合并延迟 <10ms
最终一致性 1分钟内

第六部分:常见问题

Q1: Deduplicate和LastRow有什么区别?

复制代码
两者在大多数情况下结果相同,但有细微差异:

Deduplicate:
├─ 使用sequence.field比较版本
├─ 通常是自定义的序列号或时间戳
└─ 可能不等于提交顺序

LastRow:
├─ 使用提交顺序或时间戳
├─ 保证是物理上最后提交的版本
└─ 结果更确定

推荐:大多数场景用Deduplicate(因为支持配置sequence.field)

Q2: PartialUpdate会丢失数据吗?

ini 复制代码
安全的,不会丢失。原理:

版本1:{col1=A, col2=B, col3=C}
版本2:{col1=NULL, col2=updated_B, col3=NULL}  ← 稀疏更新

合并规则:
col1: NULL in v2 → 保留v1的A
col2: updated_B in v2 → 更新为updated_B
col3: NULL in v2 → 保留v1的C

结果:{col1=A, col2=updated_B, col3=C}

所以不会丢失任何数据,只是保留了历史的好值。

Q3: Aggregation合并时如何处理NULL?

ini 复制代码
场景:有些数据没有某个字段

版本1:pv=100, uv=50
版本2:pv=NULL, uv=30  ← 缺少pv字段

合并:
├─ pv: 100 + NULL = NULL(按NULL处理)
├─ 或 pv: 100 + NULL = 100(按0处理)
└─ 配置决定:null-aggregation-mode

推荐配置:
'aggregation.null-aggregation-mode' = 'skip'
# skip:跳过NULL,按0处理
# fail:遇到NULL报错

总结

Merge Engine选择流程

objectivec 复制代码
场景分析
    ↓
是否有多个版本的同主键?
    ├─ NO → 不需要Merge Engine
    └─ YES ↓
    ↓
只保留最新状态?
    ├─ YES → Deduplicate(推荐)
    └─ NO ↓
    ↓
保留部分历史列的值?
    ├─ YES → PartialUpdate
    └─ NO ↓
    ↓
需要累加数值?
    ├─ YES → Aggregation
    └─ NO ↓
    ↓
其他场景 → 自定义MergeFunction

配置checklist

  • 选择合适的merge-engine
  • 设置sequence.field用于排序
  • 如果是Aggregation,配置各字段的聚合函数
  • 验证合并逻辑是否符合业务
  • 测试边界情况(NULL、空值等)

下一章:第11章将讲解Changelog变更日志,包括生成、消费、实时处理等

相关推荐
en-route39 分钟前
深入理解数据仓库设计:事实表与事实宽表的区别与应用
大数据·数据仓库·spark
语落心生40 分钟前
流式数据湖Paimon探秘之旅 (八) LSM Tree核心原理
大数据
Light6042 分钟前
智慧办公新纪元:领码SPARK融合平台如何重塑企业OA核心价值
大数据·spark·oa系统·apaas·智能办公·领码spark·流程再造
智能化咨询1 小时前
(66页PPT)高校智慧校园解决方案(附下载方式)
大数据·数据库·人工智能
忆湫淮1 小时前
ENVI 5.6 利用现场标准校准板计算地表反射率具体步骤
大数据·人工智能·算法
lpfasd1231 小时前
现有版权在未来的价值:AI 泛滥时代的人类内容黄金
大数据·人工智能
庄小焱1 小时前
大数据存储域——图数据库系统
大数据·知识图谱·图数据库·大数据存储域·金融反欺诈系统
jiayong231 小时前
Elasticsearch Java 开发完全指南
java·大数据·elasticsearch
语落心生1 小时前
流式数据湖Paimon探秘之旅 (七) 读取流程全解析
大数据