最近出于公司业务需要,做了拼团抢购,秒杀的业务。
秒杀与其他业务最大的区别在于,在秒杀的瞬间,系统的并发量和吞吐量会非常大,与此同时,网络的流量也会瞬间变大。会导致访问变慢、商品超卖等问题。
这个图,其实很清晰了,假设订单系统部署两台机器上,不同的用户都要同时买10台iphone,分别发了一个请求给订单系统。
接着每个订单系统实例都去数据库里查了一下,当前iphone库存是12台。 俩大兄弟一看,乐了,12台库存大于了要买的10台数量啊!
于是乎,每个订单系统实例都发送SQL到数据库里下单,然后扣减了10个库存,其中一个将库存从12台扣减为2台,另外一个将库存从2台扣减为-8台。
现在完了,库存出现了负数!问题来了,没有20台iphone发给两个用户啊!
对于系统并发量变大问题
怎么解决库存超卖问题?
笔者关于秒杀架构的设计理念
为什么要这么做:
假如,我们有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 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
// 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.查看后台日志:最后一次日志输出时库存尚有一个。
6.查看数据库的库存和订单是否有超卖现象:
6.查看请求的平均耗时,平均耗时1.3s,在本机上的运行情况可以接受。
完整代码地址:https://gitee.com/Chrishecd/chrisProject.git