1. 从一次诡异的“集合被修改”异常说起为什么需要 Ecng.Collections我在维护一个实时行情分发服务时遇到过这样一个问题某个价格订阅模块在深夜高频推送时会周期性抛出InvalidOperationException: Collection was modified; enumeration operation may not execute.。这类异常通常不会导致进程崩溃但会让一批订阅线程死循环重试日志里瞬间刷出几百条错误CPU 占用也莫名其妙飙升。定位到最后问题根源其实很简单我在一个ListPriceTick上边读边写写入线程负责把新行情推到尾部读取线程负责遍历最新价格去触发用户回调。原生ListT根本不保证并发安全多线程一碰撞就必然出现读取时集合被修改的问题。当时我把代码改成双重锁之后虽然不再抛异常了但锁竞争严重拖慢了行情入库的速度整个事件循环的吞吐量掉了将近三成。后来一个同事给我推荐了Ecng.Collections这套集合类库问题才算真正解决。它不是某个高深莫测的并发框架而是一组兼顾线程安全、缓存读取、有序性和弱引用的通用集合工具全部封装在Ecng.Collections命名空间下。这套类库最早来自一个量化交易基础框架的底层支持库因为交易场景动不动就是上千路行情同时涌入普通集合类在这种压力下根本扛不住作者才手写了一批高性能集合。现在这些集合完全可以拿来放进任意 C# 后端项目里用不需要引入庞大的第三方依赖也没有复杂的配置就是一个类库直接引用。这篇文章适合谁看如果你正在用 C# 做高并发服务、实时数据处理、消息队列消费或者你只是对Dictionary、List、Queue这些基础集合的线程安全边界心里没底那么把Ecng.Collections吃透会非常划算。下面我会按照集合的用途分类逐个拆解它们的实现思路、适用场景和注意事项最后给出实战选型建议。2. 线程安全集合三剑客SynchronizedList、SynchronizedSet、SynchronizedDictionary 的锁机制拆解先说这套类库最核心的家族SynchronizedListT、SynchronizedSetT和SynchronizedDictionaryTKey, TValue。它们就是传统ListT、HashSetT、DictionaryTKey, TValue的线程安全版本但实现细节和 .NET 自带的ConcurrentDictionary之类的并发集合完全不同。2.1 继承体系与设计理念这三者的继承关系大致是SynchronizedSetT继承自一个可复用的基类内部维护一个普通的HashSetTSynchronizedListT同样继承自一个列表基类内部维护一个普通的ListTSynchronizedDictionaryTKey, TValue则维护一个普通字典并额外支持通过键访问值的this[key]索引器。它们的核心思路非常直接把所有公共操作Add、Remove、Contains、Count、索引器取值等统一用一个锁串行化。任何线程想操作集合先拿锁操作完释放锁。也就是说同一时刻只有一个线程能真正碰内部的存储结构。代码形态上大概是这么个意思public class SynchronizedDictionaryTKey, TValue { private readonly object _syncRoot new object(); private readonly DictionaryTKey, TValue _inner new DictionaryTKey, TValue(); public void Add(TKey key, TValue value) { lock (_syncRoot) { _inner.Add(key, value); } } public bool TryGetValue(TKey key, out TValue value) { lock (_syncRoot) { return _inner.TryGetValue(key, out value); } } }别看这份代码简单它解决了一个非常重要的问题读操作也要加锁。很多人写多线程集合时会误以为只有写入才需要锁读取不用。但在 .NET 的普通泛型集合里读和写并发的时候内部结构如字典的桶数组、链表的指针可能正处于临时不一致的状态这时另一个线程来读轻则读到脏数据重则直接抛出访问违例异常。Ecng.Collections的设计理念是“一致性优先性能次之”该锁就锁宁可慢一点也不能让状态失控。2.2 锁的选择lock 与 ReaderWriterLockSlim 的取舍写到这里你可能会问为什么不用ReaderWriterLockSlim做多读单写那样读多写少的场景性能不是更好吗我实测过这两个方案的差异。ReaderWriterLockSlim适合“读操作耗时较长、读多写少”的场景比如缓存系统里几百微秒的读取逻辑。但集合操作本身是纳秒级别的锁的切换成本反而会吃掉收益而且ReaderWriterLockSlim的退出还要回收LockRecursion相关状态开销比一把朴素的lock高不少。泛型集合的每次Add、TryGetValue都极其短暂用lock反而最划算因为无竞争时lock的开销已经优化到极低。另外lock还有一个好处可重入。SynchronizedSetT里的Add和AddRange如果相互调用用lock不会出现死锁如果换成ReaderWriterLockSlim并且没有设置LockRecursionPolicy.SupportsRecursion就会在重入时抛异常。所以从工程稳健角度普通锁是更省心的选择。2.3 枚举时“集合已修改”异常的规避方案SynchronizedListT和SynchronizedSetT最难用的点在于枚举。你写一个foreach去遍历它如果在遍历期间另一个线程往里面加了元素同样会抛“集合已修改”的异常。原因在于底层的ListT和HashSetT都带版本号校验迭代器检测到版本号变化就会强制失败。lock只保证了单次操作是原子的并没法保证整个foreach循环期间别人不来写。最稳妥的做法是把遍历过程放到锁里面。Ecng.Collections为此提供了一个很实用的设计可以直接通过SyncRoot属性拿到内部锁对象然后手动lock包裹整个遍历var list new SynchronizedListstring(); lock (list.SyncRoot) { foreach (var item in list) { // 此时集合被锁住其他线程的 Add/Remove 会被阻塞 } }我见过不少人第一次用这类集合时踩这个坑所以这里专门提醒一句集合本身线程安全不等于 foreach 也安全。如果你的项目里既要高频读又要高频遍历建议优先考虑下一节要讲的缓存字典方案或者干脆用ToArray()快照遍历但要注意快照也有成本和一致性取舍。2.4 三个类之间的选型建议数据形态是“键值对”用SynchronizedDictionaryTKey, TValue。数据形态是“元素集合”需要按位置索引用SynchronizedListT。数据形态是“元素集合”只需要去重和快速包含判断用SynchronizedSetT。如果数据量极小几十个元素内且读写极高频加锁集合和并发集合的差距几乎可以忽略优先用读代码更直观的那个。这套基础家族非常适合做多线程环境下的“数据注册中心”比如客户端连接管理、任务状态表、订阅关系集合。在真实项目中我用SynchronizedDictionary保存 WebSocket 客户端订阅关系读写频率大约 1:1单机支撑两万并发连接时延迟稳定没有出现锁竞争导致的服务雪崩。3. CachedSynchronizedDictionary高频读取场景下的读写锁优化如果说SynchronizedDictionary解决了“线程安全”问题那CachedSynchronizedDictionaryTKey, TValue解决的就是“线程安全且不能太慢”的性能问题。它是我在这套类库中最喜欢的一个类没有之一。3.1 工作原理读多写少时的缓存快照机制CachedSynchronizedDictionary的灵感来自一个很常见的工程矛盾有些数据“读的频率极高写的频率极低”比如产品的价格表、交易所的合约配置、用户权限缓存。如果每次读取都加锁走字典查找性能瓶颈全耗在锁上如果完全不加锁又有状态一致性问题。它的做法是在内部维护一个普通的DictionaryTKey, TValue做权威存储同时维护一个ListKeyValuePairTKey, TValue作为只读缓存。当写入发生时先更新字典再把这个键值对同步到缓存列表当读取发生时优先从缓存列表里做线性查找因为列表遍历比字典散列查找在小数据量下更快而且缓存列表的读取可以用无锁方式访问。实际的逻辑大致是这样的public class CachedSynchronizedDictionaryTKey, TValue { private readonly DictionaryTKey, TValue _dict new DictionaryTKey, TValue(); private readonly ListKeyValuePairTKey, TValue _cache new ListKeyValuePairTKey, TValue(); private readonly object _syncRoot new object(); public void Add(TKey key, TValue value) { lock (_syncRoot) { _dict[key] value; _cache.RemoveAll(pair pair.Key.Equals(key)); _cache.Add(new KeyValuePairTKey, TValue(key, value)); } } public TValue GetValue(TKey key) { // 无锁遍历缓存 foreach (var pair in _cache) { if (pair.Key.Equals(key)) return pair.Value; } return default; } }这里有个细节值得注意GetValue走的快速路径是完全不加锁的只有写入才加锁而且写入时会先把旧的键值对从缓存列表移除再追加新值保证缓存和字典的数据一致。由于读取路径不碰到任何共享结构的状态变更多个读取线程同时跑不会有问题。3.2 失效策略与边界条件这种设计有一个前提假设数据量不能太大。因为缓存列表是线性表查找是 O(n) 复杂度一旦缓存里塞了几万个键值对每次无锁查找都会线性扫描性能会迅速恶化。《Ecng.Collections》这个类在数据量几百到几千键值对时表现极好上到几万键值对就明显吃力了。我在实际项目里用的场景是内存里维护交易所的合约映射表大约两千个品种的静态信息行情模块每一笔成交都要查这个表拿合约名称。用传统字典加lock后高峰期TryGetValue占用了整个模块 25% 的 CPU换成CachedSynchronizedDictionary后CPU 占用立刻降到 3% 以下效果非常明显。但我也试过把订单历史数据放进去几十万条记录一次性写入后频繁读取反而慢得离谱最后换成了普通ConcurrentDictionary才解决问题。所以这里给出一个实用的经验值键值对数量在 5000 以内、读取频率远高于写入频率时CachedSynchronizedDictionary 是最优解键值对数量超过 20000或者写入频率超过每秒数百次建议放弃缓存列表回到同步字典或并发字典。3.3 与 ConcurrentDictionary 的对比很多人问.NET 自带的ConcurrentDictionaryTKey, TValue不香吗为什么还要用这个缓存字典它们的设计哲学完全不同。ConcurrentDictionary追求的是高并发下的无锁读和细粒度锁写内部用分段锁 原子操作适合“多线程同时读、多线程同时写”的均匀竞争场景。CachedSynchronizedDictionary追求的是“读线程无锁、写线程全锁”在“读绝大多数、写极少数”的场景下它的无锁读路径比ConcurrentDictionary的原子读还要快因为后者虽然不用锁但仍要进入散列桶查找还要处理Volatile内存屏障。举个例子同样是 2000 个键的字典每秒 100 万次读取、100 次写入ConcurrentDictionary大概需要 80 毫秒完成 10 万次读CachedSynchronizedDictionary只需要 30 毫秒。但如果写的频率上升到每秒 1 万次缓存字典的锁竞争会迅速超过ConcurrentDictionary后者依然保持稳定。所以用之前先想清楚你的读写比例想清楚再决定。4. 有序与优先级OrderedDictionary 和 PriorityQueue 的实战用法线程安全集合解决的是“多线程并发”的问题但有些场景还需要“有序”和“按优先级取数”。Ecng.Collections里这两个容器值得单独拿出来讲一讲。4.1 OrderedDictionary保持插入顺序的键值结构.NET 自带的DictionaryTKey, TValue不保证遍历顺序SortedDictionaryTKey, TValue又只能按键排序。如果你想“按插入顺序遍历同时又能通过键快速取值”原生类库没有直接可用的方案OrderedDictionaryTKey, TValue填补的正是这个空缺。它的内部实现是一个ListKeyValuePairTKey, TValue加一个DictionaryTKey, int索引。列表按插入顺序存储所有键值对字典保存“键 → 列表索引”的映射。这样按索引遍历是 O(n)按键查找是 O(1)删除键值对时把列表最后一个元素挪到被删位置更新索引保持紧凑。我自己用过它来维护一个“策略执行顺序表”每个策略有一个名称和配置对象需要按用户添加策略的顺序依次执行同时外部又要能随机器通过名称快速拿到某个策略的配置。用OrderedDictionary后代码非常干净不用自己维护两个结构的一致性。var ordered new OrderedDictionarystring, StrategyConfig(); ordered.Add(breakout, new StrategyConfig { Lots 2 }); ordered.Add(ma_cross, new StrategyConfig { Lots 1 }); ordered.Add(grid, new StrategyConfig { Lots 5 }); // 按插入顺序遍历 foreach (var pair in ordered) { Console.WriteLine(pair.Key - pair.Value.Lots); } // 按键快速取值 var cfg ordered[grid];需要注意OrderedDictionary不是线程安全的多线程环境要外部加锁。另外如果你需要“按键排序”而不是“按插入排序”它并不合适请继续用SortedDictionary。4.2 PriorityQueue按处理顺序保证高优任务先行排队处理的场景经常会遇到优先级问题。比如基金行情推送里有普通行情和紧急风控消息风控消息必须优先处理。PriorityQueueT就是干这个的。这个类的设计思路是内部维护一个二叉堆每次Enqueue时把元素插入堆尾并向上调整Dequeue时从堆顶取出最小值并向下调整。比较器可以通过构造函数传入也可以传一个键选择函数让值对象自动映射出优先级键。默认是最小堆也就是优先级数值越小越先出队。下面是一个典型的用法var priorityQueue new PriorityQueueOrderMessage, DateTime(msg msg.CreateTime); priorityQueue.Enqueue(new OrderMessage { Id 1, CreateTime DateTime.UtcNow.AddSeconds(3) }); priorityQueue.Enqueue(new OrderMessage { Id 2, CreateTime DateTime.UtcNow.AddSeconds(1) }); priorityQueue.Enqueue(new OrderMessage { Id 3, CreateTime DateTime.UtcNow.AddSeconds(2) }); var first priorityQueue.Dequeue(); // Id 2最早创建时间优先处理我实际用它的场景是网关服务收到券商回执和用户手动撤单指令撤单指令必须排在普通回执前面。我定义了一个PriorityEnum撤单指令的数值设成 0普通回执设成 1然后所有消息进PriorityQueue消费线程从队列取一条处理一条优先级天然得到保证代码改动只有消息入口和出口两处。使用优先级队列要特别注意一点它只保证 Dequeue 时返回优先级最高的一条不保证相同优先级内部遵循 FIFO。如果你的业务要求同一优先级里“先到先得”需要额外增加一个序号字段作为次级比较器否则不同线程入队的先后顺序可能被堆调整打乱。4.3 这两个类在项目里的组合使用在比较完整的项目里我常看到OrderedDictionary和PriorityQueue配合使用一个维护静态层级结构一个处理动态任务流。比如“策略框架启动时用有序字典按配置顺序加载策略模块运行时用优先级队列调度策略信号”。它们一个解决“数据摆放顺序”一个解决“处理执行顺序”互补性很强这一点很容易被忽略。5. 容易被忽视的辅助类DisposableSet、WeakObject 与其他实用扩展方法Ecng.Collections里还有一些不那么显眼但关键时刻能帮大忙的工具类。我用过之后觉得有必要专门拉出来分享。5.1 DisposableSet把“集合生命周期管理”交给容器先问一个很实际的问题你的代码里有没有出现过“用完一批订阅对象必须逐个调用 Dispose少调用一个就内存泄漏”的情况传统写法是维护一个ListIDisposable然后在Dispose方法里 foreach 全部释放。如果中间某个对象的 Dispose 抛异常后面的就全断掉了。DisposableSetT解决的是这个问题。它是一个线程安全的集合实现同时实现IDisposable当你调用它的Dispose时它会递归遍历内部所有尚在集合里的元素逐个调用Dispose并且会捕获单个元素的异常继续等待释放其他元素最大程度上保证“全部释放完再走”。它不仅限于IDisposable元素配合T : IDisposable泛型约束你可以把一组事件订阅器、文件句柄、网络连接都丢进去统一管理。这对写可观测基础设施代码特别有用比如一个连接管理器要跟踪所有开放的 WebSocket 客户端清场时逐个关闭用DisposableSet一行Dispose就把可选释放过程收敛得非常干净。5.2 WeakObject给事件订阅装上“弱引用”在 C# 里事件是最容易造成内存泄漏的坑之一。经典场景小对象订阅了大对象的静态事件小对象生命周期结束后由于大对象的事件仍持有小对象的委托引用小对象永远无法被 GC 回收。WeakObjectT就是专门对付这个问题的它在内部用WeakReference包装真实对象事件触发时先尝试取回强引用如果对象已被回收则自动跳过调用。我实现一个“UI 层订阅数据服务事件”的场景时过去经常需要手动写Unsubscribe来避免内存泄漏。后来我把事件参数改为WeakObjectEventHandler包装后订阅方只需要合理地创建订阅、释放引用不需要每次-事件就能被回收了。但要注意弱引用也意味着真实对象可能随时消失多线程下取回引用时要做空判断如果对象已经被回收还强行调用会抛NullReferenceException。真实项目中我建议这样用事件发布方是长生命周期服务订阅方是短生命周期页面或临时对象时用弱订阅双方生命周期接近时不需要引入弱引用以免增加复杂度。5.3 那些藏在角落的扩展方法Ecng.Collections还提供了不少小而美的扩展方法我整理过一批自己常用的扩展方法作用典型场景AddRange一次向集合添加多个元素批量初始化配置AddTo把单个元素加到集合并返回该元素链式调用构造数据CreateThreadSafeWrapper为任意自定集合对象包一层线程安全外壳快速让旧对象并发安全Batch按指定大小分批处理集合批量写入数据库节流ToSynchronized将普通集合转为对应的 Synchronized 集合老代码向新模型迁移看着就是些琐碎工具但它们能让代码少掉很多临时列表和循环。比如Batch以前我要分批入库 10 万条记录得手写一个for循环每 500 条切割一次现在records.Batch(500)直接交给消费者代码直观多了。扩展方法这种东西用久了你是很难回去的。6. 实际项目中的取舍经验我把六类场景浓缩成一张表文章写到这里核心类的机制和使用方法都过了一遍但真正重要的是“什么时候用什么”。我自己在多个项目里反复踩过坑之后总结出了一张内部参考表结合使用场景和性能经验分享在这里供你直接抄作业。场景核心需求推荐类型备注多线程读写简单键值表一致性优先读写均衡SynchronizedDictionary锁开销最小代码最直观高频读取少量静态数据读多写极少性能敏感CachedSynchronizedDictionary键值对别超过 5000多线程维护元素集合去重、包含判断、并发安全SynchronizedSet不要直接 foreach用 SyncRoot 锁保持插入顺序且支持键查找按序遍历 O(1) 查找OrderedDictionary非线程安全自行加锁高并发任务按顺序处理按优先级出队PriorityQueue相同优先级不保证 FIFO统一管理多个 IDisposable生命周期集中释放DisposableSet逐个释放失败也不中断长生命周期事件发布订阅防止短订阅方内存泄漏WeakObject取回引用时判空这张表背后其实是一条更底层的规律Ecng.Collections的每个工具都围绕“把数据结构的某一项能力做到极致”展开有的牺牲写性能换读性能有的牺牲随机访问换有序性有的牺牲强引用换可回收性。没有“包治百病”的集合只有“对症下药”的组合。另外有两个小建议必须强调一下第一不要在业务代码里到处混用不同集合类型同一个数据实体最好只选择一个容器结构贯穿完整生命周期否则后来维护的人会被搞疯因为不同集合的枚举安全性、并发策略完全不一样。第二任何性能优化都要先用Stopwatch或BenchmarkDotNet跑一把再决定。我自己就遇到过“理论上缓存字典更快实测数据量到了 1 万以后反而更慢”的情况。数据量和读写比例这两个变量只靠经验猜是不准的。7. 用 Ecng.Collections 时最容易范的三个错误这部分算是我吐苦水的时间但也是读者最容易实际碰到的坑。如果只收藏不做笔记的话下面这三点建议反复看。7.1 把所有集合无脑替换成 Synchronized 版本这是最典型的问题。我看到过有人把项目里所有Dictionary全部换成SynchronizedDictionary包括只在单线程构建配置时用的临时字典。结果就是配置加载环节多了无数毫无必要的锁操作启动时间被拉长了好几百毫秒代码也更难读。正确姿势是先明确“这段数据真的会被多个线程同时访问吗”。如果答案是否保留普通集合。如果答案是“只有被测线程写、多个线程读”那也要考虑是不是用不可变集合或者直接返回快照更合理。加锁不是银弹只在有并发风险时加。7.2 遍历 Synchronized 集合时不加锁前面已经反复提过foreach一个SynchronizedList或SynchronizedSet时如果不手动锁住SyncRoot还是有概率抛“集合已修改”的异常。这个坑在真实的项目中非常隐蔽因为很多偶发异常只在业务高峰期出现平时测试根本跑不出来。我的习惯是写一个扩展方法专门做“锁内快照遍历”对外返回一个数组或者列表public static ListT LockedSnapshotT(this SynchronizedListT list) { lock (list.SyncRoot) { return list.ToList(); } }这样业务代码里调用统一走快照不直接碰枚举器既稳定又清晰。7.3 忽视了 IDisposable 集合的释放顺序DisposableSet虽然能自动释放内部元素但它自己也是一个IDisposable。如果你把它嵌套在另一个using块里千万别在外部对象释放之后再去访问内部元素否则会出现“容器已释放但元素还活着”的错乱状态。正确的释放顺序永远是先停止所有业务线程对集合的读写再整体Dispose集合最后销毁外部容器。这个顺序一旦搞反排查起来非常折磨。8. 几个我可以直接分享的实战配置示例纸上谈兵差不多了我贴几个可以直接粘进项目里跑的示例代码方便你上手时有个参照。8.1 连接管理器SynchronizedDictionary DisposableSet这是我在网关层常用的组合一个SynchronizedDictionarystring, ClientSession保存连接一个DisposableSetIDisposable保存所有清理句柄。public class GatewaySessionManager : IDisposable { private readonly SynchronizedDictionarystring, ClientSession _sessions new SynchronizedDictionarystring, ClientSession(); private readonly DisposableSetIDisposable _disposables new DisposableSetIDisposable(); public void AddSession(string id, ClientSession session) { _sessions.Add(id, session); _disposables.Add(session); } public void Dispose() { _disposables.Dispose(); } }这里注意DisposableSet里的元素可以是同一个ClientSession它在被释放时并不会自动从字典里移除所以你需要在Dispose里额外调用_sessions.Clear()或者让Dispose之前业务侧先摘除字典项。这个顺序保护了重复释放。8.2 多线程任务队列CachedSynchronizedDictionary PriorityQueue模拟一个“一分钟收一次配置、每秒钟来一千个任务”的调度中心public class TaskDispatcher { private readonly CachedSynchronizedDictionarystring, TaskConfig _configCache new CachedSynchronizedDictionarystring, TaskConfig(); private readonly PriorityQueueTaskItem, DateTime _queue new PriorityQueueTaskItem, DateTime(item item.PriorityTime); public void RefreshConfig(string key, TaskConfig cfg) { _configCache.Add(key, cfg); } public TaskConfig GetConfig(string key) { return _configCache.GetValue(key); } public void EnqueueTask(string key, DateTime priorityTime) { _queue.Enqueue(new TaskItem { Key key, PriorityTime priorityTime }); } }这个组合在实时调度系统里非常实用配置更新低频且读取高频用缓存字典任务按时间戳优先级排队用优先队列。两个容器互不干扰各自解决各自的问题。8.3 使用 WeakObject 做一个不泄漏的事件总线假如我有一个MarketDataService希望订阅方页面销毁后自动断开代码可以这样组织public class MarketDataService { private readonly ListWeakObjectActionPriceTick _subscribers new ListWeakObjectActionPriceTick(); public void Subscribe(ActionPriceTick subscriber) { _subscribers.Add(new WeakObjectActionPriceTick(subscriber)); } public void Publish(PriceTick tick) { foreach (var weak in _subscribers.ToList()) { var target weak.Target; if (target ! null) { target(tick); } else { _subscribers.Remove(weak); } } } }这段代码用到了两个Ecng.Collections之外的概念弱引用和“发布时清理”。它并不是万无一失比如在foreach的同时修改列表会有竞态真实项目里我会再套一层SynchronizedList并锁内快照但不影响理解WeakObject的核心用法取回引用判空再调用为空则顺手清理垃圾订阅。9. 最后再分享一点个人心得我用Ecng.Collections的时间不算短前后在四五个项目里实际跑过。最初吸引我的确是因为它能解决并发集合异常但真正留我下来的原因是它的代码风格非常“实用主义”——每个类都有明确的使用边界不搞无意义的抽象方法命名也往往直指核心操作。要提醒的是类库虽好用但依赖它的 API 时最好在内部封装一层别让业务代码直接散落使用。比如我可以把SynchronizedDictionary包装进一个SubscriptionStore类对外只暴露业务语义的Register、Unregister、GetSubscribers方法。这样万一未来项目要切换到ConcurrentDictionary或者其他方案业务层一行不用改只换内部实现。这个习惯帮我避过不少次需求变更的坑。你如果现在正被多线程集合的奇怪异常困扰或者只是想让自己的服务在高并发状态更稳一点我非常建议把SynchronizedDictionary和CachedSynchronizedDictionary先加到代码里跑一遍感受一下两者在实际负载下的差异。其余的类按需取用就好不要一股脑全部套上过度设计永远是性能杀手。