MySQL锁解决并发问题详解

原文地址:http://leihuang.org/2015/09/10/mysql-lock-concurrency/html

文章分为如下几个要点mysql

  1. 问题描述以及解决过程
  2. MySQL锁机制
  3. 数据库加锁分析

下面讨论的都是基于MySQL的InnoDB。web

0. 问题描述以及解决过程

由于涉及到公司利益问题,因此下面不少代码和数据库信息,进行了缩减和修改,望见谅。sql

业务场景是优惠券系统规则规定了一个优惠券活动最多可发行多少张优惠券和每一个用户最多可领取优惠券数量。数据库

下面列出两张表的结构。session

活动表并发

CREATE TABLE `coupon_activity` ( `act_id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键', `act_code` char(6) NOT NULL DEFAULT '' COMMENT '活动编码', `coup_issue_num` int(11) NOT NULL DEFAULT '0' COMMENT '优惠券发行量', `coup_per_num` int(11) NOT NULL DEFAULT '0' COMMENT '单个用户可领取数', PRIMARY KEY (`act_id`), UNIQUE KEY `act_code_idx` (`act_code`) COMMENT '活动编码惟一索引' ) ENGINE=InnoDB DEFAULT CHARSET=utf8;

优惠券明细表svg

CREATE TABLE `coupon_detail` ( `coup_id` int(11) NOT NULL AUTO_INCREMENT, `act_code` char(6) NOT NULL DEFAULT '' COMMENT '活动编号', `coup_code` char(6) NOT NULL DEFAULT '' COMMENT '优惠券编码', `coup_user_id` int(11) NOT NULL DEFAULT '0' COMMENT '领取券用户id', PRIMARY KEY (`coup_id`), UNIQUE KEY `coup_code_idx` (`coup_code`) USING BTREE COMMENT '优惠券编码惟一索引', KEY `coup_user_idx` (`coup_user_id`) USING BTREE COMMENT '用户id普通索引', KEY `act_code_idx` (`act_code`) USING BTREE COMMENT '活动编码普通索引' ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='优惠券明细表';

假设一个优惠券活动设置的最大发行量为1000张优惠券,单个用户最多可领取1张优惠券。以下高并发

insert into coupon_activity values (1,'000000',1000,1,0);

img

不考虑到并发的话,用户10领取act_code=’000000’活动的优惠券 执行的sql以下。注意#{}里面的字段表示以前的sql查询出来的字段。性能

begin;
select * from coupon_activity where act_code = '000000';
select count(coup_id) as count_all from coupon_detail where act_code = #{act_code};
select count(coup_id) as count_per from coupon_detail where coup_user_id = 10 and act_code = #{act_code};
//插入明细表 首先判断是否当前领用量小于活动发行量,当前用户领取量是否小于每一个用户可领取数
if(#{count_all} < #{coup_issue_num} && #{count_per} < #{coup_per_num}){
    insert into coupon_detail values(1,act_code,'000000',10);
}
commit;

其实上面的代码不须要用到事务,可是为了体现接下来的并发时的情形,我就加上了事务。

首先咱们来讨论,最大发行量发生并发时的问题。

假设如今优惠券领取了999张,此时有两个用户进来领取,也就是两个事务同时进行,

首先两个事务数出活动当前领用量都是999张,因此if判断经过,两个用户均可以执行insert语句,这样的话就会多出一张券没有限制住。

如何来解决这个问题呢,其实比较简单,能够利用乐观锁的方式(后面会详细介绍锁的知识),就是我假设并发不会发生,可是我在update数据的时候我会判断他是否知足条件。
此时咱们就要另外借助一个字段来完成coup_num_current:当前券领用量。每次领券后咱们都要更新这个字段,使其加1. 此时咱们加个判断看起是否小于最大
发行量。

alter table coupon_activity add coup_num_current int(11) NOT NULL DEFAULT '0' COMMENT '当前券领用量';

那么此时的执行代码就变成了下面这样的。

begin;
select * from coupon_activity where act_code = '000000';
select count(coup_id) as count_per from coupon_detail where coup_user_id = 10 and act_code = #{act_code};
//插入明细表 首先判断是否当前领用量小于活动发行量,当前用户领取量是否小于每一个用户可领取数
if(#{count_per} < #{coup_per_num}){
    insert into coupon_detail values(1,act_code,'000000',10);
}
int i = update coupon_activity set coup_num_current = coup_num_current + 1 where act_code = #{act_code} and coup_num_current < #{coup_issue_num} //若是未有数据更新 (代表不知足coup_num_current < #{coup_issue_num}即已达最大领用量) if(i == 0){ throw new Exception("此处是为了让以前的insert回滚");
}
commit;

此时咱们解决了最大领用量的并发问题,下面咱们来讨论下如何限制住单个用户可领取数,这个要复杂一些。由于设计到的操做要多一些,首先你要统计出已经领取的数量,
而后插入新数据,而不像限制优惠券发行量那样只有一个update语句,因此用以前的那种乐观锁不行,由于统计出来的数据颇有多是脏数据,那么乐观锁不行的话,那用
悲观锁来解决呢?先来分析下,伪代码以下

#统计出单个用户领取该券的数量,上了悲观锁
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的时候统计出当前领取数,并与可领取数进行对比,伪代码以下

select * 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的时候二者都处于等待对方释放锁的状态,因此就发生了死锁,数据库解决死锁以后,只有一条数据
插入成功,这样也就获得了咱们须要的结果。

在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不会加任意锁。

事务能够经过如下语句显示给记录集加共享锁或者排他锁:

SELECT * 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操做,属于快照读,不加锁。(固然,也有例外,下面会分析)

select * from table where ?;

当前读:特殊的读操做,插入/更新/删除操做,属于当前读,须要加锁。

select * 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 事务隔离级别

对锁进行分析前必需要先了解事务隔离级别的关系

详细见,MySQL四种事务隔离级别

隔离级别 脏读(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),查找命令以下

mysql>select @@session.tx_isolation;
+------------------------+
| @@session.tx_isolation |
+------------------------+
| REPEATABLE-READ        |
+------------------------+
1 row in set
  • 脏读:当一个事务进行的操做还未提交时,另一个事务读到了修改的数据,这就是脏读,可是RR级别事务避免了脏读。
  • 不可重复读:是指在一个事务内,屡次读同一数据。在这个事务尚未结束时,另一个事务也访问该同一数据。
    那么,在第一个事务中的两次读数据之间,因为第二个事务的修改,那么第一个事务两次读到的的数据多是不同的。
    这样就发生了在一个事务内两次读到的数据是不同的,所以称为是不可重复读。可是,RR级别是不会出现不同的结果的,即便另外一个事务提交了修改他也查不到变化。
  • 幻读:第一个事务对一个表中的数据进行了修改,这种修改涉及到表中的所有数据行。同时,第二个事务也修改这个表中的数据,
    这种修改是向表中插入一行新数据。那么,之后就会发生操做第一个事务的用户发现表中还有没有修改的数据行,就好象发生了幻觉同样。

2.2 sql语句加锁分析

参考:MySQL加锁分析

#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分析:

  1. 若是id是主键或者是索引的话,那么锁定的行只有符合条件的那几行。

  2. 若是id非索引,那么会锁表。

语句3分析:

  1. id或者nickname只要有一个是索引或者是主键的话,那么锁住的行都是符合条件的行。

可是要注意一个状况,若是你查看索引数据值存在大量重复的数据的话(重复的数要是where条件值),那么有可能条件是不会走索引,而是进行全表查询,因此此时锁住的也是全表。

由于索引扫描书超过30%时,会进行全表扫描。