业务流程
发红包流程
抢红包流程
业务模块划分
数据库设计
数据库脚本
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 utf8mb4 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=11 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=83 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 utf8mb4 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=72 DEFAULT CHARSET=utf8 COMMENT='抢红包记录';
红包金额随机生成算法
本系统中,红包的随机金额主要是“预生成”的方式产生的,即通过给定的红包的总金额和红包个数,采用某种随机算法生成红包随机列表,并将其放在缓存中,用于拆红包的逻辑
这里随机算法采用二倍均值法
二倍均值法
这里为了保证系统每次总金额M和红包个数N生成的小红包金额是随机的且概率相等。即保证每个用户抢到的红包是随机且概率相等的。
二倍均值法的思想:
每次剩余的总金额 :M
剩余人数:N
执行 M 除 N 乘 2 得到边界值:E
然后指定一个[0,E]的随机区间,在这个随机区间产生一个随机数: R
此时将总金额M = M-R
剩余人数 N = N-1
以此类推直到N - 1 = 0;
算法流程图如下
package com.learn.boot.utils;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* 二倍均值算法生成随机红包
*/
public class RedBagUtils {
/** 发红包算法,金额是以分为单位的
* @param totalBagMoney 发红包总金额
* @param totalBagNumber 抢红包人数
* @return
*/
public static List<Integer> divideRedBag(Integer totalBagMoney,Integer totalBagNumber) {
List<Integer> result = new ArrayList<>(10);
if (totalBagMoney > 0 && totalBagNumber > 0) {
// 记录红包的总金额,初始化时即为红包的总金额
Integer bagMoneyAmout = totalBagMoney;
// 记录抢红包的总人数
Integer bagNumber = totalBagNumber;
// 定义随机产生对象
Random random = new Random();
while (bagNumber -1 > 0) {
// 产生随机数,[1,剩余人均金额的两倍],这里用的是最小单位分
int randMoney = random.nextInt(bagMoneyAmout/bagNumber * 2 -1 ) + 1;
// 更新剩余金额
bagMoneyAmout -= randMoney;
// 更新总人数
bagNumber --;
result.add(randMoney);
}
// 循环完毕,最后一个剩余红包要加入结果集
result.add(bagMoneyAmout);
}
return result;
}
}
测试代码
package com.learn.boot.utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;
import java.util.List;
public class Test {
private static final Logger log= LoggerFactory.getLogger(Test.class);
public static void main(String[] args) {
Integer money = 1000;
Integer num = 10;
List<Integer> result = RedBagUtils.divideRedBag(money,num);
log.info("总金额 = {}分,抢红包个数={}",money,num);
final Integer[] sum = {0};
result.forEach(item -> {
log.info("随机金额为:{}分,即 {}元",item,new BigDecimal(item.toString()).divide(new BigDecimal(100)));
sum[0] += item;
});
log.info("所有随机金额加起来等于{}",sum[0]);
}
}
发红包模块
为了本模块的规范性和可读性,采用MVVM的模式开发相应的模块,
M 模型层
V 视图层,这里暂时不用,全是接口的形式
C控制层,接收请求处理,
M 中间件,采用中间件辅助
@RequestMapping("sendBag")
public ResultVo handOut(@Validated @RequestBody RedPacketDto dto, BindingResult result) {
// 参数校验
if (result.hasErrors()) {
return ResultVo.error("参数异常");
}
return iRedPacketService.handOut(dto);
}
@Override
public ResultVo handOut(RedPacketDto dto) {
try {
//采用二倍均值法生成随机金额列表(在上一节已经采用代码实现了二倍均值 法)
List<Integer> list = RedBagUtils.divideRedBag(dto.getAmount(), dto.getTotal());
//生成红包全局唯一标识串
String timestamp=String.valueOf(System.nanoTime());
//根据缓存Key的前缀与其他信息拼接成一个新的用于存储随机金额列表的Key
String redId = new StringBuffer("red:packet:").append(dto.
getUserId()).append(":").append(timestamp).toString();
// 将随机金额列表存入缓存列表中
redisTemplate.opsForList().leftPushAll(redId,list);
// 设置一天的时间戳
redisTemplate.expire(redId, 65535, TimeUnit.SECONDS);
// 根据缓存Key的前缀与其他信息拼接成一个新的用于存储红包总数的Key
String redTotalKey = redId+":total";
// 将红包总数存入缓存中
redisTemplate.opsForValue().set(redTotalKey,dto.getTotal());
// 设置一天的时间戳
redisTemplate.expire(redTotalKey, 65535, TimeUnit.SECONDS);
// 异步记录红包的全局唯一标识串、红包个数与随机金额列表入数据库
redService.recordRedPacket(dto,redId,list);
//将红包的全局唯一标识串返回给前端
return ResultVo.success("发送红包成功",redId);
}catch (Exception ex) {
log.error("发红包异常");
return ResultVo.error("发红包异常");
}
}
@Async
@Override
@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()));
redRecord.setCreateTime(new Date());
//将对象信息插入数据库
redRecordMapper.insertSelective(redRecord);
//定义红包随机金额明细实体类对象
RedDetail detail;
//遍历随机金额列表,将金额等信息设置到相应的字段中
for (Integer i:list){
detail=new RedDetail();
detail.setRecordId(redRecord.getId());
detail.setAmount(BigDecimal.valueOf(i));
detail.setCreateTime(new Date());
//将对象信息插入数据库
redDetailMapper.insertSelective(detail);
}
}
抢红包模块
@RequestMapping("robRedBag")
public ResultVo robRedBag(@RequestBody RobRedPacketDto dto) {
return iRedPacketService.robRedBag(dto);
}
package com.learn.boot.dto;
/**
* 抢红包请求的参数
*/
import com.sun.istack.internal.NotNull;
import lombok.Data;
import javax.validation.constraints.Min;
@Data
public class RobRedPacketDto {
@NotNull
// 用户id
private Integer userId;
@NotNull
// 红包Id
private String redId;
}
/** 抢红包逻辑
* @return
*/
@Override
public ResultVo robRedBag(RobRedPacketDto dto) {
try {
//定义Redis操作组件的值操作方法
ValueOperations valueOperations = redisTemplate.opsForValue();
//在处理用户抢红包之前,需要先判断一下当前用户是否已经抢过该红包了
//如果已经抢过了,则直接返回红包金额,并在前端显示出来
BigDecimal obj = (BigDecimal) valueOperations.get(dto.getRedId() + dto.getUserId() + ":rob");
if (obj != null) {
// log.info("userId = {} 成功抢到,金额为{}",dto.getUserId(),obj.toString());
return ResultVo.error("您已成功抢到",new BigDecimal(obj.toString()));
}
// 判断是否可以抢
Boolean res = click(dto.getRedId());
// true表示可以抢
if (!res) {
return ResultVo.error("红包被抢完啦");
}
// 加入setnx命令,实现分布式锁
final String lockKey = dto.getRedId() + dto.getUserId() +"-lock";
//调用setIfAbsent()方法,间接实现分布式锁
Boolean lock=valueOperations.setIfAbsent(lockKey,dto.getRedId());
// 得到锁了就执行下面的操作
if (!lock) {
log.error("手速太快");
return ResultVo.error(ResultCode.FORBIDDEN);
}
// 先从随机金币列表中随便弹出一个值
Integer money = (Integer)redisTemplate.opsForList().leftPop(dto.getRedId());
if (money == null) {
return ResultVo.error("手慢了");
}
// 如果有值,更新红包剩余个数
String redTotalKey = dto.getRedId()+":total";
Integer restNumberBag = (Integer) valueOperations.get(redTotalKey);
valueOperations.set(redTotalKey,restNumberBag - 1);
BigDecimal result = new BigDecimal(money).
divide(new BigDecimal(100));
//记录抢到红包时用户的账号信息以及抢到的金额等信息入数据库
redService.recordRobRedPacket(dto.getUserId(),dto.getRedId(),new BigDecimal(restNumberBag));
//将当前抢到红包的用户信息放置进缓存系统中,用于表示当前用户已经抢过红 包了
valueOperations.set(dto.getRedId() + dto.getUserId() +":rob",result,24L,TimeUnit.
HOURS);
//输出当前用户抢到红包的记录信息
log.info("当前用户抢到红包了:userId={} key={} 金额={} ",dto.getUserId(),
dto.getUserId(),result);
//将结果返回
return ResultVo.success("抢红包成功",result);
}catch (Exception ex) {
log.error("抢红包异常",ex);
return ResultVo.error("抢红包异常");
}
}
}
/** 判断是否可以抢
* @param redId
* @return
*/
private Boolean click(String redId) {
//定义Redis的Bean操作组件(值操作组件)
ValueOperations valueOperations=redisTemplate.opsForValue();
//定义用于查询缓存系统中红包剩余个数的Key
//这在上一节“发红包”业务模块中已经指定过了
String redTotalKey = redId+":total";
//获取缓存系统Redis中红包剩余个数
Object total=valueOperations.get(redTotalKey);
//判断红包剩余个数total是否大于0,如果大于0,则返回true,表示还有红包
if (total != null && Integer.valueOf(total.toString()) > 0){
return true;
}
//返回false,则表示已经没有红包可抢了
return false;
}
/** 抢红包记录入库
* @param userId
* @param redId
* @param amount
*/
@Override
@Async
public void recordRobRedPacket(Integer userId, String redId, BigDecimal amount) {
//定义记录抢到红包时录入相关信息的实体对象,并设置相应字段的取值
RedRobRecord redRobRecord=new RedRobRecord();
redRobRecord.setUserId(userId);
redRobRecord.setRedPacket(redId);
redRobRecord.setAmount(amount);
redRobRecord.setRobTime(new Date());
//将实体对象信息插入数据库中
redRobRecordMapper.insertSelective(redRobRecord);
}
高并发模块出现问题
出现了一个用户抢了多个红包
原因在于
// 先从随机金币列表中随便弹出一个值
Integer money = (Integer)redisTemplate.opsForList().leftPop(dto.getRedId());
if (money == null) {
return ResultVo.error("手慢了");
}
这段代码存在高并发带来的问题,假设有两个用户id的一样进来了,那么就从随机金币列表取到了两个值,并且入库
解决方法 redis 的setNx ,完好的解决了这个问题,在不存在的时候入缓存,如果之前存在过就返回false