红包雨设计
# 红包基本业务
首先,在我们做一个红包系统之前,需要了解一个抢红包的基本业务
- 创建红包: 红包的发起者首先创建一个红包,设置红包的金额、数量以及其他限制条件(如单个红包金额上限、留言等)。
- 发放红包: 红包发起者将创建好的红包发送给参与抢红包的人,可以通过社交媒体、聊天应用、红包平台等进行发放。
- 领取红包: 参与抢红包的人收到红包后,可以点击打开红包进行领取。在某些情况下,为了增加趣味性,可能需要等待特定的时间或触发某些条件才能打开红包。
- 随机分配: 当参与者打开红包时,系统会进行随机分配,计算出该红包的具体金额,并将金额实时显示给领取者。
- 金额显示: 红包金额可能是固定的,也可能是随机分配的,参与者在打开红包后可以看到自己获得的具体金额。
- 金额入账: 红包金额入账到领取者的账户中,可以作为余额存储起来或者用于其他支付或提现等操作。
- 红包记录: 抢红包的过程会被记录下来,包括谁发起的红包、参与者名单以及每个人获得的金额等信息。
- **红包回退:**如果红包还有剩余的金额,需要回退给原发红包用户。
我们首先基于单机下看看如何设计红包系统。
# 场景模拟
我们想象这样一个场景
- 老大发送了一个100元的红包,分成了10份,发送到群里供大家抢红包。(C2C)
- 某一个商家发起了一次红包雨,很多用户可以在一次红包雨中抢红包。红包雨以一定的速率下落。(B2C)
万变不离其中,上面两种情况都有一个共有的业务。就是拆红包、发红包、抢红包
疑惑点:
用什么数据结构本次的发红包记录
1.1 发红包。100元,分10个红包发送,用什么数据结构
1.2 分红包。要求大家分的金额都差不多。即金额拆分范围(1个人99远,其他10远分1元,这肯定是不行的)【即拆分红包算法是什么】
1.3 抢红包。如何在高并发下抢红包。
要求:数据一致性+高并发+不能加锁(效率)
1.4 记录本次红包活动的数据争抢情况。
每个人分别抢到了多少,记录本次短期的。
长期的统计并记录年度的红包总结(一年收发了多少红包)
发送的红包,没人抢夺,没有任何一个红包被抢到,24小时候必须回退给红包发送者?如何回退?(一个人都没有抢,全部回退;部分人抢了红包,还有剩余,剩下的进行回退)
记录分为:短期记录+长期记录+没有抢到的红包进行回退
# 解决方案
细化一下以上的业务需求即解决方案:
各种节假日,发红包+抢红包,100%高并发业务要求,不能用mysql来做 首先,这种有高并发量的任务,一般的核心操作都是在缓存redis中进行,然后将数据异步保存到mysql进行一个落盘操作。所以,抢红包我们用到的是redis进行操作。
一个总的大红包,有可能被拆分成多个小红包,总金额=分金额1+分金额2+……+分金额N 这里会用到一个红包领域通用的算法:二倍均值法 这是一种较为常见的红包分配算法。在每次分配红包时,随机生成一个红包金额,但要保证剩余红包金额的均值是前一次的一半。这样可以保证每个红包获得的金额较为均衡。
public static int[] splitRedPackageAlgorithm(int totalMoney, int redPackageNumber) {
int[] redPackageNumbers = new int[redPackageNumber];
int useMoney = 0;
for (int i = 0; i < redPackageNumber; i++) {
if (i == redPackageNumber - 1) {
redPackageNumbers[redPackageNumber - 1] = totalMoney - useMoney;
} else {
int avgMoney = ((totalMoney - useMoney) / (redPackageNumber - i)) * 2;
redPackageNumbers[i] = 1 + new Random().nextInt(avgMoney - 1);
}
useMoney = useMoney + redPackageNumbers[i];
}
return redPackageNumbers;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
其实除了这一个算法之外,还有其他的一些算法,也可以供参考文末的红包常见算法
以上,拆红包的逻辑就实现了,但是如何发红包呢?
我们可以把分好的红包放入一个队列中,对应抢红包的人从队列中拿红包就可以了。但是因为我们不想加锁,所以应该用什么队列呢?这时我们就可以使用redis队列。lpush、rpop。相应的代码案例如下:
@Operation(summary = "发红包")
@GetMapping(value = "/send/{totalMoney}/{redPackageNumber}")
public Result<String> sendRedPackage(@Parameter(name = "totalMoney", description = "红包金额", required = true) @PathVariable int totalMoney,
@Parameter(name = "redPackageNumber", description = "红包个数", required = true) @PathVariable int redPackageNumber) {
//1 拆红包,将总金额totalMoney拆分为redPackageNumber个子红包
Integer[] splitRedPackages = RedPackageUtil.splitRedPackageAlgorithm(totalMoney, redPackageNumber);//拆分红包算法通过后获得的多个子红包数组
log.info("拆红包: {}", JSON.toJSONString(splitRedPackages));
//2 发红包并保存进list结构里面且设置过期时间
String key = IdUtil.simpleUUID();
redisTemplate.opsForList().leftPushAll(Constant.RED_PACKAGE_KEY + key, splitRedPackages);
redisTemplate.expire(Constant.RED_PACKAGE_KEY + key, 1, TimeUnit.DAYS);
//3 发红包OK,返回前台显示
return Result.build(key, ResultCodeEnum.SUCCESS);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
上面的第二步,两个redisTemplate操作会发生并发问题吗?答案是不会的。
因为每一个线程会生成一个新的key,而redisTemplate也只会对一个key进行操作,相当于是只有一个用户进行发红包操作,是不会出现线程不安全的问题的。
- 若每个人只能抢一次红包(当然一个人抢对也可以,现在我们规定只能抢一个),你需要记录,比如100远,分拆分成10个红包发出去。总计有10个红包,抢一个少一个,总数显示(10/6)直到完,需要记录哪些人抢到了红包,重复抢作弊是不可以的
即当一个用户抢红包成功后,需要实时显示剩余多少,该用户是否已经抢过红包等,需要进行记录。本项目决定记录在redis当中,速率较快。且将抢的结果存放在redis hash中:
@Operation(summary = "抢红包")
@GetMapping(value = "/rob1/{redPackageKey}")
public Result robRedPackage1(@Parameter(name = "redPackageKey", description = "红包标识", required = true) @PathVariable String redPackageKey,
@Parameter(name = "token", description = "用户标识", required = true) @RequestHeader("token") String token) {
//1 验证某个用户是否抢过红包,不可以多抢
Object redPackage = redisTemplate.opsForHash().get(Constant.RED_PACKAGE_CONSUME_KEY + redPackageKey, token);
//2 没有抢过可以去抢红包,否则返回-2表示该用户抢过红包了
if (null == redPackage) {
//2.1 从红包池(list)里面出队一个作为该客户抢的红包,抢到了一个红包
Object partRedPackage = redisTemplate.opsForList().leftPop(Constant.RED_PACKAGE_KEY + redPackageKey);
if (partRedPackage != null) {
//2.2 抢到红包后需要记录进入hash结构,表示谁抢到了多少钱的某个子红包
redisTemplate.opsForHash().put(Constant.RED_PACKAGE_CONSUME_KEY + redPackageKey, token, partRedPackage);
log.info("用户:{} 抢到了多少钱的红包:{}", token, partRedPackage);
//TODO 后续异步进mysql或者MQ进一步做统计处理,每一年你发出多少红包,抢到了多少红包,年度总结
return Result.build(partRedPackage, ResultCodeEnum.SUCCESS);
}
// 抢完了
log.info("红包池已抢空,红包标识:{}", redPackageKey);
return Result.build(null, ResultCodeEnum.RED_PACKAGE_FINISHED);
}
//3 某个用户抢过了,不可以作弊抢多次
return Result.build(null, ResultCodeEnum.RED_PACKAGE_REAPT);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
上面会出现并发安全问题吗?
因为是redis,所有的操作在Object partRedPackage = redisTemplate.opsForList().leftPop(Constant.RED_PACKAGE_KEY + redPackageKey);
这里都是串行执行的,最终不会出现数据一致性的问题。
有可能还需要计时,完整抢完,从出发到全部over,耗时多少
红包过期,或者群主人品极差,没人抢红包,原封不动退回
红包过期,剩余金额可能需要退回到红包主账户下
=====================================================================================================
以上是单机最小的一个微服务。如果并发量持续加到,应该怎么办。
1w->10w
集群:
集群后可能会有的问题:
被薅羊毛了,发现后台有人盗刷,如何配置白名单和ip重点限制。gateway网管+ 隔离策略
削峰限流+服务熔断+服务降级+服务流控,分割+限流+软件负载
# 单机
前端界面
红包后台:配置界面中心
# 红包常见算法
- 随机分配算法: 最简单的算法是随机生成每个红包的金额。每个红包的金额是在一定范围内随机生成的,确保所有红包金额之和等于红包总金额。这种算法简单快捷,但可能导致某些红包金额过大或过小,不够公平。
import java.util.Random;
public class RedPacketAlgorithm {
public static int[] randomDistribution(int totalAmount, int num) {
Random random = new Random();
int[] redPackets = new int[num];
int remainingAmount = totalAmount;
for (int i = 0; i < num - 1; i++) {
int maxAmount = remainingAmount / (num - i);
redPackets[i] = random.nextInt(maxAmount) + 1;
remainingAmount -= redPackets[i];
}
redPackets[num - 1] = remainingAmount;
return redPackets;
}
public static void main(String[] args) {
int totalAmount = 1000; // 总金额,单位为分
int num = 10; // 红包数量
int[] result = randomDistribution(totalAmount, num);
for (int i = 0; i < num; i++) {
System.out.println("红包" + (i + 1) + ":" + result[i] + "分");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
- 等额分配算法: 将红包总金额均分为n份,每个红包的金额都是相等的。这种算法保证了每个红包获得的金额一样,非常公平,但可能缺乏趣味性。
public class RedPacketAlgorithm {
public static int[] equalDistribution(int totalAmount, int num) {
int[] redPackets = new int[num];
int amountPerPacket = totalAmount / num;
for (int i = 0; i < num; i++) {
redPackets[i] = amountPerPacket;
}
return redPackets;
}
public static void main(String[] args) {
int totalAmount = 1000; // 总金额,单位为分
int num = 10; // 红包数量
int[] result = equalDistribution(totalAmount, num);
for (int i = 0; i < num; i++) {
System.out.println("红包" + (i + 1) + ":" + result[i] + "分");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
- 随机金额分配算法: 在一定的范围内随机生成每个红包的金额,同时要保证所有红包金额之和等于红包总金额。与随机分配算法相比,这种算法可以更好地控制红包金额的分布,避免出现过大或过小的金额。
import java.util.Random;
public class RedPacketAlgorithm {
public static int[] randomAmountDistribution(int totalAmount, int num) {
Random random = new Random();
int[] redPackets = new int[num];
int remainingAmount = totalAmount;
for (int i = 0; i < num - 1; i++) {
int maxAmount = remainingAmount - (num - i - 1);
int currentAmount = random.nextInt(maxAmount) + 1;
redPackets[i] = currentAmount;
remainingAmount -= currentAmount;
}
redPackets[num - 1] = remainingAmount;
return redPackets;
}
public static void main(String[] args) {
int totalAmount = 1000; // 总金额,单位为分
int num = 10; // 红包数量
int[] result = randomAmountDistribution(totalAmount, num);
for (int i = 0; i < num; i++) {
System.out.println("红包" + (i + 1) + ":" + result[i] + "分");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
- 二倍均值法: 这是一种较为常见的红包分配算法。在每次分配红包时,随机生成一个红包金额,但要保证剩余红包金额的均值是前一次的一半。这样可以保证每个红包获得的金额较为均衡。
import java.util.Random;
public class RedPacketAlgorithm {
public static int[] doubleAverageDistribution(int totalAmount, int num) {
Random random = new Random();
int[] redPackets = new int[num];
int remainingAmount = totalAmount;
int remainingNum = num;
for (int i = 0; i < num - 1; i++) {
int maxAmount = remainingAmount / remainingNum * 2;
int currentAmount = random.nextInt(maxAmount) + 1;
redPackets[i] = currentAmount;
remainingAmount -= currentAmount;
remainingNum--;
}
redPackets[num - 1] = remainingAmount;
return redPackets;
}
public static void main(String[] args) {
int totalAmount = 1000; // 总金额,单位为分
int num = 10; // 红包数量
int[] result = doubleAverageDistribution(totalAmount, num);
for (int i = 0; i < num; i++) {
System.out.println("红包" + (i + 1) + ":" + result[i] + "分");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
- 线段切割法: 将红包总金额看作一条线段,随机在线段上选择n-1个点,然后将线段切割成n段,每段对应一个红包金额。这种算法也能实现较为均衡的红包分配。
import java.util.Random;
public class RedPacketAlgorithm {
public static int[] segmentCuttingDistribution(int totalAmount, int num) {
Random random = new Random();
int[] redPackets = new int[num];
int[] points = new int[num - 1];
for (int i = 0; i < num - 1; i++) {
points[i] = random.nextInt(totalAmount - num) + 1;
}
Arrays.sort(points);
redPackets[0] = points[0];
for (int i = 1; i < num; i++) {
redPackets[i] = points[i - 1] - points[i];
}
redPackets[num - 1] = totalAmount - points[num - 2];
return redPackets;
}
public static void main(String[] args) {
int totalAmount = 1000; // 总金额,单位为分
int num = 10; // 红包数量
int[] result = segmentCuttingDistribution(totalAmount, num);
for (int i = 0; i < num; i++) {
System.out.println("红包" + (i + 1) + ":" + result[i] + "分");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34