摘要
线上数据问题里,最麻烦的往往不是查不到,而是查到了异常数据却不敢轻易动。尤其是业务数据出现重复时,真正难的不是“怎么查出来”,而是怎么在不误伤正常数据的前提下完成治理。这篇文章结合一次真实的线上处理过程,聊聊我是怎么一步步排查、确认规则、完成修复,并顺手把后续防重复思路补上的。
前言
线上问题里,有一类问题特别烦。
不是接口报错,也不是服务挂了,而是你一查数据,发现:
数据重复了。
这种问题看起来好像没那么吓人,毕竟服务还在跑、页面也还能看。
但真正处理过的人都知道,数据重复这类问题最麻烦的地方,不是“看到了”,而是:
- 你不敢轻易改
- 你怕改错
- 你怕误伤正常数据
- 你怕改完以后,后面链路又出新问题
说白了,这类问题最难的地方,不是 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. 是不是状态更新不完整
有些链路是这样的:
- 先插入记录
- 再更新状态
- 再写明细
- 再触发后续流程
如果中间某一步异常,前面的数据已经写了,后面的状态没跟上。
下一次任务再扫到它,就可能又处理一次。
这种问题表面上看是数据重复,本质上可能是:
链路状态没有闭环。
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);
这个动作上线前一定要谨慎。
因为如果历史数据里已经有重复,直接加唯一索引会失败。
所以一般顺序是:
- 先治理历史重复数据
- 再确认没有存量冲突
- 再加唯一约束
- 最后配合代码处理唯一冲突异常
数据库唯一约束不是万能的,但它是非常关键的一道防线。
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 改一下”那么简单。
很多时候,真正体现经验的地方,不是你会不会查重复,而是你能不能把这件事稳稳地处理完。
大家加油:)