库存服务是交易系统非常核心的功能,如何设计好一个库存服务是比较大的一个挑战。
业界对于库存敏感的业务往往通过数据库进行库存方案的设计,那么基于数据库库存系统会有哪些坑呢?
通过SQL进行库存扣减可以类似于:
“update stock_table set inventory=inventory-1 where item_id=xxxx and inventory>0;”
item_id是商品,库存充足情况下,扣减库存,隔离级别大于等于RC的关系数据库可以保证这条语句的原子性。
在处理对少量热点商品高并发扣减库存的业务时,关系数据库都会面临如下几个难题:
当前主流的关系数据库,无论是老牌商业产品Oracle、流行开源项目MySQL、还是国产开源新秀TiDB,它们都使用经典的WAL(write ahead log)方式来实现数据的持久化,即在事务提交时保证被更新的数据(WAL)写到硬盘后,才能给客户端返回成功。
而硬盘写入的latency比内存操作大几个数量级,为了优化性能,大家都引入了组提交机制(group commit),即将同时提交的多个事务的数据,合并为一条WAL写入硬盘,对于每个事务来说,latency还是一次硬盘写入IO的耗时,但是对于整个系统来说,可以将TPS从原来与硬盘IOPS相近的水平,提升几倍甚至几十倍。
但是并不是所有的并发事务都能够合并成组提交,如果两个事务之间存在冲突(比如并发修改同一行),那么无论是基于悲观锁进行并发控制的Oracle/MySQL,还是基于乐观锁进行并发控制的TiDB,对于相互冲突的事务,他们本质上的处理方式,都只能是排队执行,即后一个事务要等前一个事务提交完成后才能执行。使用扣减库存的SQL举例如下:
找到并对商品记录加锁 --> 判断库存余额 --> 修改库存余额 --> 提交WAL写盘 --> 释放锁
针对同一个热点商品的多个并发事务,在上面加锁和释放锁之间的这段操作是无法做到并发执行的,因此在不引入任何优化的情况下,在同一个数据表中针对一个热点商品扣减库存TPS的天花板就是硬盘的IOPS,而在大量并发事务都在争抢行锁的情况下,情况会进一步恶化,较高的系统负载,叠加上锁冲突检测等额外代价,可能造成系统的整体吞吐降低至个位数。
考虑到上述并发事务提交WAL的问题,在实际系统上,为了降低写WAL的latency,保证系统吞吐,一般会将写硬盘和同步备机调整为异步方式,而这个调整又会带来新的问题,即主库宕机情况下的数据不一致,主库重启或者备库切换为主库后,可能存在宕机前部分WAL没有被持久化的风险,反映到扣减库存的逻辑上就是已经被扣减的库存又被恢复了回来,最终在业务上形成超卖。
2012年阿里双11由于商品超卖给商家的赔付,产生了较大的经济损失。
上面所举的例子是单行事务的update,行锁的临界区(“找到并对商品记录加锁 --> 判断库存余额 --> 修改库存余额 --> 提交WAL写盘 --> 释放锁”)都在数据库处理的边界之内,但是在某些复杂场景下,在库存扣减的事务中可能存在多条语句的情况,比如扣减库存(update)+生成订单(insert)在一个事务内完成,这种情况下行锁的临界区扩大到受业务网络交互的影响,整体冲突加剧、吞吐进一步降低。
数据库层面对于并发扣减库存的优化思路:
在业务层将同一个商品的库存记录拆分为多行甚至多个表里面去,降低在同一行或同一个数据表上的并发冲突,比如针对业务请求中的userid计算hash取模后确定要扣减哪个库存记录。这个方案能够很大程度的降低并发冲突,不需要数据库内核配合做修改,是行业内的主流方案,它的问题是:
同一个商品不同库存记录的扣减速度不均衡(热点商品往往在几十秒内被强光,这个不均衡问题并不严重),给总库余额计数带来的复杂度,业务需要预先感知热点商品并且针对性的进行库存拆分。
通过修改数据库内核代码,将相互冲突的事务,合并为一个事务或者一次WAL组提交,达到批处理的效果,AliSQL的做法是在MySQL server层识别这类update语句,将它们解析后合并成为一条SQL再执行,比如10个扣减库存语句,合并为一个扣减库存的语句一次性扣减数量为10,这个做法的优势是对数据库内核代码修改不多、复杂度可控,局限是只能在特定语句的基础上进行优化,没有比较好的普适性;
OceanBase则选择了另外一个优化思路,即提前释放锁,在事务确定要提交(比如单行事务执行成功或者用户在事务最后一条语句上标记“Commit on success”)的情况下,不需要等WAL同步,而先把事务涉及的行锁先释放掉,这样可以使得其他并发事务能够进入临界区,最终效果可以达到对同一行修改的多个并发事务的WAL,可能在一次组提交内完成。
即使我们在数据库内核层面引入了上述“批处理”的优化,对热点行的并发扣减库存业务仍然会面临多个事务并发争抢进入临界区的情况,并发等锁的事务会占据宝贵的连接和线程资源,系统负载可能持续恶化;
这里的一个优化思路是,在数据库内核层面将并发扣减同一个商品库存的事务排到一个队列处理(比如让用户在SQL注释上标记这个事务划分队列的依据,一般来说可以用商品ID取模),降低并发冲突,减少对连接和线程资源的占用,降低系统负载。这个优化目前已经在AliSQL上开源,效果还是比较明显的。
对于一个事务里要执行多条语句的情况,会造成临界区的扩大,严重影响并发度,一个最有效的方案是数据库层面支持存储过程,多个语句放在存储过程里一次性提交给数据库;但是MySQL并不支持存储过程,因此可以针对具体场景引入一些类似存储过程的优化,当然核心仍然是将一个事务中的多条语句合并,实现与数据库在一次交互中完成。
比如AliSQL的Commit on success,可以用在扣减库存+生成订单的场景中,即开启事务后先执行几乎没有并发冲突的insert语句生成订单,然后带上Commit on success标记执行扣减库存命令,库存扣减成功后就立即提交事务,不需要等待客户端再发commit,这样一来热点行冲突的临界区仍然与单行事务一样了。再比如OceanBase引入的... when row_affected()语法,允许在一个语句内先执行update,然后根据受影响的函数来决定事务执行其他修改,这已经很像存储过程了。
在上面,扣减库存与生成订单的事务是在同一个数据库实例完成的,但是随着业务的拆分、业务逻辑的变化,扣减库存与生成订单可能被拆到不同的服务中去,那么如何保证扣减库存与生成订单的一致性,也成为一个有挑战的问题。
需要注意的是这种场景下,产生的数据不一致,不会造成商品超卖,而是会造成用户下单成功,却看不到待支付订单。比如在发券的活动中,有约很多的券没有及时发放到用户钱包中,本质上就是这样问题,直接原因是扣减库存的金融业务撑住了压力,但是券系统超时触发了熔断,很多券没有成功发给用户,造成大量客诉,事后也花费了几个小时来使用hive上的日志数据进行补偿。
针对这类问题,一般通过DRC/DTS这类中间件来配合实现数据一致性,即扣减库存成功后,MySQL就会有相应的binlog,DRC/DTS订阅库存中心的binlog,订单中心再根据DRC/DTS订阅的数据来生成订单。
因为MySQL binlog有多份副本不会丢失,所以即使订单中心出现超时抖动等问题,在恢复正常后,就能够继续生成订单。当然,引入这类优化后,也意味着系统要进行异步化改造,因为生成订单的逻辑本质上变成了异步操作。