本文主要参考和总结自沈剑大佬扣减相关的三篇文章以及拉勾教育潘新宇老师的《23讲搞定后台架构实战》12~15章节课程,完整参考见文末。
1、方案
1.1 方案一:根据商品 id 查询库存,校验库存是否足够后,利用数据库自己的字段减法实现 set 扣减
-- select stock_num from stock where sid=$sid # sid商品id(上游查询)
update stock set stock_num=stock_num-$reduce where sid=$sid # 下游收到消息执行更新操作
缺点:扣减 sql 是一个非幂等的操作,可能会因为上游重试容错机制导致重复发了 2 个请求,于是下游发生了重复扣减。
1.2 方案二:在程序里事先计算好扣减后的结果,直接 set 结果到库存字段
select stock_num from stock where sid=$sid # sid商品id
update stock set stock_num=$result where sid=$sid # 在程序里先计算好 result=num-订单中的商品数量
缺点:并发问题导致两个线程同时查到一个库存,随后会发生后扣减的覆盖先扣减的执行结果。
1.3 方案三:在 set 新库存值时,加上当前库存与原有库存的比较,校验一下当前库存额度是否等于刚开始查到的额度
select stock_num from stock where sid=$sid # sid商品id, version 字段需要自己定义,数据库不会默认加这个字段
update stock set stock_num=$result where sid=$sid and stock_num=$num_old # 在程序里先计算好 result=stock_num-count
这其实是一种 CAS 乐观锁的思想,不会有并发问题,
- 但是高并发情况下可能会导致很多线程 set 失败,需要重试。比如上面方案二里的问题,先扣减的事务可以请求成功,但是后扣减的请求校验 stock_num 时会失败,需要重试才能成功,极端情况下可能会导致部分请求超时,吞吐量会降低。
- 而且乐观锁即使数值匹配上,有可能已经发生了剧烈变化,下单与取消订单,下单与上架,这些被忽略的过程可能对业务有害。但是这个问题可以用版本来解决。
select stock_num, version from stock where sid=$sid # sid商品id, version 字段需要自己定义,数据库不会默认加这个字段
update stock set stock_num=$result, version=version+1 where sid=$sid and version=$version # 在程序里先计算好 result=stock_num-count
注意:version 字段需要自己定义,数据库不会默认加这个字段
程序伪代码如下
for ;; {
select stock_num, version from stock where sid=$sid # sid商品id
effect_row := update stock set stock_num=$result where sid=$sid and version=$version # 在程序里先计算好 result=stock_num-count
if effect_row > 0 { // 如果影响行数大于0,说明更新成功,如果等于0,说明更新失败
break
}
}
1.4 方案四:用一个 sql 解决,不用两个 sql
update stock set stock_num=stock_num-count where sid=$sid and stock_num>=count
只有一条 sql,满足原子扣减。
幂等的问题可以通过传入单号进行排重处理,上游发现传过来相同单号直接丢弃忽略,这样就不会发生重复提交问题,从而实现了幂等。传入单号也有利于库存对账。
写操作不建议使用重试,快速失败?
单线程串行化?
根据影响行数来判断是否执行成功。
1.5 方案五:把库存放到redis里,利用redis的原子性减操作
DECR 命令可以实现原子扣减,如果扣减结果为负数,则此次扣减操作失败,不需要先查询再 set。redis-lua可以控制 redis 的原子性,这个是怎么实现的?跟 DECR 是同一个东西吗
为了避免 redis 发生错误重启,可以每次发生库存更新都更新一次数据库,但是这样有点慢,可以改成每次发生库存扣减记录一条 log,异步合并日志和更新数据库。重启时缓存失效,读日志和数据库恢复。
数据库只记录流水
1.6 方案六:使用事务把查询和更新操作都包裹起来
使用事务可以解决并发问题,需要同时把查询和更新操作都包裹起来,但是查询的时候需要加上for update 来加锁。在事务维度加锁。分布式事务的话会比较难办。
2、架构设计
扣减业务定义和关键点:
- 不允许超卖;并发一致性;高可用;某个订单包含多个商品,一个商品失败,整个订单其他商品都回滚;
- 需要有流水记录表,记录每个商品每次的扣减,用来回滚时判断哪些商品需要回仓,以及可以用流水 id 来幂等去重,防止请求超时时,重复扣减。
库存不足时则不执行扣减操作,先查询库存,如果库存满足条件,则再进行扣减,减少一部分写操作,从而减少写操作,提升数据库的并发,虽然每次扣减需要多一次查询操作会导致接口整体延迟增加,但是减少了回滚的概率,整体上是提高了性能的。
2.1 方案一:数据库实现扣减
为了进一步提高读性能,降低前置校验对扣减功能性能的影响,把读从库改成读缓存。
2.1.1 架构优点
- 实现简单
- 在没有水平分库的情况下,数据库事务可以保证一致性、原子性和隔离性,不少卖,也不超卖。
2.1.2 架构缺点:
1、单次事务可能需要操作多个商品扣减,且商品越多,接口延迟越高,不适合商品过多或者 qps 较大的场景。
2、多个事务操作多个商品时,有可能死锁;
3、水平分库时,不适用,需要加入分布式事务;
2.2 方案二:缓存+数据库扣减(分布式事务)(log 库法)
为了避免 redis 发生错误重启,可以每次发生库存更新都更新一次数据库,但是这样有点慢,可以改成每次发生库存扣减记录一条 log,异步合并日志和更新数据库。重启时缓存失效,读日志和数据库恢复。
使用缓存来记录每件商品的库存数量,请求到来时先插入一条 log 到数据库,然后使用 lua 脚本进行单个订单多个商品的库存校验以及扣减,保证隔离性。通过监听 log 库的 binlog 异步更新数据库的库存,以及插入流水记录,保证缓存和数据库数据的最终一致性。缓存宕机或者库存不足导致扣减失败则返回用户扣减失败。
如果发生 Redis 宕机且数据未持久化到磁盘,可以使用数据库恢复或者校准数据。
2.2.1 扣减详细步骤如下
- 通过订单 id 查询扣减业务数据库,进行幂等去重校验,防止一个订单被重复扣减,保证幂等。
- 开始插入 log 记录的数据库事务,记录一条 log 记录到 log 库,包含此次订单扣减的所有商品以及每件商品的扣减数量,为了提升 log 库的插入效率,可以对 log 库进行水平扩展;
- redis 执行 lua 脚本,完成该订单多个商品的库存校验以及扣减
- 扣减成功则提交插入 log记录到数据库的事务,否则回滚该事务,并返回用户扣减失败。如果事务提交失败,也需要返回用户扣减失败,如果不是因为 redis 宕机导致的扣减失败,需要执行缓存归还操作。
- 通过监听 log 库的 binlog 异步更新数据库的库存,以及插入流水记录,保证缓存和数据库数据的最终一致性
2.2.2 其他
商家通过运营平台修改数据库,同步更新缓存中的库存量。
2.2.3 技巧
redis 的数据: sku_stock_{sku},简写成 ss_{sku},在商品种类成百万上千万时可以极大地降低存储空间。
缓存扣减的性能远高于顺序插入 log 表,为了提高log库的写入效率,对log库进行水平扩展。
2.2.4 表设计
流水表:记录每个商品的扣减情况
库存表:记录每个商品的剩余库存值。
2.2.5 log 表记录内容
{
"扣减号":uuid,
"skuid1":"数量",
"skuid2":"数量",
"xxxx":"xxxx"
}
2.3 方案三:缓存+数据库扣减(分布式事务)(消息队列法)
把 log 库换成支持事务消息的消息队列,比如 RocketMQ。优势:并发高,编码简单。
插入 log 记录,改成发送 log 事务消息到消息队列。
扣减服务发送事务消息到 MQ,然后调用 redis 执行订单的商品扣减,扣减成功后发送 commit 信号到 MQ,MQ 把消息标记为可消费状态,消费者服务负责将消息转换成流水记录和执行数据库扣减;如果 redis 扣减失败则发送 rollback 回滚信号到 MQ,MQ 将消息删除。
为了保证 MQ 一定不丢消息,消费者服务处理完流水记录的插入和数据库扣减逻辑后再发送 ack 到 MQ,此时MQ 才会删除消息。
3、回仓
3.1 概述
订单生成时即完成库存的扣减,如果用户超时未支付,则需要自动取消订单,并把库存加回去,这个动作称之为回仓。
此外,订单支付完成后,未发货之前,如果用户主动取消了订单,或者退货了部分商品,也需要对这部分商品进行自动回仓。(已经发货的商品如果退货不需要自动回仓,需要商家通过后台主动增加库存,因为无法确定商品是否有质量问题,是否需要重新上架)
3.2 功能特点
退货概率低,QPS 并发量比较低,不需要考虑性能问题。
3.3 注意的原则
3.3.1 原则一:扣减完成才能返还
3.3.2 原则二:一次扣减可以多次回仓,返还的总数量要小于等于原始扣减的数量
订单包含多个商品,每件商品数量又有多个,一个订单可能发生多次退货,所以需要两张表:回仓记录表和商品回仓详情表
回仓操作记录表 t_return(id, deduction_id, return_id, content) :记录每次的回仓动作,此次回仓哪些商品,数量分别多少。uniq_deduction_return(deduction_id, return_id),扣减 id 和 返还 id 是联合唯一索引。
商品回仓详情表 t_return_detail(id, return_id, sku_id, num):记录每类商品的回仓详情,某件商品在某次回仓中回仓多少件,跟回仓记录表是一对多的关系。uniq_return_sku(return_id, sku_id),返还 id 和 sku_id 商品 id 是联合唯一索引。
乐观锁控制并发
返还前需要查询某个订单的剩余可回仓量,即订单中某商品的剩余数量,然后执行返还,为了保证查询到的版本跟执行返还的版本是同一个,避免返还数量多余原始扣减数量,需要加入乐观锁来控制,订单表插入版本号字段,或者执行扣减时比较下可回仓数量是否等于查询时的数量。
比如剩余可回仓数量为 5,两个请求分别回仓 2 和 4,(先不讨论为啥会出现这种情况,大概是各种bug导致出错了吧),两个请求都查询到可回仓数量为 5,发现可以回仓,如果没有加锁控制,两个请求都会执行返还动作,这样回仓数量就超过了扣减数量。
如果有加锁控制,后执行回仓动作的请求发现可回仓数量变化了,或者版本变化了,就会回仓失败,重试一次,重新查询可回仓数量,然后执行回仓,避免了刚才的问题。
3.3.3 原则四:返还功能要保证幂等
如果出现网络超时,回仓返还功能请求可能会重复发送。为避免请求被重复执行,可以把回仓记录表的扣减id和回仓记录 id 设置为联合唯一索引,回仓记录 id 由上游生成回仓请求时产生,这样重复的请求由于拥有相同的扣减 id 和 回仓记录 id,则会插入回仓记录失败,从而保证功能幂等。
4、问题
问:为什么说利用缓存抗读请求,利用水平扩展增加性能是提升吞吐量的根本方案?
水平扩展。将库存分布于多个子节点,所有子节点库存之和为总库存。分布式会带来一系列问题,比如库存碎片,需要异步化零为整。还有跨库事务问题等等。
问:加上事务能能否解决并发问题,如果要加事务,需要把 select 也包括进来吗
可以解决,需要包括进来,但是查询的时候需要加上for update来加锁。
但是水平分库后无法通过这个解决。
问:如何知道sql是否设置成功
影响行数为 0,则表示更新失败,大于0则表示更新成功。
问:能否使用队列,在数据库侧串行执行,降低锁冲突?
可以,但是会极大降低效率。
问:如果采用单线程串行化的方式,怎么提高效率
应用服务器内部还可以使用group commit技术,一次减库存多个订单的购买量。
如果是缓存扣减,可以把库存总额平摊到每个缓存,并发度等于缓存数量。
如果是数据库扣减,无法提高效率。
问:一个订单多个商品,如何保证每个商品都扣减成功?不用事务能实现吗?
更复杂的case:余额、订单、流水如何保证一致,本质上是一个分布式事务的问题,
问:加 redis 锁能解决这个并发问题吗
redis 做高可用的情况下可以。
问:如果用 redis 记录扣减,那怎么保证不同机房的用户访问 redis 延迟相对均衡
redis 水平切分,提前分配好两边库存
问:什么时候用悲观锁,什么时候用乐观锁
高并发,发生资源竞争的概率较大时用悲观锁,少并发,发生资源竞争概率较少时用乐观锁
问:乐观锁有什么问题?怎么优化?
并发大的情况可能存在,ABA 问题
优化思路:不仅需要保证前后相等,还等保证值没有被改过。
常见实践:“版本号”的比对,一个数据一个版本,版本变化,即使值相同,也不应该修改成功。
select stock_num, version from stock where sid=$sid # sid商品id, version 字段需要自己定义,数据库不会默认加这个字段
update stock set stock_num=$result, version=version+1 where sid=$sid and version=$version # 在程序里先计算好 result=stock_num-count
问:方案二使用缓存执行多个订单多个商品的扣减,可能会出现什么问题,如何保证订单之间的隔离性
订单之间存在相同的商品,P1商品库存5个,订单A 先扣减 P1 商品 2 个库存,订单 B 随后也扣减了 P1 商品的4个库存,发现库存不足返回扣减失败,而订单 A 虽然扣减 P1 商品成功了,但是订单中的其他商品也可能因为同意的原因被其他订单先扣减,导致库存不足而扣减失败,最总就会导致很多原本可以扣成成功的订单因为相互干扰都扣减失败了,原因是破坏了订单之间的隔离性和原子性。数据库扣减的话事务提交之前,订单之间的扣减都是不可见的,相同商品的扣减需要加锁等待,所以可以保证订单之间的隔离性,不出现这种情况。
首先 Redis 采用了单线程的事件模型,保障了我们对于隔离性的要求。当我们多个客户端给 Redis 同时发送命令后,Redis 会按接收到的顺序进行串行的执行,对于已经接收而未能执行的命令,只能排队等待。
但是 redis 只能保证单条命令的串行执行,单条命令的隔离性,无法保证多条命令的相对顺序。
单个订单的多个商品扣减使用 lua 脚本批量执行。
问:方案二,单个订单多个商品扣减能保证原子性吗
不能,需要通过返还操作来补偿。
问:方案二如何保证数据的最终一致性
如果插入log记录的事务提交成功,说明缓存扣减成功,监听 log表的 binlog,可以异步更新数据库的库存值,从而实现缓存跟数据库库存值的最终一致性
如果插入 log 记录的事务提交失败,此时返回用户扣减失败,因为 log 记录插入失败,数据库的库存值仍是正确值,
问:方案二使用 redis 来存储库存和扣减, redis 宕机会丢失数据吗
不会,只要是log事务提交之前宕机,比如redis 执行 lua 脚本时宕机,插入 log 记录的事务会检测到异常从而回滚,数据库的库存值不会更新,这样 redis 重启后可以用数据库的数据进行缓存重建。
如果是log事务之后宕机,log记录已经落库,其他服务可以正常消费log库的Binlog来进行数据库库存更新和流水记录插入。
问:方案二可能出现什么问题
可能短暂的出现缓存库存值少于实际库存值的情况,从而出现短暂的少卖的情况(实际有库存,但是 redis 显示库存不足,导致扣减失败)。
- 扣减到一半,发现库存不足,导致扣减失败
- redis 执行 lua 脚本到一半,完成部分数据的扣减,但是突然宕机了;
- 或者缓存扣减成功,但是提交插入 log 记录事务失败;
这两种情况都会导致缓存中的数据已经完成更新,但是 log 记录插入事务会因为发现异常而回滚的情况,导致 log 记录没有插入成功,数据库的数据更新失败,虽然保证了数据库数据的正确性,但是直到数据库的数据同步到缓存,缓存都是库存少于实际库存的状态;
解决方案
如果是 redis 宕机导致,则redis 重启后使用数据库的数据进行缓存数据重建即可;
如果是 redis 没宕机,只是库存不足扣减失败或者log事务提交失败,服务可以执行缓存的补偿动作,把扣减的库存值加回去。
问:为什么 redis-lua 可以控制 redis 的原子性?
lua 是一个类似 JavaScript、Shell 等的解释性语言,它可以完成 Redis 已有命令不支持的功能。用户在编写完 lua 脚本之后,将此脚本上传至 Redis 服务端,服务端会返回一个标识码代表此脚本。在实际执行具体请求时,将数据和此标识码发送至 Redis 即可。Redis 会和执行普通命令一样,采用单线程执行此 lua 脚本和对应数据。
redis 执行单条命令是串行执行的。
问:接口幂等怎么快速实现?
- 查询操作或者无状态计算天然幂等
- 写幂等 sql,比如 insert 语句,有唯一索引时重复执行会失败。update 的 set不具备幂等性,因为重复执行可能覆盖其他语句的更新结果。
- 接口执行操作时先查询是否以及执行过,如果执行过则不执行,否则执行
问:为什么分布式的架构里,不直接在 lua 脚本里进行流水记录写数据库和缓存扣减操作,这样能利用redis单线程执行 lua 脚本的性质,数据库和redis都单线程操作,这样能保证扣减数据的正确性
单线程redis扣减可以,但是如果单线程操作数据库则不行,因为效率太低了
问:取消订单后,除了回仓,可能还会有什么业务操作
取消订单或者退货部分商品后, 还需要终止物流,扣减积分等。
5、引申出姊妹问题:转账问题
悲观锁的吞吐率较低,在大并发的情况下容易产生两个问题,一个是大量的超时,包括数据库事务超时和业务接口超时;另一个是容易产生死锁,死锁在账户类操作比较常见,比如两个事务同时对相同的两个账户互相转账,事务一:A转B,事务二:B转A,此时就会产生死锁。
而CAS类型的乐观锁在大并发情况下会产生大量的失败,即数据库中某行数据的更新的影响行数为0,如使用失败重试机制来保证更新成功,又会带来其它的问题和风险。
以后有时间再整理整理转账问题。