DWS层宽表设计:维度退化、预计算与主键哲学
宽表设计,说白了就是「用空间换时间」。你想想看,实时场景下每多一次 join,延迟就多几十毫秒。DWS 层的核心使命就是把这些 join 干掉,把维度塞进事实表里。今天咱们聊聊四个关键点:维度退化、度量预计算、字典编码、主键设计。每个点我都踩过坑,咱们一个一个说。
核心原则:DWS 宽表不是简单地把表拼宽,而是「面向查询」重新组织数据。能预计算的绝不实时算,能编码的绝不存原文。
4.1 维度退化策略:把维度「拍平」进事实表
什么叫维度退化?就是把维度表的属性直接复制到事实表里,不再保留外键关联。我刚开始做数仓时,总觉得星型模型是金科玉律,事实表就该只存外键。直到有一次做实时大屏,事实表关联 7 张维度表,查询延迟直接飙到 3 秒以上。嗯,从那以后我学会了「该退化时就退化」。
哪些维度适合退化?
- 高基维度:比如订单 ID、用户 ID,每个值只出现一次,建维度表纯属浪费。
- 低基且稳定的维度:比如订单状态(待支付、已支付、已取消),枚举值不超过 10 个。
- 经常一起查询的维度:比如商品的一级类目、二级类目,用户经常按类目聚合。
避坑指南:我曾经把「用户地址」这种高频变化字段也退化了,结果用户改地址后,历史宽表数据全错了。记住:缓慢变化维度(SCD)不要轻易退化,除非你能接受快照语义。
| 维度类型 | 退化建议 | 原因 |
|---|---|---|
| 订单 ID | 强烈建议退化 | 唯一值,无维度表意义 |
| 商品类目 | 建议退化 | 查询频率高,变化慢 |
| 用户地址 | 谨慎退化 | SCD 类型 2,需快照 |
| 时间维度 | 退化到日期粒度 | 避免到秒级关联 |
4.2 度量值预计算:能算的提前算好
预计算是 DWS 层最核心的优化手段。我的原则是:同一个度量值,不要在查询时算两遍。
常见的预计算模式:
- 累加度量:比如订单金额、下单数量,直接存 SUM 值。
- 半累加度量:比如库存水位,不能跨时间累加,但可以存当前快照。
- 派生度量:比如客单价 = 金额 / 用户数,提前算好存着。
- 比率度量:比如转化率、退款率,分子分母分别存,查询时再除。
我的习惯:在 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 宽表的主键设计,直接决定了数据能否正确去重、能否支持幂等写入。我见过太多因为主键设计不合理导致的数据重复问题。
主键设计三原则:
- 唯一性:主键必须能唯一标识一行。比如「用户 ID + 日期」可以标识用户日汇总。
- 稳定性:主键字段不能变。比如用「订单 ID」做主键,订单 ID 一旦生成就不变。
- 简洁性:主键字段越少越好。联合主键不要超过 3 个字段,否则 join 和去重都很慢。
常见的主键设计方案:
- 单字段主键:比如订单宽表用 order_id,最理想。
- 复合主键:比如用户日汇总用 user_id + stat_date,注意日期字段要格式化到天。
- 代理主键:用自增 ID 或 UUID,适合没有自然主键的场景。
避坑指南:我曾经用「用户 ID + 商品 ID」做复合主键,结果同一个用户一天内买了同一个商品两次,数据直接覆盖了。后来改成「用户 ID + 商品 ID + 订单时间戳」才解决。记住:主键的粒度要等于数据的最小业务粒度。
4.5 整体设计流程:一张图看懂
下面这张图总结了 DWS 宽表设计的四个步骤,以及它们之间的关系。我习惯在设计前先画一遍这个流程,避免遗漏。
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 问题,预计算解决性能问题,编码压缩解决存储问题,主键设计解决一致性问题。四者缺一不可。你在实际项目中可以先从维度退化入手,逐步叠加其他策略,不要试图一步到位。