大部分互联网公司都需要处理计数器场景,例如风控系统的请求频控、内容平台的播放量统计、电商系统的库存扣减等。

传统方案一般会直接使用RedisUtil.incr(key),这是最简单的方式,但这种方式在生产环境中会暴露严重问题:

INCR 有自动初始化机制,即当 Redis 检测到目标 key 不存在时,会自动将其初始化为 0,再执行递增操作

高可用计数器的实现

原子操作保障计数准确性

NX+EX 原子初始化

通过Redis的SET key value NX EX命令,实现原子化的"不存在即创建+设置过期时间",避免多个线程竞争初始化导致数据覆盖(如线程A初始化后,线程B用SET覆盖值为0)

Redis单线程模型保证命令原子性,无需额外分布式锁

使用setnx命令来设置了过期时间,防止key永不过期

INCR 原子递增

先setnx命令后,再使用INCR来执行递增操作

即:

双重补偿机制解决过期异常

但只是使用以上两个命令还是有可能导致并发安全问题。

例如:

当两个线程同时执行 SETNX 时,未抢到初始化的线程直接执行INCR,导致key存在但无TTL

如果有一个线程A正在执行SET key 0 NX EX 60,而线程B也执行方法addOne,此时线程A正在执行,线程B无法执行set操作,会直接继续执行后续命令(如 INCR),此时若线程A由于网络抖动等原因初始化key失败,那就有可能导致 key 永不过期。因此需要有补偿机制,完成redis key超时时间的设置

注意:当 SETNX 命令无法执行(即目标 key 已存在时),会直接继续执行后续命令(如 INCR),而不会阻塞等待

首次递增补偿

因此可以通过判断result == 1来识别是否是首次递增,如果是首次递增的话,则强制续期

TTL异常检测补偿

极端场景下(Redis主从切换、命令执行异常导致TTL丢失),key 可能因未设置或过期时间丢失而长期存在

检查 TTL 是否为 -1(-1表示无过期时间),重新设置过期时间,作为兜底保护。

经过双重补偿机制后的代码如下:

异常处理与降级策略

有时候可能会因网络抖动、服务短暂不可用、主备切换等暂时性故障,导致Redis操作失败,因此可以对这中异常进行处理,将需要完成的操作放入到队列中,再使用一个线程循环重试,保证最终一致性

架构设计示意图

关键机制对比

机制解决的问题Redis特性利用性能影响SET NX EX并发初始化竞争原子单命令O(1)INCR计数不准确/超卖原子递增O(1)TTL双重补偿Key永不过期EXPIRE命令幂等性额外1次查询异常队列重试网络抖动/Redis不可用最终一致性异步处理 这个方案充分挖掘了Redis原子命令的潜力,通过补偿机制弥补分布式系统的不确定性,最终在简单与可靠之间找到平衡点。

扩展:Redisson实现滑动窗口计数

本方案是通过 ZSetLua 脚本来实现的分布式滑动窗口计数器。

核心思想是将每个请求的时间戳作为分数存入 ZSet,通过计算某个时间区间内的元素数量来统计请求次数,并利用 Lua 脚本保证整个‘清理过期数据-计数-添加新记录’流程的原子性,非常适合在分布式环境下进行精准的限流和统计。

Key 的组成是 sliding:counter:{baseKey}:{windowSizeInMillis}。固定前缀:业务相关key:时间窗口。这里将窗口大小作为 Key 的一部分是关键,这样同一个业务标识(如 user123)可以同时拥有多个不同时间粒度的计数器

value 的构成(ZSet 中的元素):取决于调用的方法,做了两种不同的计数场景:

普通计数(increment方法)

  • Member(成员){currentTime}_{UUID}。例如:1766863205000_550e8400-e29b-41d4-a716-446655440000

  • Score(分数):当前的毫秒时间戳,即 currentTime

  • 设计意图:使用 时间戳+UUID 是为了确保每个请求记录的唯一性,避免在高并发下同一毫秒内的多个请求因 Member 相同而被覆盖,从而导致计数不准。

唯一值计数(addUniqueItem方法)

  • Member(成员):直接使用业务上的唯一标识,如酒店ID hotel_456

  • 设计意图:对于同一个 Member(如 hotel_456),后续的 ZADD操作会更新其 Score 为最新时间。这使得 ZSet 能自动维护每个唯一项的最新访问时间,并且 ZCARD命令可以直接返回唯一项的数量,完美实现了“统计不同数据的个数”的需求。

其它设计亮点:

  • 原子性保证:强调为什么使用 Lua 脚本而不是多个独立的 Redis 命令。因为在高并发场景下,如果“清理-判断-添加”不是原子操作,可能会出现竞态条件,导致计数超限。Lua 脚本在 Redis 中单线程执行,完美解决了这个问题。同时能减少网络io,减少redis的qps次数

  • 性能优化脚本预加载。在类初始化时(构造函数中)就通过 SCRIPT LOAD将 Lua 脚本加载到 Redis 并缓存其 SHA1 摘要。后续调用使用 EVALSHA而不是直接发送脚本内容,大大减少了网络传输开销,提升了性能。

  • 应对高并发:在普通计数场景下使用 “时间戳+UUID” 来防止成员覆盖。

  • 两种模式:实现两种方法(累计计数 vs. 唯一值计数),这比单一的计数器更具实用性。