在InnoDB中,锁是逐步获得的,因此发生死锁是可能的。发生死锁后,InnoDB一般都能自动检测到,并使一个事务释放锁并回退,另外一个事务获得锁,并继续完成事务。但在涉及外部锁,或涉及表锁的情况下,InnoDB并不能完全自动检测到死锁,这需要通过设置锁等待超时参数innodb_lock_wait_timeout来解决。
1. mysql锁机制
InnoDB存储引擎既支持行级锁(row-level locking),也支持表级锁,但默认情况下是采用行级锁。
表级锁:开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。 行级锁:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
innodb 行级锁 record-level lock大致有三种:record lock, gap lock and Next-KeyLocks。
record lock 锁住某一行记录 gap lock 锁住某一段范围中的记录 next key lock 是前两者效果的叠加。
nnoDB实现了以下两种类型的行锁:
共享锁:允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁;
排他锁:允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。
为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB还有两种内部使用的意向锁(意向共享锁和意向排他锁)。这两种意向锁都是表锁。意向锁是InnoDB自动加的,不需要用户干预。 对于UPDATE、DELETE和INSERT语句,InnoDB会自动给涉及数据集加排他锁;对于普通SELECT语句,InnoDB不会加任意锁。
事务可以通过以下语句显示给记录集加共享锁或者排他锁:
1
2
1
2SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE #共享锁 SELECT * FROM table_name WHERE ... FOR UPDATE #排他锁
InnoDB的行锁实现的特点:只有通过索引条件检索数据,InnoDB才会使用行级锁,否则,InnoDB将会使用表锁。因为MySQL的行锁是针对索引加的锁, 而不是针对记录加的锁,所以虽然是访问不同行的记录,但是如果是使用相同的索引建,是会出现锁冲突的。
对于键值在条件范围内但并不存在的记录,叫做间隙。InnoDB会对这个间隙加锁,这种锁机制就是所谓的间隙锁(Next-Key锁)。 InnoDB使用间隙锁的目的:一是为了防止幻读,二是为了满足其恢复和复制的需要。
InnoDB如何解决死锁问题的:
在InnoDB中,锁是逐步获得的,因此发生死锁是可能的。发生死锁后,InnoDB一般都能自动检测到,并使一个事务释放锁并回退,另外一个事务获得锁,并继续完成事务。但在涉及外部锁, 或涉及表锁的情况下,InnoDB并不能完全自动检测到死锁,这需要通过设置锁等待超时参数innodb_lock_wait_timeout来解决。
2. 数据库加锁分析
MySQL InnoDB存储引擎,实现的是基于多版本的并发控制协议——MVCC (Multi-Version Concurrency Control) (注:与MVCC相对的, 是基于锁的并发控制,Lock-Based Concurrency Control)。MVCC最大的好处,相信也是耳熟能详:读不加锁,读写不冲突。 在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能,这也是为什么现阶段,几乎所有的RDBMS,都支持了MVCC。
在MVCC并发控制中,读操作可以分成两类:快照读 (snapshot read)与当前读 (current read)。快照读,读取的是记录的可见版本 (有可能是历史版本), 不用加锁。当前读,读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。
在一个支持MVCC并发控制的系统中,哪些读操作是快照读?哪些操作又是当前读呢?以MySQL InnoDB为例:
快照读:简单的select操作,属于快照读,不加锁。(当然,也有例外,下面会分析)
1
1select * from table where ?;
当前读:特殊的读操作,插入/更新/删除操作,属于当前读,需要加锁。
1
2
3
4
5
1
2
3
4
5select * from table where ? lock in share mode;
select * from table where ? for update;
insert into table values (…);
update table set ? where ?;
delete from table where ?;
所有以上的语句,都属于当前读,读取记录的最新版本。并且,读取之后,还需要保证其他并发事务不能修改当前记录,对读取记录加锁。 其中,除了第一条语句,对读取记录加S锁 (共享锁)外,其他的操作,都加的是X锁 (排它锁)。
2.1 事务隔离级别
对锁进行分析前必须要先了解事务隔离级别的关系
隔离级别
脏读(Dirty Read)
不可重复读(NonRepeatable Read)
幻读(Phantom Read)
未提交读(Read uncommitted)
可能
可能
可能
已提交读(Read committed)
不可能
可能
可能
可重复读(Repeatable read)
不可能
不可能
可能
可串行化(Serializable)
不可能
不可能
不可能
- 未提交读(Read Uncommitted):允许脏读,也就是可能读取到其他会话中未提交事务修改的数据
提交读(Read Committed):只能读取到已经提交的数据。Oracle等多数数据库默认都是该级别 (不重复读)
可重复读(Repeated Read):可重复读。在同一个事务内的查询都是事务开始时刻一致的,InnoDB默认级别。在SQL标准中,该隔离级别消除了不可重复读,但是还存在幻象读
串行读(Serializable):完全串行化的读,每次读都需要获得表级共享锁,读写相互都会阻塞
MySQL InnoDB默认使用的级别是可重复读级别(Repeatable read),查找命令如下
1
2
3
4
5
6
7
1
2
3
4
5
6
7mysql>select @@session.tx_isolation;
+------------------------+
| @@session.tx_isolation |
+------------------------+
| REPEATABLE-READ |
+------------------------+
1 row in set
脏读:当一个事务进行的操作还未提交时,另外一个事务读到了修改的数据,这就是脏读,但是RR级别事务避免了脏读。
不可重复读:是指在一个事务内,多次读同一数据。在这个事务还没有结束时,另外一个事务也访问该同一数据。 那么,在第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的的数据可能是不一样的。 这样就发生了在一个事务内两次读到的数据是不一样的,因此称为是不可重复读。但是,RR级别是不会出现不一样的结果的,即使另一个事务提交了修改他也查不到变化。
幻读:第一个事务对一个表中的数据进行了修改,这种修改涉及到表中的全部数据行。同时,第二个事务也修改这个表中的数据, 这种修改是向表中插入一行新数据。那么,以后就会发生操作第一个事务的用户发现表中还有没有修改的数据行,就好象发生了幻觉一样。
2.2 sql语句加锁分析
1
2
3
4
5
6
1
2
3
4
5
6#SQL语句1
select * from table where id = 1;
#SQL语句2
update set age = age + 1 where id = 1;
#SQL语句3
update set age = age + 1 where id = 1 and nickname = 'hello';
首先我们可以确定的是语句1,他是不加锁的,属于快照读。语句2和语句3要复杂些,我们慢慢来分析。
下面我们默认事务级别为可重复读(Repeated Read),因为这是MySQL InnoDB默认级别。
语句2分析:
如果id是主键或者是索引的话,那么锁定的行只有符合条件的那几行。
如果id非索引,那么会锁表。
语句3分析:
id或者nickname只要有一个是索引或者是主键的话,那么锁住的行都是符合条件的行。
但是要注意一个情况,如果你查看索引数据值存在大量重复的数据的话(重复的数要是where条件值),那么有可能条件是不会走索引,而是进行全表查询,所以此时锁住的也是全表
因为索引扫描数超过30%时,会进行全表扫描
2.3
1
2
3
4
5
1
2
3
4
5#统计出单个用户领取该券的数量,上了悲观锁
select count(coup_id) as count_per from coupon_detail where coup_user_id = 10 and act_code = #{act_code} for update;
if(#{count_per} < #{coup_per_num}){
insert into coupon_detail values(1,act_code,'000000',10);
}
分析一下上面的select count语句可以发现他对coup_user_id = 10 and act_code = ‘000000’的数据上了锁,但是我们接下来要做的操作是insert操作,而不是update操作。 当两个事务刚进来的时候统计的数据都为0,也没办法给coup_user_id = 10 and act_code = ‘000000’的数据上锁,所以两个selec count for update 都能执行, 那么后面的insert操作也自然能成功,但是当有数据的时候,其中一个select for update会等待,这样的话就能成功。
这样的话悲观锁也是不行的,但是其实我们再回过头来想一下乐观锁为什么不行,是因为他分为了两个语句,而前面那个语句select count可能会读到脏数据,那么后面的利用某个字段去 update时判断值就有可能不对,那么如何保证统计的数据跟判断保持一致呢,因为mysql处理语句的时候是一条一条处理的,所以我们通过写成一条sql就可以达到前后数据一致问题。
此处我们使用insert的时候统计出当前领取数,并与可领取数进行对比,伪代码如下
1
2
1
2select * from coupon_activity where act_code = '000000';
insert into coupon_detail (coup_id,act_code,coup_code,coup_user_id) select (coup_id,act_code,coup_code,coup_user_id) from (select count(id) as num from coupon_detail where coup_user_id = 10 and act_code = '000000')temp where temp.num < #{coup_per_num}
上面这条复杂的sql在高并发时会发生死锁的情况,但是确能得到正确的结果。我们来分析一下死锁的情形。
上面这条语句最里面的select where coup-user-id = 10 and act-code = ‘000000’ 会锁住这一行数据,但是当数据库没有值的时候,就上不了锁,那么另外一个事务的select也能查询, 但是两个事务都对coup_user-id = 10 and act-code = ‘000000’上锁了,那么insert的时候两者都处于等待对方释放锁的状态,所以就发生了死锁,数据库解决死锁之后,只有一条数据 插入成功,这样也就得到了我们需要的结果。