抽奖优化设计3

前面两篇文章谈到了抽奖的优化设计。抽奖的主要性能问题在于锁的粒度过大,每个租户一个锁,一个租户只允许一个请求进入抽奖流程。 抽奖过程的优化思路是降低锁的粒度,将锁粒度减小为奖品级别,但是不论是使用redis分布式锁,或者mysql本身的行锁,最终中奖都要进行数据奖品数量的-1,数据库往往会成为瓶颈。经过思考后,决定使用redis做数据库存储,异步线程将redis数据同步至数据库,并基于redis+lua的无锁实现抽奖算法。

Redis存储奖品信息

  • 将抽奖活动中奖品的三个关键信息:权重,中奖次数,剩余奖品数存入redis,抽奖活动过程中,奖品数量的更改不立即保存至数据库,先在redis中进行奖品数量的加减。异步线程定时将redis中的奖品数量,权重,中奖次数等信息同步至数据库。存储结构:

  • 将存放奖品信息的redis,独立一个redis实列,使用aof+appendfsync everysec持久化机制,一定程度上防止数据丢失,应用额外使用日志文件保存中奖结果,当redis意外crash,从aof文件恢复redis后,通过校对日志文件,数据库,redis值确定redis值是否错,有则修改redis值。

Redis+Lua脚本实现抽奖算法

熟悉redis的同学都知道,redis+lua可以实现CAS等功能,单线程的redis在执行lua脚本时,并不会执行其他操作,保证lua脚本中多步操作的原子性,保证线程安全。

实现

从prizeWeighHash中取出所有的奖品权重,构造权重区间,生成随机数,根据随机数和权重区间得出prizeId,调用lua脚本将奖品数量减一,中奖次数加一。

流程图:

更改中奖数量的Lua脚本:

local count=redis.call("HINCRBY",KEYS[1],KEYS[4],-1) 
if count<=0 then
   redis.call("HDEL",KEYS[1],KEYS[4])
   redis.call("HDEL",KEYS[2],KEYS[4]) 
end
if count>=0 then
    redis.call("HINCRBY",KEYS[3],KEYS[4],1)
end
return count

  • Lua脚本将奖品的剩余次数-1,当奖品数量为0,或者对应的hash key不存在,返回count为-1。这时需要将奖品对应的weight hash item和remainCount hash item删除。

  • 如果count大于等于0,用户成功中奖,将奖品中奖次数加1。 由于redis在执行lua脚本的过程中,不会执行其他操作。所以对奖品数量的操作是单线程的,并不会有线程安全问题。

  • Lua脚本会存在返回-1的情况,当多个线程同时抽中最后一个奖品时,第一个执行lua脚本的请求会将prizeId所对应的remainCount删除,这时后续的线程执行redis.call("HINCRBY",KEYS[1],KEYS[4],-1),结果将是-1,这是应用程序判断调用lua脚本的结果小于,说明该次抽奖用户没有抽中,返回token给客户端,客户端再发起重试。

    当所有奖品被抽光后,prizeRemainCount hash item的长度将为0,以此作为奖品是否被抽光的判断逻辑。

管理后台更新奖品信息

抽奖另一个需求点:可以在管理后台修改奖品数量,修改数量的前置条件是:奖品总数大于中奖次数。
在抽奖过程中,判断奖品总数大于中奖次数,而后更新奖品总数,但这时还是会有抽奖的请求进来,一样会有线程并发的问题。想到的两种解决方式:

  • 先停止抽奖活动,再修改奖品数量?但是停止抽奖活动后,并不确定所有的抽奖请求是否已经被处理完毕?
  • 管理后台修改奖品也是使用redis+lua脚本,判断totalCount> hitcount,则更新redis 的remain count hash item,并同时更新数据库。

更改奖品数量的Lua脚本:

local hitCount=redis.call("HGET",KEYS[1],KEYS[4])
local remainCount=KEYS[5]-hitCount
if remainCount<0 then
   return -1
end
if(remainCount==0) then
   redis.call("HDEL",KEYS[2],KEYS[4])
   redis.call("HDEL",KEYS[3],KEYS[4])
   return 0
end
if (remainCount>0) then
   redis.call("HSET",KEYS[2],KEYS[4],remainCount)   
   redis.call("HSET",KEYS[3],KEYS[4],KEYS[6])
   return remainCount
end

如果更新缓存成功,但是更新数据库失败的话,采用监控报警,人工干预的方式介入处理。
利用redis+lua的原子性,将抽奖功能重构无锁实现,并通过引入token重试机制,在并发出现奖品被抽光是立即返回;而不是基于租户级别的分布式悲观锁,造成tomcat大量线程被block住,拖慢整个应用程序。

posted on 2016-04-27 22:21  raymond_kop  阅读(249)  评论(0)    收藏  举报

导航