抽奖

最后更新于:2022-04-02 04:33:02

## 抽奖 抽奖类似秒杀,但又不同,虽然抽奖针对的是单个活动,但是有不同的奖品,不同的库存。 也类似群红包,有概率的成分,但奖品不是临时制成的而是提前设置好概率而已,并且不是每个人都能中奖。 ---- ### 活动初始化: 每日 0点初始化: 1. 活动数据 放到 redis key: activity.1 data: dataJson 2. 奖品库存 放到 redis key: activity:prize:stock.1 data: (total - issued_num) key: activity:prize:day_stock.1 data: (day_max_issued_num - 今日已发放数量) > 相当于有两个库存 3. 奖品列表 放到 redis key: activity:prizes.1 data: dataJson (id,status,probability,type,attr_json) > 这些数据如果后台有更新时,需要有相应的机制同步跟更新 redis ------ ### 伪代码 ``` if 活动时间 if 用户今日参与次数 INCR(k) < num 允许参与 摇奖 概率计算 ( 1. 取得全部 有库存 且 今日发放次数没超限 的奖品 2. 根据概率摇奖 // 奖品列表如果没有,那么中奖概率一直都是0 ) if 未中奖 参与记录落盘(未中奖) else 中奖 奖品发放 if 目标奖品是否有库存 && 是否发放超限 if 扣减库存:两个库存扣减成功 参与记录落盘(中奖) else 重新摇奖(注意恢复库存扣减) else 重新摇奖 ``` > 每日发放数量有限,其实也相当于一个库存,每日库存 ---- ### 分析 其实单纯用数据库也能控制不超卖,但数据库在并发场景下性能非常差,update都是锁,所以主要目的是将请求挡在mysql之上,尽量不与数据库直接操作,只在真正要落盘时才去读写数据库。 秒杀方案中,如果 redis 挂了怎么办,其实没问题,挂了只是 程序活数据 崩了,这和 程序执行过程中的上下文 没什么区别,我们 业务数据只要完整落盘了 就没问题,就是安全的,加载到 redis 中的只是程序程序运行中要使用到的数据而已,可以叫 程序数据 或者是 方案数据 ,运行数据 都行,这并不是业务数据,理解这点很重要。类似mq消息也是这样,只要业务数据安全,业务就是安全的。 redis 挂了就是服务不可用了,活动就不能进行,就需要暂停活动,暂停业务了,等人工修复完成了才能继续活动。 `INCR`/`DECR`相当于mysql的 update 和 select 并且是加锁了的,相当于两条命令合成一条原子性命令了。 ---- ### 方案实现 上面使用incr太麻烦了,还要处理”减回来“,如果要操作多个key更麻烦,还是使用执行lua脚本的方式更好,简单直观。 **表定义:** ```sql # 抽奖活动 表 DROP TABLE IF EXISTS `s_luck_activity`; CREATE TABLE `s_luck_activity` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `title` varchar(255) NOT NULL DEFAULT '' COMMENT '活动标题', `start_time` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '活动有效期:开始', `end_time` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '活动有效期:结束', `desc` varchar(500) NOT NULL DEFAULT '' COMMENT '活动备注', `create_time` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间', `update_time` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '更新时间', `no_prize` smallint(1) NOT NULL DEFAULT 0 COMMENT '活动不中奖概率(1 ~ 10000)', `rule_json` varchar(500) NOT NULL DEFAULT '' COMMENT '规则配置', `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '活动状态:0-正常,1-停用', PRIMARY KEY (`id`) ) ENGINE=innodb AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='抽奖活动 表'; -- 抽奖活动 奖品 表 DROP TABLE IF EXISTS `s_luck_prize`; CREATE TABLE `s_luck_prize` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `activity_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '活动id', `title` varchar(255) NOT NULL DEFAULT '' COMMENT '奖品名称', `img` varchar(255) NOT NULL DEFAULT '' COMMENT '奖品图片', `desc` varchar(500) NOT NULL DEFAULT '' COMMENT '奖品描述', `total` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '总数量', `day_max_issued_num` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '每日最多发放数量', `issued_num` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '已发放数量', `probability` tinyint(1) NOT NULL DEFAULT 0 COMMENT '奖品中奖概率(1 ~ 100)', `type` tinyint(1) NOT NULL DEFAULT 0 COMMENT '奖品类型:0-普通奖品,1-红包(中奖后会自动发放)', `attr_json` text NULL COMMENT '属性,普通红包没有这个', `create_time` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间', `update_time` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '更新时间', `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '奖品状态:0-正常,1-下架', PRIMARY KEY (`id`) ) ENGINE=innodb AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='抽奖活动 奖品 表'; # 抽奖记录 表 DROP TABLE IF EXISTS `s_luck_record`; CREATE TABLE `s_luck_record` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `activity_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '活动id', `user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '用户id', `prize_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '奖品id', `create_time` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间', `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '活动状态:0-未中奖,1-中奖', PRIMARY KEY (`id`) ) ENGINE=innodb AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='抽奖记录 表'; ``` **lua脚本(解决并发问题,保证不超卖的核心):** lua脚本执行方式保证了批量的命令被整体的串行化了,所以不会有并发问题,本质上可以理解为用了锁,锁的本质就是串行排队。 为什么 lua可以做到批量命令的串行? > 不是lua提供了批量命令的串行,而是 `redis.exex` 命令以 lua脚本为参数了而已,本质上 `redis.exex` 是一个命令,和其他命令一样,其本身就是串行的,而现在这个命令就是以 批量命令为参数 ```lua -- lua脚本: 抽奖功能 -- last update: 2019-08-16 18:51:52 -- 奖品:单个奖品 库存 local prizeSurplus -- 奖品:单个奖品 每日 发放数量 local prizeIssuedDay -- 活动:单个活动 每日每用户 中奖次数 local activityWinningDay local day = tostring(ARGV[1]) local userId = tonumber(ARGV[2]) local activityId = tonumber(ARGV[3]) local prizeId = tonumber(ARGV[4]) -- 活动:没人每日中奖次数限额 local activityWinningDayQuota = tonumber(ARGV[5]) -- 奖品每日发放限额 local prizeIssuedDayQuota = tonumber(ARGV[6]) -- 奖品:单个奖品 库存 key local prizeSurplusKey = "luck:prizeSurplus:" .. prizeId -- 奖品:单个奖品 每日 发放数量 key local prizeIssuedDayKey = "luck:prizeIssuedDayKey:" .. day .. '_' .. prizeId -- 活动:单个活动 每日每用户 中奖次数 key local activityWinningDayKey = "luck:activityWinningDay:" .. day .. '_' .. activityId .. '_' .. userId ------------------------------------------[1]------------------------------------------ -- 活动: 初始化 每日中奖次数 activityWinningDay = redis.call("GET", activityWinningDayKey); if activityWinningDay == false then activityWinningDay = 0 end activityWinningDay = tonumber(activityWinningDay) -- 活动: 判断每日中奖次数是否超额 if activityWinningDayQuota > 0 then if activityWinningDay >= activityWinningDayQuota then -- 活动: 每日中奖次数超额 return 1 end end ------------------------------------------[2]------------------------------------------ -- 奖品: 初始化 今日已发放数量 prizeIssuedDay = redis.call("GET", prizeIssuedDayKey); if prizeIssuedDay == false then prizeIssuedDay = 0 end prizeIssuedDay = tonumber(prizeIssuedDay) -- 奖品: 判断今日已发放数量 if prizeIssuedDayQuota > 0 then if prizeIssuedDay >= prizeIssuedDayQuota then -- 奖品: 超过每日最大发放数量 return 2 end end ------------------------------------------[3]------------------------------------------ -- 奖品: 初始化 库存 prizeSurplus = redis.call("GET", prizeSurplusKey); if prizeSurplus == false then -- 奖品: 库存还没准备 return 3 end prizeSurplus = tonumber(prizeSurplus) -- 奖品: 判断奖品是否有库存 if prizeSurplus <= 0 then -- 奖品: 没有库存了 return 4 end ------------------------------------------[4]------------------------------------------ -- 奖品: 扣减库存 redis.call("DECR", prizeSurplusKey); -- 奖品: 今日已发放数量 +1 redis.call("INCR", prizeIssuedDayKey); -- 活动: 增加中奖次数 +1 redis.call("INCR", activityWinningDayKey); return 0 ``` **抽奖逻辑:** ```php db = $GLOBALS['db']; $this->redis = $GLOBALS['redis']; $this->userId = $_SESSION['user_id']; $this->activityId = $activityId; $this->day = local_date('Y-m-d'); $this->time = time(); // 用户参与抽奖时,实例化此类时,活动数据就从redis加载到此类中作为属性了(私有内存数据,程序数据),与所有外部隔离 // 除了库存外,不会再从外部读任何数据了 // 即使想更新redis也请在 此类被初始化之前,否则这里过后是不会再读活动数据了,就没有机会再干预正在进行的抽奖了 // 当然如果外部修改了库存,则情况会复杂一些,后面再详细讨论 $this->activity = $this->redis->get('luck:activity:' . $this->activityId); $this->prizeList = $this->redis->get('luck:prizes:' . $this->activityId); } public function setActivityId($activityId) { $this->activityId = $activityId; } // 初始化活动,给定时任务用的 public function init() { $this->_loadData(); } // 缓存脚本 public function _scriptLoad() { $scriptStr = file_get_contents(__DIR__ . '/luck.lua'); $tag = $this->redis->script('load', $scriptStr); // 这个 $tag 其实时 scriptStr 哈希 $this->redis->set('luck:script_luck_lua_tag', $tag); return $tag; } // 执行lua脚本 public function _evalSha($prizeId) { $tag = $this->redis->get('luck:script_luck_lua_tag'); // 现在需要调试lua代码,等上线时再打开 // if (!$tag || !$this->redis->script('exists', $tag)[0]) { $tag = $this->_scriptLoad(); // } return $this->redis->evalSha($tag, [ $this->day, $this->userId, $this->activityId, $prizeId, $this->activity['rule_data']['u_d_winning_num'], // 活动每日最大参与次数 $this->prizeList[$prizeId]['day_max_issued_num'], // 奖品每日最大发放数量 ], 0); } // 摇奖 public function shake() { $db = $this->db; $time = $this->time; if (true !== ($_check = $this->_checkActivityQualification())) { return $_check; } $res = []; // 摇奖 $_prizeList = $this->_getPrizeList(); // 奖品完了,提示没中奖就行了 if (empty($_prizeList)) { return ['error' => 6, 'message' => '很遗憾,未中奖']; } $_probabilityRes = $this->_probability($_prizeList); // 是否中奖标记 $_isLuck = false; if (!$_probabilityRes) { // 没中 $res = ['error' => 6, 'message' => '很遗憾,未中奖']; } else { $prizeId = $_probabilityRes['id']; // 扣减库存 // 扣减成功 中奖 $code = $this->_evalSha($prizeId); $scriptRes = $this->_parseScriptRes($code); if ($scriptRes['error'] === 0) { // 这里才是真正的中奖 $_isLuck = true; } else { $res = $scriptRes; } } if ($_isLuck) { // 中奖 // 开启事务,数据落盘(更新奖品的已发放数量 +1) // 参与人很多,中奖概率很小,所以这里访问量不大,直接落盘应该没事,后面有性能问题再用MQ去落盘就行了 $errMsg = ''; try { $db->startTrans(); $db->lock(true)->getRow("SELECT id FROM s_luck_prize WHERE id = {$prizeId} "); $db->autoExecute('s_luck_prize', [ 'issued_num' => ['exp', '`issued_num` + 1'], ], 'UPDATE', " id = {$prizeId} "); $db->commit(); } catch (Exception $e) { $db->rollback(); $errCode = $e->getCode(); $errMsg = $e->getMessage(); } $num = count($list); if ($errMsg == '') { $res = ['error' => 0, 'message' => '恭喜,您中奖了', 'data' => $this->prizeList[$prizeId]]; } else { // 更新db数据失败,应该不会出现这种清空,redis库存扣减成功了,db落盘失败了,上线了不允许出现这种问题 $res = ['error' => $errCode, 'errMsg' => $errMsg]; // 严重错误:做个日志 trace($res, '>luck-error'); } } $insertData = [ 'activity_id' => $this->activityId, 'user_id' => $this->userId, 'prize_id' => $prizeId, 'create_time' => $time, 'status' => $_isLuck ? 1 : 0, ]; if ($_isLuck) { // 中奖时,所中奖品,随之快照落盘,因为后面奖品可能会被编辑更新,所以必须进行快照 $insertData['prize_json'] = json_encode($this->prizeList[$prizeId], JSON_UNESCAPED_UNICODE); } // 记录抽奖记录,顺序插入性能应该也不是问题,(后面有性能问题时可以移到MQ中去) // db性能问题主要是更新 $db->autoExecute('s_luck_record', $insertData, 'INSERT'); // 写入抽奖记录 return $res; } // 解析lua脚本返回的状态码 public function _parseScriptRes($code) { $_list = [ 0 => '库存扣减成功', // 只有这种状态才认为是本次真正中奖了 1 => '活动: 超过每日中奖次数限制', 2 => '奖品: 超过每日最大发放数量', 3 => '库存还没准备', // 活动没有初始化好 // 好巧不巧,中的奖品没有库存了,这种情况很少,相当于是两个人都中同一个奖品了,但奖品只剩下一个,第二个人就自认倒霉了 4 => '来迟一步,奖品派发完了', ]; if ($code === false) { // 脚本错误 return ['error' => 444, 'message' => '服务忙,请稍后再试']; } if (isset($_list[$code])) { return ['error' => $code === 0 ? $code : (500 + $code), 'message' => $_list[$code]]; } } // 装载数据 public function _loadData() { $this->_loadActivityData(); $this->_loadPrizeData(); } // 装载活动数据 public function _loadActivityData() { $activity = $this->db->lock(true)->getRow("SELECT * FROM s_luck_activity WHERE id = {$this->activityId} AND state = 1 AND status = 0 "); if ($activity) { $activity['rule_data'] = json_decode($activity['rule_json'] ?: '{}', true); // 活动信息缓存 $this->redis->set('luck:activity:' . $this->activityId, json_encode($activity, JSON_UNESCAPED_UNICODE)); } } // 装载奖品数据 public function _loadPrizeData() { $_prizeList = []; $prizeList = $this->db->lock(true)->getAll("SELECT * FROM s_luck_prize WHERE activity_id = {$this->activityId} AND status = 0 "); foreach ($prizeList as $item) { $item['attr_data'] = json_decode($item['attr_json'] ?: '{}', true); $_prizeList[$item['id']] = $item; // 每个奖品的库存 入库 $prizeSurplus = $item['total'] - $item['issued_num']; // 总数 - 已发放 $this->redis->set("luck:prizeSurplus:" . $item['id'], $prizeSurplus); // 注意: 每日已发放不能初始化 } if ($_prizeList) { // 奖品列表缓存 $this->redis->set('luck:prizes:' . $this->activityId, json_encode($_prizeList, JSON_UNESCAPED_UNICODE)); } } // 清除活动缓存数据 public function clean() { $this->redis->del('luck:activity:' . $this->activityId); $this->redis->del('luck:prizes:' . $this->activityId); // 活动参与计数暂时不清除(如果活动参与计数被清空,会导致计数重置,跳过参与次数拦截) // 奖品库存数据暂时不清除(如果库存被清空了,会导致提示活动未准备) } // 检测活动参与资格 public function _checkActivityQualification() { $time = $this->time; if (empty($this->activity) || empty($this->prizeList)) { return ['error' => 1, 'message' => '活动暂未开始']; } if ($this->activity['start_time'] > $time) { return ['error' => 2, 'message' => '活动暂未开始']; } if ($this->activity['end_time'] < $time) { return ['error' => 3, 'message' => '活动已经结束']; } if ($this->activity['status'] != 0 || $this->activity['state'] != 1) { return ['error' => 4, 'message' => '活动已经结束']; } // todo:参与资格检测 $participationKey = 'luck:participation:' . $this->day . '_' . $this->activityId . '_' . $this->userId; // 注意: 这样有并发问题 // if ($this->redis->get($participationKey) >= 5) { // return ['error' => 5, 'message' => '今天的抽奖机会用完了,请明天再来']; // } // 0 为不限制 $u_d_participation = $this->activity['rule_data']['u_d_participation']; // incr = set && get 是原子性的 也就没有并发问题了 if ($u_d_participation != 0 && ($this->redis->incr($participationKey) > $u_d_participation)) { // 参与额度用完了, 不应该计数, 注意减回来 $this->redis->decr($participationKey); return ['error' => 5, 'message' => '今天的抽奖机会用完了,请明天再来']; } return true; } // 取得参与摇奖的奖品列表 public function _getPrizeList() { // 奖品满足条件:总库存 > 0 && 今日已发放数量 < 每日最多发放数量 $_prizeList = []; foreach ($this->prizeList as $item) { $prizeSurplusKey = "luck:prizeSurplus:" . $item['id']; $prizeIssuedDayKey = "luck:prizeIssuedDayKey:" . $this->day . '_' . $item['id']; $prizeIssuedDay = $this->redis->get($prizeIssuedDayKey) ?: 0; // 没开始发时未0 // 注意: 这里其实有并发问题,不过没事,这里只是初步的拦截,真正摇奖在lua脚本中,是串行的,所以最终是没有并发问题的 // 排除没有库存的 if ($this->redis->get($prizeSurplusKey) > 0 && $prizeIssuedDay < $item['day_max_issued_num']) { $_prizeList[] = $item; } } return $_prizeList; } // 概率计算 public function _probability($prizes) { $_probability = []; foreach ($prizes as $item) { $_probability[] = $item['probability']; } // 最后加上活动的不中奖概率 $_probability[] = $this->activity['no_prize']; $index = $this->_get_rand($_probability); // 获取中奖 index if ($index == (count($_probability) - 1)) { return false; // 没中奖 } return $prizes[$index]; // 返回中奖商品 } /** * 概率算法 * proArr array(10, 20, 30, 40) */ public function _get_rand($proArr) { $result = ''; $proSum = array_sum($proArr); foreach ($proArr as $key => $proCur) { $randNum = mt_rand(1, $proSum); if ($randNum <= $proCur) { $result = $key; break; } else { $proSum -= $proCur; } } unset($proArr); return $result; } } ``` **上面的概率算法其实存在问题:** 概率算法有问题,如果几个连着的奖品的中奖率一样,则相同的后面的一个不会中奖,因为遍历就决定了返回顺序,用于是第一个就返回了,后面的没有机会,可以解决这个问题,利用:array_count_values array_rand 可以做到,出现相同值的项时,在进行一次随机 概率算法虽然有缺陷,但我们也可以绕过这个缺陷,_probability() 里面 自己将 奖品列表 shuffle 打乱一次既可,这样就相当于提前有一次随机了,也算是公平了(而不是从数据库查出来的顺序或者其他情况决定,这是算法之外不公平的根本原因),并且考虑到 中 不中奖的概率很大,可以把 它放在最前面,也能提高效率 ---- ### 参考资料 [Redis Lua实战 - 简书](https://www.jianshu.com/p/366d1b4f0d13) [利用Redis和Lua的原子性实现抢红包功能 - 简书](https://www.jianshu.com/p/b58ed2fe6976?utm_campaign=maleskine&utm_content=note&utm_medium=seo_notes&utm_source=recommendation) [42丨如何使用Redis来实现多用户抢票问题 | 极客时间](https://time.geekbang.org/column/article/132851) [(3) 秒杀系统优化方案之缓存、队列、锁设计思路 - 唐成勇 - SegmentFault 思否](https://segmentfault.com/a/1190000008888926) [(3) Redis使用lua脚本 - 飞鸿影下 - SegmentFault 思否](https://segmentfault.com/a/1190000016753833) [(3) php和redis设计秒杀活动 - - SegmentFault 思否](https://segmentfault.com/a/1190000019778733) [(3) 【redis进阶(1)】redis的Lua脚本控制(原子性) - 菜问专栏 - SegmentFault 思否](https://segmentfault.com/a/1190000019676878) [Redis递增递减功能 - 亻也倔强小男人 - CSDN博客](https://blog.csdn.net/qq_25218095/article/details/79723531) [(3) Redis 中 Lua 脚本的应用和实践 - 燕南飞和你一起聊技术 - SegmentFault 思否](https://segmentfault.com/a/1190000018070172) [事务(transaction) — Redis 命令参考](http://redisdoc.com/topic/transaction.html#redis) [redis中如何保证原子性_Redis](https://www.sohu.com/a/324014689_120047065#cmid=249183) > 纠错,redis事务并不是原子性的,单个命令是串行的原子的(lua脚本除外,也不是原子性的) [高性能分布式锁-redisson的使用 - webwangbao - 博客园](https://www.cnblogs.com/webwangbao/p/9247318.html) [分布式锁设计与实现](https://mp.weixin.qq.com/s?__biz=MzIwNTI2ODY5OA==&mid=2649938438&idx=1&sn=ec19c1f1cdd161dad8d5cf8fd89637f4&chksm=8f3509b3b84280a5c6750343a094657817b72b92f70787253e1accfa636e61db1316c79d0955&scene=21#wechat_redirect) [从Redis异步到反应式架构 - 知乎](https://zhuanlan.zhihu.com/p/77328969) ---- ### 思考:活动进行中修改活动数据 假设活动正在进行中,活动、奖品数据已经装载到redis了,此时更新活动、奖品数据会发生什么: 1. 更新活动数据(名称,规则配置,状态等) 2. 更新奖品数据(总数,概率,名称,下架状态,删除) 3. 新增奖品 所有更新操作都是,先更新数据库,再更新redis(假设redis都能更新成功,不成功也有补偿机制保证最新数据一定会从db刷到redis) 这里问题的关键在于,活动进行中可能也有db落盘,也有redis更新,而我们更新活动数据也会有db更新和redis操作,这之间可能出现并问题,导致设计的并发方案出问题 中奖后,活动数据和奖品数据需要随之快照,不能只存奖品id因为奖品后面可能会更新 另外,活动奖品数据都不会物理删除的 用户参与抽奖时,实例化 `Luck类` 时,活动数据就从redis加载到此类中作为属性了(私有内存数据,程序数据),与所有外部隔离 除了库存外,不会再从外部读任何数据了 即使想更新redis也请在 此类被初始化之前,否则这里过后是不会再读活动数据了 当然如果外部修改了库存,则情况会复杂一些,后面再详细讨论 #### 相对先后性理论 >[tip] 任何事物都有个相对先后性,通常以最后一次确认为证,当然最大程度的确认能保证最大程度的准确性,但这也是有极限和代价的,这里最大程度的确认就是 `luck类` 实例化时从redis加载进来的数据,和 摇奖时 时间状态等再判断了,毕竟到这里不能再查数据库了(整个抽奖就是要设计成不查数据库),所以这是这里为 **“最大程度的确认”** 所能尽的最大努力了(也是最后的努力)。 > > 如果redis数据装载进来后,已经执行到抽奖概率算法那里时(假设执行了几秒),活动刚好到期了,这里也无视,仍是以当前活动是有效的为准。这是基本的事实,任何讨论都应该基于这个事实,否则再无法进行下去,就又回到了时间极限的问题上去了。 ---- last update: 2019-08-16 18:51:52
';