title: 我的秒杀总结
date: 2016-11-24 19:50:35
categories:
- 技术
tags: - 实习
近期 接到一个十万数量的秒杀任务,后台完全由我写。因为之前对秒杀的处理所知甚少,故趁此机会学习了一下。当然,这里 秒杀 只涉及到 “抢” 的环节,没有下单、支付等,故对性能要求不是那么高。最终,“跌跌撞撞”地还是让系统上线了,目前运行良好。
项目环境
两台业务的服务器,还有一台缓存服务器和一台数据库服务器。
在整个项目 的 前端部分 自然离不开 负载均衡。故 这里 还是采用 Nginx 来实现。具体方式采用默认的配置,即轮询的方式。
关于“抢”的环节
intro:一天分为几个时段开抢,每个时段有几分钟的时间抢(该时段的奖品抢完了或者时间到了,该时段抢购就结束了),对于没有抢完的奖品,要求自动“滚到”下一轮。
为了满足如上要求,自然得从数据库开始设计。在系统运行前,将十万奖品 批量输入 至数据库。我给 所有奖品 设计了一个生效时间 和一个 是否被抢 的标志。
这里我采用的技巧是(解决方案):
每个奖品 都有一个生效时间,这个生效时间是每个时间段的开始时间,只有当前时间大于生 效时间该奖品才可以抢(具体可以用sql控制,这里时间采用ms,时间越往后时间越大)。
再 声明 一下抢奖品的策略:只有当前时间大于奖品的生效时间,该奖品才可以抢。这样设计解决了对于后面的几轮秒杀可以秒杀到之前没抢完的奖品(因为后面几轮的当前时间大于前面奖品的生效时间)的要求。同时 ,又可以避免 前一时段的秒杀环节 抢到 后面时段的奖品。原因自然是 当前时间比后面几轮的生效时间小。
性能方面:为了避免每一次都要去数据库查取符合条件的奖品(生效时间和是否被抢),++我自然需要将奖品一次性地多拿几个放到内存里++。这里 我使用 队列 来存放 符合条件的奖品,每来一个用户,抢走(弹出)队列头部的奖品。后面的用户来了 再弹出一个。从而避免 用户“同时”拿到同一个奖品,从而避免“超卖”现象。
那么队列我具体采用什么样的数据结构呢?如下
基于链接节点的无界线程安全队列(这个名字很霸气~~)
private static volatile Queue<Coupon> couponQueue;
static{
if (null == couponQueue) {
LOG.info("QueueServiceImpl init!!");
couponQueue = new ConcurrentLinkedQueue<Coupon>();
}
}
具体弹出采用什么代码呢?
@Override
public Coupon getPoll(String uid) {
Coupon coupon = null;
synchronized(this){
coupon = couponQueue.poll();
if (coupon == null) {
setQueue();//从数据库里取奖品放入队列的方法
coupon = couponQueue.poll();
}
if (coupon!=null){
int row = couponDao.allocateCoupon(coupon.getId(),uid,CouponConst.Status.USED,System.currentTimeMillis());
if (row!=1){
coupon = null;
}
}
}
//打日志
if (coupon!=null) {
LOG.info(
"getPoll uid:{},couponId:{}",
new Object[]{uid, coupon.getId()});
}
return coupon;
}
这里有个synchronized,是为了线程安全,避免多个人访问同一段代码产生冲突。
那么为什么把“弹出”(如果内存队列里没有了,还会去数据库里一次性取100奖品来放入内存)和update奖品是否被抢状态的代码同时锁住呢?
试想这样一种场景:内存里原本放100个奖品的队列,经过一段时间后还剩下两个奖品。此时,甲、乙两个用户过来弹出了最后的两个奖品,还未来的及修改奖品状态。此时此刻,丙冲了过来,结果发现队列里没有了,coupon==null,就去setQueue()从数据库里拿符合条件的100个奖品放入内存队列。然而!,甲乙还没来得及将其抢到的奖品修改状态,就是说这两个奖品还没有被标记已经抢过。所以 这两个奖品又会被丙放入内存队列,接着弹出!由后来者再去抢。从而造成一种现象,最后的奖品被多个用户抢到,从而形成冲突。解决方案:加锁!
是不是这样就可以了呢??
回答是No!因为我在学校 一般都写的是 一个业务服务器,这里是两个!亲,两个和一个有着巨大差别,这是放在内存里,不是缓存服务器里。所以A服务器取了100个符合条件的奖品,B服务器也取了100个符合条件的奖品,然而!,A和B可能取得都是前100个奖品,就是说,这俩服务器取得是相同的奖品,然后进行发放!怎么办呢??
解决方案:我首先想到的是将奖品队列放入缓存服务器(1台)里?这样就避免冲突。可是 缓存 用的是 xmemcached,不是redis!没法支持那么多数据结构,而且xmemcached的CAS用起来并不舒服,,,
那么,该怎么处理呢~上代码:
int flag = -1 ;//因为 线上有两台服务器, 所以 为避免两台服务器 取 相同的 coupon 放入自己的内存;故采用 id%2==flag 分开。
int ip = LocalIPGetter.getLocalServerIpTail(); //获取当前服务器ip
if (ip == 223) { //如果是A服务器 取id为奇数的奖品放入内存队列
flag = 1;
}else { //如果是B服务器 取id为偶数的奖品放入内存队列
flag = 0;
}
List<Coupon> couponList = couponDao.selectByStatusValidate(CouponConst.Status.INIT,current,100,flag); //两台服务器根据奇偶数进行分类去数据库取奖品。
上述代码就是我的解决思路,是不是太简单了点,把数据库表分成两块,两个服务器互不干扰。
还有缓存服务器,我将获奖者名单放在里面,减少对数据库的访问。
关于“抢”的环节 多次点击问题
我遇到这样一个问题(一位用户只能秒杀成功一次):
试想这样一个场景,对于某用户甲,点击了“秒杀”按钮。但是由于网速、性能等原因,用户甲比较着急,连续点了好几次。
当第一次请求发来时:数据库里没有该用户的记录,资格认证,发现没有秒杀记录,将要添加用户,想进入秒杀流程。注意:这里是将要,还没有添加用户。此刻!!!
该用户的第二次点击请求发了过来,资格认证,发现没有秒杀记录,将要添加用户。然而!!!第一次请求添加用户成功,第二次请求又来添加,数据库主键冲突!500!
解决方案:
每次点击,收到请求检查缓存没有该账户信息,第一件事放入 缓存(key值为其账户主键)中,同时设置2s后失效。
如果 缓存中 有该账户信息,说明 2s 内该用户 访问过,返回411,访问过于频繁。
OK
疏忽!
在活动开始的前一天,QA正在火急火燎地测试。自然,线上测试用的是测试数据,奖品的中奖码都是模拟的。注意,我说的是线上测试。
晚上,22:00测试完毕。我把数据库里的数据库变成真实数据,就屁颠屁颠地回家了。
我想说的是,然而!!!两台业务服务器里的两个奖品队列还是测试的模拟数据,然后我就,,,这个坑,,,
切记
切记,清空缓存,清空内存
重要事情说三遍,清空缓存,清空内存。
情空缓存,清空内存
后续的想法
我能不能将抽取、改变状态的 流程 写在数据库里,作为存储过程来调用会不会更快??
然后我就开始写存储过程,,,,
然而!!!后来DBA告诉我:
存储过程 与 触发器 都不允许。
计算尽量不要放在数据库来,
数据库主要完成数据存取的功能。
如果我这么做,有可能会导致数据库死锁,,,,
待续
我想我如果再写秒杀系统的话 自然会做的更好,以上都是我自己的摸索。后续我会尝试 消息队列(Notify?MetaQ?),用redis?什么有损服务?什么熔断机制?
继续探索吧~