前言
用T1代表一个数据库执行请求,T2代表另一个请求,也可以理解为T1为一个线程,T2 为另一个线程。T3,T4以此类推。
锁的种类
共享锁(Shared lock)
例1:
1 | T1: select * from table (请想象它需要执行1个小时之久,后面的sql语句请都这么想象) |
过程:
例2:
1 | T1: select * from table |
这里T2不用等待T1执行完,而是可以马上执行。
分析:
例3:
1 | T1: select * from table |
这次,T2不用等T1运行完就能运行,T3却要等T1和T2都运行完才能运行。
因为T3必须等T1和T2的共享锁全部释放才能进行加排他锁然后执行update操作。
例4:(死锁的发生)
1 | T1: |
假设T1和T2同时达到select,T1对table加共享锁,T2也对加共享锁,当T1的select执行完,准备执行update时,根据锁机制,T1的共享锁需要升级到排他锁才能执行接下来的update.在升级排他锁前,必须等table上的其它共享锁释放,但因为holdlock这样的共享锁只有等事务结束后才释放,所以因为T2的共享锁不释放而导致T1等(等T2释放共享锁,自己好升级成排他锁),同理,也因为T1的共享锁不释放而导致T2等。死锁产生了。
例5:
1 | T1: |
这种语句虽然最为常见,很多人觉得它有机会产生死锁,但实际上要看情况,如果id是主键上面有索引,那么T1会一下子找到该条记录(id=10的记录),然后对该条记录加排他锁,T2,同样,一下子通过索引定位到记录,然后对id=20的记录加排他锁,这样T1和T2各更新各的,互不影响。T2也不需要等。
但如果id是普通的一列,没有索引。那么当T1对id=10这一行加排他锁后,T2为了找到id=20,需要对全表扫描,那么就会预先对表加上共享锁或更新锁或排他锁(依赖于数据库执行策略和方式,比如第一次执行和第二次执行数据库执行策略就会不同)。但因为T1已经为一条记录加了排他锁,导致T2的全表扫描进行不下去,就导致T2等待。
死锁怎么解决呢?一种办法是,如下:
例6:
1 | T1: |
当T1的select 执行时,直接对表加上了排他锁,T2在执行select时,就需要等T1事务完全执行完才能执行。排除了死锁发生。但当第三个user过来想执行一个查询语句时,也因为排他锁的存在而不得不等待,第四个、第五个user也会因此而等待。在大并发情况下,让大家等待显得性能就太友好了,所以,这里引入了更新锁。
更新锁(Update lock)
为解决死锁,引入更新锁。
例7:
1 | T1: |
更新锁的意思是:“我现在只想读,你们别人也可以读,但我将来可能会做更新操作,我已经获取了从共享锁(用来读)到排他锁(用来更新)的资格”。一个事物只能有一个更新锁获此资格。
T1执行select,加更新锁。
T2运行,准备加更新锁,但发现已经有一个更新锁在那儿了,只好等。
当后来有user3、user4…需要查询table表中的数据时,并不会因为T1的select在执行就被阻塞,照样能查询,相比起例6,这提高了效率。
例8:
1 | T1: select * from table(updlock) (加更新锁) |
这个例子是说明:共享锁和更新锁可以同时在同一个资源上。这被称为共享锁和更新锁是兼容的。
排他锁(独占锁,Exclusive Locks)
其它事务既不能读,又不能改排他锁锁定的资源。
例9:
1 | T1: |
第一种情况:T1先达,T2紧接到达;在这种情况中,T1先对表加更新锁,T2对表加共享锁,假设T2的select先执行完,准备执行update,发现已有更新锁存在,T2等。T1这时才执行完select,准备执行update,更新锁升级为排他锁,然后执行update,执行完成,事务结束,释放锁,T2才轮到执行update。
第二种情况:T2先达,T1紧接达;在这种情况,T2先对表加共享锁,T1到达后,T1对表加更新锁,假设T2 select先结束,准备update,发现已有更新锁,则等待,后面步骤就跟第一种情况一样了。
这个例子是说明:排他锁与更新锁是不兼容的,它们不能同时加在同一资源上。
例10:
1 | T1: update table set column1='hello' where id<1000 |
假设T1先达,T2随后至,这个过程中T1会对id<1000的记录施加排他锁.但不会阻塞T2的update。
例11:(假设id都是自增长且连续的)
1 | T1: update table set column1='hello' where id<1000 |
如同例10,T1先达,T2立刻也到,T1加的排他锁会阻塞T2的update。
意向锁(Intent Locks)
意向锁就是说在屋(比如代表一个表)门口设置一个标识,说明屋子里有人(比如代表某些记录)被锁住了。另一个人想知道屋子里是否有人被锁,不用进屋子里一个个去查,直接看门口标识就行了。
当一个表中的某一行被加上排他锁后,该表就不能再被加表锁。数据库程序如何知道该表不能被加表锁?一种方式是逐条的判断该表的每一条记录是否已经有排他锁,另一种方式是直接在表这一层级检查表本身是否有意向锁,不需要逐条判断。显然后者效率高。
例12:
1 | T1: begin tran |
假设T1先执行,T2后执行,T2执行时,欲加表锁,为判断是否可以加表锁,数据库系统要逐条判断table表每行记录是否已有排他锁,如果发现其中一行已经有排他锁了,就不允许再加表锁了。只是这样逐条判断效率太低了。
实际上,数据库系统不是这样工作的。当T1的select执行时,系统对表table的id=10的这一行加了排他锁,还同时悄悄的对整个表加了意向排他锁(IX),当T2执行表锁时,只需要看到这个表已经有意向排他锁存在,就直接等待,而不需要逐条检查资源了。
例13:
1 | T1: begin tran |
这个例子和上面的例子实际效果相同,T1执行,系统同时对行加排他锁、对页加意向排他锁、对表加意向排他锁。
计划锁(Schema Locks)
例14:
1 | alter table .... (加schema locks,称之为Schema modification (Sch-M) locks |
DDL语句都会加Sch-M锁,该锁不允许任何其它session连接该表。连都连不了这个表了,当然更不用说想对该表执行什么sql语句了。
例15:
用jdbc向数据库发送了一条新的sql语句,数据库要先对之进行编译,在编译期间,也会加锁,称之为:Schema stability (Sch-S) locks。
1 | select * from tableA |
编译这条语句过程中,其它session可以对表tableA做任何操作(update,delete,加排他锁等等),但不能做DDL(比如alter table)操作。
Bulk Update Locks 主要在批量导数据时用(比如用类似于oracle中的imp/exp的bcp命令)。
何时加锁
例16:
1 | T1: begin tran |
这里,T2的select可以查出结果。如果事务隔离级别不设为脏读,则T2会等T1事务执行完才能读出结果。
数据库如何自动加锁的?
1) T1执行,数据库自动加排他锁
2) T2执行,数据库发现事务隔离级别允许脏读,便不加共享锁。不加共享锁,则不会与已有的排他锁冲突,所以可以脏读。
例17:
1 | T1: begin tran |
锁的粒度
锁的粒度就是指锁的生效范围,就是说是行锁,还是页锁,还是整表锁. 锁的粒度同样既可以由数据库自动管理,也可以通过手工指定hint来管理。
例18:
1 | T1: select * from table (paglock) |
T1执行时,会先对第一页加锁,读完第一页后,释放锁,再对第二页加锁,依此类推。假设前10行记录恰好是一页(当然,一般不可能一页只有10行记录),那么T1执行到第一页查询时,并不会阻塞T2的更新。
例19:
1 | T1: select * from table (rowlock) |
T1执行时,对每行加共享锁,读取,然后释放,再对下一行加锁;T2执行时,会对id=10的那一行试图加锁,只要该行没有被T1加上行锁,T2就可以顺利执行update操作。
例20:
1 | T1: select * from table (tablock) |
T1执行,对整个表加共享锁. T1必须完全查询完,T2才可以允许加锁,并开始更新。
锁与事务隔离级别的优先级
手工指定的锁优先。
例21:
1 | T1: |
T1是事务隔离级别为最高级,串行锁,数据库系统本应对后面的select语句自动加表级锁,但因为手工指定了NOLOCK,所以该select语句不会加任何锁,所以T2也就不会有任何阻塞。
数据库其他锁及其区别
- holdlock 对表加共享锁,且事务(commit/rollback)不完成,共享锁不释放。
- tablock 对表加共享锁,只要statement不完成,共享锁不释放。
tablock
例22:
1 | T1: |
T1执行完select,就会释放共享锁,然后T2就可以执行update。
holdlock
例23:
1 | T1: |
T1执行完select,共享锁仍然不会释放,仍然会被hold(持有),T2也因此必须等待而不能update. 当T1最后执行了commit或rollback说明这一个事务结束了,T2才取得执行权。
TABLOCKX 对表加排他锁
例24:
1 | T1: select * from table(tablockx) (强行加排他锁) |
其它session就无法对这个表进行读和更新了,除非T1执行完了,就会自动释放排他锁。
例25:
1 | T1: begin tran |
单单select执行完还不行,必须整个事务完成(commit或rollback后)才会释放排他锁。
xlock 加排他锁
例26:
1 | select * from table(xlock paglock) 对page加排他锁 |
xlock还可这么用:
1 | select * from table(xlock tablock) |
效果等同于
1 | select * from table(tablockx) |
锁的超时等待
SET LOCK_TIMEOUT 4000
用来设置锁等待时间,单位是毫秒,4000意味着等待4秒可以用select @@LOCK_TIMEOUT
查看当前session的锁超时设置。-1 意味着永远等待。
例27:
1 | T1: begin tran |
T2执行时,会等待T1释放排他锁,等了4秒钟,如果T1还没有释放排他锁,T2就会抛出异常: Lock request time out period exceeded
。
事务隔离级别
SQL标准规定了四个隔离水平:
脏读
脏读发生在:当一个事务允许读取一个被其他事务改变但是未提交的状态时,这是因为并没有锁阻止读取。
如上图,第二个事务读取了一个并不一致的值。不一致的意思是,这个值是无效的,因为修改这个值的第一个事务已经回滚,也就是说,第一个事务修改了这个值,但是未提交确认,却被第二个事务读取,第一个事务又放弃修改,而第二个事务就得到一个脏数据。
不可重复读
反复读同一个数据却得到不同的结果,这是因为在反复几次读取的过程中,数据被修改了,这就导致我们使用了stale数据,这可以通过一个共享读锁来避免。这是隔离级别READ_COMMITTED会导致可重复读的原因。设置共享读锁也就是隔离级别提高到REPETABLE_READ。
幻读
当第二个事务插入一行记录,而正好之前第一个事务查询了应该包含这个新纪录的数据,那么这个查询事务的结果里肯定没有包含这个刚刚新插入的数据,这时幻影读发生了,通过变化锁和predicate locking避免。
总结
不可重复读和幻读的区别是:前者是指读到了已经提交的事务的更改数据(update或delete),后者是指读到了其他已经提交事务的新增数据(insert)。
对于这两种问题解决采用不同的办法,防止读到更改数据,只需对操作的数据添加行级锁,防止操作中的数据发生变化;
防止读到新增数据,往往需要添加表级锁,将整张表锁定,防止新增数据(oracle采用多版本数据的方式实现)。
READ_COMMITED 是比较正确的选择,因为SERIALIZABLE虽然能在不同事务发生时避免stale数据,也就是避免上述丢失刚刚修改的数据,但是性能是最低的,因为是一种最大化的串行。