当有很多人同时在买一件商品时(假设库存充足),每个人几乎同时下单成功,给人一种并行的感觉。
但真实情况,库存只是一个数值,无论是存在 MySQL 数据库还是 Redis 缓存,减值时都要控制顺序,只能串行来扣减,当然为了保证安全性,会设计一些锁控制操作。
主要是依赖数据库特性来保证扣减的一致性,逻辑简单,开发部署成本很低。
首先会查询当前商品的剩余库存(可能不准确,但没关系,这里只是第一步粗略校验),前置校验,如果已经没有库存,前置拦截生效,减少对数据库的写操作。
毕竟读操作不涉及加锁,并发性能高。
数据库包含两张表:库存表、流水表。
字段 | 说明 |
---|---|
sku_id | 商品id |
leaved_amount | 剩余可购买数量 |
当用户进行取消订单、申请退货退款,需要把数量加回来。如果商家补过库存,需要在此基础上额外加上增量库存。
字段 | 说明 |
---|---|
id | 主键 |
sku_id | 商品id |
order_detail_id | 订单明细id |
quantity_trade | 本次购买扣减数量 |
用于查看明细、对账、盘货、排查问题等。在扣减后,某些场景下需要返还也依赖流水。
单个商品的扣减SQL:
update inventory set leaved_amount = leaved_amount - #{count} where sku_id='123' and leaved_amount >= #{count}
此 SQL 采用数据库自带行锁机制,在 where 条件里判断此次购买的数量小于等于剩余的数量。
在扣减服务的代码里,判断此 SQL 的返回值,如果值为 1 ,表示扣减成功。否则,返回 0 ,表示库存不足,需要回滚。
扣减成功后,需要记录扣减流水,并与订单明细记录做关联:
数据库扣减方案第一次升级主要是针对库存前置校验模块的优化,作为前置拦截器,承载的流量很大,如果将流量全部压到主库上,很容易把数据压垮。我们考虑把数据库架构升级。
引入了从库,确实能分摊主库很大一部分压力,但是面对秒杀这种万级 QPS 流量,MySQL 的千级 TPS 根本支撑不了,需要进一步升级读取的性能。
优点:
不足:
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:本次购买的数量
Lua 脚本执行流程:
当 Redis 扣减成功后,应用程序再将此次扣减异步化保存到数据库中,持久化存储,毕竟 Redis 只是临时性存储,有宕机风险,会丢失数据。
风险:上述 Lua 脚本把多条命令打包在一起,虽然保证了原子性,但不具备事务回滚特性。
比如,库存扣减成功了,此时 Redis 宕机,扣减流水并没有插入成功,应用程序认为本次 Redis 调用是失败的,前台给用户反馈错误提示,但是已经扣减的数量不会回滚。
当 Redis 故障修复后,再次启动,此时恢复的数据已经存在不一致了。需要结合 Redis 和数据库做数据核对 check,并结合扣减服务的日志,做数据的增量修复。
除了纯缓存化方案外,我们还可以考虑将库存表进行水平拆分,分摊洪峰压力。
假如库存表的 QPS 要求是 1.6 万,经过拆分成 16 张表后,如果数据分布均匀,每个物理表预计处理 1000 QPS,完全处于 MySQL 单实例的承载范围之内。
另外拆分后,单表的数据量也会相应减少很多,假如分表前有一个亿数据,分表后每张表不到 1 千万,索引查询性能也会快很多。
注意:同一次扣减业务,库存扣减和插入流水要放在同一个分库中,通过事务保证一致性,满足同时成功或同时失败。
如果数据分布和业务请求足够均匀,理论上经过分库分表设计后,整个系统的吞吐量将会是线性的增长,主要取决于分表实例的数量。
如果某个 sku_id 的库存扣减过热,单台实例支撑不了(MySQL 官方测评:一般单行更新的 QPS 在 500 以内),可以考虑将一个商品的大库存拆分成 N 份,放在不同的库中(也就是说所有子库的库存数总和才是一件商品的真实库存),由于前台的访问流量非常大,按照均分原则,每个子库分到的流量应该差不多。
上层路由时只需要在 sku_id 后面拼接一个范围内的随机数,即可找到对应的子库,有效减轻系统压力。
单条商品库存记录更新过热,也可以采用批量提交方式,将多次扣减累计计数,集中成一次扣减,从而实现了将串行处理变成了批处理,也可以大大减轻数据库压力。
引入 RocketMQ 消息队列,经过前置校验后,如果有剩余库存,则把创建订单的操作封装成消息发送给 MQ,订单系统从 RocketMQ 中以特定的频率消费,创建订单,该方案有一定的延迟性。