最近开始做一个通用抽奖的项目,项目中有一个活动用户每天的抽奖限制和总中奖限制,即每天最多抽多少回和活动期间总共中多少个。这一判断的特定就是请求量巨大,肯定是不能直接依靠关系数据库来做判断的,所以就要用到了缓存,将每一位用户的这两个限制都写到缓存中,需要判断限制的时候,直接读缓存就好。
首先先否定内存缓存,因为尽管缓存的数据是非常小的,但是参与抽奖的用户数量会很大,更何况一个应用会服务多个活动,那么用户数量更加不可估计,内存缓存很容易挤爆并丢失数据。
当然还有一个原因就是环境,项目的环境是容器,多容器之前无法共享内存缓存中的数据,而抽奖限制要求又必须是唯一的,所以内存缓存只能抛弃。
于是就来到了redis。
最开始的缓存逻辑,是将用户的每日抽奖限制和总中奖限制缓存在一起,在需要更新时,是将限制人为的+1,然后更新缓存。这几乎是新接触后端开发的新手都会犯的错误,这样的项目上线是一定会超发奖品的,因为后端应用是多线程并行运转。就比如限制中奖5个,一个用户已经中了3次,这时他找了个脚本,同时发了100次请求,那么这100次请求全部会读这一个限制,然后全部通过限制判断,100个都可能中奖了。会发生这种情况的原因就是没有应用到锁机制。
熟悉MySQL的同学应该都清楚MySQL的锁和事务机制,这里就不详细说了。如果redis像MySQL一样支持锁机制和事务,那么我们的问题不就迎刃而解了?
这样想的我真的是naive了,redis即支持锁机制又有事务,但就恰恰并没有解决我的问题。(当然这里的锁机制并非其他应用使用redis实现分布式锁的概念)。
redis的锁本质上是一种CAS(check-and-set)操作,即乐观锁,使用watch命令配合muti-exec的事务操作来实现,即在watch key1,之后在执行事务之前会去检验key1中的值是否有被更改过,如果有被更改过,则拒绝执行事务。乍一看这是能解决我的问题的,你在每个用户进来抽奖的时候watch这个用户的对应限制key不就可以了?
但很遗憾并非如此
首先,redis的muti-exec事务操作并非像MySQL中的事务一样为原子操作,一系列操作中的某一个操作出现问题了,redis会记录此次问题然后继续执行余下的操作,而且不具备rollback的功能。
其次,redis的事务对多线程的操作是不安全的(尽管redis服务端是单线程的),exec操作会清空watch key的列表,即假如先后进来抽奖者a和b,分别watch了key1和key2,a先判断完了执行了exec操作,redis会把key1和key2都清除,这种时候b的操作的安全性就无法保障了。
当然这并非不可解,用于我上面提到的redis的server端是单线程的,那么其实redis除了事务操作的其他命令,本身就是原子的,那么我们就可以考虑将两个限制分开存储,然后直接使用redis的自增操作。redis的自增操作会返回自增之后的值,只要拿着这个值去判断一下,就知道是否存在并发超发了。
但是自增也不是完美的,因为我们不可能把所有用户的限制一直缓存起来,肯定是要有过期时间的,否则即使是redis也可能会被撑爆。基于项目的考量,过期时间设置为每天的0点,即用户第二天就可以再次抽取。而一旦设置了过期时间,那么就可能出现如下情况
情况1:
抽奖活动限制每人最多中奖5次,中奖率100%,这个限制存在于redis中,每天0点过期(redis2.6版之前key的过期延迟为1秒,2.6版本之后过期延迟为1毫秒),那么在key即将过期之前,有一个已经中了3次奖的用户发来了4次请求(不论以何种手段),每次请求均按照该用户已中奖3次通过了中奖限制验证,4个请求均去申请自增中奖限制,而4个请求在去申请中奖限制时时间均超过了该key的过期时间,redis的自增请求会判断为key不存在,并从0开始重新+1,每个自增的请求结果都是不大于抽奖限制的,则该用户最后的限制变为了4,而其实他已经中走了7个奖。
通过incr操作返回自增后的值的特性,可以解决如上问题,即判断自增之后的值是否大于当前已知的限制值,如果不大于,那么说明本次自增是在错误的基础上自增的,应该认定为此次操作用户未中奖,同时删除错误的key, 已确保下一次请求重新计算总限制的值。
情况2:
同样如上的情况,用户只发来了一次请求,在自增操作之前key过期,而此次请求在自增之后判断是否正确的时候,又进来4次请求,该4次请求读取了错误的初始值1,同时进行自增操作,即尽管会被删除一回,但之后的4次会以1作为基础判断,第一个自增操作判断为错误并删除key,但234次操作均通过判断,则用户已中了6个奖。
所以一旦有人发现这一点,在0点时间戳附近时段大量刷新,则可能就会出问题。
针对这一情况,采用两个方法
1. 随机一个10秒到100秒的时间,加到key的过期时间中,避免时间戳直接暴露
2. 每次自增操作增加一次key的TTL判断,如果TTL小于10,则使用expire延长key的生存时间到下一个0点时间戳
更新,看了一遍memcached,觉得果然比redis更适合做缓存,支持多线程就不多说了,CAS操作依赖内建token而不是像redis一样依赖事务就能很完美解决我如上的抽奖需求,最起码会比redis的实现方案优雅很多。