一个redis库存问题发愁了三天,过年还在想,最后发现自己想复杂了

大家好,我是五阳,最近我找了出版社的编辑和几位大佬聊了一件事------------出本关于电商业务架构的书,分享一下 商品、营销、订单、会员、支付各细分业务系统的建设经验。最后因法律风险这个计划将长期搁置。为什么出本书会涉及法律问题呢?

因为业务系统架构不可避免的要说明实际的业务背景和痛点,这一定会涉及公司的商业机密。即使做了大量脱敏工作,也无法保证公司不找茬。这也很容易理解,你凭什么把公司的商业机密和业务背景作为代价,去宣传、出书和赚钱呢?

换句话说现有的出版物中很少涉及业务背景和业务逻辑,这类被阉割的内容千篇一律大同小异。之所以你看完了,觉得没学到啥,因为本来就没啥干货。真正有价值的内容,大家不敢在书里发布。

所以接下来,我将继续在掘金认真分享在互联网大厂的业务系统建设经验,分享更多的干货和经验,也尽可能做到业务脱敏,不损害公司的利益。 大家可以关注我,随时看到不注水的经验分享。言归正传。

库存系统的一个痛点

库存系统使用 MySQL做存储能满足一般场景的写入性能,无需牺牲一致性和复杂度而使用 Redis,例如 TPS 低于 500 的业务,MySQL 应付如此量级的写入是绰绰有余的。

然而电商库存业务的查询量往往远大于写入数量,例如写入TPS在 500 的业务,QPS 查询数量级往往可以达到 3000,例如访问量极高的商品详情页面会展示商品库存,这将带来极高的查询并发,也给数据库带来极大的压力。

使用MySQL数据库应对如此高数量级的查询是很危险的,即使勉强接受,也给本就脆弱的系统稳定性埋下了一颗超长保质期的地雷,一旦库存查询出问题,将立即影响商品展示,进而阻碍用户下单,最终导致后果十分严重的的线上故障。虽不足以要命,但足以消灭一个团队的年终奖。

将商品的库存数据从 MySQL异构到 Redis 中,是解决库存查询高并发的必然方案。

五阳长期从事电商业务开发,对于库存系统建设较为熟悉,可以点此了解库存系统如何建设! 可以关注我,随时可以看到我的实战经验分享。

库存一般是指商品的库存,但是扩展来看,活动库存、渠道库存等等也是有库存的,例如某个营销活动有资金预算,活动只有 10000 个名额,命中此营销活动订单的数量不得超过 10000,此时就需要活动库存能力,如果库存系统扩展一个目标类型,就可以同时支持商品库存、活动库存、渠道库存等等,极大提高了扩展性和适用场景。

所以库存在 Redis 的结构包含:目标类型、目标 id和库存数量。

即 Key: 目标类型_目标 id

Value: 库存数量

可以使用 incrBy decrBy 操作库存数量,使用 get获取库存数量。

当前问题在于:如何尽可能保证数据库和 Redis 中的数据一致性?

例如我按照扣减数量进行同步,例如刚刚库存扣减成功 2 个,则同时 Redis 执行 decrBy 操作,如何保证这个过程数据的准确性呢? Redis 操作可能会超时,超时了是否应该重试呢?不重试可能导致漏扣减 Redis 库存,重试可能导致多扣减 Redis 库存,这是一个问题。

加减法扣减 Redis 库存

使用加减法扣减 Redis 库存的时机在 MySQL 库存被更新后。一般情况下各个公司都有 MySQL binlog 的订阅消费能力,所以通过监听库存表的binlog 变更即可。当出现一次更新时,获取当前扣减的数量,则扣减相应的 Redis 库存。

这个方案要解决的问题有两个。其一是重试的幂等性,其二是长期的库存不一致问题。

先说第一个,当 Redis 扣减出现超时,超时重试的时候如何保证幂等性呢?应该在 incrBy 时,同时新增一个幂等记录,例如订单 id。

更新库存时同步新增幂等记录

在操作之前,先查询幂等记录是否存在,如果存在,则不进行更新。如果不存在幂等记录,则使用 pipeline 同步更新库存和新增幂等记录。

sequenceDiagram Flow-> Redis: 查询幂等记录是否存在? Flow-> Flow: 不存在,则使用 redis pipeline 同步扣减库存和新增记录 Flow->Flow: 存在,则说明已更新成功,跳过这条

这个过程共有两次 Redis 操作。也可以使用更复杂的 Lua脚本方案,将以上操作统一放在 lua 脚本中执行。

但是要注意一个问题: pipeline 和 lua 脚本都只能使用单机版的 redis,不能使用 Redis 集群模式。因为 Redis 集群模式将全量数据 hash 到多个子节点, pipeline 和 lua 中操作的两个 key 无法保证在同一个节点上,也就无法保证操作的原子性。

设置合理过期时间

因为幂等记录只是为了保证超时重试带来的一致性问题,不需要永久保留在 Redis 中,所以应该设置超时时间,例如 1 个小时。这最大程度减少了 Redis 内存占用。

长期的库存不一致问题。

使用加减法扣减库存,无法保证长期上完全一致。虽然引入幂等记录解决了超时重试带来的一致性问题,但是系统运行的长期时间里,难以保证两者是完全一致的。在某些异常场景,例如binlog 消息存在丢失,重复消费(间隔很短时)导致的重复扣减,系统宕机等等预料之外的问题,都有可能导致两者的数据不一致。 使用加减法扣减的方式,无法保证系统长期能一致,如果要做到这一点,就需要额外的校正极值,定期校正两者是否一致。

定期校正 Redis和 MySQL 的一致性

最简单的方案是在系统的业务低峰期执行一个定时任务,对全量库存进行扫描和处理。例如,通过查询数据库和Redis中的库存A是否一致来判断Redis库存是否准确,若不一致,则强制更新Redis库存。

然而,这个方案存在一个痛点,即是否存在绝对的业务低峰期。理论上,在查询Redis和MySQL库存并更新正确库存的窗口期中,可能会发生库存变更,从而导致数据不准确。

不过,这种情况发生的概率较低。通常情况下,业务低峰期的QPS不超过个位数,甚至无法达到个位数,因此很难出现上述问题。如果非要完全避免这种情况,也有办法。例如,在查询数据库库存时带上当前的版本号,若出现库存不一致,更新Redis库存后,再次查询当前MySQL库存的版本号,若版本号不一致,则说明在这个窗口期间库存已被修改,两者可能仍处于不一致状态。此时可以再次尝试同步两者的库存。通过引入版本号比较和重试,将此问题发生的概率降至最低。

我自己不喜欢使用加减法扣减的方案,这个方案要更复杂。同步扣减法就简单多了!

同步更新法扣减

同步扣减法和加减法扣减 有类似的地方,即借助于 MySQL binlog,通过消费 binlog 触发数据同步,这是当前业界较为通用的方案。基本上使用 MySQL 的各大互联网公司都这么做数据异构和数据同步。 如果公司内部没有 binlog 的相关基建能力,可以使用 Kafka消息,即库存扣减成功后,异步或同步发送一条 Kafka 消息,在消费逻辑中做数据异构工作。

同步扣减法即每次库存更新都 使用 set 命令覆盖 Redis 库存,不管是扣减库存还是回滚库存,统统使用 set 命令强制更新。

这种方式有一个好处,不用担心幂等问题。因为 Redis set 超时后,可以直接重试。不同于 incrBy 操作,执行多次会有不同的结果,set 命令不需要担心重试和幂等问题。

但是 同步更新法也有一致性问题,因为在库存扣减并发度非常高的时候,很难保证扣减的顺序性。

顺序消费带来的一致性问题

举个例子库存 A 的当前库存是 5,经过五次扣减后,分别变为 4,3,2,1,0。 很难保证这五条 binlog是顺序投递到 Kafka,也无法保证 Kafka 能顺序消费 5 个消息。如果没有顺序消费,5 次扣减完成后,Redis 最终的库存数据可能不是 0,而是其他 4 个值。

库存实际上已经没有了,Redis 库存还是非零值,用户能看到有库存,但是无法购买成功,这种用户体验很差。

所以要解决顺序扣减库存的难题。

生产端到消费端保证顺序

如果可以保证库存更新的 binlog 从发送到 Kafka 到消费能完全顺序,就可以保证Redis 库存更新也是有序的。可以通过创建一个分片,保证 Kafka 消费时顺序消费。然而如何保证 binlog 到 Kafka 是有序的呢? 这比较难,我们无法苛求 binlog 消费中间件(例如canal)顺序投递到 Kafka。

要想完全的顺序生产和消费是非常困难的,所以一般情况下我们采用方案 2,即版本号机制。

版本号控制顺序消费

一般情况下 mysql 库存更新的 SQL 会同步新增版本号,例如下面这个 SQL。

update inventory set cnt = cnt + #{buyCnt}, version=version+1 WHERE productId = #{productId1} AND cnt + #{buyCnt} <= totalCnt

这样每条 binlog 都带有对应的版本号,当更新库存时,先尝试检查当前版本号是否落后于 Redis 版本号,如果落后,那么说明无需更新,如果超前,则尝试更新 Redis 库存值和版本号。

这个过程是先检查再更新,需要保证一致性,可以使用 Lua 脚本。版本号和库存值需要存在 Redis 中,可以使用 redis hash 结构存储两个子 key。

接下来请看 lua 脚本

ini 复制代码
if(redis.call('exists', KEYS[1]) == false) then
    redis.call('HSET', KEYS[1], 'version', KEYS[3]);

    redis.call('HSET', KEYS[1], 'val', KEYS[2]);
    return 1;
end;


local redisVersion = redis.call('HGET', KEYS[1] ,'version');
local currVersion = KEYS[3];

if(redisVersion == false or currVersion > redisVersion) then
    redis.call('HSET', KEYS[1], 'version', KEYS[3]);

    redis.call('HSET', KEYS[1], 'val', KEYS[2]);

    return 1;
end;

if(currVersion== redisVersion) then
    return 0;

end;

return -1;

入参为 Key 库存值 版本号。例如如下的命令,eval xxxx 代表执行哪一个脚本,3 代表参数个数,总共 3 个。3_110 为商品库存的 key,4 为库存值, 1 为版本号。

evalsha ee67de65b07b8124e14db5a0c6e03440c705194e 3 1_110 4 1

返回值 1 代表 更新库存成功;返回值为 0 代表重复设置;返回值为-1为非法制,代表更新历史库存。

解释下上述代码,1-6 行 判断库存 key 是否存在,不存在则立即更新。

如果存在,则校验 redis 版本号和当前传参的版本号,如果超过 redis 版本号,则立即更新库存。如果落后于 redis 库存,则不更新,返回-1 非法值。

比较一下两个方案,两者孰优孰劣主要在于方案的一致性和复杂度。两者的优势基本相同即能保证高性能的库存查询能力!但缺陷不相同,解决缺陷的复杂度也不同,这最终导致 同步更新法的复杂度更低,更加易于实现。

方案 加减法扣减 同步更新法
缺点 需要保证重试和幂等、无法保证长期一致 难以保证同时修改版本号和库存值的原子性
补充方案 新增幂等记录保证幂等 2、定时任务保证长期一致 使用 lua 脚本保证同时成功和失败

总结

  • 在库存场景,查询的数量级要远高于写入的数量级。
  • 库存查询能力基于 Redis 实现可以提供更强的查询性能,速度更快,扛并发能力更强
  • 将MySQL数据库 库存同步到 Redis 中,可以使用同步更新法,即每当库存更新后,即将新库存值和版本号更新到 Redis 中,使用版本号和lua脚本控制顺序,保证Redis库存值是最新的,而不是历史值。
相关推荐
齐 飞13 分钟前
MongoDB笔记01-概念与安装
前端·数据库·笔记·后端·mongodb
LunarCod30 分钟前
WorkFlow源码剖析——Communicator之TCPServer(中)
后端·workflow·c/c++·网络框架·源码剖析·高性能高并发
码农派大星。1 小时前
Spring Boot 配置文件
java·spring boot·后端
杜杜的man2 小时前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*2 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
llllinuuu2 小时前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s2 小时前
Golang--协程和管道
开发语言·后端·golang
为什么这亚子2 小时前
九、Go语言快速入门之map
运维·开发语言·后端·算法·云原生·golang·云计算
想进大厂的小王2 小时前
项目架构介绍以及Spring cloud、redis、mq 等组件的基本认识
redis·分布式·后端·spring cloud·微服务·架构
customer083 小时前
【开源免费】基于SpringBoot+Vue.JS医院管理系统(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·开源·intellij-idea