Reader

分布式并发业务场景---分布式锁,以及不用锁方案

| 掘金本周最热 | Default

前言

分布式并发业务场景不要只会说加分布式锁,其实解决问题的方案有很多

先梳理好业务场景,找到该场景下的痛点

有些场景甚至不加锁,依靠sql语句即可解决,并且还是最优解,更符合业务场景

并发问题解决方案:

  • 分布式锁
    • 乐观锁
    • 悲观锁
  • 业务代码无锁,最终靠数据库行锁一次执行,通过sql条件判断

✅并发问题解决方案(分布式锁)

在我们的项目中,有很多并发的场景,但是我们的解决思路是不一样的。这一篇就从整体并发防控的角度来把这些方案以及为什么用介绍一下。

在并发防控上,其实归根结底就两个方案:

  • 1、乐观锁
  • 2、悲观锁

乐观锁

乐观锁主要以数据库的乐观锁为主,比如类似下面这种:


UPDATE aska_collection SET
         name=?,
         cover=?,
         purchase_price=?,
         collection_id=?,
         serial_no=?,
         nft_id=?,
         user_id=?,
         state=?,
         tx_hash=?,
         hold_time=?,
         sync_chain_time=?,
         biz_type=?,
         biz_no=?,
         lock_version=?,
         gmt_create=?,
         gmt_modified=?
WHERE id=?
        AND lock_version=?
        AND deleted=0
        
      // 这里面的UPDATE aska_collection set lock_version = ? where lock_version = ?  
      // 就是一个非常典型的用版本号来做乐观锁控制的场景。

悲观锁

悲观锁的话,主要以 Redis 实现的分布式锁为主,如下面这种:


@DistributeLock(keyExpression = "#request.identifier", scene = "ORDER_CREATE")
public OrderResponse create(OrderCreateRequest request) {
    //这里面的@DistributeLock是我们自己给予 Redisson 封装的一个分布式锁的注解。
}

那么,这两种方案我们是如何选择的呢?其实这主要就是悲观锁和乐观锁的区别了。

乐观锁和悲观锁的区别

先上干货 也就是说,乐观锁是先干活,后加锁。悲观锁是先加锁,再干活

  • 乐观锁的基本思想是假设冲突很少发生,每个线程在修改数据之前,先获取一个版本号或时间戳,并在更新时检查这个版本号或时间戳,以确保其他线程没有同时修改数据。

  • 乐观锁适用于读操作频繁,写操作相对较少的场景。当冲突较少,且并发写入的概率较低时,乐观锁的性能可能更好。

  • 悲观锁则是假设冲突经常发生,因此在访问共享资源之前,线程会先获取锁,确保其他线程无法同时访问相同的数据。这可能导致并发性降低,因为只有一个线程能够访问数据。

  • 悲观锁适用于写操作较为频繁,且并发写入的概率较高的场景。悲观锁可以有效地避免多个线程同时修改相同数据的情况。

  • 乐观锁和悲观锁还有个区别:乐观锁因为比较乐观,所以一般是先做业务逻辑操作,比如参数处理,内存中进行模型组装调整,然后再去更新数据库。悲观锁因为比较悲观,所以会先尝试加锁,然后再去做业务逻辑操作

乐观锁和悲观锁选择

而高并发的写操作时,你干了一大堆活,把模型都组装好了,内存计算也都做完了,结果最后去数据库那更新的时候发现版本号变了。这不是大冤种吗?

所以,应该是先尝试获取锁,如果获取锁成功,再进行业务操作,否则就直接返回失败。这样可以做fail-fast。

  • 综上,在高并发场景中,一般来说并发写入的冲突较为频繁,所以建议优先考虑悲观锁。即在做并发操作前,先尝试获取锁,如果获取锁成功,在进行业务操作,否则就直接返回失败。

实际项目中如何选择case

在我们大部分的业务场景中,锁的选择一方面考虑了上面提到的并发的情况,另外一方面,也考虑到了乐观锁其实只适合用在有 update 的场景。

订单模块加锁方案

在订单模块中,有很多会出现并发的场景

  • 1、用户的订单的冲突提交,可能存在并发的问题
  • 2、用户同一笔订单多个渠道同时成功,可能会存在并发的问题
  • 3、订单的主动关单以及订单的超时关单,可能会存在并发的问题
  • 4、订单的关单操作和用户的支付成功操作,也可能会存在并发的问题

首先,我们根据并发情况分析,其实只有订单的创建是高并发的场景,而订单的支付成功的回调、关单动作等等都不是并发特别高的。

所以,针对创建订单的接口,我们使用了分布式锁来对用户传入的下单的幂等号来做并发控制


@Override
@DistributeLock(keyExpression = "#request.identifier", scene = "ORDER_CREATE")
public OrderResponse create(OrderCreateRequest request) {
    try {
        orderValidatorChain.validate(request);
    } catch (OrderException e) {
        return new OrderResponse.OrderResponseBuilder().buildFail(ORDER_CREATE_VALID_FAILED.getCode(), e.getErrorCode().getMessage());
    }

    InventoryRequest inventoryRequest = new InventoryRequest(request);
    SingleResponse<Boolean> decreaseResult = inventoryFacadeService.decrease(inventoryRequest);

    if (decreaseResult.getSuccess()) {
        return orderService.createAndAsyncConfirm(request);
    }
    throw new OrderException(OrderErrorCode.INVENTORY_DECREASE_FAILED);
}

而在其他的几个并发场景中,我们没有显示的加悲观锁,而是通过状态机+乐观锁实现的。

比如同一笔订单的多个渠道同时成功,在我们以下逻辑中实现:

@Override
public OrderResponse paySuccess(OrderPayRequest request) {
    OrderResponse response = orderService.paySuccess(request);
    if (!response.getSuccess()) {
        TradeOrder existOrder = orderReadService.getOrder(request.getOrderId());
        if (existOrder != null && existOrder.isClosed()) {
            return new OrderResponse.OrderResponseBuilder().orderId(existOrder.getOrderId()).buildFail(OrderErrorCode.ORDER_ALREADY_CLOSED.getCode(), OrderErrorCode.ORDER_ALREADY_CLOSED.getMessage());
        }
        if (existOrder != null && existOrder.isPaid()) {
            if (existOrder.getPayStreamId().equals(request.getPayStreamId()) && existOrder.getPayChannel() == request.getPayChannel()) {
                return new OrderResponse.OrderResponseBuilder().orderId(existOrder.getOrderId()).buildSuccess();
            } else {
                return new OrderResponse.OrderResponseBuilder().orderId(existOrder.getOrderId()).buildFail(OrderErrorCode.ORDER_ALREADY_PAID.getCode(), OrderErrorCode.ORDER_ALREADY_PAID.getMessage());
            }
        }
    }
    return response;
}


// 核心sql

<update id="updateByOrderId" parameterType="tradeOrder">
    update trade_order set gmt_modified = now(),lock_version = lock_version + 1
    <if test="orderState != null">
        , order_state = #{orderState}
    </if>
    <if test="closeType != null">
        , close_type = #{closeType}
    </if>

    where order_id = #{orderId} and deleted = 0 and lock_version = #{lockVersion}
</update>


在这个逻辑中,我们通过订单在更新的时候会添加乐观锁(利用了 mybatisplus 自动识别 lock_version 进行的乐观锁判断),以及做了严格的状态机控制,来保证这个 orderService.pay 方法只会被成功调用一次,下次再调用,则会返回失败。

而在orderService.pay方法返回失败后,我们则去判断下订单的支付状态,以及订单上记录的上一次支付成功的信息,来判断是否发生了多付。

所以,针对这种场景,同一个订单多个渠道支付成功并发其实并不高,出现多付的概率也并不大的情况,我们没必要加一个悲观锁,直接用乐观锁就行了。

而其他的几个场景,和这个场景一样,也都是这样的,并发不高,完全可以借助状态机+乐观锁的校验来确保在数据库更新的时候只有一个线程能成功来防止并发的。

秒杀场景 无锁扣减库存方案

image.png

!!!留个问题 !!!

为什么库存扣减不需要加锁

尤其是乐观锁?在库存扣减的方法中(本文特指数据库的扣减,不包含 Redis 层)

我们全程是没有加锁的,甚至乐观锁都没加

没加悲观锁是因为秒杀场景不适合用悲观锁

而我们的这个库存扣减这里,其实并发冲突还挺高的,如果这里用了乐观锁,那么就会有大量的失败。

举个例子,100个线程同时来查询库存,得到的 lock_version = 1,然后同时去更新库存,都要求当前的 lock_version = 1,这就会导致99个线程都失败,那么就会导致整体失败,如果事务中有其他操作,就要回滚。

库存扣减的时候,我们的目的肯定是让多个线程都扣减成功,并且不扣错就行了

如果你的 SQL 是这样的:

UPDATE collection
SET saleable_inventory = #{saleableInventory}
WHERE id = #{id}

也就是说这个变更后的saleableInventory是你自己算好的,
给到 SQL 去执行的,那么就必须要加锁来避免并发的时候计算出错,
但是我们通过在 SQL 中扣减不会有任何并发导致出错的问题。

而如果这里不加乐观锁,只通过库存扣减,
以及库存不能小于0来做控制,
那么只需要他们各自抢锁然后按顺序扣减就行了,
反正每次库存都是在前面的结果上依次扣减的


UPDATE collection
SET 
    saleable_inventory = saleable_inventory - #{quantity},  -- 减少可售库存
    lock_version = lock_version + 1,                        -- 增加锁版本号
    gmt_modified = now()                                    -- 更新修改时间为当前时间
WHERE 
    id = #{id}                                              -- 根据ID定位记录
    AND <![CDATA[saleable_inventory - frozen_inventory >= #{quantity}]]> 
    -- 确保可售库存减去冻结库存大于等于要减少的数量

所以,这里我们只需要保证百分百不会超卖就行了,剩下的并发问题 update 过程中的(mysql 行锁)锁会帮我们解决的