高并发场景下如何实现库存扣减优化方案
时间:2026-08-15 | 作者:半糖攻略君 | 阅读:0在高并发情况下,正确扣减库存是一个非常有代表性的技术实现。
只要对这种场景有足够深入的理解,就可以更轻松地应对其它并发处理问题,因此很有必要深入掌握。
1. 从一段代码开始
在rails种,库存的扣减可以通过下面的代码实现:
ActiveRecord::Base.transaction do
product = Product.lock.find_by(id: product_id)
new_stock_num = product.stock_num - quantity
if new_stock_num >= 0
product.update!(stock_num: new_stock_num)
else
raise "库存不足"
end
end
我们知道,上面的代码可以保证并发情况下正确扣减库存。
那么问题来了:为什么它可以做到这一点?
下面一步步分析。
2. 加锁的理解
product = Product.lock.find_by(id: product_id)该如何理解?
这行代码会生成如下sql语句:
select * from products where id = 2 for update;
这行sql语句是sql中的排它锁。
它到底排斥的是什么?可以这样理解:
- 它允许读操作,排斥写操作
- 因此在加锁后,如果执行读
select * from products where id = 2,可以正常读到,不会阻塞 - 但是写操作(更新/删除),比如
update products set stock_num = 3 where id = 2会发生阻塞;当然,再次加锁——获取锁也会阻塞
3. 实践排它锁
上面说的是锁的理论,但还需要实践验证。
如果你本地已经有一个rails项目,并且已经有现成的数据库,那么可以执行rails db两次,分别开启两个连接到数据库的session,然后执行sql看看。
第一次尝试
session1先执行:
select * from products where id = 2 for update;
然后在session2执行:
update products set stock_num = 3 where id = 2;
如果你真的按上面的步骤执行,会发现:它没有阻塞,而是直接更新成功了。
第二次尝试
下面我们改变策略,从头再来一次。
session1先执行:
begin transaction;
select * from products where id = 2 for update;
然后session2执行:
update products set stock_num = 3 where id = 2;
// 此时回车后如预期一样阻塞了
虽然我们现在还不知道,为什么加了begin transaction后阻塞才生效。
但至少可以看到,此时结果已经和预期一致了。
4. 显示事务和隐式事务
前面已经实践出来了,但还没有深入解释:为什么必须加begin transaction才会和预期一致?
如果你去看数据库相关文档,会发现这样一个点:
默认情况下,我们发送给数据库的任何sql语句,数据库收到后,实际上都会把它放到一个事务中执行。
比如执行下面这条sql语句:
select * from products where id = 2;
数据库收到后,它的实际执行过程相当于:
begin transaction;
select * from products where id = 2; // 放在事务中
commit;
到这里你应该明白了。
前面必须加上begin transaction,是因为如果不加,数据库会默认放进一个隐式事务中。语句执行完后,事务立即提交,锁也随之释放。
所以,如果不显式开启事务,那么锁的效果在语句结束后就消失了,自然不会继续拦住后续写操作。
我们把事务明确写出来的方式称为——显示事务。
5. 串起来理解
结合前面的分析,这句product = Product.lock.find_by(id: product_id)的作用就很清楚了。
它是给这条 product 记录加上一把排它锁。
目的就是拦住其他线程。放到数据库语境里,就是拦住其他事务继续对这条记录发起写操作,比如更新或删除。
当这条product被加锁后,必须等到锁释放,其它线程(数据库其它事务)才能继续执行更新、删除或获取锁。
而这里的代码本身也是在获取锁,所以其它线程执行到这里时,如果锁已被别的线程拿到,就会阻塞在这里,等待锁释放。
那为什么一定要把product = Product.lock.find_by(id: product_id)放进事务里?
原因并不复杂:需要确保这把锁不会在查询完成后立刻释放,因为后面还要继续执行更新库存的操作。
也正因为如此,这里必须使用显示事务。
否则,就会出现前面“实践排它锁”中不加begin transaction时的问题——锁没有作用。
这也解释了为什么代码里常常会看到:加锁操作总是和事务一起出现。
6. 并发情况下分析
线程A
ActiveRecord::Base.transaction do
# 线程A锁定了产品ID为123的库存项
product = Product.lock.find_by(id: product_id)
# 执行一些操作...(此时其他线程试图锁定这一行将会被阻塞)
new_stock_num = product.stock_num - quantity
if new_stock_num >= 0
product.update!(stock_num: new_stock_num)
else
raise "库存不足"
end
# 线程A完成了对库存的修改
end
# 线程A的事务结束,锁被释放
线程B
ActiveRecord::Base.transaction do
# # 线程B尝试锁定产品ID为123的库存项,但由于线程A已经锁定,这里将会等待
product = Product.lock.find_by(id: product_id)
# 如果线程A的锁还没有释放,线程B将会在这里等待
# 一旦线程A的事务结束,线程B将继续执行
new_stock_num = product.stock_num - quantity
if new_stock_num >= 0
product.update!(stock_num: new_stock_num)
else
raise "库存不足"
end
# 线程A完成了对库存的修改
end
# 线程A的事务结束,锁被释放
从这个过程可以看出:
- 线程A先获取锁并完成库存扣减
- 线程B在获取锁时会被阻塞
- 等线程A事务结束、锁释放后,线程B再继续执行
因此,并发情况下不会出现多个线程同时读取同一库存值,再各自覆盖更新结果的问题。
7. 对事务的理解
使用场景
- 保证一致性(涉及到多个操作,比如A账户执行扣款、B账户加款,必须同时成功会失败)
- 并发情况锁(与锁搭配使用,保证并发情况下数据的正确性)
使用注意事项
- 只对必要的业务场景使用事务
- 事务要尽可能的短,减少锁定时长
8. 乐观锁实现方式
前面我们是通过悲观锁实现的,实际上还有其它方式可以实现,这里讨论悲观锁。
下面说下乐观锁的原理:
乐观锁相信不会发生冲突(并发),因此不做加锁操作。
它只是在更新时才去检测,当前版本和此前版本是否一致。
如果不一致,说明此前已经有并发(其它人已提前做了更新)。
那么在rails中如何实现乐观锁呢?
- 表添加lock_version字段
这个字段是rails钦定的御用字段,当然如果你想换一个也是可以的,只是这就不符合约定大于配置了。
class AddLockVersionToModel < ActiveRecord::Migration[5.0]
def change
add_column :model_name, :lock_version, :integer, default: 0
end
end
- 每次前端页面操作时候,会获取到一个数据的当前版本号,提交表单时候带上这个版本号lock_version字段
<%= form_with(model: @model, local: true) do |form| %>
<%= form.hidden_field :lock_version %>
<% end %>
- 在数据更新是同时带上更新的业务字段和lock_version这个字段,rails自动去检测版本是否有更新,当前版本的变动也是rails自动处理,我们不必关心。
如果版本发生变动,则抛出异常ActiveRecord::StaleObjectError
# 可以是某个action中
begin
if @model.update(model_params) # model_params中包含lock_version字段
# 更新成功
else
render :edit
end
rescue ActiveRecord::StaleObjectError
# 做些提示操作
end
9. sql原子更新方式
前面我们已经有了乐关锁、悲关锁的实现,其实在还有一种方式,就是利用数据库的原子操作。
在悲观锁实现中,我们之所以要加锁,是因为担心并发情况下,多个线程同时获取到当前库存数量。
然后它们都根据这个库存数量,分别计算出扣减后的库存数量,最终导致库存不准确。
这里的问题在于,整个过程被拆成了两步:
- 查到对应的商品,拿到当前库存数量
- 根据当前库存数量计算出一个,需要更新的库存数量,然后做更新
它们是非原子操作,所以会有并发问题。
如果能把这两步合并成一个原子操作,就能解决问题。
那怎么实现呢?
在更新库存的时候,同时完成库存是否足够的判断,以及更新后库存值的计算即可。
rails实现代码如下:
result_rows = Product.where(id: 2).where("stock_num >=", quantity). # 保证了库存是够的
.update_all("stock_num = (stock_num - #{quantity})") # 同时做获取库存、计算新库存、更新库存 保证原子
if results_rows == 0
# 库存不足 或者更新失败
else
# 库存扣减成功
end
10. 三种实现方式比较
-
乐观锁
适合并发不高的情况,缺点是需要自己实现版本的比较和发生冲突的比较操作,稍显复杂 -
悲观锁
和乐关锁对应,适合并发高的情况,比较暴力,先加锁再操作,实现简单,但性能不好 -
sql原子
通过原子方式实现,个人认为是最简单的方法,性能也好的方式
免责声明:文中图文均来自网络,如有侵权请联系删除,心愿游戏发布此文仅为传递信息,不代表心愿游戏认同其观点或证实其描述。
相关文章
更多-
- 迅捷路由器怎么调信号最强,设置时要注意什么?
- 时间:2026-08-27
-
- vivo浏览器怎么卸不掉?原因和解决方法在这里
- 时间:2026-08-27
-
- OPPO R11s黑屏了,怎么强制恢复出厂设置?
- 时间:2026-08-27
-
- 飞利浦显示器包装盒有生产日期和保修期吗?怎么看?
- 时间:2026-08-27
-
- 联想新平板开机必须联网吗?怎么做?
- 时间:2026-08-27
-
- 平板横竖屏切换设置与问题解决
- 时间:2026-08-27
-
- 移动电源容量怎么测?要准备哪些工具?
- 时间:2026-08-27
-
- 荣耀90 Pro防水吗?防水级别多少?怎么用才安全
- 时间:2026-08-27
精选合集
更多大家都在玩
大家都在看
更多-
- 糖尿病完全不能吃糖吗
- 时间:2026-09-15
-
- 蚂蚁庄园小课堂2026年9月16日最新题目答案
- 时间:2026-09-15
-
- 小鸡答题今天的答案是什么2026年9月16日
- 时间:2026-09-15
-
- 蚂蚁庄园每日答题答案2026年9月16日
- 时间:2026-09-15
-
- 以下哪种粮食是酿造绍兴黄酒的主要原料 蚂蚁庄园今日答案9月16日
- 时间:2026-09-15
-
- 劝学名句“及时当勉励,岁月不待人”出自哪位诗人 蚂蚁庄园今日答案9.16
- 时间:2026-09-15
-
- 蚂蚁庄园今天答题答案2026年9月16日
- 时间:2026-09-15
-
- 蚂蚁庄园答题今日答案2026年9月16日
- 时间:2026-09-15
