Redis典型应用场景实战之抢红包系统
目錄
github完整代碼:搶紅包
Redis的使用
大家都知道,Redis是一款具有高性能存儲的緩存中間件。那么在搶紅包系統中,我們是怎么使用Redis的呢?
在發紅包業務模塊中,我們將紅包個數和每個紅包的隨機金額存入redis緩存中。
在"點紅包"的業務邏輯中,是去緩存中判斷紅包個數是否大于0。
在"拆紅包"的業務邏輯中,也是從緩存的紅包隨機金額隊列中去讀取紅包金額。
同時在優化的時候將借助Redis單線程特性與操作的原子性實現搶紅包的鎖操作。
可見,Redis在搶紅包系統中占據很重要的位置。一方面Redis將大大減少高并發情況下頻繁查詢數據庫的操作,從而減輕數據庫的壓力;另一方面,Redis將提高系統的整體響應性能和保證數據的一致性。
業務流程
有人發紅包才有搶紅包啊,先看一下發紅包的業務流程。
好了,發完紅包了,那么開始去搶紅包了,來解析一下搶紅包的業務流程。
首先搶紅包分為了兩個業務處理邏輯,點紅包和拆紅包。
點紅包:主要用于判斷緩存系統中紅包個數是否大于0。如果小于等于0,則意味著紅包被搶完了;如果紅包個數大于0,則表示緩存中還有紅包,可以繼續搶。
拆紅包:主要是用于從緩存系統的紅包隨機金額隊列中彈出一個隨機金額,如果金額不為空,則表示該用戶搶到紅包了,緩存系統中紅包個數減1,同時異步記錄用戶搶紅包的記錄并結束流程;如果金額為空,則意味著用戶來晚一步,紅包已經被搶完了。
整體業務模塊的劃分
發紅包模塊:主要包括接受并處理用戶發紅包請求的邏輯處理。
搶紅包模塊:主要包括用戶點紅包和拆紅包請求的邏輯處理。
數據操作DB模塊:主要包括系統整體業務邏輯處理過程中的數據記錄。
緩存中間件Redis模塊:主要用于緩存紅包個數及紅包隨機金額
數據庫設計
三張表。發紅包時記錄紅包相關信息表、發紅包時生成的對應隨機金額信息表以及搶紅包時用戶搶到的紅包金額記錄表。
發紅包記錄表
CREATE TABLE `red_record` (`id` int(11) NOT NULL AUTO_INCREMENT,`user_id` int(11) NOT NULL COMMENT '用戶id',`red_packet` varchar(255) CHARACTER SET utf8 NOT NULL COMMENT '紅包全局唯一標識串',`total` int(11) NOT NULL COMMENT '人數',`amount` decimal(10,2) DEFAULT NULL COMMENT '總金額(單位為分)',`is_active` tinyint(4) DEFAULT '1',`create_time` datetime DEFAULT NULL,PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8 COMMENT='發紅包記錄';紅包明細金額表
CREATE TABLE `red_detail` (`id` int(11) NOT NULL AUTO_INCREMENT,`record_id` int(11) NOT NULL COMMENT '紅包記錄id',`amount` decimal(8,2) DEFAULT NULL COMMENT '金額(單位為分)',`is_active` tinyint(4) DEFAULT '1',`create_time` datetime DEFAULT NULL,PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=133 DEFAULT CHARSET=utf8 COMMENT='紅包明細金額';搶紅包記錄表
CREATE TABLE `red_rob_record` (`id` int(11) NOT NULL AUTO_INCREMENT,`user_id` int(11) DEFAULT NULL COMMENT '用戶賬號',`red_packet` varchar(255) CHARACTER SET utf8 DEFAULT NULL COMMENT '紅包標識串',`amount` decimal(8,2) DEFAULT NULL COMMENT '紅包金額(單位為分)',`rob_time` datetime DEFAULT NULL COMMENT '時間',`is_active` tinyint(4) DEFAULT '1',PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=118 DEFAULT CHARSET=utf8 COMMENT='搶紅包記錄';開發環境搭建
數據庫設計好了,接下來可才采用MyBatis的逆向工程生成這三張數據庫對應的實體類Entity,數據庫操作Mapper接口以及寫動態SQL的配置文件Mapper.xml。
這里就不將代碼摘出來了。后續可到github上取。
統一處理響應格式
統一處理響應格式
約定了處理用戶請求信息后將返回統一的響應格式,這種格式主要是借鑒了HTTP協議的響應模型,即響應信息應當包含狀態嗎、狀態的描述和響應數據。為此引入了兩個類。BaseResponse類和StatusCode類。
BaseResponse:
public class BaseResponse<T> {//狀態碼private Integer code;//描述信息private String msg;//響應數據-采用泛型表示可以接受通用的數據類型private T data;//重載的構造方法一public BaseResponse(Integer code, String msg) {this.code = code;this.msg = msg;}//重載的構造方法二public BaseResponse(StatusCode statusCode) {this.code = statusCode.getCode();this.msg = statusCode.getMsg();}//重載的構造方法三public BaseResponse(Integer code, String msg, T data) {this.code = code;this.msg = msg;this.data = data;}/**getter和setter**/ }StatusCode:
/*** 通用狀態碼類*/ public enum StatusCode {//以下是暫時設定的幾種狀態碼類Success(0,"成功"),Fail(-1,"失敗"),InvalidParams(201,"非法的參數!"),InvalidGrantType(202,"非法的授權類型");//狀態碼private Integer code;//描述信息private String msg;//重載的構造方法StatusCode(Integer code, String msg) {this.code = code;this.msg = msg;}/**getter和setter**/ }隨機生成算法前提要求
發出一個固定金額的紅包,由若干個人來搶,需要滿足的條件如下:
1、所有人搶到的金額之和等于紅包金額。
2、每個人至少搶到1分錢。
3、要保證所有人搶到金額的幾率相等。(由生成紅包隨機金額的算法決定)
二倍均值算法
根每次剩余的總金額M和剩余人數N,執行M/N再乘以2的操作得到一個邊界值E,然后制定一個從0到E的隨機區間,在這個隨機區間內將產生一個隨機金額R,此時總金額M將更新為M-R,剩余人數N更新為N-1。再繼續重復上述執行流程,以此類推,直至最終剩余人數N-1為0,即代表隨機數已經產生完畢。
流程很清楚了,那么代碼如何去實現呢?
為了后續調用方便,我們將此算法封裝成工具類。
RedPacketUtil:
import java.util.ArrayList; import java.util.List; import java.util.Random;/*** 二倍均值法的代碼實戰*/ public class RedPacketUtil {/*** 發紅包算法,金額參數以分為單位* @param totalAmount* @param totalPeopleNum* @return*/public static List<Integer> divideRedPackage(Integer totalAmount, Integer totalPeopleNum) {List<Integer> amountList = new ArrayList<Integer>();if (totalAmount>0 && totalPeopleNum>0){Integer restAmount = totalAmount;Integer restPeopleNum = totalPeopleNum;Random random = new Random();for (int i = 0; i < totalPeopleNum - 1; i++) {// 隨機范圍:[1,剩余人均金額的兩倍),左閉右開int amount = random.nextInt(restAmount / restPeopleNum * 2 - 1) + 1;restAmount -= amount;restPeopleNum--;amountList.add(amount);}//循環完畢,剩余的金額即為最后一個隨機金額,也需要將其加入到列表中amountList.add(restAmount);}return amountList;} }測試:
package com.xm;import com.xm.utils.RedPacketUtil; import org.junit.Test; import org.junit.runner.RunWith; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;import java.math.BigDecimal; import java.util.List;@SpringBootTest @RunWith(SpringJUnit4ClassRunner.class) public class RedPacketTest {private static final Logger log= LoggerFactory.getLogger(RedPacketTest.class);//二倍均值法自測@Testpublic void one() throws Exception{//總金額單位為分Integer amout=1000;//總人數-紅包個數Integer total=10;//得到隨機金額列表List<Integer> list=RedPacketUtil.divideRedPackage(amout,total);log.info("總金額={}分,總個數={}個",amout,total);//用于統計生成的隨機金額之和是否等于總金額Integer sum=0;//遍歷輸出每個隨機金額for (Integer i:list){log.info("隨機金額為:{}分,即 {}元",i,new BigDecimal(i.toString()).divide(new BigDecimal(100)));sum += i;}log.info("所有隨機金額疊加之和={}分",sum);} }
看上面兩次測試的結果,能看到紅包金額的生成滿足隨機性、概率平等性,以及所有小紅包金額之和等于總金額等特性。
開發”發紅包“業務
1、實體類RedPacketDto
回顧我們發紅包的業務流程,在處理”發紅包“的請求時,后端接口需要接收紅包金額和總個數等參數,因而將其封裝為實體對象RedPacketDto。如下:
import lombok.Data; import lombok.ToString;import javax.validation.constraints.NotNull;/*** 發紅包請求時接收的參數對象*/ @Data @ToString public class RedPacketDto {private Integer userId;//指定多少人搶@NotNullprivate Integer total;//指定總金額-單位為分@NotNullprivate Integer amount; }2、處理發紅包請求的RedPacketController
import com.xm.api.StatusCode; import com.xm.api.BaseResponse; import com.xm.pojo.RedPacketDto; import com.xm.service.IRedPacketService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*;import java.math.BigDecimal;@RestController public class RedPacketController {private static final Logger log= LoggerFactory.getLogger(RedPacketController.class);private static final String prefix="red/packet";@Autowiredprivate IRedPacketService redPacketService;/*** 發*/@RequestMapping(value = prefix+"/hand/out",method = RequestMethod.POST,consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)public BaseResponse handOut(@RequestBody RedPacketDto dto, BindingResult result){if (result.hasErrors()){return new BaseResponse(StatusCode.InvalidParams);}BaseResponse response=new BaseResponse(StatusCode.Success);try {//核心業務處理邏輯處理服務-最終返回紅包全局唯一標識串String redId=redPacketService.handOut(dto);//將紅包全局唯一標識串返回給前端response.setData(redId);}catch (Exception e){log.error("發紅包發生異常:dto={} ",dto,e.fillInStackTrace());response=new BaseResponse(StatusCode.Fail.getCode(),e.getMessage());}return response;} }3、紅包業務邏輯處理接口IRedPacketService以及實現類RedPacketService
IRedPacketService接口:
import com.xm.pojo.RedPacketDto; import java.math.BigDecimal;/** * 紅包業務邏輯處理接口 **/ public interface IRedPacketService {//發紅包String handOut(RedPacketDto dto) throws Exception; }RedPacketService類
import com.xm.pojo.RedPacketDto; import com.xm.utils.RedPacketUtil; import com.xm.utils.SnowFlake; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.stereotype.Service;import java.math.BigDecimal; import java.util.List; import java.util.concurrent.TimeUnit;@Service public class RedPacketService implements IRedPacketService {private static final Logger log= LoggerFactory.getLogger(RedPacketService.class);private final SnowFlake snowFlake=new SnowFlake(2,3);private static final String keyPrefix="redis:red:packet:";@Autowiredprivate RedisTemplate redisTemplate;@Autowiredprivate IRedService redService;/*** 發紅包* @throws Exception*/@Overridepublic String handOut(RedPacketDto dto) throws Exception {if (dto.getTotal()>0 && dto.getAmount()>0){//生成隨機金額List<Integer> list=RedPacketUtil.divideRedPackage(dto.getAmount(),dto.getTotal());//生成紅包全局唯一標識,并將隨機金額、個數入緩存String timestamp=String.valueOf(System.nanoTime());String redId = new StringBuffer(keyPrefix).append(dto.getUserId()).append(":").append(timestamp).toString();//將隨機金額列表存入緩存list中redisTemplate.opsForList().leftPushAll(redId,list);String redTotalKey = redId+":total";//將紅包總數存入緩存中redisTemplate.opsForValue().set(redTotalKey,dto.getTotal());//異步記錄紅包發出的記錄-包括個數與隨機金額redService.recordRedPacket(dto,redId,list);return redId;}else{throw new Exception("系統異常-分發紅包-參數不合法!");}} }4、將處理過程數據存入數據庫 IRedService接口和RedService類
IRedService接口
import com.xm.pojo.RedPacketDto; import java.math.BigDecimal; import java.util.List;/*** 紅包記錄服務*/ public interface IRedService {void recordRedPacket(RedPacketDto dto, String redId, List<Integer> list) throws Exception; }RedService類
import com.xm.pojo.RedDetail; import com.xm.pojo.RedRecord; import com.xm.pojo.RedRobRecord; import com.xm.mapper.RedDetailMapper; import com.xm.mapper.RedRecordMapper; import com.xm.mapper.RedRobRecordMapper; import com.xm.pojo.RedPacketDto; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional;import java.math.BigDecimal; import java.util.Date; import java.util.List;@Service @EnableAsync public class RedService implements IRedService {private static final Logger log= LoggerFactory.getLogger(RedService.class);@Autowiredprivate RedRecordMapper redRecordMapper;@Autowiredprivate RedDetailMapper redDetailMapper;/*** 發紅包記錄* @param dto* @param redId* @param list* @throws Exception*/@Override@Async@Transactional(rollbackFor = Exception.class)public void recordRedPacket(RedPacketDto dto, String redId, List<Integer> list) throws Exception {RedRecord redRecord=new RedRecord();redRecord.setUserId(dto.getUserId());redRecord.setRedPacket(redId);redRecord.setTotal(dto.getTotal());redRecord.setAmount(BigDecimal.valueOf(dto.getAmount()));redRecordMapper.insertSelective(redRecord);RedDetail detail;for (Integer i:list){detail=new RedDetail();detail.setRecordId(redRecord.getId());detail.setAmount(BigDecimal.valueOf(i));redDetailMapper.insertSelective(detail);}} }5、自測
上述發紅包的業務模塊的代碼基本已經完成了,那么咱們將redis跑起來,使用postman測試一下。
發紅包 10 人 10 元
測試發紅包:
http://localhost:8081/middleware/red/packet/hand/out
請求體
{"userId":10000,"total":10,"amount":1000}
接著,我們可以查看一下數據庫中的red_record表,可以看到如下:
再看一下red_detail 表
緩存中也存入了數據。如下
搶紅包
關于搶紅包的具體代碼這里就不摘出來講解了。可以到github上取。
這里就說一下搶紅包的接口測試。
http://localhost:8081/middleware/red/packet/rob
參數1:userId 自己設置
參數2:redId red_record表中red_packet的值
控制臺輸出如下:
那么在你red_rob_record表中也會看到插入了一條數據。
Jmeter壓力測試高并發搶紅包
1、下載
http://jmeter.apache.org/download_jmeter.cgi
2、解壓,進入到bin下雙擊jmeter.sh文件啟動即可。
啟動之后會出現下圖:
3、進行測試
點擊”文件“新建一個測試計劃
在該測試計劃下新建線程組,在該線程組下新建”HTTP請求“,”CSV數據文件設置“,”查看結果樹“。如下目錄結構:
線程組的內容設置如下:
HTTP請求的內容設置:
CSV數據文件內容設置:
察看結果樹內容設置:
至此,就完成了搶紅包請求的設置,下面進行測試。
自然得先有人發紅包,才能搶紅包,使用postman進行發紅包測試。將返回的結果data設置到”HTTP請求redId“取值中。最后調整一下線程組中1秒并發的線程數為1000,啟動,點擊”運行“按鈕。查看結果樹。就能看見響應的數據。
如下:
控制臺數據輸出:
觀察一下這個結果,你會發現一個用戶搶到了不同金額的紅包,這是一個很大的bug,違背了一個用戶對若干個隨機金額的小紅包搶一次的規則。那么這就是高并發多線程產生的并發安全導致的。下面我們如何進行解決呢?
優化-分布式鎖
為什么會出現一個用戶搶到多個紅包的情況?
在某一時刻的同一用戶瘋狂點擊紅包,如果前端不加以控制的話,同一時間的同一用戶將發起多個搶紅包請求,當后端接收到這些請求時,將很有可能同時進行”緩存系統中是否有紅包“的判斷并成功通過,然后執行后面彈出紅包隨機金額的業務邏輯,導致一個用戶搶到多個紅包的情況發生。
如何解決這個問題呢?
在這個搶紅包系統中,其核心處理邏輯在于“拆紅包”的操作。因而可以通過Redis的原子操作setIfAbsent()方法對該業務邏輯加分布式鎖,表示“如果當前的Key不存在于緩存中,則設置其對應的Value,該方法的操作結果返回True;如果當前的Key已經存在于緩存中,則設置其對應的Value 失敗,即該方法的操作結果將返回False。由于該方法具備原子性(單線程)操作的特性,因而當多個并發的線程同一時刻調用setIfAbsent()時,Redis 的底層是會將線程加入“隊列”排隊處理的。
改造后的rob()方法如下:
@Overridepublic BigDecimal rob(Integer userId,String redId) throws Exception {ValueOperations valueOperations=redisTemplate.opsForValue();//用戶是否搶過該紅包Object obj=valueOperations.get(redId+userId+":rob");if (obj!=null){return new BigDecimal(obj.toString());}//"點紅包"Boolean res=click(redId);if (res){//上鎖:一個紅包每個人只能搶一次隨機金額;一個人每次只能搶到紅包的一次隨機金額 即要永遠保證 1對1 的關系final String lockKey=redId+userId+"-lock";Boolean lock=valueOperations.setIfAbsent(lockKey,redId);redisTemplate.expire(lockKey,24L,TimeUnit.HOURS);try {if (lock) {//"搶紅包"-且紅包有錢Object value=redisTemplate.opsForList().rightPop(redId);if (value!=null){//紅包個數減一String redTotalKey = redId+":total";Integer currTotal=valueOperations.get(redTotalKey)!=null? (Integer) valueOperations.get(redTotalKey) : 0;valueOperations.set(redTotalKey,currTotal-1);//將紅包金額返回給用戶的同時,將搶紅包記錄入數據庫與緩存BigDecimal result = new BigDecimal(value.toString()).divide(new BigDecimal(100));redService.recordRobRedPacket(userId,redId,new BigDecimal(value.toString()));valueOperations.set(redId+userId+":rob",result,24L,TimeUnit.HOURS);log.info("當前用戶搶到紅包了:userId={} key={} 金額={} ",userId,redId,result);return result;}}}catch (Exception e){throw new Exception("系統異常-搶紅包-加分布式鎖失敗!");}}return null;}加上redsi的分布式鎖,我們在進行壓力測試的時候,會發現,紅包被搶完了,但是紅包的總數不是0,完全亂套了。
那么現在怎么解決這個問題呢?
我們使用Redisson的可重入鎖來解決這個問題。
增加一個RedissonConfig配置類、修改rob()方法。
RedissonConfig配置類
rob()
@Overridepublic BigDecimal rob(Integer userId,String redId) throws Exception {ValueOperations valueOperations=redisTemplate.opsForValue();//"點紅包"Boolean res=click(redId);if (res){//上鎖:一個紅包每個人只能搶一次隨機金額;一個人每次只能搶到紅包的一次隨機金額 即要永遠保證 1對1 的關系final String lockKey=redId+"-lock";RLock lock = redissonClient.getLock(lockKey); // Boolean lock=valueOperations.setIfAbsent(lockKey,redId); // redisTemplate.expire(lockKey,24L,TimeUnit.HOURS);try {lock.tryLock(100L,10L,TimeUnit.SECONDS);//用戶是否搶過該紅包Object obj=valueOperations.get(redId+userId+":rob");if (obj!=null){return new BigDecimal(obj.toString());}//"搶紅包"-且紅包有錢Object value=redisTemplate.opsForList().rightPop(redId);if (value!=null){//紅包個數減一String redTotalKey = redId+":total";Integer currTotal=valueOperations.get(redTotalKey)!=null? (Integer) valueOperations.get(redTotalKey) : 0;valueOperations.set(redTotalKey,currTotal-1);//將紅包金額返回給用戶的同時,將搶紅包記錄入數據庫與緩存BigDecimal result = new BigDecimal(value.toString()).divide(new BigDecimal(100));redService.recordRobRedPacket(userId,redId,new BigDecimal(value.toString()));valueOperations.set(redId+userId+":rob",result,24L,TimeUnit.HOURS);log.info("當前用戶搶到紅包了:userId={} key={} 金額={} ",userId,redId,result);return result;}}catch (Exception e){throw new Exception("系統異常-搶紅包-加分布式鎖失敗!");}finally {lock.unlock();}}return null;}總結
以上是生活随笔為你收集整理的Redis典型应用场景实战之抢红包系统的全部內容,希望文章能夠幫你解決所遇到的問題。