
一、Situation(背景与问题)
1.1 核心痛点
数仓开发中,COUNT(DISTINCT) 是计算开销极高的操作。传统方式需要在分布式环境下进行多次 Shuffle,性能随数据量增大直线下降。同时,StarRocks 的 Bitmap 类型字段仅支持整数(BigInt/Int),而业务中的去重键(如 user_id、order_id)往往是字符串类型,必须通过全局字典将字符串映射为全局唯一整数,才能写入 Bitmap 字段实现精确去重。
1.2 技术选型背景
| 技术组件 | 角色定位 |
|---|---|
| Apache Kylin | 最早将全局字典 + Bitmap 精确去重工业化的开源项目,其 Global Dictionary 设计对数仓场景有重要参考价值 |
| StarRocks | 支持 Roaring Bitmap 类型 + 聚合模型(AGGREGATE KEY),天然支持 BITMAP_UNION 轻聚合 rollup |
| SparkSQL | 数仓 ETL 主力计算引擎,需在 Spark 侧完成字典构建和 Bitmap 序列化 |
1.3 传统 COUNT DISTINCT 的困境
┌──────────────┐ Shuffle 1 ┌──────────────┐ Shuffle 2 ┌──────────────┐
│ Scan Node1 │ ──────────────→ │ Partial Agg │ ──────────────→ │ Global Agg │
│ Scan Node2 │ │ Node1 │ │ Node (单点) │
│ Scan Node3 │ │ Node2 │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
问题:多次 Shuffle → 网络IO暴增 → 查询分钟级响应 → 高并发下系统崩溃
二、Task(目标)
- 理解 Kylin 最新版(5.x)全局字典的构建机制与增量更新策略
- 提炼可落地的 SparkSQL 字典构建方案
- 设计 SparkSQL → StarRocks Bitmap 的完整数据管道
- 支撑 StarRocks 轻聚合表上的精准 rollup(sum + count distinct)
三、Action(核心机制剖析)
3.1 Kylin 全局字典构建机制
3.1.1 字典类型体系
Kylin 的字典体系分两层:
| 字典类型 | 作用域 | 适用场景 | 编码空间 |
|---|---|---|---|
| Segment Dictionary(局部字典) | 单个 Cube Segment | 维度编码 | Segment 内唯一 |
| Global Dictionary(全局字典) | 跨所有 Segment | 精确去重(COUNT DISTINCT) | Cube 级全局唯一 |
关键源码路径(Kylin 4.x/5.x):
kylin/
├── core-dictionary/
│ └── src/main/java/org/apache/kylin/dict/
│ ├── GlobalDictionaryBuilder.java ← 全局字典构建入口
│ ├── AppendTrieDictionary.java ← 可追加的 Trie 字典实现(核心)
│ ├── AppendTrieDictionaryNode.java ← Trie 节点定义
│ ├── DictionaryManager.java ← 字典生命周期管理
│ ├── GlobalDictHllBuildStep.java ← 全局字典构建步骤(Cube Build 流程)
│ └── DictSliceJob.java ← 字典分片构建(大规模字典优化)
3.1.2 全局字典构建流程
┌─────────────────────────────────────────────────────────────┐
│ Kylin 全局字典构建流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Step1: 读取历史全局字典(HDFS 上的 Trie 文件) │
│ ↓ │
│ Step2: 扫描增量数据中所有去重列的 distinct 值 │
│ ↓ │
│ Step3: 对新值分配全局递增整数编码(从 maxId+1 开始) │
│ ↓ │
│ Step4: 将新映射追加写入 Trie 节点(AppendTrieDictionary) │
│ ↓ │
│ Step5: 持久化新版本全局字典到 HDFS │
│ ↓ │
│ Step6: Cube 构建时使用全局字典编码替代原始值 │
│ │
└─────────────────────────────────────────────────────────────┘
AppendTrieDictionary 核心设计:
- 基于 Trie 树(前缀树) 结构,支持追加写入(不需要重建整棵树)
- 每个叶子节点存储:
byte[] value → int encodeId - 编码分配策略:全局单调递增,新值追加到现有 maxId 之后
- 存储格式:序列化为 HDFS 文件,路径
/kylin/metadata/dict/{project}/{dict_name}
关键源码大意 (AppendTrieDictionary.java):
java
// 追加新值到字典,返回全局编码
public int appendValue(String value) {
// 1. 查找现有 Trie 节点
TrieNode node = findNode(value);
if (node != null) {
return node.encodeId; // 已存在,返回已有编码
}
// 2. 不存在,分配新编码(maxId + 1)
int newId = ++maxId;
// 3. 插入新 Trie 节点
insertNode(value, newId);
return newId;
}
// 批量追加(Cube 构建时使用)
public void batchAppend(List<String> values) {
// 排序后批量插入,优化 Trie 树构建性能
Collections.sort(values);
for (String value : values) {
appendValue(value);
}
// 追加完成后,持久化到 HDFS
flushToHDFS();
}
GlobalDictionaryBuilder 构建入口 (GlobalDictionaryBuilder.java):
java
public class GlobalDictionaryBuilder {
private AppendTrieDictionary dict;
public void build(CubeSegment segment) {
// 1. 加载上一版本全局字典
dict = DictionaryManager.loadGlobalDict(segment.getCube(), columnName);
// 2. 从增量数据中提取 distinct 值
List<String> newValues = extractDistinctValues(segment);
// 3. 追加到字典
dict.batchAppend(newValues);
// 4. 保存新版本字典
DictionaryManager.saveGlobalDict(segment.getCube(), columnName, dict);
}
}
3.1.3 增量构建机制
Kylin 的增量构建以 Segment 为单位:
Cube
├── Segment[2024-01-01 ~ 2024-01-31] ← 全局字典 v1(基线构建)
├── Segment[2024-02-01 ~ 2024-02-28] ← 全局字典 v2(在 v1 基础追加)
└── Segment[2024-03-01 ~ 2024-03-31] ← 全局字典 v3(在 v2 基础追加)
增量构建关键步骤:
| 步骤 | 说明 | 对应源码 |
|---|---|---|
| 字典复用 | 新 Segment 构建时,先加载最新版全局字典 | DictionaryManager.loadGlobalDict() |
| 增量追加 | 仅对增量数据中的新 distinct 值分配编码 | AppendTrieDictionary.appendValue() |
| 版本管理 | 每次构建生成新版本字典文件,MVCC 保证一致性 | HDFS 快照 + 版本号 |
| Segment Merge | 合并多个 Segment 时,全局字典也需合并 | GlobalDictMergeStep |
Segment Merge 时的字典合并:
合并前:
Segment A: 字典 v1 → 值 {a→1, b→2, c→3}
Segment B: 字典 v2 → 值 {a→1, b→2, d→4, e→5}
合并后:
字典 v3 → 值 {a→1, b→2, c→3, d→4, e→5}
(取并集,保留原有编码不变,仅追加新值)
Kylin 5.x 新特性:支持整数类型跳过字典编码
- 配置:
kylin.query.skip-encode-integer-enabled = true - 若去重列本身是整数类型,可直接作为 Bitmap offset,省去字典构建步骤
- 这对 SparkSQL → StarRocks 方案有重要启示:如果原始 ID 已经是整数,直接用即可
3.1.4 大规模字典优化:DictSliceJob
当全局字典的基数超过千万级时,单节点构建 Trie 树会面临内存瓶颈。Kylin 引入了 DictSliceJob 进行分片构建:
java
// DictSliceJob 核心思想:将大字典拆分为多个 Slice 并行构建
public class DictSliceJob {
// 1. 按 hash 将 distinct 值分到 N 个 Slice
// 2. 每个 Slice 独立构建子字典
// 3. 合并时按 Slice 编号偏移 encodeId,保证全局唯一
public static int SLICE_SIZE = 5_000_000; // 每个 Slice 约 500 万值
}
编码分配公式:globalEncodeId = sliceIndex * SLICE_SIZE + localEncodeId
3.1.5 Kylin 全局字典的局限与启示
| 局限 | 说明 | 数仓落地启示 |
|---|---|---|
| 绑定 Cube 生命周期 | 字典随 Cube 构建产生,独立性差 | 字典应独立为维度表,与业务解耦 |
| 中心化构建 | 字典构建在单节点完成,超大数据集时成为瓶颈 | SparkSQL 分布式构建,利用集群并行能力 |
| 不支持跨 Cube 复用 | 同一字段不同 Cube 需独立构建字典 | 统一字典表,全公司共享 |
| 与 HBase/HDFS 强耦合 | 不适用于 SparkSQL → StarRocks 的独立管道 | 用 Hive 表存储字典,SparkSQL 原生支持 |
3.2 StarRocks Bitmap 精确去重机制
3.2.1 Roaring Bitmap 原理
StarRocks 采用 Roaring Bitmap 存储 Bitmap 字段:
32位整数空间 (0 ~ 2^32 - 1)
├── 高16位 → 65536 个桶 (Container)
│ ├── 桶内基数 ≤ 4096 → Array Container (有序数组,2 bytes/值)
│ └── 桶内基数 > 4096 → Bitset Container (8KB 固定大小位图)
│
├── 压缩效果示例:
│ ├── 稀疏数据 {1, 100, 1000} → Array Container,仅 6 bytes
│ ├── 密集数据 {0..9999} → Bitset Container,8KB
│ └── 极稀疏数据 {1000000} → Array Container,2 bytes
│
└── 对比传统 Bitmap:
├── 1 亿个 32 位 ID → 传统 Bitmap: 512MB → Roaring Bitmap: ~16MB (稀疏)
└── 1 亿个 32 位 ID → 传统 Bitmap: 512MB → Roaring Bitmap: ~128MB (密集)
3.2.2 聚合模型 + BITMAP_UNION
sql
-- 轻聚合表定义
CREATE TABLE dws_user_uv (
dt DATE,
page VARCHAR(64),
uv_bitmap BITMAP BITMAP_UNION, -- 声明聚合函数
pv BIGINT SUM
) AGGREGATE KEY(dt, page)
DISTRIBUTED BY HASH(dt) BUCKETS 8
PROPERTIES ("replication_num" = "3");
-- 插入时自动聚合
INSERT INTO dws_user_uv VALUES
('2024-01-01', 'home', to_bitmap(1001), 1),
('2024-01-01', 'home', to_bitmap(1002), 1),
('2024-01-01', 'home', to_bitmap(1001), 1);
-- uv_bitmap: 自动执行 BITMAP_UNION → {1001, 1002}
-- pv: 自动执行 SUM → 3
-- 查询精准 UV
SELECT dt, page,
BITMAP_UNION_COUNT(uv_bitmap) AS uv,
SUM(pv) AS pv
FROM dws_user_uv
GROUP BY dt, page;
-- 精准 rollup:上卷聚合(dt 维度)
SELECT dt,
BITMAP_UNION_COUNT(uv_bitmap) AS total_uv, -- 精准去重
SUM(pv) AS total_pv -- 精准求和
FROM dws_user_uv
GROUP BY dt;
BITMAP_UNION 聚合函数的核心特性:
| 函数 | 含义 | 数学性质 | Rollup 支持 |
|---|---|---|---|
BITMAP_UNION(bitmap) |
Bitmap 并集 | 可加(满足上卷) | 支持 |
BITMAP_UNION_COUNT(bitmap) |
并集后统计基数 = 精确 COUNT DISTINCT | 可加 | 支持 |
BITMAP_INTERSECT_COUNT(b1, b2) |
交集统计 = 留存分析 | 不可加 | 需特殊处理 |
BITMAP_CONTAINS(bitmap, value) |
判断值是否在 Bitmap 中 | --- | --- |
为什么 Bitmap 能支撑精准 Rollup:
原始数据:
dt=01-01, page=home, uv_bitmap={1001, 1002}
dt=01-01, page=search, uv_bitmap={1002, 1003}
dt=01-02, page=home, uv_bitmap={1001, 1003}
上卷到 dt 粒度(BITMAP_UNION):
dt=01-01 → {1001, 1002} ∪ {1002, 1003} = {1001, 1002, 1003} → count=3
dt=01-02 → {1001, 1003} → count=2
上卷到全表粒度(BITMAP_UNION):
{1001, 1002, 1003} ∪ {1001, 1003} = {1001, 1002, 1003} → count=3
结论:BITMAP_UNION 满足可加性,支持任意粒度上卷,结果精准无误差
3.2.3 StarRocks 自身的全局字典(低基数优化)
StarRocks 2.0+ 内置了低基数字符串全局字典优化:
sql
-- 默认开启
SET cbo_enable_low_cardinality_optimize = true;
其工作原理:
- 存储层:每个 Segment 的 Footer 中自动存储低基数列的局部字典
- 统计信息收集:FE 通过统计信息筛选潜在低基数列
- 全局字典构建:从各 Segment 的局部字典去重合并,生成全局字典
- 查询优化:CBO 优化器将 String 操作转化为 int 操作,减少 Shuffle 和内存开销
- 增量维护:新导入数据产生新 Segment 时,FE 检查局部字典是否为全局字典子集,否则触发全局字典重建
重要区分 :这是查询加速层面的优化,并非为 Bitmap 字段服务的字符串→整数映射字典。Bitmap 字段要求的字符串→全局整数映射需要开发者在 ETL 侧自行完成。
3.3 SparkSQL 构建全局字典 + Bitmap 的落地方案
3.3.1 方案整体架构
┌──────────────────────────────────────────────────────────────────┐
│ 数据管道全景 │
│ │
│ ┌───────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Hive/HDFS │────→│ SparkSQL │────→│ dim_global_dict │ │
│ │ (原始数据) │ │ Step1: 构建/ │ │ (全局字典表) │ │
│ └───────────┘ │ 增量更新字典 │ └────────┬─────────┘ │
│ └──────────────┘ │ │
│ │ JOIN │
│ ┌──────────────┐ │ │
│ │ SparkSQL │◄─────────────┘ │
│ │ Step2: 字典 │ │
│ │ 编码+Bitmap │ │
│ │ 构建 │ │
│ └──────┬───────┘ │
│ │ │
│ ┌─────────────┼─────────────┐ │
│ ▼ ▼ ▼ │
│ 方式一: 方式二: 方式三: │
│ bitmap_from_string PySpark UDF Spark StreamLoad │
│ 中间表→目标表 RoaringBitmap 直接写入 │
│ (小数据量) 序列化写入 (推荐) │
│ (大数据量) │
│ │ │
│ ┌──────▼───────┐ │
│ │ StarRocks │ │
│ │ AGGREGATE │ │
│ │ KEY 表 │ │
│ │ (轻聚合) │ │
│ └──────┬───────┘ │
│ │ │
│ ┌──────▼───────┐ │
│ │ BI 查询 │ │
│ │ 精准 Rollup │ │
│ │ SUM + UV │ │
│ └─────────────┘ │
└──────────────────────────────────────────────────────────────────┘
3.3.2 Step1:全局字典构建(SparkSQL)
核心思路:借鉴 Kylin AppendTrieDictionary 的"追加分配"思想,在 Spark 侧用 Hive 表维护全局字典。
⚠️ 问题警示:在海量数据(亿级以上)下存在严重性能瓶颈:
- CROSS JOIN max_id 单点汇聚 :
MAX(encode_id)需扫描全表汇聚到单 Reducer,亿级字典表耗时长- ROW_NUMBER 全局排序 Shuffle :
ORDER BY raw_value触发全量数据全局排序,内存溢出风险高- NOT EXISTS 反复探测:每条增量值都需与字典表比对,退化为 Nested Loop Join
- 编码空间单点瓶颈:max_id 是全局唯一入口,无法分布式并行分配
解决方案 :采用哈希分桶预分配编码空间,将单点瓶颈转化为分布式并行计算。
方案A:哈希分桶预分配(推荐,海量数据场景)
借鉴 Kylin DictSliceJob 的分片思想和 StarRocks/SelectDB 正交分桶 Bitmap 的设计,将字典按 raw_value 哈希分到固定数量的桶中,每个桶内独立编码,全局编码由桶号计算得出。
sql
-- ============================================================
-- 分桶字典表定义(海量数据推荐)
-- ============================================================
CREATE TABLE IF NOT EXISTS dim_global_dict_bucketed (
dict_name STRING COMMENT '字典名称',
bucket_id INT COMMENT '哈希分桶ID(0 ~ N-1)',
raw_value STRING COMMENT '原始字符串值',
encode_id BIGINT COMMENT '全局编码ID = bucket_id * BUCKET_SIZE + local_row_num',
update_dt STRING COMMENT '更新日期'
)
PARTITIONED BY (dict_name_part STRING)
CLUSTERED BY (raw_value) SORTED BY (raw_value) INTO 64 BUCKETS
STORED AS ORC
TBLPROPERTIES (
'transactional'='true',
'orc.compress'='ZSTD',
'orc.create.index'='true'
);
-- 分桶参数:桶数量和每桶编码空间大小
-- 桶数量建议:64 ~ 256,根据集群规模和数据量调整
-- 每桶编码空间:需 >= 该桶历史最大行数 + 增量行数,建议预留 10%~20% 裕量
-- 全局编码公式:encode_id = bucket_id * BUCKET_SIZE + local_encode_id
-- BUCKET_SIZE 必须是 2 的幂次,方便位运算,例如 67108864 (2^26 ≈ 6700万/桶)
-- 全局编码上限 = BUCKET_COUNT * BUCKET_SIZE,如 128 * 2^26 ≈ 85.9 亿,远小于 2^32 上限
增量构建SQL(每日调度执行):
sql
-- ============================================================
-- 增量构建分桶字典(分布式并行,无单点瓶颈)
-- ============================================================
-- Step 1: 提取增量数据中的新 distinct 值,并计算桶号
-- hash 函数根据引擎选择:SparkSQL 用 hash()/pmod(),Hive 用 pmod(hash(), N)
CREATE TEMPORARY VIEW incremental_new_values AS
SELECT
i.raw_value,
pmod(hash(i.raw_value), ${bucket_count}) AS bucket_id
FROM (
SELECT DISTINCT user_id AS raw_value
FROM dwd_user_action
WHERE dt = '${bizdate}'
) i
LEFT JOIN dim_global_dict_bucketed d
ON d.dict_name = 'user_id_dict' AND d.raw_value = i.raw_value
WHERE d.raw_value IS NULL; -- LEFT JOIN + IS NULL 替代 NOT EXISTS,可走 SortMergeJoin
-- Step 2: 按桶分配局部编码 + 计算全局编码
-- 关键:ROW_NUMBER 仅在桶内排序,数据量 = 增量新值 / 桶数,远小于全局排序
INSERT INTO TABLE dim_global_dict_bucketed PARTITION(dict_name_part='user_id_dict')
SELECT
'user_id_dict' AS dict_name,
n.bucket_id,
n.raw_value,
n.bucket_id * ${bucket_size} + ROW_NUMBER() OVER (
PARTITION BY n.bucket_id ORDER BY n.raw_value
) AS encode_id,
'${bizdate}' AS update_dt
FROM incremental_new_values n;
-- ============================================================
-- 也可使用 MERGE INTO(需要 Hive 3.x+ / Spark 3.x+)
-- ============================================================
MERGE INTO dim_global_dict_bucketed AS target
USING (
SELECT
'user_id_dict' AS dict_name,
n.bucket_id,
n.raw_value,
n.bucket_id * ${bucket_size} + ROW_NUMBER() OVER (
PARTITION BY n.bucket_id ORDER BY n.raw_value
) AS encode_id,
'${bizdate}' AS update_dt,
'user_id_dict' AS dict_name_part
FROM incremental_new_values n
) AS source
ON target.dict_name = source.dict_name
AND target.raw_value = source.raw_value
WHEN NOT MATCHED THEN INSERT (dict_name, bucket_id, raw_value, encode_id, update_dt, dict_name_part)
VALUES (source.dict_name, source.bucket_id, source.raw_value, source.encode_id, source.update_dt, source.dict_name_part);
哈希分桶方案的核心优势:
| 对比维度 | 初始方案(max_id + ROW_NUMBER) | 分桶方案(bucket_id * SIZE + local_row_num) |
|---|---|---|
| 编码分配 | 全局单点(需先算 MAX) | 分桶并行(无需全局 MAX) |
| ROW_NUMBER 排序 | 全局 ORDER BY → 单 Reducer | 桶内 ORDER BY → 分桶并行 |
| Shuffle 量 | 全量数据全局 Shuffle | 桶内局部 Shuffle,数据量 = 总量/桶数 |
| JOIN 去重 | NOT EXISTS → Nested Loop | LEFT JOIN + IS NULL → SortMergeJoin |
| 编码唯一性 | 全局连续递增 | 桶内连续,桶间由 bucket_id * SIZE 偏移保证唯一 |
| 扩展性 | 差(单点瓶颈随数据量恶化) | 好(增加桶数即可水平扩展) |
注意:桶内增量编码可能产生空洞(每日增量不全量读取桶内已有 max_id),但 Bitmap 仅关心 offset 是否被 set,空洞不影响去重精度,仅轻微增加 Bitmap 内存占用。若需消除空洞,可定期全量重建(建议月度执行)。
方案B:号段模式预分配(适用于超大规模字典)
借鉴美团 Leaf-segment 号段模式的思想,将编码空间按号段批量预分配给每个 Spark Executor,每个 Executor 在本地内存中独立分配编码,无需全局协调。
sql
-- ============================================================
-- 号段分配元数据表
-- ============================================================
CREATE TABLE IF NOT EXISTS dict_segment_allocator (
dict_name STRING COMMENT '字典名称',
segment_id BIGINT COMMENT '号段起始ID',
step INT COMMENT '号段步长(如1000000)',
status STRING COMMENT 'USED / AVAILABLE',
update_dt STRING COMMENT '分配日期'
)
STORED AS ORC
TBLPROPERTIES ('transactional'='true');
-- 初始化号段池(一次性操作,预生成足够多的号段)
-- 例如:step=100万,预生成 1000 个号段,覆盖 0 ~ 10亿 编码空间
INSERT INTO TABLE dict_segment_allocator
SELECT
'user_id_dict' AS dict_name,
rn * 1000000 AS segment_id,
1000000 AS step,
'AVAILABLE' AS status,
NULL AS update_dt
FROM (SELECT explode(sequence(0, 999)) AS rn) t;
python
# ============================================================
# PySpark 号段模式字典构建(Executor 本地分配编码,零 Shuffle)
# ============================================================
from pyspark.sql import SparkSession
from pyspark.sql.functions import udf, monotonically_increasing_id
from pyspark.sql.types import LongType, StructType, StructField, StringType
import threading
# 号段分配器(每个 Executor 独立持有一段编码空间)
class SegmentAllocator:
"""线程安全的号段分配器,从元数据表批量申请号段后在本地分配"""
_lock = threading.Lock()
_current_id = 0
_max_id = 0
_step = 1000000 # 每次申请100万个ID
@classmethod
def next_id(cls):
with cls._lock:
if cls._current_id >= cls._max_id:
# 号段用尽,通过 JDBC 申请新号段(原子操作)
# UPDATE dict_segment_allocator
# SET status='USED', update_dt='${bizdate}'
# WHERE dict_name='user_id_dict' AND status='AVAILABLE'
# LIMIT 1
# SELECT segment_id FROM dict_segment_allocator
# WHERE dict_name='user_id_dict' AND status='USED'
# AND update_dt='${bizdate}' ORDER BY segment_id DESC LIMIT 1
cls._current_id = _fetch_next_segment() # 外部实现
cls._max_id = cls._current_id + cls._step
cls._current_id += 1
return cls._current_id
@udf(returnType=LongType())
def assign_encode_id():
"""UDF: 在 Executor 本地分配全局唯一编码ID"""
return SegmentAllocator.next_id()
# Spark 作业:增量构建字典
spark = SparkSession.builder.appName("DictBuilder_SegmentMode").getOrCreate()
# 提取增量新值(LEFT ANTI JOIN 排除已存在值)
new_values = spark.sql("""
SELECT DISTINCT a.user_id AS raw_value
FROM dwd_user_action a
LEFT ANTI JOIN dim_global_dict d
ON d.dict_name = 'user_id_dict' AND a.user_id = d.raw_value
WHERE a.dt = '${bizdate}'
""")
# 本地分配编码(零 Shuffle)
dict_entries = new_values.withColumn("encode_id", assign_encode_id())
# 写入字典表
dict_entries.select(
lit('user_id_dict').alias('dict_name'),
col('encode_id'),
col('raw_value'),
lit('${bizdate}').alias('update_dt'),
lit('user_id_dict').alias('dict_name_part')
).write.mode("append").insertInto("dim_global_dict")
方案C:小数据量简易方案(基数 < 5000万)
对于基数较小的场景,原 max_id + ROW_NUMBER() 方案仍可使用,但需将 NOT EXISTS 改为 LEFT JOIN + IS NULL 以利用 SortMergeJoin:
sql
-- 小数据量场景(基数 < 5000万)
-- 改进点:NOT EXISTS → LEFT JOIN + IS NULL,走 SortMergeJoin 而非 Nested Loop
INSERT INTO TABLE dim_global_dict PARTITION(dict_name_part='user_id_dict')
SELECT
'user_id_dict' AS dict_name,
i.raw_value,
m.max_id + ROW_NUMBER() OVER (ORDER BY i.raw_value) AS encode_id,
'${bizdate}' AS update_dt
FROM (
SELECT DISTINCT a.user_id AS raw_value
FROM dwd_user_action a
WHERE a.dt = '${bizdate}'
) i
LEFT JOIN dim_global_dict d
ON d.dict_name = 'user_id_dict' AND d.raw_value = i.raw_value
CROSS JOIN (
SELECT COALESCE(MAX(encode_id), 0) AS max_id
FROM dim_global_dict
WHERE dict_name = 'user_id_dict'
) m
WHERE d.raw_value IS NULL; -- LEFT JOIN + IS NULL 替代 NOT EXISTS
方案选型对照表:
| 方案 | 适用基数 | 编码分配方式 | Shuffle 量 | 扩展性 | 实现复杂度 |
|---|---|---|---|---|---|
| A: 哈希分桶预分配 | 5000万 ~ 10亿+ | bucket_id * SIZE + local_row_num | 桶内局部 | 极好 | 中 |
| B: 号段模式预分配 | 10亿+ | Executor 本地分配,零 Shuffle | 近零 | 极好 | 高(需 UDF + 号段管理) |
| C: 小数据量简易方案 | < 5000万 | max_id + ROW_NUMBER | 全局 | 差 | 低 |
关键设计要点(借鉴 Kylin 源码思想):
| 设计要点 | 说明 | Kylin 对应 |
|---|---|---|
| 分桶独立编码 | 每桶独立编码空间,无需全局 MAX | DictSliceJob 分片构建 |
| 全局编码偏移 | bucket_id * BUCKET_SIZE + local_id 保证全局唯一 |
sliceIndex * SLICE_SIZE + localEncodeId |
| LEFT JOIN 去重 | 替代 NOT EXISTS,走 SortMergeJoin | Trie 查找已有节点 |
| 编码从 1 开始 | Bitmap 不支持 0 和负数 | Kylin 同理,minId = 1 |
| 分桶 + 排序 | 分桶表按 raw_value 分桶排序,加速 JOIN | DictSliceJob 分片思想 |
| 号段本地分配 | Executor 本地内存分配,零 Shuffle | AppendTrieDictionary 批量追加 |
3.3.3 Step2:SparkSQL 构建 Bitmap 并写入 StarRocks
方式一:通过中间表 + bitmap_from_string(简单场景推荐)
sql
-- Step2a: SparkSQL 构建聚合结果
INSERT OVERWRITE TABLE dws_uv_temp
SELECT
dt,
page,
CONCAT_WS(',', COLLECT_SET(CAST(d.encode_id AS STRING))) AS uv_ids_str
FROM dwd_user_action a
JOIN dim_global_dict d
ON d.dict_name = 'user_id_dict' AND a.user_id = d.raw_value
GROUP BY dt, page;
-- Step2b: StarRocks 侧导入
-- 中间表(StarRocks,Duplicate Key)
CREATE TABLE dws_uv_temp (
dt DATE,
page VARCHAR(64),
uv_ids_str VARCHAR(65533)
) DUPLICATE KEY(dt, page)
DISTRIBUTED BY HASH(dt) BUCKETS 8;
-- 目标聚合表(StarRocks,Aggregate Key)
CREATE TABLE dws_user_uv (
dt DATE,
page VARCHAR(64),
uv_bitmap BITMAP BITMAP_UNION,
pv BIGINT SUM
) AGGREGATE KEY(dt, page)
DISTRIBUTED BY HASH(dt) BUCKETS 8;
-- 导入:临时字符串 → Bitmap
INSERT INTO dws_user_uv (dt, page, uv_bitmap, pv)
SELECT
dt,
page,
BITMAP_FROM_STRING(uv_ids_str), -- "1,2,3" → Bitmap
1
FROM dws_uv_temp;
方式二:PySpark + RoaringBitmap 直接序列化(大数据量推荐)
python
from pyspark.sql import SparkSession
from pyspark.sql.functions import udf, collect_set, sum as _sum
from pyspark.sql.types import BinaryType, ArrayType, LongType
import struct
# ---- 方案 A:使用 pyroaring 库 ----
from pyroaring import BitMap as RoaringBitMap
@udf(returnType=BinaryType())
def build_bitmap(ids):
"""将整数数组构建为 RoaringBitmap 二进制"""
rb = RoaringBitMap()
for id_val in ids:
rb.add(int(id_val))
return rb.serialize()
# ---- 方案 B:不依赖第三方库,使用 StarRocks 的 bitmap_from_string ----
@udf(returnType=BinaryType())
def build_bitmap_native(ids):
"""利用 StarRocks 原生 bitmap_from_string,传入逗号分隔字符串"""
# 此方式仅适用于通过 Stream Load 导入
pass
# ---- SparkSQL + UDF 构建 Bitmap ----
spark = SparkSession.builder.appName("BitmapBuilder").getOrCreate()
df = spark.sql("""
SELECT a.dt, a.page,
COLLECT_SET(d.encode_id) AS uv_ids,
COUNT(1) AS pv
FROM dwd_user_action a
JOIN dim_global_dict d
ON d.dict_name = 'user_id_dict' AND a.user_id = d.raw_value
WHERE a.dt = '${bizdate}'
GROUP BY a.dt, a.page
""")
df_with_bitmap = df.withColumn("uv_bitmap", build_bitmap("uv_ids"))
df_result = df_with_bitmap.select("dt", "page", "uv_bitmap", "pv")
# ---- 写入 StarRocks(Stream Load 方式,推荐) ----
df_result.write.format("starrocks") \
.option("url", "http://sr-fe:8030") \
.option("database", "dws") \
.option("table", "dws_user_uv") \
.option("user", "root") \
.option("password", "***") \
.option("load_type", "stream_load") \
.save()
方式三:Spark Stream Load 直接写入(生产推荐)
python
import requests
import json
def stream_load_bitmap(df_partition, sr_url, sr_db, sr_table, sr_user, sr_password):
"""通过 Stream Load API 直接将 Bitmap 二进制写入 StarRocks"""
# 将 DataFrame 转为 CSV 格式
csv_data = df_partition.toPandas().to_csv(index=False, header=False)
response = requests.put(
f"{sr_url}/api/{sr_db}/{sr_table}/_stream_load",
headers={
"column_separator": ",",
"columns": "dt, page, uv_bitmap, pv",
"partial_columns": "true",
},
data=csv_data,
auth=(sr_user, sr_password)
)
return response.json()
3.3.4 字典增量更新保障机制
sql
-- ============================================================
-- 字典一致性校验(每次增量构建后执行)
-- ============================================================
-- 1. 检查编码连续性(无空洞)
SELECT 'GAP_CHECK' AS check_type,
COUNT(*) AS gap_count
FROM dim_global_dict d
WHERE dict_name = 'user_id_dict'
AND encode_id NOT IN (
SELECT encode_id FROM dim_global_dict
WHERE dict_name = 'user_id_dict'
);
-- 2. 检查编码唯一性(无重复)
SELECT 'DUPLICATE_CHECK' AS check_type,
raw_value, COUNT(*) AS cnt
FROM dim_global_dict
WHERE dict_name = 'user_id_dict'
GROUP BY raw_value
HAVING cnt > 1;
-- 3. 检查编码范围(最大编码 = 记录数)
SELECT 'RANGE_CHECK' AS check_type,
MAX(encode_id) AS max_id,
COUNT(*) AS row_count,
CASE WHEN MAX(encode_id) = COUNT(*) THEN 'OK' ELSE 'ERROR' END AS status
FROM dim_global_dict
WHERE dict_name = 'user_id_dict';
-- 4. 定期全量重建字典(每月执行,消除碎片)
INSERT OVERWRITE TABLE dim_global_dict PARTITION(dict_name_part='user_id_dict')
SELECT
'user_id_dict' AS dict_name,
raw_value,
ROW_NUMBER() OVER (ORDER BY raw_value) AS encode_id, -- 重新从1编号
'${bizdate}' AS update_dt,
'user_id_dict' AS dict_name_part
FROM (
SELECT DISTINCT raw_value
FROM dim_global_dict
WHERE dict_name = 'user_id_dict'
) t;
3.4 StarRocks 轻聚合表支撑精准 Rollup
3.4.1 完整建表示例
sql
-- ============================================================
-- DWS 轻聚合表:同时包含 SUM 指标和 BITMAP 去重指标
-- ============================================================
CREATE TABLE dws_page_stat (
dt DATE COMMENT '日期',
page VARCHAR(64) COMMENT '页面',
channel VARCHAR(32) COMMENT '渠道',
-- SUM 聚合指标(可加)
pv BIGINT SUM COMMENT '页面浏览量',
order_amt BIGINT SUM COMMENT '订单金额',
-- BITMAP 聚合指标(可加,精准去重)
uv_bitmap BITMAP BITMAP_UNION COMMENT '用户UV Bitmap',
buyer_bitmap BITMAP BITMAP_UNION COMMENT '买家Bitmap',
-- HLL 聚合指标(近似去重,可加,适用于超高基数场景)
uv_hll HLL HLL_UNION COMMENT 'UV近似去重'
) AGGREGATE KEY(dt, page, channel)
DISTRIBUTED BY HASH(dt) BUCKETS 16
PROPERTIES (
"replication_num" = "3",
"storage_format" = "DEFAULT",
"enable_persistent_index" = "true"
);
3.4.2 精准 Rollup 查询
sql
-- 按日汇总(上卷:page 维度消失)
SELECT
dt,
SUM(pv) AS total_pv,
BITMAP_UNION_COUNT(uv_bitmap) AS total_uv, -- 精准 COUNT DISTINCT
SUM(order_amt) AS total_amt
FROM dws_page_stat
WHERE dt BETWEEN '2024-01-01' AND '2024-01-31'
GROUP BY dt;
-- 按渠道汇总(上卷:page 维度消失)
SELECT
channel,
BITMAP_UNION_COUNT(uv_bitmap) AS channel_uv,
SUM(pv) AS channel_pv
FROM dws_page_stat
WHERE dt = '2024-01-01'
GROUP BY channel;
-- 全局汇总(上卷:所有维度消失)
SELECT
BITMAP_UNION_COUNT(uv_bitmap) AS total_uv,
BITMAP_UNION_COUNT(buyer_bitmap) AS total_buyer,
SUM(pv) AS total_pv,
SUM(order_amt) AS total_amt
FROM dws_page_stat
WHERE dt BETWEEN '2024-01-01' AND '2024-01-31';
-- 留存分析(Bitmap 交集)
SELECT
dt,
BITMAP_INTERSECT_COUNT(
uv_bitmap,
LAG(uv_bitmap) OVER (ORDER BY dt)
) AS retention_count
FROM (
SELECT dt, BITMAP_UNION(uv_bitmap) AS uv_bitmap
FROM dws_page_stat
WHERE dt IN ('2024-01-01', '2024-01-02')
GROUP BY dt
) t;
3.4.3 物化视图加速
sql
-- 创建物化视图:按 dt 预聚合,加速按日查询
CREATE MATERIALIZED VIEW mv_dt_agg AS
SELECT
dt,
BITMAP_UNION(uv_bitmap) AS uv_bitmap,
SUM(pv) AS pv,
SUM(order_amt) AS order_amt
FROM dws_page_stat
GROUP BY dt;
-- 查询自动命中物化视图
SELECT dt, BITMAP_UNION_COUNT(uv_bitmap) AS uv, SUM(pv) AS pv
FROM dws_page_stat
GROUP BY dt;
-- → 自动改写为查询 mv_dt_agg,亚秒级响应
四、Result(总结与实践建议)
4.1 Kylin 全局字典的核心启示
| Kylin 设计思想 | 数仓落地借鉴 |
|---|---|
| AppendTrieDictionary 追加式构建 | Spark 侧字典表采用 MERGE/INSERT 追加模式 |
| 分桶独立编码分配 | bucket_id * BUCKET_SIZE + local_row_num 保证全局唯一性 |
| Segment 级增量构建 | 按日期分区增量更新字典,不重建全量 |
| MVCC 版本管理 | 字典表加 update_dt 字段,支持时间旅行查询 |
| DictSliceJob 分片构建 | 大字典按 raw_value 分桶预分配编码空间,分布式并行构建 |
| 整数跳过字典(5.x新特性) | 若原始 ID 已是整数,直接用 to_bitmap(id) 省去字典 |
4.2 最佳实践建议
| 序号 | 建议 | 说明 |
|---|---|---|
| 1 | 整数型 ID 优先 | 若业务 ID 本身是整数,直接 to_bitmap() 写入 StarRocks,无需字典 |
| 2 | 字典分区管理 | 大字典按 dict_name + 日期分区,避免单表过大 |
| 3 | 编码空间预留 | 字典编码从 1 开始,与 StarRocks Bitmap offset 对齐(Bitmap 不支持 0 和负数) |
| 4 | Bitmap 导入方式选择 | 小数据量用 bitmap_from_string(),大数据量用 PySpark + RoaringBitmap + Stream Load |
| 5 | 聚合模型选择 | StarRocks 使用 AGGREGATE KEY 模型 + BITMAP_UNION,支持多次导入自动合并 |
| 6 | 字典一致性保障 | 增量构建时使用 ACID 事务表或加锁;分桶方案下桶间独立无冲突,号段方案下 Executor 本地线程安全分配 |
| 7 | 定期全量重建字典 | 长期增量追加后字典碎片化,建议每月全量重建一次 |
| 8 | 分桶优化 JOIN | 字典表按 raw_value 分桶,与事实表 JOIN 时避免全表扫描 |
| 9 | 物化视图预聚合 | 为高频查询维度组合创建物化视图,利用 Bitmap Union 的可加性 |
| 10 | 监控字典膨胀 | 编码空间上限约 2^32(约 42 亿),超过后需考虑分片或换用 HLL |
4.3 性能对比参考
| 方案 | COUNT DISTINCT 10亿行 | 存储开销 | Rollup 支持 | 精度 |
|---|---|---|---|---|
| 传统 COUNT(DISTINCT) | 分钟级 | 高(需 Shuffle) | 不支持预计算 | 精准 |
| HLL 近似去重 | 秒级 | 极低 | 支持 | 有 ~1% 误差 |
| 全局字典 + Bitmap | 亚秒级 | 低(Roaring 压缩) | 精准支持 | 精准 |
4.4 方案选型决策树
需要 COUNT DISTINCT 精准去重?
├── 否 → 使用 HLL 近似去重(HLL_UNION)
└── 是 → 去重键是整数类型?
├── 是 → 直接 to_bitmap(id) 写入 StarRocks(无需字典)
└── 否 → 需要全局字典映射
├── 基数 < 5000万 → 方案C:简易方案(LEFT JOIN + max_id + ROW_NUMBER)
├── 基数 5000万 ~ 10亿 → 方案A:哈希分桶预分配(bucket_id * SIZE + local_row_num)
└── 基数 ≥ 10亿 → 方案B:号段模式预分配(Executor本地分配,零Shuffle)
附录 A:完整调度脚本示例
bash
#!/bin/bash
# 每日调度:增量构建全局字典 + Bitmap 聚合 + 写入 StarRocks
BIZDATE=$1
DICT_NAME="user_id_dict"
SR_FE="http://sr-fe:8030"
SR_DB="dws"
SR_TABLE="dws_user_uv"
echo "=== Step1: 增量更新全局字典 ==="
spark-sql --hivevar bizdate=$BIZDATE --hivevar dict_name=$DICT_NAME \
-f /sql/step1_incremental_dict.sql
echo "=== Step2: 字典编码 + Bitmap 聚合 ==="
spark-sql --hivevar bizdate=$BIZDATE --hivevar dict_name=$DICT_NAME \
-f /sql/step2_bitmap_aggregation.sql
echo "=== Step3: 写入 StarRocks ==="
spark-submit --master yarn \
--py-files /lib/roaringbitmap.py \
/scripts/step3_write_starrocks.py $BIZDATE $SR_FE $SR_DB $SR_TABLE
echo "=== Step4: 数据校验 ==="
spark-sql --hivevar bizdate=$BIZDATE \
-f /sql/step4_data_validation.sql
echo "=== 完成: $BIZDATE ==="
附录 B:StarRocks Bitmap 常用函数速查
| 函数 | 用途 | 示例 |
|---|---|---|
to_bitmap(int) |
整数转 Bitmap | to_bitmap(1001) |
bitmap_from_string(str) |
逗号分隔字符串转 Bitmap | bitmap_from_string('1,2,3') |
BITMAP_UNION(bmp) |
Bitmap 并集 | BITMAP_UNION(uv_bitmap) |
BITMAP_UNION_COUNT(bmp) |
并集后计数 | BITMAP_UNION_COUNT(uv_bitmap) |
BITMAP_INTERSECT_COUNT(b1,b2) |
交集计数(留存) | BITMAP_INTERSECT_COUNT(a, b) |
BITMAP_CONTAINS(bmp, val) |
判断值是否存在 | BITMAP_CONTAINS(uv, 1001) |
BITMAP_CARDINALITY(bmp) |
Bitmap 基数 | BITMAP_CARDINALITY(to_bitmap(1)) = 1 |
BITMAP_HAS_ANY(b1, b2) |
是否有交集 | BITMAP_HAS_ANY(a, b) |
BITMAP_TO_STRING(bmp) |
转字符串 | BITMAP_TO_STRING(to_bitmap(1,2)) = "1,2" |
参考资料