高并发场景下的数据重复问题分析与解决方案
发布时间:2026/10/31 3:03:39 锦皓数字建站

1. 事故背景与现象还原那天凌晨2点37分我被一阵急促的电话铃声惊醒。运维同事在电话那头声音颤抖数据库里出现大量重复通话记录客户投诉电话已经打爆了瞬间睡意全无我立刻打开电脑查看生产日志。问题出现在通话详单同步服务上。这个服务每小时会从运营商接口拉取最新通话记录处理后存入业务数据库。日志显示在01:00的定时任务执行期间系统竟然为同一条通话生成了3-5条重复记录。更可怕的是这些重复数据已经流入计费系统导致部分用户被重复扣费。2. 问题定位与根因分析2.1 并发场景还原通话记录同步服务的核心逻辑看似简单从运营商API获取原始通话记录根据通话ID检查是否已存在不存在则插入新记录问题出在第二步的检查逻辑。当多个线程同时处理同一批通话数据时会出现这样的执行序列线程A检查通话ID-123不存在 线程B检查通话ID-123不存在 线程A插入通话ID-123 线程B插入通话ID-1232.2 关键错误点非原子性检查检查存在性与插入操作不是原子操作无并发控制方法未加任何同步锁或并发控制错误假设认为定时任务单线程执行实际使用Scheduled默认线程池3. 解决方案设计与实现3.1 短期应急方案立即采取的止血措施// 使用数据库唯一索引 ALTER TABLE call_records ADD UNIQUE INDEX idx_call_id (call_id);同时编写数据修复脚本-- 保留第一条记录删除重复项 WITH duplicates AS ( SELECT call_id, ROW_NUMBER() OVER(PARTITION BY call_id ORDER BY create_time) AS rn FROM call_records ) DELETE FROM call_records WHERE id IN ( SELECT id FROM duplicates WHERE rn 1 );3.2 长期架构方案3.2.1 数据库层面保持唯一索引作为最后防线对关键表添加版本号字段实现乐观锁3.2.2 应用层方案方案对比方案实现方式优点缺点悲观锁synchronized或ReentrantLock实现简单性能影响大乐观锁版本号或CAS性能好需要重试机制分布式锁Redis或Zookeeper适合分布式系统复杂度高最终选择// 使用Guava的LoadingCache实现内存锁 private static final LoadingCacheString, Lock callIdLocks CacheBuilder.newBuilder() .weakValues() .build(new CacheLoaderString, Lock() { Override public Lock load(String key) { return new ReentrantLock(); } }); public void syncCallRecord(CallRecord record) { Lock lock callIdLocks.get(record.getCallId()); try { lock.lock(); // 核心业务逻辑 } finally { lock.unlock(); } }4. 深度防御体系构建4.1 防御层级设计前端防御批量操作添加进度提示和防重提交网关层对相同请求参数添加短时间缓存服务层方法级同步控制参数校验和幂等设计数据层唯一约束审计日志4.2 幂等性设计模板通用幂等处理方案Idempotent(key #record.callId, expire 24h) public void processCallRecord(CallRecord record) { // 业务逻辑 }实现原理方法执行前检查Redis中是否存在key不存在则设置key并执行已存在则直接返回上次结果5. 监控与应急方案5.1 监控指标关键方法并发调用数监控数据库唯一约束违反告警重复数据比例监控通过定时扫描5.2 应急预案当监控到重复数据时的处理流程立即停止相关服务根据严重程度选择低影响记录日志后续修复高影响自动触发数据修复脚本问题修复后人工确认再恢复服务6. 经验总结与避坑指南6.1 并发编程黄金法则所有共享资源访问必须考虑并发不要依赖调用方的单线程承诺防御式编程总是假设方法会被并发调用6.2 常见并发陷阱清单Spring的Async和Scheduled默认使用SimpleAsyncTaskExecutor无限制创建线程建议配置自定义线程池双重检查锁定// 错误实现 if (instance null) { synchronized (lock) { if (instance null) { instance new Object(); } } }正确实现需要加volatile修饰ConcurrentHashMap的复合操作// 错误用法 if (!map.containsKey(key)) { map.put(key, value); }应该使用putIfAbsent6.3 代码审查要点在CR时特别关注所有对共享变量的操作所有IO操作和外部服务调用所有缓存相关操作使用FindBugs或SpotBugs扫描并发问题7. 压测验证方案7.1 测试场景设计使用JMeter模拟单接口多线程调用批量任务并发执行长时间运行的稳定性测试7.2 验证指标数据一致性100%无重复性能衰减控制在20%以内错误率0%测试脚本示例RepeatedTest(100) void testConcurrentSync() throws InterruptedException { CountDownLatch latch new CountDownLatch(10); for (int i 0; i 10; i) { new Thread(() - { service.syncCallRecord(testRecord); latch.countDown(); }).start(); } latch.await(); assertEquals(1, getRecordCount(testRecord.getCallId())); }8. 架构演进思考8.1 消息队列解耦引入RabbitMQ实现运营商API - 消息队列 - 消费者集群优点天然流量控制易于实现幂等消费者可水平扩展8.2 事件溯源模式采用事件存储架构所有变更作为事件持久化通过回放事件重建状态内置并发冲突解决机制实现示例public class CallRecord { private ListEvent changes new ArrayList(); public void apply(Event event) { validate(event); changes.add(event); } private void validate(Event newEvent) { // 检查与已有事件的冲突 } }这次事故给我的核心教训是在分布式系统中任何未经验证的并发假设都是潜在的定时炸弹。现在我们的代码审查清单第一条就是这个方法如果被100个线程同时调用会怎样
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。