- 数据库事务一致性问题
- 第一类丢失修改
- 脏读
- 不可重复读
- 第二类丢失修改
- 幻读
- 不可重复读与幻读的区别
- 简述数据库的几种锁
- 悲观锁
- 使用性质分类
- 共享锁(Share locks,S锁)
- 排它锁(Exclusive locks,X锁)
- 更新锁(U锁)
- 意向锁(Intent locks,I锁)
- 区间锁/间隙锁(gap locks)
- next-Key locks
- 作用范围分类
- 行锁
- 表锁
- 乐观锁
- 版本号(version)
- 时间戳(timestamp)
- 待更新字段
- 所有字段
- 乐观锁几种方式比较
- 数据库隔离级别
- READ_UNCOMMITTED(RU)
- 锁分析
- READ_UNCOMMITTED隔离级别下为什么能读到未提交的数据
- READ_UNCOMMITTED下的一致性问题
- READ_COMMITED(RC)
- 锁分析
- MVCC多版本并发控制
- MVCC实现的依赖项
- undo log
- 行的更新过程
- read view 判断当前版本数据项是否可见
- 代码描述
- 不同隔离级别下 read view 的生成原则
- READ_COMMITED
- REPEATABLE READ
- REPEATABLE READ(RR)
- 锁分析
- SERIALIZABLE
- 锁分析
- 总结
数据库事务并发一致性问题主要包括:第一类丢失修改、脏读、不可重复读、第二类丢失修改、幻读。
第一类丢失修改此种更新丢失是因为回滚的原因,所以也叫回滚丢失。此时两个事务同时更新count,两个事务都读取到100,事务一更新成功并提交,count=100+1=101,事务二出于某种原因更新失败了,然后回滚,事务二就把count还原为它一开始读到的100,此时事务一的更新就这样丢失了。
脏读事务T1读取了T2更改的x,但是T2在实际存储数据时可能出错回滚了,这时T1读取的实际是无效的数据,这种情况下就是脏读。
不可重复读在T1读取x时,由于中间T2更改了x,所以T1前后两次读取的x值不相同,这就是所谓的不可重复读。
第二类丢失修改第二类丢失修改是事务A和B先后更改数据数据x(假设初始是x0),但是在A未正式更改前,B已经读取了原先的数据x0,最后A更改后为x1,B更改的并不是A更新后的x1,而是更改的x0,更改后假设为x2,这时x2将x1覆盖了,相当于事务A针对x的更改丢失了。
幻读在T1读取符合某个条件的所有记录时,T2增加了一条符合该条件的记录,这就导致T1执行过程中前后读取的记录可能不一致,即T2之后读取时会多出一条记录。
不可重复读与幻读的区别从上面不可重复读和幻读的描述中就能发现,不可重复读主要是事务在两次读期间被其他事务更改了记录,导致两次读取内容不一样,因此,不可重复读更侧重于读到其他事务的upate和delete操作数据;而幻读侧重于读到其他事务的insert操作数据。
简述数据库的几种锁数据库锁大分类上有悲观锁和乐观锁。
悲观锁顾名思义,就是很悲观,它对于数据被外界修改持保守态度,认为数据随时会修改,所以整个数据处理中需要将数据加锁。悲观锁一般都是依靠关系数据库提供的锁机制,事实上关系数据库中的行锁,表锁不论是读写锁都是悲观锁。
使用性质分类 共享锁(Share locks,S锁)也称读锁,事务A对对象T加S锁,其他事务也只能对T加S锁,多个事务可以同时读,但不能有写操作,直到A释放S锁。
排它锁(Exclusive locks,X锁)也称写锁,事务A对对象T加X锁以后,其他事务不能对T加任何锁,只有事务A可以读写对象T直到A释放X锁。
更新锁(U锁)用来预定要对此对象施加X锁,它允许其他事务读,但不允许再施加U锁或X锁;当被读取的对象将要被更新时,则升级为X锁,主要是用来防止死锁的。因为使用共享锁时,修改数据的操作分为两步,首先获得一个共享锁,读取数据,然后将共享锁升级为排它锁,然后再执行修改操作。这样如果同时有两个或多个事务同时对一个对象申请了共享锁,在修改数据的时候,这些事务都要将共享锁升级为排它锁。这些事务都不会释放共享锁而是一直等待对方释放,这样就造成了死锁。如果一个数据在修改前直接申请更新锁,在数据修改的时候再升级为排它锁,就可以避免死锁。
意向锁(Intent locks,I锁)当一个表中的某一行被加上排他锁后,该表就不能再被加表锁。
数据库程序如何知道该表不能被加表锁?
一种方式是逐条的判断该表的每一条记录是否已经有排他锁,另一种方式是直接在表这一层级检查表本身是否有意向锁,不需要逐条判断。显然后者效率高。
如:
T1: select * from table where id=10;
T2: select * from table;
假设T1先执行,T2后执行。T2执行时,欲加表锁,为判断是否可以加表锁,数据库系统要逐条判断table表每行记录是否已有排他锁,如果发现其中一行已经有排他锁了,就不允许再加表锁了。只是这样逐条判断效率太低了。
实际上,数据库系统不是这样工作的。innodb支持多重粒度锁,当T1的select执行时,系统对表table的id=10的这一行加了排他锁,还同时悄悄的对整个表加了意向排他锁(IX),当T2执行表锁时,只需要看到这个表已经有意向排他锁存在,就直接等待,而不需要逐条检查资源了。意向排他锁(IX),表明一个事务对表中个别数据设置一个排他锁
当然还有意向共享锁(IS),表明一个事务对表中个别数据设置一个共享锁。
锁加在不存在的空闲空间,可以是两个索引记录之间,也可能是第一个索引记录之前或最后一个索引之后的空间(区间锁针对索引列操作,若没有索引,则就加表锁了)。
区间锁是innodb中行锁的一种, 但是这种锁锁住的却不止一行数据,他锁住的是多行,是一个数据范围。区间锁的主要作用是为了防止出现幻读,但是它会把锁定范围扩大。
控制区间锁的参数:
默认 innodb_locks_unsafe_for_binlog 为OFF,即启用区间锁,但是有个前提,innodb的隔离级别必须是可重复读,才会加区间锁。
但是这个参数会影响到主从复制及灾难恢复,这个方法还尚待商量。
区间锁的出现主要集中在同一个事务中先delete后 insert的情况下,当我们通过一个参数去删除一条记录的时候,如果参数在数据库中存在,那么这个时候产生的是普通行锁,锁住这个记录,然后删除,然后释放锁。如果这条记录不存在,问题就来了,数据库会扫描索引,发现这个记录不存在,这个时候的delete语句获取到的就是一个区间锁,然后数据库会向左扫描扫到第一个比给定参数小的值,向右扫描扫描到第一个比给定参数大的值,然后以此为界,构建一个区间,锁住整个区间内的数据,一个特别容易出现死锁的区间锁诞生了。这时,倘若有另一个事务在这个区间插入数据,就会发生死锁。
如:
操作表command如下:
事务T1:
删除不存在的数据。
此后,事务T2:
在区间内插入数据,死锁。
行锁+间隙锁,锁定一个范围,并且锁定记录本身。(间隙锁不锁定记录本身)
作用范围分类 行锁锁的作用范围是行级别,数据库能够确定哪些行需要锁的情况下使用行锁,如果不知道会影响哪些行的时候就会使用表锁。
举个例子,一个用户表user,有主键id和用户生日birthday当你使用update … where id=?这样的语句数据库明确知道会影响哪一行,它就会使用行锁,当你使用update … where birthday=?这样的的语句的时候因为事先不知道会影响哪些行就可能会使用表锁。
锁的作用范围是整张表。
乐观锁顾名思义,就是很乐观,每次自己操作数据的时候认为没有人会来修改它,所以不去加锁,但是在更新的时候会去判断在此期间数据有没有被修改,需要用户自己去实现。既然都有数据库提供的悲观锁可以方便使用为什么要使用乐观锁呢?对于读操作远多于写操作的时候,大多数都是读取,这时候一个更新操作加锁会阻塞所有读取,降低了吞吐量。最后还要释放锁,锁是需要一些开销的,我们只要想办法解决极少量的更新操作的同步问题。换句话说,如果是读写比例差距不是非常大或者你的系统没有响应不及时,吞吐量瓶颈问题,那就不要去使用乐观锁,它增加了复杂度,也带来了额外的风险。
版本号(version)就是给数据增加一个版本标识,在数据库上就是表中增加一个version字段,每次更新把这个字段加1,读取数据的时候把version读出来,更新的时候比较version,如果还是开始读取的version就可以更新了,如果现在的version比老的version大,说明有其他事务更新了该数据,并增加了版本号,这时候得到一个无法更新的通知,用户自行根据这个通知来决定怎么处理,比如重新开始一遍。这里的关键是判断version和更新两个动作需要作为一个原子单元执行,否则在你判断可以更新以后正式更新之前有别的事务修改了version,这个时候你再去更新就可能会覆盖前一个事务做的更新,造成第二类丢失更新,所以你可以使用update … where … and version=”old version”这样的语句,根据返回结果是0还是非0来得到通知,如果是0说明更新没有成功,因为version被改了,如果返回非0说明更新成功。
时间戳(timestamp)和版本号基本一样,只是通过时间戳来判断而已,注意时间戳要使用数据库服务器的时间戳不能是业务系统的时间。
待更新字段和版本号方式相似,只是不增加额外字段,直接使用有效数据字段做版本控制信息,因为有时候我们可能无法改变旧系统的数据库表结构。假设有个待更新字段叫count,先去读取这个count,更新的时候去比较数据库中count的值是不是我期望的值(即开始读的值),如果是就把我修改的count的值更新到该字段,否则更新失败。java的基本类型的原子类型对象如AtomicInteger就是这种思想。
所有字段和待更新字段类似,只是使用所有字段做版本控制信息,只有所有字段都没变化才会执行更新。
乐观锁几种方式比较新系统设计可以使用version方式和timestamp方式,需要增加字段,应用范围是整条数据,不论那个字段修改都会更新version,也就是说两个事务更新同一条记录的两个不相关字段也是互斥的,不能同步进行。旧系统不能修改数据库表结构的时候使用数据字段作为版本控制信息,不需要新增字段,待更新字段方式只要其他事务修改的字段和当前事务修改的字段没有重叠就可以同步进行,并发性更高。
数据库隔离级别数据库隔离级别有4种:READ_UNCOMMITED(读未提交)、READ_COMMITED(读已提交)、REPEATABLE READ(可重复读)、SERIALIZABLE(可串行化)。
上述隔离级别的一致性强度递增,而并发程度递减。
MYSQL默认的隔离级别是 REPEATABLE READ。
(可通过 select @@session.tx_isolation; 和 select @@global.tx_isolation; 查看当前会话的隔离级别和系统当前的隔离级别;
通过 set session transaction isolation level read uncommitted; 来设置会话事务隔离级别;
通过 修改配置文件mysql.ini的配置项 transaction-isolation=READ-UNCOMMITTED;来设置全局事务隔离级别)
- 事务对当前被读取的数据不加锁;
- 事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级排他锁(X锁)(但是其他事务可读,因此造成脏读),直到事务结束才释放。
根据以上分析,读未提交隔离级别下,会加行级排他锁,按照锁的特性,会排斥任何读写操作,但是该级别下却能读到其他事务未提交的数据。
在 READ UNCOMMITTED 级别运行的事务不会发出共享锁来防止其他事务修改当前事务读取的数据, 既然不加共享锁了, 那么当前事务所读取的数据自然就可以被其他事务来修改。 而且当前事务要读取其他事务未提交的修改, 也不会被排他锁阻止, 因为排他锁会阻止其他事务再对其锁定的数据加读写锁, 但是可笑的是, 事务在该隔离级别下去读数据的话根本什么锁都不加, 这就让排他锁无法排它了, 因为它连锁都没有。READ_UNCOMMITTED下的一致性问题
- 第一类丢失修改不会发生。因为写时加行级排他锁,在一个事务修改时,其他事务是无法修改的。
- 不可重复读、第二类丢失修改、脏读和幻读都会发生。READ_UNCOMMITTED会读到未提交数据。
从隔离级别效果上:
- 事务对当前被读取的数据加行级共享锁(当读到时才加锁),一旦读完该行,立即释放该行级共享锁;
- 事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级排他锁,直到事务结束才释放。
数据库事务隔离级别的实现,innodb支持行级锁,写时加的是行级排他锁(X lock),那么当其他事务访问另一个事务正在update (除select操作外其他操作本质上都是写操作)的同一条记录时,事务的读操作会被阻塞。所以只能等到记录(其实是索引上的锁)上的排他锁释放后才能进行访问,也就是事务提交的时候。这样确实能实现read commited隔离级别效果。
数据库这样做确实可以实现 事务只能读其他事务已提交的的记录 的效果,但是这是很低效的一种做法。因为对于大部分应用来说,读操作是多于写操作的,当写操作加锁时,那么读操作全部被阻塞,这样会导致应用的相应能力受数据库的牵制。
来看看RC隔离级别下MYSQL的innodb的表现:
- 分别开启MYSQL两个会话,并设置会话事务隔离级别为RC,手动提交;
- 两个会话分别开启一个事务。事务1用于修改记录,事务2用于查询被修改的记录;
可以看到,RC隔离级别下,读未被提交的数据并不会被阻塞,这说明在MYSQL的innodb中,并不是单纯的用读加共享锁,写加排他锁方案,这样会影响并发性能。
其实MYSQL的innodb的RC隔离级别,内部使用了MVCC机制,实现了一致性非阻塞读,大大提高了并发读写效率,写不影响读,且读到的是记录的镜像版本。
MYSQL的innodb采用的方式:
- 读不影响写:事务以排他锁的形式修改原始数据,读时不加锁,因为 MySQL 在事务隔离级别READ_COMMITED、REPEATABLE READ下,innodb存储引擎采用非锁定性一致读——即读取不占用和等待表上的锁。即采用的是MVCC中一致性非锁定读模式。因读时不加锁,所以不会阻塞其他事物在相同记录上加X锁来更改这行记录。
- 写不影响读:事务以排他锁的形式修改原始数据,当读取的行正在执行 delete 或者 update 操作,这时读取操作不会因此去等待行上锁的释放。相反地,innodb存储引擎会去读取行的一个快照数据。
MYSQL的innodb是支持MVCC的。
innodb为每行记录都维护了3个隐藏字段:
- 隐藏id;
- 6字节的事务id(DB_TRX_ID);
- 7字节的回滚指针(DB_ROLL_PTR)。
MVCC在MYSQL中的实现依赖的是 undo log 与 read view。
undo log:undo log中记录的是数据表记录行的多个版本,也就是事务执行过程中的回滚段,其实就是MVCC中的一行原始数据的多个版本镜像数据。
read view:主要用来判断当前版本数据的可见性。
undo log是为回滚而用,具体内容就是copy事务前的数据库内容(行)到undo buffer,在适合的时间把undo buffer中的内容刷新到磁盘。undo buffer与redo buffer一样,也是环形缓冲,但当缓冲满的时候,undo buffer中的内容会也会被刷新到磁盘;与redo log不同的是,磁盘上不存在单独的undo log文件,所有的undo log均存放在主ibd数据文件中(表空间),即使客户端设置了每表一个数据文件也是如此。
行的更新过程- 初始数据行:
C1~C6是某行列名,1~6是其对应的数据。后面三个隐含字段分别对应该行的隐藏ID,事务号和回滚指针,假如这条数据是刚INSERT的,可以认为ID为1,其他两个字段为空。
- 事务01修改该行:
当事务01更改该行的值时,会进行如下操作:
1. 用排他锁锁定该行; 2. 记录redo log; 3. 把该行修改前的值Copy到undo log,即上图中下面的行; 4. 修改当前行的值,填写事务编号,使回滚指针指向undo log中的修改前的行。
- 事务02修改该行:
与事务01相同,此时undo log,中有有两行记录,并且通过回滚指针连在一起。
在innodb中,创建一个新事务的时候,innodb会将当前系统中的活跃事务列表(trx_sys->trx_list)创建一个副本(read view),副本中保存的是系统当前不应该被本事务看到的其他事务id列表。当用户在这个事务中要读取该行记录的时候,innodb会将该行当前的版本号与该read view进行比较。
比较算法:
- 设该行的当前事务id为trx_id_0,read view中最早的事务id为trx_id_1, 最迟的事务id为trx_id_2。
- 如果trx_id_0 < trx_id_1的话,那么表明该行记录所在的事务已经在本次新事务创建之前就提交了,所以该行记录的当前值是可见的。跳到步骤6;
- 如果trx_id_0 > trx_id_2的话,那么表明该行记录所在的事务在本次新事务创建之后才开启,所以该行记录的当前值不可见,跳到步骤5;
- 如果trx_id_1 <= trx_id_0 <= trx_id_2, 那么表明该行记录所在事务在本次新事务创建的时候处于活动状态,从trx_id_1到trx_id_2进行遍历,如果trx_id_0等于他们之中的某个事务id的话,那么不可见。跳到步骤5;否则,说明该行记录所在的事务已经提交,所以该行记录的当前值是可见的。跳到步骤6;
- 从该行记录的DB_ROLL_PTR指针所指向的回滚段中取出最新的undo-log的版本号,将它赋值该trx_id_0,然后跳到步骤2;
- 将该可见行的值返回。
注意:新建事务(当前事务)与正在内存中commit 的事务不在活跃事务链表中。
代码描述函数: read_view_sees_trx_id
read_view 保存了当前全局活动的事务的范围:[low_limit_id, up_limit_id]
1. 当行记录的事务ID小于当前系统的最小活动id,就是可见的。
if (trx_id < view->up_limit_id) {
return(TRUE);
}
2. 当行记录的事务ID大于当前系统的最大活动id,就是不可见的。
if (trx_id >= view->low_limit_id) {
return(FALSE);
}
3. 当行记录的事务ID在活动范围之中时,判断是否在活动链表中,如果在就不可见,如果不在就是可见的。
for (i = 0; i < n_ids; i++) {
trx_id_t view_trx_id = read_view_get_nth_trx_id(view, n_ids - i - 1);
if (trx_id <= view_trx_id) { //当前事务id与read view中的第i+1个事务id比较
return(trx_id != view_trx_id);
}
}
不同隔离级别下 read view 的生成原则
READ_COMMITED
函数: ha_innobase::external_lock
if (trx->isolation_level <= TRX_ISO_READ_COMMITTED
&& trx->global_read_view) {
read_view_close_for_mysql(trx);
在每次语句执行的过程中,都关闭read_view, 重新在row_search_for_mysql函数中创建当前的一份read_view。
这样就可以根据当前的全局事务链表创建read_view的事务区间,实现READ_COMMITED隔离级别。
在REPEATABLE READ的隔离级别下,创建事务trx结构的时候,就生成了当前的global read view。
使用trx_assign_read_view函数创建,一直维持到事务结束,这样就实现了REPEATABLE READ隔离级别。
基于以上read view 生成原则,导致在不同隔离级别下,READ_COMMITED 总是读最新一份快照数据,而REPEATABLE READ 读事务开始时的行数据版本。
REPEATABLE READ(RR) 锁分析从隔离级别效果上:
- 事务在读取某数据的瞬间(就是开始读取的瞬间),必须先对其加行级共享锁,直到事务结束才释放;
- 事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加 行级排他锁,直到事务结束才释放。
通过以上方式,单纯以锁的方式也能实现REPEATABLE READ效果,但是效率较低。
MYSQL的innodb采用的方式:
- 读不影响写:事务以排他锁的形式修改原始数据,读时不加锁,因为 MySQL 在事务隔离级别READ_COMMITED、REPEATABLE READ下,innodb存储引擎采用非锁定性一致读——即读取不占用和等待表上的锁。即采用的是MVCC中一致性非锁定读模式。因读时不加锁,所以不会阻塞其他事物在相同记录上加X锁来更改这行记录。
- 写不影响读:事务以排他锁的形式修改原始数据,当读取的行正在执行 delete 或者 update 操作,这时读取操作不会因此去等待行上锁的释放。相反地,innodb存储引擎会去读取行的一个快照数据。
- 事务在读取数据时,必须先对其加表级共享锁 ,直到事务结束才释放;
- 事务在更新数据时,必须先对其加表级排他锁 ,直到事务结束才释放。
| 隔离级别/一致性问题 | 第一类更新丢失 | 脏读 | 不可重复读 | 第二类丢失更新 | 幻读 |
|---|---|---|---|---|---|
| 读未提交 | N | Y | Y | Y | Y |
| 读已提交 | N | N | Y | Y | Y |
| 可重复读 | N | N | N | N | Y |
| 串行化 | N | N | N | N | N |
参考:
数据库隔离级别 及 其实现原理
理解数据库的事务,ACID,CAP和一致性
数据库事务与锁详解
mysql(InnoDB)事务隔离级别(READ UNCOMMITTED) 与 锁
数据库事务特征、数据库隔离级别,各级别数据库加锁情况(含实操)–read committed && MVCC



