事务与锁
多个事务的并发会出现以下几个问题:
1)脏读:事务A读取了一条记录的值,然后基于这个值做业务逻辑,在事务A提交之前,事务B回滚了该记录,导致事务A读到的这条记录是一个脏数据;
2)不可重复读:在同一个事务里面,两次读到同一行记录,但结果不一样,因为另一个事务在此期间对该条记录进行update操作;
3)幻读:在同一个事务里面,同样的select查询语句执行两次,得到的记录条数不一样,因为另一个事务在此期间对表记录进行insert/delete操作;
4)丢失更新:两个事务同时修改同一条记录,事务A的修改被事务B覆盖了。
为了解决这些问题,数据库设置了不同的事务隔离级别。包括:
1)读未提交:
2)读已提交:只解决了脏读问题
3)可重复读:解决了脏读、不可重复读、幻读
4)串行化:解决了脏读、不可重复读、幻读、丢失更新
以上四种隔离级别一级比一级严格,通常读未提交和串行化不会被数据库查询引擎采用,因为前者什么隔离措施都没有做,后者将事务完全按照先后顺序依次执行,导致数据库的事务吞吐量太小。
MySQL的InnoDB引擎采用的是可重复读隔离级别。这就导致使用者要自己处理丢失更新问题,例如在下表(tbl)中
user_id | balance |
---|---|
1 | 30 |
2 | 80 |
两个事务操纵同一条记录,一个充钱一个扣钱,伪代码如下:
# 事务A
start transaction
int b = select balance from tbl where user_id = 1
b = b + 50
update tbl set balance = b where user_id = 1
commit
# 事务B
start transaction
int b = select balance from tbl where user_id = 1
b = b - 50
update tbl set balance = b where user_id = 1
commit
在事务A与B并发的情况下,由于事务A与B的多条语句之间可能被交叉执行,因此最后balance的值可能是80、-20、30。解决这个问题有如下的几种方法:
1)利用单条SQL语句的原子性改写事务代码,例如:
# 事务A
start transaction
update tbl set balance = balance + 50 where user_id = 1
commit
# 事务B
start transaction
update tbl set balance = balance - 50 where user_id = 1
commit
2)使用悲观锁(也就是select xxx for update语句),悲观锁在读记录之前就上锁,伪代码如下:
# 事务A
start transaction
int b = select balance from tbl where user_id = 1 for update
b = b + 50
update tbl set balance = b where user_id = 1
commit
# 事务B
start transaction
int b = select balance from tbl where user_id = 1 for update
b = b - 50
update tbl set balance = b where user_id = 1
commit
# 之所以说悲观锁降低了事务的并发度,是因为单个记录被锁住之后,其他访问该记录的事务都会被阻塞
# 另外,使用悲观锁,会导致死锁问题(例如事务A锁住user_id = 1的记录后,在Commit之前发生故障
# 会导致锁无法被释放)
3)使用乐观锁,即利用版本号 + CAS操作来控制,这需要对表加一个版本号字段(可以使用version、flag、tag等来命名)
user_id | balance | version |
---|---|---|
1 | 30 | |
2 | 80 |
有了版本号字段,就可以使用下面的伪代码来规避丢失更新问题:
# 事务A
while(!success) {
start transaction
int b, v1 = select balance, version from tbl where user_id = 1
b = b + 50
int v2 = v1 + 1
success = update tbl set balance = b, version = v2 where user_id = 1 and version = v1
commit
}
# 事务B
while(!success) {
start transaction
int b, v1 = select balance, version from tbl where user_id = 1
b = b - 50
int v2 = v1 + 1
success = update tbl set balance = b, version = v2 where user_id = 1 and version = v1
commit
}
# CAS的核心思想是:记录读出来的时候带一个版本号v1,然后在内存里面修改(包括修改业务字段和更
# 新版本号,比如更新成v2),当再写回去的时候,如果发现版本号不是v1,说明自己在修改期间有别的
# 事务修改了该记录,则放弃当前修改。重新读取记录,重新修改记录以及更新版本号,重新对比版本号,
# 如此不断重试
# 使用乐观锁,即利用版本号 + CAS操作的时候,有一个要点:version的比较、balance的更新、version
# 的更新要写在同一个SQL语句中,使用单条SQL的原子性保证整个过程的原子性
4)使用分布式锁,以上方法3)只能解决同一张表的事务操作场景中的丢失更新问题,但是一个事务可能跨表进行操作,例如:
start transaction
select xxx from tbl1
select yyy from tbl2
根据tbl1和tbl2的查询结果进行逻辑计算,然后用计算结果更新tbl3
update tbl3
commit
要实现update tbl3的同时,tbl1和tbl2是锁住的状态,不能让其他事务修改,这种情况可以考虑使用分布式锁。
实现读写并发往往有三种策略:互斥锁、读写锁、COW
1)互斥锁:一个数据对象上面只有一把锁,任何时候只能由一个线程持有锁,其他线程被阻塞。这种策略能够实现写写互斥、读写互斥、读读互斥;
2)读写锁:一个数据对象上面只有一把锁,但是有两个视图${TO-COMPREHENSION}。这种策略能够实现写写互斥、读写互斥、读读并发;
3)COW:CopyOnWrite,写的时候,把该数据对象拷贝一份,在拷贝对象上进行写操作,等写完之后,再用拷贝对象代替原始对象(将引用从原始对象指向拷贝对象),读的时候读取原始对象,这种策略能够实现:读读并发、读写并发、写写并发。
以上三种策略,并发度越来越高。MySQL中的InnoDB引擎就利用了COW策略实现了MVCC,以提高数据库的查询并发度。
每个事务修改某个记录之前,都会先把这个记录拷贝一份出来,每一次修改,就是一个版本,因此InnoDB维护了数据从旧到新的每一个版本,各个版本之间的记录通过链表串联。也正是因为每条记录都有多个版本,每个版本都和一个事务相关联,因此才容易实现事务的隔离性。
要实现多个事务同时操纵同一条记录的并发特性,要实现并发过程中的“读未提交”、“可重复读”的事务隔离级别,就不能让事务读取到正在被修改的数据,只能读取与该事务关联的历史版本,这体现了COW思想。
InnoDB的这个特性叫做多版本并发控制,即MVCC。InnoDB正是有了MVCC的特性,通常的select xxx from语句都是不加锁的,读取的全部是数据的历史版本,从而支持高并发的查询,这种读,叫做“快照读”;与之相对的是“当前读”,使用当前读的语句包括:select xxx from yyy for update语句、select xxx from yyy lock in share mode语句、insert / update / delete语句。
MVCC解决了快照读和写之间的并发,但是对于写与写之间、当前读与写之间的并发,MVCC就无能为力了。这时候就需要用到锁。MySQL的官方文档列出了InnoDB的七种锁:共享锁 / 排它锁、意向锁、记录锁、间隙锁、临键锁、插入意向锁、自增锁。(MySQL的官方文档从不同的维度划分,得到了这七种锁。乐观锁与悲观锁是对行锁的另一种维度的划分。)
1)共享锁 / 排它锁:共享锁(S)与排它锁(X)是读写锁的另外一种叫法,共享锁即读锁,支持读读之间并发;排它锁就是写锁,保证读写之间不能并发、写写之间也不能并发。一个事务读记录的时候,对该记录添加共享锁,其他事务也可以继续添加共享锁,但不能添加排它锁,从而使得共享锁支持读读并发;一个事务写记录的时候,对该记录添加排他锁,其他事务既不能再对该记录添加共享锁,也不能添加排它锁,只能阻塞,从而保证读写之间不能并发、写写之间也不能并发。InnoDB通常对行添加共享锁 / 排它锁,但有些场景会对整个表加共享锁 / 排它锁,如DDL语句。
2)意向锁:意向锁是一种表锁,具体分为意向排它锁(IX)和意向共享锁(IS)。当一个事务要给某表的某记录添加共享锁的时候,必先给这个表添加IS(IS可重入,有多少个S,IS就重入多少次);当一个事务要给某表的某记录添加排他锁的时候,必先给这个表添加IX(IX可重入,有多少个X,IX就重入多少次)。反之,行共享锁 / 排它锁撤销的时候,意向锁的重入次数也会相应减掉,直至为0,意向锁撤销。有了意向锁,事务在给整个表加共享锁 / 排它锁的时候,就不用遍历所有记录以确定表中是否存在行的共享锁 / 排它锁,只需看表上是否存在意向锁即可,这样就很容易避免表锁与行锁的冲突。
3)记录锁:即行锁,相对来讲还有表锁,间隙锁(Gap Lock,锁住一个范围内的记录,也可以叫做范围锁),这些锁的叫法反映了锁的粒度。
4)间隙锁:按照锁的粒度划分,除了有行锁、表锁之外,还有一种用于锁住范围的间隙锁(Gap Lock)。锁Gap和锁行密切相关,Gap Lock肯定建立在某一行的基准之上,Gap Lock锁住一个范围时不包含记录本身,一个事务用Gap Lock锁住一个表区间时,可以避免另一个事务在这个区间上插入新纪录。一个事务操作表记录时是否需要加间隙锁,这和事务的隔离级别密切相关。之所以要锁Gap,一个主要目的是避免幻读,如果事务的隔离级别允许幻读,则不需要使用间隙锁。另外,间隙锁往往针对非唯一索引,因为对唯一索引(例如主键索引、唯一字段上的辅助索引)的每次修改都可以具体定位到哪条或哪几条记录,不需要锁Gap。
5)临键锁(Next-Key Lock):临键锁可以看成是行锁和间隙锁的综合,不仅锁住某个记录,也会锁住该记录之前的范围。
6)插入意向锁:插入意向锁也算作一种间隙锁,专门针对Insert操作。多个事务在同一索引、同一个范围内的记录可以并发插入,即插入意向锁之间并不互相阻碍。
7)自增锁:例如有两个事务:
事务A 事务B
insert into tbl1 values xxx
insert into tbl1 values xxx
insert into t1 values xxx
select xxx from tbl1
insert into tbl1 values xxx
在事务A中,连续进行了两次记录插入,并且表tbl1使用了自增主键。因此事务A中最后一条语句查询出来的结果里,我们期望看到插入的两条记录的id递增且连续。但是由于事务B和事务A的语句可能会交叉执行,且事务B中也在插入记录,因此最后事务A中看到的两条插入的记录的id是不连续的,这不符合主键自增的要求。因此,需要一种自增锁来保证同一个事务中插入的递增列的值是连续的。
InnoDB设计了如此多类型的锁,是因为不同的SQL语句、不同的事务并发场景、不同的事务隔离级别、不同的索引类型,所需求的锁的特性可能都不一样,针对具体场景使用合适的锁,才能更好保证事务的隔离、查询的并发、查询的速度等等。
InnoDB通过MVCC + 锁实现了事务的隔离性和并发性。
利用数据库锁进行数据操作时,无论是显式的锁还是隐式的锁,若使用不当会导致死锁问题。利用有向图对事务对锁的持有进行建模,利用有向图的环检测算法可以检测死锁。
下面给出一个数据库发生死锁的实例:
事务A 事务B
delete from tbl1 where id = 1
update tbl2 set xxx where id = 2
update tbl2 set xxx where id = 2
delete from tbl1 where id = 1
再给出一个较为隐晦的死锁实例:
事务A 事务B
delete from tbl1 where id = 1
update tbl1 set xxx where id = 5
insert into tbl1 values xxx
insert into tbl1 values xxx
# 在InnoDB使用可重复读的事务隔离级别时,以上两个事务的执行可能会产生死锁。原因是insert操作
# 会对tbl1加间隙锁,导致两个事务死锁。${TO-COMPREHENSION}