一个并发比较大的下单接口;
包括
- step1 扣减商品库存
- step2 生成订单数据
- step3 记录操作记录
伪代码如下,底层使用的是MySQL数据库,单体服务
(你问我为什么单体,案例需要啦)。
提问
:暂时不考虑分布式锁、缓存、异步等使用场景,下面的代码执行步骤有没有值得优化的点?
@Transactional(rollbackFor = Exception.class)
public void createOrder(Entity param){
...校验等
//step1 扣减商品库存
//update products set stock = stock where sku_id = XXX
deductStock(param);
//step2 生成订单数据
//insert into order XXXXXX
createOrder(param);
//step3 记录操作记录
//insert into operation_records XXX
recordOperation(param);
...
}
上述代码做了哪些事,开启了事务
更新了一个个表
:库存表库存分
插入了两条数据
:用户订单表,用户操作记录
对于当前的业务,上述代码的DB执行流程是没有问题的,
但是朋友,我们是要有梦想滴,CRUD BOY不是我们的终点,我们的目标是星辰大海。
来,键盘给你,优化下。
正常业务情况下,上述代码没毛病,开启事务,库存更新语句会加上行锁
,插入语句可能会加上插入意向锁
等。
我们重点来关注下 更新语句的行锁
,并发高的情况下,同一种库存商品数据是根据sku_id来更新,存在激烈的行锁竞争抢占
的问题。
来回忆一下,MySQL事务开启后,是什么时候开始给库存加行锁的?
事务开启就加行锁? 错。
是在执行更新语句的时候加锁的,上述的step1
。
那么解锁是什么时候呢?
当然是事务提交
的时候。
那么对于案例中的语句来说,products
表的sku_id = XXX这行数据的行锁,将从step1开始锁定,直到createOrder()
方法执行完毕才会解锁。
对于其他的创建订单请求,热点商品,相同的sku_id,同时执行这行代码的时候,只能等待。
这样分析可以发现,持有锁的时间太长了。
没错,是时间太长了,换个场景,或许我会欣喜若狂,彷佛得到了认可,
但此时此刻,这并不是我想要的。
那么,是否可以将更新库存的语句放到最后呢,step1 变成step3
,这样对一一次创建订单的请求,加锁的时长会大大减少,并发度会提升。
@Transactional(rollbackFor = Exception.class)
public void createOrder(Entity param){
...校验等
//step1 生成订单数据
//insert into order XXXXXX
createOrder(param);
//step2 记录操作记录
//insert into operation_records XXX
recordOperation(param);
//step3 扣减商品库存
//update products set stock = stock where sku_id = XXX
deductStock(param);
...
}
这样的优化虽不起眼,但是架不住积少成多,从细节处着手,才能仰望星空。
这样才是泰裤辣!
继续讨论下,上述代码没有问题了,但是
职业生涯中,我并没有真正的面对过MySQL的死锁,很幸运也很不幸,无需面对老大难的风险,但也没有获得实操的经验。
死锁的产生及竞态条件不再赘述。
最接近死锁的一次,还是刚工作的时候在一家保险公司,我还是nobody,当然了现在也还是nobody。
大约记得夜里的批处理更新账户信息,以及客户投保时,都需要保险账户总表
及账户明细表
更新,批处理和客户投保的请求互相竞争对方持有的锁,导致死锁,疯狂报警,处理方案则是DBA大半夜爬起来 kill掉其中一个session,解除死锁。
很明显上述处理的方案是不可持续的。
完全避免死锁不太现实,我们需要做的是尽可能减少死锁发生的概率。
MySQL给我们提供了两种方案
innodb_lock_wait_timeout
来设置innodb_deadlock_detect
设置为on
(8.0之后版本提供)第一种策略,我们固然可以设置很短的超时时间,但是对于慢SQL来说很容易误伤。
所以一般会有第二种方案,超时检测。
MySQL会自动查询是否存在连接相互等待获取对方连接的情况,如果存在,会回滚
其中一个事务来打破死锁。
但是高并发情况下,比如上述的案例接口,死锁检测会影响到MySQL执行速度和效率,我们就只能通过第一这种方案来等待超时释放。
正如我们上面讨论的,案例中的某个热门产品的库存对于MySQL来说就是热点行,会存在频繁的锁竞争
及死锁
等问题,会导致性能下降,MySQL本身也没有更好的方案来避免。
我们考虑下从业务层来解决这个问题。
使用redis缓存热点商品,借助redis的高性能、高并发特性,承担大部分的请求压力。
但是redis会增加复杂度,可能会带来数据不一致的情况,例如超卖和少卖。
往往库存只有一条数据,作为共享资源,为了保证安全,MySQL会对这条数据加锁,请求只能排一条长队等待锁的释放,如果我们把这条数据拆分成多条,10条数据,20条数据,性能的提升是可以预见的,大量请求的锁竞争可被分散在多条数据上,请求的耗时根据拆分的力度逐渐降低。
拆分缺点也很明显,对于拆分后的数据,某条库存用完了怎么办,怎么判断库存超卖不超卖,以及库存数据的增加维护等等等,系统复杂度会提升。
对于反馈需要及时的电商场景,请求合并并不一定适合,但是对于实时性没那么强的业务场景,比如说银行或者保险的热点账户,多个账户变动请求同时变更同一个账户,我们可以在1s、5s或者更长一段时间内将这段时间的多个请求在内存中计算合并后,再去更新数据库,可以大大降低数据库的压力。
这种方式也是针对异步场景 UPDATE语句转INSERT语句,原来更新一条数据,改为插入单独的一个明细表,通过异步的方式将明细表进行汇总计算,再合并到原数据中。
这么一想设计思想是否和MySQL redo log的使用方式比较接近了,先顺序写磁盘,然后异步写入MySQL数据表中。
很多设计思想都是一致的,无非是实现的区别
理论上我们上述说的方案,也都可以通过MySQL进行改造,比如请求合并,改为在MySQL层对相同的SQL语句(这里的相同指更新逻辑基本一致)进行合并。