高并发下订单库存防止超卖策略

打个广告

我的架构设计专栏:https://www.nowcoder.com/creation/manager/columnDetail/0ybvLm

我的java八股专栏:https://www.nowcoder.com/creation/manager/columnDetail/j8ZZk0

八股专栏内有详细苍穹外卖话术哦!

欢迎大家订阅!

0.什么是超卖问题?

在并发的场景下,比如商城售卖商品中,一件商品的销售数量>库存数量的问题,称为超卖问题。主要原因是在并发场景下,请求几乎同时到达,对库存资源进行竞争,由于没有适当的并发控制策略导致的错误。

例如简单的下单操作,通常我们会按照如下写法

public ServerResponse createOrder(Integer userId, Integer shippingId){
    // 执行查询sql select amount form store where postID = 12345;
    // 判断是否大于0然后执行更新操作 update store set amount = amount - quantity where postID = 12345;
}

由于如上的写法在应用层没有任何并发控制,如果 postID 为12345的商品库存为1件,此时有两个请求到达,先后执行了查询sql,则通过MySQL读取库存时,会加共享锁,因此都能获取到商品库存为1件,然后又分别执行更新操作,MySQL会将两个更新操作串行化执行,依次成功减库存,因此库存数量变成-1。

1.悲观锁(并发很高的场景不适用)

悲观锁主要用于保护数据的完整性。当多个事务并发执行时,某个事务对数据应用加锁,则其他事务只能等该事务执行完了,才能进行对该数据进行修改操作。

update goods set num = num - 1 WHERE id = 1001 and num > 0

假设现在商品只剩下一件了,此时数据库中 num = 1;

但有 100 个线程同时读取到了这个 num = 1,所以 100 个线程都开始减库存了。

但你最终会发觉,其实只有一个线程减库存成功,其他 99 个线程全部失败。update操作会自动加排它锁

需要注意的是,FOR UPDATE 生效需要同时满足两个条件时才生效:

  • 数据库的引擎为 innoDB
  • 操作位于事务块中(BEGIN/COMMIT)

悲观锁采用的是「先获取锁再访问」的策略,来保障数据的安全。但是加锁策略,依赖数据库实现,会增加数据库的负担,对于并发很高的场景并不会使用悲观锁,会导致其他事务都会发生阻塞,造成大量的事务发生积压拖垮整个系统。

2.乐观锁

在商品表中增加一个版本号字段,在进行更新操作之前会去比较此时的版本号和刚开始操作的时候的版本号是不是一致,如果是一致的那么就可以让这个更新操作正常执行,版本号+1。如果此时的版本号不一致了,那么就无法进行更新操作了。

select version from goods WHERE id= 1001

update goods set num = num - 1, version = version + 1 WHERE id= 1001 AND num > 0 AND version = @version(上面查到的version);

假设此时 version = 100, num = 1; 100 个线程进入到了这里,同时他们 select 出来版本号都是 version = 100。

然后直接 update 的时候,只有其中一个先 update 了,同时更新了版本号。

那么其他 99 个在更新的时候,会发觉 version 并不等于上次 select 的 version,就说明 version 被其他线程修改过了。那么我就放弃这次 update。

使用乐观锁需修改数据库的事务隔离级别:

使用乐观锁的时候,如果一个事务修改了库存并提交了事务,那其他的事务应该可以读取到修改后的数据值,所以不能使用可重复读的隔离级别,应该修改为读取已提交(Read committed)

缺点:虽然防止了超卖,但是会导致很多线程更新库存失败了,所以在我们这种设计下,1000个人真要是同时发起购买,可能只有100个幸运儿能够买到东西。

3.Redis原子操作(Redis incr)+乐观锁+lua脚本(推荐)

利用Redis increment 的原子操作,保证库存数安全

  1. 先查询redis缓存中是否有库存信息这样就可以减少访问数据库的次数。 获取到后把数值填入redis,以商品id为key,数量为value。 还需要设置redis对应这个key的超时时间,以防所有商品库存数据都在redis中。

剩余60%内容,订阅专栏后可继续查看/也可单篇购买

内容包含: 1.后端八股大全:多一句没有少一句不行的最精简八股整理,完全可以应付校招八股拷打! 2.速成项目话术:目前有魔改苍穹外卖项目话术(额外扩展了很多技术亮点),能速成拿去面试,后面会更新agent开发等等热门高质量项目话术 3.智力题超详细题解汇总; 4.面试时非技术问题话术整理,绝对震惊面试官一年; 5.算法lc hot100全题系列题解:绝对通俗易懂。 欢迎订阅!

全部评论

相关推荐

03-31 00:30
已编辑
门头沟学院 golang
无手撕,被面试官暴打一小时,第一次被拷打 go 相关的内容,被拷打得我想找个地缝钻进去,😭我是菜🐶,我的腾讯面试之旅结束了 qwq。1. go routine 是啥怎么调度的。2. 如果 gmp 中 p 的分配是怎么分配的(go语言 runtime系统进行内存管理,调度器还有系统调度封装)3.向一个 nil channel发送读取消息会怎么样?4.向一个close的channel接发消息会有什么结果?5.slice是引用还是还是拷贝(我最开始很干脆地说是引用,结果面试官一副匪夷所思的语气:你确定?我就跟他说是拷贝,被戏耍麻了😭,只能怪自己基础不牢)6.内存逃逸是什么,简单介绍一下。7.接口的底层实现,接口的类型。8.闭包求输出:funcs := []func(){}for i := 0; i < 3; i++ {funcs = append(funcs, func() {fmt.Println(i)})}for _, f := range funcs {f()}其实这个答案根据 go 的不同版本,输出结果不同。在 1.22 之前,输出是三个 3, 因为循环内的 i 实际上地址是指向同一处的。但是在 1.22 及之后,结果是 0, 1, 2, 如果这个时候你打印一些 i 的地址,你会发现地址是不相同的。因为 go 在 1.22 更新了 for 循环(包括 range)的方式,循环内的临时变量 i 和 range 中取出的数据都是完全独立的一份拷贝,而且指向的地址不相同!9.Go GC 机制讲解一下,什么是写屏障,如果没有写屏障,可能会有什么情况?10.让我们来聊聊你的项目吧,来看看你的项目,啊哈!~达人探店项目,高并发店铺点评博客项目",介绍一下你的项目,你如何实现异步秒杀的?如何防止超卖,如何实现一人一单,如何使用分布式锁,lua 脚本是怎么写的?怎么实现消息不丢失的?压测怎么做的?有没有使用过阿波罗普罗米修斯宙斯哈迪斯盖亚(已晕qwq)等压测工具,有没有压测过你的各个缓节的时间,你的性能瓶颈在哪里?Redis 如果 set 数据量较大的情况下如何进行大 key 值拆分,你的 Redis 是单机还是主从?你的 kafka 有几个节点,划分了几个 partition?有几个消费者节点,你的项目有几个服务?11. 有什么经常用的 AI coding 的工具?有 Agent 开发相关的经历吗?哦?你还了解过 Agent 开发? MCP、RAG#@$*^~你能展开讲讲吗?12. 反问:请教之前空接口,受教了。给了本菜🐕一点建议:go 的基础太不牢了,作为主要的开发语言来说是不够格的。期间面试官一直没开视频,但我隐隐听到他在偷笑
点赞 评论 收藏
分享
评论
8
30
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务