并发按 Key 计数与阈值触发:从问题到选型,解决高并发场景下的精准计数难题

43 阅读7分钟

并发按 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 一台闸机,闸机自带计数器,每来一个人数字加 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 设置过期时间。
  • 示意

    INCR counter:device:xxx
    EXPIRE counter:device:xxx 600   # 10 分钟后过期
    
  • 形象类比

    • 不同楼里的闸机统一把数据报给“园区大屏”,所有统计都在大屏上完成,任何一栋楼都可以看到同一个总数。

三、怎么选?(选型指南)

可以按下面的问题,自上而下做决策:

1. 你是否需要“严格的自增后值”?

也就是:你当前拿到的这个 num,必须是“刚刚这次 +1 之后的准确值”,并且要基于它做本次动作决策。

  • 如果 必须
    • 优先选择 方案 A:AtomicInteger
  • 如果 不强求(比如只是统计、监控指标,可以接受轻微滞后):
    • 可以考虑 方案 B:LongAdder

2. 写入并发是否非常高,热点 key 是否明显?

  • 如果 高并发 + 热点明显
    • 更倾向于 方案 B:LongAdder,吞吐更高。
  • 如果 中等并发
    • 方案 A 和 B 都可以,当更看重精确自增语义时选 A

3. 并发不高、更看重代码简洁?

  • 如果是简单场景,吞吐要求不高:
    • 可以选 方案 C:merge(key, 1, Integer::sum),代码最简。

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. 类型选择

    • 有溢出风险就用 AtomicLongLongAdder
    • 单机统计通常问题不大,但长时间运行/计数非常多时要注意。
  • 5. 使用场景区分:监控 vs 决策

    • 监控数据 / QPS / TPS 统计:适合 LongAdder,追求吞吐。
    • 业务决策(例如批次触发/限流阈值):更适合 AtomicInteger + 精确“自增后值”。

五、一个简单的小结

  • 我遇到的本质问题
    并发环境下,如何按 key 安全地计数,并在需要时获得准确的“自增后值”,同时兼顾性能和内存。

  • 核心解决方案

    • 并发中等 + 要精确自增 → ConcurrentHashMap + AtomicInteger + computeIfAbsent + incrementAndGet
    • 并发很高 + 吞吐优先 → ConcurrentHashMap + LongAdder + computeIfAbsent
    • 低并发 + 写法简洁 → merge(key, 1, Integer::sum)
    • 需要自动过期 / 分布式 → Caffeine / Redis 等方案。
  • 选型建议

    • 先问清楚:是决策用计数还是监控用计数
    • 决策用计数更关注“这一次 +1 后是多少” → 选 AtomicInteger
    • 监控用计数更关注整体趋势和吞吐 → 选 LongAdder