序
最近出于公司业务需要,做了拼团抢购,秒杀的业务。
秒杀系统场景特点
- 秒杀时大量用户会在同一时间同时进行抢购,网站瞬时访问流量激增。
- 秒杀一般是访问请求数量远远大于库存数量,只有少部分用户能够秒杀成功。
- 秒杀业务流程比较简单,一般就是下订单减库存。
- 秒杀的业务场景跟其他业务场景不一样,主要是秒杀的瞬间,并发非常大,如何针对此大并发是我们需要取解决的。秒杀业务,是典型的短时大量突发访问在瞬间涌入,造成服务器瘫痪,宕机,用户体验差,想必大家都经历过早期春运抢火车票的痛。
秒杀为什么会成为技术难点,系统瓶颈
秒杀与其他业务最大的区别在于,在秒杀的瞬间,系统的并发量和吞吐量会非常大,与此同时,网络的流量也会瞬间变大。会导致访问变慢、商品超卖等问题。
访问慢不需要解释,我们来看看商品超卖现象是怎么产生的
-
这个图,其实很清晰了,假设订单系统部署两台机器上,不同的用户都要同时买10台iphone,分别发了一个请求给订单系统。
-
接着每个订单系统实例都去数据库里查了一下,当前iphone库存是12台。 俩大兄弟一看,乐了,12台库存大于了要买的10台数量啊!
-
于是乎,每个订单系统实例都发送SQL到数据库里下单,然后扣减了10个库存,其中一个将库存从12台扣减为2台,另外一个将库存从2台扣减为-8台。
-
现在完了,库存出现了负数!问题来了,没有20台iphone发给两个用户啊!
怎么做
对于系统并发量变大问题
- 接口限流防刷,限制用户在单位时间内访问接口的频率
- 这里的核心在于如何在大并发的情况下保证数据库能扛得住压力,因为大并发的瓶颈在于数据库。如果用户的请求直接从前端传到数据库,显然,数据库是无法承受几十万上百万甚至上千万的并发量的。因此,我们能做的只能是减少对数据库的访问。例如,前端发出了100万个请求,通过我们的处理,最终只有10个会访问数据库,这样就会大大提升系统性能。再针对秒杀这种场景,因为秒杀商品的数量是有限的,因此这种做法刚好适用。
怎么解决库存超卖问题?
- 如果商品数量比较多,比如1万件商品参与秒杀,那么就有1万*10=10万个请求并发去访问数据库,数据库的压力还是会很大,直接读数据库的库存,可能造成超卖。这里就用到了另外一个非常重要的组件:消息队列。我们不是把请求直接去访问数据库,而是先把请求写到消息队列中,做一个缓存,然后再去慢慢的更新数据库。这样做之后,前端用户的请求可能不会立即得到响应是成功还是失败,很可能得到的是一个排队中的返回值,这个时候,需要客户端去服务端轮询,因为我们不能保证一定就秒杀成功了。
- 这样处理以后,我们的应用是可以很简单的进行分布式横向扩展的,以应对更大的并发。
笔者关于秒杀架构的设计理念
- 限流: 鉴于只有少部分用户能够秒杀成功,所以要限制大部分流量,只允许少部分流量进入服务后端。(限制用户在单位时间内访问接口的频率)。
- 削峰:对于秒杀系统瞬时会有大量用户涌入,所以在抢购一开始会有很高的瞬间峰值。高峰值流量是压垮系统很重要的原因,所以如何把瞬间的高流量变成一段时间平稳的流量也是设计秒杀系统很重要的思路。实现削峰的常用的方法有利用缓存和消息中间件等技术。(利用消息队列进行异步处理,流量削峰)。
- 异步处理:秒杀系统是一个高并发系统,采用异步处理模式可以极大地提高系统并发量,其实异步处理就是削峰的一种实现方式。当服务端出队,生成订单以后,把用户ID和商品ID写到缓存中,来应对客户端的轮询就可以了。
- 内存缓存:秒杀系统最大的瓶颈一般都是数据库读写,由于数据库读写属于磁盘IO,性能很低,如果能够把部分数据或业务逻辑转移到内存缓存,效率会有极大地提升。(利用redis减少对数据库的访问)。
为什么要这么做:
假如,我们有10W用户同时抢10台手机,服务层并发请求压力至少为10W。
采用消息队列缓存请求:既然服务层知道库存只有10台手机,那完全没有必要把10W个请求都传递到数据库,那么可以先把这些请求都写到消息队列缓存一下,数据库层订阅消息减库存,减库存成功的请求返回秒杀成功,失败的返回秒杀结束。
利用缓存应对读请求:对类似于12306等购票业务和商品秒杀场景,是典型的读多写少业务,大部分请求是查询请求,所以可以利用缓存分担数据库压力。
利用缓存应对写请求:缓存也是可以应对写请求的,比如我们就可以把数据库中的库存数据转移到Redis缓存中,所有减库存操作都在Redis中进行,然后再通过后台进程把Redis中的用户秒杀请求同步到数据库中。
数据库层
数据库层是最脆弱的一层,一般在应用设计时在上游就需要把请求拦截掉,数据库层只承担“能力范围内”的访问请求。所以,上面通过在服务层引入队列和缓存,让最底层的数据库高枕无忧。
核心代码
/**
* 系统初始化时把商品库存加入到缓存中
*/
@Override
public void afterPropertiesSet() throws Exception {
//查询库存数量
List<Map<String, Object>> stockList = miaoshaService.queryAllGoodStock();
System.out.println("系统初始化:"+stockList);
if(stockList.size() <= 0){
return;
}
for(Map<String, Object> m : stockList) {
//将库存加载到redis中
redisUtil.getRedisTemplate().opsForValue().set(m.get("goodsId")+"", m.get("goods_stock").toString());
//添加内存标记
localOverMap.put(m.get("goodsId").toString(), false);
}
}
/**
* 请求秒杀,redis+rabbitmq方式
*/
@SuppressWarnings("unchecked")
@RequestMapping(value="/go")
@ResponseBody
public ResultVo miaosharabbitmq(HttpServletRequest request){
ConcurrentHashMap<String, String> parameterMap = ParameterUtil.getParameterMap(request);
if(ToolUtil.isEmpty( parameterMap.get("userId")) || ToolUtil.isEmpty( parameterMap.get("goodsId"))
|| ToolUtil.isEmpty( parameterMap.get("num"))){
return ResultVo.error("参数不全");
}
String userid = parameterMap.get("userId").toString();
String goodsId = parameterMap.get("goodsId").toString();
long num = Long.parseLong(parameterMap.get("num").toString());
//TODO
// 1.根据需求 校验是否频繁请求
// 2. 校验是否重复下单
// Map<String,Object> map = redisService.get("order"+userid+"_"+goodsId,Map.class);
// if(map != null) {
// return "重复下单";
// }
boolean over = localOverMap.get(goodsId);
if(over) {
return ResultVo.error("秒杀结束");
}
long stock = redisUtil.decr(goodsId,num);
if(stock < 0) {
localOverMap.put(goodsId, true);
return ResultVo.error("库存不足");
}
System.out.println("剩余库存:" + stock);
//加入到队列中,返回0:排队中,客户端轮询或延迟几秒后查看结果
Map<String,Object> msg = new HashMap<>();
msg.put("user_id", userid);
msg.put("goods_id", goodsId);
msg.put("num", num);
mQSender.send(msg);
return ResultVo.success("排队中!");
}
//查询秒杀结果(orderId:成功,-1:秒杀失败,0: 排队中)
@RequestMapping(value="/result", method=RequestMethod.GET)
@ResponseBody
public ResultVo miaoshaResult(HttpServletRequest request) {
ConcurrentHashMap<String, String> parameterMap = ParameterUtil.getParameterMap(request);
String userid = parameterMap.get("userId").toString();
String goodsId = parameterMap.get("goodsId").toString();
String result = miaoshaService.getMiaoshaResult(userid, goodsId);
if(!result.equals("0") && !result.equals("-1")){
return ResultVo.success("秒杀成功! "+result);
}else{
return ResultVo.error("秒杀失败!");
}
}
消息队列生产消息:
//生产者
@Service
public class MQSender {
@Autowired
AmqpTemplate amqpTemplate;
//Direct模式
public void send(Map<String,Object> msg) {
//第一个参数队列的名字,第二个参数发出的信息
amqpTemplate.convertAndSend(MQConfig.QUEUE, msg);
}
}
消息队列消费消息:
//消费者
@Service
public class MQReceiver {
@Autowired
private MiaoshaService miaoshaService;
private static Logger log = LoggerFactory.getLogger(MQReceiver.class);
@RabbitListener(queues= MQConfig.QUEUE)//指明监听的是哪一个queue
public void receive(Map<String,Object> msg) {
//log.info("监听到队列消息,用户id为:{},商品id为:{},购买数量:{}", msg.get("user_id"),msg.get("goods_id"),msg.get("num"));
int stock = 0;
//查数据库中商品库存
Map<String, Object> m = miaoshaService.queryGoodStockById(msg);
if(m != null && m.get("goods_stock") != null){
stock = Integer.parseInt(m.get("goods_stock").toString());
}
if(stock <= 0){//库存不足
log.info("用户:{}秒杀时商品的库存量没有剩余,秒杀结束", msg.get("user_id"));
return;
}
//这里业务是同一用户同一商品只能购买一次,所以判断该商品用户是否下过单
// List<Map<String, Object>> list = miaoshaService.queryOrderByUserIdAndCoodsId(msg);
// if(list != null && list.size() > 0){//重复下单
// return;
// }
//减库存,下订单
log.info("用户:{}秒杀该商品:{}库存有余:{},可以进行下订单操作", msg.get("user_id"),msg.get("goods_id"),stock);
miaoshaService.miaosha(msg);
}
执行数据库减库存操作
@Service
public class MiaoshaService {
@Autowired
private MiaoshaDao miaoshaDao;
@Autowired
private RedisService redisService;
@Autowired
private RedisUtil redisUtil;
//查询全部商品库存数量
public List<Map<String, Object>> queryAllGoodStock(){
return miaoshaDao.queryAllGoodStock();
};
//通过商品ID查询库存数量
public Map<String, Object> queryGoodStockById(Map<String, Object> m){
return miaoshaDao.queryGoodStockById(m);
};
//根据用户ID和商品ID查询是否下过单
public List<Map<String, Object>> queryOrderByUserIdAndCoodsId(Map<String, Object> m){
return miaoshaDao.queryOrderByUserIdAndCoodsId(m);
};
//减少库存,下订单,是一个事务
@Transactional
public void miaosha(Map<String, Object> m){
//减少库存
int count = miaoshaDao.updateGoodStock(m);
if(count > 0){
try {
//减少库存成功后下订单,由于一件商品同一用户只能购买一次,所以需要建立用户ID和商品ID的联合索引
m.put("id", UUID.randomUUID().toString().replaceAll("-", ""));
miaoshaDao.insertOrder(m);
//将生成的订单放入缓存
redisService.set("order"+m.get("user_id")+"_"+m.get("goods_id"), m);
} catch (Exception e) {
//出现异常手动回滚
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
redisService.incr("goods"+m.get("goods_id"));
}
}else {
//减少库存失败做一个标记,代表商品已经卖完了
redisService.set("goodsover"+m.get("goods_id"), true);
}
}
//获取秒杀结果
@SuppressWarnings("unchecked")
public String getMiaoshaResult(String userId, String goodsId) {
Map<String, Object> orderMap = redisService.get("order"+userId+"_"+goodsId, Map.class);
if(orderMap != null) {//秒杀成功
return orderMap.get("id").toString();
}else {
boolean isOver = getGoodsOver(goodsId);
if(isOver) {
return "-1";
}else {
return "0";
}
}
}
//查询是否卖完了
private boolean getGoodsOver(String goodsId) {
return redisService.exists("goodsover"+goodsId);
}
}
效果如下
1.用jemeter模拟创建1W个并发线程数
2.执行请求,查看后台输出日志,这里可以看到程序执行时,先从内存中扣减库存,再排队消费
3.消息队列消费情况
4.查看后台日志:最后一次日志输出时库存尚有一个。
5.当库存扣减完后,别的线程的响应结果:
6.查看数据库的库存和订单是否有超卖现象:
- 每个用户买1个商品,共生成了1000条订单记录;
- 商品表中,商品id为2的库存全部卖光,未出现负数,大功告成!
6.查看请求的平均耗时,平均耗时1.3s,在本机上的运行情况可以接受。
完整代码地址:https://gitee.com/Chrishecd/chrisProject.git
后续思考
- 秒杀是有很多种技术解决方案的,比如悲观锁,分布式锁,乐观锁,队列串行化,Redis原子操作,等等。在高并发场景下如何优化并发的性能也应该是我们一直思考的问题。
写文章不易,点个赞再走吧