锁机制
- 事务与锁是不同的。事务具有ACID( 原子性、一致性、隔离性和持久性),锁是用于解决隔离级别的一种机制。事务的隔离级别通过锁的机制来实现。
- 为了保证数据并发访问时的一致性和有效性,任何一个数据库都存在锁机制。锁机制的优劣直接影响到数据库的并发处理能力和系统性能,所以锁机制也就成为了各种数据库的核心技术之一。
- 锁机制是为了解决数据库的并发控制问题而产生的。如在同一时刻,客户端对同一个表做更新或查询操作,为了保证数据的一致性,必须对并发操作进行控制。同时,锁机制也为实现 MySQL 的各个隔离级别提供了保证。
- 可以将锁机制理解为使各种资源在被并发访问时变得有序所设计的一种规则。
- 如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。从这个角度来说,锁对数据库显得尤其重要,也更加复杂。
锁分类
- 按照锁的粒度划分:行锁、表锁、页锁
- 按照锁的使用方式划分:共享锁、排它锁(悲观锁的一种实现)
- 还有两种思想上的锁:悲观锁、乐观锁。
- InnoDB 中有几种行级锁类型:Record Lock、Gap Lock、Next-key Lock
- 按锁级别分类,可分为共享锁、排他锁和意向锁。
MySQL 的锁机制最显著的特点是不同的存储引擎支持不同的锁机制。比如,MyISAM 和 MEMORY 存储引擎采用的是表级锁,不支持行锁;BDB 存储引擎采用的是页面锁,但也支持表级锁;InnoDB 存储引擎既支持行级锁,也支持表级锁,但默认情况下是采用行级锁。不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同 一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。InnoDB 是支持行锁的, 这也是 MyISAM 被 InnoDB 替代的重要原因之一。
行锁(row-level locking)
- 行级锁的锁定颗粒度在 MySQL 中是最小的,只针对操作的当前行进行加锁,所以行级锁发生锁定资源争用的概率也最小。
- 行级锁能够给予应用程序尽可能大的并发处理能力,从而提高需要高并发应用系统的整体性能。虽然行级锁在并发处理能力上面有较大的优势,但也因此带来了不少弊端。
- 由于锁定资源的颗粒度很小,所以每次获取锁和释放锁需要做的事情也就更多,带来的消耗自然也就更大。此外,行级锁也最容易发生死锁。所以说行级锁最大程度地支持并发处理的同时,也带来了最大的锁开销。
- 行级锁主要应用于 InnoDB 存储引擎。
- 随着锁定资源颗粒度的减小,锁定相同数据量的数据所需要消耗的内存数量也越来越多,实现算法也会越来越复杂。不过,随着锁定资源颗粒度的减小,应用程序的访问请求遇到锁等待的可能性也会随之降低,系统整体并发度也会随之提升。
- 行级锁按照使用方式分为共享锁和排他锁。
表锁(table-level locking)
- 表级锁为表级别的锁定,会锁定整张表,可以很好的避免死锁,是 MySQL 中最大颗粒度的锁定机制。
- 一个用户在对表进行写操作(插入、删除、更新等)时,需要先获得写锁(也叫排斥锁),这会阻塞其它用户对该表的所有读写操作。没有写锁时,其它读取的用户才能获得读锁,读锁之间是不相互阻塞的。
- 表级锁最大的特点就是实现逻辑非常简单,带来的系统负面影响最小。所以获取锁和释放锁的速度很快。当然,锁定颗粒度大带来最大的负面影响就是出现锁定资源争用的概率会很高,致使并发度大打折扣。
- 不过在某些特定的场景中,表级锁也可以有良好的性能。例如,READ LOCAL 表级锁支持某些类型的并发写操作。另外,写锁也比读锁有更高的优先级,因此一个写锁请求可能会被插入到读锁队列的前面(写锁可以插入到锁队列中读锁的前面,反之读锁则不能插入到写锁的前面)。
- 使用表级锁的主要是 MyISAM,MEMORY,CSV 等一些非事务性存储引擎。
- 尽管存储引擎可以管理自己的锁,MySQL 本身还是会使用各种有效的表级锁来实现不同的目的。例如,服务器会为诸如 ALTER TABLE 之类的语句使用表级锁,而忽略存储引擎的锁机制。
页锁(page-level locking)
- 页级锁是 MySQL 中比较独特的一种锁定级别,在其他数据库管理软件中并不常见。
- 页级锁的颗粒度介于行级锁与表级锁之间,所以获取锁定所需要的资源开销,以及所能提供的并发处理能力同样也是介于上面二者之间。另外,页级锁和行级锁一样,会发生死锁。
- 页级锁主要应用于 BDB 存储引擎。
共享锁(Shared Lock)
共享锁的代号是
S
,是Share
的缩写,也可称为读锁。是一种可以查看但无法修改和删除的数据锁。共享锁的锁粒度是行或者元组(多个行)。一个事务获取了共享锁之后,可以对锁定范围内的数据执行读操作。会阻止其它事务获得相同数据集的排他锁。
加锁方式:
1
2
3
4-- 行级读锁
select * from T where id=1 lock in share mode;
-- 表级读锁
lock table user_info read;释放方式:
1
commit、rollback;
排他锁(EXclusive Lock)
排他锁的代号是
X
,是eXclusive
的缩写,也可称为写锁,是基本的锁类型。排他锁的粒度与共享锁相同,也是行或者元组。一个事务获取了排他锁之后,可以对锁定范围内的数据执行写操作。允许获得排他锁的事务更新数据,阻止其它事务取得相同数据集的共享锁和排他锁。
当有事务对数据加写锁后,其他事务不能再对锁定的数据加任何锁,又因为 InnoDB 对 select 语句默认不加锁,所以其他事务除了不能写操作外,照样是允许读的(尽管不允许加读锁)。
主要为了在事务进行写操作时,不允许其他事务修改。
加锁方式:
1
2
3
4
5-- 行级写锁
自动:DML语句默认加写锁
手动:select * from T where id=1 for update;
-- 表级写锁
lock table user_info write;释放方式:
1
commit、rollback;
意向锁(Intention Lock)
- 又称I锁。针对表锁。
- 为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB 还有两种内部使用的意向锁。
- 意向锁是一种表锁,锁定的粒度是整张表,分为**意向共享锁(IS)和意向排他锁(IX)**两类。
- 意向共享锁表示一个事务有意对数据上共享锁或者排他锁。“有意”表示事务想执行操作但还没有真正执行。
- 当有事务给表的数据行加了共享锁或排他锁,同时会给表设置一个标识,代表已经有行锁了,其他事务要想对表加表锁时,就不必逐行判断有没有行锁可能跟表锁冲突了,直接读这个标识就可以确定自己该不该加表锁。特别是表中的记录很多时,逐行判断加表锁的方式效率很低。而这个标识就是意向锁。
- 意向共享锁,IS锁,对整个表加共享锁之前,需要先获取到意向共享锁。
- 意向排他锁,IX锁,对整个表加排他锁之前,需要先获取到意向排他锁。
主要是为了提高加表锁的效率。
加锁方式:
无法手动创建。
锁和锁之间的关系,要么是相容的,要么是互斥的。
- 锁 a 和锁 b 相容是指:操作同样一组数据时,如果事务 t1 获取了锁 a,另一个事务 t2 还可以获取锁 b;
- 锁 a 和锁 b 互斥是指:操作同样一组数据时,如果事务 t1 获取了锁 a,另一个事务 t2 在 t1 释放锁 a 之前无法获取锁 b
其中共享锁、排他锁、意向共享锁、意向排他锁相互之间的兼容/互斥关系如下表所示,其中 Y 表示相容,N 表示互斥。
如果一个事务请求的锁模式与当前的锁兼容,InnoDB 就将请求的锁授予该事务;反之,如果两者不兼容,该事务就要等待锁释放。
为了尽可能提高数据库的并发量,需每次锁定的数据范围越小越好,越小的锁其耗费的系统资源越多,系统性能下降。为在高并发响应和系统性能两方面进行平衡,这样就产生了“锁粒度”的概念。
并发控制
当程序中可能出现并发的情况时,就需要保证在并发情况下数据的准确性,以此确保当前用户和其他用户一起操作时,所得到的结果和他单独操作时的结果是一样的,这就叫做并发控制。并发控制的目的是保证一个用户的工作不会对另一个用户的工作产生不合理的影响。
没有做好并发控制,就可能导致脏读、幻读和不可重复读等问题。
常说的并发控制,一般都和数据库管理系统(DBMS)有关。在 DBMS 中并发控制的任务,是确保多个事务同时增删改查同一数据时,不破坏事务的隔离性、一致性和数据库的统一性。
实现并发控制的主要手段分为乐观并发控制和悲观并发控制两种
无论是悲观锁还是乐观锁,都是人们定义出来的概念,可以认为是一种思想。其实不仅仅是关系型数据库系统中有乐观锁和悲观锁的概念,像 hibernate、tair、memcache 等都有类似的概念。所以,不应该拿乐观锁、悲观锁和其他的数据库锁等进行对比。乐观锁比较适用于读多写少的情况(多读场景),悲观锁比较适用于写多读少的情况(多写场景)。
悲观锁(Pessimistic Lock)
在关系数据库管理系统里,悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作对某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。
悲观锁,具有强烈的独占和排他特性,正如其名,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度(悲观),因此,在整个数据处理过程中,将数据处于锁定状态。
悲观锁的实现,往往依靠数据库提供的锁机制 (也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)
之所以叫做悲观锁,是因为这是一种对数据的修改持有悲观态度的并发控制方式。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。
悲观锁的实现:
- 传统的关系型数据库使用这种锁机制,比如行锁、表锁、读锁、写锁等,都是在操作之前先上锁。
- Java 里面的同步
synchronized
关键字的实现。
悲观锁主要分为共享锁和排他锁
mysql中实现悲观锁的具体流程:
- 在对任意记录进行修改前,先尝试为该记录加上排他锁(exclusive locking)
- 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。 具体响应方式由开发者根据实际需要决定。
- 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
- 期间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常。
- 总而言之就是一句话:mysql 中悲观锁的实现是通过排他锁来实现的
在 mysql(InnoDB) 中使用悲观锁:
1 | 1.开始事务 |
通过下面的例子来说明:
1. 当你手动加上排它锁,但是并没有关闭 mysql 中的 autocommit。
1 | 1.这里先给user表所有的行加上行锁的排他锁 |
2. 关闭 mysql 中的 autocommit 后的正常流程
1 | 窗口1: |
上面的例子展示了排它锁的原理:一个锁在某一时刻只能被一个线程占有,其它线程必须等待锁被释放之后才可能获取到锁或者进行数据的操作。
悲观锁的优点和不足
悲观锁实际上是采取了“先取锁再访问”的策略,为数据的处理安全提供了保证,但是在效率方面,由于额外的加锁机制产生了额外的开销,并且增加了死锁的机会。并且降低了并发性;当一个事务加锁一行数据的时候,其他事务必须等待该事务提交之后,才能操作这行数据。
乐观锁(Optimistic Locking)
在关系数据库管理系统里,乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。
乐观锁( Optimistic Locking ) 相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。
相对于悲观锁,在对数据库进行处理的时候,乐观锁并不会使用数据库提供的锁机制。
mysql 实现乐观锁一般来说有3种方式:
- 使用数据版本(Version)记录机制实现,这是乐观锁最常用的一种实现方式。一般是通过为数据库表增加一个数字类型的 “version” 字段,表示数据被修改的次数。当读取数据时,将 version 字段的值一同读出,数据每更新一次,对此 version 值加一。当提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的 version 值进行比对,如果数据库表当前版本号与第一次取出来的 version 值相等,就进行更新操作,否则认为是过期数据,正在提交的事务会进行回滚。
- 第二种实现方式和第一种差不多,同样是在需要乐观锁控制的 table 中增加一个字段,名称无所谓,字段类型使用时间戳(timestamp), 和上面的 version 类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致就更新,否则就是版本冲突。
- CAS 实现:Java 中 java.util.concurrent.atomic 包下面的原子变量使用了乐观锁的一种 CAS 实现方式
乐观锁的优点和不足
乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。但如果直接简单这么做,还是有可能会遇到不可预期的结果,例如两个事务都读取了数据库的某一行,经过修改以后写回数据库,这时就遇到了问题。
具体流程
- 主要就是两个步骤:冲突检测和数据更新。比较典型的就是 CAS (Compare and Swap)。
- CAS 即比较并交换。是解决多线程并行情况下使用锁造成性能损耗的一种机制,CAS 操作包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值(V)与预期原值(A)相匹配,那么处理器会自动将该位置值更新为新值(B)。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。CAS 有效地说明了“我认为位置(V)应该包含值(A)。如果包含该值,则将新值(B)放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可”。Java 中,sun.misc.Unsafe 类提供了硬件级别的原子操作来实现这个 CAS。java.util.concurrent 包下大量的类都使用了这个 Unsafe.java 类的 CAS 操作。
- 当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。比如前面的扣减库存问题,
通过乐观锁可以实现如下:
1
2
3
4查出商品库存信息,quantity=3
select quantity from items where id=1;
修改商品库存为2
update items set quantity=2 where id=1 and quantity = 3;在更新之前,先查询一下库存表中当前库存数(quantity),然后在做 update 的时候,以库存数作为一个修改条件。当提交更新的时候,判断数据库表对应记录的当前库存数与第一次取出来的库存数进行比对,如果数据库表当前库存数与第一次取出来的库存数相等,则予以更新,否则认为是过期数据。
以上更新语句存在一个比较严重的问题,即 ABA问题:
- 比如说线程一从数据库中取出库存数 3,这时候线程二也从数据库中取出库存数 3,并且线程二进行了一些操作变成了 2。
- 然后线程二又将库存数变成 3,这时候线程一进行 CAS 操作发现数据库中仍然是 3,然后线程一操作成功。
- 尽管线程一的 CAS 操作成功,但是不代表这个过程就是没有问题的。
一个比较好的解决办法,就是通过一个单独的可以顺序递增的 version 字段。优化如下:
乐观锁每次在执行数据修改操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行 +1 操作,否则就执行失败。因为每次操作的版本号都会随之增加,所以不会出现 ABA 问题。除了 version 以外,还可以使用时间戳,因为时间戳天然具有顺序递增性。
以上 SQL 其实还是有一定的问题的,就是一旦遇上高并发的时候,就只有一个线程可以修改成功,那么就会存在大量的失败。对于像淘宝这样的电商网站,高并发是常有的事,总让用户感知到失败显然是不合理的。所以,还是要想办法减少乐观锁的粒度。一个比较好的建议,就是减小乐观锁力度,最大程度的提升吞吐率,提高并发能力!如下:
1
2修改商品库存
update item set quantity=quantity-1 where id=1 and quantity-1 > 0;- 以上 SQL 语句中,如果用户下单数为 1,则通过
quantity - 1 > 0
的方式进行乐观锁控制。在执行过程中,会在一次原子操作中查询一遍 quantity 的值,并将其扣减掉 1。 - 高并发环境下锁粒度把控是一门重要的学问。选择一个好的锁,在保证数据安全的情况下,可以大大提升吞吐率,进而提升性能。
- 以上 SQL 语句中,如果用户下单数为 1,则通过
理解 CAS 底层
假如说有 3 个线程并发的要修改一个AtomicInteger的值,底层机制如下:
- 首先,每个线程都会先获取当前的值,接着走一个原子的 CAS 操作。原子的意思就是这个 CAS 操作一定是自己完整执行完的,不会被别人打断。
- 然后 CAS 操作里,会比较一下,现在的值是不是刚才获取到的那个值。如果是,说明没人改过这个值,然后设置成累加 1 之后的一个值。
- 同理,如果有人在执行 CAS 的时候,发现之前获取的值跟当前的值不一样,会导致 CAS 失败。失败之后,进入一个无限循环,再次获取值,接着执行 CAS 操作。
CAS 典型应用
java.util.concurrent.atomic 包下的类大多是使用 CAS 操作来实现的,比如 AtomicInteger、AtomicBoolean、AtomicLong。一般在竞争不是特别激烈的时候,使用该包下的原子操作性能比使用 synchronized 关键字的方式高效的多(查看 getAndSet(),可知如果资源竞争十分激烈的话,这个 for 循环可能会持续很久都不能成功跳出。不过这种情况可能需要考虑降低资源竞争才是)。
在较多的场景都可能会使用到这些原子类操作。一个典型应用就是计数了,在多线程的情况下需要考虑线程安全问题。
- 支持计数功能 Demo 实现
1 | public class Increment { |
在并发环境下对 count 进行自增运算是不安全的,为什么不安全以及如何解决这个问题呢?
- 为什么并发环境下的 count 自增操作不安全?因为 count++ 不是原子操作,而是三个原子操作的组合:
- 读取内存中的 count 值赋值给局部变量 temp;
- 执行 temp+1 操作;
- 将 temp 赋值给 count。
所以如果两个线程同时执行 count++ 的话,不能保证线程一按顺序执行完上述三步后线程二才开始执行。
- 并发环境下 count++ 不安全问题的解决方案
方案①:synchronized加锁。同一时间只有一个线程能加锁,其他线程需要等待锁,这样就不会出现 count 计数不准确的问题了:
1 | public class Increment { |
但是引入 synchronized 会造成多个线程排队的问题,相当于让各个线程串行化了,一个接一个的排队、加锁、处理数据、释放锁,下一个再进来。同一时间只有一个线程执行,这样的锁有点“重量级”了。这类似于悲观锁的实现,需要获取这个资源,就给它加锁,别的线程都无法访问该资源,直到操作完后释放对该资源的锁。虽然随着 Java 版本更新,也对 synchronized 做了很多优化,但是处理这种简单的累加操作,仍然显得“太重了”。
方案②:Atomic 原子类。对于 count++ 的操作,完全可以换一种做法,Java 并发包下面提供了一系列的 Atomic 原子类,比如说 AtomicInteger:
1 | //import java.util.concurrent.atomic.AtomicInteger; |
多个线程可以并发的执行 AtomicInteger 的 incrementAndGet(),意思就是把 count 的值累加 1,接着返回累加后最新的值。实际上,Atomic 原子类底层用的不是传统意义的锁机制,而是无锁化的 CAS 机制,通过 CAS 机制保证多线程修改一个数值的安全性。
CAS 性能优化
从流程图可以看出来,大量的线程同时并发修改一个 AtomicInteger,可能有很多线程会不停的自旋,进入一个无限重复的循环中。这些线程不停地获取值,然后发起 CAS 操作,但是发现这个值被别人改过了,于是再次进入下一个循环,获取值,发起 CAS 操作又失败了,再次进入下一个循环。在大量线程高并发更新 AtomicInteger 的时候,这种问题可能会比较明显,导致大量线程空循环,自旋转,性能和效率都不是特别好。那么如何优化呢?
Java8 有一个新的类,LongAdder,它就是尝试使用分段 CAS 以及自动分段迁移的方式来大幅度提升多线程高并发执行 CAS 操作的性能,这个类具体是如何优化性能的呢?如图:
LongAdder
LongAdder 核心思想就是热点分离,这一点和 ConcurrentHashMap 的设计思想相似。就是将 value 值分离成一个数组,当多线程访问时,通过 hash 算法映射到其中的一个数字进行计数。而最终的结果,就是这些数组的求和累加。这样一来,就减小了锁的粒度。
LongAddr 的兄弟类如下:
LongAdder兄弟类
如何选择
在乐观锁与悲观锁的选择上面,主要看下两者的区别以及适用场景就可以了。
- 响应效率:如果需要非常高的响应速度,建议采用乐观锁方案,成功就执行,不成功就失败,不需要等待其他并发去释放锁。乐观锁并未真正加锁,效率高。一旦锁的粒度掌握不好,更新失败的概率就会比较高,容易发生业务失败。
- 冲突频率:如果冲突频率非常高,建议采用悲观锁,保证成功率。冲突频率大,选择乐观锁会需要多次重试才能成功,代价比较大。
- 重试代价:如果重试代价大,建议采用悲观锁。悲观锁依赖数据库锁,效率低。更新失败的概率比较低。
- 乐观锁如果有人在你之前更新了,你的更新应当是被拒绝的,可以让用户从新操作。悲观锁则会等待前一个更新完成。这也是区别。
随着互联网**三高架构(高并发、高性能、高可用)**的提出,悲观锁已经越来越少的被应用到生产环境中了,尤其是并发量比较大的业务场景。
乐观锁和MVCC的区别?
在数据库中,并发控制是指在多个用户/进程/线程同时对数据库进行操作时,如何保证事务的一致性和隔离性的,同时最大程度地并发。
当多个用户/进程/线程同时对数据库进行操作时,会出现3种冲突情形:
- 读-读,不存在任何问题
- 读-写,有隔离性问题,可能遇到脏读(会读到未提交的数据) ,幻影读等。
- 写-写,可能丢失更新
要解决冲突,一种办法是是锁,即基于锁的并发控制,比如2PL两阶段锁协议,这种方式开销比较高,而且无法避免死锁。而基于无锁的并发控制有两种方式:就是MVCC多版本并发控制和OCC乐观并发控制,这两种方式分别解决上面的第2,3种情况。
多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。 这样在读操作不用阻塞写操作,写操作不用阻塞读操作的同时,避免了脏读和不可重复读
乐观并发控制(OCC)是一种用来解决写-写冲突的无锁并发控制,认为事务间争用没有那么多,所以先进行修改,在提交事务前,检查一下事务开始后,有没有新提交改变,如果没有就提交,如果有就放弃并重试。乐观并发控制类似自旋锁。乐观并发控制适用于低数据争用,写冲突比较少的环境。
多版本并发控制可以结合基于锁的并发控制来解决写-写冲突,即 MVCC+2PL,也可以结合乐观并发控制来解决写-写冲突。
InnoDB 锁的特性
在不通过索引条件查询的时候,InnoDB 使用的确实是表锁(锁的是整张表)!
1
2
3
4
5
6
7
8
9
10
11
12
13
14price属性并没有加索引,因此这时候虽然是用的行锁,锁的却是整张表
窗口1:
mysql> select * from product where price=88 for update;
+----+------+-------+-----+
| id | name | price | num |
+----+------+-------+-----+
| 2 | 蒙牛 | 88 | 1 |
+----+------+-------+-----+
窗口2:
mysql> update product set price=price-100 where id=6;
这里会等待,直到窗口1 commit后显示下面结果!
Query OK, 1 row affected
Rows matched: 1 Changed: 1 Warnings: 0由于 MySQL 的行锁是针对索引加的锁,不是针对记录加的锁,所以虽然是访问不同行的记录,但是如果是使用相同的索引键,是会出现锁冲突的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24这里的num属性 加上了普通索引,price属性并没有索引
窗口1:
mysql> set autocommit=0;
Query OK, 0 rows affected
mysql> select * from product where num=1 and price=68 for update;
+----+------+-------+-----+
| id | name | price | num |
+----+------+-------+-----+
| 1 | 伊利 | 68 | 1 |
+----+------+-------+-----+
窗口2:
mysql> update product set price=price+100 where num=1 and price=88;
这里会发生等待,直到窗口1 commit 显示下面结果
Query OK, 1 row affected
Rows matched: 1 Changed: 1 Warnings: 0
mysql> select * from product;
+----+----------+-------+-----+
| id | name | price | num |
+----+----------+-------+-----+
| 1 | 伊利 | 68 | 1 |
| 2 | 蒙牛 | 188 | 1 |
+----+----------+-------+-----+当表有多个索引的时候,不同的事务可以使用不同的索引锁定不同的行,另外,不论是使用主键索引、唯一索引或普通索引,InnoDB 都会使用行锁来对数据加锁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14锁一行数据,DML操作其他行并没有影响
窗口1:
mysql> select * from user where id=1 for update;
+----+-------+
| id | price |
+----+-------+
| 1 | 400 |
+----+-------+
窗口2:
mysql> update user set price=price+100 where id=2;
无需等待窗口1 commit
Database changed
Rows matched: 1 Changed: 1 Warnings: 0即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。因此,在分析锁冲突时, 别忘了检查SQL 的执行计划(explain查看),以确认是否真正使用了索引。
死锁的原理及分析
MVCC
MySQL InnoDB 存储引擎,实现的是基于多版本并发控制协议—MVCC(Multi-Version Concurrency Control) MVCC最大的好处,相信也是耳熟能详:读不加锁,读写不冲突。在读多写少的 OLTP 应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能,这也是为什么现阶段,几乎所有的 RDBMS,都支持了 MVCC。
PL:Two-Phase Locking
传统 RDBMS(关系数据库管理系统)加锁的一个原则,就是 2PL (二阶段锁):Two-Phase Locking。相对而言,2PL 比较容易理解,说的是锁操作分为两个阶段:加锁阶段与解锁阶段,并且保证加锁阶段与解锁阶段不相交。下面,仍旧以 MySQL 为例,来简单看看 2PL 在MySQL 中的实现。
transaction | mysql |
---|---|
begin; | 加锁阶段 |
insert into | 加insert对应的锁 |
update table | 加update对应的锁 |
delete from | 加delete对应的锁 |
commit | 解锁阶段 |
将insert、update、delete的锁全部解开 |
上面的例子可以看出2PL就是将加锁、解锁分为两个阶段,并且互相不干扰。加锁阶段只加锁,解锁阶段只解锁。
为什么会发生死锁
MyISAM 中是不会产生死锁的,因为 MyISAM 总是一次性获得所需的全部锁,要么全部满足,要么全部等待。而在 InnoDB 中,锁是逐步获得的,就造成了死锁的可能。(不过现在一般都是 InnoDB 引擎,关于MyISAM不做考虑)
在 InnoDB 中,行级锁并不是直接锁记录,而是锁索引。索引分为主键索引和非主键索引两种,如果一条 sql 语句操作了主键索引,MySQL 就会锁定这条主键索引;如果一条语句操作了非主键索引,MySQL 会先锁定该非主键索引,再锁定相关的主键索引。
当两个事务同时执行,一个锁住了主键索引,在等待其他相关索引。另一个锁定了非主键索引,在等待主键索引。这样就会发生死锁。
通过两个SQL死锁的例子来说明
- 两个session的两条语句
这种情况很好理解,首先 session1 获得 id=1 的锁 session2 获得 id=5 的锁,然后 session 想要获取 id=5 的锁等待,session2 想要获取id=1 的锁 ,也等待!
- 两个session的一条语句
这种情况需要我们了解数据的索引的检索顺序原理简单说下:普通索引上面保存了主键索引,当我们使用普通索引检索数据时,如果所需的信息不够,那么会继续遍历主键索引。
假设默认情况是 RR 隔离级别,针对 session 1 从 name 索引出发,检索到的是(hdc,1)(hdc,6)不仅会加 name 索引上的记录 X 锁,而且会加聚簇索引上的记录 X 锁,加锁顺序为先[1,hdc,100],后[6,hdc,10] 这个顺序是因为 B+树 结构的有序性。而 Session 2,从pubtime 索引出发,[10,6]、[100,1]均满足过滤条件,同样也会加聚簇索引上的记录 X 锁,加锁顺序为[6,hdc,10],后[1,hdc,100]。发现没有,跟 Session 1的加锁顺序正好相反,如果两个Session恰好都持有了第一把锁,请求加第二把锁,死锁就发生了。
避免死锁,这里只介绍常见的三种
- 如果不同程序会并发存取多个表,尽量约定以相同的顺序访问表,可以大大降低死锁机会。
- 在同一个事务中,尽可能做到一次锁定所需要的所有资源,减少死锁产生概率;
- 对于非常容易产生死锁的业务部分,可以尝试使用升级锁定颗粒度,通过表级锁定来减少死锁产生的概率;