ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JAVA多线程下并发竞态问题导致写表数据错乱的排查与修改

JAVA多线程下并发竞态问题导致写表数据错乱的排查与修改 **JAVA多线程下并发竞态问题导致写表数据错乱的排查与修改**FileTypeStrategyContext 无状态化改造 —— 并发竞态修复实战笔记一、问题背景与现象二、根因定位三、修改前问题代码四、修改后无状态化改造零侵入五、修改前后对比六、技术原理深入6.1 线程栈私有 vs 堆共享6.2 Java 内存模型JMM与数据竞争6.3 final 的真正作用安全发布 不可变性七、比喻黑板 vs 便签八、改动后的优势九、测试验证十、附相邻的并发隐患修复GatewayRecordService.realTimeDatas十一、经验总结FileTypeStrategyContext 无状态化改造 —— 并发竞态修复实战笔记本文记录一次真实生产环境 Bug 的定位与修复全过程SSCJ出井信息文件概率性被写入realtime_value实时表根因是策略分发类的共享可变状态导致的并发竞态。本文同时从 Java 内存模型层面剖析了无状态化改造的原理并附上生动比喻便于理解。一、问题背景与现象在煤矿人员定位数据采集平台中系统按文件类型SSCJ 出井 / SSWZ 位置 / SSFZ 分站 / RYXX 人员…将数据路由到不同的处理器写入不同的 MongoDB 表文件类型处理器正常写入表SSCJ出井信息GatewayRecordServicegatewayRecordSSWZ实时位置RealTimeServicerealtime_valueSSFZ分站信息RealTimeSubstationServicerealtime_value_substation现象realtime_value表中偶发出现fileType SSCJ的记录——SSCJ 数据被写进了实时位置表。问题是概率性的高并发时出现的概率更高难以稳定复现。二、根因定位调用链BaseReceiverAbstractService.dealContent// 分发入口两步非原子操作context.getStrategy(bean.getFileType()).save(list,bean.getCoalMineId(),bean.getFileType(),bean.getFileTime());而FileTypeStrategyContext是 Spring 单例Component内部用共享可变字段保存当前文件类型对应的处理器。竞态过程线程A: getStrategy(SSCJ) → strategy GatewayRecordService写 gatewayRecord 线程B: getStrategy(SSWZ) → strategy RealTimeService ← 覆盖了 A 写入的值 线程A: save(...) → 实际调用 RealTimeService.excuteSave(SSCJ数据) → SSCJ 数据被写入 gcpps.realtime_value 表 ✅ 复现故障getStrategy写共享字段与save读共享字段两步之间没有同步多线程并发时互相覆盖导致数据被错误处理器执行。三、修改前问题代码ComponentSlf4jpublicclassFileTypeStrategyContext{// ① 共享可变字段保存当前文件类型对应的处理器privateIFileTypeStrategystrategynull;privateIMapFileTypeStrategymapStrategynull;privateIPicFileTypeStrategypicStrategynull;// ② getStrategy写共享字段返回 thispublicFileTypeStrategyContextgetStrategy(StringfileType){if(Constants.FileName.txtList.contains(fileType)){this.strategy(IFileTypeStrategy)...getBean(fileType);// 写 strategy}elseif(Constants.FileName.shpList.contains(fileType)){this.mapStrategy(IMapFileTypeStrategy)...getBean(fileType);// 写 mapStrategy}else{fileTypefileType.substring(0,3);this.picStrategy(IPicFileTypeStrategy)...getBean(fileType);// 写 picStrategy}returnthis;// 返回 this供链式 .save()}// ③ save读共享字段执行含懒加载兜底publicbooleansave(ListDocumentlist,StringmineName,StringfileType,StringfileTime){if(nullstrategy){this.getStrategy(fileType);// 懒加载依赖上一次调用的残留状态}returnstrategy.excuteSave(list,mineName,fileType,fileTime);}// saveImag / saveShp 同理}问题点三个共享可变字段是堆上的单例字段所有线程读写同一块内存getStrategy与save是两步非原子操作中间窗口期可被其他线程覆盖懒加载if (null strategy)在多线程首次并发时会加剧混乱四、修改后无状态化改造零侵入ComponentSlf4jpublicclassFileTypeStrategyContext{// ① 无任何共享字段// ② getStrategy纯函数每次返回携带本次策略的调用器无副作用publicStrategyInvokergetStrategy(StringfileType){if(Constants.FileName.txtList.contains(fileType)){IFileTypeStrategystrategy(IFileTypeStrategy)...getBean(fileType);returnStrategyInvoker.forTxt(strategy);// 局部变量 → 封装进新对象返回}elseif(Constants.FileName.shpList.contains(fileType)){returnStrategyInvoker.forMap((IMapFileTypeStrategy)...getBean(fileType));}else{StringpicTypefileType.substring(0,3);returnStrategyInvoker.forPic((IPicFileTypeStrategy)...getBean(picType));}}// ③ 调用器不可变持有本次解析出的策略方法局部、线程隔离publicstaticclassStrategyInvoker{privatefinalIFileTypeStrategytxtStrategy;// final构造后不可变privatefinalIMapFileTypeStrategymapStrategy;privatefinalIPicFileTypeStrategypicStrategy;publicbooleansave(ListDocumentlist,StringmineName,StringfileType,StringfileTime){returntxtStrategy.excuteSave(list,mineName,fileType,fileTime);// 直接用本次策略}// saveImag / saveShp 同理}}生产调用方零改动context.getStrategy(x).save(...)调用链形态完全不变仅需重新编译即可上线。五、修改前后对比维度修改前修改后状态存储3 个共享可变字段strategy/mapStrategy/picStrategy无字段策略在StrategyInvoker局部对象中getStrategy返回值this写入共享字段后返回自身新的StrategyInvoker携带本次策略调用形态context.getStrategy(x).save(...)context.getStrategy(x).save(...)完全不变线程安全❌ 竞态多线程互相覆盖共享字段✅ 天然安全局部对象栈隔离无数据竞争懒加载if (null strategy)有依赖上一次调用的残留状态无逻辑完全确定字段可变性可变谁都能改final构造后不可变并发开销无锁但数据竞争读到值不确定无锁、无竞争、无死锁风险六、技术原理深入6.1 线程栈私有 vs 堆共享Java 内存中数据存放位置决定了谁看得到存放位置归属典型内容堆Heap所有线程共享实例字段、静态字段、对象本体线程栈Stack每个线程私有方法的局部变量、参数、临时返回值修改前—— 共享的可变字段堆上单例 FileTypeStrategyContext 对象 ┌───────────────────────────────────┐ │ strategy 字段 ──→ 一块共享内存地址 │ └───────────────────────────────────┘ ▲ 写 ▲ 写 ▲ 读 线程A(GatewayRecord) 线程B(RealTime) 线程A(save) → B 把 A 刚写的值覆盖 → A 读到了 B 的值 → 错配this.strategy是实例字段在堆上所有线程读写的是同一块内存地址。线程 A 写完后线程 B 的写入覆盖了同一地址A 回头读时拿到的是 B 的值。这就是竞态的本质多线程写同一内存位置且无同步。修改后—— 每次独立的局部对象线程A的栈帧: context.getStrategy(SSCJ) └→ new StrategyInvoker(引用) ← 引用只存在于A的栈 线程B的栈帧: context.getStrategy(SSWZ) └→ new StrategyInvoker(引用) ← 引用只存在于B的栈 两个对象在堆上但互不相干各自持有自己的策略getStrategy每次执行都会new StrategyInvoker(...)返回值作为临时局部对象引用存放在当前线程自己的栈帧里随.save()调用链直接消费。线程 A 的 invoker 引用只存在 A 的栈/寄存器中线程 B 物理上无法访问它。→ 没有共享的内存位置 →数据竞争在结构上不存在→ 不需要加锁、不需要volatile。6.2 Java 内存模型JMM与数据竞争修改前是典型的数据竞争Data Race两个线程对同一内存位置this.strategy并发访问至少一个是写且无 happens-before 关系。JMM 下读到的值不确定可能是线程 A 写的值可能是线程 B 写的值覆盖甚至可能因 CPU 缓存 / 指令重排序读到过期或部分值修改后没有共享的可写内存位置每个线程只读自己栈上的局部变量 →数据竞争在结构上不存在无需synchronized/volatile。6.3final的真正作用安全发布 不可变性需要澄清真正防覆盖的不是final而是无共享 局部实例。final是辅助负责两点不可变性策略引用构造后不可变杜绝对象发布后再被修改的可能。JMM 的 final 语义安全发布final字段在构造函数完成后即冻结任何线程只要拿到该对象的引用必然能看到 final 字段的正确值无需同步。这消除了普通字段在无同步时可能被其他线程看到半构造/过期值的隐患。一句话final保证这个对象一旦给我内容就是准的而不会串到别人那去靠的是无共享的局部实例。七、比喻黑板 vs 便签修改前共享字段全班共用一个黑板。同学 A 写上处理 SSCJ同学 B 擦掉改成处理 SSWZA 回来一看黑板——处理 SSWZ→ 用错了处理器。修改后局部对象每人发一张自己的便签StrategyInvoker。A 的便签写 SSCJB 的便签写 SSWZ各拿各的永远不冲突。final相当于便签写完就塑封保证内容不会中途被改。八、改动后的优势根治竞态核心收益从共享可变状态 两步非原子操作变为每次调用独立解析、随调用链传递。没有共享可变字段就不存在数据竞争和可见性问题SSCJ 数据绝不可能再被 SSWZ 处理器写入gcpps.realtime_value。零侵入、行为兼容生产调用方三处调用链一字不改仅需重新编译即可上线风险最小。顺带消除懒加载隐患原if (null strategy)在多线程首次并发时会让多个线程同时进入getStrategy加剧覆盖。改造后该分支消失行为完全确定。无需锁、无性能开销、无死锁风险相比加锁方案不引入任何同步原语高并发下吞吐不受影响。更符合函数式/不可变设计原则getStrategy成为纯函数相同输入必然相同输出、无副作用、可重入StrategyInvoker不可变代码更易推理和测试。九、测试验证通过并发回归测试验证竞态已解决// 双线程各 2000 次交替处理 SSCJ / SSWZ检测处理器与文件类型错配// 修复后必然通过、不 flakyTestpublicvoidshouldNeverRouteSSCJToRealTimeTable_underConcurrency()throwsException{AtomicBooleanmismatchnewAtomicBoolean(false);// ... 在 mock 的 excuteSave 中记录 fileType与处理器类型比对 ...ThreadsscjThreadnewThread(()-{/* 循环 2000 次 getStrategy(SSCJ).save(...) */});ThreadsswzThreadnewThread(()-{/* 循环 2000 次 getStrategy(SSWZ).save(...) */});// 同时起跑、join ...assertFalse(并发下出现文件类型与处理器错配,mismatch.get());}运行结果Tests run: 2, Failures: 0, Errors: 0BUILD SUCCESS。十、附相邻的并发隐患修复GatewayRecordService.realTimeDatas排查过程中还发现一处相邻并发隐患一并修复问题realTimeDatas使用非线程安全HashMapSSCJ 处理线程并发put定时任务线程每 20 分钟迭代remove高并发下可能丢数据、偶发ConcurrentModificationException。修复// ① HashMap → ConcurrentHashMap保证 put/迭代安全privateMapString,DocumentrealTimeDatasnewConcurrentHashMap();// ② 迭代删除改为条件删除避免迭代期间新写入的同 key 记录被误删while(iter.hasNext()){Map.EntryString,Documententryiter.next();if(this.realTimeDatas.remove(entry.getKey(),entry.getValue())){// 值未被覆盖才删除datas.add(entry.getValue());}}ConcurrentHashMap.remove(key, value)是原子条件删除CAS仅当值还是原对象时才删除若期间被并发覆盖则新值保留待下次处理数据不丢失。十一、经验总结单例对象要警惕共享可变状态Spring 单例 Bean 若含可变字段且被多线程访问就是竞态温床。两步非原子调用是竞态窗口a.doX().doY()中间任何时刻都可能被其他线程打断除非doX().doY()整体无共享状态。无状态化 加锁加锁只保证互斥仍有性能与死锁成本无状态化让并发问题在结构上不存在是最彻底的解法。final 局部对象 双保险无共享保证不会串不可变保证串了也改不动这里根本没有共享。概率性 Bug 用并发回归测试把真实调度交错展开为确定性时序或并发压力循环可稳定复现/验证。本文基于真实项目修复记录整理供学习交流。
返回列表