3. 去重规则误伤:基于全字段去重丢失有效重复记录的修复

问题定位:全字段去重为什么误伤合法交易

某支付流水表 pay_flow 在清洗阶段执行了 drop_duplicates()(默认按全部列去重),清洗后行数从 128,640 降到 121,905,少了 6,735 行。业务方反馈:同一用户在同一天的多笔小额充值被合并成一笔,对账金额出现缺口。这类问题的根因不是"去重错了",而是去重键选错了——全字段去重把"字段值恰好相同"误当成"业务上重复"。

先看一个最小复现:同一用户 U1001 在 10:02 和 10:05 各充值 50 元,两笔交易除流水号外字段完全一致。全字段去重会保留第一笔、丢弃第二笔,而这两笔在业务上是两笔独立交易。

import pandas as pd

raw = pd.DataFrame({
    "flow_id":   ["F001", "F002", "F003", "F004"],
    "user_id":   ["U1001", "U1001", "U1002", "U1001"],
    "trade_time":["2024-05-01 10:02", "2024-05-01 10:05",
                  "2024-05-01 10:02", "2024-05-01 10:02"],
    "amount":    [50.0, 50.0, 50.0, 50.0],
    "channel":   ["APP", "APP", "APP", "APP"],
})

# 错误做法:全字段去重
bad = raw.drop_duplicates()
print(len(raw), "->", len(bad))   # 4 -> 3,F002 被误删

关键判断:flow_id 是支付系统生成的全局唯一流水号,天然具备唯一性;user_id + trade_time + amount + channel 只是业务属性,不能作为唯一标识。因此正确的去重键应当是 flow_id,而不是全部字段。

去重键选择对比:全字段 vs 业务主键 原始流水(4 行) flow_id user_id trade_time amount channel F001 U1001 10:02 50.0 APP F002 U1001 10:05 50.0 APP F003 U1002 10:02 50.0 APP F004 U1001 10:02 50.0 APP ✗ 全字段去重(错误) 去重键 = 全部列 F001 与 F002 除流水号外完全相同 → 合并 结果:4 行 → 3 行,丢失 F002 金额缺口 50 元,对账不平 ✓ 业务主键去重(正确) 去重键 = flow_id(全局唯一流水号) F001、F002 流水号不同 → 均保留 结果:4 行 → 4 行,无合法记录丢失 金额合计 200 元,与业务口径一致

重构去重键并验证保留记录

修复分两步:先确认业务主键,再按主键去重,最后用金额合计与行数双重校验。注意 flow_id 本身也可能存在重复(例如上游重放),因此去重键应设为 flow_id,并保留 keep="first" 以保留首次入库记录。

# 1) 确认业务主键:flow_id 应唯一
dup_flow = raw[raw.duplicated(subset=["flow_id"], keep=False)]
print("flow_id 重复行数:", len(dup_flow))   # 示例数据为 0

# 2) 按业务主键去重
fixed = raw.drop_duplicates(subset=["flow_id"], keep="first")
print(len(raw), "->", len(fixed))            # 4 -> 4

# 3) 双重校验:行数与金额合计
assert len(fixed) == len(raw), "合法记录被误删"
assert fixed["amount"].sum() == raw["amount"].sum(), "金额合计不一致"
print("校验通过,金额合计:", fixed["amount"].sum())   # 200.0

若 flow_id 确实存在重复(上游重放),则需进一步判断:重复的 flow_id 是否金额、时间也一致。若完全一致,说明是重放,保留一条即可;若金额不同,说明是流水号生成冲突,应上报而非静默去重。可用如下检查区分:

conflict = (raw.groupby("flow_id")["amount"]
               .nunique()
               .loc[lambda s: s > 1])
print("流水号相同但金额不同的冲突:", conflict.to_dict())
# 示例数据为空;若不为空,应保留全部并标记待人工核查
去重键的选择原则:优先使用系统生成的唯一标识(流水号、订单号、事件 ID);只有当业务上确实不存在唯一标识时,才退化为"业务属性组合键",且必须经过业务方确认该组合能唯一确定一笔交易。
不要用 drop_duplicates() 不带参数直接跑在生产数据上。默认全字段去重会静默删除"看起来一样"的合法记录,且不报错、不留痕,是最难排查的清洗缺陷之一。

修复后的验收清单

检查项方法通过条件
去重键唯一性对候选主键做 duplicated 统计重复行数为 0,或重复项已标记待核查
行数守恒对比清洗前后行数仅删除真实重复,合法记录数不变
金额守恒对比清洗前后 amount 合计合计一致,无缺口
用户维度核对按 user_id 分组对比笔数与金额与业务系统对账结果一致

本例的核心结论:去重不是"删掉一样的行",而是"按业务唯一性删掉真正的重复"。把去重键从全字段改为 flow_id 后,行数与金额双重守恒,合法重复交易得以保留。