线上业务数据重复怎么治理?一次从排查到修复的实战总结

0 阅读20分钟

摘要

线上数据问题里,最麻烦的往往不是查不到,而是查到了异常数据却不敢轻易动。尤其是业务数据出现重复时,真正难的不是“怎么查出来”,而是怎么在不误伤正常数据的前提下完成治理。这篇文章结合一次真实的线上处理过程,聊聊我是怎么一步步排查、确认规则、完成修复,并顺手把后续防重复思路补上的。


前言

线上问题里,有一类问题特别烦。

不是接口报错,也不是服务挂了,而是你一查数据,发现:

数据重复了。

这种问题看起来好像没那么吓人,毕竟服务还在跑、页面也还能看。

但真正处理过的人都知道,数据重复这类问题最麻烦的地方,不是“看到了”,而是:

  • 你不敢轻易改
  • 你怕改错
  • 你怕误伤正常数据
  • 你怕改完以后,后面链路又出新问题

说白了,这类问题最难的地方,不是 SQL 怎么写,而是:

这批数据到底能不能动,怎么动才稳。

我这次碰到的也是类似的问题。

表面上看,就是某类线上业务数据出现了重复。
但真开始处理以后,我发现这事远远不是“查出来然后删掉”这么简单。

因为你真正要面对的是:

  • 什么才算重复?
  • 哪条该留?
  • 哪条该治理?
  • 有没有下游已经引用了这些记录?
  • 这次治完了,后面还会不会继续重复?

所以这篇文章,我不打算写成那种纯 SQL 技巧文。
我更想把它写成一次完整的线上治理过程:

从发现问题,到确认规则,再到修复和防复发。

如果你平时也会碰到线上数据、业务修复、补数据、异常治理这类事情,这种问题应该会很有感觉。


一、这类问题最容易让人误判的地方:以为查出重复就等于问题解决了一半

很多人第一次遇到重复数据问题时,第一反应都差不多:

先查出来,后面改掉不就行了?

我一开始其实也会下意识这么想。

但这类问题真做几次以后就会发现,查出重复只是开始,不是结束。

因为“重复”这两个字,本身就没那么简单。

举个最直接的情况:

有些数据看起来字段很像,甚至某几个关键字段一模一样,但它不一定就是真的“脏数据”。

它也有可能是:

  • 某次补录产生的正常记录
  • 某种特定状态下允许存在的多条业务记录
  • 某个链路下分阶段写入的不同结果
  • 表面相似,但业务语义其实不一样

所以如果一上来就直接按“看起来一样”去处理,风险是非常大的。

我后来越来越在意的一件事就是:

线上数据治理,最怕的不是慢,而是误伤。

因为慢一点,你还能再核对。
但误伤了正常数据,后面补救往往更麻烦。

所以这类问题,我现在一般不会先想 update,而是先想:

到底什么才算“真的重复”。


二、第一步不是改数据,而是先定义判重规则

这个步骤我现在基本已经形成习惯了。

只要是线上数据治理,尤其是“重复”这种问题,我都会先做一件事:

先定规则,再写 SQL。

这一步听起来有点慢,但其实特别值。

因为你如果连“重复”的标准都没定清楚,后面改得越快,风险越大。

我当时最先做的事,就是先把判重维度理出来

比如会先想这些问题:

  • 哪些字段组合起来,才代表同一个业务含义?
  • 是否要把状态字段算进去?
  • 是否要考虑来源字段?
  • 时间维度要不要纳入判断?
  • 有没有“长得像重复,实际上不应该算重复”的情况?

说白了,先回答一句话:

我凭什么说这两条数据是重复的?

这一步如果说不清楚,后面的 SQL 基本都不稳。

很多人做这类问题,最容易犯的错误就是:

  • 先按几个字段 group by
  • 看见 count(*) > 1
  • 就觉得问题找到了

但真正线上治理不是这么干的。

因为 SQL 只能告诉你“数据上看起来重复”,不能自动替你做业务判断。

所以我现在特别认同一句话:

业务规则先于 SQL。

你先把规则定清楚,后面的查询、治理、复核才有底气。


三、第二步:先把重复范围查清楚,不要一上来就改

规则定完以后,下一步也不是马上 update

而是先把这件事的范围摸清楚。

因为你得先知道:

  • 重复到底有多少
  • 是集中在少数对象上,还是铺得很开
  • 是某个时间段集中出现,还是长期就有
  • 是一类数据有问题,还是多类数据都受影响

这一步我通常会拆成两层来看。


1. 先做重复统计,看看问题到底多大

这一层的目的很简单:

先摸全局。

典型查法一般就是按“业务唯一维度”去做聚合统计,看看哪些组合键下出现了多条。

像这种思路就很常见:

select biz_key1,
       biz_key2,
       biz_key3,
       count(*) as cnt
from business_record
where status = 'VALID'
group by biz_key1, biz_key2, biz_key3
having count(*) > 1;

这类 SQL 不复杂,但特别重要。

因为它能先告诉你几件事:

  • 问题范围大不大
  • 主要集中在哪些业务组合上
  • 后面治理是不是要分批做
  • 是局部治理,还是系统性排查

很多时候,先把这个统计跑出来,心里就有底很多了。


2. 再把重复明细拉出来,别只看统计

光看聚合统计其实不够。

因为统计只能告诉你“这里有重复”,但后面真正要处理,还得看明细。

所以我后面一般会再把重复组对应的全部记录完整拉出来,重点看这些字段:

  • 主键 ID
  • 状态
  • 创建时间
  • 更新时间
  • 来源
  • 关键业务字段
  • 是否有下游引用痕迹

思路一般像这样:

select *
from business_record
where (biz_key1, biz_key2, biz_key3) in (
    select biz_key1, biz_key2, biz_key3
    from business_record
    where status = 'VALID'
    group by biz_key1, biz_key2, biz_key3
    having count(*) > 1
)
order by biz_key1, biz_key2, biz_key3, create_time, id;

这一步特别像“拉案发现场”。

因为很多时候,你只有把明细一条条拉出来看,才会慢慢发现:

  • 哪些是真的重复
  • 哪些只是看起来像
  • 哪些记录状态其实不一样
  • 哪些是后续链路又写了一次
  • 哪些可能已经被别的流程引用了

这时候你才会意识到:

数据治理最怕的不是查不到,而是看得不够细。


四、第三步:查出重复不难,难的是保留哪条、治理哪条

这一步才是真正开始让人不敢轻易下手的地方。

因为重复查出来以后,后面真正要做的不是“删掉多余的”,而是先回答一个更关键的问题:

到底保留哪条?

这个问题如果没想清楚,你后面的治理就很容易带风险。

我自己后来处理这类问题时,基本都会先定一个“保留规则”。

常见的保留思路,一般会从这些角度考虑

  • 保留最早生成的一条
  • 保留状态正确的一条
  • 保留下游已经引用的一条
  • 保留来源更可信的一条
  • 其他记录不做物理删除,只做逻辑治理

这一点特别重要:

数据治理不是简单“删多余的”,而是先明确保留规则,再决定剩下的怎么处理。

如果你连“为什么留这一条”都说不清楚,那最好先别动。


我这一步一般会怎么辅助判断

如果只是想先看每组里谁排第一,窗口函数就很好用。

比如:

select id,
       biz_key1,
       biz_key2,
       biz_key3,
       status,
       source_type,
       create_time,
       row_number() over (
           partition by biz_key1, biz_key2, biz_key3
           order by create_time asc, id asc
       ) as rn
from business_record
where status = 'VALID';

这段 SQL 的价值,不是为了炫技巧,而是它能很直观地告诉你:

  • 每组里谁是第一条
  • 后面的记录是谁
  • 如果后面想按“保留第一条”的规则来治理,范围大概是什么

但说实话,我一般不会看完这个就直接 update

因为线上数据没那么简单。

我通常还会再结合:

  • 状态
  • 来源
  • 是否被引用
  • 是否有后续链路依赖

再做一次确认。

这一步虽然慢一点,但真的很值得。


五、第四步:真正开始治理时,我为什么不喜欢上来就物理删除

很多人一提“重复数据治理”,第一反应就是删。

这个思路不能说一定错,但对生产数据来说,我自己一直比较谨慎。

如果不是特别确定,我一般不太会一上来就做物理删除。

原因很现实:

  • 数据一删,很难回头
  • 你不一定百分之百确认没有下游引用
  • 有些记录虽然重复,但可能已经参与过后续链路
  • 后面如果有业务追溯需求,删掉的数据不好还原

所以我更倾向的做法是:

先做逻辑治理,而不是物理删除。

比如:

  • 改状态
  • 增加治理标记
  • 保留原记录
  • 让这类数据后面不再参与业务处理

这种方式虽然看起来没那么“干净”,但对生产来说更稳。


一个常见的治理思路

如果已经确认规则是“每组保留第一条,其余记录逻辑失效”,那就可以先用窗口函数把待治理数据圈出来。

思路类似这样:

with dup as (
    select id,
           row_number() over (
               partition by biz_key1, biz_key2, biz_key3
               order by create_time asc, id asc
           ) as rn
    from business_record
    where status = 'VALID'
)
update business_record t
set    status = 'INVALID',
       update_time = now(),
       update_user = 'data_fix',
       remark = '重复数据治理:同业务维度下保留第一条,其余逻辑失效'
from dup d
where t.id = d.id
  and d.rn > 1;

当然,真在线上执行前,我一般不会直接跑这条。

我通常会先把 dup 结果单独查出来核对一遍,确认待治理范围没问题,再执行。

比如先查:

with dup as (
    select id,
           biz_key1,
           biz_key2,
           biz_key3,
           status,
           create_time,
           row_number() over (
               partition by biz_key1, biz_key2, biz_key3
               order by create_time asc, id asc
           ) as rn
    from business_record
    where status = 'VALID'
)
select *
from dup
where rn > 1
order by biz_key1, biz_key2, biz_key3, create_time, id;

如果数据库或者当前环境不适合直接这么写,也可以先查出待治理 ID,再分批更新。

这一步我现在的想法很直接

治理最怕的不是慢,而是错。

所以这类生产数据问题,我宁可多看两眼,也不想图快。


六、第五步:治理前,我一定会先做几件确认

这类问题最怕“手快”。

有时候一条 SQL 下去,数据是改了,但后面你才发现:

  • 改多了
  • 改错了
  • 某些记录不该动
  • 下游流程已经引用了

这种就很被动。

所以真正执行治理前,我一般会做几件确认。


1. 先备份待治理数据

不一定非得全表备份,但至少把准备治理的那批数据单独留一份。

比如:

create table business_record_fix_bak as
select *
from business_record
where id in (
    -- 这里放待治理 ID 查询
);

或者如果公司不允许随便建表,也可以导出成 Excel / CSV,至少保留一份治理前明细。

这不是形式主义。

这东西真到回溯问题的时候,非常有用。

2. 先确认待治理数量

比如:

select count(*)
from business_record
where id in (
    -- 待治理 ID 查询
);

这个数量要和你前面查出来的重复明细数量对得上。

如果数量突然不一致,就要停一下,别急着执行。


3. 先看有没有下游引用

这个非常关键。

比如某些业务记录已经被后续流程引用了,你直接改状态,很可能会影响后面的查询、统计、核对。

示意 SQL 类似:

select r.id,
       count(d.id) as detail_count
from business_record r
left join business_record_detail d on d.record_id = r.id
where r.id in (
    -- 待治理 ID 查询
)
group by r.id
having count(d.id) > 0;

这里不是说有引用就一定不能改。

而是你至少要知道:

我动的这批数据,有没有被别人用过。

这一步不做,心里会很虚。


4. 先小范围验证

如果数据量比较大,我一般不喜欢一把梭。

可以先挑一小部分样本验证,比如:

  • 只处理某一个业务对象
  • 只处理某一天的数据
  • 只处理某个分组下的数据

确认逻辑没问题以后,再扩大范围。

说白了就是:

线上数据治理,不要一上来就全量动。


七、第六步:治理后,不能只看 SQL 执行成功,还要复核结果

很多人做数据修复,很容易停在这一步:

SQL 执行成功,影响行数也对,结束。

但我现在不太敢这么干。

因为 SQL 执行成功,只能说明“数据库接受了你的操作”,不能说明“业务结果一定对”。

治理后我一般会再查几类东西。


1. 重复数据是否真的消除了

先把最开始那条重复统计 SQL 再跑一遍:

select biz_key1,
       biz_key2,
       biz_key3,
       count(*) as cnt
from business_record
where status = 'VALID'
group by biz_key1, biz_key2, biz_key3
having count(*) > 1;

理想情况下,这里应该查不出来,或者只剩下业务允许存在的特殊情况。


2. 关键数据量是否符合预期

比如治理前后有效数据数量变化:

select status,
       count(*) as cnt
from business_record
group by status;

如果你治理的是金额、数量、状态相关的数据,还要看关键汇总值有没有异常。

比如:

select sum(amount) as total_amount,
       count(*) as total_count
from business_record
where status = 'VALID';

这个不是为了凑步骤,而是为了防止一种情况:

重复确实没了,但关键业务结果也被你带偏了。


3. 抽样看明细

治理后我一般还会抽几组看明细。

尤其是治理前重复比较明显的那些业务组合,改完后最好再查一下:

select *
from business_record
where biz_key1 = 'xxx'
  and biz_key2 = 'xxx'
  and biz_key3 = 'xxx'
order by create_time, id;

这一步很土,但很有用。

很多时候,聚合结果看着正常,明细里才能看出有没有问题。


4. 看后续链路是否还能正常跑

有些问题不是改完立刻暴露。

可能要等到:

  • 后续任务跑一遍
  • 报表重新统计
  • 业务流程继续往下走
  • 某个定时任务再次执行

所以治理后最好观察一段时间,不要改完就完全不管了。

我的习惯是:

至少把后续关键链路看一遍,确认没有新的异常。


八、第七步:真正有价值的,不是这次把数据改掉,而是搞清楚它为什么会重复

说实话,这一步我后来越来越在意。

因为把一批重复数据清掉,很多时候只是止血。

真正有价值的是搞清楚:

它为什么会重复。

如果根因不找,那这次治理完了,后面还是会再来。


我一般会从这些方向去看

1. 是不是上游重复调用

有些接口本身可能会被重复调用。

比如:

  • 前端重复提交
  • 上游系统重复推送
  • 网络超时后再次请求
  • 任务重试又发了一次

如果下游没有幂等控制,就很容易重复写入。


2. 是不是幂等没做好

这个是重复数据非常常见的根因。

很多链路第一次写的时候,只考虑“正常执行一次”。

但真实线上不是这样。

线上会有:

  • 重试
  • 补偿
  • 超时
  • 重跑
  • 人工补数据

如果没有业务唯一键校验,没有唯一约束,没有状态判断,那重复数据迟早会出现。


3. 是不是任务重跑导致的

有些批量任务、定时任务、补数据任务,最容易出现这种问题。

第一次跑一半失败了,第二次又从头跑。
如果没有跳过已处理数据,就容易重复。

这种我之前踩过,真的很烦。

因为它不是完全失败,而是“半成功”,后面最难查。


4. 是不是状态更新不完整

有些链路是这样的:

  1. 先插入记录
  2. 再更新状态
  3. 再写明细
  4. 再触发后续流程

如果中间某一步异常,前面的数据已经写了,后面的状态没跟上。

下一次任务再扫到它,就可能又处理一次。

这种问题表面上看是数据重复,本质上可能是:

链路状态没有闭环。


5. 是不是补数据流程缺少限制

补数据也很容易制造重复。

尤其是临时脚本、人工补录、批量修复这类动作,如果没有提前判断是否已存在,就很容易越补越乱。

所以我现在对补数据也会很谨慎。

补之前一定会问:

这批数据之前有没有处理过?再次执行会不会重复?


九、第八步:怎么防止后面再次重复

数据治理做完以后,不能只停在“这次修好了”。

因为如果后面还会继续重复,那这次只是清历史数据,问题没有真正解决。

我后面一般会从几个方向补防护。


1. 应用层加幂等判断

比如插入前先判断同一业务唯一键是否已经存在。

示意代码:

public void saveBusinessRecord(BusinessRecord record) {
    BusinessRecord exist = businessRecordMapper.selectByBizKey(
            record.getBizKey1(),
            record.getBizKey2(),
            record.getBizKey3()
    );

    if (exist != null) {
        log.info("业务记录已存在,本次跳过,bizKey1={}, bizKey2={}, bizKey3={}",
                record.getBizKey1(), record.getBizKey2(), record.getBizKey3());
        return;
    }

    businessRecordMapper.insert(record);
}

这类代码很简单,但能挡住一部分重复写入。

不过这里也要注意:

只靠先查再插,不一定能完全解决并发问题。

如果并发特别高,两个线程可能同时查不到,然后一起插入。

所以关键数据最好还要配合数据库约束。


2. 数据库层加唯一约束

如果某几个字段从业务上就是唯一的,那最稳的还是数据库层兜底。

比如:

create unique index uk_business_record_biz_key
on business_record (biz_key1, biz_key2, biz_key3);

这个动作上线前一定要谨慎。

因为如果历史数据里已经有重复,直接加唯一索引会失败。
所以一般顺序是:

  1. 先治理历史重复数据
  2. 再确认没有存量冲突
  3. 再加唯一约束
  4. 最后配合代码处理唯一冲突异常

数据库唯一约束不是万能的,但它是非常关键的一道防线。

3. 对重试和补偿逻辑加保护

很多重复数据不是正常流程产生的,而是重试、补偿、重跑产生的。

所以这类逻辑一定要格外小心。

比如重跑任务时,不要无脑全量重跑:

public void retryFailedRecords(List<Long> recordIds) {
    for (Long id : recordIds) {
        BusinessRecord record = businessRecordMapper.selectById(id);

        if (record == null) {
            continue;
        }

        if ("SUCCESS".equals(record.getProcessStatus())) {
            log.info("记录已处理成功,本次重试跳过,id={}", id);
            continue;
        }

        doProcess(record);
    }
}

这种逻辑看起来啰嗦,但很实用。

因为线上最怕的就是:

你本来想补救,结果又制造了一批重复数据。


4. 补日志和监控

重复问题最怕发现得太晚。

如果等到业务方发现报表不对、金额不对、状态不对时,通常已经积累了一段时间。

所以后面最好补一些日志和监控。

比如:

  • 重复拦截日志
  • 入库失败日志
  • 唯一约束冲突日志
  • 任务重跑日志
  • 某类业务记录短时间内异常增长告警

不一定一上来就做得很复杂,但至少要让问题下次出现时更早被发现。


十、我最后总结的一套处理顺序

如果后面再遇到类似问题,我基本会按这个顺序来:

1. 先别急着改

先把问题范围、影响面、业务规则搞清楚。

2. 先定义什么算重复

没有判重规则,就不要急着写治理 SQL。

3. 先查统计,再查明细

统计看全局,明细看真实情况。

4. 先定保留规则

明确留哪条、治哪条。

5. 尽量先逻辑治理

生产数据不要轻易物理删除。

6. 治理前备份,治理后复核

改之前留痕,改之后证明自己改对了。

7. 最后一定要找根因

不找根因,就只是清了一次历史数据。

8. 补防重复机制

幂等、唯一约束、重试保护、日志监控,该补的要补上。

这套流程不一定多高级,但比较稳。

线上数据问题很多时候不需要你炫技,
需要的是:

别误伤、能追溯、能复核、能防复发。


总结

线上业务数据出现重复,表面上看像是一个“查重问题”。

但真正做下来你会发现,它本质上更像是一个完整的治理过程:

排查 → 定规则 → 圈范围 → 定保留策略 → 修复 → 复核 → 找根因 → 防复发。

真正难的,不是写出一条查重 SQL,而是下面这些事:

  • 什么才算真的重复
  • 哪些能动,哪些不能动
  • 保留哪条,治理哪条
  • 怎么在不误伤正常数据的前提下处理掉
  • 以及,怎么避免后面再重复

对我来说,这类问题最大的感受就是:

查出重复不难,难的是不误伤。

如果你平时也会处理线上数据、业务治理、补数据或者异常修复,这类问题真的特别值得认真对待。

因为它从来都不是“写条 SQL 改一下”那么简单。

很多时候,真正体现经验的地方,不是你会不会查重复,而是你能不能把这件事稳稳地处理完。

大家加油:)