数据库如何加锁?锁是用来干嘛的?

感谢内容提供者:金牛区吴迪软件开发工作室

数据库中的共享锁与排它锁

共享锁(S锁),又称为读锁,如果数据对象加上共享锁之后,则该数据库对象可以被其他事务查看但无法修改和删除数据。
排他锁(X锁),又称为写锁、独占锁,如果数据对象加上排他锁,则其他的事务不能对它读取【如果读取没有加锁是可以读取的】和修改。

对于共享锁大家可能很好理解,就是多个事务只能读数据不能改数据,对于排他锁大家的理解可能就有些差别,我当初就犯了一个错误,以为排他锁锁住一行数据后,其他事务就不能读取和修改该行数据,其实不是这样的。 排他锁指的是一个事务在一行数据加上排他锁后,其他事务不能再在其上加其他的锁。mysql InnoDB引擎默认的修改数据语句,update,delete,insert都会自动给涉及到的数据加上排他锁,select语句默认不会加任何锁类型,如果加排他锁可以使用select …for update语句,加共享锁可以使用select … lock in share mode语句。 所以加过排他锁的数据行在其他事务中是不能修改数据的,也不能通过for update和lock in share mode锁的方式查询数据,但可以直接通过select …from…查询数据,因为普通查询没有任何锁机制。


示例:
1、先搞一个事务,进行一个update操作【相当于是加了一个排它锁】
在这里插入图片描述
2、而后进行一个加锁查询
在这里插入图片描述
3、普通的查询是没有加任何锁的,所以可以查询到我们想要的结果!
在这里插入图片描述

我们通过上面的三个步骤就验证了我们刚刚说的排它锁的机制问题。
说明:顺便说一下如果你命中索引了那么就是行锁,如果没命中索引那就是表锁。
另外还有一种情况,如果一个共享锁还未释放锁,此时一个排他锁进来处于等待状态,然后一个共享锁进来,此时仍然是处于等待状态。


为什么要加锁

锁主要是为了保持数据库数据的一致性,可以阻止用户修改一行或整个表,一般用在并发较高的数据库中。
在多个用户访问数据库的时候若对并发操作不加控制就可能会读取和存储不正确的数据,破坏数据库的一致性。

很多人都知道,锁是用来解决并发问题的,那么什么是并发问题呢?并发情况下,不加锁会有什么问题呢?

拿生活中的洗手间举例子,每个洗手间都会有一个门,并且是可以上锁的,当我们进入洗手间之后会把门反锁,当我们出来之后再把锁打开。

当门被锁上之后,其他人只能在门外等待。洗手间之所以要有门锁,就是为了保护隐私的,避免出现多个人同时进入洗手间的情况。

这和数据库中的锁其实是一样的,为了避免多个事务同时操作数据库导致数据异常,一般会通过锁机制解决。

在介绍共享锁和排他锁之前,我们先来大个比方,前面已经用了一个洗手间的例子,那么就继续这个例子。一般情况下,我们进入洗手间有可能做一下几件事:洗手、化妆、上厕所等,其实只要上厕所这件事是极度隐私的,而其他事没有那么隐私。

我们可以认为洗手间就是一个数据库表,而洗手间内部的设施就是数据库表中的数据。我们每个想要进入洗手间的人都是一个事务,简单的洗手、化妆等操作可以认为是读操作,而上厕所操作可以认为是写操作。


共享锁

前面简单介绍了数据库与洗手间之间的类比关系,那么接下来继续分析什么是共享锁。

有些时候,如果我们进入洗手间只是想洗手的话,我们一般不会锁门。而其他人也可以进来洗手、化妆等。但是,其他人是不可以进来上厕所的。

这就是共享锁,也叫读锁。就是我们对数据进行读取操作的时候,其实是不会改变数据的值的。

所以我们可以给数据库增加读锁,获得读锁的事务就可以读取数据了。当数据库已经被别人增加了读锁的时候,其他新来的事务也可以读数据,但是不能写。

也就是说,如果事务T对数据A加上共享锁后,则其他事务只能对A再加共享锁,不能加排他锁。获准共享锁的事务只能读数据,不能修改数据。


用法

在查询语句后面增加LOCK IN SHARE MODE,Mysql会对查询结果中的每行都加共享锁。

SELECT ... LOCK IN SHARE MODE;

当没有其他线程对查询结果集中的任何一行使用排他锁时,可以成功申请共享锁,否则会被阻塞。其他线程也可以读取使用了共享锁的表,而且这些线程读取的是同一个版本的数据。

排他锁

介绍完了共享锁后,在来说说互斥锁。

如果我们进入洗手间只是想洗手,那么我们可以允许其他人也进来洗手。但是,如果我们进入洗手间是为了上厕所,那么任何人不能再进来做任何事。

这就是排他锁,也叫写锁。就是我们对数据进行写操作的时候,要先获得写锁,获得写锁的事务既可以写数据也可以读数据。但是,如果数据库已经被别人增加了排他写锁,那么后面的事务是无法在获得该数据库的任何锁的。

也就是说,如果事务T对数据A加上排他锁后,则其他事务不能再对A加任何类型的封锁。获准排他锁的事务既能读数据,又能修改数据。


用法

在查询语句后面增加FOR UPDATE,Mysql会对查询结果中的每行都加排他锁

SELECT ... FOR UPDATE;

当没有其他线程对查询结果集中的任何一行使用排他锁时,可以成功申请排他锁,否则会被阻塞。

加锁原则

拿MySql的InnoDB引擎来说,对于insert、update、delete等操作。会自动给涉及的数据加排他锁;

对于一般的select语句,InnoDB不会加任何锁,事务可以通过以下语句给显示加共享锁或排他锁。

// 共享锁:
SELECT ... LOCK IN SHARE MODE;

// 排他锁:
SELECT ... FOR UPDATE;

数据库中的乐观锁与悲观锁

悲观锁

当我们要对一个数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。

这种借助数据库锁机制在修改数据之前先锁定,再修改的方式被称之为悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)。

之所以叫做悲观锁,是因为这是一种对数据的修改抱有悲观态度的并发控制方式。我们一般认为数据被并发修改的概率比较大,所以需要在修改之前先加锁。

悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。


但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会;

另外,还会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。

悲观锁实现方式

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

在对记录进行修改前,先尝试为该记录加上排他锁(exclusive locking)。
如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。具体响应方式由开发者根据实际需要决定。
如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
其间如果有其他事务对该记录做加锁的操作,都要等待当前事务解锁或直接抛出异常。
我们拿比较常用的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;

以上,在对id = 1的记录修改前,先通过for update的方式进行加锁,然后再进行修改。这就是比较典型的悲观锁策略。

如果以上修改库存的代码发生并发,同一时间只有一个线程可以开启事务并获得id=1的锁,其它的事务必须等本次事务提交之后才能执行。这样我们可以保证当前的数据不会被其它事务修改。

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

乐观锁

乐观锁( Optimistic Locking ) 是相对悲观锁而言的,乐观锁假设数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。

相对于悲观锁,在对数据库进行处理的时候,乐观锁并不会使用数据库提供的锁机制。一般的实现乐观锁的方式就是记录数据版本。

乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。

乐观锁实现方式

使用乐观锁就不需要借助数据库的锁机制了。

乐观锁的概念中其实已经阐述了他的具体实现细节:主要就是两个步骤:冲突检测和数据更新。其实现方式有一种比较典型的就是Compare and Swap(CAS)技术。

CAS是项乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

比如前面的扣减库存问题,通过乐观锁可以实现如下:

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

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

但是以上更新语句存在一个比较重要的问题,即ABA问题。

比如说一个线程1从数据库中取出库存数3,这时候另一个线程2也从数据库中库存数3,并且线程2进行了一些操作将库存数变成了2,紧接着又将库存数变成3,这时候线程1进行CAS操作发现数据库中仍然是3,然后线程1操作成功。尽管线程1的CAS操作成功,但是不代表这个过程就是没有问题的。

有一个比较好的办法可以解决ABA问题,那就是通过一个单独的可以顺序递增的version字段。改为以下方式即可:

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

乐观锁每次在执行数据的修改操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行+1操作,否则就执行失败。因为每次操作的版本号都会随之增加,所以不会出现ABA问题,因为版本号只会增加不会减少。

除了version以外,还可以使用时间戳,因为时间戳天然具有顺序递增性。

以上SQL其实还是有一定的问题的,就是一旦高并发的时候,就只有一个线程可以修改成功,那么就会存在大量的失败。

对于像淘宝这样的电商网站,高并发是常有的事,总让用户感知到失败显然是不合理的。所以,还是要想办法减小乐观锁的粒度的。

有一条比较好的建议,可以减小乐观锁力度,最大程度的提升吞吐率,提高并发能力!如下:

//修改商品库存
update item 
set quantity=quantity - 1 
where id = 1 and quantity - 1 > 0

以上SQL语句中,如果用户下单数为1,则通过quantity - 1 > 0的方式进行乐观锁控制。

以上update语句,在执行过程中,会在一次原子操作中自己查询一遍quantity的值,并将其扣减掉1。

高并发环境下锁粒度把控是一门重要的学问,选择一个好的锁,在保证数据安全的情况下,可以大大提升吞吐率,进而提升性能。

如何选择乐观锁还是悲观锁

在乐观锁与悲观锁的选择上面,主要看下两者的区别以及适用场景就可以了。

  1. 乐观锁并未真正加锁,效率高。一旦锁的粒度掌握不好,更新失败的概率就会比较高,容易发生业务失败。

  2. 悲观锁依赖数据库锁,效率低。更新失败的概率比较低。

随着互联网三高架构(高并发、高性能、高可用)的提出,悲观锁已经越来越少的被使用到生产环境中了,尤其是并发量比较大的业务场景。

吴迪软件开发 小程序 ECMAScript 6 React.js
金牛区吴迪软件开发工作室博客