总结复习一下之前的秒杀系统,以下是前文链接,后续会不断修改和完善。
SSM项目实战(一)— 高并发秒杀系统之DAO层
SSM项目实战(二)–高并发秒杀系统之Service层
SSM项目实战(三)— 高并发秒杀系统之Web层
项目源码地址为:https://github.com/LiuKay/seckill
秒杀事务= 减库存 + 插入购买明细
为什么说这是一个事务?
如果减了库存没有记录明细,或者记录了明细却没有减库存,这时候就会出现少卖或者超卖的情况。
通过这张图,我们来提取后台业务需要的接口(红色代表会出现高并发的点,下面分析性能优化时会说到):
假设我们的项目根目录为:/seckill
注:seckillId 为秒杀商品的ID即:productId
具体分析:
初期,我们可能会这么实现接口:
//商品详情
@RequestMapping(value = "/{seckillId}/detail",method = RequestMethod.GET)
public String detail(@PathVariable("seckillId") Long seckillId,Model model){
if(seckillId==null){
return "redirect:/seckill/list";
}
//调用Service,Service调用DAO
Seckill seckill=seckillService.getById(seckillId);
if(seckill==null){
return "forward:/seckill/list";
}
model.addAttribute("seckill",seckill);
return "detail";
}
也就是:
通过商品详情url -> 访问应用程序 (controller-> 商品详情service -> DAO)-> MySQL
但是实际情况是:在秒杀场景中,我们都会不停的刷新浏览器来查看秒杀是否开启,如果是上面的这个方案,就会对应用程序和数据库造成巨大压力,从而导致崩溃。
分析秒杀业务容易得知,用户其实并不关心商品详情,同时商品信息也不会变更,所以秒杀商品的Detail 可以做成静态页面,然后用CDN 加速。
CDN 其实就是一个就近访问,也是类似于缓存的作用。
然后我们会有下面方案:
商品详情页静态化、CDN化之后,并不需要访问应用程序了,此时我们需要判断秒杀是否开启,于是就有了下面这个接口:
通过获取系统时间与秒杀开启时间比较来判断秒杀是否开启,此时,我们的接口只需要返回一个系统时间即可:
//返回服务器当前时间
@RequestMapping(value = "/time/now",method = RequestMethod.GET)
@ResponseBody
public SeckillResult<Long> time(){
Date now=new Date();
return new SeckillResult<Long>(true,now.getTime());
}
Java进行一个系统调用的时间时2ns,1s=100010001000ns,在一秒内能够抗住的并发量是很高的
在前台我们利用一个jq倒计时插件,当秒杀未开启时显示倒计时,秒杀开始后显示为秒杀按钮,并且暴露秒杀接口,如下图:
秒杀接口只在秒杀开启时暴露,防止通过url在秒杀开启前进行秒杀,通过js倒计时插件判断秒杀开启时,然后获取秒杀接口
客户端js代码:
countdown:function(seckillId,nowTime,startTime,endTime) {
var seckillBox=$("#seckill-box");
if(nowTime>endTime){
//秒杀结束了
seckillBox.html('秒杀结束');
}else if(nowTime<startTime){
//秒杀未开始,计时事件绑定
var killTime=new Date(startTime+1000);
seckillBox.countdown(killTime,function(event) {
//时间格式
var format=event.strftime('秒杀倒计时: %D天 %H时 %M分 %S秒');
seckillBox.html(format);
/*时间完成后回调事件*/
}).on('finish.countdown',function() {
//获取秒杀地址,控制实现逻辑,执行秒杀
seckill.handleSeckillkill(seckillId,seckillBox);
});
}else{
//秒杀开始
seckill.handleSeckillkill(seckillId,seckillBox);
}
}
handleSeckillkill:function(seckillId,node) {
//处理秒杀逻辑
node.hide()
.html('');
$.post(seckill.URL.exposer(seckillId),{},function(result) {
//在回调函数中执行交互流程
if(result && result['success']){
var exposer=result['data'];
if(exposer['exposed']){
//开启秒杀
//获取秒杀的地址
var md5=exposer['md5'];
var killUrl=seckill.URL.execution(seckillId,md5);
console.log('killUrl:',killUrl);
//用one绑定,只绑定一次点击事件
$('#killBtn').one('click',function() {
//绑定执行秒杀请求的操作
//1.先禁用按钮
$(this).addClass('disabled');
//2.发送秒杀请求
$.post(killUrl,{},function(result) {
if(result ){
var killResult=result['data'];
var state=killResult['state'];
var stateInfo=killResult['stateInfo'];
//3.显示秒杀结果
node.html(''+stateInfo+'');
}
});
});
node.show();
}else {
//未开启秒杀
var now=exposer['now'];
var start=exposer['start'];
var end=exposer['end'];
//重新计算计时逻辑
seckill.countdown(seckillId,now,start,end);
}
}else {
console.log('result=',result);
}
});
}
秒杀接口返回一个服务器生成的加密token,秒杀时带着这个token才能进行秒杀。
public Exposer exportSeckillUrl(long seckillId) {
//todo 在缓存超时的基础上维护一致性
//1.先去缓存中找
Seckill seckill = redisDao.getSeckill(seckillId);
if(seckill==null){
//2.缓存中没有则去DB里面找
seckill = seckillDao.queryById(seckillId);
if (seckill == null) {
return new Exposer(false,seckillId);
}else {
//3.从数据库取出来之后再放入缓存
redisDao.putSeckill(seckill);
}
}
Date startTime=seckill.getStartTime();
Date endTime=seckill.getEndTime();
Date nowTime=new Date();
if(nowTime.getTime()<startTime.getTime()
|| nowTime.getTime()>endTime.getTime()){
return new Exposer(false,seckillId,nowTime.getTime(),startTime.getTime(),endTime.getTime());
}
//不可逆的转化md5字符串
String md5=getMD5(seckillId);
return new Exposer(true,md5,seckillId);
}
客户端带着商品id和加密token进行秒杀,此时会出现几种情况:
RepeatKillException
,SeckillCloseException
,SeckillException
在update库存时开启事务,此时库存表会被加上行级锁。
在更新库存后,通过返回值来判断是否成功,此时java应用端就需要等待返回结果,再执行下面的操作:
分析这其中的网络延迟:如果是同城机房
异地机房带来的延迟会更大:
由于行级锁要在事务commit之后才会释放,那么优化的方向就放在了减少行级锁的持有时间,如果我们把整个事务管理放在MySQL数据库中,那么就会大大减少这种延迟,将提交放在MySQL服务端的方案:
Java端使用事务执行秒杀逻辑:
@Transactional
public SeckillExecution executeSeckill(long seckillId, long userphone, String md5) throws SeckillException, RepeatKillException, SeckillCloseException {
if(md5==null || !md5.equals(getMD5(seckillId))){
throw new SeckillException("seckill data rewrite!");
}
//执行秒杀逻辑>减库存+记录购买记录
Date nowTime=new Date();
try {
//记录购买行为
int insertCount = successKilledDao.insertSuccessKilled(seckillId, userphone);
//唯一:seckillId ,userphone
if (insertCount <= 0) {
throw new RepeatKillException("seckill repeat");
} else {
//减库存,todo 执行竞争条件,减库存,update获得行级锁
int updateCount = seckillDao.reduceNumber(seckillId, nowTime);
if (updateCount <= 0) {
//没有更新记录
throw new SeckillCloseException("seckill is closed");
} else {
//秒杀成功
SuccessKilled successKilled = successKilledDao.queryByIdWithSeckill(seckillId, userphone);
return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS, successKilled);
}
}
}catch (SeckillCloseException e1) {
throw e1;
}catch (RepeatKillException e2){
throw e2;
}catch (Exception e) {
logger.error(e.getMessage(), e);
//编译异常转换,spring发现后会回滚
throw new SeckillException("seckill inner error:" + e.getMessage());
}
}
此方案适合一般需求,可以看到一条update语句MySQL的QPS效果:
利用redis等来做一个原子计数器,将秒杀记录放到队列中进行削峰,控制进入的流量,最后让服务器从消息队列中进行消费落地。