redis高併發抽獎
最近一直在忙,只能抽空週末把程式碼擼了出來。週一才來寫這篇文章。程式碼有點繚亂,沒時間整理,如果有誤還請留言斧正。現在進入正題。
一、思路
1.獎品:
獎品分為獎品id(編號)、count(數量)、pointVal(價值)、remainCount (剩餘數量)分為四個引數組成。
2.概率規則:
單個產品剩餘數量/總產品剩餘數量=單個產品概率*1000,這也就意味著每個獎品的概率都是隨著數量變化的,這時候計算概率對資料在時間維度要求必須一致(資料快照)。
每個產品概率相加=1000,如果沒有等於1*概率相乘的數說明資料快照有問題(我這裡是乘1000所以等於1000)。
3.設計思路:
這個demo,我用的是取之於民用之於民的想法寫的,保證每一個人都會中獎。是不是很開心???。首先我們得有甲乙雙發,甲方就是抽獎系統的提供方,乙方就是我們這些抽獎的小百姓。甲方會先提供獎品出來,我這裡是一,二,三,四獎項,有一個預設五等獎獎項(demo裡面有一,二,三,四,五個獎項)。我會先計算甲方提供每個獎品的數量、價值然後計算所有獎品的總價值,然後用 總價值/每次抽獎的分數=總抽獎次數。總抽獎次數 - 每個獎品的 = 預設五等獎次數。這樣我們就算出了所有獎品的數量(包括預設五等獎也就是安慰獎的次數)。沒當抽獎總次數==0的時候就會自動輪詢補充庫存開始新的一輪抽獎。
中獎公式 :
總抽獎消耗積分上限值/每次抽獎消耗固定積分 = 總抽獎次數
總抽獎次數 - 已經抽獎數量 = 剩餘抽獎次數
獎品型別 * 獎品數量 = 獎品總數量
獎品總數量 - 已中獎數量 = 剩餘獎品數量
剩餘抽獎次數/剩餘獎品數量 = 剩餘獎品中獎概率
4.降級問題:
這個一開始我也考慮過使用降級處理,以防伺服器併發過大GG。不過我考慮到跟我的設計思路不符合,會影響到概率的公平性,我就把那部分去掉了(如果你們專案需要可以自己進行降級,限流,不過這會損失一部分概率的公平性,直接導致的結果就真正的獎品往往都是在最後面出現)。
5.注意事項:
特別強調一點,每次輪詢的時候判斷數量一定要用 == 不用用 <= 週末吃過這個虧,結果看輪詢資料快照的時候偶爾來個 -1,特蛋疼。花了很多時間排除問題。總結一點就是涉及的數量紅線變更的必須用精確判斷,不能範圍判斷。
6.技術難點:
一、概率的計算
二、庫存變更
三、輪詢策略
基本就上面三個點,我這裡是靈活使用redis 做快取共享(也可以使用db的悲觀鎖、樂觀鎖、版本號),使用了其中管道技術做時間維度上的獎品資料快照解決概率計算的正確性;事物技術解決庫存變更;兩者結合解決了輪詢策略問題。做到了監控每個輪詢前每一個獎品數量,精確到每個輪詢每個使用者所中的獎品和順序。
二、程式碼乾貨
獎品類:
public class Award { /**編號*/ public String id; /**數量(該類獎品數量)*/ public int count; /**價值(該類獎品價值積分)*/ public int pointVal; /**剩餘數量(該類獎品剩餘數量)*/ public int remainCount; public String getId() { return id; } public void setId(String id) { this.id = id; } public int getCount() { return count; } public void setCount(int count) { this.count = count; } public int getPointVal() { return pointVal; } public void setPointVal(int pointVal) { this.pointVal = pointVal; } public int getRemainCount() { return remainCount; } public void setRemainCount(int remainCount) { this.remainCount = remainCount; } /** * * @param id *編號* * @param count 數量(該類獎品數量)* * @param pointVal 價值(該類獎品價值積分)* * @param remainCount 剩餘數量(該類獎品剩餘數量) */ public Award( String id, int count, int pointVal, int remainCount) { this.id = id; this.count = count; this.pointVal = pointVal; this.remainCount = remainCount; } @Override public String toString() { return "Award [id=" + id + ", count=" + count + ", pointVal=" + pointVal + ", remainCount=" + remainCount + "]"; } }
概率計算:
詳細演算法可以看看這篇文章。
import java.util.ArrayList; import java.util.List; import java.util.Random; import java.util.Set; import redis.clients.jedis.Jedis; import redis.clients.jedis.Pipeline; /** * * @author XiongYC * @date 2017年12月3日 * */ public class LotteryUtil { public static String lottery(Jedis jedis) { try { /** * 讀取redis 快取引數(使用watch 確保資料準確性) */ Set<String> set = jedis.smembers(TestDemo.PRIZE_LIST); //獲取獎品列表(id) List<String> list = new ArrayList<String>(set.size()+1); //獎品列表(有序) //使用管道技術一次性獲取所有獎品數量確保資料完整性和概率計算的正確性 Pipeline p = jedis.pipelined(); for (String id : set) { list.add(id); //增加獎品列表 p.get(id);//獲取換成獎品數量 } list.add(TestDemo.RESIDUAL_QUANTITY); p.get(TestDemo.RESIDUAL_QUANTITY); List<Object> list1= p.syncAndReturnAll();//獲取所有獎品的剩餘數量 int totailCount = Integer.valueOf(String.valueOf(list1.get(list1.size()-1))); //獲取剩餘獎品總數 if (totailCount == 0) { // 重置獎品 TestDemo.initData(jedis); return "-1"; } // 儲存每個獎品新的概率區間 List<Float> proSection = new ArrayList<Float>(); proSection.add(0f); //起始區間 float totalPro = 0f; // 總的概率區間 for (int i = 0; i < list1.size()-1; i++) { // awardCount += Float.valueOf(jedis.get(id)); //計算獎品現有總數量 //彈性計算每個獎品的概率(剩餘獎品數量/剩餘總獎品數量) 每個概率區間為獎品概率乘以1000(把三位小數換為整) totalPro += (Float.valueOf(String.valueOf(list1.get(i))) / Float.valueOf(String.valueOf(list1.get(list1.size()-1)))) * 1000; proSection.add(totalPro); } // 獲取總的概率區間中的隨機數 Random random = new Random(); float randomPro = (float) random.nextInt((int) totalPro); for (int i = 0, size = proSection.size(); i < size; i++) { if (randomPro >= proSection.get(i) && randomPro < proSection.get(i + 1)) { return list.get(i); } } } catch (Exception e) { System.err.println("概率之外計算錯誤" + e.getMessage()); return null; } return null; } }
庫存共享變更:
import java.util.List; import java.util.UUID; import redis.clients.jedis.Jedis; import redis.clients.jedis.Transaction; /** * * @author XiongYC * @date 2017年12月3日 * */ public class MyRunnable1 implements Runnable { private Jedis jedis = RedisPoolUtils.getJedis(); @Override public void run() { try { // 查詢剩餘獎品總數 String key = getPrize(); System.err.println("執行緒" + Thread.currentThread().getName() + "中獎獎品id為:" + key); } catch (Exception e) { System.err.println("演算法計算異常:異常原因 = " + e.getMessage()); } finally { RedisPoolUtils.returnResourceObject(jedis); } } private String getPrize() { String key = LotteryUtil.lottery(jedis); //獲取中獎獎品ID jedis.watch(key,TestDemo.RESIDUAL_QUANTITY); //精確監控單個獎品剩餘數 if("-1".equals(key) || "0".equals(jedis.get(key))){ jedis.unwatch(); key = getPrize(); }else{ // key = AvailablePrize(key); Transaction tx = jedis.multi(); //開啟redis事物 tx.incrBy(TestDemo.RESIDUAL_QUANTITY, -1); //減少總庫存 tx.incrBy(key, -1); //減少中獎獎品總庫存 List<Object> listObj = tx.exec(); //提交事務,如果此時watch key被改動了,則返回null if (listObj != null) { //多個程序同時 key>0 key相等時 // String useId = UUID.randomUUID().toString(); jedis.sadd("failuse", UUID.randomUUID().toString() + key); System.out.println("使用者中獎成功!!!"); //中獎成功業務邏輯 } else { key = getPrize(); //重新計算獎品 } } return key; } //是否是有效獎品 // private String AvailablePrize(String key) { // int prizeNum = Integer.valueOf(jedis.get(key)); // // //獎品無效重新計算驗證 // if(prizeNum <= 0){ // AvailablePrize(LotteryUtil.lottery(jedis)); // } // return key; // } }
初始化:
import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import redis.clients.jedis.Jedis; import redis.clients.jedis.Pipeline; import redis.clients.jedis.Transaction; /** * * @author XiongYC * @date 2017年12月3日 * */ public class TestDemo { /** * 安慰獎 */ public static final String CONSOLATION_PRIZE_BY_ID= "2017112400005"; /** * 快取總次數 安慰獎+獎品 */ public static final String RESIDUAL_QUANTITY= "residualQuantity"; /** * 獎品列表(ID) */ public static final String PRIZE_LIST= "prizeList"; /** * 每日抽獎所需積分 */ public static final String POINTS_REQUIRED_FOR_THE_LOTTERY = "pointsRequiredForTheLlottery"; /** * 輪詢成功次數 */ public static final String POLLING_SUCCESS_NUM = "POLLING_SUCCESS_NUM"; /** * 輪詢失敗次數 */ public static final String POLLING_FAIL_NUM = "POLLING_FAIL_NUM"; /** * 輪詢獎品數量快照 */ public static final String SNAPSHOT_LIST = "SNAPSHOT_LIST"; /** *中獎使用者記錄 */ public static final String FAILUSE = "FAILUSE"; /** * 從redis連線池獲取連線 */ private static Jedis jedis = RedisPoolUtils.getJedis(); public static void main(String[] args) { try { // jedis.del(POLLING_SUCCESS_NUM,POLLING_FAIL_NUM,SNAPSHOT_LIST,FAILUSE); // jedis.set(RESIDUAL_QUANTITY, "0"); jedis.flushAll();//清除資料庫 // 初始化獎品引數 initData(jedis); ExecutorService executor = Executors.newFixedThreadPool(50); for (int i = 0; i <2400; i++) { executor.execute(new MyRunnable1()); } executor.shutdown(); } catch (Exception e) { System.err.println("ERROR_MSG = " + e.getMessage()); }finally{ if(jedis!=null){ RedisPoolUtils.returnResourceObject(jedis);//釋放redis資源 } } } /** * 初始化獎品引數 */ public static void initData(Jedis jedis) { Award award1 = new Award("2017112400001", 1, 2000, 1); //一等獎 Award award2 = new Award("2017112400002", 2, 1500, 2); //二等獎 Award award3 = new Award("2017112400003", 3, 1000, 3); //三等獎 Award award4 = new Award("2017112400004", 4, 500, 4); //四等獎 List<Award> list = new ArrayList<Award>(); list.add(award1); list.add(award2); list.add(award3); list.add(award4); int tailCount = 0; //總獎品數 int tailPoint = 0; //總獎品總積分值 /** * 讀取redis 快取引數(使用watch 確保資料準確性) */ Set<String> set = jedis.smembers(TestDemo.PRIZE_LIST); //獲取獎品列表(id) //輪詢快照 Pipeline p = jedis.pipelined(); for (String id : set) { p.get(id);//獲取換成獎品數量 } p.get(RESIDUAL_QUANTITY); List<Object> temp = p.syncAndReturnAll();//獲取所有獎品的剩餘數量 //開始初始化快取獎品資料 for (int i = 0; i < list.size(); i++) { jedis.sadd(PRIZE_LIST, list.get(i).id); //快取獎品列表 jedis.set(list.get(i).id, String.valueOf(list.get(i).count));//快取獎品數量 tailCount +=list.get(i).count; //計算總獎品數 tailPoint += list.get(i).count* list.get(i).pointVal; //計算獎品總積分值 } jedis.set(POINTS_REQUIRED_FOR_THE_LOTTERY, "500"); //每次抽獎所需積分 int residualQuantity = tailPoint / Integer.valueOf(jedis.get(POINTS_REQUIRED_FOR_THE_LOTTERY)); //計算未中獎次數(安慰獎) int missesNum = residualQuantity - tailCount; //安慰劑次數 jedis.watch(RESIDUAL_QUANTITY); int count = 0; //判斷是否是初次輪詢 if(jedis.exists(RESIDUAL_QUANTITY)){ count= Integer.valueOf(jedis.get(RESIDUAL_QUANTITY)); } if(count == 0){ Transaction tx = jedis.multi(); tx.set(CONSOLATION_PRIZE_BY_ID, String.valueOf(missesNum)); //快取安慰獎次數 tx.sadd(PRIZE_LIST, CONSOLATION_PRIZE_BY_ID); //快取安慰獎到獎品列表 tx.set(RESIDUAL_QUANTITY, String.valueOf(residualQuantity)); // 快取總次數 安慰獎+獎品 List<Object> obj = tx.exec(); if (obj != null) { // 多個程序同時 key>0 key相等時 jedis.incrBy(POLLING_SUCCESS_NUM,1); jedis.sadd(SNAPSHOT_LIST, jedis.get(POLLING_SUCCESS_NUM)+temp.toString()); System.out.println("初始化成功=============================》!!!"); } else { jedis.incrBy(POLLING_FAIL_NUM,1); System.err.println("初始化失敗=============================》!!!"); } }else{ jedis.unwatch(); } } }
三、資料驗證
案例:根據以上demo我們可以看出有五個獎品(四個真正的獎品,一個安慰獎),我們根據設定的四個獎品可以得出 總積分價值為10000積分,每次抽獎500積分,一共需要抽20次完成一輪輪詢,其中真正的獎品為10個,其餘的10為安慰獎個數。
論證:現在我們用50個執行緒跑2400個請求
預期效果:我們會有2400/20=120次輪詢,每次輪詢獎品剩餘庫存資料快照為輪詢次數+[0,0,0,0,0],每次輪詢抽獎結果為一等獎1個,二等獎2個,三等獎3個。四等獎4個,五等獎10個(安慰獎),共20個獎品;
實際效果:
輪詢次數120次。
,--驗證通過
每次輪詢獎品剩餘庫存資料快照
我截取了最好一部分資料快照,可以看出資料條數是可以對上輪詢次數的,快照資料也是沒問題的。--驗證通過
每次輪詢抽獎結果 :我隨機抽取了一個迴圈的獎品記錄
- 初始化成功=============================》!!!
- 執行緒pool-1-thread-46中獎獎品id為:2017112400005
- 使用者中獎成功!!!
- 使用者中獎成功!!!
- 執行緒pool-1-thread-14中獎獎品id為:2017112400003
- 執行緒pool-1-thread-43中獎獎品id為:2017112400005
- 使用者中獎成功!!!
- 執行緒pool-1-thread-30中獎獎品id為:2017112400005使用者中獎成功!!!
- 執行緒pool-1-thread-36中獎獎品id為:2017112400001
- 使用者中獎成功!!!
- 執行緒pool-1-thread-13中獎獎品id為:2017112400002
- 使用者中獎成功!!!
- 執行緒pool-1-thread-49中獎獎品id為:2017112400005
- 使用者中獎成功!!!
- 執行緒pool-1-thread-5中獎獎品id為:2017112400005
- 使用者中獎成功!!!
- 執行緒pool-1-thread-25中獎獎品id為:2017112400005
- 使用者中獎成功!!!
- 執行緒pool-1-thread-35中獎獎品id為:2017112400003
- 使用者中獎成功!!!
- 執行緒pool-1-thread-18中獎獎品id為:2017112400003
- 使用者中獎成功!!!
- 使用者中獎成功!!!
- 執行緒pool-1-thread-25中獎獎品id為:2017112400004
- 使用者中獎成功!!!
- 執行緒pool-1-thread-38中獎獎品id為:2017112400005
- 使用者中獎成功!!!
- 執行緒pool-1-thread-15中獎獎品id為:2017112400004
- 使用者中獎成功!!!
- 執行緒pool-1-thread-48中獎獎品id為:2017112400004
- 使用者中獎成功!!!
- 執行緒pool-1-thread-7中獎獎品id為:2017112400005
- 使用者中獎成功!!!
- 執行緒pool-1-thread-37中獎獎品id為:2017112400002
- 使用者中獎成功!!!
- 執行緒pool-1-thread-12中獎獎品id為:2017112400004
- 執行緒pool-1-thread-31中獎獎品id為:2017112400005
- 使用者中獎成功!!!
- 使用者中獎成功!!!
-
執行緒pool-1-thread-1中獎獎品id為:2017112400005
後臺成功中獎記錄裡面條數也對應上了2400請求(有的細心的小夥伴可能會直接去快取裡面按行數來去驗證獎品數量,我這裡用的是集合,小夥伴們可以自行快取list儲存驗證)。--驗證通過。
以上就是全部內容了,有點糙,還望見諒。
MQ流量削峰 在這個場景要怎麼實現~