当前位置:首页 > 问答 > 正文

Redis计数器怎么用才能避免并发冲突,防止数据错乱的小技巧分享

Redis计数器是我们在项目中经常用到的一个功能,比如统计网站点击量、用户点赞数、库存扣减等等,它很简单,就是用Redis的INCRINCRBY命令来给一个键的值增加,但就是因为简单,很多人会觉得直接用就行了,结果在高并发访问下,很容易出现数据错乱,比如计数不准、超额扣减库存等问题。

这背后的主要原因就是并发冲突,当很多个请求在同一时刻去读写同一个计数器的时候,如果操作不是原子性的,或者我们的逻辑有漏洞,后一个请求可能会覆盖前一个请求的结果,或者读取到了过期的值,从而导致最终计数错误。

怎么才能避免这些问题呢?其实Redis本身已经为我们提供了很好的工具,关键在于我们要用对方法,下面分享几个实用的小技巧。

第一个,也是最核心的技巧,就是坚定不移地使用Redis的原生命令。

Redis是单线程执行命令的,这意味着在同一个Redis实例中,任何命令的执行都是排着队一个一个来的,根本不会发生两个命令同时执行、同时修改同一个数据的情况,Redis的很多命令本身就是“原子操作”,执行过程中不会被其他命令打断。

对于我们计数器来说,INCR(增加1)、INCRBY(增加指定整数)、DECR(减少1)、DECRBY(减少指定整数)这些命令都是原子操作,这是避免并发冲突的第一道,也是最重要的一道防线,要给一个文章的点赞数加1,一定要用INCR article:123:likes,而绝对不能GET这个键的值到应用服务器,在程序里加1,再SET回去,那种“先读后写”的模式,在并发下百分百会出问题,因为多个请求可能同时读到同一个值,然后都加1后再SET回去,结果本该加了好几次,最后却只加了一次。

第二个技巧,在处理“检查后再增减”的复杂逻辑时,使用Lua脚本。

有时候我们的计数逻辑没那么简单,最经典的例子就是“库存扣减”,我们不仅要减少库存,还得先检查库存是否大于0,大于0才允许扣减,这个“检查库存”和“扣减库存”是两个操作,如果分两步走,即使每个命令是原子的,但整个逻辑不是原子的,可能会发生这种情况:请求A检查库存,发现是1,准备扣减;请求B也检查库存,看到的也是1(因为A还没扣减),然后两个请求都执行了扣减,结果库存变成了-1,这就超卖了。

解决这个问题的法宝就是Lua脚本,Redis允许我们把多个命令写在一个Lua脚本里,然后一次性发送给Redis执行,在整个脚本执行期间,不会有其他命令插队,从而保证了整个复杂逻辑的原子性,我们可以写一个这样的Lua脚本:

Redis计数器怎么用才能避免并发冲突,防止数据错乱的小技巧分享

local current = tonumber(redis.call('GET', KEYS[1])) -- 获取当前库存
if current > 0 then -- 检查库存是否大于0
    redis.call('DECR', KEYS[1]) -- 如果大于0,则扣减1
    return 1 -- 返回1表示扣减成功
else
    return 0 -- 返回0表示库存不足
end

这样,无论是多少个请求同时过来,这个“检查-扣减”的动作都是一个不可分割的整体,从而彻底避免了超卖的问题。

第三个技巧,利用SETNX命令来实现简单的分布式锁,应对更复杂的场景。

虽然Lua脚本能解决大部分原子性问题,但有些业务逻辑可能非常复杂,或者需要与外部系统交互,不适合全部塞进Lua脚本里,这时候,我们可以用一个简单的“锁”来保证同一时间只有一个请求能执行这段逻辑。

Redis的SETNX(SET if Not eXists)命令是实现锁的基石,它的作用是,只有当键不存在时,才设置它,并且返回1表示设置成功;如果键已经存在,就不做任何操作,返回0表示失败,我们可以把一个特定的键当作“锁”。

基本流程是:

Redis计数器怎么用才能避免并发冲突,防止数据错乱的小技巧分享

  1. 一个请求到来时,尝试用SETNX命令去设置一个代表锁的键(比如lock:counter_key),并给它一个短暂的过期时间(避免死锁)。
  2. 如果SETNX返回1,说明这个请求成功拿到了锁,它就可以放心地去执行那些非原子性的复杂计数逻辑了,比如先查数据库再更新Redis。
  3. 如果SETNX返回0,说明锁已经被其他请求占用了,这个请求可以等待一会儿重试,或者直接返回“系统繁忙”的提示。
  4. 拿到锁的请求在处理完业务逻辑后,需要主动删除这个锁键,释放资源给其他等待的请求。

这种方式相当于让并发的请求们排队,一次只处理一个,自然也就没有冲突了,不过要注意,设置过期时间是必须的,防止某个请求拿到锁后因为程序崩溃等原因无法释放锁,导致整个系统卡死。

第四个技巧,为计数器设置过期时间,并注意缓存的穿透和雪崩。

计数器数据往往有生命周期,比如一天的UV统计,过了当天就没意义了,我们可以使用EXPIRE命令为计数器设置一个过期时间,让它自动清理,避免无用数据长期占用内存。

但这里有个小坑要注意:如果我们在一个键不存在的时候去执行INCR,Redis会先把这个键的值初始化为0,然后再执行加1操作,如果我们紧接着用EXPIRE设置过期时间,这是没问题的,但最好确保这两个操作能连续执行,可以用上面提到的Lua脚本把INCREXPIRE打包,或者使用Redis的管道(pipeline)功能一次性发送,减少网络开销和不一致的风险。

对于可能频繁访问但又不存在的计数器(比如初始化前的计数器),要警惕缓存穿透问题(大量请求查询一个不存在的键,直接压到数据库),可以考虑预先初始化一个0值并设置过期时间。

总结一下,要让Redis计数器在并发下安全可靠,记住四点:

  1. 核心原则:直接用INCR/DECR等原子命令,杜绝“先读后写”。
  2. 复杂逻辑:用Lua脚本打包多个命令,实现原子性。
  3. 非常复杂的业务:用SETNX实现分布式锁,让请求排队。
  4. 生命周期管理:用好EXPIRE,并注意缓存穿透的预防。

只要我们根据具体的业务场景,灵活运用这些Redis提供的小工具,就能轻松构建出高效、准确、能扛住高并发的计数器系统。