一:事务四要素
- 原子性 (Atomicity):要么全部完成,要么全部不完成(通过redo log 日志实现);
- 一致性 (Consistency):一个事务单元需要提交之后才会被其他事务可见(通过undo log 日志实现);
- 隔离性 (Isolation):并发事务之间不会互相影响,设立了不同程度的隔离级别,通过适度的破坏一致性,得以提高性能(通过 lock 实现);
- 持久性 (Durability):事务提交后即持久化到磁盘不会丢失(通过redo log 和undo log日志实现)。
二:并发事务问题:
**脏读:**没有提交的事务被其他事务读取到了
不可重复读:同一个事务对同一条记录读取两遍,两次读出来的结果竟然不一样。(针对update语句)
**幻读:**同样的条件,第一次和第二次读出来的记录数不一样(针对insert语句)
**丢失更新:**当两个或多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题--最后的更新覆盖了由其他事务所做的更新。
三:隔离级别
四种不同的隔离级别:
- 读未提交(Read Uncommitted):可以读取未提交的记录,会出现脏读,幻读,不可重复读,所有并发问题都可能遇到;
- 读已提交(Read Committed):事务中只能看到已提交的修改,不会出现脏读现象,但是会出现幻读,不可重复读;(大多数数据库的默认隔离级别都是 RC,但是 MySQL InnoDb 默认是 RR)
- 可重复读(Repeatable Read):MySQL InnoDb 默认的隔离级别,解决了不可重复读问题,但是任然存在幻读问题;(MySQL 的实现有差异,后面介绍)
- 序列化(Serializable):最高隔离级别,啥并发问题都没有。
存在的问题
"脏读"、"不可重复读"和"幻读",其实都是数据库读一致性问题,必须由数据库提供一定的事务隔离机制来解决。
查看和设置 MySQL 的隔离级别
查看MySQL 正在使用什么隔离级别
select @@tx_isolation;
这里要注意的是在 MySQL 中变量一般分为两类:
- 用户变量 :用户变量的变量名格式为
@variable,
- 系统变量 :系统变量的格式为
@@variable,tx_isolation
是系统变量,所以变量名为@@tx_isolation,而系统变量又分为以下两种:
- 全局变量: 默认情况下使用
select @@variable
查询出来的是会话变量的值,也可以写作select @@session.variable
或者select @@local.variable
- 会话变量: 如果要查询全局变量的值,则使用
select @@global.variable
- 全局变量: 默认情况下使用
设置隔离级别
- set transaction isolation level read uncommitted;
- set transaction isolation level read committed;
- set transaction isolation level repeatable read;
- set transaction isolation level serializable;
四:锁分类
锁是实现隔离级别的几种方式之一,除了锁,实现并发问题的方式还有时间戳时间戳时间戳,多版本控制多版本控制多版本控制等等,这些也可以称为无锁的并发控制无锁的并发控制。传统的隔离级别是基于锁实现的,这种方式叫做 基于锁的并发控制(Lock-Based Concurrent Control,简写 LBCC)
1. 按锁粒度分:
- 全局锁:锁整Database,由MySQL的server层实现
- 表级锁:锁某Table,由MySQL的server层实现
- 行级锁:锁某Row的索引,也可锁定行索引之间的间隙,由存储引擎实现【InnoDB】
2. 按锁功能分:
- 共享锁Shared Locks(S锁,也叫读锁)
- 排他锁Exclusive Locks(X锁,也叫写锁)
五:MySQL 加锁流程
当用户执行这条 SQL 时,MySQL Server 和 InnoDb 之间的执行流程:
从图中可以看到当 UPDATE 语句被发给 MySQL 后,MySQL Server 会根据 WHERE 条件读取第一条满足条件的记录,然后 InnoDB 引擎会将第一条记录返回并加锁(current read),待 MySQL Server 收到这条加锁的记录之后,会再发起一个 UPDATE 请求,更新这条记录。一条记录操作完成,再读取下一条记录,直至没有满足条件的记录为止。因此,MySQL 在操作多条记录时 InnoDB 与 MySQL Server 的交互是一条一条进行的,加锁也是一条一条依次进行的,先对一条满足条件的记录加锁,返回给 MySQL Server,做一些 DML 操作,然后在读取下一条加锁,直至读取完毕。理解这一点,对我们后面分析复杂 SQL 语句的加锁过程将很有帮助。
注意,如果 SQL 语句无法使用索引时会走主索引实现全表扫描,这个时候 MySQL 会给整张表的所有数据行加记录锁。如果一个 WHERE 条件无法通过索引快速过滤,存储引擎层面就会将所有记录加锁后返回,再由 MySQL Server 层进行过滤。不过在实际使用过程中,MySQL 做了一些改进,如果是 RC 隔离级别,在 MySQL Server 过滤条件发现不满足后,会调用 unlock_row 方法,把不满足条件的记录锁释放掉(违背了 2PL 的约束)。这样做可以保证最后只会持有满足条件记录上的锁,但是每条记录的加锁操作还是不能省略的。如果是 RR 隔离级别,一般情况下 MySQL 是不能这样优化的,除非设置了 innodb_locks_unsafe_for_binlog
参数,这时也会提前释放锁,并且不加 GAP 锁,这就是所谓的 semi-consistent read。。可见在没有索引时,不仅会消耗大量的锁资源,增加数据库的开销,而且极大的降低了数据库的并发性能,所以说,更新操作一定要记得走索引。
六:基本的加锁规则
虽然 MySQL 的锁各式各样,但是有些基本的加锁原则是保持不变的,譬如:快照读是不加锁的,更新语句肯定是加排它锁的,RC 隔离级别是没有间隙锁的等等。这些规则整理如下:
-
常见语句的加锁
- SELECT ... 语句正常情况下为快照读,不加锁;
- SELECT ... LOCK IN SHARE MODE 语句为当前读,加 S 锁;
- SELECT ... FOR UPDATE 语句为当前读,加 X 锁;
- 常见的 DML 语句(如 INSERT、DELETE、UPDATE)为当前读,加 X 锁;
- 常见的 DDL 语句(如 ALTER、CREATE 等)加表级锁,且这些语句为隐式提交,不能回滚;
-
表锁
- 表锁(分 S 锁和 X 锁)
- 意向锁(分 IS 锁和 IX 锁)
- 自增锁(一般见不到,只有在 innodb_autoinc_lock_mode = 0 或者 Bulk inserts 时才可能有)
-
行锁
- LOCK_REC_NOT_GAP:只锁记录,记录锁(分 S 锁和 X 锁)
- LOCK_GAP:间隙锁,锁两个记录之间的 GAP,防止记录插入(分 S 锁和 X 锁);
- LOCK_ORDINARY:也称为 Next-Key Lock,锁一条记录及其之前的间隙,这是 RR 隔离级别用的最多的锁,从名字也能看出来(分 S 锁和 X 锁);
- LOCK_INSERT_INTENSION:插入意向 GAP 锁,插入记录时使用,是 LOCK_GAP 的一种特例。
-
行锁分析
- 行锁都是加在索引上的,最终都会落在聚簇索引上;
- 加行锁的过程是一条一条记录加的;
-
锁冲突
- S 锁和 S 锁兼容,X 锁和 X 锁冲突,X 锁和 S 锁冲突;
-
不同隔离级别下的锁
- 上面说 SELECT ... 语句正常情况下为快照读,不加锁;但是在 Serializable 隔离级别下为当前读,加 S 锁;
- RC 隔离级别下没有间隙锁和 Next-key 锁(特殊情况下也会有:purge + unique key);
七:全局锁
全局锁是对整个数据库实例加锁,加锁后整个实例就处于只读状态,后续的DML的写语句,DDL语句, 已经更新操作的事务提交语句都将被阻塞。其典型的使用场景是做全库的逻辑备份,对所有的表进行锁 定,从而获取一致性视图,保证数据的完整性。
加全局锁的命令为:
flush tables with read lock;
释放全局锁的命令为:
unlock tables;
或者断开加锁session的连接,自动释放全局锁。
说到全局锁用于备份这个事情,还是很危险的。因为如果在主库上加全局锁,则整个数据库将不能写 入,备份期间影响业务运行,如果在从库上加全局锁,则会导致不能执行主库同步过来的操作,造成主 从延迟。 对于innodb这种支持事务的引擎,使用mysqldump备份时可以使用--single-transaction参数,利用 mvcc提供一致性视图,而不使用全局锁,不会影响业务的正常运行。而对于有MyISAM这种不支持事务 的表,就只能通过全局锁获得一致性视图,对应的mysqldump参数为--lock-all-tables。
举个栗子:
# 提交请求锁定所有数据库中的所有表,以保证数据的一致性,全局读锁【LBCC】
mysqldump -uroot -p --host=localhost --all-databases --lock-all-tables >
/root/db.sql
# 一致性视图【MVCC】
mysqldump -uroot -p --host=localhost --all-databases --single-transaction >
/root/db.sql
八:表级锁
MySQL的表级锁有四种:
- 表读锁(Table Read Lock)
- 表写锁(Table Write Lock)
- 元数据锁(meta data lock,MDL)
- 自增锁(AUTO-INC Locks)
看表锁定状态
show status like 'table%';
- table_locks_immediate:产生表级锁定的次数;
- table_locks_waited:出现表级锁定争用而发生等待的次数;
手动增加表锁:
lock table 表名称 read(write),表名称2 read(write),其他;
# 举例:
lock table t read; #为表t加读锁
lock table t write; #为表t加写锁
查看表锁情况:
show open tables;
删除表锁:
unlock tables;
元数据锁
元数据锁不需要显式指定,在访问一个表的时候会被自动加上,锁的作用是保证读写的正确性。 可以想象一下:如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更, 删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。 因此,在 MySQL 5.5 版本中引入了元数据锁,当对一个表做增删改查操作的时候,加 元数据 读锁;当 要对表做结构变更操作的时候,加 元数据 写锁。
- 读锁是共享的,是互相不阻塞的:因此你可以有多个线程同时对一张表加读锁,保证数据在读取的 过程中不会被其他线程修改。
- 写锁则是排他的:也就是说一个写锁会阻塞其他的写锁和读锁,用来保证变更表结构操作的安全 性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。
AUTO_INC 锁
AUTO_INC 锁又叫自增锁(一般简写成 AI 锁),它是一种特殊类型的表锁,当插入的表中有自增列(AUTO_INCREMENT)的时候可能会遇到。当插入表中有自增列时,数据库需要自动生成自增值,在生成之前,它会先为该表加 AUTO_INC 表锁,其他事务的插入操作阻塞,这样保证生成的自增值肯定是唯一的。AUTO_INC 锁具有如下特点:
- AUTO_INC 锁互不兼容,也就是说同一张表同时只允许有一个自增锁;
- 自增锁不遵循二段锁协议,它并不是事务结束时释放,而是在 INSERT 语句执行结束时释放,这样可以提高并发插入的性能。
- 自增值一旦分配了就会 +1,如果事务回滚,自增值也不会减回去,所以自增值可能会出现中断的情况。
显然,AUTO_INC 表锁会导致并发插入的效率降低,为了提高插入的并发性,MySQL 从 5.1.22 版本开始,引入了一种可选的轻量级锁(mutex)机制来代替 AUTO_INC 锁,我们可以通过参数 innodb_autoinc_lock_mode
控制分配自增值时的并发策略。参数 innodb_autoinc_lock_mode
可以取下列值:
-
innodb_autoinc_lock_mode = 0 (traditional lock mode)
- 使用传统的 AUTO_INC 表锁,并发性比较差。
-
innodb_autoinc_lock_mode = 1 (consecutive lock mode)
- MySQL 默认采用这种方式,是一种比较折中的方法。
- MySQL 将插入语句分成三类:
Simple inserts、Bulk inserts、Mixed-mode inserts
。通过分析 INSERT 语句可以明确知道插入数量的叫做Simple inserts
,譬如最经常使用的 INSERT INTO table VALUE(1,2) 或 INSERT INTO table VALUES(1,2), (3,4);通过分析 INSERT 语句无法确定插入数量的叫做Bulk inserts
,譬如 INSERT INTO table SELECT 或 LOAD DATA 等;还有一种是不确定是否需要分配自增值的,譬如 INSERT INTO table VALUES(1,'a'), (NULL,'b'), (5, 'C'), (NULL, 'd') 或 INSERT ... ON DUPLICATE KEY UPDATE,这种叫做Mixed-mode inserts
。 - Bulk inserts 不能确定插入数使用表锁;Simple inserts 和 Mixed-mode inserts 使用轻量级锁 mutex,只锁住预分配自增值的过程,不锁整张表。Mixed-mode inserts 会直接分析语句,获得最坏情况下需要插入的数量,一次性分配足够的自增值,缺点是会分配过多,导致浪费和空洞。
- 这种模式的好处是既平衡了并发性,又能保证同一条 INSERT 语句分配的自增值是连续的。
-
innodb_autoinc_lock_mode = 2 (interleaved lock mode)
- 全部都用轻量级锁 mutex,并发性能最高,按顺序依次分配自增值,不会预分配。
- 缺点是不能保证同一条 INSERT 语句内的自增值是连续的,这样在复制(replication)时,如果 binlog_format 为 statement-based(基于语句的复制)就会存在问题,因为是来一个分配一个,同一条 INSERT 语句内获得的自增值可能不连续,主从数据集会出现数据不一致。所以在做数据库同步时要特别注意这个配置。
MySQL 表锁的加锁规则如下:
-
对于读锁
- 持有读锁的会话可以读表,但不能写表;
- 允许多个会话同时持有读锁;
- 其他会话就算没有给表加读锁,也是可以读表的,但是不能写表;
- 其他会话申请该表写锁时会阻塞,直到锁释放。
-
对于写锁
- 持有写锁的会话既可以读表,也可以写表;
- 只有持有写锁的会话才可以访问该表,其他会话访问该表会被阻塞,直到锁释放;
- 其他会话无论申请该表的读锁或写锁,都会阻塞,直到锁释放。
锁的释放规则如下:
- 使用 UNLOCK TABLES 语句可以显示释放表锁;
- 如果会话在持有表锁的情况下执行 LOCK TABLES 语句,将会释放该会话之前持有的锁;
- 如果会话在持有表锁的情况下执行 START TRANSACTION 或 BEGIN 开启一个事务,将会释放该会话之前持有的锁;
- 如果会话连接断开,将会释放该会话所有的锁。
九:行级锁
InnoDB的行级锁,按照锁定范围来说,分为四种:
- 记录锁(Record Locks):锁定索引中一条记录。
- 间隙锁(Gap Locks):要么锁住索引记录中间的值,要么锁住第一个索引记录前面的值或者 最后一个索引记录后面的值。
- 临键锁(Next-Key Locks):是索引记录上的记录锁和在索引记录之前的间隙锁的组合(间 隙锁 + 记录锁)。
- 插入意向锁(Insert Intention Locks):做insert操作时添加的对记录id的
1. 记录锁
记录锁(Record Locks)仅仅锁住索引记录的一行,在单条索引记录上加锁。记录锁锁住的永远是索 引,而非记录本身,即使该表上没有任何显示索引,那么innodb会在后台创建一个隐藏的聚簇索引索 引,那么锁住的就是这个隐藏的聚簇索引索引。
2. 间隙锁
(1)间隙锁(Gap Locks),仅仅锁住一个索引区间(开区间,不包括双端端点)。
(2)在索引记录之间的间隙中加锁,或者是在某一条索引记录之前或者之后加锁,并不包括该索引记录 本身。
(3)间隙锁可用于防止幻读,保证索引间隙不会被插入数据。
(4)在可重复读(REPEATABLE READ)这个隔离级别下生效。
值得注意的是,间隙锁和间隙锁之间是互不冲突的,间隙锁唯一的作用就是为了防止其他事务的插入,所以加间隙 S 锁和加间隙 X 锁没有任何区别。
3. 临键锁
(1)临键锁(Next-Key Locks)相当于记录锁 + 间隙锁【左开右闭区间】,例如(5,8]
(2)默认情况下,innodb使用临键锁来锁定记录,但在不同的场景中会退化
(3)当查询的索引含有唯一属性的时候,临键锁会进行优化,将其降级为记录锁,即仅锁住索引本身, 不是范围。
4. 插入意向锁
(1)插入意向锁(Insert Intention Locks)是一种在 INSERT 操作之前设置的一种特殊的间隙锁。
(2)插入意向锁表示了一种插入意图,即当多个不同的事务,同时往同一个索引的同一个间隙中插入数 据的时候,它们互相之间无需等待,即不会阻塞。
(3)插入意向锁不会阻止插入意向锁,但是插入意向锁会阻止其他间隙写锁(排他锁)、记录锁。
行锁的兼容矩阵
其中,**第一行表示已有的锁,第一列表示要加的锁,**它们之间的兼容矩阵如下图所示:
十:MySQL 中观察行锁
有两种方式可以在 MySQL 中观察行锁,Mysql5.7版本
select * from information_schema.innodb_locks;
show engine innodb status;
-
如果发生死锁,也可以通过这个命令来定位死锁发生的原因。不过在这之前需要先打开 Innodb 的锁监控,可以看到更加详细的锁信息:
set global innodb_status_output = ON;
set global innodb_status_output_locks = ON;
-