1、如何利用数据库实现并发扣减?
1.1、扣减类业务的技术关注点
发生扣减必然就会存在归还,比如用户购买了商品之后因为一些原因想要退货,这个时候就需要将商品的库存、商品设置的购买次数以及订单金额等进行归还。
基于扣减类业务的定义,关于扣减的实现,需要关注的技术点总结如下:
- 当前剩余的数量需要大于等于当次扣减的数量,即不允许超卖;
- 对同一个数据的数量存在用户并发扣减,需要保证并发一致性;
- 需要保证可用性和性能,性能至少是秒级;
- 一次的扣减会包含多个目标数量;
- 当次扣减有多个数量时,其中一个扣减不成功即不成功,需要回滚。
对于返还的实现需要关注的技术点:
- 必须有扣减才能返还;
- 返还的数量必须要加回,不能丢失;
- 返还的数据总量不能大于扣减的总量;
- 一次扣减可以有多次返还;
- 返还需要保证幂等。
1.2、纯数据库式扣减实现
纯数据库实现之所以能够满足扣减业务的各项功能要求,主要依赖各类主流数据库提供的两个特性:
- 第一是基于数据库乐观锁的方式保证数据并发扣减的强一致性;
- 第二是基于数据库的事务实现批量扣减部分失败时的数据回滚。
数据库存储扣减中所有数据,主要包含两张表:扣减剩余数量表和流水表,扣减剩余数量表是最主要的表,包含实时的剩余数量。
如上表所示,对于当前剩余可购买的数量,当用户进行取消订单、售后等场景时,都需要把数量加回到此字段。同时,当商家补齐库存时,也需要把数量加回。
从完成业务功能的角度来看,只要扣减剩余数量表即可,但是在实际场景中,会需要查看明细进行对账、盘货、排查问题等需求。其次,在扣减后需要进行返还时是非常依赖流水的。因为只能返还有扣减记录的库存数量。最后,在技术上的幂等性,也非常依赖流水表。
1.2.1、扣减接口实现
扣减接口接受用户提交的扣减请求,包含用户账号、一批商品及对应的购买数量,大致实现逻辑如下图所示:
流程开始时,首先进行的是数据校验,在其中可以做一些常规的参数格式校验,其次它还可以进行库存扣减的前置校验,比如当前数据库存库只有8个时,而用户需要10个,此时在数据校验中即可前置拦截,减少对于数据库的写操作。
当用户只购买某商品2个时,如果在校验时剩余库存有8个,此时校验会通过,但在后续的实际扣减时,因为其他用户也在并发的扣减,可能会出现幻读,即此用户实际去扣减时不足2个,导致失败。这种场景就会导致多一次数据库查询,降低了整体的扣减性能。其次,即使将校验放置在事务内,先查询数据库数量校验通过后再扣减,也会增加性能。
扣减完成之后,需要记录流水数据,每一次扣减时,都需要外部用户传入一个uuid作为流水编号,此编号是全局唯一的,用户在扣减时传入唯一的编号有两个作用。
1、当用户归还数量时,需要带回此编号,用来表示此次返还属于历史上的具体那次扣减。
2、进行幂等控制,当用户调用扣减接口出现超时时,因为不知道是否成功,用户可以采用此编号进行重试或反查,在重试时,使用此编号进行标示防重。
存在的问题:多一次查询,就会增加数据库的压力,同时对整体服务性能也有一定影响,对外提供的查询库存数量的接口也会对数据库产生压力,同时读的请求量远大于写,由此带来的压力会更大。
1.2.2、扣减接口实现升级
根据业务场景分析,读库存的请求一般是顾客浏览商品时产生的,而调用扣减库存基本是用户购买时才会触发,用户购买请求的业务价值相比读请求会更大,因此对于写需要重点保障,转换到技术上,价值相对低的读来说是可以降级的,有损的。对于写要尽可能性能好、尽量减少不必要的读与写请求等。
针对上述问题,可以对整体架构进行升级。
整体的升级策略采用了读写分离的方式,另外主从复制直接使用了MySQL等数据库已有的功能,改动上非常小,只要在扣减服务里配置两个数据源,当客户查询剩余库存数量、扣减服务中的前置校验时,读取从数据库即可。而真正数据扣减使用主库。
1.2.3、扣减接口实现再升级
在基于数据库的主从复制降低了主库流量压力之后,还需要升级的就是读取的性能了,这里我们使用Binlog实现简单,可靠的异构数据同步的技能。
1.3、纯数据库扣减方案适用性
纯数据库方案有以下几个优点:
- 实现简单,即使读使用了前置缓存,整体代码工程就两个,即扣减服务与数据映射服务,在需求交付周期非常短、人力紧张的场景是非常适用的;
- 使用了数据库的ACID特性进行扣减,在业务上,库存数据库不会出现超卖和少卖的问题。
存在的不足:
- 当扣减SKU数据增多时,性能非常差,因为对每一个SKU都要单独扣减,导致事务非常大,极端情况下,可能出现几十秒的情况。
2、如何利用缓存实现万级并发扣减?
2.1、纯缓存方案浅析
纯数据库的方案虽然避免了超卖与少卖的问题,但因采用了事务的方式保证一致性和原子性,所以在SKU数量较多时性能下降较明显。
因为扣减有一个要求即当一个SKU购买的数量不够时,整个批量扣减就要回滚,因此我们需要使用类似for循环的方式对每一个扣减的SQL的返回值进行检查,另一个原因是,当多个用户买一个SKU时,它的性能也不客观。因为当出现高并发扣减或并发扣减同一个SKU时,事务的隔离性会导致加锁等待以及死锁的情况发生。
下面我们对问题再次梳理一遍,进而寻找可升级演化方案。
首先,扣减只需要保证原子性即可,并不需要数据库提供的ACID。在扣减库存时,重点保证商品不超卖不少卖。而持久化这个功能,只有在数据库故障切换及恢复时才有需要,因为被中断的事务需要持久化的日志进行重演,也就是说持久化是主功能之外的后置功能,附加功能。
那么,在不改变机器配置情况下,把传统的SQL类数据库替换为性能更好的NoSQL类数据存储试试?是不是有一个性能又好同时又能满足扣减多个SKU具有原子性的NoSQL数据库呢?
Redis 采用了单线程的事件模型,保障了我们对于原子性的要求。对于单线程的事件模型,简单的比喻就是说当我们多个客户端给 Redis 同时发送命令后,Redis 会按接收到的顺序进行串行的执行,对于已经接收而未能执行的命令,只能排队等待。基于此特性,当我们的扣减请求在 Redis 执行时,也即是原子性的。此特性刚好符合我们对于扣减原子性的要求。
2.2、方案实现剖析
缓存中存储的信息和数据库表结构基本类似,包含当前商品和剩余的库存数量和当次的扣减流水,这里需要注意两点。
-
首先,因为扣减全部依赖于缓存,不依赖数据库,所有存储于Redis的数据均不设置过期并全量存储。
-
其次,Redis是以k-v结构为主,伴随hash、set等结构,与MySQL以表+行为主的结构有一定差异。Redis中库存数量结构大致如下:
key为:sku_stock_{sku}。前缀sku_stock是固定不变,所有以此为前缀的均表示是库存。{sku}是占位符,在实际存储时被具体的skuid替代。 value:库存数量。当前此key表示的sku剩余可购买的数量。
对于Redis中存储的流水表采用hash结构,即key+hashField+hashValue的形式,结构大致如下:
key:sx_{sku}。前缀sx_是按上述缩短的形式设计的,只起到了区分的作用。{sku}为占位符 hashField:此次扣减流水编号。 hashValue: 此次扣减的数量
在一次扣减时,会按SKU在Redis中先扣减完库存数量在记录流水信息。
扣减接口支持一次扣减多个SKU+数量,查询Redis的命令文档会发现:
首先,Redis对于hash结构不支持多个key的批量操作;
其次,Redis对于不同数据结构键不支持批量操作,如KV与Hash间。
如果对于多个SKU不支持批量操作,我们就需要按单个SKU发起Redis调用,在上文中提到过,Redis不对命令间保证单线程执行。如果采用上述Redis的数据结构,一次扣减必须要发起多次对Redis的命令才可完成。这样上文提到的利用Redis单线程来保证扣减的原子性此时则满足不了了。
针对上述问题,我们可以采用Redis的lua脚本来实现批量扣减的单线程诉求。
Redis中的lua脚本执行时,首先会使用get命令查阅uuid是否存在,如已存在则直接返回,并提示用户请求重复,当防重通过后,会按SKU批量获取对应的剩余库存状态并进行判断,如果其中一个SKU此次扣减的数量大于剩余数量,则直接给扣减服务返回错误并提示数量不足。通过Redis的单线程模型,确保当所有SKU的扣减数量在判断均满足后,在实际扣减时,数量不够的情况是不会出现的,同时单线程保证判断数量的步骤和后续扣减步骤之间,没有其他任何线程出现并发的执行。
判断数量满足之后,lua脚本后续就可以按SKU进行循环的扣减数量并记录流水。
当Redis扣减成功后,扣减接口会异步的将此次扣减内容保存至数据库,异步保存数据库的目的是防止出现极端情况,Redis当即后数据未持久化到磁盘,此时我们可以使用数据库恢复或校准数据。
最后,在纯缓存架构图中还有一个运营后台,它直接连接了数据库,是运营和商家修改库存的入口。当商品补齐了新的货物时,商家在运营后台将此SKU库存数量加回。同时运营后台的实现需要将此数量同步的增加至Redis。因为当前方案的所有实际扣减都在Redis中。
2.3、异常情况分析
因为Redis不支持ACID特性,导致在使用Redis进行可偶见时相比纯数据库方案有较多异常场景需要处理。
-
Redis突然宕机
如果Redis宕机,请求在Redis中只进行了前置的防重和数量验证,此时则没有任何影响,直接返回给客户扣减失败即可。
但如果此时Redis中的lua脚本执行到了扣减逻辑并做了实际的扣减,则会出现数据丢失的情况,因为Redis没有事务的保证,宕机时已经扣减的数量不会回滚,宕机导致扣减服务给客户返回扣减失败,但实际上Redis已经扣减了部分数据并刷新了磁盘,当此Redis故障处理完成再次启动后或者failover之后,部分库存数量已经丢失。
为了解决这种问题,可以使用数据库中的数据进行校准。常见的方式是开发对账程序,通过对比Redis与数据库中的数据是否一致,并结合扣减服务的日志。当发现数据不一致同时日志记录扣减失败时,可以将数据库比Redis多的库存数据在Redis中进行加回。
-
扣减Redis完成并成功返回客户后,异步刷新数据库失败
此时,Redis中的数据库是准的,但数据库中的库存数量时多的,在结合扣减服务的日志确定是Redis扣减成功但异步记录数据失败后,可以将数据库比Redis多的库存数据在数据库中进行扣减。
2.4、升级纯缓存实现方案
扣减服务不仅包含扣减接口还包含数量查询接口,查询接口的量级相比写接口至少是十倍以上,即使是使用了缓存进行抗量,但读写都请求了同一个Redis,就会导致扣减请求被读影响。
其次,运营在后台进行操作增加或者修改库存时,是在修改完数据库之后在代码中异步修改刷新Redis,因为数据库和Redis不支持分布式事务,为了保证在修改时它们的数据一致性,在实际开发中,需要增加很多手段来保证一致性,成本较高。
我们可以增加一个Redis节点,在扣减服务里根据请求类型路由到不同的Redis节点。使用主从分离的好处是,不用太多的数据库同步开发,直接使用Redis主从同步方案,成本低开发量小。
运营后台修改数据库数量后同步至Redis的逻辑使用binlog进行处理,当商家修改了数据库中的数量之后,MySQL的binlog会自动发出,在数据转换模块接受binlog并转换格式插入Redis即可。因为binlog消费是采用ack机制,如果在转换和插入Redis时出错,ack不确定即可。下一次数据转换代码运行时,会继续上一次未消费的binlog继续执行。最终binlog机制不需要太多逻辑处理即可达到最终一致性。
2.5、纯缓存方案适用性分析
纯缓存方案的主要优点是性能提升明显。使用缓存的扣减方案在保证了扣减的原子性和一致性等功能性要求之外,相比纯数据库的扣减方案至少提升十倍以上。
除了优点之外,纯缓存的方案同样存在一些缺点。Redis 及其他一些缓存实现,为了高性能,并没有实现数据库的 ACID 特性。导致在极端情况下可能会出现丢数据,进而产生少卖。另外,为了保证不出现少卖,纯缓存的方案需要做很多的对账、异常处理等的设计,系统复杂度会大幅增加。
3、如何利用缓存+数据库构建高可靠的扣减方案?
3.1、顺序写的性能更好
在向磁盘进行数据操作时,向文件末尾不断追加写入的性能要远大于随机修改的性能,数据库同样是插入要比更新的性能好,对于数据库的更新,为了保证对同一条数据并发更新的一致性,会在更新时增加锁,但加锁是十分消耗性能的,此外对于没有索引的更新条件,要想找到需要更新的那条数据,需要遍历整张表,时间复杂度为O(N),而插入只在末尾进行追加,性能非常好。
上述的架构和纯缓存的架构区别在于,写数据库不是异步写入,而是在扣减的时候同步写入。
同步写入数据库使用的是insert操作,也就是顺序写,而不是update做数据库数据量的扣减。
insert的数据库称为任务库,它只存储每次扣减的原始数据,而不做真实的扣减(即不进行update),它的表结构大致如下:
create table task{
id bigint not null comment "任务顺序编号",
task_id bigint not null
}
任务表里存储内容格式可以是json、xml等结构化的数据,以json为例,数据内容大致可以如下:
{
"扣减号":uuid,
"skuid1":"数量",
"skuid2":"数量",
"xxxx":"xxxx"
}
在上述的架构中,还有一个正式业务库,这里存储的存储的才是真正的扣减明细和SKU的汇总数据,对于正式库的数据,通过任务表的任务进行同步即可,此种方式保证了数据的最终一致性。
3.2、扣减流程
在引入了任务表之后,整体的扣减流程如下图所示:
1、首先是前置业务参数校验(包括基础参数,数量检验等)
2、开启事务
3、当开启事务后,首先将此次序列化后的扣减明细写入到扣减数据库的任务表里面。
4、假设数据库插入扣减明细失败,则事务回滚,任务表中无新增数据,数据一致,无任何影响。
5、当数据库插入扣减明细成功后,便针对缓存进行扣减,使用lua等功能扣减就行。
6、如果缓存扣减成功,也就是流程正常结束,提交数据库事务,给客户返回扣减成功。
7、如果缓存扣减失败,可能有两大类原因。
1、此次扣减数量不够
2、缓存出现故障,导致扣减失败,缓存失败的可能性有很多,比如网络不通、调用缓存扣减超时,在扣减到一半时缓存宕机。
完成上面步骤,便可以进行任务库里的数据处理了,任务库里存储的是纯文本的JSON数据,无法被直接使用,需要将其中的数据转储至实际的业务库里,业务库里会存储两类数据,一类每次扣减的流水数据,他与任务表里的数据区别在于它是结构化的,而不是json文本大字段内容,另一类是汇总数据,即每一个sku当前总共有多少量,当前还剩多少量(即从任务库同步时需要进行扣减的),表结构大致如下:
create table 流水表{
id bigint not null,
uuid bigint not null comment '扣减编号',
sku_id bigint not null comment '商品编号',
num int not null comment '当次扣减的数量'
}comment '扣减流水表'
商品的实时数据汇总表,结构如下:
create table 汇总表{
id bitint not null,
sku_id unsigned bigint not null comment '商品编号',
total_num unsigned int not null comment '总数量',
leaved_num unsigned int not null comment '当前剩余的商品数量'
}comment '记录表'
3.3、原理分析
数据库+缓存的架构主要利用了数据库顺序写入要比更新性能快的这一特性,在写入的基础上,又利用了数据库事务特性来保证数据的最终一致性,当异常出现后,通过事务进行回滚,来保证数据库的数据不会丢失。
在整体流程上,还是复用了纯缓存的架构流程,当新加入一个商品,或者对已有商品进行补货时,对应的新增商品数量都会通过Binlog同步至缓存里,在扣减时,依然以缓存中的数量为准。
3.4、性能提升
进行方案升级后,我们便完成了一个更加可靠的扣减架构,且使用任务数据库的顺序插入也保证了一定的性能,但是即使是基于数据库的顺序插入,缓存操作的性能和数据库的顺序插入也不是一个量级。
任务库主要提供两个作用,一个是事务支持 ,其次是随机的扣减流水任务的存取,这两个功能均不依赖具体的路由规则,也是随机的,无状态的。
4、如何设计和实现扣减中的返还
4.1、什么是扣减的返还
扣减的返还指的是在扣减完成之后,业务上发生了一些逆向行为,导致原先已扣减的数据需要恢复以便供后续的扣减请求使用的场景。以在购买商品时的扣减库存举例,其中常见的逆向行为有:
1、当客户下单之后,发现某个商品买错了(商品品类买错或数量填错),客户便会取消订单,此时该订单对应的所有商品的库存数量需要返还;
2、其次,假设客户在收到订单后,发现其中某一个商品质量有问题或者商品的功能和预期有差异,便会发起订单售后流程,比如退、换货。此时该订单下被退货的商品,也需要单独进行库存返还。
4.2、返还实现原则
相比扣减,返还的并发量比较低,因为下单完成后繁盛整单取消或者个别商品售后概率较低。因此,返还实现上,可以参考商家对已有商品补货的实现,直接基于数据库进行落地,但返还自身也具备以下实现原则:
-
原则一:扣减完成才能返还
返还接口设计时,必须要有扣减号这个字段。因为所有的返还都是依赖于扣减的,如果某一个商品的返还没有带上当时的扣减号,后续很难对当时的情况作出准确判断。
- 当前商品是否能够返还。 因为没有扣减号,无法找到当时的扣减明细,无法判断此商品当时是否做了扣减,没有做扣减的商品是无法进行返还的。
- 当前返还的商品数量是否超过扣减值。假设外部系统因为异常,传入了一个超过当时扣减值的数量,如果不通过扣减号获取当时的扣减明细,你无法判断此类异常。
-
原则二:一次扣减可以多次返还
-
原则三:返还的总数量要小于等于原始扣减的数量
-
原则四:返还要保证幂等
5、秒杀场景:热点扣减如何保证命中的存储分片不挂?
5.1、如何应对秒杀流量?
从秒杀的业务上进行分析,虽然秒杀带来的热点扣减请求非常大,但每次参与秒杀的商品数量有限,可能就几百个或者上千个,而热点扣减的流量可能达到上百万,通过简单的计算可以得出,秒杀到商品的概率只有0.1%,其中99%的扣减请求都是"陪跑"。
这些“陪跑”的请求对于使用这来说可能只是一次简单的点击,但很可能会把正在运行的扣减服务打挂,此时我们可以对这些瞬间量非常大的“陪跑”请求进行一些前置处理,降低“陪跑”请求的瞬间请求量,或者降低他们对于系统的冲击,此方式叫作流量消峰。
如何实现流量消峰
-
基于用户维护设置限制。
比如同一个账号在5秒内最多可以请求扣减多少次,超过多少次便进行拦截,直接返回失败信息给商品页面。
-
基于来源IP设置限制。
有些黄牛会提前预申请很多账号,因此使用上述账户限制方式并不能完全拦截住,在账户基础上,可以对用户来源IP设置限制。
-
通过设备的唯一编码等设置限制。
上述提到的拦截在实现上,可以采用比较成熟的漏桶算法,令牌桶算法。
限流在实现上有两种方式,一种是集中式,一种是单机式。集中式是指设置一个总的限流阈值,并将此值存储在一个单独的限流应用中,所有的扣减应用在接收到请求后,均采用远程请求此限流应用的方式,来判断当前是否达到限流值。单机式限流是指将限流阈值在管理端配置后,主动下发到每一台扣减应用中去。
第二步进行消峰的是,业务层面需要设置权重登记。秒杀是一种营销活动,营销是有目的的,比如激活许久未下单用户,或者优先让会员抢到商品,增加会员的续费意愿等。
第三步进行的削峰是,增加一定的过滤比例。 如果上述两个方式过滤后,热点扣减的并发量仍然较大。可以设置一个固定比例,如 10% 的请求前置过滤并直接返回失败消息,告知用户“抢购火爆,请稍后再试”,也可以降低一部分无效请求。
第四步进行的削峰是,兜底降级不可少。
最后进行的削峰是,售完的商品需前置拦截。 秒杀商品会在瞬间售完,后续所有的请求都会返回无货。对于已经无货的商品,将商品已经无货的标记记录在本地缓存里。在秒杀扣减前,先在本地缓存进行判断,如果无货直接返回即可。
5.2、水平扩展架构升级
通过上述几种限流的组合,便可以应对秒杀的热点流量了。但上述的方式会牺牲一定的用户体验,比如按一定比例过滤用户请求、按缓存分片维度过滤用户请求等。
我们可以在上述方案的基础上,做一定的升级来减少有损体验。在设置秒杀库存时,将秒杀库存按缓存分片的数量进行平均等分,每一个缓存里均存储一等份即可。比如某一个商品(记为 SKU1)的秒杀库存为 10,当前部署的缓存分片共计 10 个,那么每一个分片里存储该 SKU 的库存数可以为 1,存储在各个缓存里的 key 可以为:SKU1_1、SKU1_2、...、SKU1_10。在处理秒杀请求时,不只是固定地命中某一个缓存分片,而是在每次请求时轮询命中缓存集群中的每一个缓存分片。将秒杀商品的库存前置散列到各个缓存分片,可以将原先热点扣减只能使用一个缓存分片升级至多个,提升吞吐量。但此方式有一个弊端,就是更加的定制化。