万级并发!电商库存扣减如何设计,如何做到不超卖?( 二 )


 
采用了 读写分离 方式,新增加了一套从库,借助mysql自带的数据同步能力 。 库存校验 时读取从数据库 。
当然,数据同步有一定的时间延迟,从库的数据新鲜度有一定的滞后性,所以这个 库存校验 结果并不一定准确,但却能拦截大部分的 无效流量  。最终能不能成功购买,由主库的 乐观扣减SQL来控制,并不会影响最终扣减的准确性 。大大减轻主库的查询压力 。
palm_tree: 【数据库扣减方案】第二次升级引入了从库,确实能分摊主库很大一部分压力,但是面对秒杀这种万级QPS流量,mysql的 千级TPS 根本支撑不了,需要进一步升级读取的性能 。

万级并发!电商库存扣减如何设计,如何做到不超卖?

文章插图
 
  • 此时引入缓存中间件(如Redis),将mysql的数据定时同步到缓存中
  • 库存校验 模块,从redis中查询剩余的库存数据 。由于缓存基于内存操作,性能比数据库高出几个数量级,单台redis实例可以达到10W QPS的读性能
该方案升级后,基本上解决了在前置 库存校验 环节及 获取库存数量接口 的性能问题,提高了系统整体性能,提供较好的用户体验 。
补充说明:如果并发量还是很高的话,可以考虑引入 缓存集群 ,将不同的 秒杀商品sku 尽量均匀分布在多个redis节点中,从而分摊掉整体的峰值QPS压力 。(参考缓存热点的解决方案)
数据库方案的优点:
  • ACID 超卖 少买
  • 实现简单,如果项目工期紧张,或者开发资源不足情况下非常适用
数据库方案的不足:
  • 如果参与秒杀的SKU非常多,最后的写操作都是基于 库存主库 ,性能压力会比较大 。
palm_tree: 纯缓存扣减方案Redis采用单线程的事件模型,具有 原子性 的特性 。当有多个客户端给Redis发送命令时,Redis会按照接收到的顺序 串行化 执行 。对于还未被调度的命令,则放在队列里 排队等待  。
库存扣减为了保证数据并发安全,要求原子性,而 Redis 正好满足扣减类的特殊性要求,是个不错的技术选型 。
下面,我们简单来看看基于 Redis 如何来设计库存扣减?
万级并发!电商库存扣减如何设计,如何做到不超卖?

文章插图
 
首先,设计Redis的数据模型:剩余库存(k-v结构):key:sku_leaved_amount_{sku_id}value:剩余的库存数值流水(hash结构):key:inventory_flow_{sku_id}hash—key:订单明细id(不同业务场景的全局性id,用来做幂等控制)hash—value:本次购买的数量对于购物车下单,多个sku批量扣减,我们需要按单个sku循环发起Redis调用 。但是多个Redis命令无法保证原子性 。我们可以采用 lua脚本 形式,将这些命令打包到一个脚本中,作为一个命令发送给Redis执行,从而保证了原子性 。
lua 是一个类似 JAVAScript、Shell 等的解释性语言,它可以完成 Redis 已有命令不支持的功能 。用户在编写完 lua 脚本之后,将此脚本上传至 Redis 服务端,服务端会返回一个标识码代表此脚本 。在实际执行具体请求时,将数据和此标识码发送至 Redis 即可 。Redis 会和执行普通命令一样,采用单线程执行此 lua 脚本和对应数据 。
Lua 脚本执行流程:批量扣减是对单个扣减的循环调用,所以这里介绍的流程只讲单次扣减的处理步骤 。
  1. 首先根据 订单明细id 查询扣减流水,是否已经操作过,做幂等性校验
  2. 然后查询sku的剩余库存,并根据 下单购买数 做校验,只要有一个sku 数量不足,则返回失败
  3. 修改所有sku的缓存中的剩余库存数
  4. 缓存中插入扣减流水记录
当Redis扣减成功后,应用程序再将此次扣减 异步化 保存到数据库中,持久化存储,毕竟Redis只是临时性存储,有宕机风险,会丢失数据 。
缓存方案利弊分析:
  • Redis 高性能 ACID 少卖
  • 为了避免 少卖 情况发生, 纯缓存方案 需要做大量的对账、异常处理的设计,系统复杂度增加很多 。
  • 纯缓存方案 适合一些高并发、大流量场景,但对数据准确度要求不是特别苛刻的业务场景 。
风险:上述 Lua脚本 把多条命令打包在一起,虽然保证了原子性,但不具备 事务回滚 特性 。比如,库存扣减成功了,此时 Redis宕机 ,扣减流水并没有插入成功,应用程序认为本次 Redis调用 是失败 的,前台给用户反馈错误提示,但是已经扣减的数量不会回滚 。当Redis故障修复后,再次启动,此时恢复的数据已经存在不一致了 。需要结合 Redis 和 数据库 做数据核对check,并结合扣减服务的日志,做数据的增量修复 。


推荐阅读