20 KiB
设计文档:ODS 去重与软删除机制标准化
概述
本设计对 ODS 层的 OdsTaskSpec 配置、content_hash 去重策略、软删除语义进行标准化改造。核心原则:ODS 是追加写入的版本化存储,每次内容变更(包括删除)都是一个新版本行。
改造分四个阶段:
- 配置精简(方案 1):删除无效/冗余字段,引入 SnapshotMode 枚举
- 去重优化(方案 2):默认开启 skip_unchanged,hash 改用 payload + is_delete
- 索引支持(方案 3):为"取最新版本"查询添加复合索引
- 软删除语义(方案 4):从 UPDATE 改为 INSERT 删除版本行
改造前后对比
配置层对比
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 去重开关 | enable_content_hash_dedup=False(22/23 任务关闭) |
skip_unchanged=True(默认开启) |
| 快照策略 | snapshot_full_table + snapshot_window_columns 两个字段组合 |
SnapshotMode 枚举(NONE/FULL_TABLE/WINDOW)+ snapshot_time_column |
| 冲突列 | conflict_columns_override(运行时不生效,仅声明性标注) |
删除,PK 唯一来源为 DDL |
| 冗余字段 | include_site_column/include_page_no/include_page_size(全部 False) |
删除,硬编码移除 |
content_hash 计算对比
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 输入 | 展平后的 merged_rec,排除 7 个元数据字段 | 原始 payload JSON + is_delete |
| 排除逻辑 | _sanitize_record_for_hash 递归排除 source_file/source_endpoint/fetched_at/record_index/content_hash/payload/data |
无需排除——payload 天然不含元数据字段 |
| is_delete 参与 | 不参与(is_delete 变化不改变 hash) | 参与(is_delete 变化产生新 hash → 新版本行) |
| 默认行为 | 22/23 任务不算 hash(每次抓取都插入新行) | 所有任务默认算 hash(内容不变则跳过) |
软删除对比
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 操作方式 | UPDATE ... SET is_delete=1(修改所有历史版本) |
INSERT 一条 is_delete=1 的新版本行 |
| 历史版本影响 | 所有历史版本的 is_delete 被改为 1 | 历史版本完全不变 |
| 幂等性 | 重复执行无副作用(UPDATE 幂等) | 重复执行无副作用(最新版本已是 is_delete=1 则跳过) |
| 下游取数 | WHERE is_delete = 0(但历史版本也被改了) |
DISTINCT ON (id) ORDER BY fetched_at DESC + WHERE is_delete = 0 |
新版本数据处理流程
正常写入流程(每次 ETL 运行)
1. API 抓取 → 获得一批记录
2. 对每条记录:
a. _normalize_is_delete_flag:标准化 is_delete 字段(API 可能返回 isDelete/is_deleted 等变体)
b. 取原始 record 作为 payload
c. _compute_content_hash(payload, is_delete) → 计算 hash
d. 若 skip_unchanged=True:
- 查询该业务 ID 在数据库中的最新 content_hash
- 若 hash 相同 → 跳过(内容未变,无需新版本)
- 若 hash 不同或无历史记录 → 继续插入
e. INSERT INTO ods.xxx (..., content_hash, payload, is_delete, fetched_at)
ON CONFLICT (id, content_hash) DO UPDATE ...
软删除流程(快照对比,路径 B)
前提:任务配置了 snapshot_mode != NONE,且 run.snapshot_missing_delete=True
1. 收集本次抓取到的所有业务 ID → fetched_keys
2. 查询快照范围内数据库中已有的业务 ID(is_delete != 1):
- FULL_TABLE 模式:全表范围
- WINDOW 模式:WHERE {snapshot_time_column} >= window_start AND < window_end
3. 差集 = 数据库中的 ID - fetched_keys → 缺失 ID
4. 对每个缺失 ID:
a. SELECT DISTINCT ON (id) * FROM ods.xxx WHERE id = ? ORDER BY fetched_at DESC
→ 读取最新版本行
b. 若最新版本已是 is_delete=1 → 跳过(幂等)
c. 否则:
- 复制最新版本行的所有字段
- 设 is_delete = 1
- _compute_content_hash(原payload, is_delete=1) → 新 hash
- INSERT 新版本行(hash 不同,不会与现有行冲突)
5. 历史版本行完全不变
下游取数规约
-- DWD 层从 ODS 取最新有效版本的标准查询
SELECT DISTINCT ON (id) *
FROM ods.{table_name}
WHERE is_delete IS DISTINCT FROM 1 -- 排除已删除
ORDER BY id, fetched_at DESC; -- 利用 (id, fetched_at DESC) 索引
-- 若需要包含删除状态(如审计场景)
SELECT DISTINCT ON (id) *
FROM ods.{table_name}
ORDER BY id, fetched_at DESC;
-- 然后在应用层判断 is_delete 字段
架构
改造集中在 ODS 写入管线的三个核心环节:
flowchart TD
A[上游 API / JSON 回放] --> B[BaseOdsTask.execute]
B --> C{记录处理}
C --> D[_normalize_is_delete_flag<br/>标准化 is_delete 字段]
D --> E[_compute_content_hash<br/>基于 payload + is_delete 算 hash]
E --> F{skip_unchanged?}
F -->|hash 相同| G[跳过]
F -->|hash 不同或新记录| H[INSERT 新版本行]
B --> I{快照对比}
I -->|snapshot_mode != NONE| J[_mark_missing_as_deleted]
J --> K[读取缺失 ID 的最新版本]
K --> L[构造 is_delete=1 的新版本]
L --> M{最新版本已是 is_delete=1?}
M -->|是| N[跳过]
M -->|否| O[INSERT 删除版本行]
影响范围:
apps/etl/pipelines/feiqiu/tasks/ods/ods_tasks.py— 主要改动文件db/etl_feiqiu/migrations/— 新增索引迁移脚本db/etl_feiqiu/schemas/ods.sql— DDL 注释更新(索引)- 7 个文档文件 — 同步更新
组件与接口
1. SnapshotMode 枚举
from enum import Enum
class SnapshotMode(Enum):
"""ODS 快照软删除策略。"""
NONE = "none" # 不做快照对比,不触发软删除
FULL_TABLE = "full_table" # 全表快照:对比全表所有记录
WINDOW = "window" # 窗口快照:仅对比时间窗口内的记录
定义在 ods_tasks.py 顶部,与 OdsTaskSpec 同文件。
2. OdsTaskSpec(改造后)
@dataclass(frozen=False)
class OdsTaskSpec:
code: str
class_name: str
table_name: str
endpoint: str
data_path: Tuple[str, ...] = ("data",)
list_key: str | None = None
pk_columns: Tuple[ColumnSpec, ...] = ()
extra_columns: Tuple[ColumnSpec, ...] = ()
# --- 保留字段(语义不变)---
include_source_file: bool = True
include_source_endpoint: bool = True
include_record_index: bool = False
include_fetched_at: bool = True
requires_window: bool = True
time_fields: Tuple[str, str] | None = ("startTime", "endTime")
include_site_id: bool = True
description: str = ""
extra_params: Dict[str, Any] = field(default_factory=dict)
# --- 改造字段 ---
skip_unchanged: bool = True # 原 enable_content_hash_dedup,默认翻转
snapshot_mode: SnapshotMode = SnapshotMode.NONE # 替代 snapshot_full_table + snapshot_window_columns
snapshot_time_column: str | None = None # WINDOW 模式的时间列
def __post_init__(self) -> None:
if self.snapshot_mode == SnapshotMode.WINDOW and not self.snapshot_time_column:
raise ValueError(
f"任务 {self.code}: snapshot_mode=WINDOW 时必须指定 snapshot_time_column"
)
if self.snapshot_mode != SnapshotMode.WINDOW and self.snapshot_time_column is not None:
raise ValueError(
f"任务 {self.code}: snapshot_mode={self.snapshot_mode.value} 时不应指定 snapshot_time_column"
)
删除的字段:
conflict_columns_override— 运行时不生效include_site_column— 全部 Falseinclude_page_no— 全部 Falseinclude_page_size— 全部 Falsesnapshot_full_table— 被 SnapshotMode 替代snapshot_window_columns— 被 SnapshotMode + snapshot_time_column 替代enable_content_hash_dedup— 被 skip_unchanged 替代
3. 23 个任务的 SnapshotMode 映射
当前配置到新配置的映射规则:
| 原配置 | 新配置 |
|---|---|
snapshot_full_table=True |
snapshot_mode=SnapshotMode.FULL_TABLE |
snapshot_window_columns=("col",) |
snapshot_mode=SnapshotMode.WINDOW, snapshot_time_column="col" |
| 两者都未设置 | snapshot_mode=SnapshotMode.NONE(默认值) |
具体任务映射:
| 任务 | 原配置 | 新 snapshot_mode | snapshot_time_column |
|---|---|---|---|
| ODS_ASSISTANT_ACCOUNT | snapshot_full_table=True | FULL_TABLE | None |
| ODS_MEMBER_CARD | snapshot_full_table=True | FULL_TABLE | None |
| ODS_GROUP_PACKAGE | snapshot_full_table=True | FULL_TABLE | None |
| ODS_STORE_GOODS | snapshot_full_table=True | FULL_TABLE | None |
| ODS_TENANT_GOODS | snapshot_full_table=True | FULL_TABLE | None |
| ODS_TABLE_USE | snapshot_window_columns=("create_time",) | WINDOW | "create_time" |
| ODS_ASSISTANT_LEDGER | snapshot_window_columns=("create_time",) | WINDOW | "create_time" |
| ODS_STORE_GOODS_SALES | snapshot_window_columns=("create_time",) | WINDOW | "create_time" |
| ODS_REFUND | snapshot_window_columns=("pay_time",) | WINDOW | "pay_time" |
| ODS_PLATFORM_COUPON | snapshot_window_columns=("consume_time",) | WINDOW | "consume_time" |
| ODS_MEMBER_BALANCE | snapshot_window_columns=("create_time",) | WINDOW | "create_time" |
| ODS_GROUP_BUY_REDEMPTION | snapshot_window_columns=("create_time",) | WINDOW | "create_time" |
| ODS_TABLE_FEE_DISCOUNT | snapshot_window_columns=("create_time",) | WINDOW | "create_time" |
| 其余 10 个任务 | 无快照配置 | NONE | None |
4. _compute_content_hash(改造后)
@classmethod
def _compute_content_hash(cls, record: dict, payload: Any, is_delete: int) -> str:
"""基于原始 payload 和 is_delete 计算 content_hash。
payload: 原始 API 返回的 JSON 对象(未展平)
is_delete: 0 或 1
"""
payload_str = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=cls._hash_default,
)
raw = f"{payload_str}|{is_delete}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
关键变更:
- 输入从"展平后的 merged_rec"改为"原始 payload + is_delete"
- 删除
_sanitize_record_for_hash方法(不再需要字段排除逻辑) - 删除
include_fetched_at参数(payload 天然不含 fetched_at) - 分隔符
|确保 payload 和 is_delete 不会产生歧义
一次性代价: 切换后首次运行,所有记录的 hash 都会变化(因为算法不同),会插入一批新版本行。这是预期行为,后续运行恢复正常去重。
5. _mark_missing_as_deleted(改造后)
def _mark_missing_as_deleted(self, *, table, business_pk_cols,
snapshot_mode, snapshot_time_column,
window_start, window_end,
key_values, allow_empty) -> int:
"""快照对比软删除:INSERT 删除版本行,而非 UPDATE 历史版本。"""
# 1. 查询快照范围内、is_delete != 1 的业务 ID
# 2. 排除本次抓取到的 key_values,得到缺失 ID 集合
# 3. 对每个缺失 ID:
# a. 读取最新版本行(DISTINCT ON ... ORDER BY fetched_at DESC)
# b. 若最新版本已是 is_delete=1,跳过
# c. 否则:复制该行,设 is_delete=1,重算 content_hash,INSERT
# 4. 返回插入的删除版本行数
接口变更:
window_columns参数改为snapshot_mode+snapshot_time_columnfull_table参数删除(由 snapshot_mode 表达)- 内部从 UPDATE 改为 SELECT + INSERT
6. _insert_records_schema_aware 的适配
compare_latest判断条件中self.SPEC.enable_content_hash_dedup改为self.SPEC.skip_unchanged_compute_content_hash调用签名变更:传入原始 record(作为 payload)和 is_delete 值- 删除对
include_site_column、include_page_no、include_page_size的引用
7. BaseOdsTask.execute 的适配
snapshot_full_table/snapshot_window_columns的读取改为spec.snapshot_mode/spec.snapshot_time_column_mark_missing_as_deleted调用参数适配- 删除对已移除字段的引用
数据模型
ODS 表结构(不变)
所有 23 个 ODS 表的 DDL 结构不变,PK 仍为 (业务id, content_hash)。
新增索引(迁移脚本)
每张含 fetched_at 列的 ODS 表新增复合索引:
-- 迁移脚本:db/etl_feiqiu/migrations/YYYY-MM-DD__add_ods_latest_version_indexes.sql
-- 为 DISTINCT ON (id) ORDER BY id, fetched_at DESC 查询模式提供索引支持
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ods_member_profiles_latest
ON ods.member_profiles (id, fetched_at DESC);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ods_member_balance_changes_latest
ON ods.member_balance_changes (id, fetched_at DESC);
-- ... 对每张含 fetched_at 的 ODS 表重复此模式
-- 索引命名规范:idx_ods_{table_name}_latest
-- 业务主键列名因表而异(大多数是 id,少数是 recharge_order_id、sitegoodsstockid 等)
注意:
include_fetched_at=False的任务(如 ODS_ASSISTANT_ACCOUNT)其表中 fetched_at 列有 DEFAULT now(),实际仍有值,也需要索引。但需确认 DDL 中是否所有表都有 fetched_at 列。- 索引定义需同步写入
db/etl_feiqiu/schemas/ods.sql(DDL 源文件),确保新环境初始化时自动创建索引。 - 迁移脚本
db/etl_feiqiu/migrations/YYYY-MM-DD__add_ods_latest_version_indexes.sql用于已有环境的增量部署。
下游查询规约
DWD 层从 ODS 取数的标准模式:
SELECT DISTINCT ON (id) *
FROM ods.{table_name}
WHERE is_delete = 0 -- 或 is_delete IS DISTINCT FROM 1
ORDER BY id, fetched_at DESC;
此查询利用新增的 (id, fetched_at DESC) 索引,避免全表扫描。
正确性属性
正确性属性是系统在所有合法执行路径上都应保持的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。
Property 1: SnapshotMode 与 snapshot_time_column 一致性
For any OdsTaskSpec 实例,当 snapshot_mode 为 WINDOW 时 snapshot_time_column 必须为非空字符串,当 snapshot_mode 为 FULL_TABLE 或 NONE 时 snapshot_time_column 必须为 None;违反此约束应抛出 ValueError。
Validates: Requirements 2.3, 2.4, 2.5, 2.6
Property 2: content_hash 确定性
For any 原始 payload(合法 JSON 对象)和 is_delete 值(0 或 1),对相同的 (payload, is_delete) 输入调用 _compute_content_hash 应始终产生相同的 SHA-256 哈希值。
Validates: Requirements 5.1, 5.4
Property 3: content_hash 区分性
For any 两组不同的 (payload, is_delete) 输入(payload 不同或 is_delete 不同),_compute_content_hash 应产生不同的哈希值。
Validates: Requirements 5.5
Property 4: skip_unchanged 跳过内容未变的记录
For any ODS 任务(skip_unchanged=True),当一条记录的 content_hash 与数据库中该业务 ID 最新版本的 content_hash 相同时,该记录应被计入 skipped 而非 inserted。
Validates: Requirements 4.3, 8.5
Property 5: 记录数闭合不变量
For any 非空记录列表被写入 ODS 时,fetched == inserted + updated + skipped 恒成立。
Validates: Requirements 8.3
Property 6: 软删除构造正确性
For any 快照对比中发现的缺失业务 ID,_mark_missing_as_deleted 应读取该 ID 的最新版本行,构造一条 is_delete=1 的新版本行,其 content_hash 基于原始 payload + is_delete=1 重算,并通过 INSERT(而非 UPDATE)写入。
Validates: Requirements 7.1, 7.2, 7.4
Property 7: 软删除幂等性
For any 业务 ID,若其最新版本已经是 is_delete=1,再次执行 _mark_missing_as_deleted 不应插入新的删除版本行。
Validates: Requirements 7.3, 8.7
Property 8: 软删除不修改历史版本
For any 软删除操作执行后,数据库中该业务 ID 的所有历史版本行(执行前已存在的行)的内容应保持不变——不应有 UPDATE 语句作用于 ODS 表。
Validates: Requirements 7.4, 8.6
错误处理
OdsTaskSpec 校验错误
SnapshotMode.WINDOW+snapshot_time_column=None→__post_init__抛出ValueErrorSnapshotMode.FULL_TABLE/NONE+snapshot_time_column不为 None →__post_init__抛出ValueError- 这些错误在任务注册时(模块加载时)即触发,属于 fail-fast 设计
hash 算法切换的一次性代价
- 首次运行后所有记录的 content_hash 都会变化,导致全量插入新版本行
- 这是预期行为,不是错误
- 日志中应记录 "hash 算法已变更,本次运行将插入全量新版本" 的提示信息
- 后续运行恢复正常去重
软删除的边界情况
- 缺失 ID 在数据库中无任何记录(从未抓取过)→ 跳过,不插入删除版本
- 缺失 ID 的最新版本已是 is_delete=1 → 跳过(幂等性)
- 快照范围内无任何记录且 allow_empty=False → 返回 0,不执行任何操作
迁移脚本错误
CREATE INDEX CONCURRENTLY不能在事务块内执行 → 迁移脚本需单独执行- 索引创建失败不影响数据写入,仅影响查询性能 → 可重试
测试策略
属性测试(hypothesis)
使用 pytest + hypothesis 库,每个属性测试至少运行 100 次迭代。
测试文件: apps/etl/pipelines/feiqiu/tests/unit/test_ods_dedup_properties.py
| 属性 | 测试方法 | 生成策略 |
|---|---|---|
| Property 1 | 生成随机 SnapshotMode + snapshot_time_column 组合,验证校验逻辑 | st.sampled_from(SnapshotMode) × st.one_of(st.none(), st.text(min_size=1)) |
| Property 2 | 生成随机 JSON payload + is_delete,验证两次调用结果相同 | st.dictionaries(st.text(), st.text()) × st.sampled_from([0, 1]) |
| Property 3 | 生成两组不同的 (payload, is_delete),验证 hash 不同 | 同上,加 assume(pair1 != pair2) |
| Property 4 | 用 PkAwareFakeDB 预设最新 hash,验证相同记录被跳过 | _ods_record_with_id 策略 |
| Property 5 | 生成随机记录列表,验证 fetched == inserted + updated + skipped | st.lists(_ods_record_with_id) |
| Property 6 | 用 FakeDB 模拟缺失 ID 场景,验证 INSERT 而非 UPDATE | st.lists(st.integers()) |
| Property 7 | 预设最新版本 is_delete=1,验证不产生新行 | 同上 |
| Property 8 | 执行软删除后检查 FakeDB 中无 UPDATE 语句 | 同上 |
每个测试用注释标注:# Feature: ods-dedup-standardize, Property N: {title}
单元测试
测试文件: 适配现有 test_ods_tasks.py 和 test_debug_ods_properties.py
- 适配 OdsTaskSpec 构造函数变更(删除旧字段,使用新字段)
- 适配
_compute_content_hash签名变更 - 适配
_mark_missing_as_deleted参数变更 - 验证 SnapshotMode 枚举的边界情况(edge cases from prework 2.5, 2.6)
现有测试适配
现有测试中需要适配的关键点:
test_debug_ods_properties.py中的 Property 4(content_hash 确定性)需要适配新的_compute_content_hash签名test_debug_ods_properties.py中的 Property 5(快照删除标记)需要适配新的 INSERT 语义(检查 INSERT 而非 UPDATE)test_ods_tasks.py中的所有任务测试需要确保在新的 OdsTaskSpec 下仍能正常运行
测试执行命令
# ETL 单元测试(包含属性测试)
cd apps/etl/pipelines/feiqiu && pytest tests/unit -v
# 仅运行本次改造的属性测试
cd apps/etl/pipelines/feiqiu && pytest tests/unit/test_ods_dedup_properties.py -v