使用redis zset實現抽獎,獎池商品按時間隨機分佈
話不多說,直接上需求描述:
最近需要上一期活動,這個活動是以轉盤抽獎為形式的抽獎活動,要求每個使用者用積分進行抽獎,且中獎率為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]); }