订阅更新

补数把历史数据搞乱了——重跑机制怎么设计才安全?

数据质量手册 第 14 题 约 9 分钟读完

本文目录
  1. 先说结论
  2. 补数的三种典型场景
  3. 原则一:幂等性设计
  4. 原则二:隔离机制(补数 vs 正常调度)
  5. 原则三:可追溯(每次补数都有记录)
  6. 原则四:可回滚(保留快照)
  7. 补数的标准流程
  8. 面试高频追问
  9. 实战踩坑:并发补数互相覆盖的事故
  10. 总结

先说结论

安全补数四大原则:①幂等(重跑多少次结果一致)②隔离(补数和正常调度互不影响)③可追溯(什么时候谁补的什么)④可回滚(出问题能恢复)。每条都缺不得。

最容易翻车的不是技术,是流程:补数SQL没review、并发补数互相覆盖、补完没通知下游。技术保证「能补对」,流程保证「不补错」。

先把这段讲顺;面试官追问时,再展开下面的原理和细节。

详细讲解

「补数」是数据团队的家常便饭——上游延迟、ETL失败、口径调整、数据修复,都需要补数。但「补数翻车」也是高频事故:补错了分区、补的逻辑和原逻辑不一致、补完下游没刷新、并发补数互相覆盖。这道题考的是工程严谨性。我在出海硬件公司维护过一套支撑日均500+次补数的重跑平台,今天聊聊核心设计。

补数的三种典型场景

场景原因范围技术方案
失败重跑ETL任务失败单分区调度系统自动重试
延迟补数上游延迟才到1-N天Backfill脚本
逻辑修复ETL逻辑bug可能历史全量全表回算
维度变更维度口径调整全表回算全表回算+下游联动

原则一:幂等性设计

幂等是补数的生命线。同一个分区补N次,结果必须完全一样。这要求ETL写法必须用OVERWRITE而不是INSERT INTO:

-- ❌ 危险写法:INSERT INTO
-- 重跑会叠加,数据翻倍
INSERT INTO TABLE dwd_order PARTITION(dt='${bizdate}')
SELECT ... FROM ods_order WHERE dt = '${bizdate}';
 
-- ✅ 安全写法:INSERT OVERWRITE
-- 无论跑几次,结果都是「最新一次的产出」
INSERT OVERWRITE TABLE dwd_order PARTITION(dt='${bizdate}')
SELECT ... FROM ods_order WHERE dt = '${bizdate}';
 
-- ✅ 流式补数也要幂等:用UPSERT或幂等键
-- 每条数据带biz_id,下游用ON CONFLICT保证幂等

我在出海硬件公司订过一条铁律:所有分区表的ETL必须用OVERWRITE,违反者code review不通过。半年后再没出现「补数据翻倍」类故障。

原则二:隔离机制(补数 vs 正常调度)

最危险的场景:白天补数和凌晨调度撞车。比如凌晨调度跑当日分区,白天有人手动补昨天的,但写的WHERE条件带'今天',结果把今天的数据覆盖。隔离三层:

# 隔离方案
 
# 1. 物理隔离:补数走单独资源池
backfill_resource_group = 'mr_backfill_pool'
regular_resource_group = 'mr_daily_pool'
 
# 2. 时间窗口隔离:补数禁止操作未来分区
def safe_backfill(table, dt_range):
    today = date.today()
    if any(d >= today for d in dt_range):
        raise Exception('禁止补数操作今日及未来分区')
    if any(d < today - timedelta(days=90) for d in dt_range):
        raise Exception('补数超过90天需要Tech Lead审批')
    
# 3. 锁机制:同一分区不能并发补数
with distributed_lock(f'backfill_{table}_{dt}'):
    run_backfill_sql(table, dt)

原则三:可追溯(每次补数都有记录)

补数操作必须留痕——什么时候、谁、为什么、补了哪些分区、结果如何。我设计了一张补数审计表:

CREATE TABLE backfill_audit (
  backfill_id STRING,           -- 补数任务ID
  operator STRING,              -- 操作人
  reason STRING,                -- 补数原因(必填)
  ticket_id STRING,             -- 关联工单
  table_name STRING,            -- 目标表
  partition_range STRING,       -- 分区范围
  sql_text STRING,              -- 补数SQL
  start_time TIMESTAMP,
  end_time TIMESTAMP,
  status STRING,                -- SUCCESS/FAILED
  affected_rows BIGINT,         -- 影响行数
  diff_with_original DOUBLE,    -- 和原数据的差异
  approver STRING,              -- 审批人(高危补数必填)
  downstream_notified BOOLEAN   -- 是否通知下游
) PARTITIONED BY (dt);

这张表帮我多次定位故障——某次下游报数据不对,查audit表立刻发现昨天有人补过数,原因是某字段口径调整。如果没记录,根因要排查几个小时。

原则四:可回滚(保留快照)

补数前必须保留原始数据快照,万一补错了能恢复:

-- 方案1:物理快照(小表)
CREATE TABLE dwd_order_backup_20240315_v1 AS
SELECT * FROM dwd_order WHERE dt = '20240315';
 
-- 方案2:Delta Lake/Iceberg版本(推荐)
-- 自动保留版本,无需手动备份
DESCRIBE HISTORY dwd_order;
-- 显示所有版本,含时间戳和SQL
 
-- 一旦补错,回滚
RESTORE TABLE dwd_order TO VERSION AS OF 42;
 
-- 方案3:分区级别的「待发布」机制
-- 补数先写到 dwd_order_staging.dt='20240315'
-- DQC通过后才rename到正式表
ALTER TABLE dwd_order_staging RENAME PARTITION (dt='20240315') TO dwd_order;

补数的标准流程

# 补数SOP(含审批和DQC)
def run_backfill(table, dt_range, sql, reason, ticket_id, operator):
    # Step 1: 前置校验
    validate_partition_range(dt_range)
    validate_sql_safety(sql)  # 检查WHERE条件、避免无限制更新
    
    # Step 2: 风险审批
    risk = assess_risk(table, dt_range)
    if risk == 'HIGH':
        require_approval(operator, ticket_id)  # 钉钉审批
    
    # Step 3: 备份
    snapshot_id = create_snapshot(table, dt_range)
    
    # Step 4: 执行(加锁、用资源池)
    with distributed_lock(f'backfill_{table}_{dt_range}'):
        run_in_pool('mr_backfill_pool', sql)
    
    # Step 5: DQC校验
    if not run_dqc(table, dt_range):
        rollback_to_snapshot(table, snapshot_id)
        raise Exception('DQC failed, rolled back')
    
    # Step 6: 通知下游 + 触发下游补数
    notify_downstream(table, dt_range)
    trigger_downstream_backfill(table, dt_range)
    
    # Step 7: 审计落库
    log_to_audit_table(operator, ticket_id, sql, ...)

面试高频追问

追问一:补数和Time Travel是什么关系?

Time Travel是「事后回滚」的能力,补数是「事中重新计算」的能力。两者互补:补数前用Time Travel做snapshot,万一补错了能立刻恢复。生产实践里,对Delta Lake/Iceberg表的补数远比Hive表安全——后者一旦OVERWRITE出错就回不去了。这也是我推动核心表都迁到Delta Lake的原因之一。

追问二:补数引发的下游连锁反应怎么处理?

两个机制:①血缘驱动的自动级联——当上游表某分区被补数,调度系统自动把所有下游同分区任务标记为「过期」,等待重跑;②业务方明确预期——补数前钉钉群广播「dwd_order 20240315分区已重置,下游报表预计14:00更新」。技术+沟通双管齐下,避免下游用着「还没刷新的旧数据」做决策。

追问三:超大规模补数(30天+全表)怎么做?

两个原则:①分批跑——按时间或按维度拆成多个小批,避免单次SQL跑太久失败重头来过;②先冒烟再全量——先补1天,DQC通过、下游验证OK,再补全量。我在出海硬件公司做过一次180天全表回算,拆成6个30天的批次,每批跑前后都做对账,整个过程花了3天,零故障。

实战踩坑:并发补数互相覆盖的事故

出海硬件公司有段时间数据团队在做某重大口径调整,需要补过去90天的某指标。两个同事不知道对方也在做,同时跑了补数SQL。两人的SQL逻辑不完全一样(一个用了新口径,一个还在用旧口径),在Hive上互相OVERWRITE,最终生效的是「最后跑完的那个」。

第二天业务发现数据「时而正确时而错误」——其实是被两套SQL交替覆盖。复盘的时候才发现这种问题。

解决方案:立刻在补数平台加了分布式锁机制:

# 用Redis/Etcd做分布式锁
lock_key = f'backfill_{table}_{partition}'
with redis.lock(lock_key, timeout=3600):
    # 这里只能同时有一个进程在跑
    run_backfill_sql(...)
 
# 如果第二个补数请求来了,要么排队,要么直接拒绝
if redis.is_locked(lock_key):
    raise Exception(f'另一个补数任务正在运行:{get_lock_owner(lock_key)}')

教训:生产环境的补数从来不是「一个人的事」。多人并发、跨任务依赖、上下游联动,必须有锁机制+审计追踪。补数平台是工具,但更重要的是流程和规范。

总结

安全补数四原则:幂等、隔离、可追溯、可回滚。配合标准SOP(前校验+审批+备份+执行+DQC+通知+审计)和分布式锁机制。补数是工程严谨性的试金石。面试时把四原则+SOP+并发覆盖故事讲清楚,秀出工程师素养。

下一篇聊一个性能向的话题:高基数字段(user_id/device_id)的质量校验怎么做才不爆内存?

你们补数有没有审批流程?踩过什么坑?评论区聊聊~

关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长

刷题进度保存在当前浏览器