上一篇文章通過redis實現的搶紅包通過測試發現有嚴重的阻塞的問題,搶到紅包的用戶很快就能得到反饋,不能搶到紅包的用戶很久(10秒以上)都無法獲得搶紅包結果,起主要原因是:
1、用了分布式鎖,導致所有的操作只能順序排隊,而后面沒有搶到紅包的需要等待前面搶紅包的同學完事后他才能去看自己是否已經搶到紅包
2、多次與redis交互,消耗了很多時間(交互一次大概是幾十到上百毫秒),分布式鎖本身也需要和redis交互
所以通過仔細打磨,我決定通過lua表達式來達到縮減redis交互次數以及保證高並發情況下與redis多個交互命令的原子性
優化1、優化搶紅包流程
除了添加lua腳本來處理真正搶紅包的過程,去掉了分布式鎖,還在lua腳本中通過布隆過濾器校驗用戶是否搶過紅包
//搶紅包的過程必須保證原子性,此處加分布式鎖 //但是用分布式鎖,阻塞時間太久,導致部分線程需要阻塞10s以上,性能非常不好 //如果沒有紅包了,則返回 if (Integer.parseInt(redisUtil.get(redPacketId + TAL_PACKET)) > 0) {//有紅包,才能有機會去真正的搶 //真正搶紅包的過程,通過lua腳本處理保證原子性,並減少與redis交互的次數 // lua腳本邏輯中包含了計算搶紅包金額 //任何余額等瞬時信息都從這里快照取出,否則不准 //如果我們在這里分開寫邏輯,不保證原子性的情況下有可能造成前面獲取的金額后面用的時候紅包已經不是原來獲取金額時的情況了,並且多次與redis交互耗時嚴重 String result = grubFromRedis(redPacketId + TAL_PACKET, redPacketId + TOTAL_AMOUNT, userId, redPacketId); //准備返回結果
其中很多操作都壓縮到了lua腳本中
local packet_count_id = KEYS[1] -- 紅包余量ID local packet_amount_id = KEYS[2] -- 紅包余額ID local user_id = KEYS[3] -- 用戶ID 用於校驗是否已經搶過紅包 local red_packet_id = KEYS[4] -- 紅包ID用於校驗是否已經搶過紅包 -- grub local bloom_name = red_packet_id .. '_BLOOM_GRAB_REDPACKET'; -- 布隆過濾器ID local rcount = redis.call('GET', packet_count_id) -- 獲取紅包余量 local ramount = redis.call('GET', packet_amount_id) -- 獲取紅包余額 local amount = ramount; -- 默認紅包金額為余額,用於只剩一個紅包的情況 if tonumber(rcount) > 0 then -- 如果有紅包才做真正的搶紅包動作 local flag = redis.call('BF.EXISTS', bloom_name, user_id) -- 通過布隆過濾器校驗是否存在 if(flag == 1) then -- 如果存在(可能存在)這是個待優化點 return "1" -- 不能完全確定用戶已經存在 elseif(tonumber(rcount) ~= 1) then -- 不存在則計算搶紅包金額,並實施真正的扣減 local maxamount = ramount / rcount * 2; amount = math.random(1,maxamount); end local result_2 = redis.call('DECR', packet_count_id) local result_3 = redis.call('DECRBY', packet_amount_id, amount) redis.call('BF.ADD', bloom_name, user_id) return amount .. "SPLIT" .. rcount else return "0" end
優化2、優化回寫邏輯(用MQ替代更可靠、合適)
@Async @Transactional(propagation = Propagation.REQUIRES_NEW) public void callback(String userId,String redPacketId,int amount) throws Exception { log.info("用戶:{},搶到當前紅包:{},金額:{},回寫成功!", userId, redPacketId, amount); //新增搶紅包信息 //不能用自增ID,已經調整 RedPacketRecord redPacketRecord = new RedPacketRecord().builder() .user_id(userId).red_packet_id(redPacketId).amount(amount).build(); redPacketRecord.setId(UUID.randomUUID().toString()); redPacketRecordRepository.save(redPacketRecord); }
中間發現高並發情況下JPA+mysql自增ID有嚴重的死鎖問題
所以調整了兩個表的主鍵生成邏輯:
@MappedSuperclass @Data @NoArgsConstructor @AllArgsConstructor public class BaseEntity { @Id //標識主鍵 公用主鍵 // @GeneratedValue //遞增序列 private String id; @Column(updatable = false) //不允許修改 @CreationTimestamp //創建時自動賦值 private Date createTime; @UpdateTimestamp //修改時自動修改 private Date updateTime; }
@Entity //標識這是個jpa數據庫實體類 @Table @Data //lombok getter setter tostring @ToString(callSuper = true) //覆蓋tostring 包含父類的字段 @Slf4j //SLF4J log @Builder //biulder模式 @NoArgsConstructor //無參構造函數 @AllArgsConstructor //全參構造函數 @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) public class RedPacketInfo extends BaseEntity implements Serializable { private String red_packet_id; private int total_amount; private int total_packet; private String user_id; }
@Entity //標識這是個jpa數據庫實體類 @Table @Data //lombok getter setter tostring @ToString(callSuper = true) //覆蓋tostring 包含父類的字段 @Slf4j //SLF4J log @Builder //biulder模式 @NoArgsConstructor //無參構造函數 @AllArgsConstructor //全參構造函數 @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) public class RedPacketRecord extends BaseEntity implements Serializable { private int amount; private String red_packet_id; private String user_id; }
@Transactional public RedPacketInfo handOut(String userId, int total_amount, int tal_packet) { RedPacketInfo redPacketInfo = new RedPacketInfo(); redPacketInfo.setRed_packet_id(genRedPacketId(userId)); redPacketInfo.setId(redPacketInfo.getRed_packet_id()); redPacketInfo.setTotal_amount(total_amount); redPacketInfo.setTotal_packet(tal_packet); redPacketInfo.setUser_id(userId); redPacketInfoRepository.save(redPacketInfo); redisUtil.set(redPacketInfo.getRed_packet_id() + TAL_PACKET, tal_packet + ""); redisUtil.set(redPacketInfo.getRed_packet_id() + TOTAL_AMOUNT, total_amount + ""); return redPacketInfo; }
測試代碼
測試1000並發,搶10元20個紅包,平均每人搶紅包時間1秒之內(平均600ms),大大優於之前版本的搶紅包數據
@GetMapping("/concurrent") public String concurrent(){ RedPacketInfo redPacketInfo = redPacketService.handOut("zxp",1000,20); String redPacketId = redPacketInfo.getRed_packet_id(); for(int i = 0;i < 1000;i++) { Thread thread = new Thread(() -> { String userId = "user_" + randomValuePropertySource.getProperty("random.int(10000)").toString(); Date begin = new Date(); GrabResult grabResult = redPacketService.grab(userId, redPacketId); Date end = new Date(); log.info(grabResult.getMsg()+",本次消耗:"+(end.getTime()-begin.getTime())); }); thread.start(); } return "ok"; }
Fork From GitHub