详解悲观锁和乐观锁-简单易懂

概念

乐观锁和悲观锁都是一种思想,并不是真实存在于数据库中的一种机制。

悲观锁 (Pessimistic Lock)

顾名思义,就是很悲观,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改数据,所以每次在拿数据的时候都会上锁,别人只能等待,直到我释放锁才能拿到锁;传统的关系型数据库里边就用到了很多这种锁机制,比如行锁、表锁、读锁、写锁等,都是在做操作之前先上锁,java 中的 synchronized 和 ReentrantLock 也是悲观锁的思想。

我们认为系统中的并发更新会非常频繁,并且事务失败了以后重来的开销很大,这样以来,我们就需要采用真正意义上的锁来进行实现。悲观锁的基本思想就是每次一个事务读取某一条记录后,就会把这条记录锁住,这样其它的事务要想更新,必须等以前的事务提交或者回滚释放锁。在效率方面,处理锁的操作会产生额外的开销,而且增加了死锁的可能。当一个线程在处理某行数据的时候,其它线程只能等待。

实现方式

悲观锁的实现是依赖于数据库提供的锁机制,流程如下:

  1. 修改记录前,对记录加上排他锁(exclusive locking)
  2. 如果加锁失败,说明这条数据正在被修改,那么当前查询要等待或者抛出异常,这由开发者决定
  3. 如果加锁成功,可以对这条数据修改了,事务完成解锁
  4. 加锁修改期间,其他事务也想对这条记录进行操作时,都要等待或者直接抛出异常

注意:在使用 mysql Innodb 引擎实现悲观锁时,必须关闭 mysql 的自动提交属性,因为 MySQL 默认使用 autocommit 模式,也就是说,当你执行一个更新操作后,MySQL 会立刻将结果进行提交。执行:set autocommit = 0;

我们平时大多在数据库层面实现加锁操作,比如 JDBC 方式:在 JDBC 中使用悲观锁,需要使用 select for update 语句。

例:下单扣减库存如何使用悲观锁实现

//0.开始事务
begin; 
//1.查询出商品库存信息
select quantity from items where id = 1 for update;
//2.修改商品库存为2
update items set quantity = 2 where id = 1;
//3.提交事务
commit;

使用 select for update 会把数据锁住。MySQL InnoDB 默认行级锁,行级锁都是基于索引的,如果一条 SQL 语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住,这点需要注意。

乐观锁(Optimistic Lock)

顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。乐观锁认为系统中的事务并发更新不会很频繁,即使冲突了也没事,大不了重新再来一次。它的基本思想就是每次提交一个事务更新时,我们想看看要修改的东西从上次读取以后有没有被其它事务修改过,如果修改过,那么更新就会失败,返回错误的信息,让用户决定如何去做。
乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库如果提供类似于 write_condition 机制的其实都是提供的乐观锁。乐观锁不会使用数据库提供的锁机制,一般基于版本号机制实现。

实现方式

大多是基于数据版本(Version)记录机制实现,何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来实现。
读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据 版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。
乐观锁的实现不需要借助于数据库锁机制,只要就是两个步骤:冲突检测和数据更新,其中一种典型的是实现方法就是CAS(Compare and Swap)

CAS实现乐观锁

CAS是一种乐观锁实现方式,顾名思义就是先比较后更新。在对一个数据进行更新前,先持有对这个数据原有值的备份。比如,要将a=2更新为a=3,在进行更新前会比较此刻a是否为2.如果是2,才会进行更新操作。当多个线程尝试使用CAS同时更新一个变量时,只有一个线程能够成功,其余都是失败。失败的线程不会被挂起,而是被告知这次竞争失败,并且可以再次尝试。
比如前面的扣减库存问题,乐观锁方式实现如下:

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

在更新之前,先查询库存表中当前库存数,然后在做 update 时,以库存数作为一个修改条件。当进行提交更新的时候,判断数据库的当前库存数与第一次取出来的库存数进行比对,相等则更新,否则认为是过期数据。
但是这种更新存在一个比较严重的问题,即 ABA 问题

ABA问题
A线程去除库存数3,B线程取出库存数3,B线程先将库存数变为2,又将库存数变为3,A线程在进行更新操作时发现库存是仍然是3,然后操作成功。尽管A线程操作是成功的,但是不能代表这个过程就是没问题的

解决ABA问题的一个方法是通过一个顺序递增的 version 字段:

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

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

平时我们在 Java 代码中使用 synchronized 进行跨线程的同步的方式就属于悲观锁,它有一个明显的缺点,它不管数据之间存不存在竞争都会加锁,随着并发量的增加,且如果锁的时间比较长,其性能开销将会变得很大。有没有办法解决这个问题?
答案就是基于冲突检测的乐观锁。这种模式下,已经没有所谓的锁概念了,每条线程都直接先去执行操作,计算完成后检测是否与其他线程存在共享数据竞争,如果没有则让此操作成功,如果存在共享数据竞争则可能不断地重新执行操作和检测,直到成功为止,这种操作叫做 CAS 自旋。也就是我们上面介绍的CAS实现,下面介绍一下 java 里的CompareAndSet(CAS):

AtomicInteger的incrementAndGet的实现:

public final int incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next)) {
            return next;
        }
    }
}

首先可以看到他是通过一个无限循环(spin)直到 increment 成功为止。
循环的内容是:

  1. 取得当前值
  2. 计算+1后的值
  3. 如果当前值还有效(没有被)的话设置那个+1后的值
  4. 如果设置没成功(当前值已经无效了即被别的线程改过了),再从1开始。

compareAndSet的实现:

private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();

public final boolean compareAndSet(int expectedValue, int newValue) {
        return U.compareAndSetInt(this, VALUE, expectedValue, newValue);
}

这里直接调用的是UnSafe这个类的 compareAndSetInt 方法,这个类是Oracle(Sun)提供的实现,在别的公司的JDK里就不一定是这个类了。

compareAndSetInt的实现:

 /**
     * Atomically updates Java variable to {@code x} if it is currently
     * holding {@code expected}.
     *
     * <p>This operation has memory semantics of a {@code volatile} read
     * and write.  Corresponds to C11 atomic_compare_exchange_strong.
     *
     * @return {@code true} if successful
     */
    @HotSpotIntrinsicCandidate
    public final native boolean compareAndSetInt(Object o, long offset,
                                                 int expected,
                                                 int x);

此方法不是 Java 实现的,而是通过 JNI 调用操作系统的原生程序,这涉及到 CPU 原子操作,现在几乎所有的 CPU 指令都支持 CAS 的原子操作, X86 下对应的是 CMPXCHG 汇编指令。

CAS 原子操作在维基百科中的代码描述如下:

int compare_and_swap(int* reg, int oldval, int newval) {
    ATOMIC();
    int old_reg_val = *reg;
    if (old_reg_val == oldval)
        *reg = newval;
    END_ATOMIC();
    return old_reg_val;
}

这段实现也就是检查内存 *reg 里的值是不是 oldval,如果是的话,则对其赋值 newval。上面的代码总是返回 old_reg_value,调用者如果需要知道是否更新成功还需要做进一步判断,为了方便,它可以变种为直接返回是否更新成功,如下:

bool compare_and_swap (int *accum, int *dest, int newval) {
    if ( *accum == *dest ) {
        *dest = newval;
        return true;
    }
    return false;
}

异同

  • 乐观锁并不是真正的加锁,优点是效率高,缺点是更新失败的概率比较高;
  • 悲观锁依赖于数据库锁机制,更新失败的概率比较低,但是效率也低(看使用场景,在竞争高的情况下,乐观锁的效率反而会更低)。

应用场景

  • 乐观锁适用于读多写少的情况,即冲突很少发生;
  • 如果是多写的情况,应用会不断重试,反而会降低系统性能,这种情况最好用悲观锁,因为等待到锁被释放后,可以立即获得锁进行操作。

总结

悲观锁和乐观锁这两种锁各有优缺点,不可认为一种好于另一种,比如乐观锁适用于写比较少的情况下,即冲突真的很少发生的时候,这样可以减小锁的开销,从而加大整个系统的吞吐量。但如果经常产生冲突,上层应用会不断的进行 retry,这样反而会降低性能,所以这种情况下用悲观锁就比较合适。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值