位置:首页 > Ruby > 高并发场景下如何实现库存扣减优化方案

高并发场景下如何实现库存扣减优化方案

时间:2026-08-15  |  作者:半糖攻略君  |  阅读:0

在高并发情况下,正确扣减库存是一个非常有代表性的技术实现。

只要对这种场景有足够深入的理解,就可以更轻松地应对其它并发处理问题,因此很有必要深入掌握。

如何实现高并场景下的库存扣减
            <!----></a> <!---->

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. 对事务的理解

使用场景

  1. 保证一致性(涉及到多个操作,比如A账户执行扣款、B账户加款,必须同时成功会失败)
  2. 并发情况锁(与锁搭配使用,保证并发情况下数据的正确性)

使用注意事项

  1. 只对必要的业务场景使用事务
  2. 事务要尽可能的短,减少锁定时长

8. 乐观锁实现方式

前面我们是通过悲观锁实现的,实际上还有其它方式可以实现,这里讨论悲观锁。

下面说下乐观锁的原理:

乐观锁相信不会发生冲突(并发),因此不做加锁操作。

它只是在更新时才去检测,当前版本和此前版本是否一致。

如果不一致,说明此前已经有并发(其它人已提前做了更新)。

那么在rails中如何实现乐观锁呢?

  1. 表添加lock_version字段
    这个字段是rails钦定的御用字段,当然如果你想换一个也是可以的,只是这就不符合约定大于配置了。
class AddLockVersionToModel < ActiveRecord::Migration[5.0]
  def change
    add_column :model_name, :lock_version, :integer, default: 0
  end
end
  1. 每次前端页面操作时候,会获取到一个数据的当前版本号,提交表单时候带上这个版本号lock_version字段
<%= form_with(model: @model, local: true) do |form| %>
  <%= form.hidden_field :lock_version %>
  
<% end %>
  1. 在数据更新是同时带上更新的业务字段和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原子更新方式

前面我们已经有了乐关锁、悲关锁的实现,其实在还有一种方式,就是利用数据库的原子操作。

在悲观锁实现中,我们之所以要加锁,是因为担心并发情况下,多个线程同时获取到当前库存数量。

然后它们都根据这个库存数量,分别计算出扣减后的库存数量,最终导致库存不准确。

这里的问题在于,整个过程被拆成了两步:

  1. 查到对应的商品,拿到当前库存数量
  2. 根据当前库存数量计算出一个,需要更新的库存数量,然后做更新

它们是非原子操作,所以会有并发问题。

如果能把这两步合并成一个原子操作,就能解决问题。

那怎么实现呢?

在更新库存的时候,同时完成库存是否足够的判断,以及更新后库存值的计算即可。

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原子
    通过原子方式实现,个人认为是最简单的方法,性能也好的方式

免责声明:文中图文均来自网络,如有侵权请联系删除,心愿游戏发布此文仅为传递信息,不代表心愿游戏认同其观点或证实其描述。

相关文章

更多

精选合集

更多

大家都在玩

热门话题

大家都在看

更多