話不多說,直接上需求描述:
最近需要上一期活動,這個活動是以轉盤抽獎為形式的抽獎活動,要求每個用戶用積分進行抽獎,且中獎率為100%即不可出現不中任何獎品的情況,之后,又加了一個要求,即不能實行純隨機的抽取,如果如此會產生一個極端情況,如果開始的時候活動極其火爆由於隨機的不可控性頭一天用戶便將所有優質獎品全部抽走,那么后來的用戶將只會抽到保底獎品。
那么獎品就需要按時間分布在從活動開始到結束的時間段,其次需要做的是,在某些特殊的時間段,我們希望多投放一些獎品給用戶抽到。
需求分析:
那么開獎策略可以為為每個獎品設置開獎時間,只有在開獎后來抽獎才能抽到該獎品,否則視為未中獎發保底獎品,我們只需要拿當前時間與最接近獎品開獎時間對比即可。
由上需求,那么就需要一個容器來存放這些獎品,對這個容器的要求:
1. 它可以以時間軸為維度取出獎品;
2. 它可以以時間軸為維度放入獎品;
3.它可以以時間軸為維度將獎品排序;
同時,后台應該有地方配置每個小時應投放的獎品數量,同時為保證配置數據能及時生效,應當是每小時前去向獎品池投放下一個小時的獎品;
如下圖所示,每個獎品都有對應開獎時間,獎品1只有10000毫秒之后的請求才可以抽到,且只有獎品1抽走之后才可以抽獎品2;
抽獎步驟:
性能安全考慮:
顯然,抽獎是容易引發並發問題的場景,高並發情況往往會帶來兩個問題
1. 超發問題,例如將10個獎品發給了11個人,用鎖可解決;
2.數據庫等基礎組件負載過高導致宕機,以數據庫為例,如果每個用戶每抽走一個獎品都去連接數據庫更新庫存,數據庫很有可能承受不住(數據庫能承受的qps遠不如redis);
方案:
使用redis的zset數據結構,這里簡單說明下zset,它是一個基於跳表實現的有序集合,尤其適合排序場景比較多的場景,是一個典型的用空間換取時間的數據結構。這里我們用開獎時間戳作為score,保證其按照時間排序,存入的時候可以直接將獎品ID與時間戳存入其中即可。
同時設置定時任務,每個小時去拿下一個小時的所需的獎品,隨機將其散列在下一個小時的各個時間上,並在此時就將各個獎品庫存扣除。
ok,需求完美解決,鎖的問題直接上代碼,鎖就是保證zset的排序操作與移除操作是原子操作,否則便會出現超發,使用了redis的setNx做分布式鎖。
/** * 抽獎 * * @param turnTableNum 轉盤編號 * @return 獎品ID */ public long getLotteryResult(long userId, int turnTableNum, Map<Long, ActivityTurntableGoodsConfig> goodsConfigMap) { Set<String> prizeSet = null; String prizeResStr = null; try { if (RedisUtils .lock(RedisKey.TURNTABLE_PRIZE_QUEUE_LOCK, String.valueOf(turnTableNum))) { Set prizeSet = RedisClusterAccessor .zrangeByScore(RedisKey.TURNTABLE_PRIZE_QUEUE, String.valueOf(turnTableNum), 0, System.currentTimeMillis(), 0,1); if (null != prizeResStr) { //在獎池中移除獎品 log.debug("{} remove prize {} {}", XGameContextHolder.get(), turnTableNum, prizeResStr); RedisClusterAccessor .zrem(RedisKey.TURNTABLE_PRIZE_QUEUE, String.valueOf(turnTableNum), prizeResStr); } } } catch (Exception e) { throw e; } finally { RedisUtils.unlock(RedisKey.TURNTABLE_PRIZE_QUEUE_LOCK, String.valueOf(turnTableNum)); } if (null == prizeResStr) { return -1; } return CommonUtil.safeParseLong(prizeResStr.split("_")[0]); }