笔者16年刚入新公司不久时,曾接到一个需求要搞一个从来没搞过的抽奖项目。做摇一摇、大转盘等抽奖业务。和两个小伙伴一起,我负责服务端抽奖的所有接口,他们负责后台抽奖数据管理,一周时间搞定。当时由于刚进公司,对公司产品流量没什么经验数据,某个同事给的方案是抽奖过程查数据、存数据走Mysql数据库。刚上线时还算顺利,流量确实不是很高,但也吃紧吧。不久恰逢公司想做大力度活动,筹划了一个百万红包雨,几乎是没有开发时间的。当时产品找过来要求前端直接对接我们接口。我给的评估是流量太高,走数据库肯定撑不住的,但是大家都是新人,都不知道会有多少流量会进来,这个活动也是第一次做,时间上似乎确实是来不及了。最后强行对接了我们的抽奖接口。后果是,系统大面积瘫痪,3分钟写入几十万数据,严重线上事故。。。
事故之后,便是反思以及做系统改造,使其能够支撑现有业务。于是,我这边负责抽奖服务的改造工作。经过一系列改造和压测后,抽奖服务的性能达到了4万qps,基本满足了业务的要求。下面简单分享下我的项目改造的一些实战经验吧。
一、抽奖算法模型
以下是省略了相关业务、额外算法的单纯根据配置计算中奖概率的算法代码,方便读者理解算法
图示,阐述了算法原理,计算出抽奖活动一组数字,根据抽奖奖品的概率计算出每个奖品所在的数字区间。Random随机数落在了哪个奖品的数字区间,则用户中这个区间对应的奖品。
1)抽奖奖品对象
public class LotteryItem {
/**
* 奖品名称
*/
private String awardName;
/**
* 中奖几率
*/
private Double awardProbability;
/**
* 奖品中奖数字范围起点
*/
private Integer awardStartCode;
/**
* 奖品中奖数字范围终点
*/
private Integer awardEndCode;
/**
* 中奖数字,实际应用可不定义。
* 此处定义是为了方便读者理解
*/
private Integer awardCode;
public String getAwardName() {
return awardName;
}
public void setAwardName(String awardName) {
this.awardName = awardName;
}
public Double getAwardProbability() {
return awardProbability;
}
public void setAwardProbability(Double awardProbability) {
this.awardProbability = awardProbability;
}
public Integer getAwardStartCode() {
return awardStartCode;
}
public void setAwardStartCode(Integer awardStartCode) {
this.awardStartCode = awardStartCode;
}
public Integer getAwardEndCode() {
return awardEndCode;
}
public void setAwardEndCode(Integer awardEndCode) {
this.awardEndCode = awardEndCode;
}
public Integer getAwardCode() {
return awardCode;
}
public void setAwardCode(Integer awardCode) {
this.awardCode = awardCode;
};
}
2) 抽奖信息对象
/**
* @description: 抽奖活动中,中奖概率计算模型
* @author www.ityuan.com
* @date 2017年12月28日 上午11:48:02
*/
public class Lottery {
/**
* 中奖数字范围起点(通常0作为起点)
*/
private Integer winningStartCode;
/**
* 当前概率计算出的中奖数字范围终点
*/
private Integer winningEndCode;
/**
* 中奖的数字范围
*/
private Integer codeScope;
public Integer getWinningStartCode() {
return winningStartCode;
}
public void setWinningStartCode(Integer winningStartCode) {
this.winningStartCode = winningStartCode;
}
public Integer getWinningEndCode() {
return winningEndCode;
}
public void setWinningEndCode(Integer winningEndCode) {
this.winningEndCode = winningEndCode;
}
public Integer getCodeScope() {
return codeScope;
}
public void setCodeScope(Integer codeScope) {
this.codeScope = codeScope;
}
}
3)抽奖算法代码
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* @description: TODO(这里用一句话描述这个类的作用)
* @author www.ityuan.com
* @date 2017年12月28日 下午9:24:40
*/
public class LotteryUtils {
private static final Random random = new Random();
private static final Integer MAXSOPE = 100000000;
public static void calAwardProbability(Lottery lottery, List<LotteryItem> lotteryItemList) {
Integer codeScope = 1;
for (LotteryItem item : lotteryItemList) {
Integer nowScope = 1;
Double awardProbability = item.getAwardProbability();
while (true) {
Double test = awardProbability * nowScope;
// 概率的精确度,调整到小数点后10位,概率太小等于不中奖,跳出
if (test < 0.0000000001) {
break;
}
if ((test >= 1L && (test - test.longValue()) < 0.0001D) || nowScope >= MAXSOPE) {
if (nowScope > codeScope) {
// 设置中奖范围
codeScope = nowScope;
}
break;
} else {
// 中奖数字范围以10倍进行增长
nowScope = nowScope * 10;
}
}
}
Integer winningStartCode = 0;
Integer winningEndCode = winningStartCode;
for (LotteryItem item : lotteryItemList) {
Integer codeNum = (int) (item.getAwardProbability() * codeScope); // 获得其四舍五入的整数值
// 无人中奖时,将中奖的起始范围设置在随机数的范围之外
if (codeNum == 0) {
item.setAwardStartCode(codeScope + 1);
item.setAwardEndCode(codeScope + 1);
} else {
item.setAwardStartCode(winningEndCode);
item.setAwardEndCode(winningEndCode + codeNum - 1);
winningEndCode = winningEndCode + codeNum;
}
}
// 设置用户的中奖随机码信息
lottery.setWinningStartCode(winningStartCode);
lottery.setWinningEndCode(winningEndCode);
lottery.setCodeScope(codeScope);
}
public static LotteryItem beginLottery(Lottery lottery, List<LotteryItem> lotteryItemList) {
// 确定活动是否有效,如果活动无效则,直接抽奖失败
Integer randomCode = random.nextInt(lottery.getCodeScope());
if (randomCode >= lottery.getWinningStartCode() && randomCode <= lottery.getWinningEndCode()) {
for (LotteryItem item : lotteryItemList) {
if (randomCode >= item.getAwardStartCode() && randomCode <= item.getAwardEndCode()) {
item.setAwardCode(randomCode);
return item;
}
}
}
return null;
}
public static void main(String[] args) {
List<LotteryItem> lotteryItemList = new ArrayList<LotteryItem>();
LotteryItem awardItem1 = new LotteryItem();
awardItem1.setAwardName("红包10元");
awardItem1.setAwardProbability(0.25D);
lotteryItemList.add(awardItem1);
LotteryItem awardItem2 = new LotteryItem();
awardItem2.setAwardName("红包20元");
awardItem2.setAwardProbability(0.25D);
lotteryItemList.add(awardItem2);
LotteryItem awardItem3 = new LotteryItem();
awardItem3.setAwardName("谢谢参与");
awardItem3.setAwardProbability(0.5D);
lotteryItemList.add(awardItem3);
Lottery lottery = new Lottery();
LotteryUtils.calAwardProbability(lottery, lotteryItemList);
System.out.println("抽奖活动中奖数字范围:["+lottery.getWinningStartCode()+","+lottery.getWinningEndCode()+")");
LotteryUtils.beginLottery(lottery, lotteryItemList);
for (LotteryItem item : lotteryItemList) {
System.out.println(item.getAwardName()+" 中奖数字范围:["+item.getAwardStartCode()+","+item.getAwardEndCode()+"]");
}
System.out.println("以下是模拟的抽奖中奖结果:");
LotteryItem award1 = LotteryUtils.beginLottery(lottery, lotteryItemList);
System.out.println("抽中的数字是:"+award1.getAwardCode()+",恭喜中奖:"+award1.getAwardName()+",数字落点["+award1.getAwardStartCode()+","+award1.getAwardEndCode()+"]");
LotteryItem award2 = LotteryUtils.beginLottery(lottery, lotteryItemList);
System.out.println("抽中的数字是:"+award2.getAwardCode()+",恭喜中奖:"+award2.getAwardName()+",数字落点["+award2.getAwardStartCode()+","+award2.getAwardEndCode()+"]");
LotteryItem award3 = LotteryUtils.beginLottery(lottery, lotteryItemList);
System.out.println("抽中的数字是:"+award3.getAwardCode()+",恭喜中奖:"+award3.getAwardName()+",数字落点["+award3.getAwardStartCode()+","+award3.getAwardEndCode()+"]");
LotteryItem award4 = LotteryUtils.beginLottery(lottery, lotteryItemList);
System.out.println("抽中的数字是:"+award4.getAwardCode()+",恭喜中奖:"+award4.getAwardName()+",数字落点["+award4.getAwardStartCode()+","+award4.getAwardEndCode()+"]");
}
}
抽奖Demo代码执行结果
抽奖活动中奖数字范围:[0,100)
红包10元 中奖数字范围:[0,24]
红包20元 中奖数字范围:[25,49]
谢谢参与 中奖数字范围:[50,99]
以下是模拟的抽奖中奖结果:
抽中的数字是:47,恭喜中奖:红包20元,数字落点[25,49]
抽中的数字是:69,恭喜中奖:谢谢参与,数字落点[50,99]
抽中的数字是:22,恭喜中奖:红包10元,数字落点[0,24]
抽中的数字是:83,恭喜中奖:谢谢参与,数字落点[50,99]
二、对核心电商系统的保护
如果因为成本控制原因,当电商系统的硬件耐压能力有限时,抽奖活动带来的瞬间高频流量可能会将防火墙击溃,从而导致整个电商或者其他正常业务受影响。这时候就需要考虑将抽奖系统与正常业务系统的环境进行隔离。例如,将抽奖系统迁移到阿里云上部署或者其他次要机房。
三、系统的过载保护
系统的过载保护目的是当流量超出预期时,自动过滤一部分流量,防止系统被拖垮。
常用的过载保护思路,大多是基于漏桶算法思想或者信号量控制。
例如:java自带的Semaphore 或者Google Guava
Semaphore semaphore = new Semaphore(10);
if (semaphore.tryAcquire()) {// (非阻塞式)
// 获得许可证才可进行下一步操作
// semaphore.acquire();(阻塞式)
// dos somethine
// 释放许可证
semaphore.release();
}
四、前端的空包策略
在预估流量过高的情况下,可以前端采用空包的策略。即用户发起的抽奖一定概率下不调用后端接口服务,直接返回未中奖。防止过多的请求流向后端服务。
五、数据的存储策略,压测支持4万qps
如果数据查询直接走数据库,在不可预计的高频流量下,极有可能拖垮数据库,从而导致整个服务崩溃。所以,要支持高并发、高流量,需采用高效的缓存策略以及耐压的数据存储服务。
1) 本地缓存策略,抽奖的基础数据因为数据量不大,可以放入到本地缓存中。从而进行高效读取。
2) Redis缓存策略,数据查询先走本地缓存,再走Redis缓存,最后走MySql,也就是说几乎彻底隔离了抽奖过程中与数据库的直接打交道。
六、高并发下抽奖如何防止奖品因为并发超量发奖?
采用Redis的自增策略,可在高效抽奖的同时并保证类似数据库乐观锁的方式,来实现抽奖的奖品不会被超量抽中奖。实现方式如下:
参考Redis的封装:http://www.ityuan.com/coding/385.html
封装一个Redis的工具类:RedisUtils以及方法inc。RedisUtils.inc(“key”) 每执行一次,返回值自增+1。那么:
RedisUtils.inc(“Prefix”+lotteryItemId) 自增值大于奖品lotteryItem的最大可发奖品数num时,则返回谢谢参与或者未中奖即可。
七、中奖记录的保存、抽取、发奖
1) 用户中奖后,将中奖记录保存Redis中。为方便将数据取出,需要通过Redis构造一个自增主键(incKey)与抽奖活动ID构建缓存的Key。我们暂且将它命名为:lotteryAwardKey。
lotteryAwardKey = "prefix"+lotteryId+"_"+incValue。
incValue从1开始自增。nowIncValue=RedisUtils.inc(lotteryAwardKey);
2) 将中奖记录抽取并批量insert进入Mysql数据库,类似代码如下:
for (int start = awardPageNo;start < nowIncValue;start++) {
awardList.add(RedisUtils.get("prefix”+lotteryId+"_"+start));
}
(这里awardPageNo为尚未抽取数据的自增值的起点)
3)发奖操作,只需要定时器将Mysql中未发奖的中奖数据捞取,采用多线程发奖即可。