文章目录
-
- 一、事故现场
- 二、背景
- 三、为什么必然出错
- 四、最反直觉的一点
- 五、服务端改不了,客户端怎么办
-
- [1. 加 try-catch(必须)](#1. 加 try-catch(必须))
- [2. 降低并发概率](#2. 降低并发概率)
- [3. 干脆不清空(最终选择)](#3. 干脆不清空(最终选择))
- 六、代价
- 七、几个值得记住的点
- 八、一句话
一、事故现场
程序弹出异常:
服务器无法处理请求。 ---> 集合已修改;可能无法执行枚举操作。
调用栈直指:
Dictionary.KeyCollection.Enumerator.MoveNext()
WebService.Service.setData(...)
翻译:遍历字典时,字典被人改了。
二、背景
- 两台上位机,程序一模一样,1 秒一次调同一个 WebService
- 服务端维护一个共享字典
dicData - 还有另一路程序也在往这个字典写数据
- 两台机器都要读同一个 key,读完后原本还会"清空"它
三、为什么必然出错
C# 的 Dictionary 内部有个 _version,任何改结构 的操作(Add / Remove / Clear)都会让它 +1。
foreach 遍历时,枚举器每次 MoveNext() 都检查版本号是否一致,不一致就抛 InvalidOperationException: 集合已修改。
所以只要:
- A 请求正在遍历
dicData - B 请求同时
Add/Remove - 时序重叠
就炸。两机 + 1 秒定时 + 另一路写,长期跑必然撞上。
四、最反直觉的一点
异常抛在"正在遍历"的那一方,但根源往往是"另一方在写"。
堆栈里看起来是你炸了,实际是别人改了版本号。所以光改客户端没用------你只是受害者。
五、服务端改不了,客户端怎么办
三条路:
1. 加 try-catch(必须)
原来 Tick 没捕获异常,服务端一抛就弹框崩溃。加上后:异常被兜住,下次 Tick 再试。
2. 降低并发概率
- 只在读到有效数据时才清空,减少一半调用
- 两机错开 Tick 相位,降低同时调用的概率
3. 干脆不清空(最终选择)
原逻辑是"读 → 立刻清空",目的是防两台重复执行。
但换个思路:本地记录上次状态,变了才处理,重复读到同一条也不会重复执行。
于是:
- 不再写服务端 → 不当冲突加害者
- 靠本地状态比对去重 → 没变化不动作
- 两台各维护本地状态 → 互不干扰
六、代价
不能 100% 去重。两台机器同一时刻首次读到同一条新数据时,可能各处理一次。
服务端改不了的前提下,只能把崩溃和重复的概率降到可接受范围。
七、几个值得记住的点
foreach遍历时,集合不能改结构- 改 value 不触发版本变化,
Add/Remove/Clear才触发 - 报错方往往不是凶手,并发写才是
- 共享字典 + 多客户端 = 必须有并发保护:加锁、
ConcurrentDictionary、或避开遍历用ContainsKey - 改不了服务端时,客户端要会兜住 + 去重 + 降频
- 有时候"不做某件事"(不清空)比做更聪明
八、一句话
两台机器抢一个共享字典,一个在遍历,一个在改结构,枚举器直接翻脸。
服务端改不了,就在客户端兜住、降频、错相、本地去重,最后干脆不清空。
问题没 100% 消失,但系统从"必崩"变成了"稳得住"。