秒杀P6-下单+秒杀

189 阅读8分钟

30min左右可以回看

慢查询

登录服务器,进入mysql,开启慢查询日志,设置慢查询时间

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement. mysql> show variables like 'slow_query%'; mysql> show variables like 'long_query%'; mysql> set global slow_query_log='ON'; mysql> set global long_query_time=1;

退出重进mysql才生效。

mysql> quit;

再次登录mysql,查询一个大于生效时间

mysql> select sleep(3)

执行完sql退出mysql,调用慢查询日志,可看到超时的sql语句

image.png

image.png

查询功能优化的数据库方面:慢查询分析

先启用慢查询日志,发现某SQL大于xx秒,觉得比较慢,explain去看语法结构,发现没走索引。

可能没加索引;加了索引但是没走,可能条件跟最左前缀原则不匹配。或者子查询的分页分组写的不好。

超卖问题

下单时锁库存

  1. 下单时锁库存
    • 业务层面解决超卖问题。(不会超卖,可能会少卖)
    • 超时自动释放订单,回补库存
    • 主流方法

image.png

  1. 付款时锁库存
    • 会超卖
    • 体验不好
    • 库存很多时,选这种

image.png

一般都是下单时锁库存,方法一

表分析

order_info订单表:字段

  • 订单id
  • 用户id
  • 商品id
  • 商品参加活动id
  • 冗余记录订单单价
  • 商品数量
  • 订单总金额

订单id:日期+流水号

  • 便于业务的表拆分,把xx范围的日期订单,拆分出去到历史表。
  • 流水号恒自增,持久性存储。放数据库,不能放内存中。

serial_number表

  • 流水号表,记录订单索引的最大序号
  • 字段:name,什么什么的索引序号。可以扩展,记录别的表的最大数量
  • value,索引序号最大值
  • step:步长,每次增加x个订单。提高业务灵活性

代码分析

数据访问层

mybatis插件,自动生成的OrderMapper、ItemMapper、ItemStockMapper、SerialNumberMapper的dao增删改查接口,以及mapper包下的.xml的sql方法。

ItemMapper加一个,增加销量方法increaseSales(Integer id, Integer amount),ItemStockMapper加一个减少库存方法decreaseStock(Integer itemId, Integer amount) id匹配,库存要大于减少的数量

SerialNumberMapper.xml的sql中,根据主键查询最大索引(订单)序号的方法。

sql结尾加 for update,排他锁。

因为查询目的,要立马修改数据,所以加锁,防止其他线程访问

image.png

业务层service【主要看serviceimpl实现类】

  • 实现类中减库存逻辑,不用判断库存够不够,直接扣,根据返回值的T/F反应减扣成功还是库存不够
  • 库存不够,mapper.xml文件的sql查不到数据,返回的rows=0,返回F
@Transactional
public boolean decreaseStock(int itemId, int amount) {
   int rows = itemStockMapper.decreaseStock(itemId, amount);
   return rows > 0;
}

创建订单:

  • 传入活动参数,不为空但是小于零,活动有问题。防止传入奇怪参数被攻击。(只为空表示不参与活动)
  • 校验用户是否存在、同理商品
  • 校验库存,判断一下库存是否大于购买数量
  • 校验活动,活动id不为空继续,在上面校验商品item时,会查活动对象。
  • 校验item活动对象,没有-无活动。
  • 商品的活动id跟传入的活动id不匹配,抛异常
  • 活动状态码不为0,抛异常

=====================================================

  • 参数都没问题后,开始扣减库存。下单时锁库存,所以先扣库存,扣完后锁住。

  • 调用缄口库存方法,不成功报错,成功继续

  • 生成订单

  • 更新销量

@Transactional
public Order createOrder(int userId, int itemId, int amount, Integer promotionId) {
    // 校验参数
    if (amount < 1 || (promotionId != null && promotionId.intValue() <= 0)) {
        throw new BusinessException(PARAMETER_ERROR, "指定的参数不合法!");
    }

    // 校验用户
    User user = userService.findUserById(userId);
    if (user == null) {
        throw new BusinessException(PARAMETER_ERROR, "指定的用户不存在!");
    }

    // 校验商品
    Item item = itemService.findItemById(itemId);
    if (item == null) {
        throw new BusinessException(PARAMETER_ERROR, "指定的商品不存在!");
    }

    // 校验库存
    int stock = item.getItemStock().getStock();
    if (amount > stock) {
        throw new BusinessException(STOCK_NOT_ENOUGH, "库存不足!");
    }

    // 校验活动
    if (promotionId != null) {
        if (item.getPromotion() == null) {
            throw new BusinessException(PARAMETER_ERROR, "指定的商品无活动!");
        } else if (!item.getPromotion().getId().equals(promotionId)) {
            throw new BusinessException(PARAMETER_ERROR, "指定的活动不存在!");
        } else if (item.getPromotion().getStatus() == 1) {
            throw new BusinessException(PARAMETER_ERROR, "指定的活动未开始!");
        }
    }

    // 扣减库存
    boolean successful = itemService.decreaseStock(itemId, amount);
    if (!successful) {
        throw new BusinessException(STOCK_NOT_ENOUGH, "库存不足!");
    }

    // 生成订单
    Order order = new Order();
    order.setId(this.generateOrderID());
    order.setUserId(userId);
    order.setItemId(itemId);
    order.setPromotionId(promotionId);
    order.setOrderPrice(promotionId != null ? item.getPromotion().getPromotionPrice() : item.getPrice());
    order.setOrderAmount(amount);
    order.setOrderTotal(order.getOrderPrice().multiply(new BigDecimal(amount)));
    order.setOrderTime(new Timestamp(System.currentTimeMillis()));
    orderMapper.insert(order);

    // 更新销量
    itemService.increaseSales(itemId, amount);

    return order;
}

还有生成订单流水号

  • 更新流水号是获取目前库存最大值,在加上步长,之后的就是订单流水号

  • 前面拼12个0,要除去占的位数后

@Transactional(propagation = Propagation.REQUIRES_NEW)
private String generateOrderID() {
   StringBuilder sb = new StringBuilder();

   // 拼入日期
   sb.append(Toolbox.format(new Date(), "yyyyMMdd"));

   // 获取流水号
   SerialNumber serial = serialNumberMapper.selectByPrimaryKey("order_serial");
   Integer value = serial.getValue();

   // 更新流水号
   serial.setValue(value + serial.getStep());
   serialNumberMapper.updateByPrimaryKey(serial);

   // 拼入流水号
   String prefix = "000000000000".substring(value.toString().length());
   sb.append(prefix).append(value);

   return sb.toString();
}

下单的controller

  • 从session取得当前用户,创建订单,传入订单信息。

  • 正常就返回正常的编码,出错由拦截器拦截异常

@Controller
@RequestMapping("/order")
@CrossOrigin(origins = "${nowcoder.web.path}", allowedHeaders = "*", allowCredentials = "true")
public class OrderController implements ErrorCode {

    @Autowired
    private OrderService orderService;

    @RequestMapping(path = "/create", method = RequestMethod.POST)
    @ResponseBody
    public ResponseModel create(
            HttpSession session, int itemId, int amount, Integer promotionId) {
        User user = (User) session.getAttribute("loginUser");
        orderService.createOrder(user.getId(), itemId, amount, promotionId);
        return new ResponseModel();
    }

}

小结

  • 下单时锁库库存,业务层面解决超卖问题

  • 后期订单很多,要把历史订单数据拆分出去,所以订单id采用日期+流水号

  • 流水号表记录了最大订单数,每次查此表,会在select的sql语句后加for update排他锁,锁住。避免别的事务访问

事务(基于MySQL的InnoDB引擎)

原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Duration)

ACID

  • 原子性(Atomicity):事务是最小的执行单位。确保了动作要么全部完成,要么全不起作用
  • 一致性(Consistency):执行事务前后,数据保持一致。
  • 隔离性(Isolation):并发访问数据库时,一个用户的事务不受其他事务影响。各并发事务之间数据库是独立的。
  • 持久性(Duration):事务提交后,对数据库的更改是持久的。

InnoDB主键索引就是聚簇索引,用主键索引访问数据不用回表,因为数据都存在其主键(聚簇)索引叶子节点上

隔离性

粒度

  • 表锁:针对非索引字段加的锁
  • 行锁:针对索引字段加的锁
  • InnoDB中底层都是页锁(区锁)

锁类型

  • 共享锁 S :行锁,允许读一行数据
  • 排他锁 X :行锁,允许改一行数据
  • 意向共享锁 IS :表锁,想要/准备读表中的数据
  • 意向排他锁 IX :表锁,想要/准备改表中的数据

注:

  • X最重:与其他所有锁互斥
  • IX第二重:与行锁S、X互斥

以前加X锁丧失并发性,现在不会了因为MVCC

image.png

底层机制(提升并发性)

无锁:

  • 底层能不加锁就不加锁

  • 通过MVCC机制实现

  • MVCC:多版本并发控制

    • 对于正在更新的数据,InnoDB会去读取该行的一个快照数据(undo log)。
    • 每次提交事务,日志(undo log)会记录历史版本数据。
    • 事务一更新数据加了排他X锁,事务二可以读数据,但是读的历史版本数据。
    • 能不能读undo log要看隔离级别。

加锁:

  • 查询时可以显式加锁

  • X锁:SELECT ... FOR UPDATE

  • S锁:SELECT ... LOCK IN SHARE MODE

算法

InnoDB的三种行锁/三种算法实现锁机制:

  • 记录锁/Record Lock:锁行
  • 间隙锁/Gap Lock:锁定一个范围,但不包含记录本身
  • 临键锁/Next-Key Lock:锁定一个范围,并且锁定记录本身。上面两者综合

注意:

  • 记录锁只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁。
  • 临键锁解决幻读问题

image.png

读取/更新

  • 脏读:事务a读取到事务b更新但未提交的数据(不能接受)
  • 幻读:事务期间,对某数据前后查询行数(记录数量)不一致(范围变化,还可以接受)
  • 不可重复读:事务对同一数据前后读取结果不一致(勉强接受)

image.png

例子

image.png

  • 第一类丢失更新:事务a回滚,导致事务b已更新(修改)数据丢失
  • 第二类丢失更新:事务a提交,导致事务b已更新数据丢失
  • 原因:多个事务同时处理数据,不行!

image.png

image.png

例子

image.png

死锁

场景:互相占用对方后续要用的资源,都在等待对方解锁释放。

解决:

  • 超时回滚:innodb lock wait timeout,等待时间超过设置的阈值,让事务a回滚,另事务b就能继续。一般谁锁的数据范围大回滚谁。有弊端,事务周期长,有误杀嫌疑。
  • 死锁检测:wait-for graph,采用等待图的方式来进行死锁检测

锁升级

InnoDB存储引|擎不存在锁升级的问题。

因为底层采用位图方式,是锁页面,不是每行加锁,所以锁单行还是多行开销一样

隔离级别

——————基于锁 + MVCC机制共同实现

  • READ UNCOMMITTED(读取未提交) : 最低的隔离级别,未解决脏读、幻读、不可重复读

  • READ COMMITTED(读取已提交) : 解决了脏读

    • InnoDB引擎中——“严谨”
    • 采用记录锁/Record Lock算法,更新时加X锁锁行,其他事务不能读,解决了脏读问题
    • 采用MVCC,总是读取被锁定行最新的一份快照数据
  • REPEATABLE READ(可重复读) : 解决了脏读和不可重复读,很大程度降低幻读

    • 是默认的隔离级别
    • 采用临键锁/Next-Key Lock算法,更新时锁行+范围,解决了脏读、不可重复读,大部分幻读
    • 采用MVCC,总是读取事务开始时的行数据版本
    • Next-Key Lock锁,更新时锁行+范围,其他事务不能改,可以读,但读的是历史版本数据解决不可重复读,同理,只能读历史版本数据,很大程度解决幻读
    • 当前读/改时读
    • 快照读/普通读
  • SERIALIZABLE(可串行化) : 最高的隔离级别,解决了脏读、幻读、不可重复读。

    • 读的时候就加Record Lock锁,S锁,(SELECT ... LOCK IN SHARE MODE)

隔离级别基于锁 + MVCC机制共同实现

一致性

undo log

image.png

持久性

redo log

image.png

重点:项目——生成订单。原理——事务(隔离级别、mvcc等)