1. 多线程环境下普通集合为什么会"翻车"
很多人第一次写多线程代码时,都会有一个很自然的想法:List<T>、Dictionary<TKey, TValue>这些集合平时用得好好的,多开几个线程往里塞数据应该也没问题吧?结果一跑起来,要么数据莫名其妙少了几个,要么直接抛InvalidOperationException,要么程序卡死不动。这不是运气问题,而是这些集合从设计之初就没有考虑并发访问。
1.1 从一次真实的翻车现场说起
我之前做过一个日志聚合的小工具,主线程负责接收日志,另外开了四个工作线程分别往一个List<string>里追加日志条目。单线程测试的时候一切正常,一上多线程,跑个几万条就开始出问题。最典型的表现是:最终List里的条目数量比实际写入的少,而且少得毫无规律。有时候还会蹦出一个IndexOutOfRangeException,明明代码里没有任何越界访问。
后来把问题定位清楚了,根因就在List<T>的内部实现上。List<T>底层维护了一个数组_items和一个表示当前元素数量的字段_size。调用Add方法时,它做的事情大致是:先检查容量够不够,不够就扩容(分配新数组、复制旧数据),然后把新元素放到_items[_size]的位置,最后_size++。这一连串操作没有任何锁保护,两个线程同时执行时,可能都读到了相同的_size值,然后都往同一个位置写,其中一个元素就被覆盖了。扩容的时候更危险,一个线程正在复制数组,另一个线程还在往旧数组里写,数据直接丢失。
这就是所谓的竞态条件(Race Condition)。它不是偶发的 bug,而是必然会发生的问题,只是触发时机不确定而已。线程越多、数据量越大、操作越频繁,出问题的概率就越高。
1.2 线程安全到底在保证什么
要理解线程安全集合,先得搞清楚"线程安全"这三个字到底意味着什么。很多人以为线程安全就是"加了锁",这个理解太粗糙了。严格来说,一个集合被称为线程安全的,需要满足以下几个层面的保证:
原子性:单个操作(比如Add、Remove、TryGetValue)在执行过程中不会被其他线程打断。要么完全执行成功,要么完全没执行,不存在中间状态被其他线程看到的情况。
可见性:一个线程对集合的修改,其他线程能够及时看到。这涉及到内存屏障和 CPU 缓存一致性的问题。在没有同步机制的情况下,一个线程写入的值可能还停留在 CPU 缓存里,另一个线程读到的仍然是旧值。
复合操作的原子性:这是最容易被忽略的一点。即使单个Add和TryGetValue都是线程安全的,但"先检查再添加"这种复合操作仍然可能出问题。比如"如果 key 不存在就添加"这个逻辑,两个线程可能同时判断 key 不存在,然后都执行添加,导致重复添加或者覆盖。
注意:线程安全集合保证的是单个操作的原子性,复合操作仍然需要额外的同步手段。这一点在后面讲
ConcurrentDictionary的时候会详细展开。
1.3 几种常见的错误应对方式
面对多线程集合访问的问题,很多人的第一反应是"加个 lock 不就行了"。加锁确实能解决问题,但如果不理解其中的门道,很容易踩进新的坑。
最常见的错误做法是直接 lock 集合对象本身:
// 不推荐的做法 lock (myList) { myList.Add(item); }这样做的问题在于,如果其他地方也 lock 了同一个myList对象,或者集合内部某些操作也尝试获取同一个锁,就可能造成死锁。更安全的做法是使用一个专门的锁对象:
private readonly object _lockObj = new object(); lock (_lockObj) { myList.Add(item); }但即使这样,手动加锁也有几个明显的缺点。第一,每次访问都要记得加锁,漏掉一处就可能出问题。第二,锁的粒度不好控制,锁太粗会影响并发性能,锁太细又容易出逻辑错误。第三,读操作和写操作如果都用同一把锁,读多写少的场景下性能会非常差。
正是因为有这些痛点,.NET 才提供了一整套线程安全的集合类型,让我们不用从零开始处理这些复杂的同步逻辑。
2. Concurrent系列集合的选型逻辑与内部机制
.NET 在System.Collections.Concurrent命名空间下提供了一组线程安全集合,包括ConcurrentDictionary、ConcurrentQueue、ConcurrentStack、ConcurrentBag和BlockingCollection。这些集合不是简单地在普通集合外面套一层锁,而是采用了更精细的并发控制策略。
2.1 无锁编程与细粒度锁的取舍
ConcurrentQueue和ConcurrentStack内部使用的是无锁算法(Lock-Free),基于Interlocked.CompareExchange等原子操作实现。这种方式的优势在于,即使在高并发场景下,也不会出现线程被阻塞等待锁的情况,吞吐量更高。
ConcurrentDictionary则采用了细粒度锁的策略。它把整个字典分成多个桶(bucket),每个桶有自己的锁。当两个线程操作不同的桶时,它们可以完全并行,不会互相阻塞。只有当它们恰好操作同一个桶时,才需要等待。这种设计在大多数场景下比全局锁的性能好得多。
ConcurrentBag比较特殊,它是为"同一个线程既生产又消费"的场景优化的。内部为每个线程维护了一个本地队列,线程往 Bag 里添加元素时优先放到自己的本地队列,取元素时也优先从自己的本地队列取。只有在本地队列为空时,才会去"偷"其他线程的元素。这种设计极大地减少了线程间的竞争。
2.2 各集合的适用场景对照
选错了集合类型,不仅性能上不去,还可能引入难以察觉的 bug。下面这张表是我在实际项目中总结出来的选型参考:
| 集合类型 | 底层策略 | 最佳适用场景 | 不适用场景 |
|---|---|---|---|
ConcurrentDictionary | 细粒度锁 | 多线程共享的键值缓存、计数器 | 需要按顺序遍历的场景 |
ConcurrentQueue | 无锁 | 生产者-消费者、任务队列 | 需要随机访问中间元素 |
ConcurrentStack | 无锁 | 后进先出的任务处理、撤销操作 | 需要先进先出 |
ConcurrentBag | 线程本地存储 | 同一线程生产消费、对象池 | 跨线程严格顺序要求 |
BlockingCollection | 包装其他集合 | 有界队列、需要阻塞等待 | 不需要阻塞的简单场景 |
选型的时候,先问自己三个问题:数据的存取模式是什么(FIFO、LIFO、键值对、无序)?生产者和消费者是不是同一批线程?需不需要阻塞等待?把这三个问题回答清楚,选型基本就不会错。
2.3 什么时候不该用Concurrent集合
Concurrent 集合虽然好用,但并不是所有场景都适合。有几种情况我建议还是用普通集合加锁:
第一种是读远多于写,且写操作很少发生的场景。比如一个配置字典,启动时加载一次,之后几乎只读。这种情况下用ConcurrentDictionary反而增加了每次读操作的 overhead(虽然不大,但确实存在)。更好的做法是用Dictionary配合ReaderWriterLockSlim,或者干脆在初始化完成后就不再修改,用不可变集合。
第二种是需要多个操作作为一个整体原子执行的场景。比如"从队列取出一个元素,处理它,然后根据结果更新字典"这一整套逻辑,如果中间状态不能被其他线程看到,那用 Concurrent 集合反而更麻烦,因为你需要额外的手段来保证复合操作的原子性。
第三种是集合非常小且访问频率极低的场景。比如一个只有几个元素的列表,每秒访问几次。这种情况下加锁的开销可以忽略不计,用lock反而更简单直观。
3. ConcurrentDictionary的实战细节与常见误区
ConcurrentDictionary是日常开发中使用频率最高的线程安全集合,但也是误用最多的一个。很多人以为用了它就万事大吉,结果在复合操作上栽了跟头。
3.1 GetOrAdd和AddOrUpdate的原子性边界
ConcurrentDictionary提供了几个看起来很美好的方法:GetOrAdd、AddOrUpdate。从名字上看,它们似乎能原子地完成"获取或添加"、"添加或更新"的操作。但这里有一个非常关键的细节:值工厂委托可能被多次执行。
var dict = new ConcurrentDictionary<string, ExpensiveObject>(); // 危险:valueFactory可能被调用多次 var obj = dict.GetOrAdd("key", k => new ExpensiveObject(k));在并发环境下,如果两个线程同时调用GetOrAdd且 key 都不存在,两个线程可能都会执行valueFactory,然后其中一个的结果被丢弃。这意味着如果你的工厂方法有副作用(比如打开文件、发起网络请求、修改共享状态),就会出现问题。
正确的做法是确保工厂方法是幂等且无副作用的。如果确实需要保证只创建一次,可以用Lazy<T>来包装:
var dict = new ConcurrentDictionary<string, Lazy<ExpensiveObject>>(); var lazyObj = dict.GetOrAdd("key", k => new Lazy<ExpensiveObject>(() => new ExpensiveObject(k))); var obj = lazyObj.Value; // 只有这里才真正创建对象这样即使GetOrAdd创建了多个Lazy对象,最终只有一个Lazy.Value会被执行,ExpensiveObject只会被创建一次。
3.2 复合操作必须用AddOrUpdate或锁保护
再来看一个更隐蔽的坑。假设我们要实现一个计数器,统计每个 key 出现的次数:
// 错误做法:存在竞态条件 if (dict.ContainsKey(key)) { dict[key]++; } else { dict[key] = 1; }这段代码看起来逻辑没问题,但在多线程下完全不可靠。ContainsKey和后面的赋值之间,其他线程可能已经修改了字典。两个线程可能同时判断 key 不存在,然后都执行dict[key] = 1,最终计数少了。
正确的做法是使用AddOrUpdate:
dict.AddOrUpdate( key, addValue: 1, updateValueFactory: (k, oldValue) => oldValue + 1);AddOrUpdate内部会循环重试,直到成功为止。如果更新过程中发现值被其他线程改了,它会用新的旧值重新计算,保证最终结果正确。
但要注意,updateValueFactory同样可能被多次调用。所以它里面也不应该有副作用,只做纯粹的计算。
3.3 遍历ConcurrentDictionary时的一致性保证
ConcurrentDictionary的遍历操作返回的是一个快照(snapshot),而不是实时的视图。这意味着在遍历过程中,即使其他线程修改了字典,你看到的仍然是遍历开始那一刻的数据状态。
这个特性有好有坏。好处是遍历过程中不会抛异常,也不会看到不一致的中间状态。坏处是你可能读到已经过时的数据。如果你的业务逻辑要求遍历时看到最新数据,那ConcurrentDictionary就不适合,需要考虑其他方案。
另外,ConcurrentDictionary的Count属性返回的也是一个近似值。在并发修改的情况下,Count可能不是精确的。如果你需要精确计数,应该用Interlocked维护一个单独的计数器,而不是依赖字典的Count。
4. 生产者-消费者场景下的队列选择与阻塞控制
生产者-消费者是最经典的并发模式之一。多个生产者线程往队列里放数据,多个消费者线程从队列里取数据。这个模式看起来简单,但实际实现时有几个关键决策点。
4.1 ConcurrentQueue与BlockingCollection的配合使用
ConcurrentQueue本身不提供阻塞功能。如果队列为空,消费者调用TryDequeue会立即返回 false,需要自己写循环等待。这在低延迟场景下会导致 CPU 空转,浪费资源。
BlockingCollection解决了这个问题。它可以包装任何IProducerConsumerCollection(包括ConcurrentQueue),并提供Add和Take方法。当队列为空时,Take会阻塞当前线程,直到有数据可用。当队列满了(如果设置了容量上限),Add也会阻塞。
// 创建一个有界队列,最多容纳1000个元素 var queue = new BlockingCollection<int>( new ConcurrentQueue<int>(), boundedCapacity: 1000); // 生产者 Task.Run(() => { for (int i = 0; i < 10000; i++) { queue.Add(i); // 队列满时会阻塞 } queue.CompleteAdding(); // 通知消费者没有更多数据了 }); // 消费者 Task.Run(() => { foreach (var item in queue.GetConsumingEnumerable()) { // 处理item Console.WriteLine(item); } // GetConsumingEnumerable在CompleteAdding且队列为空时自动结束 });GetConsumingEnumerable是一个很优雅的设计。它会持续从队列取数据,直到CompleteAdding被调用且队列为空。这样消费者代码不需要自己写循环和退出条件,逻辑非常清晰。
4.2 有界队列的背压机制设计
设置boundedCapacity实际上是在实现背压(Backpressure)。当消费者处理速度跟不上生产速度时,有界队列会迫使生产者等待,防止内存无限增长。
这个容量设多大合适?我的经验是:如果生产者和消费者的速度差异不大,容量可以设小一点(比如100到1000),这样内存占用少,延迟也低。如果生产速度波动很大,偶尔会有突发流量,容量可以设大一些(比如10000),起到缓冲作用。但不管怎样,一定要设一个上限,否则队列无限增长最终会导致内存溢出。
提示:
BlockingCollection的Add方法在队列满时会阻塞。如果你不想阻塞,可以用TryAdd并设置超时时间,这样可以在队列满时做其他处理(比如记录日志、降级处理)。
4.3 取消操作与优雅关闭的正确姿势
在实际项目中,消费者线程通常需要支持取消操作。比如用户关闭了程序,或者服务需要重启,这时候需要优雅地停止所有消费者。
BlockingCollection配合CancellationToken可以实现这一点:
var cts = new CancellationTokenSource(); // 消费者 Task.Run(() => { try { foreach (var item in queue.GetConsumingEnumerable(cts.Token)) { // 处理item } } catch (OperationCanceledException) { // 正常取消,做清理工作 } }); // 某个时刻触发取消 cts.Cancel();需要注意的是,取消操作会导致GetConsumingEnumerable抛出OperationCanceledException。这不是错误,而是正常的控制流。你需要在 catch 块里做好清理工作,比如把未处理完的数据保存到磁盘,或者记录日志。
另外,取消之后记得调用queue.Dispose()释放资源。BlockingCollection内部使用了SemaphoreSlim等同步原语,不释放的话会造成资源泄漏。
5. 那些年我在线程安全集合上踩过的坑
理论说再多,不如实际踩几个坑来得深刻。下面这几个问题都是我在真实项目中遇到过的,有些排查了很久才找到根因。
5.1 以为用了ConcurrentDictionary就万事大吉
前面提到过GetOrAdd的工厂方法可能被多次调用,这个坑我踩过。当时是在做一个缓存服务,用GetOrAdd来加载数据。工厂方法里会去查数据库,结果在高并发下,同一个 key 的数据库查询被执行了好几次。虽然最终结果是对的,但数据库压力白白增加了好几倍。
后来改成用Lazy<T>包装,问题才解决。这个教训让我明白:ConcurrentDictionary 保证的是字典操作的原子性,不保证你的工厂方法只执行一次。任何有副作用的操作,都要仔细考虑是否会被重复执行。
5.2 在遍历时修改集合导致的诡异异常
有一次写了一个定时任务,遍历ConcurrentDictionary清理过期数据。代码大概是这样:
foreach (var kvp in dict) { if (IsExpired(kvp.Value)) { dict.TryRemove(kvp.Key, out _); } }这段代码在ConcurrentDictionary上其实是安全的,不会抛异常。但问题是,遍历的是快照,删除操作可能删掉了快照之后新添加的数据(如果 key 恰好相同)。更严重的是,如果清理逻辑比较复杂,遍历过程中字典已经被修改了很多次,最终清理的结果可能不符合预期。
后来我改成先收集所有需要删除的 key,遍历结束后再统一删除:
var keysToRemove = dict .Where(kvp => IsExpired(kvp.Value)) .Select(kvp => kvp.Key) .ToList(); foreach (var key in keysToRemove) { dict.TryRemove(key, out _); }这样逻辑更清晰,也避免了遍历过程中的不确定性。
5.3 误用ConcurrentBag导致的顺序错乱
ConcurrentBag的无序性是我踩过的另一个坑。当时做一个任务调度器,把任务放到ConcurrentBag里,然后多个工作线程去取。我潜意识里以为取出来的顺序和放进去的顺序差不多,结果发现完全不是那么回事。
ConcurrentBag的内部结构决定了它既不保证 FIFO 也不保证 LIFO。每个线程有自己的本地队列,取元素时优先从自己的队列取(LIFO),本地队列空了才去偷其他线程的(FIFO)。所以最终的处理顺序是混乱的。
如果你的业务对顺序有要求,千万别用ConcurrentBag。需要 FIFO 就用ConcurrentQueue,需要 LIFO 就用ConcurrentStack。ConcurrentBag只适合那些顺序完全不重要的场景,比如对象池、并行计算的任务分配。
5.4 锁粒度过大导致的性能瓶颈
早期我写多线程代码时,习惯用一个全局锁保护所有共享数据。后来做一个高并发的统计服务,发现 CPU 利用率上不去,吞吐量也卡在一个瓶颈上。用性能分析工具一看,大量时间花在了锁等待上。
后来把全局锁拆成了多个细粒度的锁,不同的数据结构用不同的锁保护,性能立刻提升了好几倍。这个经历让我深刻理解了一个道理:锁的粒度直接决定了并发性能的上限。在保证正确性的前提下,锁的范围越小越好。
ConcurrentDictionary的细粒度锁设计就是这个思路的体现。它把锁分散到各个桶上,不同桶的操作可以并行。这也是为什么在高并发场景下,ConcurrentDictionary比"Dictionary+ 全局锁"快得多的原因。
6. 不可变集合与线程安全集合的互补关系
除了 Concurrent 系列,.NET 还提供了System.Collections.Immutable命名空间下的不可变集合。这两者解决的是不同层面的问题,很多时候需要配合使用。
6.1 不可变集合如何从根本上消除竞态
不可变集合的核心思想是:一旦创建,就永远不能修改。所有的"修改"操作实际上是返回一个新的集合,原集合保持不变。
var list1 = ImmutableList<int>.Empty; var list2 = list1.Add(1); // list1仍然是空的,list2包含1 var list3 = list2.Add(2); // list2仍然只有一个元素,list3有两个这种设计从根本上消除了竞态条件,因为没有任何线程能修改一个已经存在的集合。多个线程可以同时读取同一个不可变集合,完全不需要同步。
但不可变集合也有明显的代价:每次修改都要创建新对象,内存分配和垃圾回收的压力比较大。所以它适合读多写少的场景,比如配置数据、常量表、事件溯源中的事件流。
6.2 读写分离场景下的组合策略
在实际项目中,我经常把不可变集合和 Concurrent 集合组合使用。一个典型的模式是:用ConcurrentDictionary作为可变的入口,内部存储不可变集合。
比如一个多线程的事件处理器,每个事件类型对应一组处理器:
var handlers = new ConcurrentDictionary<string, ImmutableList<IHandler>>(); // 注册处理器(写操作,频率低) handlers.AddOrUpdate( eventType, _ => ImmutableList.Create(handler), (_, existing) => existing.Add(handler)); // 触发事件(读操作,频率高) if (handlers.TryGetValue(eventType, out var list)) { foreach (var h in list) // 遍历不可变列表,无需加锁 { h.Handle(eventData); } }这样写操作通过ConcurrentDictionary保证线程安全,读操作拿到的是一个不可变列表,遍历时完全不需要担心其他线程的修改。读操作的性能非常高,而且代码逻辑清晰。
6.3 性能对比:什么时候该用哪种
选择不可变集合还是 Concurrent 集合,主要看读写比例和性能要求:
| 维度 | 不可变集合 | Concurrent集合 |
|---|---|---|
| 读性能 | 极高(无锁) | 高(部分无锁) |
| 写性能 | 低(每次创建新对象) | 中等(有锁竞争) |
| 内存占用 | 高(每次修改产生新对象) | 低 |
| 适用场景 | 读多写少、需要快照 | 读写均衡、频繁修改 |
| 复合操作 | 天然原子 | 需要额外处理 |
我的经验是:如果一个数据结构在初始化后很少修改(比如每天更新一次),用不可变集合。如果需要频繁修改(比如每秒几百次),用 Concurrent 集合。如果介于两者之间,可以考虑用ConcurrentDictionary存储不可变集合的组合方式。
7. 高并发场景下的性能调优经验
线程安全集合用对了只是第一步,用好了还需要一些调优技巧。下面这些经验都是我在实际项目中通过性能测试和线上监控总结出来的。
7.1 初始容量对ConcurrentDictionary的影响
ConcurrentDictionary和普通Dictionary一样,在容量不足时会进行扩容。扩容涉及到重新分配桶、重新哈希所有元素,是一个比较重的操作。如果在高并发下频繁扩容,性能会受到明显影响。
// 预估元素数量,设置合理的初始容量 var dict = new ConcurrentDictionary<string, int>( concurrencyLevel: Environment.ProcessorCount, capacity: 10000);concurrencyLevel决定了内部锁的数量。默认值是Environment.ProcessorCount,在大多数场景下够用。如果你的并发写入非常频繁,可以适当调大这个值,但也不要太大,否则每个锁保护的桶太少,反而增加了锁管理的开销。
capacity根据你预估的元素数量来设。如果预估不准,宁可设大一点。多占一点内存比频繁扩容的代价小得多。
7.2 减少锁竞争的几种实用手段
减少锁竞争是提升并发性能的核心。除了选对集合类型,还有几个实用的手段:
分片(Sharding):把一个大的 ConcurrentDictionary 拆成多个小的,每个线程根据自己的 ID 或 key 的哈希值选择对应的分片。这样不同线程操作不同分片时完全不会竞争。比如做一个计数器服务,可以按 key 的哈希值把计数器分散到 16 个字典里。
批量操作:如果需要对集合进行大量修改,尽量批量执行,而不是一个一个来。比如用AddOrUpdate的批量版本,或者先收集到本地列表,再一次性合并到共享集合。
读写分离:如果读操作远多于写操作,可以考虑用前面提到的"ConcurrentDictionary + 不可变集合"模式,让读操作完全无锁。
避免不必要的共享:最简单的优化往往最有效。如果某个数据只在一个线程内使用,就不要放到共享集合里。用线程本地存储(ThreadLocal<T>)或者把数据作为参数传递,从根本上消除竞争。
7.3 用性能计数器定位集合瓶颈
当系统出现性能问题时,怎么判断是不是线程安全集合的锅?我通常从几个指标入手:
CPU 使用率:如果 CPU 利用率不高但吞吐量上不去,很可能是锁竞争导致的。线程都在等锁,没有真正干活。
GC 频率:如果 Gen0/Gen1 回收非常频繁,可能是不可变集合创建了太多临时对象,或者 ConcurrentDictionary 频繁扩容。
线程状态:用性能分析工具查看线程状态,如果有大量线程处于WaitSleepJoin或Contention状态,说明锁竞争严重。
具体集合的指标:.NET 提供了一些 EventCounter 可以监控 ConcurrentDictionary 的锁竞争情况。在 .NET Core 3.0 及以上版本,可以通过dotnet-counters工具实时查看。
定位到瓶颈之后,再针对性地优化。如果是锁竞争,考虑分片或换用无锁集合。如果是 GC 压力,考虑减少临时对象创建或调整容量。如果是扩容频繁,设置合理的初始容量。
8. 从单机到分布式:线程安全集合的边界
最后聊一个容易被忽略的问题:线程安全集合解决的是单进程内多线程的并发问题。一旦你的系统扩展到多进程或多机器,这些集合就无能为力了。
8.1 进程内线程安全不等于跨进程安全
ConcurrentDictionary保证的是同一个进程内多个线程访问的线程安全。如果两个进程同时访问同一个文件或数据库,ConcurrentDictionary帮不了你。这时候需要的是分布式锁、数据库事务、或者消息队列等跨进程的同步机制。
我见过有同学用ConcurrentDictionary做本地缓存,然后多个服务实例各自维护一份,结果数据不一致。这不是ConcurrentDictionary的问题,而是架构设计的问题。本地缓存本来就不保证多个实例之间的一致性,如果需要强一致,应该用分布式缓存。
8.2 分布式场景下的替代思路
在分布式场景下,线程安全集合的角色通常被以下几种方案替代:
分布式缓存:用外部的缓存服务(如内存缓存集群)来存储共享数据,各个服务实例通过网络访问。缓存服务内部会处理并发访问的问题。
消息队列:生产者把消息发到队列,消费者从队列取消息。消息队列天然支持多生产者多消费者,而且提供了持久化和可靠性保证。
数据库:用数据库的事务和锁机制来保证并发安全。虽然性能不如内存集合,但胜在可靠和通用。
分布式协调服务:用于需要强一致性的场景,比如选主、配置管理、分布式锁。
这些方案各有优劣,选择哪种取决于你的具体需求:一致性要求、性能要求、可靠性要求、运维复杂度等。线程安全集合在单机内仍然是最简单高效的方案,但不要试图用它来解决跨进程的问题。
8.3 什么情况下该考虑换方案
有几个信号提示你可能需要从线程安全集合升级到分布式方案:
- 服务需要水平扩展,多个实例需要共享状态
- 对数据一致性有强要求,不能接受最终一致
- 单机内存已经放不下所有数据
- 需要跨进程的通知和协调机制
如果只是单机内的并发问题,线程安全集合仍然是最佳选择。它的性能好、使用简单、不需要额外的基础设施。不要为了"架构先进"而过度设计,把简单问题复杂化。
我在实际项目中的体会是:先把单机的线程安全问题解决好,用 Concurrent 集合把代码写正确。等到确实需要分布式方案时再演进,不要一开始就上重型武器。很多系统其实单机就能扛住,过早引入分布式只会增加复杂度和故障点。