22 KiB
DWD 层任务详解
本文档说明飞球 ETL 系统中 DWD(明细数据层)的所有任务。 DWD 层负责从 ODS 读取原始数据,经清洗、类型转换和列映射后写入维度表(dim_)和事实表(dwd_ / fact_*), 维度表采用 SCD2 或 Type1 Upsert 策略,事实表按时间增量装载。
概述
DWD 层共有 2 个已注册任务:
| 任务代码 | Python 类 | 注册参数 | 说明 |
|---|---|---|---|
DWD_LOAD_FROM_ODS |
DwdLoadTask |
layer="DWD" |
核心装载任务:遍历 TABLE_MAP,维度走 SCD2/Type1,事实走增量 |
DWD_QUALITY_CHECK |
DwdQualityTask |
layer="DWD", task_type="verification" |
ODS 与 DWD 行数/金额核对 |
注册位置:
orchestration/task_registry.py历史说明:早期版本曾有 3 个独立 DWD 任务(TICKET_DWD、PAYMENTS_DWD、MEMBERS_DWD), 通过专用 Loader 写入不存在的
billiards.*schema。这些任务已于 2026-02-14 废弃删除, 其功能由DWD_LOAD_FROM_ODS的 TABLE_MAP 映射完全覆盖。
DWD_LOAD_FROM_ODS — 核心装载任务
| 属性 | 值 |
|---|---|
| 任务代码 | DWD_LOAD_FROM_ODS |
| Python 类 | tasks.dwd.dwd_load_task.DwdLoadTask |
| 继承 | BaseTask |
| 数据来源 | billiards_ods.*(ODS 层各表) |
| 数据目标 | billiards_dwd.*(维度表 + 事实表,共 40+ 对映射) |
| 事务模式 | 每张表一次独立事务,单表失败回滚后继续后续表 |
| 配置项 | dwd.only_tables(可选,限定只处理指定表)、dwd.fact_upsert(默认 True) |
执行流程
extract(context) → 返回 {"now": datetime.now()}
↓
load(extracted, context) → 遍历 TABLE_MAP
↓
对每张 DWD 表:
├─ 获取 DWD 列信息 + ODS 列信息
├─ 判断表名前缀:
│ ├─ dim_* → _merge_dim()(维度合并)
│ └─ 其他 → _merge_fact_increment()(事实增量)
├─ commit(成功)或 rollback(失败)
└─ 记录 summary / errors
TABLE_MAP 映射表
TABLE_MAP 定义了 DWD 表到 ODS 表的完整映射关系。每对映射中,DWD 表名为 key,ODS 表名为 value。
主表与扩展表(_ex)共享同一 ODS 源表,通过 FACT_MAPPINGS 中的列映射区分写入哪些字段。
维度表映射
| DWD 表 | ODS 源表 | 说明 |
|---|---|---|
billiards_dwd.dim_site |
billiards_ods.table_fee_transactions |
门店维度(从台费流水的 siteprofile 快照提取) |
billiards_dwd.dim_site_ex |
billiards_ods.table_fee_transactions |
门店扩展(灯控、WiFi、客服等) |
billiards_dwd.dim_table |
billiards_ods.site_tables_master |
台桌维度 |
billiards_dwd.dim_table_ex |
billiards_ods.site_tables_master |
台桌扩展(台布使用时间等) |
billiards_dwd.dim_assistant |
billiards_ods.assistant_accounts_master |
助教维度 |
billiards_dwd.dim_assistant_ex |
billiards_ods.assistant_accounts_master |
助教扩展(简介、分组、灯控设备等) |
billiards_dwd.dim_member |
billiards_ods.member_profiles |
会员维度 |
billiards_dwd.dim_member_ex |
billiards_ods.member_profiles |
会员扩展(注册来源、组织等) |
billiards_dwd.dim_member_card_account |
billiards_ods.member_stored_value_cards |
会员储值卡维度 |
billiards_dwd.dim_member_card_account_ex |
billiards_ods.member_stored_value_cards |
储值卡扩展(电费抵扣、冻结余额等) |
billiards_dwd.dim_tenant_goods |
billiards_ods.tenant_goods_master |
租户商品维度 |
billiards_dwd.dim_tenant_goods_ex |
billiards_ods.tenant_goods_master |
租户商品扩展(条码、备注等) |
billiards_dwd.dim_store_goods |
billiards_ods.store_goods_master |
门店商品维度 |
billiards_dwd.dim_store_goods_ex |
billiards_ods.store_goods_master |
门店商品扩展(库存、安全库存等) |
billiards_dwd.dim_goods_category |
billiards_ods.stock_goods_category_tree |
商品分类维度(含子类展开) |
billiards_dwd.dim_groupbuy_package |
billiards_ods.group_buy_packages |
团购套餐维度 |
billiards_dwd.dim_groupbuy_package_ex |
billiards_ods.group_buy_packages |
团购套餐扩展 |
事实表映射
| DWD 表 | ODS 源表 | 说明 |
|---|---|---|
billiards_dwd.dwd_settlement_head |
billiards_ods.settlement_records |
结算头(订单结算主记录) |
billiards_dwd.dwd_settlement_head_ex |
billiards_ods.settlement_records |
结算头扩展(支付方式、撤单、促销等) |
billiards_dwd.dwd_table_fee_log |
billiards_ods.table_fee_transactions |
台费流水 |
billiards_dwd.dwd_table_fee_log_ex |
billiards_ods.table_fee_transactions |
台费流水扩展(销售员、消费类型等) |
billiards_dwd.dwd_table_fee_adjust |
billiards_ods.table_fee_discount_records |
台费调整/折扣 |
billiards_dwd.dwd_table_fee_adjust_ex |
billiards_ods.table_fee_discount_records |
台费调整扩展 |
billiards_dwd.dwd_store_goods_sale |
billiards_ods.store_goods_sales_records |
商品销售记录 |
billiards_dwd.dwd_store_goods_sale_ex |
billiards_ods.store_goods_sales_records |
商品销售扩展 |
billiards_dwd.dwd_assistant_service_log |
billiards_ods.assistant_service_records |
助教服务记录 |
billiards_dwd.dwd_assistant_service_log_ex |
billiards_ods.assistant_service_records |
助教服务扩展 |
billiards_dwd.dwd_assistant_trash_event |
billiards_ods.assistant_cancellation_records |
助教取消/废单事件 |
billiards_dwd.dwd_assistant_trash_event_ex |
billiards_ods.assistant_cancellation_records |
助教取消扩展 |
billiards_dwd.dwd_member_balance_change |
billiards_ods.member_balance_changes |
会员余额变动 |
billiards_dwd.dwd_member_balance_change_ex |
billiards_ods.member_balance_changes |
会员余额变动扩展 |
billiards_dwd.dwd_groupbuy_redemption |
billiards_ods.group_buy_redemption_records |
团购核销记录 |
billiards_dwd.dwd_groupbuy_redemption_ex |
billiards_ods.group_buy_redemption_records |
团购核销扩展 |
billiards_dwd.dwd_platform_coupon_redemption |
billiards_ods.platform_coupon_redemption_records |
平台优惠券核销 |
billiards_dwd.dwd_platform_coupon_redemption_ex |
billiards_ods.platform_coupon_redemption_records |
平台优惠券核销扩展 |
billiards_dwd.dwd_recharge_order |
billiards_ods.recharge_settlements |
充值订单 |
billiards_dwd.dwd_recharge_order_ex |
billiards_ods.recharge_settlements |
充值订单扩展 |
billiards_dwd.dwd_payment |
billiards_ods.payment_transactions |
支付记录 |
billiards_dwd.dwd_refund |
billiards_ods.refund_transactions |
退款记录 |
billiards_dwd.dwd_refund_ex |
billiards_ods.refund_transactions |
退款扩展 |
共计 17 对维度映射(含
_ex)+ 23 对事实映射(含_ex)= 40 对映射。
维度/事实分流逻辑
load() 方法遍历 TABLE_MAP 时,根据 DWD 表名前缀自动分流:
if self._table_base(dwd_table).startswith("dim_"):
# 维度表 → _merge_dim()
else:
# 事实表 → _merge_fact_increment()
_merge_dim() 内部进一步判断维度合并策略:
| 条件 | 策略 | 方法 |
|---|---|---|
DWD 表列中包含 SCD2 列(scd2_start_time / scd2_end_time / scd2_is_current / scd2_version) |
SCD2 合并:关闭旧版 + 插入新版 | _merge_dim_scd2() |
| DWD 表列中不包含 SCD2 列 | Type1 Upsert:主键冲突则更新 | _merge_dim_type1_upsert() |
SCD2 列集合定义:
SCD_COLS = {"scd2_start_time", "scd2_end_time", "scd2_is_current", "scd2_version"}
SCD2 处理流程
当维度表包含 SCD2 列时,执行 _merge_dim_scd2() 方法,完整流程如下:
1. 最新快照选取
从 ODS 源表中按业务主键取最新快照,使用 DISTINCT ON + fetched_at DESC 去重:
SELECT DISTINCT ON (业务主键表达式)
<列映射表达式>
FROM billiards_ods.<ods_table>
WHERE "fetched_at" IS NOT NULL
ORDER BY 业务主键表达式, "fetched_at" DESC NULLS LAST
- 业务主键:从 DWD 表主键中剔除 SCD2 列后得到(
_strip_scd2_keys()) - 列映射:优先使用
FACT_MAPPINGS中的显式映射(支持 JSON 路径提取如siteprofile->>'shop_name'、类型转换如::numeric),其次按同名列直接映射 - 特殊处理:
dim_goods_category表会额外读取categoryboxes列并展开子分类行(_expand_goods_category_rows())
2. 变更检测
将 ODS 最新快照与 DWD 当前版本(scd2_is_current=1)逐列对比:
# 预加载 DWD 当前版本(避免逐行 SELECT)
SELECT * FROM billiards_dwd.<dwd_table> WHERE COALESCE(scd2_is_current, 1) = 1
# 逐行对比(跳过 SCD2 列本身)
for col in dwd_cols:
if col in SCD_COLS: continue
if not _values_equal(current[col], incoming[col]):
return True # 有变更
_values_equal() 在对比前会做类型归一化:
- 空值归一化:空字符串
""和None视为等价 - 日期时间归一化:朴素时间(naive)与时区感知时间(aware)统一比较
- 布尔值归一化:
"true"/"false"/"1"/"0"/"yes"/"no"等字符串与布尔值统一比较 - 数值归一化:字符串形式的数字(如
"3.14")与Decimal/float统一比较
3. 版本关闭与新建
对于检测到变更的记录,分两步批量操作:
步骤 A — 批量关闭旧版本(_close_current_dim_bulk()):
-- 单主键优化:使用 ANY 数组
UPDATE billiards_dwd.<table>
SET scd2_end_time = <now>, scd2_is_current = 0
WHERE COALESCE(scd2_is_current, 1) = 1 AND "<pk>" = ANY(<ids>)
-- 复合主键:逐条 execute_batch
UPDATE billiards_dwd.<table>
SET scd2_end_time = <now>, scd2_is_current = 0
WHERE COALESCE(scd2_is_current, 1) = 1 AND "<pk1>" = %s AND "<pk2>" = %s
步骤 B — 批量插入新版本(_insert_dim_rows_bulk()):
INSERT INTO billiards_dwd.<table> (<所有列>) VALUES %s
新版本行的 SCD2 列填充规则:
| SCD2 列 | 值 |
|---|---|
scd2_start_time |
当前时间(now) |
scd2_end_time |
9999-12-31 00:00:00(表示"当前有效") |
scd2_is_current |
1 |
scd2_version |
旧版本号 + 1(新记录为 1) |
4. 返回统计
{
"processed": <ODS 去重后总行数>,
"inserted": <新增记录数(首次出现的业务主键)>,
"updated": <变更记录数(关闭旧版+插入新版)>,
"skipped": <无变更跳过数>
}
Type1 Upsert 处理流程
当维度表不包含 SCD2 列时,执行 _merge_dim_type1_upsert() 方法:
- 从 ODS 取最新快照(同 SCD2 的
DISTINCT ON逻辑) - 按主键去重,跳过主键为
NULL的行 - 使用 PostgreSQL
INSERT ... ON CONFLICT DO UPDATE一次性写入:
INSERT INTO billiards_dwd.<table> (<列>) VALUES %s
ON CONFLICT (<主键>) DO UPDATE SET <非主键列> = EXCLUDED.<列>
WHERE <任一非主键列 IS DISTINCT FROM EXCLUDED 值> -- 仅在有实际变更时才更新
RETURNING (xmax = 0) AS inserted -- 区分新增 vs 更新
Type1 Upsert 不保留历史版本,直接覆盖旧值。SCD2 列(如果存在于列定义中)会被填充默认值(
scd2_start_time=now,scd2_version=1)但不参与冲突判断。
事实表增量装载
非 dim_* 前缀的表走 _merge_fact_increment() 方法,核心逻辑如下:
1. 水位线(Watermark)机制
事实表统一使用 fetched_at 列作为增量过滤依据,水位线获取优先级:
| 优先级 | 条件 | 行为 |
|---|---|---|
| 1 | 配置了手动时间窗口(run.window_override.start/end) |
WHERE fetched_at >= <start> AND fetched_at < <end> |
| 2 | 无手动窗口 | 自动计算水位线 _get_fact_watermark(),WHERE fetched_at > <watermark> |
_get_fact_watermark() 的计算逻辑:
- 若 DWD 表包含
fetched_at列 →SELECT MAX(fetched_at) FROM <dwd_table> - 若 DWD 表不含
fetched_at但有主键 → 通过 ODS JOIN DWD 取MAX(ods.fetched_at) - 兜底 →
"1970-01-01"(全量装载)
2. 列映射与类型转换
列映射来源(按优先级):
FACT_MAPPINGS中的显式映射:(dwd_列名, ods_源表达式, 可选类型转换)- DWD 与 ODS 同名列直接映射
- 主键兜底:若 DWD 主键在 ODS 中不存在但 ODS 有
id列,自动映射pk → id
类型转换(_build_fact_select_exprs()):
- 当 DWD 列为数值类型(
integer/numeric/bigint等)而 ODS 列为文本类型时,自动添加CAST(NULLIF(CAST("<col>" AS text), '') AS <type>)
3. 快照去重
若 ODS 表包含 content_hash 列(snapshot_mode=True),则使用 DISTINCT ON 按主键 + fetched_at DESC 取最新快照,避免重复记录。
4. 写入策略
根据配置 dwd.fact_upsert(默认 True)和 snapshot_mode 决定冲突处理:
| 条件 | SQL 策略 |
|---|---|
snapshot_mode=True 或 fact_upsert=True |
ON CONFLICT (<pk>) DO UPDATE SET ... WHERE <任一列 IS DISTINCT FROM> |
fact_upsert=False 且无 content_hash |
ON CONFLICT (<pk>) DO NOTHING |
写入后通过 RETURNING (xmax = 0) AS inserted 区分新增(xmax=0)和更新(xmax≠0)。
5. 缺失主键回补
对于可能出现"回补旧记录"的事实表(如 dwd_assistant_service_log,定义在 FACT_MISSING_FILL_TABLES 中),
在主增量写入完成后,额外执行 _insert_missing_by_pk():
INSERT INTO <dwd_table> (<列>)
SELECT <列> FROM <ods_table> o
LEFT JOIN <dwd_table> d ON d.<pk> = o.<pk>
WHERE d.<pk> IS NULL
AND o.fetched_at > <watermark> -- 同样受水位线约束
ON CONFLICT (<pk>) DO NOTHING
此步骤确保因时序乱序导致的遗漏记录能被补齐。
6. FACT_MAPPINGS 列映射详解
FACT_MAPPINGS 是一个字典,key 为 DWD 表全名,value 为三元组列表 (dwd_列名, ods_源表达式, 类型转换)。
映射类型示例:
| 映射类型 | 示例 | 说明 |
|---|---|---|
| 简单重命名 | ("table_id", "id", None) |
ODS id → DWD table_id |
| JSON 路径提取 | ("shop_name", "siteprofile->>'shop_name'", None) |
从 JSONB 字段提取 |
| 类型转换 | ("longitude", "siteprofile->>'longitude'", "numeric") |
提取后转 numeric |
| 布尔转换 | ("is_first_limit", "is_first_limit", "boolean") |
转布尔类型 |
| 日期转换 | ("pay_date", "pay_time", "date") |
时间戳截断为日期 |
| SQL 表达式 | ("category_level", "CASE WHEN pid = 0 THEN 1 ELSE 2 END", None) |
计算列 |
维度表和事实表共用
FACT_MAPPINGS(名称虽含 "FACT" 但实际覆盖所有表)。
配置与环境变量
| 配置项 | 来源 | 说明 |
|---|---|---|
dwd.only_tables |
AppConfig / 环境变量 DWD_ONLY_TABLES |
逗号分隔的表名列表,限定只处理指定表(支持全名或短名) |
dwd.fact_upsert |
AppConfig | 事实表是否使用 upsert(默认 True),设为 False 则用 DO NOTHING |
run.window_override.start / end |
AppConfig | 手动指定时间窗口,覆盖自动水位线 |
错误处理
- 每张表独立事务:成功则
commit,失败则rollback并记录错误后继续下一张表 - 主键缺失的行会被跳过并记录警告日志
- ODS 表缺少
fetched_at列时跳过该表并记录错误 fetched_at为NULL的 ODS 行会被过滤(WHERE fetched_at IS NOT NULL)- 最终返回
{"tables": [<各表统计>], "errors": [<失败表及原因>]}
DWD_QUALITY_CHECK — 数据质量检查
| 属性 | 值 |
|---|---|
| 任务代码 | DWD_QUALITY_CHECK |
| Python 类 | tasks.dwd.dwd_quality_task.DwdQualityTask |
| 继承 | BaseTask |
| 任务类型 | verification(校验类任务) |
| 数据来源 | DwdLoadTask.TABLE_MAP 中定义的所有 ODS / DWD 表对(40 对) |
| 输出文件 | reports/dwd_quality_report.json |
用途
在 DWD_LOAD_FROM_ODS 装载完成后,对 ODS 与 DWD 两端进行行数和金额的交叉核对,
自动生成 JSON 格式的质检报表。用于发现装载过程中可能出现的数据丢失或金额偏差。
执行流程
extract(context) → 返回 {"now": datetime.now()}
↓
load(extracted, context)
↓
遍历 DwdLoadTask.TABLE_MAP 中的每对 (dwd_table, ods_table):
├─ _compare_counts() → 行数核对
├─ _compare_amounts() → 金额核对
└─ 结果追加到 report["tables"]
↓
写入 reports/dwd_quality_report.json
注意:本任务不执行
transform()阶段,直接在load()中完成查询与报表输出。
行数核对逻辑
_compare_counts(cur, dwd_table, ods_table) 分别对 DWD 表和 ODS 表执行 COUNT(1),
返回两端行数及差值:
-- DWD 端
SELECT COUNT(1) AS cnt FROM "billiards_dwd"."<dwd_table>"
-- ODS 端
SELECT COUNT(1) AS cnt FROM "billiards_ods"."<ods_table>"
返回结构:
{
"dwd": 12345,
"ods": 12350,
"diff": -5
}
diff = dwd - ods:正值表示 DWD 多于 ODS(可能因 SCD2 产生多版本),负值表示 DWD 少于 ODS(可能有数据丢失)- 维度表因 SCD2 历史版本的存在,DWD 行数通常 ≥ ODS 行数,
diff > 0属于正常现象 - 事实表理论上 DWD 行数应 ≤ ODS 行数(去重后),
diff > 0需要关注
表名解析:_split_table_name() 将全限定名(如 billiards_dwd.dim_member)拆分为 (schema, table)。
若表名不含 .,则使用默认 schema(DWD 端默认 billiards_dwd,ODS 端默认 billiards_ods)。
金额列自动扫描规则
_compare_amounts(cur, dwd_table, ods_table) 自动识别两端表中的金额相关列,
对公共列逐列汇总对比。
扫描机制
通过 _get_numeric_amount_columns(cur, schema, table) 从 information_schema.columns 查询:
SELECT column_name
FROM information_schema.columns
WHERE table_schema = %s
AND table_name = %s
AND data_type IN ('numeric', 'double precision', 'integer', 'bigint', 'smallint', 'real', 'decimal')
在返回的数值型列中,进一步按列名关键词过滤,只保留列名中包含以下任一关键词的列:
| 关键词 | 匹配示例 |
|---|---|
amount |
pay_amount, discount_amount, actual_amount |
money |
consume_money, member_money |
fee |
table_fee, service_fee, fee_amount |
balance |
card_balance, gift_balance |
关键词定义:
AMOUNT_KEYWORDS = ("amount", "money", "fee", "balance")匹配规则:列名(小写)包含关键词即命中,如pay_amount包含amount。
公共列取交集
分别获取 DWD 端和 ODS 端的金额列后,取交集(set(dwd_cols) & set(ods_cols))并排序,
只对两端都存在的同名金额列进行汇总对比。
汇总对比
对每个公共金额列执行 SUM():
-- DWD 端
SELECT COALESCE(SUM("<col>"), 0) AS val FROM "billiards_dwd"."<dwd_table>"
-- ODS 端
SELECT COALESCE(SUM("<col>"), 0) AS val FROM "billiards_ods"."<ods_table>"
返回结构(每个金额列一条记录):
{
"column": "pay_amount",
"dwd_sum": 98765.50,
"ods_sum": 98770.00,
"diff": -4.50
}
diff = dwd_sum - ods_sum:非零值表示两端金额不一致,需排查原因COALESCE(..., 0)确保NULL值不影响汇总结果- 若某对表没有公共金额列,
amounts数组为空
JSON 报表输出格式
报表写入路径:reports/dwd_quality_report.json(目录不存在时自动创建)。
完整结构
{
"generated_at": "2025-01-15T14:30:00.123456",
"tables": [
{
"dwd_table": "billiards_dwd.dim_member",
"ods_table": "billiards_ods.member_profiles",
"count": {
"dwd": 1200,
"ods": 1000,
"diff": 200
},
"amounts": [
{
"column": "balance",
"dwd_sum": 50000.00,
"ods_sum": 50000.00,
"diff": 0.0
}
]
},
{
"dwd_table": "billiards_dwd.dwd_payment",
"ods_table": "billiards_ods.payment_transactions",
"count": {
"dwd": 5000,
"ods": 5000,
"diff": 0
},
"amounts": [
{
"column": "pay_amount",
"dwd_sum": 123456.78,
"ods_sum": 123456.78,
"diff": 0.0
},
{
"column": "fee_amount",
"dwd_sum": 1234.56,
"ods_sum": 1234.56,
"diff": 0.0
}
]
}
],
"note": "行数/金额核对,金额字段基于列名包含 amount/money/fee/balance 的数值列自动扫描。"
}
字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
generated_at |
string |
报表生成时间(ISO 8601 格式) |
tables |
array |
每对 DWD↔ODS 表的核对结果 |
tables[].dwd_table |
string |
DWD 表全限定名 |
tables[].ods_table |
string |
ODS 表全限定名 |
tables[].count |
object |
行数核对结果 |
tables[].count.dwd |
integer |
DWD 端行数 |
tables[].count.ods |
integer |
ODS 端行数 |
tables[].count.diff |
integer |
行数差值(dwd - ods) |
tables[].amounts |
array |
金额列核对结果(可能为空数组) |
tables[].amounts[].column |
string |
金额列名 |
tables[].amounts[].dwd_sum |
float |
DWD 端汇总值 |
tables[].amounts[].ods_sum |
float |
ODS 端汇总值 |
tables[].amounts[].diff |
float |
金额差值(dwd_sum - ods_sum) |
note |
string |
报表说明文字 |
注意事项
- 全量扫描:本任务对 TABLE_MAP 中所有 40 对表执行全表
COUNT和SUM,在数据量较大时可能耗时较长 - 不区分增量:行数和金额对比基于全表统计,不受时间窗口限制
- SCD2 影响:维度表因 SCD2 历史版本的存在,DWD 行数通常大于 ODS 行数,这是预期行为
- 列名匹配大小写:金额列扫描时将列名统一转为小写后匹配关键词
- 报表覆盖:每次运行会覆盖上一次的报表文件(
reports/dwd_quality_report.json)