本文共 11820 字,大约阅读时间需要 39 分钟。
一、相关名词
|--表级锁(锁定整个表) |--页级锁(锁定一页) |--行级锁(锁定一行) |--共享锁(S锁,MyISAM 叫做读锁) |--排他锁(X锁,MyISAM 叫做写锁) |--悲观锁(抽象性,不真实存在这个锁) |--乐观锁(抽象性,不真实存在这个锁)二、InnoDB与MyISAM
Mysql 在5.5之前默认使用 MyISAM 存储引擎,之后使用 InnoDB 。查看当前存储引擎: show variables like '%storage_engine%'; MyISAM 操作数据都是使用的表锁,你更新一条记录就要锁整个表,导致性能较低,并发不高。当然同时它也不会存在死锁问题。 而 InnoDB 与 MyISAM 的最大不同有两点:一是 InnoDB 支持事务;二是 InnoDB 采用了行级锁。也就是你需要修改哪行,就可以只锁定哪行。 在 Mysql 中,行级锁并不是直接锁记录,而是锁索引。索引分为主键索引和非主键索引两种,如果一条sql 语句操作了主键索引,Mysql 就会锁定这条主键索引;如果一条语句操作了非主键索引,MySQL会先锁定该非主键索引,再锁定相关的主键索引。 InnoDB 行锁是通过给索引项加锁实现的,如果没有索引,InnoDB 会通过隐藏的聚簇索引来对记录加锁。也就是说:如果不通过索引条件检索数据,那么InnoDB将对表中所有数据加锁,实际效果跟表锁一样。因为没有了索引,找到某一条记录就得扫描全表,要扫描全表,就得锁定表。三、共享锁与排他锁
1.首先说明:数据库的增删改操作默认都会加排他锁,而查询不会加任何锁。 |--共享锁:对某一资源加共享锁,自身可以读该资源,其他人也可以读该资源(也可以再继续加共享锁,即 共享锁可多个共存),但无法修改。要想修改就必须等所有共享锁都释放完之后。语法为: select * from table lock in share mode |--排他锁:对某一资源加排他锁,自身可以进行增删改查,其他人无法进行任何操作。语法为: select * from table for update --增删改自动加了排他锁2.下面援引例子说明(援自:http://blog.csdn.net/samjustin1/article/details/52210125):
这里用T1代表一个数据库执行请求,T2代表另一个请求,也可以理解为T1为一个线程,T2 为另一个线程。例1:-------------------------------------------------------------------------------------------------------------------------------------
T1:select * from table lock in share mode(假设查询会花很长时间,下面的例子也都这么假设) T2:update table set column1='hello'过程:
T1运行(并加共享锁) T2运行 If T1还没执行完 T2等...... else锁被释放 T2执行 endifT2 之所以要等,是因为 T2 在执行 update 前,试图对 table 表加一个排他锁,而数据库规定同一资源上不能同时共存共享锁和排他锁。所以 T2 必须等 T1 执行完,释放了共享锁,才能加上排他锁,然后才能开始执行 update 语句。
例2:-------------------------------------------------------------------------------------------------------------------------------------
T1:select * from table lock in share mode T2:select * from table lock in share mode这里T2不用等待T1执行完,而是可以马上执行。
分析:
T1运行,则 table 被加锁,比如叫lockAT2运行,再对 table 加一个共享锁,比如叫lockB两个锁是可以同时存在于同一资源上的(比如同一个表上)。这被称为共享锁与共享锁兼容。这意味着共享锁不阻止其它人同时读资源,但阻止其它人修改资源。例3:-------------------------------------------------------------------------------------------------------------------------------------
T1:select * from table lock in share mode T2:select * from table lock in share mode T3:update table set column1='hello'T2 不用等 T1 运行完就能运行,T3 却要等 T1 和 T2 都运行完才能运行。因为 T3 必须等 T1 和 T2 的共享锁全部释放才能进行加排他锁然后执行 update 操作。
例4:(死锁的发生)-----------------------------------------------------------------------------------------------------------------
T1:begin transelect * from table lock in share modeupdate table set column1='hello' T2:begin transelect * from table lock in share modeupdate table set column1='world'假设 T1 和 T2 同时达到 select,T1 对 table 加共享锁,T2 也对 table 加共享锁,当 T1 的 select 执行完,准备执行 update 时,根据锁机制,T1 的共享锁需要升级到排他锁才能执行接下来的 update.在升级排他锁前,必须等 table 上的其它共享锁(T2)释放,同理,T2 也在等 T1 的共享锁释放。于是死锁产生了。
例5:-------------------------------------------------------------------------------------------------------------------------------------
T1:begin tranupdate table set column1='hello' where id=10 T2:begin tranupdate table set column1='world' where id=20这种语句虽然最为常见,很多人觉得它有机会产生死锁,但实际上要看情况
|--如果id是主键(默认有主键索引),那么T1会一下子找到该条记录(id=10的记录),然后对该条记录加排他锁,T2,同样,一下子通过索引定位到记录,然后对id=20的记录加排他锁,这样T1和T2各更新各的,互不影响。T2也不需要等。 |--如果id是普通的一列,没有索引。那么当T1对id=10这一行加排他锁后,T2为了找到id=20,需要对全表扫描。但因为T1已经为一条记录加了排他锁,导致T2的全表扫描进行不下去(其实是因为T1加了排他锁,数据库默认会为该表加意向锁,T2要扫描全表,就得等该意向锁释放,也就是T1执行完成),就导致T2等待。死锁怎么解决呢?一种办法是,如下:
例6:------------------------------------------------------------------------------------------------------------------------------------- T1:begin transelect * from table for updateupdate table set column1='hello' T2:begin transelect * from table for updateupdate table set column1='world'这样,当 T1 的 select 执行时,直接对表加上了排他锁,T2 在执行 select 时,就需要等 T1 事物完全执行完才能执行。排除了死锁发生。但当第三个 user 过来想执行一个查询语句时,也因为排他锁的存在而不得不等待,第四个、第五个 user 也会因此而等待。在大并发情况下,让大家等待显得性能就太友好了。
所以,有些数据库这里引入了更新锁(如Mssql,注意:Mysql不存在更新锁)。例7:-------------------------------------------------------------------------------------------------------------------------------------
T1:begin transelect * from table (加更新锁)update table set column1='hello' T2:begin transelect * from table (加更新锁)update table set column1='world'更新锁其实就可以看成排他锁的一种变形,只是它也允许其他人读(并且还允许加共享锁)。但不允许其他操作,除非我释放了更新锁。T1 执行 select,加更新锁。T2 运行,准备加更新锁,但发现已经有一个更新锁在那儿了,只好等。当后来有 user3、user4...需要查询 table 表中的数据时,并不会因为 T1 的 select 在执行就被阻塞,照样能查询,相比起例6,这提高了效率。
后面还有意向锁和计划锁:意向锁即是:某行修改时,自动加上了排他锁,同时会默认给该表加意向锁,表示里面有记录正被锁定,这时,其他人就不可以对该表加表锁了。如果没有意向锁这个类似指示灯的东西存在,其他人加表锁之前就得扫描全表,查看是否有记录正被锁定,效率低下。而计划锁这些,和程序员关系不大,就没去了解了。
四、乐观锁与悲观锁
案例: 某商品,用户购买后库存数应-1,而某两个或多个用户同时购买,此时三个执行程序均同时读得库存为n,之后进行了一些操作,最后将均执行update table set 库存数=n-1,那么,很显然这是错误的。解决:
1.使用悲观锁(其实说白了也就是排他锁) |--程序A在查询库存数时使用排他锁(select * from table where id=10 for update) |--然后进行后续的操作,包括更新库存数,最后提交事务。 |--程序B在查询库存数时,如果A还未释放排他锁,它将等待。 |--程序C同B…… 2.使用乐观锁(靠表设计和代码来实现) |--一般是在该商品表添加version版本字段或者timestamp时间戳字段 |--程序A查询后,执行更新变成了: update table set num=num-1 where id=10 and version=23 这样,保证了修改的数据是和它查询出来的数据是一致的,而其他执行程序未进行修改。当然,如果更新失败,表示在更新操作之前,有其他执行程序已经更新了该库存数,那么就可以尝试重试来保证更新成功。为了尽可能避免更新失败,可以合理调整重试次数(阿里巴巴开发手册规定重试次数不低于三次)。总结:对于以上,可以看得出来乐观锁和悲观锁的区别。
1.悲观锁使用了排他锁,当程序独占锁时,其他程序就连查询都是不允许的,导致吞吐较低。如果在查询较多的情况下,可使用乐观锁。 2.乐观锁更新有可能会失败,甚至是更新几次都失败,这是有风险的。所以如果写入较频繁,对吞吐要求不高,可使用悲观锁。 也就是一句话:读用乐观锁,写用悲观锁。在中介绍过,数据库管理系统(DBMS)中的并发控制的任务是确保在多个事务同时存取数据库中同一数据时不破坏事务的隔离性和统一性以及数据库的统一性。
乐观并发控制(乐观锁)和悲观并发控制(悲观锁)是并发控制主要采用的技术手段。
无论是悲观锁还是乐观锁,都是人们定义出来的概念,可以认为是一种思想。其实不仅仅是关系型数据库系统中有乐观锁和悲观锁的概念,像memcache、hibernate、tair等都有类似的概念。
针对于不同的业务场景,应该选用不同的并发控制方式。所以,不要把乐观并发控制和悲观并发控制狭义的理解为DBMS中的概念,更不要把他们和数据中提供的锁机制(行锁、表锁、排他锁、共享锁)混为一谈。其实,在DBMS中,悲观锁正是利用数据库本身提供的锁机制来实现的。
下面来分别学习一下悲观锁和乐观锁。
在关系数据库管理系统里,悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作都某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。
悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。
悲观锁,正如其名,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度(悲观),因此,在整个数据处理过程中,将数据处于锁定状态。 悲观锁的实现,往往依靠数据库提供的锁机制 (也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)
在对任意记录进行修改前,先尝试为该记录加上(exclusive locking)。
如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。 具体响应方式由开发者根据实际需要决定。
如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
其间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常。
要使用悲观锁,我们必须关闭mysql数据库的自动提交属性,因为MySQL默认使用autocommit模式,也就是说,当你执行一个更新操作后,MySQL会立刻将结果进行提交。
set autocommit=0;
//0.开始事务begin;/begin work;/start transaction; (三者选一就可以)//1.查询出商品信息select status from t_goods where id=1 for update;//2.根据商品信息生成订单insert into t_orders (id,goods_id) values (null,1);//3.修改商品status为2update t_goods set status=2;//4.提交事务commit;/commit work;
上面的查询语句中,我们使用了select…for update
的方式,这样就通过开启的方式实现了悲观锁。此时在t_goods表中,id为1的 那条数据就被我们锁定了,其它的事务必须等本次事务提交之后才能执行。这样我们可以保证当前的数据不会被其它事务修改。
上面我们提到,使用
select…for update
会把数据给锁住,不过我们需要注意一些锁的级别,MySQL InnoDB默认。行级锁都是基于索引的,如果一条SQL语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住,这点需要注意。
悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会;另外,在只读型事务处理中由于不会产生冲突,也没必要使用锁,这样做只能增加系统负载;还有会降低了并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数
在关系数据库管理系统里,乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。乐观事务控制最早是由孔祥重(H.T.Kung)教授提出。
乐观锁( Optimistic Locking ) 相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。
相对于悲观锁,在对数据库进行处理的时候,乐观锁并不会使用数据库提供的锁机制。一般的实现乐观锁的方式就是记录数据版本。
数据版本,为数据增加的一个版本标识。当读取数据时,将版本标识的值一同读出,数据每更新一次,同时对版本标识进行更新。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的版本标识进行比对,如果数据库表当前版本号与第一次取出来的版本标识值相等,则予以更新,否则认为是过期数据。
实现数据版本有两种方式,第一种是使用版本号,第二种是使用时间戳。
使用版本号时,可以在数据初始化时指定一个版本号,每次对数据的更新操作都对版本号执行+1操作。并判断当前版本号是不是该数据的最新的版本号。
1.查询出商品信息select (status,status,version) from t_goods where id=#{id}2.根据商品信息生成订单3.修改商品status为2update t_goods set status=2,version=version+1where id=#{id} and version=#{version};
乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。但如果直接简单这么做,还是有可能会遇到不可预期的结果,例如两个事务都读取了数据库的某一行,经过修改以后写回数据库,这时就遇到了问题。
悲观锁(Pessimistic Lock),顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。
悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。
Java synchronized 就属于悲观锁的一种实现,每次线程要修改数据时都先获得锁,保证同一时刻只有一个线程能操作数据,其他线程则会被block。
乐观锁(Optimistic Lock),顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在提交更新的时候会判断一下在此期间别人有没有去更新这个数据。乐观锁适用于读多写少的应用场景,这样可以提高吞吐量。
乐观锁:假设不会发生并发冲突,只在提交操作时检查是否违反数据完整性。
乐观锁一般来说有以下2种方式:
1. 使用数据版本(Version)记录机制实现,这是乐观锁最常用的一种实现方式。何谓数据版本?即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。 2. 使用时间戳(timestamp)。乐观锁定的第二种实现方式和第一种差不多,同样是在需要乐观锁控制的table中增加一个字段,名称无所谓,字段类型使用时间戳(timestamp), 和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。Java JUC中的atomic包就是乐观锁的一种实现,AtomicInteger 通过CAS(Compare And Set)操作实现线程安全的自增。
MySQL InnoDB采用的是两阶段锁定协议(two-phase locking protocol)。在事务执行过程中,随时都可以执行锁定,锁只有在执行 COMMIT或者ROLLBACK的时候才会释放,并且所有的锁是在同一时刻被释放。前面描述的锁定都是隐式锁定,InnoDB会根据事务隔离级别在需要的时候自动加锁。
另外,InnoDB也支持通过特定的语句进行显示锁定,这些语句不属于SQL规范:
* SELECT … LOCK IN SHARE MODE * SELECT … FOR UPDATE接下来,我们通过一个具体案例来进行分析:考虑电商系统中的下单流程,商品的库存量是固定的,如何保证商品数量不超卖? 其实需要保证数据一致性:某个人点击秒杀后系统中查出来的库存量和实际扣减库存时库存量的一致性就可以。
假设,MySQL数据库中商品库存表tb_product_stock 结构定义如下:
CREATE TABLE `tb_product_stock` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`product_id` bigint(32) NOT NULL COMMENT '商品ID',
`number` INT(8) NOT NULL DEFAULT 0 COMMENT '库存数量',
`create_time` DATETIME NOT NULL COMMENT '创建时间',
`modify_time` DATETIME NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `index_pid` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品库存表';
对应的POJO类:
class ProductStock {
private Long productId; //商品id
private Integer number; //库存量
public Long getProductId() {
return productId;
}
public void setProductId(Long productId) {
this.productId = productId;
}
public Integer getNumber() {
return number;
}
public void setNumber(Integer number) {
this.number = number;
}
}
不考虑并发的情况下,更新库存代码如下:
/**
* 更新库存(不考虑并发)
* @param productId
* @return
*/
public boolean updateStockRaw(Long productId){
ProductStock product = query("SELECT * FROM tb_product_stock WHERE product_id=#{productId}", productId);
if (product.getNumber() > 0) {
int updateCnt = update("UPDATE tb_product_stock SET number=number-1 WHERE product_id=#{productId} AND number=#{number}", productId, product.getNumber());
if(updateCnt > 0){ //更新库存成功
return true;
}
}
return false;
}
多线程并发情况下,会存在超卖的可能。
/**
* 更新库存(使用悲观锁)
* @param productId
* @return
*/
public boolean updateStock(Long productId){
//先锁定商品库存记录
ProductStock product = query("SELECT * FROM tb_product_stock WHERE product_id=#{productId} FOR UPDATE", productId);
if (product.getNumber() > 0) {
int updateCnt = update("UPDATE tb_product_stock SET number=number-1 WHERE product_id=#{productId}", productId);
if(updateCnt > 0){ //更新库存成功
return true;
}
}
return false;
}
/**
* 下单减库存
* @param productId
* @return
*/
public boolean updateStock(Long productId){
int updateCnt = 0;
while (updateCnt == 0) {
ProductStock product = query("SELECT * FROM tb_product_stock WHERE product_id=#{productId}", productId);
if (product.getNumber() > 0) {
updateCnt = update("UPDATE tb_product_stock SET number=number-1 WHERE product_id=#{productId} AND number=#{number}", productId, product.getNumber());
if(updateCnt > 0){ //更新库存成功
return true;
}
} else { //卖完啦
return false;
}
}
return false;
}
使用乐观锁更新库存的时候不加锁,当提交更新时需要判断数据是否已经被修改(AND number=#{number}),只有在 number等于上一次查询到的number时 才提交更新。
乐观锁的思路一般是表中增加版本字段,更新时where语句中增加版本的判断,算是一种CAS(Compare And Swep)操作,商品库存场景中number起到了版本控制(相当于version)的作用( AND number=#{number})。
悲观锁之所以是悲观,在于他认为本次操作会发生并发冲突,所以一开始就对商品加上锁(SELECT … FOR UPDATE),然后就可以安心的做判断和更新,因为这时候不会有别人更新这条商品库存。
从中我们也可以知道只要更新数据是依赖读取的数据作为基础条件的,就会有并发更新问题,需要乐观锁或者悲观锁取解决,特别实在计数表现明显。又比如在更新数据不依赖查询的数据的就不会有问题,例如修改用户的名称,多人同时修改,结果并不依赖于之前的用户名字,这就不会有并发更新问题。
这里我们通过 MySQL 乐观锁与悲观锁 解决并发更新库存的问题,当然还有其它解决方案,例如使用 分布式锁。目前常见分布式锁实现有两种:基于Redis和基于Zookeeper,基于这两种 业界也有开源的解决方案,例如 、 ,这里就不细说,网上Google 一下就有很多资料。