事务
数据库事务(简称:事务)是数据库管理系统执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。一个数据库事务通常包含了一个序列的对数据库的读/写操作。它的存在包含有以下两个目的:
当事务被提交给了DBMS(数据库管理系统),则DBMS(数据库管理系统)需要确保:
Basically any time you have a unit of work that is either sensitive to outside changes or needs the ability to rollback every change, if an error occurs or some other reason.
ACID
并非任意的对数据库的操作序列都是数据库事务。数据库事务拥有以下四个特性: 原子性(Atomiocity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),习惯上被称之为ACID特性。
- 原子性(Atomicity)
一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
- 一致性(Consistency)
事务应确保数据库的状态从一个一致状态转变为另一个一致状态。一致状态的含义是数据库中的数据应满足完整性约束。这表示写入的数据必须完全符合所有的预设规则,这包含数据的精确度、关联性以及后续数据库可以自发性地完成预定的工作,如两用户转款前后的金额总和要一样。
- 隔离性(Isolation)
当两个或者多个事务并发访问(此处访问指查询和修改的操作)数据库的同一数据时所表现出的相互关系。通常来说,一个事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对其他事务是隔离的,并发执行的各个事务之间互相不干扰。但事务之间的真实隔离性取决于事务的隔离模式。
SQL标准中定义了4中隔离级别(或称为隔离模式),低级别的隔离可以执行更高的并发,系统的开销也更低。这4种级别包括读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable)。
给出每种隔离级别的实际代码例子
- READ UNCOMMITTED(未提交读)
该隔离级别下,事务中的修改,即使没有提交,对其他事务也都是可见的。因此,对于其他业务,可能会产生“脏读”,从而引起很多问题。同时从性能层面考虑,READ UNCOMMITED 和其他隔离级别也差不多,因此实际场景中一般很少使用。
脏读: 读取到部分修改的、事务未提交的数据, 即SELECT会读取其他事务修改而还没有提交的数据。比如:事务T1更新了一行记录的内容,但是并没有提交所做的修改;事务T2读取更新后的行,然后T1执行回滚操作,取消了刚才所做的修改。现在T2所读取的行就无效了。
- READ COMMITTED(提交读)
大多数据库的默认隔离级别如Oracle,但MySQL不是。本隔离级别下,满足隔离的基本定义:事务在提交前所做的修改对其他业务不可见。该级别下,两次执行同样的查询,可能会得到不一样的结果,产生不可重复读的效果。
不可重复读: SELECT的时候无法重复读,即同一个事务中两次执行同样的查询语句,若在第一次与第二次查询之间时间段,其他事务又刚好修改了其查询的数据且提交了,则两次读到的数据不一致。比如:事务T1读取一行记录,紧接着事务T2修改了T1刚才读取的那一行记录。然后T1又再次读取这行记录,发现与刚才读取的结果不同。这就称为“不可重复”读,因为T1原来读取的那行记录已经发生了变化。
- REPEATABLE READ(可重复读)
MySQL默认隔离级别,该隔离级别解决了不可重复读问题——SELECT的时候可以重复读,即同一个事务中两次执行同样的查询语句,得到的数据始终都是一致的,但还是存在幻读。
可重复读: 在同一个事务内的查询都与事务开始时刻一致的,InnoDB默认级别。
幻读: 事务T1读取一条指定的WHERE子句所返回的结果集。然后事务T2新插入一行记录,这行记录恰好可以满足T1所使用的查询条件中的WHERE 子句的条件。然后T1又使用相同的查询再次对表进行检索,但是此时却看到了事务T2刚才插入的新行。这个新行就称为“幻像”,因为对T1来说这一行就像突然出现的一样。InnoDB 通过多版本并发控制(MVCC)解决幻读问题。
- SERIALIZE(可串行化)
强制事务串行执行。该隔离级别下,会对读取的每一行数据上都加上锁,因而对锁机制的管理比较耗系统资源,数据库一般都不会用这个隔离级别。与可重复读的唯一区别是,默认把普通的SELECT语句改成SELECT …. LOCK IN SHARE MODE。即为查询语句涉及到的数据加上共享琐,阻塞其他事务修改真实数据。
在MySQL中,可以通过 select @@tx_isolation;
命令查看当前的事务隔离级别,如:
也可以通过执行命令set session transaction isolation level read committed;
修改事务隔离级别,如:
需要注意的是上述方式修改的事务隔离级别仅对当前session有效。如果要对所有新建的连接设置隔离级别,可以用set global transaction isolation level read committed;
它将决定新建连接的初始隔离级,但不会改变已有连接的隔离级。可以通过select @@global.tx_isolation;
命令查看global transaction isolation level:
如果想要全局修改事务隔离级别,可以在my.cnf 配置文件中修改,只需在最后加上”
- 持久性(Durability)
已被提交的事务对数据库的修改应该永久保存在数据库中,接下来其他的其他操作或故障不应该对其执行结果有任何影响,即提交的事务一定保证写入磁盘。
事务的实现
1. 如何保证原子性(A)?
数据库中与原子性相关的操作有rollback和commit。commit用于正常提交一个事务,rollback用于将事务中之前的操作回滚。第三种情况是数据库出现异常时,如断电,事务执行一半而退出。 事务的整个执行过程说明如下: (1) 每个事务开始时,系统会为该事务分配一个时间戳(唯一标识该事务)、回滚段和undo段。 (2) 事务中的每条SQL在执行修改操作前都会写undo日志,然后再将更新的内容写入undo段。 (3) 执行commit时,系统将修改的数据写入实际内存,并将修改信息写入回滚段。 (4) 执行rollback时,系统将undo段内容失效。 (5) 当系统在执行事务过程中出现异常退出后,系统再次启动,会从undo日志中恢复。
2. 如何保证一致性(C)?
事务一致性的保证和原子性和隔离性都有关系,即系统保证事务一致性的前提是保证事务的原子性和隔离性。上文中的“脏读”、“不可重复读”、“幻读”,其实都是数据库读一致性问题,必须由数据库提供一定的事务隔离机制来解决。
3. 如何保证隔离性(I)?
目前数据库实现的事务隔离方式分两种:
- 悲观并发控制
悲观并发控制,正如其名,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,因此,在整个数据处理过程中(当前事务中),将数据处于锁定状态,即读取数据时给加锁,其它事务无法修改这些数据,修改删除数据时也要加锁,其它事务无法读取这些数据。那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。
悲观锁的实现,往往依靠数据库提供的锁机制(Lock-Based Concurrency Control)(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。
悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。然而,数据库又是个高并发的应用,同一时间会有大量的并发访问,如果加锁过度,会极大的降低并发处理能力,于是就有了乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)。
- 乐观并发控制
在关系数据库管理系统里,乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。相对悲观锁而言,乐观锁机制采取了更加宽松的加锁机制。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。
乐观并发控制多数用于数据争用不大、冲突较少的环境中,这种环境中,偶尔回滚事务的成本会低于读取数据时锁定数据的成本,因此可以获得比其他并发控制方法更高的吞吐量。
乐观锁,大多是基于MVCC (Multi-Version Concurrency Control),即多版本控制协议实现。MVCC最大的好处是读不加锁,读写不冲突。不加任何锁,通过一定的机制生成一个数据请求时间点的一致性数据快照(snapshot),并用这个快照来提供一定级别(语句级或事务级)的一致性读取。从用户角度来看,好像是数据库可以提供同一数据的多个版本,因此这种技术又叫做多版本并发控制(Mutil Version Concurrency Control,简称MVCC或MCC),也称为多版本数据库。 在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能,这也是为什么现阶段,几乎所有的RDBMS,都支持了MVCC。要说明的是,MVCC的实现没有固定的规范,每个数据库都会有不同的实现方式,这里讨论的是InnoDB的MVCC。
4. 如何保证持久性(D)?
持久性的保证需要日志的支持,数据库写日志的原则是执行写数据前要先写日志。
针对事务一些推荐的做法
- 开启新事务前先rollback一下
- 每次做完update后校验affected_rows是否是期望的
- 考虑重连逻辑
- 尽量避免大事务
- 加锁资源使用要有一定的顺序, 避免死锁
- mysql的事务尽量小,使用完,立即commit或rollback.不要起一个过大的事务
- 避免尝试去锁一个不存在的记录,for update语句where条件请使用主键
- 避免过多的for update集合
- mysql单表记录保持在1000W以下,以获得较好的性能
- 需要修改mysql 锁等待时间,避免for update等待时间超长,造成系统阻塞。innodb_lock_wait_timeout 参数
锁
锁就是事务T在对某个数据对象例如表、记录等操作之前,先向系统发出请求,对其加锁。加锁后事务T就对该数据对象有了一定的控制。当多个客户端同时访问和修改相同的数据时,锁机制可以保证数据的一致性。
InnoDB的锁结构如下:
InnoDB中的每个行信息由多个结点组成,每个结点对应该行的物理存储的页号和偏移量。行级锁的数据结构如下:
锁的粒度
锁的粒度又称为锁的级别,实际上是可控制的资源的范围级别。常见的有表级锁、行级锁和页级锁,三种方式各有利弊。行级锁的锁粒度最小,表级锁的锁粒度最大,页级锁趋于中间。
锁粒度在锁机制存在的情况下,提高共享资源并发性的方法是让锁定对象更准确。尽量只锁定需要修改的数据部分。理想的情况下,精确锁定会修改的数据片段。另一方面,加锁也需要消耗资源。锁的各种操作,包括获得锁、检查锁是否已解除、释放锁等,都会增加系统开销。如果锁的操作比较频繁,系统会花大量的时间来管理锁,如不是执行数据存储,则兄台那个的性能也会受到影响。
- 表级锁(MyISAM,MEMORY)
表级的锁是锁定整张表的资源。表级锁逻辑简单,可以较容易的避免死锁。但表级锁的并发性较差,因为它锁住的是整个表,对于大量并发读写的应用不太适用。
如果加的是S锁,则其他事务只能再对此表加S锁,而不能加X锁。如果加的是X锁,则其他事务不能加任何锁。 可以理解为如果有事务在读表A中的数据,则其他事务此时只能读表A,不能写表A。如果有事务在插入或者更新表A中的数据,则其他事务此时不能读表A,也不能写表A。
表级锁的优势:
- 行级锁(InnoDB)
行级的锁是仅对指定的记录进行加锁,这样其它进程还是可以对同一个表中的其它记录进行操作。行级锁在高并发下可以得到较高的性能,但实现较为复杂,会带来很多bug。行级锁较难避免死锁,许多实现都是采用检测死锁机制来避免死锁。
如果有事务在读表A中的行l的数据,则其他事务此时可以读表A的所有数据,可以插入数据,可以更新除l行以外的数据。如果有事务在更新表A中的行l的数据,则其他事务此时不能不能读取或更新行l的数据,表A中的其他数据不受限制。
行级锁的优势:
- 页级锁(BDB)
页级锁一次锁定相邻的一组记录。表级锁速度快,但冲突多,行级冲突少,但速度慢。页级是两者的一个折衷方案。
锁的分类
锁可以分为两类,即共享锁(读锁、S锁)、独占锁(排它锁、写锁、X锁)。
- 共享锁(读锁、S锁)
•如果事务T对数据A加上共享锁后,则其他事务只能对A再加共享锁,不能加排他锁,直到已释放所有共享锁。获准共享锁的事务只能读数据,不能修改数据。
•如果在资源上没有独占锁,把一个共享锁定放在它上面。否则,把锁请求放在共享锁定队列中。
- 独占锁(排它锁、写锁、X锁)
•如果事务T对数据A加上排他锁后,则其他事务不能再对A加任任何类型的锁,直到在事务的末尾将资源上的锁释放为止。获准独占锁的事务既能读数据,又能修改数据。
•如果在资源上没有锁,在它上面放一个独占锁。否则,把锁定请求放在独占锁定队列中。
MySQL InnoDB中的事务与锁
InnoDB实现了两种类型的行锁:
另外,为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁。意向锁是InnoDB自动加的,不需用户干预。
上述锁模式的兼容情况具体如表所示:
如果一个事务请求的锁模式与当前的锁兼容,InnoDB就将请求的锁授予该事务;反之,如果两者不兼容,该事务就要等待锁释放。
在InnoDB中使用锁
1. 对于UPDATE、DELETE和INSERT语句
2. 对于SELECT语句
- SELECT …… LOCK IN SHARE MODE
会话事务中查找的数据,加上一个共享锁。若会话事务中查找的数据已经被其他会话事务加上独占锁的话,共享锁会等待其结束再加,若等待时间过长就会显示事务需要的锁等待超时。
- SELECT ….. FOR UPDATE
会话事务中查找的数据,加上一个读更新琐,其他会话事务将无法再加其他锁,必须等待其结束。需要注意的一点是:
select的条件不一样,采用的是行级锁还是表级锁也不一样。由于 InnoDB 预设是 Row-Level Lock,所以只有明确的指定主键,MySQL 才会执行 Row lock (只锁住被选取的行) ,否则 MySQL 将会执行 Table Lock (将整个表锁住)。下面来举例说明何时会锁表,何时会锁行。
假设有如下products表,其中productID是主键。
- (1)查询条件明确指定主键并且有符合条件的行——加行锁
- (2)查询条件明确指定主键但没有符合条件的行——不加锁
- (3)查询条件无主键——表锁
- (4)查询条件主键不明确——表锁
- (5)查询条件主键不明确——表锁
InnoDB行锁实现方式
InnoDB行锁实现方法:
这是MySQL与Oracle的不同点,后者是通过在数据块中对相应的数据行加锁来实现的。InnoDB这种行锁的实现特点意味着:
MySQL的行锁是针对索引加锁的,不是针对记录加的锁,所以虽然访问不同行的记录,但如果是使用相同的索引键,是会出现锁冲突的。
另外:
即便在条件中使用了索引字段,但是否使用索引来检索数据是由MySQL通过判断不同执行计划的代价来决定的,如果MySQL认为全表扫描效率更高,比如对一些很小的表,它就不会使用索引,这种情况下InnoDB将使用表锁,而不是行锁。因此,在分析锁冲突时,应用explain检查SQL的执行计划,以确认是否真正使用了索引。
间隙锁(Next-key锁)
当用范围条件而不是相等条件来查询数据并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁;对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁(Next-Key锁)。
我们再以products表举例,假设现在表中有4条记录,productID分别为1、2、4、5。查询语句为:
这事会在products表上加一个间隙锁,所以 product < 4
的话,会给 0、1、2、3、4
加上行锁这样就保证了不会出现插入 productID=3
这种事情的发生。
为了防止幻读,以满足相关隔离级别的要求。如果不使用间隙所,如果其它事务插入了 productID=3
的任何记录,那么本次事务如果再次执行上述语句,就会出现幻读。
很显然,在使用范围条件检索并锁定记录时,InnoDB这种加锁机制会阻塞符合条件范围内键值的并发插入,这会造成严重的锁等待。因此,在实际应用开发中,尤其是并发插入比较多的应用,我们要尽量优化业务逻辑,尽量使用相等条件来访问更新数据,避免使用范围条件。
InnoDB除了通过范围条件加锁时使用间隙锁外,如果使用相等条件请求给一个不存在的记录加锁,InnoDB也会使用间隙锁。
死锁
死锁是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。
死锁发生的条件:
- 互斥条件:一个资源每次只能被一个进程使用;
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放;
- 不剥夺条件:进程 已获得的资源,在末使用完之前,不能强行剥夺;
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
一次封锁法 VS 两段锁
当有大量的并发访问存在时,为了预防死锁,一般应用中推荐使用一次封锁法,就是在方法的开始阶段,已经预先知道会用到哪些数据,然后全部锁住,在方法运行之后,再全部解锁。这种方式可以有效的避免循环死锁,但在数据库中却不适用,因为在事务开始阶段,数据库并不知道会用到哪些数据。
数据库遵循的是两段锁协议,将事务分成两个阶段,加锁阶段和解锁阶段(所以叫两段锁)
1)加锁阶段:在该阶段可以进行加锁操作。在对任何数据进行读操作之前要申请并获得S锁(共享锁,其它事务可以继续加共享锁,但不能加排它锁),在进行写操作之前要申请并获得X锁(排它锁,其它事务不能再获得任何锁)。加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。
2)解锁阶段:当事务释放了一个封锁以后,事务进入解锁阶段,在该阶段只能进行解锁操作不能再进行加锁操作。事务加锁/解锁处理过程如下:
这种方式虽然无法避免死锁,但是两段锁协议可以保证事务的并发调度是串行化(串行化很重要,尤其是在数据恢复和备份的时候)的。
死锁的产生
在MySQL中:
对于InnoDB 和BDB 存储引擎来说,是可能产生死锁的。MyISAM表锁是deadlock free的,这是因为MyISAM总是一次获得所需的全部锁,要么全部满足,要么等待,因此不会出现死锁。但在InnoDB中,除单个SQL组成的事务外,锁是逐步获得的,这就决定了在InnoDB中发生死锁是可能的。
InnoDB中会发生死锁的几种情况:
死锁的检测
InnoDB会把下面两种情况判断为死锁:
在MySQl中可以通过 show engine innodb status
命令查看死锁情况,返回结果中包括死锁相关事务的详细信息,如引发死锁的SQL语句,事务已经获得的锁,正在等待什么锁,以 及被回滚的事务等。
InnoDB 会自动检测一个事务的死锁并回滚一个或多个事务来防止死锁。从 4.0.5 版开始,InnoDB 将设法提取小的事务来进行回滚。一个事务的大小由它所插入(insert)、更新(update)和删除(delete)的数据行数决定。当 InnoDB 执行一个事务完整的回滚,这个事务所有所加的锁将被释放。然而,如果只一句的 SQL 语句因结果返回错误而进行回滚的,由这条 SQL 语句所设置的锁定可能会被保持。这是因为 InnoDB r的行锁存储格式无法知道锁定是由哪个 SQL 语句所设置。
死锁的避免
设置锁等待超时参数:innodb_lock_wait_timeout
。需要说明的是,这个参数并不是只用来解决死锁问题,在并发访问比较高的情况下,如果大量事务因无法立即获得所需的锁而挂起,会占用大量计算机资源,造成严重性能问题,甚至拖跨数据库。我们通过设置合适的锁等待超时阈值,可以避免这种情况发生。
一个锁定记录集的事务,其操作结果集应尽量简短,以免一次占用太多资源,与其他事务处理的记录冲突。 对定点运行脚本的情况,避免在同一时间点运行多个对同一表进行读写的脚本,特别注意加锁且操作数据量比较大的语句。
在应用中,如果不同的程序会并发存取多个表,应尽量约定以相同的顺序来访问表,这样可以大大降低产生死锁的机会在程序以批量方式处理数据的时候,如果事先对数据排序,保证每个线程按固定的顺序来处理记录,也可以大大降低出现死锁的可能。
在事务中,如果要更新记录,应该直接申请足够级别的锁,即排他锁,而不应先申请共享锁,更新时再申请排他锁,因为当用户申请排他锁时,其他事务可能又已经获得了相同记录的共享锁,从而造成锁冲突,甚至死锁。
在REPEATABLE-READ隔离级别下,如果两个线程同时对相同条件记录用SELECT…FOR UPDATE加排他锁,在没有符合该条件记录情况下,两个线程都会加锁成功。程序发现记录尚不存在,就试图插入一条新记录,如果两个线程都这么做,就会出现死锁。这种情况下,将隔离级别改成READ COMMITTED,就可避免问题。