# 架构决策 > 重构完成后沉淀的关键架构决策(ADR),给自己看的备忘。 ## 说明 - 本文档是**重构完成态**的沉淀:`90-重构路线.md` / `91-重构备忘.md` / `92-重构进度.md` 三份过程文档会在重构收尾时删除,关键决策、权衡、反悔条件压到这里留档。 - 当前仍在重构中,本文档先留骨架,内容待重构收尾时填入。 - 单条 ADR 格式按业界主流(Michael Nygard 版精简):**背景 / 决策 / 后果 / 候选方案 / 反悔条件** 五段;不套公司标准模板的多余字段。 ## 模板 > 新增一条决策时,复制以下模板到 §2 列表内。编号连续,不复用已弃用编号。 ```markdown ### ADR-NN 决策标题 - **状态**:已采纳 / 被 ADR-MM 取代 / 已弃用 - **背景**:当时面对的问题、约束、触发决策的场景。 - **决策**:最终选了什么方案,一句话。 - **后果**:带来的好处、新增的代价、影响到的模块。 - **候选方案**:考察过但否决的方案,以及否决理由。 - **反悔条件**:什么条件下会重新评估或反悔。 ``` ## 决策清单 ### ADR-01 DataX 入口默认单日,显式 `-backfill` 开日循环用于存量回填 - **状态**:草案 老 `spark-sql-starter` 的 `get_date_range` 支持 `20260401-20260410` 范围格式自动展开;DataX 入口从未用过。本项目调度用 DolphinScheduler,DS 原生支持**业务日期补数**(时间区间选定后,按调度周期逐日实例化 task)。用户老 DS 配置即 `-start-date=${dt} -stop-date=${cdt}` 单日传参。2026-04-24 出现场景:接入新源表需一次性回填 N 年历史存量数据——这类一次性手工批量 DS 的日历补数组件实例化太慢,走外部工具更合适。 - **决策**:DataX 入口**默认单日语义**(`start_date` / `stop_date` 对应一个 dt 分区),T+1 增量补数仍归 DS;**hive-import 入口 2026-04-24 补 `-backfill` flag**,显式开启后 `-start-date` / `-stop-date` 作外层回填范围,按日循环调用单日逻辑,用于存量回填。 - **后果**: - 正面:DataX 层默认职责单一(单日),T+1 增量 / 补数 / 回溯在 DS 层统一可视化;一次性存量回填通过 `-backfill` 内置支持,不再需要外部 bash 循环 / 临时 dispatcher - 负面:参数语义轻度重载(`-start-date` / `-stop-date` 在 `-backfill` 模式下含义不同);DS 任务模板规范上不得加 `-backfill`(团队约定) - **候选方案**: - DataX 入口层实现"日期范围自动展开 + 多 json 分发多 worker"作为默认行为——否决(重复 DS 职责 + 增入口常态复杂度) - 独立 `bin/datax-backfill.py` 工具文件——否决(和 hive-import 共享 100% 参数,独立文件冗余;单 flag 切换更简洁) - **反悔条件**:出现必须在 DataX 层做日期展开的默认场景(如 DS 彻底换掉);或 `-backfill` 被误用频繁到需物理隔离 ### ADR-02 分布式分发归 DolphinScheduler worker group,DataX 不重复随机 - **状态**:草案 DataX 老入口 `single-job-starter.sh` 内置 `-random` + `workers.ini` 权重加权随机选 worker + ssh 分发。DS 自身亦有 **worker group** 机制(group 绑定机器列表、task 落到 group 内一台 worker)。两层叠加:DS 选 node01 → node01 上 DataX 再 random 到 node03。【查证 kb/91 §4.4】:用户老 DS 配置不传 `-random`,说明 DS 层已完成分发,DataX 只在本机跑——两层分发在实际运营中就没被"同时启用"过 - **决策**:DataX 入口不做 worker 分发;分布式执行在 task 粒度靠 DS worker group 承担。DataX 入口的 `select_worker` 等同"返回 `current_host`",ssh 分支可删,`workers.ini` 可移除 - **后果**: - 正面:消除两层独立随机叠加打乱 DS 负载均衡;DataX 链路大幅简化(`worker.py` / ssh 远端执行 / `workers.ini` 可裁);维护成本下降 - 负面:单 DS 任务节点内部的批量(多 ini + `-parallel`)场景无法在 DataX 层散到多 worker,要分布式必须在 DS 层拆成多 task - **候选方案**:保留 DataX 两层分发——否决,"两层独立随机"破坏 DS worker group 语义 - **反悔条件**:DS 换成无 worker group 支持的调度器;或单 task 内批量规模大到 DS 拆分成本过高 ### ADR-03 零点漂移决策 - **状态**:草案 T+1 按 `update_time` 过滤单日窗口 `[day-start, day-stop)` 同步时,源库在同步执行期间持续写入——跨零点的记录 `update_time` 会从 N 号"漂到" N+1 号,单日窗口**无法捕获漂移记录**。 漂移概率和漂移数据量的相关变量: - **漂移窗口 = 执行时刻距零点的小时数**。越晚跑漂移窗口越大 - 漂移窗口越大,单条记录的漂移概率越高 - 漂移窗口内用户越活跃(业务写入高峰落在漂移窗口内),漂移数据量越多 **业界一般做法**:小数据量、用户低活跃度的场景下,通常凌晨 0:30 前后跑 T+1,漂移窗口 30 分钟、活跃度低,单日固定窗口 `[day-start, day-stop)` 即可,**忽略极少量漂移数据**是可接受的工程权衡。 **本项目特殊性**:业务高峰在凌晨 6 点前,同步定时必须避开高峰定在 6 点后,漂移窗口 6 小时;且若干用户行为集中在 0-6 点,漂移窗口和活跃度两个放大因子都踩中,漂移不能忽略——需要单独设计。 极端场景佐证:某用户习惯 0-6 点更新自己的数据,若走业界做法的单日固定窗口,数仓永远只能看到该用户最早的 `create_time` 版本,最新状态永远抓不到。 - **决策**: - raw 层 where 右界扩展为次日同期:`where update_time >= '{day-start} 00:00' AND update_time < '{day+1-stop} 00:00'`,在关闭侧加 1 天 buffer 覆盖漂移 - buffer 取 1 天而非 N 小时:**定时可变更**(同步执行时刻从 6 点调到 3 点或 8 点都不影响窗口语义)+ **可维护**(固定区间可复跑、可回刷) - raw 层不纠正分区漂移:所有抓到的记录(含"漂到次日"的)按 ini `dt = start_date`(业务日)统一落当日分区,分区内允许含次日漂移数据 - ods 层 Spark SQL 用**动态分区** `PARTITION (dt)`,按每行 `update_time` 真实日期归位 - ods 写入模式:`INSERT OVERWRITE` 覆盖式 + **双源 union `raw dt=T-1 + raw dt=T-2`**,按 `WHERE DATE(update_time) = T-1` 过滤,dedupe by `(pk, max(update_time))`。覆盖式写入支持重跑;双源 union 完整捕获 update_time ∈ [T-1, T) 范围的所有版本(含跨 raw 任务时段被业务覆盖更新跨日的早期版本) - **为什么双源 union 必要**(具体例子): **设定**:业务日 04-27,跑批日 04-28,raw 任务凌晨 03:00 跑批,业务库覆盖式更新会推 `update_time` 字段。 **订单 X 的 update 时间线**(业务库视角): - 04-27 02:00:update,`update_time = 04-27 02:00`(状态 A) - 04-28 02:00:再 update,`update_time = 04-28 02:00`(状态 B,跨日) **raw 抓取过程**: - raw dt=04-26 任务(04-27 03:00 跑批,窗口 [04-26, 04-28)):业务库 X 是状态 A,抓到 `update_time = 04-27 02:00` → 落 raw dt=04-26 / update_dt=04-27 - raw dt=04-27 任务(04-28 03:00 跑批,窗口 [04-27, 04-29)):业务库 X **已变成状态 B**(04-27 02:00 那条 update_time 字段被覆盖到 04-28 02:00),抓到 `update_time = 04-28 02:00` → 落 raw dt=04-27 / update_dt=04-28 **ods 04-28 跑批写 dt=04-27 分区**(`WHERE DATE(update_time)='04-27'`): - **单源 raw dt=04-27**:X `update_time = 04-28 02:00`,DATE=04-28,不入 → ods dt=04-27 **漏 X 的 04-27 状态** ❌ - **双源 union raw dt=04-27 + raw dt=04-26**:raw dt=04-26 里 X `update_time = 04-27 02:00`(DATE=04-27,入),raw dt=04-27 里 X `update_time = 04-28 02:00`(不入)→ ods dt=04-27 **完整包含 X 的 04-27 状态 A** ✓ X 的 04-28 状态 B 第二天 ods 04-29 跑批时落 ods dt=04-28 分区,拉链表轨迹完整无断节。 业务高峰跨零点(如本项目订单 0-6 点高峰)下,跨日漂移是常态,双源 union 是必要机制不是 defensive。 - ods 层**跨 dt 不去重**:同一业务 id 允许在多个 dt 分区并存(每条代表一个"时间段状态快照")——上层拉链表(SCD Type 2)的必要基础 - **T+1 batch 容忍边界**: 本设计基于 T+1 batch ETL,容忍范围明确: - **可救回**:跨日漂移 1 次(双源 union 救回);同 pk 跨多日持续 update(每天 batch capture 当日 update_time 的最新版本) - **不可救回**:同 pk 在两次 raw 跑批之间的"日内中间状态"——例如 10/10 02:00 状态 A → 10/10 05:00 状态 B → 10/11 02:00 状态 C,raw dt=09(10/10 03:00 跑)抓到 A,raw dt=10(10/11 03:00 跑)抓到 C,**B 永久丢失**。这是 T+1 batch 固有限制(只能 capture 跑批时刻 snapshot),要 capture B 必须上 CDC(见 §候选方案) **如果没有 raw 宽窗 + ods 双源(即单日固定窗口 + 单源),会出现"幽灵更新"**: 设想某用户固定在每天凌晨 2 点 update 同一条记录 X: - raw dt=N 任务 N+1 凌晨 3 点跑批时,X 业务库 update_time 已经变成 N+1 02:00(被覆盖到次日) - 单日窗口 `[N, N+1)` 不覆盖 update_time=N+1 02:00 → raw dt=N **漏 X** - 第二天 raw dt=N+1 任务 N+2 凌晨 3 点跑批时,X.update_time 又变成 N+2 02:00 → 单日窗口 `[N+1, N+2)` 又不覆盖 → **又漏** - 每天都漏,X **永远捕捉不到,永远漂向后方** 宽窗(48h)+ 双源 union 配套解决幽灵更新:raw dt=N 任务窗口 [N, N+2) 抓到 X update_time=N+1 02:00 漂到 N+1 范围;ods 写 dt=N+1 时双源 union(raw dt=N + raw dt=N+1)把这条早版本归位到 ods dt=N+1 分区。 - **后果**: - 正面:不漏漂移记录;raw 简单(只管多抓、不管分对);ods 动态分区自动归位;同 id 多 dt 并存作为拉链表底层 - 负面:raw 每日抓量约翻倍;ods SQL 必须统一动态分区写法 + 双源 union,开发规范需明文约束 - **候选方案**: - 单日窗口 `[day-start, day-stop)` 固定区间(无 buffer):业界小公司通用做法,本项目因漂移窗口 + 活跃度双放大**否决** - 动态 `now` 右界 `[day-start, now)`:可复现性、复跑、补数场景难处理,**否决** - 精确匹配漂移的半天 buffer `[day-start, day+6h-stop)`:和具体同步时刻耦合,调定时就得改窗口,**否决** - ods 跨 dt 按 `(pk, max(update_time))` 去重:破坏拉链表基础,**否决** - **ods 单源 raw dt=T-1(不 union raw dt=T-2)**:业务覆盖式更新跨日漂移场景下,漏 update_time=T-1 的早期版本(业务库该记录 update_time 已被推到 T 范围,raw dt=T-1 抓到的 DATE=T 不入),拉链表轨迹断一节,**否决**(具体例子见 §决策"为什么双源 union 必要") - **ods `INSERT INTO` 追加 + 分区内去重**(早先并列方案 B):意图保留"每日 ods 跑时刻"的中间快照轨迹。但与"支持重跑"语义冲突(追加模式重跑会重复入数据),且方案 A 双源 union 已能完整捕获跨日漂移版本,方案 B 的"中间快照"价值不抵复杂度代价,**否决** - 源库 `REPEATABLE READ` snapshot isolation:业务库长事务风险,**否决** - CDC(PG 逻辑复制 / MySQL binlog 流读):架构正路,需独立立项,**本阶段不取**(见 kb/12 CDC 演进节) - **反悔条件**:迁 CDC ### ADR-04 DataX speed 三级注入(L1 conf / L2 ini / L3 CLI) - **状态**:草案 老代码 `job_config_generator.py:60-67` 把 speed 三参(channel / byte / record)按工作时段硬编码:07:50-19:00 走 `channel=10 / byte=10MB / record=40k`(保护业务 DB),其余时段走 `channel=6 / byte=256MB / record=100k`。时段边界和各档值完全写死在 Python 代码里,调优需要改代码 + 发布。压测 / 回填场景下 speed 是高频调参项,需要运行时覆盖能力。 - **决策**:speed 三参走三级合并,优先级 L1 < L2 < L3。 - **L1** `conf/datax-tuning.conf`:项目级默认,承载宽松时段 `relaxed_period.{start,stop}` 边界 + strict/relaxed 两档各 3 个 speed 参值,共 8 个 key;时段配置用 `HH:MM` 字符串,代码解析为 HHMM 整数比较 - **L2** DataX ini 新增可选 `[speed]` 段:单 ini 级覆盖 channel / byte / record;一旦在 L2 显式写了,忽略时段判断直接使用 - **L3** CLI `-channel / -byte / -record`:本次运行级覆盖,`bin/datax-{hive-import,hdfs-export}-starter.py` 暴露,逐层透传到 `dw_base.datax.cli gen-json` 子进程,最终到 `JobConfigGenerator` - 合并发生在 `JobConfigGenerator.assemble()` 内存里,结果写进生成的 json(`job.setting.speed` + `core.transport.channel.speed`);ini / conf 文件本身不动 - 时段判断采用左闭右开 `[start, stop)`;每 key 的来源打印到 stdout 便于审计 - **schema 以宽松时段为基准**(区间内 relaxed、区间外 strict):原计划 `strict_period` 不工作——本项目业务高峰跨午夜(12:00 至次日 06:00),HHMM 整数比较 `1200 ≤ x < 0600` 为空集;宽松时段在低峰期(如 06:00-12:00)天然连续不跨日 - **后果**: - 正面:压测 / 回填 speed 调参不用改代码;时段分档逻辑可配,业务高峰期调整不用发布;单 ini 特殊场景可用 L2 覆盖;ADR 级改动只加约 350 行代码 + 10 条单测 - 负面:参数语义复杂度上升(3 层合并规则)——通过 `[tuning]` log 逐层打印缓解;`conf/datax-tuning.conf` 是新文件需同步到所有 worker(sync-all.sh 已覆盖) - **候选方案**: - 只做 L1(conf 外配,不要 L2 L3)——否决,压测高频调 speed 不想改 conf 再部署 - 参数全外配(speed + reader.fetchSize + writer.batchSize)——否决,fetchSize/batchSize 低频调,现有 `ini 覆盖硬编码` 够用,外配反而复杂 - CLI 用合成 flag `--datax-conf key=value,...`——否决,speed 高频用独立 flag 更直观,Spark 入口风格参考 - **反悔条件**:三级注入使用场景长期 <5%(即 L2 L3 基本不用),退回单层 conf;或者 DataX 被替换 ### ADR-05 DWD 事实表设计:默认"事件 vs 状态"拆派组合,acc 累积快照仅留例外口子 - **状态**:已采纳 老 `kb/20 §5.5` 走"合 vs 拆"二分法(粒度相同则合粒度不同则拆),举例订单履约 / 拼团生命周期均"合到 acc 累积快照"——一行一实体,每个里程碑一列时间戳,状态单向推进。该写法属 Kimball 维度建模理论里的 Accumulating Snapshot Fact Table,在传统数仓(Teradata / Oracle / Greenplum)里是经典模式。 2026-04-26 启动埋点 + 业务库接入业务建模时重新审视,发现两个核心问题: 1. **Hive 技术栈不友好**:Hive ORC 普通表不支持原生 UPDATE,acc 模式(每经过里程碑回写时间戳列)只能"INSERT OVERWRITE 整张表 / 整个分区"实现,每天扫近 N 天 ODS 重算,写入开销大、性能粗暴。要解此约束需开 Hive ACID(CDH 6 支持但少用 + 性能折损)或上 Iceberg / Hudi / Delta Lake,本项目都不具备 2. **业务流程多有循环**:拼团有审核机制(提交 → 拒绝 → 重新提交 → 再审核),订单亦有退款 / 重派单等循环。Kimball 对 acc 的前提是"每个状态只经过一次"——循环场景 acc 的时间戳列会丢失历史,不适用 期间在 inbox 收了一篇分层分区语义草稿,主张"事实表只承载事件、状态归 DIM 拉链"。讨论后认为该结论方向正确,但论证过度(直接否定 Kimball 三类事实表的全部);最终采纳折中表述:默认拆派 + 留 acc 例外口子。 - **决策**:默认采用"事件 vs 状态"拆派组合: - **DWD 事实表只承载业务事件**(不可变事实),命名 `dwd_{域}_{业务过程}_apd_d`,append-only,业务时间分区 - **实体当前状态进 DIM 拉链表**,命名 `dim_{域}_{实体}_zip_d`,SCD Type 2 每次状态变更生成新行 - 循环状态机天然支持:事件流水任意次重复追加,DIM 拉链每次变更生成新行可完整回溯 - **acc 不一刀切禁用**:极少数严格不循环、固定线性里程碑场景(如不可逆的合同审批流程)按需评估,新建 acc 表时在 PR / 设计稿里说明选型理由 - **后果**: - 正面: - 与 Hive 列存"只追加 + 分区"模型天然契合,不需要 UPDATE - 状态查询走拉链表 `is_current` 切片或按 dt 切片,直观 - 循环 / 单向状态机统一同一套建模,降低团队心智负担 - 与阿里 OneData / 字节 / 美团 Hive 数仓主流做法对齐 - 负面: - 跨表 JOIN("下单数 - 退单数 = 实际下单数")成为常态,但维度建模本就支持 - DIM 拉链表实现复杂度高于 acc 单表(每日变更 pk 检测 + 原行 `end_date` 置昨天 + 新行 insert) - 状态查询需要 join dim 拉链而非读单表,少量查询复杂度 - **候选方案**: - **保留老 §5.5 合派 acc**:经典 Kimball 但不适合 Hive 技术栈 + 不支持循环——否决 - **完全禁用 acc**:inbox 草稿原版主张"事实表出现需要更新状态就是建模错"——过度扩大化,放弃了 Kimball 三类事实表的理论丰富性,未来引擎栈升级(Iceberg / Hudi)想用 acc 又得反悔——否决,留例外口子 - **inc 主表 + apd 流水表 fallback**(kb/21 §2.2 老循环 fallback 方案):inc 只保留最新状态,状态历史靠 apd 流水还原,但状态查询要 join 流水表算最新值,远不如拉链表 `is_current` 直接——否决 - **反悔条件**: - 引擎栈升级到支持高效 UPDATE 的存储(Iceberg / Hudi / Delta Lake),acc 实现成本 / 性能不再是问题,可重新评估 - DIM 拉链表实现复杂度成为团队瓶颈(开发提效需要) - 实际场景中跨表 JOIN 性能 / 维护成本超出预期