数据库的乐观锁和悲观锁

168 阅读9分钟

数据库的乐观锁和悲观锁

为什么需要锁?

MySQL 的锁可以分成三类:总体、类型、粒度。

  • 总体上分成两种:乐观锁和悲观锁
  • 数据库管理上分成两种:读锁(共享锁或者S锁(Shared Lock) )和写锁(排他锁或者X锁(Exclusive Lock))
  • 锁的粒度上可以分成五种:表锁,行锁,页锁,间隙锁,临键锁

在并发环境下,如果多个客户端访问同一条数据,此时就会产生数据不一致的问题,如何解决,通过加锁的机制,常见的有两种锁,乐观锁和悲观锁,可以在一定程度上解决并发访问。或者说支付下单,并发情况下可能造成重复下单,扣款,资金相关的业务对数据一致性要求极高。

乐观锁:在操作数据时非常乐观,并发情况下,多个客户端可以同时获取到同一数据,认为别人不会同时修改数据,因此不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据。如果别人修改了数据则放弃操作,否则执行操作。

乐观锁的适用场景和实现方式 适用场景:

  • 读多写少的场景

高并发读取:在读操作频繁而写操作较少的情况下,乐观锁能够提高系统的并发性能。由于大多数操作都是读取数据,冲突的概率较低,因此乐观锁可以减少因加锁带来的开销。 - 减少锁争用:在数据冲突发生概率较低的情况下,乐观锁可以减少因为锁争用而带来的性能开销。例如,新闻资讯和电商商品浏览等场景。

  • 无需长时间锁定资源的场景

    • 快速释放资源:乐观锁操作不需要长时间锁定资源,适用于需要尽快释放资源的场景。例如,实时数据处理和高并发事务处理等场景。
  • 数据库并发控制

    • 版本号机制:在数据库更新操作中,通过为每条记录添加一个版本号字段,可以实现乐观锁机制。当多个用户同时对同一条数据进行修改时,系统会检测到版本冲突,并根据特定策略进行处理。
    • 避免数据不一致:乐观锁可以有效避免并发更新导致的数据不一致问题,如订单库存管理和用户账户操作等。
  • 分布式系统中的并发控制

    • 防止数据覆盖:在分布式系统中,乐观锁可以防止多个节点同时修改同一数据导致的数据覆盖问题。例如,电商平台中的库存管理和价格调整等场景。
    • 提高系统吞吐量:通过乐观锁机制,系统可以在高并发情况下保持较高的吞吐量,避免因锁等待而导致的性能瓶颈。
  • 高并发队列操作

    • 保证队列正确性:当多个任务需要同时操作队列时,使用乐观锁可以保证队列的正确性。例如,任务调度系统中的任务分配和执行等场景。
  • 数据版本控制

    • 确保数据一致性:在需要保证数据版本一致性的场景下,可以使用乐观锁来控制数据的更新操作。例如,文档编辑和代码合并等场景。
  • 用户积分和余额管理

    • 保证数据准确性:在用户同时操作账户时,使用乐观锁可以避免账户金额错误。例如,在线支付和积分兑换等场景。 实现方式:
    • 使用版本号
      使用数据版本(Version)记录机制实现,这是乐观锁最常用的一种实现方式。何谓数据版本?即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。
  • 使用时间戳
    乐观锁定的第二种实现方式和第一种差不多,同样是在需要乐观锁控制的table中增加一个字段,名称无所谓,字段类型使用时间戳(timestamp), 和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。

    乐观锁的优劣

优点:

  • 高并发高吞吐:乐观锁不会阻塞其他事务的读取操作,只在提交时检查数据是否被修改,因此可以提供更好的并发性能。
  • 无锁操作:乐观锁不需要显式地获取和释放锁,减少了锁竞争和上下文切换的开销。
  • 无死锁风险:由于乐观锁不会阻塞其他事务的访问,因此不会出现死锁的情况。

缺点:

  • 冲突处理复杂:由于乐观锁不会阻塞其他事务,因此在提交时需要检查数据是否被其他事务修改,如果发现冲突,需要回滚事务或重新尝试操作,这增加了冲突处理的复杂性。

  • 数据一致性风险:乐观锁假设并发冲突较少,因此可能存在数据一致性的风险。如果多个事务同时对同一数据进行修改,可能会导致数据不一致的情况。

  • 需要额外字段:为了实现乐观锁,通常需要在数据表中添加额外的版本号或时间戳字段,这增加了存储空间的需求。

  • 处理不当造成死循环风险:在大多数业务中乐观锁更新失败都会进行自旋,如果没有控制好自旋退出逻辑可能会造成递归死循环问题。

  • ABA问题:当变量从A修改为B再修改回A时,变量值等于期望值A,但是无法判断是否修改,CAS操作在ABA修改后依然成功。

    代码:

    update tb_account_balance set balacne=?,version=verison+1 where id=#{id}and version=#{oldversion}

悲观锁

概念:总是假设最坏的情况,每次获取数据的时候都认为别人会修改,所以每次在获取数据的时候都会上锁,这样别人想获取这个数据就会阻塞直到它拿到锁后才可以获取(共享资源每次只给一个线程使用,其它线程阻塞,当前线程用完后再把资源转让给其它线程)。

悲观锁适用场景

  • 高并发且数据竞争激烈的场景:当多个事务需要同时访问和修改同一份数据时,使用悲观锁可以确保数据在任一时刻只被一个事务访问和修改,从而避免数据的不一致性和脏读。
  • 数据一致性要求极高的场景:对数据的一致性要求非常高,不允许出现任何的数据不一致或脏读现象。在这些场景中,使用悲观锁可以确保数据在任一时刻只被一个事务访问和修改,从而满足数据一致性的要求。
  • 写操作频繁的场景:如果系统中写操作(如更新、删除等)远多于读操作(如查询),那么使用悲观锁可以更有效地保护数据,避免在写操作时被其他事务干扰。
  • 事务执行时间较长的场景:当事务的执行时间较长时,使用悲观锁可以确保在该事务执行期间,数据不会被其他事务修改,从而避免数据的不一致性和脏读。
  • 悲观锁优劣

优点:

  • 数据一致性高:悲观锁认为冲突一定会发生,因此在数据处理前会先加锁,这样可以确保数据在任一时刻只被一个事务访问和修改,从而避免数据的不一致性和脏读。
  • 简单易用:悲观锁的实现相对简单,只需要在操作数据前获取锁即可。

缺点:

  • 性能开销大:悲观锁在操作数据前需要获取锁,如果有大量的并发操作,可能会导致性能问题,因为其他事务需要等待锁释放。
  • 容易造成死锁:如果多个事务相互等待对方释放锁,可能会导致死锁的发生,影响系统的稳定性和可用性。
  • 可能导致资源浪费:如果获取锁后长时间不释放,可能会导致其他事务无法操作数据,从而造成资源浪费。
  • 代码示例

-       @Transactional(rollbackFor = Exception.class)
    public boolean pessimisticLockSubAmount(Long customerId, Long happenAmount) {
        // 1、查询用户钱包 - 并且添加for update 锁,这里customer_id字段添加了索引最终锁定的还是索引定义行的ID,和直接使用ID区别不大
        // 这段代码相当于 select * from customer_wallet where customer_id = ? for update
        CustomerWallet customerWallet = lambdaQuery()
                .eq(CustomerWallet::getCustomerId, customerId)
                .last("for update")
                .one();
        if(customerWallet == null){
            throw new RuntimeException("用户钱包不存在");
        }
        // 2、校验用户余额是否足够
        Long balanceAmount = customerWallet.getBalanceAmount() - happenAmount;
        if(balanceAmount < 0){
            throw new RuntimeException("用户余额不足");
        }
        // 3、更新钱包余额 update customer_wallet set balance_amount = ? where id = ?
        boolean update = lambdaUpdate()
                .eq(CustomerWallet::getId, customerWallet.getId())
                .set(CustomerWallet::getBalanceAmount, balanceAmount)
                .update();
        if(!update){
            throw new RuntimeException("钱包更新失败");
        }

悲观锁可以通过数据库的锁机制实现,如 SELECT … FOR UPDATE 语句。这个语句告诉数据库锁定选中的数据行,直到事务完成。 使用这个映射查询数据时,被选中的行将被锁定,直到当前事务完成。 最后,使用乐观锁和悲观锁时,还需要注意一些问题。对于悲观锁,需要注意锁的粒度、锁的持有时间和锁的竞争程度等因素,以避免降低系统的并发性能和导致死锁问题。对于乐观锁,需要注意版本号或时间戳的更新频率、数据冲突的概率和重试次数等因素,以避免事务重试次数过多,影响系统的性能。