如何实现高并场景下的库存扣减

674 阅读8分钟

在高并发情况下,实现对库存的正确扣减是一种非常有代表性的技术实现,只要对此种情况有了足够的理解,可以轻松的应对其它并发场景的处理,因此有必要深入掌握。

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中的排它锁,啥意思,它排斥的是啥?

  1. 它允许读操作,排斥写操作
  2. 因此在加锁后,如果执行读select * from products where id = 2是可以正常读到的不会阻塞。
  3. 但是写操作(更新/删除)比如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的事务结束,锁被释放

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. 三种实现方式比较

  1. 乐观锁 适合并发不高的情况,缺点是需要自己实现版本的比较和发生冲突的比较操作,稍显复杂

  2. 悲观锁 和乐关锁对应,适合并发高的情况,比较暴力,先加锁再操作,实现简单,但性能不好

  3. sql原子 通过原子方式实现,个人认为是最简单的方法,性能也好的方式