DWS层宽表设计:维度退化、预计算与主键哲学

宽表设计,说白了就是「用空间换时间」。你想想看,实时场景下每多一次 join,延迟就多几十毫秒。DWS 层的核心使命就是把这些 join 干掉,把维度塞进事实表里。今天咱们聊聊四个关键点:维度退化、度量预计算、字典编码、主键设计。每个点我都踩过坑,咱们一个一个说。

核心原则:DWS 宽表不是简单地把表拼宽,而是「面向查询」重新组织数据。能预计算的绝不实时算,能编码的绝不存原文。

4.1 维度退化策略:把维度「拍平」进事实表

什么叫维度退化?就是把维度表的属性直接复制到事实表里,不再保留外键关联。我刚开始做数仓时,总觉得星型模型是金科玉律,事实表就该只存外键。直到有一次做实时大屏,事实表关联 7 张维度表,查询延迟直接飙到 3 秒以上。嗯,从那以后我学会了「该退化时就退化」。

哪些维度适合退化?

  • 高基维度:比如订单 ID、用户 ID,每个值只出现一次,建维度表纯属浪费。
  • 低基且稳定的维度:比如订单状态(待支付、已支付、已取消),枚举值不超过 10 个。
  • 经常一起查询的维度:比如商品的一级类目、二级类目,用户经常按类目聚合。

避坑指南:我曾经把「用户地址」这种高频变化字段也退化了,结果用户改地址后,历史宽表数据全错了。记住:缓慢变化维度(SCD)不要轻易退化,除非你能接受快照语义。

维度类型 退化建议 原因
订单 ID 强烈建议退化 唯一值,无维度表意义
商品类目 建议退化 查询频率高,变化慢
用户地址 谨慎退化 SCD 类型 2,需快照
时间维度 退化到日期粒度 避免到秒级关联

4.2 度量值预计算:能算的提前算好

预计算是 DWS 层最核心的优化手段。我的原则是:同一个度量值,不要在查询时算两遍。

常见的预计算模式:

  1. 累加度量:比如订单金额、下单数量,直接存 SUM 值。
  2. 半累加度量:比如库存水位,不能跨时间累加,但可以存当前快照。
  3. 派生度量:比如客单价 = 金额 / 用户数,提前算好存着。
  4. 比率度量:比如转化率、退款率,分子分母分别存,查询时再除。

我的习惯:在 DWS 层至少保留「原始累加值」和「常用派生值」两套字段。比如存「支付金额」和「7日累计支付金额」,后者用 Flink 的滑动窗口预计算好。查询时直接取,不用再开窗。

代码示例:Flink SQL 预计算 7 日累计

-- 预计算每个用户的7日累计支付金额
INSERT INTO dws_user_pay_7d
SELECT
    user_id,
    SUM(pay_amount) AS pay_amount_7d,
    COUNT(DISTINCT order_id) AS order_cnt_7d,
    -- 窗口结束时间作为分区字段
    window_end
FROM (
    SELECT *,
        TUMBLE_START(proctime, INTERVAL '1' DAY) AS window_start,
        TUMBLE_END(proctime, INTERVAL '7' DAY) AS window_end
    FROM dwd_order_pay
    GROUP BY
        user_id,
        TUMBLE(proctime, INTERVAL '7' DAY)
);

注意看,这里用了 7 天滚动窗口,而不是 1 天窗口再累加。为什么?因为 7 天窗口能保证数据不重叠,查询时直接取最新分区就行。我曾经用 1 天窗口累加 7 天,结果每天凌晨数据对不上,排查了半天才发现是窗口边界问题。

4.3 字典编码与压缩:让宽表「瘦」下来

宽表字段多,动辄上百列。如果不做压缩,存储和 IO 都扛不住。字典编码是我最常用的手段。

什么时候用字典编码?

  • 低基数字段:比如性别、状态码、类目 ID,枚举值不超过几百个。
  • 重复率高的字段:比如城市名、渠道来源,虽然基数可能上千,但重复率极高。
  • 不适合的场景:订单 ID、手机号这种几乎唯一的字段,字典编码反而浪费。

我常用的编码方案:

字段类型 编码方式 压缩比 备注
枚举值(状态、性别) TINYINT 映射 8:1 字典表固定,无需维护
类目 ID(几千个) INT 字典编码 4:1 字典表可动态更新
城市名(几百个) SHORT 编码 6:1 配合 Bitmap 加速
长文本(商品标题) ZSTD 压缩 3:1~5:1 不适用字典编码

注意:字典编码虽然省空间,但会引入一次「解码」开销。如果查询频率极高(比如每秒几万次),建议把解码后的值也冗余存一份。我有个项目就是字典编码后查询太慢,最后改成「编码+原文」双字段存储。

4.4 主键设计原则:唯一且稳定

DWS 宽表的主键设计,直接决定了数据能否正确去重、能否支持幂等写入。我见过太多因为主键设计不合理导致的数据重复问题。

主键设计三原则:

  1. 唯一性:主键必须能唯一标识一行。比如「用户 ID + 日期」可以标识用户日汇总。
  2. 稳定性:主键字段不能变。比如用「订单 ID」做主键,订单 ID 一旦生成就不变。
  3. 简洁性:主键字段越少越好。联合主键不要超过 3 个字段,否则 join 和去重都很慢。

常见的主键设计方案:

  • 单字段主键:比如订单宽表用 order_id,最理想。
  • 复合主键:比如用户日汇总用 user_id + stat_date,注意日期字段要格式化到天。
  • 代理主键:用自增 ID 或 UUID,适合没有自然主键的场景。

避坑指南:我曾经用「用户 ID + 商品 ID」做复合主键,结果同一个用户一天内买了同一个商品两次,数据直接覆盖了。后来改成「用户 ID + 商品 ID + 订单时间戳」才解决。记住:主键的粒度要等于数据的最小业务粒度。

4.5 整体设计流程:一张图看懂

下面这张图总结了 DWS 宽表设计的四个步骤,以及它们之间的关系。我习惯在设计前先画一遍这个流程,避免遗漏。

DWS宽表设计流程 DWS宽表设计四步流程 1. 维度分析 识别高基/低基维度 2. 度量规划 确定预计算策略 3. 编码压缩 字典/ZSTD选择 4. 主键设计 唯一/稳定/简洁 DWS 宽表 维度退化 + 预计算 + 编码 + 主键 💡 设计要点: • 维度退化优先处理高基和稳定维度,SCD维度谨慎退化 • 度量预计算遵循「一次计算,多次使用」原则,窗口粒度对齐查询需求

4.6 实战案例:订单宽表设计

拿订单场景举个例子。假设我们有订单事实表、用户维度表、商品维度表、商家维度表。传统星型模型需要 4 次 join,DWS 宽表直接拍平。

宽表字段设计:

-- DWS 订单宽表(退化后)
CREATE TABLE dws_order_wide (
    -- 主键
    order_id          STRING,       -- 单字段主键
    
    -- 退化维度(来自用户表)
    user_id           STRING,
    user_level        TINYINT,      -- 字典编码:1-普通,2-VIP,3-SVIP
    user_register_city STRING,      -- 字典编码
    
    -- 退化维度(来自商品表)
    product_id        STRING,
    category_l1       STRING,       -- 一级类目
    category_l2       STRING,       -- 二级类目
    brand_id          INT,          -- 字典编码
    
    -- 退化维度(来自商家表)
    seller_id         STRING,
    seller_grade      TINYINT,      -- 字典编码
    
    -- 预计算度量
    order_amount      DECIMAL(18,2), -- 原始累加
    order_quantity    INT,
    pay_amount        DECIMAL(18,2), -- 支付金额
    pay_time          TIMESTAMP,
    
    -- 派生度量
    order_to_pay_seconds BIGINT,    -- 下单到支付时长,预计算好
    
    -- 分区字段
    dt                STRING        -- 按天分区
)
PARTITIONED BY (dt);

我的习惯:宽表字段命名统一用「维度_属性」格式,比如 user_level、product_category。这样查询时一眼就能看出字段来源,不用翻文档。另外,所有字典编码字段都加注释说明映射关系,避免后人看不懂。

嗯,以上就是 DWS 宽表设计的四个核心点。维度退化解决 join 问题,预计算解决性能问题,编码压缩解决存储问题,主键设计解决一致性问题。四者缺一不可。你在实际项目中可以先从维度退化入手,逐步叠加其他策略,不要试图一步到位。