微信小程序抢红包高并发设计
微信小程序抢红包高并发设计
1、背景
某次促销活动采用微信炒群,红包雨的方式进行引流,面向广大C端用户,活动期间面向大规模用户,系统设计需要承载三高(高可用、高并发、高性能)要求。
系统设计首先我们要考虑几个问题:
1、业务场景面向高并发,怎么设计一个高性能抢红包程序以解决在高并发条件下能正常运行?
2、系统高并发瓶颈会出现在哪里?
3、如何拦截无效请求(重复的)?
4、如何应对羊毛党缛羊毛问题?
带着以上问题,尝试设计一个小巧红包发放程序。
系统现有基座层面采用SpringCloud微服务技术框架,SLB负载,应用层面可以加多个应用节点,实现水平扩展,应用服务压力可以有效分解(应用服务基础架构方面基本固化,可改造空间有限);
而数据库集中式存储(目前使用阿里云Polardb一主多从),数据库暂无法实现水平扩展,所以数据库可能成为整个系统一个关键瓶颈,因为高并发秒杀抢红包场景存在短时间内有大量请求去操作数据库时会出现数据的错乱,超发,系统崩溃,mysql死锁等情况。
2、解决思路
既然数据库可能会成系统性能瓶颈,那么就要对症下药,系统设计就尽最大量的减少对数据库的高并发访问。抢红包场景与秒杀场景类似。解决秒杀场景的关键措施在于:上游限流。
上游限流成为系统设计的关键策略。这里提供一下解决高并发设计几个关键措施。
1、页面静态化,就是将整个页面静态化放到OSS或CDN节点中(前端是小程序,整体页面不好做静态化,只能在图片、JS、CSS等方面做点工作)。
2、防止前端操作频繁或重复提交,可以将短时间内同一个用户多次请求合并,也可以分批暂停机制或加数学验证码:用户在计算验证码结果时可以减少大量请求同时进入,减少redis, mysql,服务器的压力。
3、采用多级缓存机制,有一些不常变化字典可以缓存在前端;后端应用程序做多级缓存机制,
a、第一级缓存:内存缓存标记;
b、第二级缓存:应用服务器本地缓存Ehcache本地磁盘化缓存一些关键数据;
c、第三级缓存:分布式缓存redis。
加上多级缓存机制,这是一个巨大优化,通过标识来判断redis的库存是否足够,如不足就中断去读取redis库存
例:
boolean over = map.get(goodsId);
if(over) { return Result.error(‘库存不足’); }
当我们map通过key读取到value值为true的时候,就返回错误提示给用户,这样不管以后有多个请求进入都只运行两行代码,后面的操作无法进入。
4、防刷规则限定
主要是防止恶意用户通过接口方式,或者机器自动去请求系统,造成瞬间大量压力,可以引入之前抢红包通用策略规则,建立有效的防刷机制。
5、redis预减库存
在用户抢红包或秒杀商品前去redis获取当前的库存数量,然后在秒杀时候直接减去redis存储的库存(这里Redis和MySQL数据是同步的,只要进入MQ队列操作完成下单,MySQL数据库会-1数量),从而避开去MySQL读取库存数据。
6、加入MQ消息队列
它是一个消息中间件,通过生产者发送消息给消费者,进行业务操作,而生产者无需知道执行结果,也就是用户点击摇一摇之后等待处理结果,之后再去轮询查询处理结果(异步操作),这样就避开了不断请求去操作数据库。(这里的轮询查询也是直接从redis里面去查询,因为秒杀成功之后会将秒杀的结果放到redis中,轮询时候通过key去查询)。
7、采用负载均衡Nginx
解决高并发的好方法,使用前端负载均衡SLB/Nginx等,后端服务也就是多增加几个tomcat服务器。当用户访问的时候,请求可以提交到空闲的tomcat服务器上。
3、高并发设计
1、前端做好频繁重复提交策略(如合并提交),这是限流的第一步;
2、网关层面或负载均衡层面限流,把一些通用规则限流策略通过脚本方式动态加上去;
3、接口层面做好一些业务限流,比如同一个用户多次提交去重合并,抽过的不给再抽,可以有效防止程序机器刷单情况;
4、利用redis分布式缓存,把库存预加载到redis里面,库存在redis中预扣;
5、利用本地服务器内存标记当用作二级缓存,当redis库存<0时把一些无效请求过滤,减少redis访问压力。
6、利用MQ消息队列,排队抽奖,异步处理订单业务和零钱发放等操作时间较长作业。
抢红包处理流程图
前端和后端接口交互逻辑
1、前端摇一摇发起接口请求,后端接口做一些规则校验之后,快速返回并附带异步任务查询ID即:jobId。
2、前端等摇一摇动作停止之后,发起任务(带上参数jobId)查询接口,如果查询有结果(抽中或抽不中),对于当前用户来说业务算是完成一轮,让用户等待抽下一轮,例如可以暂停3/5分钟(时间间隔业务定义)再次摇一摇。如果查询结果还在处理中,前端可设置每5秒轮询查询一次(直到任务返回处理完成为止)。
3、暂停3/5分钟后再摇如果上次已经抽中,这次再摇,根据业务要求,后端不入抽奖队列,即不给再抽中的机会,直接返回提示给前端。如果上次没有抽中,可以再次入抽奖队列排队再抽。
4、当然前端请求过程中间还有各种防刷验证和各种业务返回提示,如红包已经抽完等待下一轮抽奖。
抢红包处理流程时序图
4、代码实现
秒杀或抢红包实现代码片断,标注说明,代码中有很多业务操作(写入、查询等),当时写的代码优雅性较差,不要看代码优雅性,读者可以不管它,只需要理解高并发处理思路即可。
/**
* 限时红包雨,红包抽奖
*
* @param con 前端提交的业务参数
* @param request 用于获取请求头信息
*/
public ResponseData onRedPackageRain(ActivityPrizeRainCondition con, HttpServletRequest request) {
//各种校验, 校验必填参数
this.validateParam(con);
//校验签名
this.checkAsign(con, request);
//活动轮数id
String liveActivityRoundsId = con.getLiveActivityRoundsId();
String ip = NetworkUtils.getClientIp(request);
//同openID或ip限流
this.rateLimit(con.getOpenid(), ip);
//校验是否在活动时间执行
this.checkedActivityTime(liveActivityRoundsId);
//所有奖品抽完,内存标识,减少Redis访问
Assert.isTrue(!localFlag, "奖品已抽完了,请等下一轮");
//校验当天抽奖次数
this.checkedLotteryNum(liveActivityRoundsId, con.getOpenid(), 86400L);
//取缓存有奖品数量的奖品 随机抽,某一轮红包轮数id前缀
String prefix_key = GlobalConstant.WECHAT_LIVE_ACTIVITY_ROUNDS + liveActivityRoundsId + ":";
Set<String> keys = stringRedisTemplate.keys(prefix_key + "*");
Assert.isTrue(!CollectionUtils.isEmpty(keys), "没有奖品啦");
//可抽奖的奖品id
List<String> newKeys = new ArrayList<>();
for (String key : keys) {
String mapKey = key.substring(key.lastIndexOf(":") + 1);
String mapValue = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isNotEmpty(mapValue) && Integer.parseInt(mapValue) > 0) {
newKeys.add(mapKey);
}
}
if (CollectionUtils.isEmpty(newKeys)) {
localFlag = true;
Assert.isTrue(false, "奖品已抽完了,请等下一轮");
}
/*随机抽一个奖品id*/
Integer rand = RandomUtils.nextInt(newKeys.size());
String prizeLotteryId = newKeys.get(rand);
con.setPrizeNumber(prizeLotteryId);
con.setIp(ip);
//符合条件的用户请求放入MQ队列
rabbitTemplate.convertAndSend(MIAOSHA_QUEUE, con);
LOG.info("-----------红包雨抽到奖品,加入队列:{}", con.toString());
return renderSuccess("具备秒杀资格");
}
/**
* 异步消费抽中红包队列
* @param con 活动请求业务参数
* @param message MQ消息对象
* @param channel MQ通道对象
*/
@RabbitListener(queues = MIAOSHA_QUEUE)
public void consumeMessage(ActivityPrizeRainCondition con, Message message, Channel channel) {
try {
LOG.info("rabbitmq message consume======={}", con.toString());
//设置最大服务消息数量,避免消息处理不过来,全部堆积在本地缓存里
// 会告诉RabbitMQ不要同时给一个消费者推送多于N个消息,即一旦有N个消息还没有ack,则该consumer将block掉,直到有消息ack
// channel.basicQos(0,5,false);
//确认应答信息
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
//执行红包发放流程
this.doRedPackageRain(con);
}catch (Exception e){
LOG.error("Message consume ERROR!",e);
}
}
/**
* 红包发放关键执行程序
* @param con 业务请求参数
*/
private void doRedPackageRain(ActivityPrizeRainCondition con){
//初始中奖金额
double randomMoney = 0;
String liveActivityRoundsId= con.getLiveActivityRoundsId();
String prizeId = con.getPrizeNumber();
String prefix_key = GlobalConstant.WECHAT_LIVE_ACTIVITY_ROUNDS + liveActivityRoundsId+ ":"+prizeId;
String prizeNum = stringRedisTemplate.opsForValue().get(prefix_key);
//入队后再次校验Redis是否有库存
if(StringUtils.isEmpty(prizeNum)||Integer.parseInt(prizeNum)<=0){
//再次 检查缓存所有奖品是否还有库存 有的随机抽
// 某一轮轮数id 前缀
String prefix_key2 = GlobalConstant.WECHAT_LIVE_ACTIVITY_ROUNDS + liveActivityRoundsId+ ":";
Set<String> keys = stringRedisTemplate.keys(prefix_key2+"*");
if(CollectionUtils.isEmpty(keys)){
this.setRedisResult(null,randomMoney,"已抢光了,请下次再来",con.getOpenid(),con.getTimeStamp());
return ;
}
//可抽奖的奖品id
List<String> newKeys = new ArrayList<>();
for(String key : keys){
String mapKey = key.substring(key.lastIndexOf(":")+1);
String mapValue = stringRedisTemplate.opsForValue().get(key);
if(StringUtils.isNotEmpty(mapValue)&&Integer.parseInt(mapValue)>0){
newKeys.add(mapKey);
}
}
if(CollectionUtils.isEmpty(newKeys)){
this.setRedisResult(null,randomMoney,"奖品已抽完了,请等下一轮",con.getOpenid(),con.getTimeStamp());
return ;
}else {
/*再次随机抽一个奖品id*/
Integer rand = RandomUtils.nextInt(newKeys.size());
prizeId = newKeys.get(rand);
con.setPrizeNumber(prizeId);
}
}
// 扣减奖品数据库库存
int cou = liveActivityRoundsDetailMapper.updatePrizeNum(liveActivityRoundsId,prizeId);
if(cou>0){
int prizeNumNew = Integer.parseInt(prizeNum)-1;
//扣减Redis库存
stringRedisTemplate.opsForValue().set(prefix_key,String.valueOf(prizeNumNew));
}else {
this.setRedisResult(null,randomMoney,"奖品已抽完了,请等下一轮",con.getOpenid(),con.getTimeStamp());
return ;
}
//查询中奖奖品详情
LiveActivityPrizeRoundsVo prizeIdVo = liveActivityRoundsDetailMapper.getLiveActivityPrizeRoundsVo(liveActivityRoundsId,prizeId);
//中奖奖品标题
String prizeTitle = prizeIdVo.getTitle();
ActivityPrizeLog activityPrizeLog = new ActivityPrizeLog();
BeanUtils.copyProperties(con, activityPrizeLog);
//插入中奖记录
String uuid = UUID.randomUUID().toString().replaceAll("-", "");
activityPrizeLog.setId(uuid);
activityPrizeLog.setCreateDt(new Date());
activityPrizeLog.setPrizeNumber(prizeId);
activityPrizeLog.setSourceFlag(2);
activityPrizeLog.setActivityId(prizeIdVo.getActivityId());
//轮数id
activityPrizeLog.setSourceBusinessEntityId(liveActivityRoundsId);
//风格:0券1报名卡2特权卡3红包4奖品
if (3 == prizeIdVo.getStyle()) {
//有奖品参与抽奖的:小红包
randomMoney = MoneyPackageUtil.getRandomMoneyByMinMax(prizeIdVo.getPriceMin().doubleValue(), prizeIdVo.getPriceMax().doubleValue());
activityPrizeLog.setIsPay(0);
activityPrizeLog.setTotalMoney(randomMoney);
//插入中奖记录
activityPrizeLogMapper.insert(activityPrizeLog);
LOG.info("插入中奖记录,抽中红包随机生成红包金额==={}", randomMoney);
PayTransferRecord payTransferRecord = this.insertPayTransferWechat(activityPrizeLog, con.getIp(),con.getWechatId());
try {
//异步调起微信红包入账接口
CompletableFuture.supplyAsync(() -> this.payTransferWechat(payTransferRecord.getId(), activityPrizeLog.getId()));
}catch (Exception e){
LOG.error(e.getMessage(),e);
}
} else {
//插入中奖记录
activityPrizeLogMapper.insert(activityPrizeLog);
LOG.info("插入中奖记录,抽中非红包奖品==={}",prizeTitle);
}
this.setRedisResult(prizeId,randomMoney,prizeTitle,con.getOpenid(),con.getTimeStamp());
}
/**
* 把秒杀结果放进Redis
* @param prizeId 红包金额记录ID
* @param randomMoney 随机发放金额
* @param prizeTitle 红包标题
* @param openid 用户openid
*/
private void setRedisResult(String prizeId, Double randomMoney, String prizeTitle, String openid,
String timeStamp) {
Map<String, Object> map = new HashMap<>();
map.put("prizeId", prizeId);
map.put("randomMoney", randomMoney);
map.put("prizeTitle", prizeTitle);
stringRedisTemplate.opsForValue().set(GlobalConstant.WECHAT_LIVE_ACTIVITY_PRIZE_PREFIX + openid + timeStamp,
JSONObject.toJSONString(map), 180, TimeUnit.SECONDS);
}
/**
* 执行保存红包发放记录
* @param activityPrizeLog 活动对象信息
* @param ip 客户端IP
* @param wechatId 用户ID
* @return 返回交易结果记录对象
*/
private PayTransferRecord insertPayTransferWechat(final ActivityPrizeLog activityPrizeLog,
final String ip, final Integer wechatId) {
LOG.info("插入红包发放记录: {}", activityPrizeLog);
PayTransferRecord payTransferRecord = new PayTransferRecord();
payTransferRecord.setWechatId(wechatId);
//状态:0未支付 1已支付
payTransferRecord.setState(0);
payTransferRecord.setCreateDt(new Date());
payTransferRecord.setCustomerId(activityPrizeLog.getCustomerId());
payTransferRecord.setOpenId(activityPrizeLog.getOpenid());
payTransferRecord.setAmount(activityPrizeLog.getTotalMoney());
payTransferRecord.setDesc("福利红包");
payTransferRecord.setBusinessEntityId(activityPrizeLog.getId());
payTransferRecord.setCreateIp(ip);
//插入 轮数id
payTransferRecord.setDeviceInfo(activityPrizeLog.getSourceBusinessEntityId());
payTransferRecord = wxPayService.createPayTransferRecord(payTransferRecord);
return payTransferRecord;
}
/**
* 红包零钱插入支付记录,并且调用远程服务发放接口
*
* @return
*/
private Integer payTransferWechat(String payTransferRecordId, String activityPrizeLogId) {
int count = 0;
if (StringUtils.isNotEmpty(payTransferRecordId)) {
LOG.info("商户开始调用发放福利红包接口,企业预付款payTransferRecordId={}",payTransferRecordId);
PayTransferRecord returnInfo = wxPayService.startPayTransfer(payTransferRecordId);
if (returnInfo != null && 1 == returnInfo.getState()) {
ActivityPrizeLog newActivityPrizeLog = new ActivityPrizeLog();
newActivityPrizeLog.setId(activityPrizeLogId);
newActivityPrizeLog.setIsPay(1);
count = activityPrizeLogMapper.updateById(newActivityPrizeLog);
LOG.info("商户成功发放福利红包,并成功更改支付状态的条数=={}", count);
}
}
return count;
}
以上是定时发放红包促销活动主要代码,代码比较简单,除掉数据库操作的业务,实际代码不到200行。
这里有个问题是:redis扣减库怎么与数据同步,存库数据与数据库、redis同步顺序先后的问题?
上面的代码处理逻辑是抢到直接扣减redis库存,如果扣减后面程序发生一些问题,后面还要解决redis库存与数据库库存的数据同步问题。
=============================================================================================================================
第二个业务场景,直播间抢红包也类似,只不过代码稍微的改进一下,不直接扣减redis库存,而是采用预扣减除redis库存的方式,消除数据同步的异常问题,情况稍微好一些。
查询获得红包(红包在派发的时候,已经放入redis相应key)。直接操作查询redis,无数据库操作,性能较高。
/**
* 用户直播间查询已获得有效的红包,同一时间内一个直播活动id只有一个红包
* @param activityId 活动id
* @param liveActivityId 直播活动id
* @param userAccountId 用户id
* @return
*/
public String getValidRedEnvelope(String activityId, String liveActivityId,
String userAccountId, HttpServletRequest request) {
//参数校验
checkParam(activityId, liveActivityId, userAccountId);
//红包剩余可抢名额数量:红包id_红包数量
Object obj = redisUtils.get(RedEnvelopesUtil.LIVE_RED_ENVELOPES_KEY + liveActivityId);
//该直播活动下没有红包,直接返回空
if (Objects.isNull(obj) || StringUtils.isBlank(obj.toString())) {
return null;
}
String redEnvelopeId = obj.toString();
//根据直播活动id+用户id+红包id查缓存判断用户是否领过红包,没有就返回红包
String userGrantKey = RedEnvelopesUtil.getHadGetEnvelopesUserKey(liveActivityId, userAccountId, redEnvelopeId);
if (Objects.isNull(redisUtils.get(userGrantKey))) {
return redEnvelopeId;
}
return null;
}
在直播间发放红包关键代码
查看代码
生成随机红包金额工具类
import java.util.Random;
/**
* 随机产生红包金额工具类
* @author cgli, E-mail:cgli@qq.com
* @version created on :Dec 24, 2019 8:31:27 PM
*/
public final class MoneyPackageUtil {
/**
*
*/
private MoneyPackageUtil() {
}
/**
* 获取每次抢红包的钱。
*
* @param remainer
* @return
*/
public static double getRandomMoney(MoneyPackage remainer) {
// remainSize 剩余的红包数量
// remainMoney 剩余的钱
double money = 0;
//金额剩余0时直接返回0
if (remainer.getRemainMoney() == 0) {
return money;
}
if (remainer.getRemainSize() <= 1) {
//remainer.remainSize--;
money = remainer.getRemainMoney();
remainer.setRemainSize(0);
remainer.setRemainMoney(0);
return money;
}
Random r = new Random();
//最小初始红包
double min = 0.01;
double max = (remainer.getRemainMoney() / remainer.getRemainSize()) * 2;
money = r.nextDouble() * max;
money = money <= min ? 0.01 : money;
money = Math.floor(money * 100) / 100;
remainer.setRemainSize(remainer.getRemainSize() - 1);
remainer.setRemainMoney(remainer.getRemainMoney() - money);
//remainer.remainMoney -= money;
return money;
}
/**
* 取某个最小值min,最大值max之间的随机数(min,max) double类型
*
* @param min
* @param max
* @return
*/
public static double getRandomMoneyByMinMax(double min, double max) {
double money = 0;
// money = min + Math.random() * max % (max - min + 1);
money = min + ((max - min) * new Random().nextDouble());
money = Math.floor(money * 100) / 100;
return money;
}
}
5、压力测试
用户抢红包获取金额场景压测
服务器配置,应用2台,网关服务整个平台(还有很多其他服务)共享4台
- CPU: 4核
- 内存:8G
- SSD硬盘:60G
- 前端阿里云SLB负载均衡。
- 数据库:使用阿里的Polardb;
- 操作系统:Linux 7.4
- JDK8版本;
- RabbitMQ
- 阿里云购买redis服务4HZ/16GB
场景 |
并发用户数 |
每秒事务数TPS |
平均响应时间(秒) |
90%响应时间(秒) |
平均网络吞吐(Bytes/s) |
成功事务数 |
失败事务数 |
平均每秒点击数 |
持续时长(分钟) |
获取用户实时金额 |
1 |
20 |
0.035 |
0.038 |
18434.2 |
100 |
0 |
20 |
- |
50 |
909.73 |
0.052 |
0.075 |
1080858.6 |
181946 |
0 |
909.73 |
3 |
|
100 |
1098.273 |
0.082 |
0.094 |
1033785.077 |
241620 |
0 |
1098.273 |
3 |
|
200 |
1152.715 |
0.146 |
0.177 |
1084904.538 |
299706 |
0 |
1152.715 |
3 |
|
500 |
1139.852 |
0.262 |
0.394 |
1072909.096 |
437703 |
190 |
1139.852 |
3 |
|
600 |
1132.715 |
0.304 |
0.45 |
1066107.639 |
342080 |
266 |
1132.715 |
3 |
|
800 |
1055.268 |
0.336 |
0.476 |
993213.388 |
361957 |
635 |
1055.268 |
3 |
|
1000 |
1148.911 |
0.455 |
0.823 |
1083026.667 |
441182 |
1707 |
1151.583 |
3 |
场景说明:基准场景为1个并发迭代100次,取平均值;并发场景为规定并发数(每秒上5个)迭代1次在3分钟内持续发起请求。
结果分析: 获取用户实时余额接口响应较快,服务器压力不大,随着并发数的增加失败的事务也有所增加,但事务成功率都在99%以上。
备注:并发场景压测过程中出现的报错如下:
Error -26366:Action.c(19) Error -26366 "Text="status"true" not found for web_reg_find
Error -26374:Action.c(19) Error -26374 The above "not found" error(s) may be explained by header and body byte counts being 0 and 0, respectively.
Error -27728:Action.c(19) Error -27728 Step download timeout (120 seconds) has expired
到了1000并发,再往上就压不上去了,主要是压力测试机器自身出口带宽已经打满的情况。2台低配置机器,在综合场景下1000并发响应0.45秒,也已经符合业务场景的要求。实际上,在后面业务投入情况来看也是OK的。
应用本身机器和数据库压力并不高。
应用服务器资源使用情况:
数据库资源使用情况:
6、未完问题
防刷在IP检查,前后端验签等处理下已经可以有效拦住80%以上无效请求,但不能做到100%拦截。
1、后端合并请求处理,在这里没有体现,还可以继续完善优化。
2、防羊毛党处理机制,篇幅较长,打算另外一篇中详细介绍。