Kylin 全局字典机制与 StarRocks Bitmap 精确去重技术调研

一、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(目标)

  1. 理解 Kylin 最新版(5.x)全局字典的构建机制与增量更新策略
  2. 提炼可落地的 SparkSQL 字典构建方案
  3. 设计 SparkSQL → StarRocks Bitmap 的完整数据管道
  4. 支撑 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;

其工作原理:

  1. 存储层:每个 Segment 的 Footer 中自动存储低基数列的局部字典
  2. 统计信息收集:FE 通过统计信息筛选潜在低基数列
  3. 全局字典构建:从各 Segment 的局部字典去重合并,生成全局字典
  4. 查询优化:CBO 优化器将 String 操作转化为 int 操作,减少 Shuffle 和内存开销
  5. 增量维护:新导入数据产生新 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 表维护全局字典。

⚠️ 问题警示:在海量数据(亿级以上)下存在严重性能瓶颈:

  1. CROSS JOIN max_id 单点汇聚MAX(encode_id) 需扫描全表汇聚到单 Reducer,亿级字典表耗时长
  2. ROW_NUMBER 全局排序 ShuffleORDER BY raw_value 触发全量数据全局排序,内存溢出风险高
  3. NOT EXISTS 反复探测:每条增量值都需与字典表比对,退化为 Nested Loop Join
  4. 编码空间单点瓶颈: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"

参考资料

相关推荐
StarChainTech1 小时前
先享后付,正在悄悄改变电商的“信任游戏”
大数据·人工智能·游戏·微信小程序·小程序·软件需求
Data_Journal1 小时前
Node.js网络爬取指南——简单易上手!
大数据·linux·服务器·前端·javascript
FairGuard手游加固1 小时前
FairGuard全链路反外挂方案,破解游戏安全困局
大数据·安全·游戏
CORNERSTONE3652 小时前
如何理解工业软件 PLM、ERP、MES 的边界?
大数据·人工智能·plm·产品全生命周期管理
爱喝热水的呀哈喽2 小时前
agent4hypermesh计划
大数据·elasticsearch·搜索引擎
大任视点2 小时前
康道灵芝:不拼故事拼实力,重新定义“好灵芝”
大数据
小袁说公考2 小时前
2026广东公考培训标杆深度解析:广东粉笔——科技赋能本土,领跑粤考赛道
大数据·人工智能·经验分享·笔记·科技·其他
hf2000122 小时前
从 Palantir Ontology 到企业 AI 决策系统
大数据·人工智能
ZGi.ai2 小时前
AI搜索引擎崛起:企业AI内容如何被GEO收录和引用
大数据·人工智能·搜索引擎·aigc·geo·ai搜索引擎