Transaction
Transaction
数据库事务(Database Transaction)是指作为单个逻辑工作单元执行的一系列操作,要么完整地执行,要么完全地不执行。正常情况下,这些操作将顺利进行,最终操作成功。但是,如果在这一系列过程中任何一个环节除了差错,数据库中所有信息都必须保持第一步操作前的状态不变,否则,数据库的信息将会一片混乱而不可预测。
事务并发的场景一般发生在多用户访问同一数据库时。
事务的基本概念
概念
- 事务是一种机制、一个操作序列,包含了一组数据库操作命令,并且把所有的命令作为一个整体一起向系统提交或撤销操作请求,即这一组数据库命令要么都执行,要么都不执行。
- 事务是一个不可分割的工作逻辑单元,在数据库系统上执行并发操作时,事务是最小的控制单元
- 事务适用于多用户同时操作的数据库系统的场景,如银行、保险公司及证券交易系统等等,通过事务的整体性以保证数据的一致性,是保证一组操作的平稳性和可预测性的技术,和并发编程中的进程同步概念类似
事务特性
事务具有四个特性(ACID):原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。就这是一个可靠数据库的事务应当具有的特性
- 原子性:指事务是一个不可再分割的工作单位,事务中的操作要么都发生,要么都不发生。事务中的各个元素完整不可分割,即原子的。事务中的任何元素失败,则整个事务失败。例如转账事务中的扣款和加款语句不能分割。需要注意的是保证的是结果的原子性,即事务A执行过程中可以穿插事务B的执行
- 一致性:一致性是指数据库的完整性约束没有被破坏,在事务执行前后都是一致状态,而事务执行过程中可以不处于一致状态。这里的一致可以表示数据库自身的约束没有被破坏,比如某些字段的唯一性约束、字段长度约束等等;还可以表示各种实际场景下的业务约束,比如上面转账操作,一个账户减少的金额和另一个账户增加的金额一定是一样的。
- 隔离性:指的是多个事务彼此之间是完全隔离、互不干扰的。隔离性的最终目的也是为了保证一致性。在并发环境中,当不同的事务同时操纵相同的数据时,每个事务都有各自的完整数据空间。对数据进行修改的所有并发事务是彼此隔离的,表明事务必须是独立的,它不应以任何方式依赖于或影响其他事务。修改数据的事务可在另一个使用相同数据的事务开始之前访问这些数据,或者在另一个使用相同数据的事务结束之后访问这些数据。
- 持久性:持久性是指只要事务提交成功,那么对数据库做的修改就被永久保存下来了,不可能因为任何原因再回到原来的状态。
在MySQL事务中,原子性是基础,隔离性是手段,一致性是目的,持久性是结果
事务的状态
根据事务所处的不同阶段,事务可以大致分为以下五个状态
- 活动状态(active):当事务对应的数据库操作正在执行过程中,则该事务处于活动状态。
- 部分提交状态(partially committed):当事务中的最后一个操作执行完成,但还未将变更刷新到磁盘时,则该事务处于部分提交状态。
- 失败状态(failed):当事务处于活动或者部分提交状态时,由于某些错误导致事务无法继续执行,则事务处于失败状态。
- 终止状态(aborted):当事务处于失败状态,且回滚操作执行完毕,数据恢复到事务执行之前的状态时,则该事务处于中止状态。
- 提交状态(committed): 当事务处于部分提交状态,并且将修改过的数据都同步到磁盘之后,此时该事务处于提交状态。
事务并发带来的问题及解决方案
脏读
一个事务读取了另一个未提交的数据,而这个数据是有可能回滚的
不可重复读
一个事务内两个相同的查询却返回了不同数据,这是由于查询时系统中同时有其他事务修改的提交而引起的
幻读
事务A 按照一定条件进行数据读取, 期间事务B 插入了相同搜索条件的新数据,事务A再次按照原先条件进行读取时,发现了事务B 新插入的数据 称为幻读。
不可重复读重点在于update和delete,而幻读的重点在于insert。
丢失更新
两个事务同时读取同一条记录,A 先修改记录,B 也修改记录(B不知道A修改过),B 提交数据后,B 的修改结果覆盖了 A 的修改结果。
MySQL事务隔离级别
MySQL中包含四种事务隔离级别,它是一种概念上的并发问题解决方案,用于解决上述读一致性问题,事务的隔离级别是用锁或者MVCC实现的:
- read uncommitted(未提交读)
- read committed(已提交读)
- repeatable read(可重复读)
- serializable(串行化)
隔离级别越高,事务的并发度越低。InnoDB在可重复读级别就解决了幻读的问题,这也是InnoDB使用可重复读作为事务默认隔离级别的原因。
在MySQL中,支持使用sql语句直接设置隔事务离级别,无需考虑具体的实现。
1 |
|
多版本并发控制
MVCC(Multi Version Concurrency Control),中文名是多版本并发控制,简单来说就是通过维护数据历史版本,从而解决并发访问情况下的读一致性问题,是一种无锁解决方案。
redo日志和undo日志
- redo log 重做日志,是记录物理数据变化的日志,使用数据库DML对数据的修改操作,都会产生redo log,它可以保证事务的持久性。事务采用日志先行来保证数据是持久的,不管三七二十一,先写日志再说。当一个事务提交时,其产生所有的日志必须先写到磁盘中,这样一来,若在日志写入磁盘后,内存中的数据持久化前数据库发生了宕机,那么数据库重启时,可以通过日志来保证数据的完整性。
- undo log是回滚日志,有两个作用:提供回滚操作和多个行版本控制(MVCC)。在数据修改的时候,不仅记录了redo,还记录了相对应的undo,如果因为某些原因导致事务失败或回滚了,可以借助该undo进行回滚。
版本链
在InnoDB中,每行记录实际上都包含了两个隐藏字段:事务id(trx_id)和回滚指针(roll_pointer)。
- trx_id:表示最近修改的事务的id ,每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列。新增一个事务时,trx_id会递增,因此 trx_id 能够表示事务开始的先后顺序。
- roll_pointer:指向该行上一个版本的地址,每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
在MysQL中每行记录实际上也都维护了一个该行记录所有历史记录的链表,当然,这个链表存在于 undo log 中,和最新版本的数据不在一起,最新版本的数据是这个链表的头节点。每次对某条记录进行更新后,都会将旧值放到一条 undo log 中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被roll_ptr 属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。
ReadView
MVCC 只在read-committed和repeatable-read 两个隔离级别下工作,在这两个级别下的MVCC的ReadView策略不同。其他两个隔离级别:read-uncommitted,总是读取最新的数据行,而不会读当前事务版本的数据行。serializable,则会对所有读取的行都加锁,和 MVCC不兼容。
对于使用 read-committed 和 repeatable-read 隔离级别的事务来说,都必须保证读到的是已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的。而readview就是用于判断哪个版本的事务是可见的。
ReadView 中有个 id 集合 trx_ids 来存储系统中当前活跃着的读写事务,也就是 begin 了还未 commit 或 rollback 的事务。也就是所有未提交事务的trx_id。当事务要对一条记录进行查询时,在版本链上按顺序查询直到找到trx_id不在集合 trx_ids中的首条记录,这条记录便是可见的。
在read-committed级别,readview会在每次查询操作前进行更新,这样就保证了读到的记录都是已提交的,这样也就保证了不会出现脏读。
在repeatable-read级别,readview只在某个事物第一次查询操作之前更新,这样就保证了可重复读。
锁
不显示的使用锁命令时,数据引擎会有一套自己的上锁规则。
事务并发访问同一数据资源的情况主要就分为读-读、写-写和读-写三种。
- 读-读:即并发事务同时访问同一行数据记录。由于两个事务都进行只读操作,不会对记录造成任何影响,因此并发读完全允许。
- 写-写:即并发事务同时修改同一行数据记录。这种情况下可能导致丢失更新问题,这是任何情况下都不允许发生的,因此只能通过加锁实现,也就是当一个事务需要对某行记录进行修改时,首先会先给这条记录加锁,如果加锁成功则继续执行,否则就排队等待,事务执行完成或回滚会自动释放锁。
- 读-写:即一个事务进行读取操作,另一个进行写入操作。这种情况下可能会产生脏读、不可重复读、幻读。最好的方案是读操作利用多版本并发控制(MVCC),写操作进行加锁。
按作用范围分类,锁可以分为行级锁、表级锁和全局锁。
- 行级锁:作用在数据行上,锁的粒度比较小
- 表级锁:作用在整张数据表上,锁的粒度比较大
- 全局锁:锁定数据库中的所有表
按属性分类,锁可以分为共享锁和排他锁。
- 共享锁:又称之为读锁,简称S锁,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。只有当数据上的读锁被释放后,其他事务才能对其添加写锁。共享锁主要是为了支持并发的读取数据而出现的,读取数据时,不允许其他事务对当前数据进行修改操作,从而避免”不可重读”的问题的出现。
- 排他锁:排它锁,又称之为写锁、独占锁,简称X锁,当事务对数据加上写锁后,其他事务既不能对该数据添加读锁,也不能对该数据添加写锁,写锁与其他锁都是互斥的。只有当前数据写锁被释放后,其他事务才能对其添加写锁或者是读锁。写锁主要是为了解决在修改数据时,不允许其他事务对当前数据进行修改和读取操作,从而可以有效避免”脏读”问题的产生。
按算法分类,锁可以分为间隙锁、临键锁和记录锁
- 间隙锁:间隙锁基于非唯一索引,它锁定一段范围内的索引记录。使用间隙锁锁住的是一个区间,而不仅仅是这个区间中的每一条数据。
- 临键锁:临键锁,是记录锁与间隙锁的组合,它的封锁范围,既包含索引记录,又包含索引区间,是一个左开右闭区间。临键锁的主要目的,也是为了避免幻读(Phantom Read)。如果把事务的隔离级别降级为RC,临键锁则也会失效。每个数据行上的非唯一索引列上都会存在一把临键锁,当某个事务持有该数据行的临键锁时,会锁住一段左开右闭区间的数据。需要强调的一点是,InnoDB 中行级锁是基于索引实现的,临键锁只与非唯一索引列有关,在唯一索引列(包括主键列)上不存在临键锁。
- 记录锁:也叫行锁,作用于行
按模式分类,锁可以分为悲观锁和乐观锁
- 悲观锁:悲观锁是基于一种悲观的态度类来防止一切数据冲突,它是以一种预防的姿态在修改数据之前把数据锁住,然后再对数据进行读写,在它释放锁之前任何人都不能对其数据进行操作,直到前面一个人把锁释放后下一个人数据加锁才可对数据进行加锁,然后才可以对数据进行操作,一般数据库本身锁的机制都是基于悲观锁的机制实现的。
- 乐观锁:乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何去做。乐观锁适用于读多写少的场景,这样可以提高程序的吞吐量。一般通过版本号机制实现乐观锁,在数据表中加上版本号字段 version,表示数据被修改的次数。当数据被修改时,这个字段值会加1。
举个简单的例子,假设帐户信息表中有一个 version 字段,当前值为 1 ,而当前帐户的余额( balance )为 100 。- 操作员 A 此时准备将其读出( version=1 ),并从其帐户余额中扣除 50( 100-50 );
- 操作员 A 操作的过程中,操作员 B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 20 ( 100-20 );
- 操作员 A 完成修改工作,将数据版本号加1( version=2 ),连同帐户扣除后余额( balance=50 ),提交到数据库完成更新;
- 操作员 B 完成了操作,也将版本号加1( version=2 )试图向数据库提交数据( balance=80 ),但此时比对数据库记录版本发现,操作员 B 提交的数据版本号为 2 ,数据库记录的当前版本也为 2 ,不满足 “提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略。
- 因此,操作员 B 的提交被驳回。这样,就避免了操作员 B 用基于 version=1 的旧数据修改,最终造成覆盖操作员 A 操作结果的可能。
按状态分类,锁可以分为意向共享锁和意向排他锁。
- 意向锁是指,未来的某个时刻,事务可能要加共享/排它锁了,先提前声明一个意向。由于意向锁仅仅表明意向,它其实是比较弱的锁,意向锁之间并不相互互斥,而是可以并行。意向锁的存在价值在于在定位到特定的行所持有的锁之前,提供一种更粗粒度的锁,可以大大节约引擎对于锁的定位和处理的性能,因为在存储引擎内部,锁是由一块独立的数据结构维护的,锁的数量直接决定了内存的消耗和并发性能。例如,事务A对表t的某些行修改(DML通常会产生X锁),需要对t加上意向排它锁,在A事务完成之前,B事务来一个全表操作(alter table等),此时直接在表级别的意向排它锁就能告诉B需要等待(因为t上有意向锁),而不需要再去行级别判断。
按加锁方式分类,锁可以分为隐式自动枷锁和显示命令枷锁
- 隐式锁:InnoDB自动加意向锁、对于UPDATE、DELETE和INSERT语句InnoDB自动加排他锁,对于普通SELECT语句,InnoDB不加锁
- 显示锁:使用命令按照作用范围、属性、算法、状态、模式加锁
死锁是是指多个事务在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。死锁可以解决和避免。
- 解决死锁:
- 等待,直至超时,一个事务阻塞的时间超过innodb_lock_wait_timeout 时,就会回滚
- 使用死锁检测进行死锁处理
- 避免死锁:
- 合理设计索引,使业务SQL 尽可能通过索引定位更少的行,减少锁竞争
- 调整业务逻辑SQL执行顺序,避免 update/delete 长时间持有锁的 SQL在事务前面
- 避免大事务,尽量将大事务拆成多个小事务来处理,小事务缩短锁定资源的时间,发生锁冲突的几率也更小
- 在并发比较高的系统中,不要显式加锁,特别是是在事务里显式加锁。如 select …for update 语句,如果是在事务里运行了start transaction 或设置了autocommit 等于0,那么就会锁定所查找到的记录
- 降低隔离级别也是较好的选择,比如将隔离级别从RR调整为RC,可以避免降低隔离级别。如果业务允许,可以避免掉很多因为gap锁造成的死锁
事务的操作
默认情况下,MySQL 的事务是自动提交的,即我们用 SQL 操作数据库时,一条语句执行后,系统会自动执行事务提交。当需要把一组语句作为一个事务提交时,需要手动对事务进行控制。手动控制事务有两种方法,一种是使用事务处理命令控制,另一种是使用 set 设置事务的处理方式。
使用事务命令控制事务
使用以下命令控制事务
1 |
|
使用SET设置控制事务
1 |
|