让我们一起搞懂:数据库的悲观锁和乐观锁

本文详细介绍了在多线程环境下如何通过悲观锁(如FORUPDATE)和乐观锁(基于版本号)避免库存超卖问题。通过实例展示了悲观锁可能导致的阻塞和乐观锁的非阻塞性,以及它们各自的优缺点和实现原理。
摘要由CSDN通过智能技术生成

一、前言

1 问题描述

  • 后端开发少不了操作数据库的表(也就是CRUD boy~)。在多线程环境下,线程A和线程B同时修改同一张表的同一条记录,会导致错误。
  • 例如,对于库存Stock表:
idproduct_namequantity
1Widget1
  • 用户A和用户B几乎同时访问网站,并决定购买"Widget"商品。
    • 用户A的请求被线程A处理,线程A读取到"Widget"的库存数量为1。
    • 同时,用户B的请求被线程B处理,线程B也读取到"Widget"的库存数量为1。
    • 线程A开始处理用户A的购买请求,减少库存数量,即将quantity从1减少到0,并尝试更新到数据库。
    • 同时,线程B也开始处理用户B的购买请求,同样减少库存数量,即将quantity从1减少到0,并尝试更新到数据库。
    • 假设线程B的更新请求先到达数据库并成功更新了库存数量为0
    • 然后,线程A的更新请求也到达数据库,它基于最初读取的库存数量1来减少库存,导致库存数量被错误地更新为0。(1件商品,却卖给了2个用户,超卖了!!!

2 解决办法

  • 为了避免这种情况,我们需要采用适当的并发控制技术,比如乐观锁(通过版本号控制)悲观锁(通过数据库锁机制)

二、悲观锁

1 示例

  • service层
@Service
public class StockServiceImpl implements IStockService {
    @Autowired
    private StockMapper stockMapper;

    @Autowired
    private TransactionTemplate transactionTemplate;

    @Override
    public void updateStock(String productName, int quantity) {
        transactionTemplate.execute(status -> {
            StockDO stock = stockMapper.findStockByProductName(productName);
            if (stock == null || stock.getQuantity() < quantity) {
                throw new RuntimeException("Not enough stock for " + productName);
            }

            // 在事务中执行悲观锁定和库存更新
            int updatedRows = stockMapper.updateStock(stock);
            if (updatedRows == 0) {
                throw new RuntimeException("The stock update was not successful for " + productName);
            }

            return null;
        });
    }
}
  • dao层
<!-- 查询库存信息并锁定 -->
<select id="findStockByProductName" parameterType="String" resultMap="StockMap">
    SELECT <include refid="cols" />
    FROM <include refid="tb_s" />
    WHERE product_name = #{productName} FOR UPDATE
</select>

<!-- 更新库存 -->
<update id="updateStock" parameterType="com.forrest.springboot.stock.domain.StockDO">
    UPDATE <include refid="tb_s"/> SET quantity = quantity - #{quantity} WHERE id = #{id}
</update>
  • 线程A和线程B执行StockDO stock = stockMapper.findStockByProductName(productName);时,假设线程A先执行,那么线程B就会阻塞。
    • 当线程A执行完,并提交事务后。Widget的库存量为0。
    • 因此,线程B执行时,查询到Widget的库存量为0,if (stock == null || stock.getQuantity() < quantity) {...}成立,这样Widget就不会被超卖了。

2 FOR UPDATE悲观锁的原理

  • 锁定资源:当你执行一个带有FOR UPDATE的SELECT语句时,数据库会锁定查询中返回的所有行。这些锁会一直保持,直到当前事务完成(提交或回滚)
  • 排他性:FOR UPDATE锁定的是排他锁(exclusive lock),这意味着其他事务不能修改被锁定的行,直到锁被释放。这防止了数据的不一致性和并发冲突。
    • 如果不是FOR UPDATE的SELECT语句,是可以执行的。
  • 隔离级别:FOR UPDATE通常与事务的隔离级别一起使用。不同的隔离级别决定了锁的粒度和持续时间。例如,在可串行化(SERIALIZABLE)隔离级别下,FOR UPDATE会锁定查询中所有可能影响的行,以避免幻读。
  • 锁等待超时:如果一个事务已经锁定了某些行,其他事务尝试使用FOR UPDATE锁定相同的行时,将会等待直到锁被释放或超时。如果在指定的时间内锁没有被释放,数据库会抛出一个锁等待超时异常
  • 事务结束一旦事务完成(无论是提交还是回滚),所有的锁都会被释放,资源被解锁,其他事务可以继续操作这些资源。

三、乐观锁

1 悲观锁的缺点

  • 悲观锁是一种保守的并发控制策略,它通过锁定资源来确保数据的一致性。然而,它也可能导致性能问题,如死锁和长时间的锁等待。

2 基于版本号实现乐观锁

  • 增加版本号:
idproduct_namequantityversion
1Widget11

2.1 示例

  • service层:
@Override
public void updateStockWithVersion(String productName, int quantity) {
    StockDO stock = stockMapper.findStockByProductNameWithVersion(productName);
    if (stock == null || stock.getQuantity() < quantity) {
        throw new RuntimeException("Not enough stock for " + productName);
    }

    // 构建更新对象
    StockDO updateStock = new StockDO();
    updateStock.setId(stock.getId());
    updateStock.setQuantity(stock.getQuantity() - quantity);
    updateStock.setVersion(stock.getVersion());

    int updatedRows = stockMapper.updateStockWithVersion(updateStock);
    if (updatedRows == 0) {
        // 可能需要重试或通知用户
    }
}
  • dao层:
StockDO findStockByProductNameWithVersion(String productName);

<select id="findStockByProductNameWithVersion" parameterType="String" resultMap="StockMap">
    SELECT <include refid="cols" />
    FROM <include refid="tb_s" />
    WHERE product_name = #{productName}
</select>


int updateStockWithVersion(StockDO stock);
<update id="updateStockWithVersion" parameterType="com.forrest.springboot.stock.domain.StockDO">
    UPDATE <include refid="tb_s"/>
    SET quantity = #{quantity}, version = #{version} + 1
    WHERE id = #{id} and version = #{version}
</update>

2.2 乐观锁的原理

  • 线程A和线程B执行StockDO stock = stockMapper.findStockByProductNameWithVersion(productName);时,都不会被阻塞。
    • 线程A读到的数据:[1, “Widget”, 1, 1]
    • 线程B读到的数据:[1, “Widget”, 1, 1]
  • 因此,都会执行:
StockDO updateStock = new StockDO();
updateStock.setId(stock.getId());
updateStock.setQuantity(stock.getQuantity() - quantity);
updateStock.setVersion(stock.getVersion());
  • 线程A将更新的数据:[1, “Widget”, 0, 1]
  • 线程B将更新的数据:[1, “Widget”, 0, 1]
  • 假设线程A先执行:int updatedRows = stockMapper.updateStockWithVersion(updateStock);
    • Stock表的记录变成:[1, “Widget”, 0, 2]
    • 这时候线程B再执行时,不满足 WHERE id = #{id} and version = #{version},因此不会更新Stock表。线程B便购买失败了。"Widget"商品没有超卖。
  • 20
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值