Files
Neo-ZQYY/apps/etl/pipelines/feiqiu/docs/etl_tasks/dwd_tasks.md

22 KiB
Raw Blame History

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 表名为 keyODS 表名为 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() 方法:

  1. 从 ODS 取最新快照(同 SCD2 的 DISTINCT ON 逻辑)
  2. 按主键去重,跳过主键为 NULL 的行
  3. 使用 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. 列映射与类型转换

列映射来源(按优先级):

  1. FACT_MAPPINGS 中的显式映射:(dwd_列名, ods_源表达式, 可选类型转换)
  2. DWD 与 ODS 同名列直接映射
  3. 主键兜底:若 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=Truefact_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_atNULL 的 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)。 若表名不含 .,则使用默认 schemaDWD 端默认 billiards_dwdODS 端默认 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 对表执行全表 COUNTSUM,在数据量较大时可能耗时较长
  • 不区分增量:行数和金额对比基于全表统计,不受时间窗口限制
  • SCD2 影响:维度表因 SCD2 历史版本的存在DWD 行数通常大于 ODS 行数,这是预期行为
  • 列名匹配大小写:金额列扫描时将列名统一转为小写后匹配关键词
  • 报表覆盖:每次运行会覆盖上一次的报表文件(reports/dwd_quality_report.json