Redis 实现高并发场景下的计数器设计

81 阅读4分钟

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

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

public long addOne(String key) {
    Long result = RedisUtil.incr(key); 
    
    return result;
}


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

高可用计数器的实现

原子操作保障计数准确性

NX+EX 原子初始化

RedisUtil.set(key, "0", "nx", "ex", time);


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

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

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

INCR 原子递增

long result = RedisUtil.incr(key);


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

即:

public void addOne(String key) {
    RedisUtil.set(key, "0", "nx", "ex", time);
    Long result = RedisUtil.incr(key); 
	return result;
}


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

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

例如:

当两个线程同时执行 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来识别是否是首次递增,如果是首次递增的话,则强制续期

if (result == 1) {
    RedisUtil.expire(key, time);
}


TTL 异常检测补偿

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

if (RedisUtil.ttl(key) == -1) {
    RedisUtil.expire(key, time);
}


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

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

public void addOne(String key) {
    RedisUtil.set(key, "0", "nx", "ex", time);
    Long result = RedisUtil.incr(key); 
    
    
    if (result == 1) {
         RedisUtil.expire(key, time);
    }

    
    if (RedisUtil.ttl(key) == -1) {
         RedisUtil.expire(key, time);
    }
    return result;
}


异常处理与降级策略

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

public void addOne(String key) {
    Long result = 1;
    try{
        RedisUtil.set(key, "0", "nx", "ex", time);
        result = RedisUtil.incr(key); 
        
        
        if (result == 1) {
             RedisUtil.expire(key, time);
        }

        
        if (RedisUtil.ttl(key) == -1) {
             RedisUtil.expire(key, time);
        }
    } catch (Exception e) {
        
    	queue.offer(key); 
	}
    return result;
}


架构设计示意图

关键机制对比

机制解决的问题Redis 特性利用性能影响
SET NX EX并发初始化竞争原子单命令O(1)
INCR计数不准确 / 超卖原子递增O(1)
TTL 双重补偿Key 永不过期EXPIRE 命令幂等性额外 1 次查询
异常队列重试网络抖动 / Redis 不可用最终一致性异步处理

这个方案充分挖掘了 Redis 原子命令的潜力,通过补偿机制弥补分布式系统的不确定性,最终在简单与可靠之间找到平衡点。

往期推荐