• 十七、乐观锁和悲观锁(干货版)


    一、题目

    乐观锁和悲观锁

    二、悲观锁

    当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制

    在这里插入图片描述

    悲观锁的实现方式

    • 关系型数据库使用这种锁机制,比如行锁、表锁、读锁、写锁等,都是在操作之前先上锁;
    • Java 里面的同步 Synchronized 关键字的实现

    三、乐观锁

    在这里插入图片描述

    乐观锁假设数据一般情况不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何去做。乐观锁适用于读多写少的场景,这样可以提高程序的吞吐量

    乐观锁的实现

    • CAS 实现Javajava.util.concurrent.atomic 包下面的原子变量使用了乐观锁的一种 CAS 实现方式
    • 版本号控制:在数据表中加上一个数据版本号 Version 字段,表示数据被修改的次数。 当数据被修改时,Version 值会 +1;当线程 A 要更新数据时,在读取数据的同时也会读取 Version 值,在提交更新时, 若刚才读取到的 Version 值与当前数据库中的 Version 值相等时才更新,否则重试更新操作,直到更新成功

    四、具体案例

    4.1、悲观锁

    悲观锁的实现,往往依靠数据库提供的锁机制。在数据库中,悲观锁的流程如下:

    1. 在对记录进行修改前,先尝试为该记录加上排他锁;
    2. 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。具体响应方式由开发者根据实际需要决定;
    3. 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了;
    4. 期间如果有其他对该记录做修改或加排他锁的操作,都会等待解锁或直接抛出异常

    以 MySQL InnoDB 引擎举例,说明 SQL 中悲观锁的应用

    要使用悲观锁,必须关闭 MySQL 数据库的自动提交属性 set autocommit = 0。因为 MySQL 默认使用 autocommit 模式,也就是说,当执行一个更新操作后,MySQL 会立刻将结果进行提交;

    以电商下单扣减库存的过程说明一下悲观锁的使用:

    -- 0、开始事务
    begin;
    -- 1、查询此商品库存信息
    select quantity from items where id = 1 for update;
    -- 2、修改商品库存为 2
    update items set quantity = 2 where id = 1;
    -- 3、提交事务
    commit;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 在对 id = 1 的记录修改前,先通过 for update 的方式进行加锁,然后再进行修改。这就是比较典型的悲观锁策略
    • 如果发生并发,同一时间只有一个线程可以开启事务并获得 id = 1 的锁,其它的事务必须等本次事务提交之后才能执行。这样可以保证当前的数据不会被其它事务修改
    • 使用 select…for update 锁数据,需要注意锁的级别,MySQL InnoDB 默认行级锁。行级锁都是基于索引的,如果一条 SQL 语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住,这点需要注意

    实践出真知

    建表

    CREATE TABLE `items` (
      `id` int(11) NOT NULL,
      `quantity` int(255) DEFAULT NULL COMMENT '库存',
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    
    • 1
    • 2
    • 3
    • 4
    • 5

    在这里插入图片描述
    新开一个事务更新数据,会因为一直获取不到锁而超时

    在这里插入图片描述

    4.2、乐观锁

    乐观锁不需要借助数据库的锁机制,主要就是两个步骤:冲突检测和数据更新。比较典型的就是 CAS (Compare and Swap)

    当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。比如前面的扣减库存问题,通过乐观锁可以实现如下

    -- 查询出商品库存信息,quantity = 3
    select quantity from items where id = 1
    -- 修改商品库存为 2
    update items set quantity = 2 where id = 1 and quantity = 3;
    
    • 1
    • 2
    • 3
    • 4

    在更新之前,先查询一下库存表中当前库存数(quantity),然后在做 update 的时候,以库存数作为一个修改条件。当提交更新的时候,判断数据库表对应记录的当前库存数与第一次取出来的库存数进行比对,如果数据库表当前库存数与第一次取出来的库存数相等,则予以更新,否则认为是过期数据;

    以上语句存在一个比较严重的问题,即 ABA 问题

    • 比如说线程一从数据库中取出库存数 3,这时候线程二也从数据库中取出库存数 3,并且线程二进行了一些操作变成了 2;
    • 然后线程二又将库存数变成 3,这时候线程一进行 CAS 操作发现数据库中仍然是 3,然后线程一操作成功;
    • 尽管线程一的 CAS 操作成功,但是不代表这个过程就是没有问题的;
    import java.util.concurrent.TimeUnit;
    import java.util.concurrent.atomic.AtomicReference;
    
    public class TestAtomic {
        static AtomicReference<Integer> atomicReference = new AtomicReference<>(100);
    
        public static void main(String[] args) throws InterruptedException {
            new Thread(() -> {
                atomicReference.compareAndSet(100, 101);
                atomicReference.compareAndSet(101, 100);
            }, "t1").start();
    
            new Thread(() -> {
                try {
                    TimeUnit.SECONDS.sleep(1);
                    System.out.println(atomicReference.compareAndSet(100, 2019) + "\t" + atomicReference.get());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            },"t2").start();
    
            TimeUnit.SECONDS.sleep(2);
        }
    }
    /*
        true	2019
    */
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    一个比较好的解决办法,就是通过一个单独的可以顺序递增的 version 字段

    乐观锁每次在执行数据修改操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行 +1 操作,否则就执行失败。因为每次操作的版本号都会随之增加,所以不会出现 ABA 问题。除了 version 以外,还可以使用时间戳,因为时间戳天然具有顺序递增性

    import java.util.concurrent.TimeUnit;
    import java.util.concurrent.atomic.AtomicStampedReference;
    
    public class TestAtomic {
        static AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(100, 1);
    
        public static void main(String[] args) throws InterruptedException {
            new Thread(() -> {
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                atomicStampedReference.compareAndSet(100, 101, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
                atomicStampedReference.compareAndSet(101, 100, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
            }, "t1").start();
    
            new Thread(() -> {
                try {
                    int stamp = atomicStampedReference.getStamp();
                    TimeUnit.SECONDS.sleep(3);
                    System.out.println(atomicStampedReference.compareAndSet(100, 2019, stamp, stamp + 1) + "\t" + atomicStampedReference.getReference());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }, "t2").start();
    
            TimeUnit.SECONDS.sleep(2);
        }
    }
    /*
        false	100
    */
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33

    实践出真知

    给数据库表添加 version 字段

    CREATE TABLE `items` (
      `id` int(11) NOT NULL,
      `quantity` int(255) DEFAULT NULL COMMENT '库存',
      `version` int(255) DEFAULT NULL,
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    原始数据
    在这里插入图片描述
    开事务执行更新语句

    在这里插入图片描述

    执行后的结果

    在这里插入图片描述

    此时,另一个事务执行同样的语句,数据无变化

    在这里插入图片描述

    如何选择

    • 响应效率:如果需要非常高的响应速度,建议采用乐观锁方案,成功就执行,不成功就失败,不需要等待其他并发去释放锁。乐观锁并未真正加锁,效率高。一旦锁的粒度掌握不好,更新失败的概率就会比较高,容易发生业务失败;
    • 冲突频率:如果冲突频率非常高,建议采用悲观锁,保证成功率。冲突频率大,乐观锁会需要多次重试才能成功,代价较大;
    • 重试代价:如果重试代价大,建议采用悲观锁。悲观锁依赖数据库锁,效率低。更新失败的概率比较低;
    • 乐观锁如果有人在你之前更新了,你的更新应当是被拒绝的,可以让用户从新操作。悲观锁会等待前一个更新完成。这也是区别

    转载请标明出处,原文地址:https://blog.csdn.net/weixin_41835916 如果觉得本文对您有帮助,请点击支持一下,您的支持是我写作最大的动力,谢谢。
    这里写图片描述

  • 相关阅读:
    应用程序处理:TCP模块的处理
    select基础查询
    Linux 设备驱动模型platform==Linux驱动开发5
    SkeyeGisMap地图扩展(三) 自定义事件
    ONVIF学习笔记11:搜索设备不匹配问题排查
    计算机毕业设计Java奥利给共享自习室系统(源码+系统+mysql数据库+lw文档)
    mint下apt安装MySQL8.0修改密码
    watch 和 watchEffect
    JavaScript
    靶机练习: Hacker_Kid-v1.0.1
  • 原文地址:https://blog.csdn.net/weixin_41835916/article/details/126017493