数据库锁
以 mysql innoDB 为例,数据库的锁有 排他锁,共享锁,意向锁,自增锁,间隙锁,锁的范围有包括,行锁,表锁 ,区间锁。
从应用研发的视角,我们需要关注的主要是 排他锁,共享锁,以及锁的范围。其他如意向锁,自增所,间隙锁是mysql 内部在处理某些逻辑是自己处理的锁。
平时还有提到的悲观锁,乐观锁,在数据库层面上没有这个锁的概念,如果要做简单映射,悲观锁可以映射成排他锁,乐观锁是由应用层面保障的,和 DB 的锁概念无关。
排他锁是应用研发中用的最多的数据库锁了,收单、支付、金融,几乎任何一个系统都会使用到排他锁。
我们平常锁说的加锁,悲观锁,说的也就是数据库中的排他锁,排他锁也称为 X 锁(共享锁是 S 锁)如
<operation name="lock" paramtype="primitive" multiplicity="one" rawsql="true">
<sql><![CDATA[
select xxx
from table where id = ? for update
]]></sql>
</operation>
select * from table where ? for update 会对于一条记录 / 一个区间 / 整表进行加锁。
在加锁之前,必须要开启事务,只有在事务中的 for update 语句才是有效的,对于一个事务,如果成功的获取了锁,那意味其他事务无法对于这条记录记录进行加排他锁、共享锁,无法更新,删除操作。不过可以做无锁查询。
所以如收单对于 iaqc_base 进行加锁之后,正常没有 for update 的查询是依旧可以进行的。
排他锁的使用场景在于,保证一张表的一致性,举例说明,以账务为例,用户A余额有 200 块,用户A支付了100块,余额 -100,与此同时,用户B给用户A转账50块,余额 +50,期望用户余额是 200 - 100 + 50 = 150。
期望是
如果在没有锁的情况下,结果是不确定的,可能是 250元,可能是 100元,也可能是 150 元。
事务1 用户支付 100 元,余额 -100 | 线程3 用户收到转账 50 元,余额 + 50 |
---|---|
select * from account where user = A返回用户余额为 200 元 | |
判断余额是否足够,内存计算 200 - 100 = 100 | select * from account where user = A返回用户余额为 200 元 |
update account set balance = 100 where user = A更新余额为 100 元 | 内存计算 200 + 50 = 250 |
commit; | update account set balance = 250 where user = A更新余额为 250 元 |
commit; |
如果增加了悲观锁,可以保证结果的一致性。
事务1 用户支付 100 元,余额 -100 | 线程3 用户收到转账 50 元,余额 + 50 |
---|---|
select * from account where user = A for update返回用户余额为 200 元 | |
判断余额是否足够,内存计算 200 - 100 = 100 | select * from account where user = A for update |
update account set balance = 100 where user = A更新余额为 100 元 | 等待 |
commit; | 等待 |
返回用户余额为 100 元 | |
内存计算 100 + 50 = 150 | |
update account set balance = 150 where user = A更新余额为 150 元 | |
commit; |
悲观锁除了这个案例意外,在收单,支付等各种系统中都有使用,比如
共享锁,S 锁,相对于悲观锁来说,是低一级的锁,若有事务对于某一条数据加了共享锁后,其他事务依旧可以增加共享锁,但是不能增加排他锁。
select * from table lock in share mode
共享锁(S) | 排它锁(X) | |
---|---|---|
共享锁(S) | 允许 | 不允许 |
排它锁(X) | 不允许 | 不允许 |
共享锁的使用场景不多,在蚂蚁我没有找到使用共享锁的代码,只要是数据修改都是排他锁。
在 mysql 的官方文档中,排他锁是用来保证一张表中数据的一致性的,那共享锁是用来保证主从表的一致性的,举例:有用户表和用户联系人表,我们可以认为用户表示主表,联系人表示从表,在写入联系人的时候需要对用户表进行加锁,以免在写入的时候,联系人主记录被删除了。(在 ipay 用的排他锁)
这里排他锁和共享锁最大的区别在于如果对于用户表加的是排他锁,表示同一时刻只能一个事务写入联系人表,如果是共享锁,则同时可以多个事务写入联系人表。
共享锁案例:用户张三写入联系人李四和王五的两条记录。
事务1 写入联系人 李四 | 写入联系人王五 | 删除用户张三 |
---|---|---|
select * from user lock in share mode where user id = ‘张三’ | select * from user lock in share mode where user id = ‘张三’ | delete from user where user_id = ‘张三’ |
insert into user_relation values(‘李四’) | insert into user_relation values(‘王五’) | 等待 |
commit; | commit; | 等待 |
commit; |
共享锁前面提到主要的使用场景在保持主表和从表之间的一致性,所以不建议在获得共享锁之后对于获得锁的数据进行更新操作,如果有更新操作的话,共享锁会升级成排他锁,可能会导致死锁。
因为共享锁是可能会被多个事务同时获得的,如果在获得之后同时进行 update 则会产生死锁,举例:
2个事务同事获得用户余额的共享锁,并且进行金额操作
事务1 用户支付 100 元,余额 -100 | 线程3 用户支付 50 元,余额 -50 |
---|---|
select * from account where ? lock in share mode返回用户余额为 200 元 | |
判断余额是否足够,内存计算 200 - 100 = 100 | select * from account where ? lock in share mode返回用户余额为 200 元 |
update account set balance = 100 where user = A更新余额为 100 元 | 判断余额是否足够,内存计算 200 - 100 = 100 |
尝试锁升级成排他锁 等待事务 T2 共享锁释放 | update account set balance = 150 where user = A更新余额为 100 元 |
等待 | 尝试锁升级成排他锁 等待事务 T1 释放 |
等待 | 检测出死锁,事务失败,roll back |
获得锁成功,更新成功 | |
commit |
意向锁 intention lock,分为 IS 共享意向锁 和 IX 排他意向锁。意向锁表示该表中有某一条记录被锁了,如果某条记录被加了排他锁,则该表上有 IX 锁,如果某条记录被加了共享锁,则该表上有 IS 锁,需要注意,意向锁是表级别的锁。
因为mysql 支持行锁和表锁,假设一张表中有一条记录被 T1 事务加了排他锁,T2 事务来加表锁的时候,应该是被阻塞。如果没有意向锁的情况下,mysql 需要循环该表的每一条记录,判断是否有加锁,最后才能得出能否加表锁,这个效率非常低。所以就有了意向锁。
当某一条记录被加锁的时候,会在表上先加意向锁,代表这个表中的某条记录被加锁了,那当其他事务来对表进行加锁的时候,只需要判断表上是否有意向锁,就可以判断出是否可以对表进行加锁。
意向锁的互斥性 | IX 意向排他锁 | IS 意向共享锁 |
---|---|---|
IX 意向排他锁 | 兼容 | 兼容 |
IS 意向共享锁 | 兼容 | 兼容 |
因为意向锁是代表这个表中的记录是否有增加排他锁和共享锁,所以对于意向锁之间是不会互斥的。
举例:
-- 在 user 表上增加 IX 锁 意向排他锁
-- 在 1 的聚簇索引上增加 X锁 排他锁
select * from user where id = 1 for update
-- 在 user 表上增加 IX 锁 意向排他锁
-- 在 2 的聚簇索引上增加 X锁 排他锁
select * from user where id = 2 for update
意向锁的互斥性 | IX 意向排他锁 | IS 意向共享锁 |
---|---|---|
X 排他锁 表级别 | 阻塞 | 阻塞 |
S 共享锁 表级别 | 阻塞 | 兼容 |
间隙锁,主要是解决 mysql 在可重复读的级别下部分幻读的问题。
间隙锁在范围查询加锁,或者查询不存在值加锁的时候会使用,用于锁定一定范围内的数据,防止其他事务写入,以来解决幻读的问题。
场景:假设有如下数据
1 | 100 | 105 | 110 | 120 | 200 |
---|---|---|---|---|---|
当我们开启事务 T1 执行 sql select * from table where id = 102 for update;
的时候,如果没有间隙锁,此事务不会加锁,如果此时 T2 事务写入了 id = 102 数据,T1 可能会出现如下问题
这些都是幻读的体现。
mysql 默认在可重复读下通过间隙锁解决了这个问题,当执行上述 sql 时,会对 102 这个间隙加锁,加锁是左开右闭,为 (100, 105] (新版本的mysql 好像优化了这个细节,是左开右开,即 (100, 105) ) . 加了区间锁之后,其他的事务则无法写入或者修改这个区间之内的数据。举例:
在我们执行 select * from table where id = 102 for update;
之后,查看数据库中的锁,产生间隙锁,这个时候锁的范围 (100, 105)
产生间隙锁之后,我们期望是 (100, 105),所以 100 和 105 不会加锁,范围内的会加锁,下面的例子操作了 105 的 update 和 103 的写入,可以看到 105 的 update 可以成功,103 的写入会阻塞。
自增锁是一种特殊的表级别锁(table-level lock),专门针对事务插入AUTO_INCREMENT 自增类型的列。最简单的情况,如果一个事务正在往表中插入记录,所有其他事务的插入必须等待,以便第一个事务插入的行,是连续的主键值。
自增锁的分配有传统模式,连续模式,和交叉模式
举例:有 user 表,有2个字段,1. id 自增列 2. name varchar
事务1 | 事务2 |
---|---|
begin; | |
insert into user values (‘A’); | begin; |
select * from user; | id | name|| 1 | A | | insert into user values (‘B’); |
insert into user values (‘C’); | comit; |
select * from user; | id | name || 1 | A || 3 | C | | |
commit; | |
select * from user;| id | name | | 1 | A || 2 | B || 3 | C | | select * from user;| id | name | | 1 | A || 2 | B || 3 | C | |
行级锁是一种排他锁,防止其他事务修改此行;在使用以下语句时,Oracle 会自动应用行级锁:
表示对当前操作的整张表加锁,它实现简单,资源消耗较少,被大部分 MySQL 引擎支持。最常使用的 MYISAM 与 INNODB 都支持表级锁定。表级锁定分为表共享读锁(共享锁)与表独占写锁(排他锁)。
页级锁是 MySQL 中锁定粒度介于行级锁和表级锁中间的一种锁。表级锁速度快,但冲突多,行级冲突少,但速度慢。所以取了折衷的页级,一次锁定相邻的一组记录。BDB 支持页级锁