3 万条会员被分配了两次:一次"同步延迟"背锅案的平反

一、事情是这样的

前阵子一个朋友找我聊问题。他们公司做 CRM 系统,技术栈大概是:

  • MySQL 8:业务主库,订单、会员、分配关系等源数据都在这
  • ClickHouse:会员数据的复杂组合条件查询引擎
  • Redis 7:缓存
  • 阿里 DTS:MySQL 增量同步到 ClickHouse,频率大概 2000 条/秒

为什么上 ClickHouse?因为 CRM 的会员圈选查询条件非常复杂——会员属性、订单行为、话务记录、访问明细多表组合,MySQL 扛不住这种任意组合的筛选,所以引入了 CK 做查询加速。

架构看起来很标准:写在 MySQL,读在 ClickHouse,中间一条同步链路。

然后出事了。

二、事故现象:任务包里的会员重复了

他们的业务流程大概是:

  1. 操作员用组合条件在 CK 里圈选会员,一捞就是十几万
  2. 从里面挑 3~5 万,分配到不同的任务包(公海 → 租户 → 员工)
  3. 分配动作全部在 MySQL 上做:更新分配关系表、记录日志、写任务包

最近发现:部分任务包里出现了重复的会员。同一个会员,被分进了不止一个任务包。

朋友的初步排查结论是:DTS 同步有延迟,MySQL 里已经分配掉的会员,CK 里还是"公海"状态,下一轮圈选又把它捞出来了。

这个判断对不对?对了一半。

三、根因分析:同步延迟只是帮凶,不是主犯

我跟朋友说,如果只盯着同步延迟修,大概率修不干净。重复数据的产生,需要三个条件同时成立:

条件 1:读写分离带来的时间差。 分配发生在 MySQL,圈选发生在 CK。DTS 2000 条/秒,而且一次分配涉及多张表变更,延迟是必然的——秒级甚至分钟级。CK 里用了 FINAL 查询也没用,FINAL 解决的是 ReplacingMergeTree 合并前的多行问题,解决不了"数据还没同步过来"的问题。这两件事经常被混为一谈。

条件 2:分配动作没有防重保护。 流程是"CK 查出列表 → 直接批量 UPDATE"。没有任何一行代码去校验"这个会员现在到底是不是还能分"。只要 CK 给了,就照单全收。

条件 3:并发操作没有互斥。 操作员经常是"先捞十几万,再慢慢分"。两个人圈选条件有重叠时,就算同步零延迟,抢同一批数据也会出重复。

所以结论很明确:就算把 DTS 优化到毫秒级,只要写入不做条件校验、并发不做控制,重复照样发生。

修复的重心,不应该放在同步链路上。

四、方案讨论:先别急着动手,把候选方案摆上桌

我们列了五个候选方案,逐个过:

方案思路能否根治重复成本
① CK 查询加时间偏移过滤update_time < NOW() - 5min,制造"安全窗口"❌ 逻辑不成立低
② MySQL 条件写(CAS)UPDATE ... WHERE status='未分配',校验 affected_rows✅ 根治低
③ 预分配 + 黑名单MySQL 锁定状态 + Redis 过滤"锁定中"ID✅ 辅助防重中
④ 优化同步链路换 Canal/Flink CDC,压低延迟❌ 治标高
⑤ 异步任务队列分配变成任务单,Worker 查 MySQL 主库执行✅ 长期方向高

方案①:一个漂亮的错误

这是朋友从某个 AI 助手那里拿到的"最快见效"方案:查 CK 时加上 update_time < NOW() - INTERVAL 5 MINUTE,把最近 5 分钟变更过的数据排除掉。

乍一听很合理,仔细一想是错的:

会员 X 十分钟前在 MySQL 被分配了,但这次变更因为 DTS 积压还没同步到 CK。CK 里 X 的 update_time 还是上次变更的旧时间——可能是半年前。旧时间通过了"5 分钟前"的过滤,X 照样被查出来。

这个过滤器过滤掉的,是已经正确同步的数据;漏掉的,恰恰是有问题的数据。 方向完全反了。

唯一成立的变体是用"同步入库时间"过滤(CK 表里加一个 ETL 打点的 sync_time 字段),但那要改同步链路,就不是"纯查询修改"了。

这件事给我一个提醒:AI 给的方案,每一条都要在脑子里跑一遍真实数据流,才能判断它是不是真的成立。 看起来逻辑通顺,不代表经得起推敲。

方案②:真正的主角,MySQL 条件写

UPDATE 会员分配关系表
SET 租户ID = ?, 状态 = '已分配', 更新时间 = NOW()
WHERE member_id IN (...) AND 状态 = '未分配';

不管 CK 查出来的列表有多"脏",写入 MySQL 时带上"仍是未分配"这个条件,重复就物理上不可能发生——第二个分配请求的 affected_rows 会是 0。

这里有个他们业务特有的坑:他们的分配关系表是"有记录才算归属,没记录就是公海"。也就是说公海会员在表里根本没有行,分配动作实际是 INSERT 而不是 UPDATE。那么防重就得靠:

  • member_id 唯一约束(数据库兜底,最硬)
  • 或 INSERT ... SELECT ... WHERE NOT EXISTS(代码兜底)

这个细节,无论是我还是那个 AI 助手,第一版方案里都漏掉了。脱离具体表结构谈方案,都是纸上谈兵。

方案③:黑名单是体验优化,不是正确性保障

思路是:分配前先在 MySQL 里把状态改成"锁定中",同时把 ID 写进 Redis;别人查 CK 之后,用 Redis 黑名单把"锁定中"的过滤掉,就不会看到别人正在抢的数据。

两个细节必须抠:

  1. 黑名单过滤要放在应用层做,CK 查完回内存里比对,不要拼成 id NOT IN (几万个ID) 塞进 CK 的 SQL——SQL 文本爆炸,解析和执行都慢。
  2. Redis 锁要按 ID 独立设置、各自带 TTL,别用一个大 Set 统一设过期时间,否则要么锁被意外续命,要么一批锁同时蒸发。

另外要想明白主次关系:黑名单丢了、Redis 挂了,都不会产生重复数据——因为方案②的条件写在兜底。黑名单只是减少"看得见却分不了"的空转体验。想清楚这一点,就不会在这个方案上过度设计。

方案④:同步优化,先量化再决定

DTS 到底延迟多少?峰值多少?朋友其实没有准确数字,只是"感觉慢"。

我的建议是:先上延迟监控打点,拿到真实数字再决定要不要换链路。如果延迟只有 3 秒,那方案②③组合就够了,换 Canal/Flink CDC 的投入完全不值。监控是免费的,换链路是昂贵的。

方案⑤:异步任务化,留给未来

多人高频大批量分配,终态应该是"提交任务单 → 后台 Worker 查 MySQL 主库执行 → 任务中心看结果"。Worker 直查主库,天然没有延迟问题。

但它要改前端交互、引入任务中心,属于架构级改造,不适合在救火期做。

五、加状态字段的风险:别只盯着分配这一条链路

方案③要引入"锁定中"这个新状态,朋友当时最担心的就是这个。担心得对——会员分配关系表是他们系统里变化最频繁的表,公海转租户、内部归属变更、回收、延迟、黑名单,全都要读写这张表。

加一个状态值,影响面必须一条条过:

1. 多次更新带来的中间态暴露。 "未分配 → 锁定中 → 已分配"从一次写变成两次写。第二次写之前进程崩了怎么办?必须配套超时释放的定时任务。这里有两个设计要点:

  • 时间字段建议存"过期时刻"而不是"加锁时刻"。与其存 lock_time 再算 lock_time < NOW() - 10min,不如加锁时直接写入 lock_expire_time = NOW() + TTL。好处有两个:不同批量大小可以给不同 TTL;长任务可以通过续期(更新 expire_time)保活,避免"任务还在跑,锁被端了"。
  • 防误伤靠的不是时间字段,而是条件写的原子性。超时释放和分配完成是两条争抢同一行的 UPDATE,MySQL 行锁会串行化它们,先执行者生效:分配先完成,释放 SQL 的 WHERE status='锁定中' 匹配 0 行,误伤不了;释放先执行,分配 SQL 匹配 0 行,失败被暴露。所以两条 SQL 都必须带 status='锁定中' 条件,谁输谁 affected_rows=0,谁也别想悄悄改赢。

还有一个更隐蔽的边界要防:静默丢失。任务跑得太慢被定时任务回收,数据被别人合法分走,姗姗来迟的"完成"SQL 匹配 0 行——这时如果代码不看 affected_rows,前端还提示"分配成功",数据就对不上了。所以完成侧必须校验 affected_rows,少于预期就反查哪些会员被回收了,明确告知操作员,绝不能退化成按 ID 强更新。

2. 存量查询条件的排查成本。 所有查这张表的地方,凡是写了 status = '未分配' 的,都要问一句:"锁定中"的数据该不该被查出来? 回收流程查"可回收"列表、黑名单流程查"在册"会员、报表统计"公海数量"——每一处都是一个判断点。漏改一处,就是新的线上问题。

3. 对 DTS 同步的额外压力。 锁定操作本身也是一次写,会产生 binlog,DTS 要多同步一批数据。高频分配场景下,这等于给本来就有积压的同步链路雪上加霜。好在锁定状态只是过程态,CK 端查询最终依赖 MySQL 兜底,这个压力可以接受,但要心里有数。

4. 状态回滚的路径要想全。 分配成功:锁定中 → 已分配。分配失败:锁定中 → 未分配,且要清锁信息。操作员中途关浏览器:靠定时任务超时释放。每一条失败路径都要有对应的状态出口,否则数据会被永久冻结在"锁定中",业务方会拿着刀来找你。

这就是现实的工程判断:加状态字段不是加一个枚举值的事,是把一颗石子扔进一张蛛网,每一根丝都会动。

六、改造成本与时间评估

和朋友最后敲定的落地计划,按投入产出排序:

阶段内容预估工时上线时间
🔴 紧急止血分配写入改为条件写 + affected_rows 校验 + 确认 INSERT 防重(唯一约束)1~2 人日本周内
🟡 体验加固Redis 锁定标记(应用层过滤)+ 超时释放定时任务3~4 人日下周
🟡 安全网对账任务:扫描跨任务包重复会员,出报表/修复存量脏数据(可选)1~2 人日下周
🟢 观测DTS 同步延迟监控打点 + 告警1 人日下周
⚪ 排期分配任务异步化(任务单 + Worker)2~3 周下个迭代评估

几个关键判断:

  • 止血方案只改分配服务层,不动表结构、不动同步链路、不动 CK,所以可以当天评审、隔天上线。救火期最重要的就是改动面小。
  • 对账任务看起来是"锦上添花",其实是必做项——存量已经产生的重复数据不会自己消失,而且它还能兜住未来任何漏网的新场景。
  • 异步化先不做。等业务量真的涨到并发冲突成为日常,再启动也不迟。过早的架构升级,本身就是一种风险。

七、写在最后

这个问题前前后后聊了两天,最大的感受是:

这类一致性问题的答案,从来不是某个精妙的技术方案,而是一连串清醒的取舍。

  • 同步延迟要不要治?先量化,别凭感觉。
  • 新状态字段要不要加?先把所有读写这张表的流程列一遍。
  • 架构要不要升级?先把止血做完,让子弹飞一会儿。

写 MySQL 读 CK 的架构没有错,错的是默认"查出来的就是最新的"。任何跨存储的读写分离,都要先回答一个问题:当两边不一致时,谁说了算? 在他们的场景里,答案很清晰——分配状态,永远以 MySQL 为准;CK 只是一个筛选引擎。

把这个原则写进团队规范,比修十个 Bug 都值钱。

最后用一句话收尾,也是我跟朋友说的原话:

没有永远完美的方案,只有最适合当前的取舍。 工程的美学不在"最优解"里,而在"清楚地知道自己放弃了什么"里。


如果你也遇到过读写分离带来的一致性问题,欢迎留言聊聊你们最后选了哪条路。有更好建议,也希望留言给我,感谢

标签:#Java #ClickHouse #DTS #读写分离 #脏数据

评论

为保障内容质量,评论需站长审核通过后才会展示;提交后没立刻看到属正常,请勿重复提交。

还没有评论,来抢沙发~