第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变更日志,包括生成、消费、实时处理等