这篇讲解的内容较多。先解决业务问题再解决性能问题。
业务问题
之前的代码中有两类业务问题:
- 超卖;
- 若一个用户用两个平台同时秒杀某一商品,可能会出现该用户秒杀两件商品的情况。
这两个问题虽然业务问题很大,但代码改动特别小。
- 超卖
这里利用数据库本身的锁来解决,只需要改动dao层代码,修改GoodsDao的代码。
//改动前的代码
@Update("update miaosha_goods set stock_count = stock_count - 1 where goods_id = #{goodsId}")
public int reduceStock(MiaoshaGoods g);
//改动后的代码
@Update("update miaosha_goods set stock_count = stock_count - 1 where goods_id = #{goodsId} and stock_count > 0")
public int reduceStock(MiaoshaGoods g); //利用数据库的锁来保证不会卖超(数据库本身会保证不会有两个线程同时改一条记录)
- 一个用户秒杀多件商品的情况
在数据库层面:把用户id和商品id组合设置成唯一索引,来防止一个用户多秒杀商品的情况;
在缓存中:把用户id和商品id组合成一个键,下单前查询该键是否存在,若不存在则下单,并在缓存里存入键值对(值是秒杀订单),这样比直接查询数据库要快些。
@Service
public class OrderService {
@Autowired
OrderDao orderDao;
@Autowired
RedisService redisService;
public MiaoshaOrder getMiaoshaOrderByUserIdGoodsId(long userId, long goodsId) {
// return orderDao.getMiaoshaOrderByUserIdGoodsId(userId, goodsId);
//在缓存中查
return redisService.get(OrderKey.getMiaoshaOrderByUidGid, "" + userId + "_" + goodsId, MiaoshaOrder.class);
}
@Transactional
public OrderInfo createOrder(MiaoshaUser user, GoodsVo goods) {
OrderInfo orderInfo = new OrderInfo();
orderInfo.setCreateDate(new Date());
orderInfo.setDeliveryAddrId(0L);
orderInfo.setGoodsCount(1);
orderInfo.setGoodsId(goods.getId());
orderInfo.setGoodsName(goods.getGoodsName());
orderInfo.setGoodsPrice(goods.getMiaoshaPrice());
orderInfo.setOrderChannel(1);
orderInfo.setStatus(0);
orderInfo.setUserId(user.getId());
orderDao.insert(orderInfo);
MiaoshaOrder miaoshaOrder = new MiaoshaOrder();
miaoshaOrder.setGoodsId(goods.getId());
miaoshaOrder.setOrderId(orderInfo.getId());
miaoshaOrder.setUserId(user.getId());
orderDao.insertMiaoshaOrder(miaoshaOrder);
//放入缓存中
redisService.set(OrderKey.getMiaoshaOrderByUidGid, "" + user.getId() + "_" + goods.getId(), miaoshaOrder);
return orderInfo;
}
}
可以看到,代码中就改动了两点,一是查询重复订单时是在缓存中查,第二点是,在数据库中创建秒杀订单和订单详情后,会在缓存中插入用户id和商品id结合的键(值是秒杀订单),为之后在缓存中查找做准备。
性能问题
秒杀系统的性能瓶颈一般是在对数据库的访问上,若能减少对数据的访问,就能在很大程度上提高并发量。
大致的思路是:
- Redis预减库存减少数据库的访问;
- 内存标记减少Redis访问;
- 请求先入队缓冲,异步下单,增强用户体验。
具体的代码流程是:
- 系统初始化,把商品库存数量加载到Redis中;
- 收到请求,先在内存标记上判断商品是否秒杀完毕,若没有,则进入下一步;
- Redis预减库存,库存不足,直接返回,否则进入下一步;
- Redis中判断是否重复下单,若没有进入下一步;
- 请求入队,前端页面立即返回排队中;
- 请求出队,数据库中判断库存、缓存中判断是否重复秒杀,若都成功进入下一步;
- 数据库中减少库存,数据库中生成订单,在缓存中生成用户和商品的键以便之后验证是否重复下单;
- 客户端轮询(前端中实现),是否秒杀成功。
Controller层
在这部分主要完成:
- 系统的初始化,把秒杀商品的库存数量加载到Redis中;
- 内存中判断商品是否秒杀完、Redis预减库存、判断是否重复秒杀、入队操作。
@Controller
@RequestMapping("/miaosha")
public class MiaoshaController implements InitializingBean {
@Autowired
MiaoshaUserService userService;
@Autowired
RedisService redisService;
@Autowired
GoodsService goodsService;
@Autowired
OrderService orderService;
@Autowired
MiaoshaService miaoshaService;
@Autowired
MQSender sender;
private HashMap<Long, Boolean> localOverMap = new HashMap<Long, Boolean>();
/**
* 系统初始化,初始化的时候把数据库中的库存加载进缓存中
*/
public void afterPropertiesSet() throws Exception {
List<GoodsVo> goodsList = goodsService.listGoodsVo();
if (goodsList == null) {
return;
}
for (GoodsVo goods : goodsList) {
redisService.set(GoodsKey.getMiaoshaGoodsStock, "" + goods.getId(), goods.getStockCount());
localOverMap.put(goods.getId(), false);
}
}
//第三版,消息队列的方式
@RequestMapping(value = "/do_miaosha", method = RequestMethod.POST)
@ResponseBody
public Result<Integer> miaosha(Model model, @RequestParam("goodsId") long goodsId, HttpServletResponse response,
@CookieValue(value = MiaoshaUserService.COOKI_NAME_TOKEN,required = false) String cookieToken,
@RequestParam(value = MiaoshaUserService.COOKI_NAME_TOKEN,required = false) String paramToken) {
if (StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
return Result.error(CodeMsg.SESSION_ERROR);//token不存在或失效
}
String token = StringUtils.isEmpty(paramToken) ? cookieToken : paramToken;
MiaoshaUser user = userService.getByToken(response, token);//从token中读用户信息
//内存标记,减少redis访问
boolean over = localOverMap.get(goodsId);
if (over) {//库存减去10后不必要访问redis,访问map即可
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//预减库存
long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, "" + goodsId);
if (stock < 0) {
localOverMap.put(goodsId, true);
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//判断是否已经秒杀到了
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
if (order != null) {
return Result.error(CodeMsg.REPEATE_MIAOSHA);
}
//入队
MiaoshaMessage mm = new MiaoshaMessage();
mm.setUser(user);
mm.setGoodsId(goodsId);
sender.sendMiaoshaMessage(mm);
return Result.success(0);//排队中
}
}
其中,MiaoshaMessage的定义为:
public class MiaoshaMessage {
private MiaoshaUser user;
private long goodsId;
//Getter and Setter...
}
RabbitMQ相关操作
- MQSender:
@Service
public class MQSender {
private static Logger log = LoggerFactory.getLogger(MQSender.class);
@Autowired
AmqpTemplate amqpTemplate; //操作queue的工具类
public void sendMiaoshaMessage(MiaoshaMessage mm) {
String msg = RedisService.beanToString(mm);
log.info("send message:" + msg);
amqpTemplate.convertAndSend(MQConfig.MIAOSHA_QUEUE, msg);
}
}
- MQReceiver
@Service
public class MQReceiver {
private static Logger log = LoggerFactory.getLogger(MQReceiver.class);
@Autowired
RedisService redisService;
@Autowired
GoodsService goodsService;
@Autowired
OrderService orderService;
@Autowired
MiaoshaService miaoshaService;
@RabbitListener(queues = MQConfig.MIAOSHA_QUEUE) //监听的指定的队列
public void receive(String message) {
log.info("receive message:" + message);
MiaoshaMessage mm = RedisService.stringToBean(message, MiaoshaMessage.class);
MiaoshaUser user = mm.getUser();
long goodsId = mm.getGoodsId();
//判断库存
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
int stock = goods.getStockCount();
if (stock <= 0) {
return;
}
//判断是否已经秒杀到了
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
if (order != null) {
return;
}
//减库存 下订单 写入秒杀订单
miaoshaService.miaosha(user, goods);
}
}
其中,miaoshaService.miaosha(user, goods)操作为:
@Service
public class MiaoshaService {
@Autowired
GoodsService goodsService;
@Autowired
OrderService orderService;
@Autowired
RedisService redisService;
@Transactional
public OrderInfo miaosha(MiaoshaUser user, GoodsVo goods) {
//减库存 下订单 写入秒杀订单
boolean success = goodsService.reduceStock(goods);
if (success){
//order_info maiosha_order
return orderService.createOrder(user, goods);
}else {
setGoodsOver(goods.getId());
return null;
}
}
}
目前,优化完成了。当用户请求到达Controller层,在到达数据库之前,内存map和redis会抵挡大部分的数据库访问操作,大概只有与秒杀商品个数差不多的请求会进入队列从而访问数据库。
压测
在之前写的秒杀功能(3)的压测中,初步版本的TPS = 808/sec;
本次的压测结果为:
TPS = 908/sec,略高一些,没有提高太多,这可能是和硬件条件有关,我的服务器性能不高。
数据库情况:
- 秒杀商品情况
- 秒杀订单情况
正好9个订单,没有出现卖超的现象了。
(这篇写完我是奔溃的,,明明在几个小时前我就能写完。。中间开了个组会我掉网了。。我还没保存。。于是写了两遍。。又拖累了今天的进度,offer君又远离我一步。。 )