并发按 Key 计数与阈值触发:从问题到选型,解决高并发场景下的精准计数难题
一、问题复现?
在处理类似 Kafka 这类高并发消息消费场景时,我遇到这样一个需求:
- 需要按设备 / 用户 / 业务字段作为 key 做计数,比如:
- 某个设备每来 100 条消息就打一个批次。
- 某个用户每 N 条就触发一次校验 / 告警。
- 希望每次计数时能拿到“自增后的值”,用来做阈值判断(例如:当前是第几条?是否达到 100?)。
- 系统是并发消费的,不能简单地用
HashMap + int,否则会有线程安全问题。
于是问题变成:
在并发环境下,如何按 key 做安全、性能尚可的计数,并且在需要时能准确拿到“自增后的值”?
同时还伴随几个现实约束:
- 性能:不能因为一个小小的计数,把整个消费线程都卡住。
- 热点 key:有的 key 非常“热”(例如某个大客户/某个热门设备),高并发时冲突会很严重。
- 内存:key 可能越来越多,如果不清理,Map 会不断膨胀。
- 部署形态:是单机统计,还是需要多实例/分布式全局统计?
可以把它类比成:
我们在一栋写字楼门口装了计数器,想知道每个公司每天进来多少人——
- 人少的时候,随手记一笔就行;
- 人多的时候,最好多开几个闸机;
- 如果要全园区统计,就要一个统一的大屏来收集每栋楼的数据。
二、有哪些解决方案?
方案 A:ConcurrentHashMap + computeIfAbsent + AtomicInteger
关键词:中等并发 + 需要严格“自增后值”。
-
使用场景
- 读写并发不算极端,但也不是单线程。
- 每次计数时,需要基于当前“自增后值”立刻决策,比如:
- 当
num == 100时触发一个批处理。 - 当
num % 50 == 0时记录一次日志。
- 当
-
核心写法
private final ConcurrentHashMap<String, AtomicInteger> deviceCountMap = new ConcurrentHashMap<>(); AtomicInteger counter = deviceCountMap.computeIfAbsent(key, k -> new AtomicInteger()); int num = counter.incrementAndGet(); // 严格的“自增后值” if (num >= threshold) { // 触发动作,例如:组装批次、落库、告警等 deviceCountMap.remove(key, counter); // 避免键空间无限增长 } -
优点
incrementAndGet()是原子操作,返回值就是“自增后值”,逻辑非常直观。- 基于这个值做阈值判断/批处理,非常顺手。
-
缺点
- 在极高并发 + 热点 key 很多的场景下,CAS 冲突会变多,吞吐不如
LongAdder。
- 在极高并发 + 热点 key 很多的场景下,CAS 冲突会变多,吞吐不如
-
形象类比
- 每个 key 一台闸机,闸机自带计数器,每来一个人数字加 1,并且可以随时准确看到当前人次。
方案 B:ConcurrentHashMap + computeIfAbsent + LongAdder
关键词:高并发 + 热点 key 明显 + 吞吐优先。
-
使用场景
- 写入很频繁,很多线程在同时对同一个 key 计数。
- 更看重整体吞吐,能接受读取的值有轻微“滞后”。
-
核心写法
private final ConcurrentHashMap<String, LongAdder> deviceCountMap = new ConcurrentHashMap<>(); LongAdder adder = deviceCountMap.computeIfAbsent(key, k -> new LongAdder()); adder.increment(); long num = adder.sum(); // 读取值,可能与刚刚那次 increment 有轻微交错 if (num >= threshold) { // 触发动作 deviceCountMap.remove(key, adder); } -
优点
- 在高竞争下吞吐明显高于
AtomicInteger。 - 适合做统计类计数、监控指标等。
- 在高竞争下吞吐明显高于
-
缺点
sum()和increment()不是同一次原子操作,没有严格的“自增后值”语义。- 用它直接做“精确批次控制”要谨慎。
-
形象类比
- 每个 key 有好几个闸机,每个闸机各自计数,最后把所有闸机的数字加起来。过人速度快,但某一瞬间你看到的总数不一定是“刚刚那个人通过之后的精确值”。
方案 C:merge(key, 1, Integer::sum)(或 compute)
关键词:低并发 + 追求简洁。
-
使用场景
- 并发度不高,或者对性能不那么敏感。
- 更想要写法简洁、一眼看懂。
-
核心写法
private final ConcurrentHashMap<String, Integer> deviceCountMap = new ConcurrentHashMap<>(); int num = deviceCountMap.merge(key, 1, Integer::sum); // 返回的是新值 -
优点
- 一行搞定:初始化 + 自增 + 返回新值。
- 写法非常简洁。
-
缺点
Integer是不可变对象,每次都要新建一个Integer放回 Map。- 在热点 key 较多时,频繁修改 Map 中的值,锁争用会比方案 A、B 更明显。
-
形象类比
- 每次有人进门都在一本账本上:先翻到那一页,读出当前数字,然后在原地擦掉,重新写上 +1 后的结果。人少没问题,人多就显得有点慢。
方案 D:带过期 / 分布式的计数方案(缓存或 Redis)
关键词:需要过期 / 需要分布式全局一致。
D1. 本地带过期的缓存(例如 Caffeine)
-
使用场景
- 单机统计,但希望 key 自动过期。
- 避免 Map 无边界增长,控制内存。
-
核心写法
Cache<String, LongAdder> cache = Caffeine.newBuilder() .maximumSize(100_000) // 限制最大 key 数 .expireAfterAccess(Duration.ofMinutes(10)) // 一段时间不用自动过期 .build(); LongAdder adder = cache.asMap().computeIfAbsent(key, k -> new LongAdder()); adder.increment(); long num = adder.sum(); -
适用
- 统计类指标、本地限流、本地热点计数等。
D2. 分布式计数(例如 Redis)
-
使用场景
- 多实例部署,需要全局统一计数或限流。
- 想要 key 带 TTL,到期后自动清理。
-
核心思路
- 使用 Redis 的
INCR/HINCRBY等原子操作。 - 配合
EXPIRE设置过期时间。
- 使用 Redis 的
-
示意
INCR counter:device:xxx EXPIRE counter:device:xxx 600 # 10 分钟后过期 -
形象类比
- 不同楼里的闸机统一把数据报给“园区大屏”,所有统计都在大屏上完成,任何一栋楼都可以看到同一个总数。
三、怎么选?(选型指南)
可以按下面的问题,自上而下做决策:
1. 你是否需要“严格的自增后值”?
也就是:你当前拿到的这个
num,必须是“刚刚这次 +1 之后的准确值”,并且要基于它做本次动作决策。
- 如果 必须:
- 优先选择 方案 A:
AtomicInteger。
- 优先选择 方案 A:
- 如果 不强求(比如只是统计、监控指标,可以接受轻微滞后):
- 可以考虑 方案 B:
LongAdder。
- 可以考虑 方案 B:
2. 写入并发是否非常高,热点 key 是否明显?
- 如果 高并发 + 热点明显:
- 更倾向于 方案 B:
LongAdder,吞吐更高。
- 更倾向于 方案 B:
- 如果 中等并发:
- 方案 A 和 B 都可以,当更看重精确自增语义时选 A。
3. 并发不高、更看重代码简洁?
- 如果是简单场景,吞吐要求不高:
- 可以选 方案 C:
merge(key, 1, Integer::sum),代码最简。
- 可以选 方案 C:
4. 是否需要自动过期 / 分布式全局一致?
- 需要本地自动过期、容量控制:
- 选 方案 D1:Caffeine + LongAdder / AtomicLong。
- 需要跨实例全局一致:
- 选 方案 D2:Redis 或类似分布式计数组件。
四、关键注意点与实践小贴士
-
1. Map 类型
- 并发场景一定要用
ConcurrentHashMap。 - 单线程 / 只在单线程使用时,
HashMap即可。
- 并发场景一定要用
-
2.
computeIfAbsent的 lambda 要“无副作用”- 在竞争下,lambda 可能被调用多次,只有一个会成功放入 Map。
- 不要在 lambda 里面写有外部副作用的逻辑(比如写库、发 MQ)。
-
3. 清理策略
- 达到阈值后,可以:
deviceCountMap.remove(key, counter); // 利用 (key, value) 双重匹配减少误删 - 或者定期扫描 / 基于时间戳做过期清理;
更高级可以用 Caffeine/Redis 带 TTL。
- 达到阈值后,可以:
-
4. 类型选择
- 有溢出风险就用
AtomicLong或LongAdder。 - 单机统计通常问题不大,但长时间运行/计数非常多时要注意。
- 有溢出风险就用
-
5. 使用场景区分:监控 vs 决策
- 监控数据 / QPS / TPS 统计:适合
LongAdder,追求吞吐。 - 业务决策(例如批次触发/限流阈值):更适合
AtomicInteger+ 精确“自增后值”。
- 监控数据 / QPS / TPS 统计:适合
五、一个简单的小结
-
我遇到的本质问题:
并发环境下,如何按 key 安全地计数,并在需要时获得准确的“自增后值”,同时兼顾性能和内存。 -
核心解决方案:
- 并发中等 + 要精确自增 →
ConcurrentHashMap + AtomicInteger + computeIfAbsent + incrementAndGet - 并发很高 + 吞吐优先 →
ConcurrentHashMap + LongAdder + computeIfAbsent - 低并发 + 写法简洁 →
merge(key, 1, Integer::sum) - 需要自动过期 / 分布式 → Caffeine / Redis 等方案。
- 并发中等 + 要精确自增 →
-
选型建议:
- 先问清楚:是决策用计数还是监控用计数?
- 决策用计数更关注“这一次 +1 后是多少” → 选 AtomicInteger。
- 监控用计数更关注整体趋势和吞吐 → 选 LongAdder。