本文将讲述使用redis实现抢红包功能,采用发红包时将红包拆好存储,解决红包金额平衡问题(两种算法)、解决超发现象、将数据通过消息队列传递给另一个服务写入数据库,现阶段不考虑redis宕机的情况。
--新增余额处理。
框架为:springboot2.x,环境搭建、maven配置略。
一个简单的前端页面模拟并发量:
两个功能:一个发红包和一个抢红包
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script type="text/javascript" src="/js/jquery.min.js"></script>
<script type="text/javascript">
function aaa(){
var redId=1;
var num=document.getElementById("userNum").value;
for (var i = 1; i<=num;i++){
$.post("http://localhost:8081/rushToBuy/redPaper",{"userId":i,"redId":redId},function (result) {
});
}
}
function bbb(){
var redId=1;
var amount=document.getElementById("amount").value*100;
var num=document.getElementById("num").value;
if (amount<num){
alert("每个红包最少一分钱")
return;
}
$.post("http://localhost:8081/rushToBuy/sendRedPaperLine",{"redId":redId,"amount":amount,"num":num},function (result) {
});
}
</script>
抢红包 <button id="but1" onclick="aaa()">启动</button> |抢红包人数:<input id="userNum"><br>
发红包 <button id="but2" onclick="bbb()">发送</button> |红包金额:<input id="amount"> 红包个数:<input id="num">
</body>
</html>
一、我们先来实现redis功能
实现发红包时将红包拆分存储到redis
使用的算法1:线性切割法。
中心思想:将总金额想象成一条那么长的线段,需要分割成num份,随机num-1次,将每次的随机值映射到该线段上。这样的好处是将随机交给程序,缺点是有小概率造成某个人分配过多(抢红包嘛,只要不是太离谱就可以接受)。
代码思路:获取(0,max)的随机数(防止有人抢到红包但金额为0),使用Treeset进行排序去重(去重是为了防止随机到相同大小,会导致一个人抢到红包但金额为0),然后循环将两个区间内的差值作为红包金额存入redis。
/*
算法1 分段切割法 红包算法
*/
public void sendRedPaperLine(int redId,int amount,int num){
Random random=new Random();
int m=0,n=amount;
Set<Integer> sets = new TreeSet<>();
//(m,n)区间
for (int i =0;i<(num-1);i++){
int randInt = random.nextInt(n-m-1)+(m+1); //将区间控制在(0,max) ,不能出现为0和最大的情况
sets.add(randInt);
}
while (sets.size()<(num-1)){
int randInt = random.nextInt(n-m-1)+(m+1);
sets.add(randInt);
}
int cur=0; //做为当前set循环中的上一参数
for (Integer i:sets){
redisTemplate.opsForList().leftPush("redId:"+redId,(double)(i-cur)/100);
cur=i;
}
redisTemplate.opsForList().leftPush("redId:"+redId,(double)(amount-cur)/100);
}
因为算法1使用的redis中的list,所以取走一个少一个,不会存在多人拿到同一个的情况,所以可以忽略超发问题。
那么不需要考虑超发问题,抢红包时的代码就非常简单。
public void RushRedPaper(int redId, int userId) {
Double amount = (Double) redisTemplate.opsForList().leftPop("redId:"+redId);
if (amount!=null){
RedPaperUserInfo userInfo = new RedPaperUserInfo();
userInfo.setRedId(redId);
userInfo.setCreateTime(LocalDateTime.now());
userInfo.setUserId(userId);
userInfo.setRushAmount(Double.valueOf(amount));
redisTemplate.opsForHash().put("redInfo:"+redId,"user:"+userId,userInfo);
System.out.println("用户id:"+userId+"抢到了"+Double.valueOf(amount)+"元");
}
}
测试一下:100元红包,20个,100人抢。
输出结果:让我们恭喜一个4号倒霉蛋。
用户id:6抢到了2.29元
用户id:4抢到了0.09元
用户id:3抢到了13.56元
用户id:2抢到了7.86元
用户id:1抢到了5.18元
用户id:5抢到了5.36元
用户id:11抢到了2.68元
用户id:7抢到了11.6元
用户id:9抢到了1.16元
用户id:8抢到了6.48元
用户id:10抢到了1.82元
用户id:12抢到了0.47元
用户id:13抢到了5.72元
用户id:16抢到了3.72元
用户id:17抢到了17.56元
用户id:14抢到了5.89元
用户id:18抢到了5.55元
用户id:15抢到了1.18元
用户id:20抢到了0.93元
用户id:19抢到了0.9元
算法2.两倍均值法
中心思想:剩余红包金额为M,剩余人数为N,每次抢到的金额 = 随机区间 (0, M/N *2)
代码实现:抢红包代码不变,只改变发红包时的代码,需要注意的是最后一个人要把剩余的所有金额拿走。
/*
算法2 二倍均值法 红包算法
*/
public void sendRedPaperTwo(int redId,int amount,int num){
Random random=new Random();
//剩余红包金额为M,剩余人数为N,每次抢到的金额 = 随机区间 (0, M/N *2)
for (;num>1;num--){
int randInt = random.nextInt(amount/num*2-1)+1; //将区间控制在(0, M/N *2) ,不能出现为0和最大的情况
amount -= randInt;
redisTemplate.opsForList().leftPush("redId:"+redId,(double)randInt/100);
}
//最后一个将剩余所有金额拿走
redisTemplate.opsForList().leftPush("redId:"+redId,(double)amount/100);
}
测试一下:100元红包,20个,100人抢。
输出结果:
用户id:1抢到了1.18元
用户id:2抢到了15.22元
用户id:3抢到了0.02元
用户id:4抢到了2.7元
用户id:7抢到了5.79元
用户id:6抢到了3.02元
用户id:5抢到了12.11元
用户id:8抢到了2.53元
用户id:9抢到了5.79元
用户id:10抢到了9.82元
用户id:11抢到了9.08元
用户id:12抢到了2.2元
用户id:13抢到了9.67元
用户id:14抢到了0.45元
用户id:15抢到了6.45元
用户id:16抢到了2.42元
用户id:17抢到了1.31元
用户id:19抢到了5.1元
用户id:18抢到了2.44元
用户id:20抢到了2.7元
二、通过消息队列异步实现持久化
使用的消息队列为activeMQ,搭建略。
在抢红包的方法中进行修改:
@Autowired
private JmsMessagingTemplate jmsMessagingTemplate;
@Value("${activemq.name}")
private String name;
@Override
public void RushRedPaper(int redId, int userId) {
Double amount = (Double) redisTemplate.opsForList().leftPop("redId:"+redId);
if (amount!=null){
RedPaperUserInfo userInfo = new RedPaperUserInfo();
userInfo.setRedId(redId);
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String localdataString = LocalDateTime.now().format(dtf);
userInfo.setCreateTime(localdataString);
userInfo.setUserId(userId);
userInfo.setRushAmount(Double.valueOf(amount));
redisTemplate.opsForHash().put("redInfo:"+redId,"user:"+userId,userInfo);
jmsMessagingTemplate.convertAndSend(name,JSONObject.fromObject(userInfo).toString());
System.out.println("用户id:"+userId+"抢到了"+Double.valueOf(amount)+"元");
}
}
避免包的信任问题,改由json字符串传递。
将消息发送到消息队列后,由监听器异步监听接收消息,写入到mysql进行持久化操作。
@Component
public class RedPaperListener {
@Autowired
private RedPaperUserInfoDao redPaperUserInfoDao;
@Async
@JmsListener(destination = "${activemq.name}")
public void getRedInfo(Message message){
if (message instanceof TextMessage) {
TextMessage textMessage = (TextMessage) message;
try {
String s = textMessage.getText();
RedPaperUserInfo redPaperUserInfo=(RedPaperUserInfo) JSONObject.toBean(JSONObject.fromObject(s), RedPaperUserInfo.class);
redPaperUserInfoDao.insert(redPaperUserInfo);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
三、红包余额退回
本来没写这块内容,后来发现这个余额退回也不是那么直白,毕竟设置了过期时间的key一失效便拿不到红包的信息,在网上找了一些解决方案,比如quartz框架,但这个框架暂时还没学习,后面可能会有补充。于是通过逻辑去解决这个问题。
过期退回思路:在拆红包时向redis存两条数据,一条队列存小红包的信息,一条字符串存该红包的过期时间。当红包过期触发监听事件,读取队列里红包的信息,使用完删除(既然使用了队列,只需要把里面的内容都取走即可)。避免监听不及时,在领取红包的内容也增加了判断。
代码实现:
先配置redis,打开监听。放开这一条notify-keyspace-events Ex
重启redis,在项目里增加配置。
@Configuration
public class RedisListenerConfig {
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
return container;
}
}
修改之前的发红包逻辑,以二倍均值法为例。
仅仅增加了一句代码:redisTemplate.opsForValue().set("copyredId:"+redId,"0",30, TimeUnit.SECONDS);
public void sendRedPaperTwo(int redId,int amount,int num){
//每次发红包将两条数据放入redis中,一条存数据,一条存过期时间
redisTemplate.opsForValue().set("copyredId:"+redId,"0",30, TimeUnit.SECONDS);
Random random=new Random();
//剩余红包金额为M,剩余人数为N,每次抢到的金额 = 随机区间 (0, M/N *2)
for (;num>1;num--){
int randInt = random.nextInt(amount/num*2-1)+1; //将区间控制在(0, M/N *2) ,不能出现为0和最大的情况
amount -= randInt;
redisTemplate.opsForList().leftPush("redId:"+redId,(double)randInt/100);
}
//最后一个将剩余所有金额拿走
redisTemplate.opsForList().leftPush("redId:"+redId,(double)amount/100);
}
修改抢红包代码。增加了红包过期判断,当用户点击红包,如果过期则去返还这个红包余额(为了防止监听器来不及处理)。
public void RushRedPaper(int redId, int userId) {
Object copyred= redisTemplate.opsForValue().get("copyredId:"+redId);
//为空则已过期,当用户再次点击时清空红包
if (copyred==null){
Double stockMoney = 0.0;
while (true){
Double obj = (Double)redisTemplate.opsForList().leftPop("redId:"+redId);
if (obj==null){
System.out.println("该红包已过期");
break;
}
stockMoney+=obj;
}
if (Math.abs(stockMoney) > 0.000001){
System.out.println("还有"+stockMoney+"元未领取,返回给用户");
}
return;
}
Double amount = (Double) redisTemplate.opsForList().leftPop("redId:"+redId);
if (amount!=null){
RedPaperUserInfo userInfo = new RedPaperUserInfo();
userInfo.setRedId(redId);
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String localdataString = LocalDateTime.now().format(dtf);
userInfo.setCreateTime(localdataString);
userInfo.setUserId(userId);
userInfo.setRushAmount(Double.valueOf(amount));
redisTemplate.opsForHash().put("redInfo:"+redId,"user:"+userId,userInfo);
jmsMessagingTemplate.convertAndSend(name,JSONObject.fromObject(userInfo).toString());
System.out.println("用户id:"+userId+"抢到了"+Double.valueOf(amount)+"元");
}
}
最后设置监听器,用来通知红包过期,返还红包余额。
@Component
public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {
@Autowired
private RedisTemplate redisTemplate;
public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
}
/**
* 针对redis数据失效事件,进行数据处理
* @param message
* @param pattern
*/
@Async
@Override
public void onMessage(Message message, byte[] pattern) {
//监听失效key,将余额返回给用户
String expiredCopyKey = message.toString();
String expiredKey=expiredCopyKey.substring(4);
Double stockMoney=0.0;
while (true){
Double obj = (Double) redisTemplate.opsForList().leftPop(expiredKey);
if (obj==null){
break;
}
stockMoney+=obj;
}
//有余额
if (Math.abs(stockMoney)>0.000001){
System.out.println("还有"+stockMoney+"元未领取,返回给用户");
}
}
}