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);
//二倍均值法自测
@Test
public 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;
//指定多少人抢
@NotNull
private Integer total;
//指定总金额-单位为分
@NotNull
private 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";
@Autowired
private 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:";
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private IRedService redService;
/**
* 发红包
* @throws Exception
*/
@Override
public 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);
@Autowired
private RedRecordMapper redRecordMapper;
@Autowired
private 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()方法如下:
@Override
public 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配置类
package com.xm.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
/**
* Redisson相关开源组件自定义注入
**/
@Configuration
public class RedissonConfig {
@Autowired
private Environment env;
/**
* 自定义注入配置操作Redisson的客户端实例
* @return
*/
@Bean
public RedissonClient config(){
//创建配置实例
Config config=new Config();
//可以设置传输模式为EPOLL,也可以设置为NIO等等
//config.setTransportMode(TransportMode.NIO);
//设置服务节点部署模式:集群模式;单一节点模式;主从模式;哨兵模式等等
//config.useClusterServers().addNodeAddress(env.getProperty("redisson.host.config"),env.getProperty("redisson.host.config"));
config.useSingleServer()
.setAddress(env.getProperty("redisson.host.config"))
.setKeepAlive(true);
//创建并返回操作Redisson的客户端实例
return Redisson.create(config);
}
}
rob()
@Override
public 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;
}