索引和锁是数据库中的两个核心知识点,不论在工作中,还是在面试中,我们都经常会跟它们打交道。之前已经从不同维度对索引进行了了解,比如B+树,Hash索引、页结构、缓冲池和索引原则等,了解它们的工作原理可以加深对索引的了解。事务的隔离级别的实现都是通过锁来完成的,思考以下为什么我们需要给数据加锁呢?

实际上加锁是为了保证数据的一致性,这个思想在程序开发领域中同样很重要。在程序开发中也会存在多线程同步的问题。当多个线程并发访问某个数据的时候,尤其是针对一些敏感的数据(比如订单、金额等),我们就需要 保证这个数据在任何时候最多只有一个线程在进行访问,保证数据的完整性和一致性。

按照锁的粒度进行划分

锁用来对数据进行锁定,我们可以从锁定对象的力度大小来对锁进行划分,分别为行锁页锁表锁

顾名思义,行锁就是按照行的力度对数据进行锁定。锁定力度小,发生冲突概率低,可以实现的并发度高,但是对于锁的开销比较大,加锁会比较慢,容易出现死锁情况。

页锁就是在页的力度上进行锁定,锁定的数据资源比行要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的想象,但这样的浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定力度介于表锁和行锁之间,并发力度一般。

表锁就是对数据表进行锁定,锁定力度很大,同时发生锁冲突的概率也会较高,数据访问的并发度低。不过好处在于对锁的使用开销小,加锁会很快。

行锁、页锁和表锁是相对常见的三种锁,除此之外我们还可以在区和数据库的粒度上锁定数据,对应区锁和数据库锁。不同的数据库和存储引擎支持的锁粒度不同,InnoDB 和 Oracle支持行锁和表锁。而MyISAM只支持表锁,MySQL中的BDB存储引擎支持页锁和表锁。SQL Server 可以同时支持行锁、页锁和表锁,如下图所示:

这里需要说名一下,每个层级的锁数量是有限制,因为锁会占用内存空间,锁空间的大小是有限的。当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级。锁升级就是用更大力度的锁代替多个更小力度的锁,比如InnoDB中行锁升级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了。

从和数据库管理的角度对锁进行划分

除了按照锁力度的大小对锁进行划分外,我们还可以从数据库管理的角度对锁进行划分。共享锁和排它锁,是我们经常接触到的两把锁。

共享锁也叫读锁或S锁,共享锁锁定的资源可以被其他用户读取,但不能修改。在进行SELECT 的时候,会将对象进行共享锁锁定,当数据读取完毕后,就会释放共享锁,这样就可以保证数据在读取时不被修改。

比如我们想给 product_comment 在表上加共享锁,可以使用下面这行命令:

1
LOCK TABLE product_comment READ;

当数据表加上共享锁的时候,该数据表就变变成了只读模式,此时我们想要更新product_comment表中的数据,比如这样:

1
UPDATE product_comment SET product_id = 10002 WHERE user_id = 912178;

系统会做出如下提示:

1
ERROR 1099 (HY000): Table 'product_comment' was locked with a READ lock and can't be updated 

也就是但共享锁没有释放时,不能对锁住的数据进行修改。

如果我们想要对表上的共享锁进行解锁,可以使用下面的命令:

1
UNLOCK TABLE;

如果想要给某一行加上共享锁呢,比如想对user_id = 912178的数据行加上共享锁,可以像下面这样:

1
SELECT comment_id,product_id,comment_text,user_id FROM product_comment WHERE user_id = 912178 LOCK IN SHARE MODE;

排它锁也叫独占锁、写锁或X锁。排它锁锁定的数据只允许进行锁定操作的事务使用,其他的事务无法对数据进行查询或修改。

如果我们想给product_comment数据表添加排它锁,可以使用下面这行命令:

1
LOCK TABLE product_comment WRITE;

这时我们释放掉排它锁,使用这个命令即可。

1
UNLOCK TABLE;

同样的,如果我们想要在某个数据行上添加排它锁,比如针对user_id = 912178的数据行,则写成如下这样:

1
SELECT comment_id,product_id,comment_text,user_id FROM product_comment WHERE user_id = 912178 FOR UPDATE;

另外当我们对数据进行更新的时候,也就是INSERT、DELETE 或者 UPDATE的时候,数据库也会自动使用排他锁,防止其他事务对该数据进行操作。

当我们想要获取某个数据表的排它锁的时候,需要看下这张数据表有没有上了排它锁。如果这个数据表中的某个数据行被上了行锁,我们就无法获取排它锁。这时需要对数据表中的行逐一排查,检查是否有行锁,如果没有,才可以获取这张数据表的排它锁。这个过程是不是有些麻烦?这里就需要用到意向锁。

意向锁(Intent Lock ),简单来说就是给更大一级别的空间示意里面是否已经上过锁。举个例子,你可以给整个房子设置一个标识。告诉它里面有人,即使你这时获取了房子中的某一个房间的锁。这样其他人如果想要获取这个房子的控制权,只需要看看这个房子的标识即可,不需要在对每个房间进行查找。这样是不是很方便?

返回数据表的场景,如果我们给某一行的数据加上了排他锁,数据库就会自动给更大一级别的空间,比如数据页或数据表上加意向锁,告诉其他人这个数据页或者数据表已经获取了这个数据表的意向排他锁即可。

如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上添加意向共享锁。同理,事务想要获得数据表中某些记录的排他锁,就需要在数据表上添加意向排他锁。这时,意向锁会告诉其他事务已经有人锁定了表中的某些记录,不能对整个表进行全表扫描。

为什么共享锁会发生死锁的情况?

当我们是用共享锁的时候就会出现死锁的风险,下面我们用两个MySQL客户端来模拟一下事务查询。

首先客户端1开启事务,然后采用读锁的方式对user_id = 912178的数据行进行查询,这时事务没有提交的时候,这两个数据行上了读锁。

然后我们用客户端2开启事务,同样对user_id = 912178获取读锁,理论上获取读锁后还可以对数据进行修改,比如执行下面这两条语句:

1
UPDATE product_comment SET product_i = 10002 WHERE user_id = 912178;

当我们执行的时候客户端2会一直等待,因为客户端1也获取了该数据的读锁,不需要客户端2对该数据进行修改。这时客户端2会提示等待超时,重新执行事务。

你能看到当有多个事务对同一数据获得读锁的时候,可能会出现死锁的情况。

从程序员的角度对锁进行划分

如果从程序员的视角来看锁的话,可以将锁分成乐观锁和悲观锁,从名字也可以看出这两种锁看待数据并发的思维方式。

乐观锁(Optimistic Locking) 认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用版本号机制或者时间戳机制实现。

乐观锁的版本号机制

在表中设计一个版本字段version,第一次读的时候,会获取version字段的取值。然后对数据进行更新或者删除操作时,会执行UPDATE … SET version = version+1 WHERE version = version。此时如果已经有事务对这条数据进行了修改,修改就不成功。

这种方式类似我们熟悉的 SVN、CVS 版本管理系统,当我们修改了代码进行提交时,首先会检查当前版本号与服务器上的版本号是否一致,如果一致就可以直接提交,如果不一致就需要更新服务器上的最新代码,然后再进行提交。

乐观锁的时间戳机制

时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。

你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是用过给数据行增加一个戳(版本号或者时间戳),从而证明自己拿到的数据是最新的。

悲观锁(Pessimistic Locking)也是一种思想,对数据被其他是事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证操作的排他性。

从这两种锁的设计思想中,能看出乐观锁适合读操作多的场景:

  1. 乐观锁适合读操作多的场景,相对来说写的操作比较少。它的优点在于程序实现,不存在死锁问题,不过使用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。
  2. 悲观锁适合写操作多的场景,因为写的操作具有排他性。采用悲观锁的方式,可以在数据库层面阻止了其他事务对该数据的操作权限,防止读-写和 写-写的冲突。

总结