事务定义
- 一个最小的不可再分的工作单元;通常一个事务对应一个完整的业务(例如银行账户转账业务,该业务就是一个最小的工作单元)
- 一个完整的业务需要批量的DML(insert、update、delete)语句共同联合完成
- 事务只和DML语句有关,或者说DML语句才有事务。这个和业务逻辑有关,业务逻辑不同,DML语句的个数不同
事务四大特性(ACID)
原子性(atomiciy)
一个事务是不可分割的最小工作单元,必须保证其中的操作要么全部执行,要么全部回滚,不可能存在只执行了一部分这种情况出现。
一致性(consistency)
执行事务是使得数据库从一个一致性状态到另一个一致性状态,如果事务最终没有被提交,那么事务所做的修改也不会保存到数据库中。在事务的开始之前和事务结束以后,数据库的完整性约束没有被破坏。达到了你的预期,
隔离性
一个事务提交之前对其他事务是不可见的,但是这里所说的不可见需要考虑隔离级别,比如未提交读在提交前对于其他事务来说也是可见的,隔离级别,在下面会详细讲。
持久性
事务一旦被提交,那么对数据库的修改会被永久的保存,即使数据库崩溃修改后的数据也不会丢失。
事务的(ACID)特性是由关系数据库管理系统(RDBMS,数据库系统)来实现的。
数据库管理系统采用日志来保证事务的原子性、一致性和持久性。日志记录了事务对数据库所做的更新,如果某个事务在执行过程中发生错误,就可以根据日志,撤销事务对数据库已做的更新,使数据库退回到执行事务前的初始状态。
数据库管理系统采用锁机制来实现事务的隔离性。当多个事务同时更新数据库中相同的数据时,只允许持有锁的事务能更新该数据,其他事务必须等待,直到前一个事务释放了锁,其他事务才有机会更新该数据。
实现通过必须先将该事务的所有事务日志写入到磁盘上的redo log file和undo log file中进行持久化。
事务日志(redo log和undo log)
innodb事务日志包括redo log 和undo log 。redo log是重做日志,提供前滚操作,undo log 是回滚日志,提供回滚操作。
undo log 不是通过redo log的逆向过程,其实他们都算是用来恢复的日志。
1. redo log 通常是物理日志,记录的是数据页的物理修改,而不是某一行修改成怎样怎样,它用来恢复提交后的物理数据页(恢复数据页,且只能恢复到最后一次提交的位置)。
2.undo 用来回滚记录到某个版本。undo log 一般是逻辑日志,根据每行记录进行记录。
redo log
redo log 和二进制的区别
redo log 不是二进制日志,虽然二进制日志中也记录了innodb的很多操作,也能实现重做的功能,但是他们之间有很大的区别。
1.二进制日志是存储引擎上层产生的,不管是什么存储引擎,对数据库进行了修改都会产生二进制日志。而redo log 是innodb 层产生的,只记录该存储引擎表中的修改。并且二进制日志先于redo log 被记录。具体的见后文 group commit 小结。
2.二进制日志记录操作的方法是逻辑性的语句。
即便它是基于行的记录方式,其本质也还是逻辑的SQL设置,如该行记录的每列的值是多少。而redo log 是物理格式上的日志,它记录的数据库每页的修改。
3.二进制日志只在每次事务提交的时候一次性写入缓存中的日志文件。而redo log 在数据修改前写入缓存中redo log 中,然后才对缓存中的数据执行修改操作,而且保证在发出事务提交指令时,先向缓存中的redo log写入日志,写入后才执行提交动作。
4. 因为二进制只在提交的时候一次性写入,所以二进制日志中的记录方式和提交顺序有关,且一次提交对应一次记录。而redo log 中是记录的物理页的修改,redo log 文件中同一个事务可能多次记录,最后一次提交事务记录会覆盖所有未提交的事务记录。
5.事务日志记录的是物理页的情况,它具有幂等性,因此记录日志的方式及其简练。幂等性的意思是多次操作前后状态是一样的,例如新插入一行后又删除该行,前后状态没有变化。而二进制日志记录的是所有影响数据的操作,记录的内容较多。例如插入一行记录一次,删除该行又记录一次。
redo log的基本概念
redo log 分为两部分,一是内存中的日志缓存(redo log buffer),该部分日志是易失性的;二是磁盘上的重做日志文件(redo log file)该部分日志是持久的。
innodb 通过force log at commit 机制实现事务的持久性,即在事务提交的时候,必须先将该事务的所有事务日志写入到磁盘上的redo log file 和undo log file 中进行持久化。
为了确保每次日志都能写入到事务日志文件中,在每次将log buffer中的日志写入日志文件的过程中都会调用一次操作系统的fsync操作。因为MariaDB/Mysql是工作在用户空间的,Maria/Mysql的log buffer 处于用户空间的内存中。要写入到磁盘上的log file 中(redo:ib_logfileN文件,undo:share tablespace或.ibd文件),中间还要经过操作系统内核空间的os buffer,调用fsync()的作用就是将OS buffer中的日志刷到磁盘上的log file中。
Mysql支持用户自定义在commit时如何将logbuffer 中的日志刷log file 中。这种控制通过变量 innodb_flush_log_at_trx_commit 的值来决定。该变量有三种值:0,1,2 默认为1。但注意,这个变量只是控制commit动作是否刷新log buffer到磁盘。
- 当设置为1的时候,事务每次提交都会将log buffer 中的日志写入os buffer 并调用 fsync() 刷到log file on disk 中。这种方式即使系统崩溃也不回失去任何数据,但是因为每次提交都写入磁盘,IO的性能较差。
- 当设置为0的时候,事务提交时都不会将log buffer中日志写入到os buffer并调用fsync()写入到 log file on disk 中,也就是说设置为0时 是每秒刷新写入到磁盘中的,当系统崩溃,会对视一秒钟的数据。
- 当设置为2的时候,每次提交都仅写入到os buffer,然后是每秒调用fsync()将os buffer中的日志写入到log file on disk。
注意,有一个变量 innodb_flush_log at timeout 的值为1秒,该变量表示的是刷日志的频率,很多人误以为是控制 innodb_flush_log_at_trx_commit 设置为0和2 的时候性能基本都是不变的。
在主从复制结构中,要保证事务的持久性和一致性,需要对日志相关变量设置为如下:
- 如果启用了二进制日志,则设置sync_binlog=1,即每提交一次事务同步写到磁盘中。
- 总是设置innodb_flush_log_at_trx_commit=1,即每提交一次事务都写到磁盘中。
上述两项变量的设置保证了:每次提交事务都写入二进制日志和事务日志,并在提交时将它们刷新到磁盘中。
选择刷日志的时间会严重影响数据修改时的性能,特别是刷到磁盘的过程。下例就测试了 innodb_flush_log_at_trx_commit 分别为0、1、2时的差距。
日志块
innodb存储引擎中,redo log 以块为单位进行存储,每个块占512字节,这称为redo log block ,所以不管是log buffer 中还是os buffer 中以及redo log file on disk中,都是这样以512 字节的块存储的。
每个redo log block由3部分组成:日志块头、日志块尾和日志主体。其中日志块头占用12字节,日志块尾占用8字节,所以每个redo log block的日志主体部分只有512-12-8=492字节。
因为redo log 记录的是数据也的变化需要使用超过492字节()的redo log 来记录,那么就会使用多个redo log block 来记录改数据页的变化。
日志块头包含4部分:
- log_block_hdr_no:(4字节)该日志块在redo log buffer 中的位置ID。
- log_block_hdr_data_len:(2字节)该log block 中已记录的log 大小,写满该log block 是为0x2000,表示512字节。
- log_block_first_rec_group:(2字节)该log block中第一个log的开始偏移位置。
- lock_block_checkpoint_no:(4字节)写入检查点信息的位置。
关于log block块头的第三部分 log_block_first_rec_group ,因为有时候一个数据页产生的日志量超出了一个日志块,这是需要用多个日志块来记录该页的相关日志。例如,某一数据页产生了552字节的日志量,那么需要占用两个日志块,第一个日志块占用492字节,第二个日志块需要占用60个字节,那么对于第二个日志块来说,它的第一个log的开始位置就是73字节(60+12)。如果该部分的值和 log_block_hdr_data_len 相等,则说明该log block中没有新开始的日志块,即表示该日志块用来延续前一个日志块。
日志尾只有一个部分: log_block_trl_no ,该值和块头的 log_block_hdr_no 相等。
上面所说的是一个日志块的内容,在redo log buffer或者redo log file on disk中,由很多log block组成。如下图:
log group和redo log file
log group 表示的是redo log group ,一个组内由多个大小完全相同的redo log file 组成。组内redo log file 的数量由变量 innodb_log_files_group 决定,默认值为2,即两个redo log file。这个组是一个逻辑的概念,并没有真正的文件来表示这是一个组,但是可以通过变量
innodb_log_group_home_dir 来定义组的目录,redo log file 都放在这个目录下,默认是datadir下。
在innodb将log buffer中的redo log block刷到这些log file中时,会以追加写入的方式循环轮训写入。即先在第一个log file(即ib_logfile0)的尾部追加写,直到满了之后向第二个log file(即ib_logfile1)写。当第二个log file满了会清空一部分第一个log file继续写入。
由于是将log buffer中的日志刷到log file,所以在log file中记录日志的方式也是log block的方式。
在每个组的第一个redo log file中,前2KB记录4个特定的部分,从2KB之后才开始记录log block。除了第一个redo log file中会记录,log group中的其他log file不会记录这2KB,但是却会腾出这2KB的空间。如下:
redo log的格式
因为innodb存储引擎存储数据的单元是页(和SQL SERVER ZHO),所以redo log也是基于页的格式来记录的。。默认情况下,innodb的页大小是16KB(由 innodb_page_size 变量控制),一个页内可以存放非常多的log block(每个512字节),而log block中记录的又是数据页的变化。
其中log block中492字节的部分是log body,该log body的格式分为4部分:
其中log block中492字节的部分是log body,该log body的格式分为4部分:
- redo_log_type:占用1个字节,表示redo log的日志类型。
- space:表示表空间的ID,采用压缩的方式后,占用的空间可能小于4字节。
- page_no:表示页的偏移量,同样是压缩过的。
- redo_log_body表示每个重做日志的数据部分,恢复时会调用相应的函数进行解析。例如insert语句和delete语句写入redo log的内容是不一样的
日志刷盘的规则
log buffer中未刷到磁盘的日志称为脏日志。
在上面说过,默认情况下事务每次提交的时候都会刷到事务日志到磁盘中,这是因为变量 innodb_flush_log_at_trx_commit 的值为1。但是innodb不仅仅只会在有commit动作后才会刷日志到磁盘,这只是innodb存储引擎刷日志的规则之一。
刷日志到磁盘有以下几种规则:
1.发出commit动作时。已经说明过,commit发出后是否刷日志由变量 innodb_flush_log_at_trx_commit 控制。
2.每秒刷一次。这个刷日志的频率由变量 innodb_flush_log_at_timeout 值决定,默认是1秒。要注意,这个刷日志频率和commit动作无关。
3.当log buffer中已经使用的内存超过一半时。
4.当有checkpoint时,checkpoint在一定程度上代表了刷到磁盘时日志所处的LSN位置。
数据页刷盘的规则及checkpoint
内存中未刷到磁盘数据称为脏数据。由于数据和日志都以页的形式存在,所以脏页表示脏数据和脏日志。
上一节介绍了日志是何时刷到磁盘的,不仅仅是日志需要刷盘,脏数据页也一样需要刷盘。
在innodb中,数据刷盘的规则只有一个:checkpoint。但是触发checkpoint的情况却有几种。不管怎样,checkpoint触发后,会将buffer中脏数据页和脏日志页都刷到磁盘。
innodb存储引擎中checkpoint分为两种:
- sharp checkpoint:在重用redo log文件(例如切换日志文件)的时候,将所有已记录到redo log中对应的脏数据刷到磁盘。
- fuzzy checkpoint:一次只刷一小部分的日志到磁盘,而非将所有脏日志刷盘。有以下几种情况会触发该检查点:
- master thread checkpoint:由master线程控制,每秒或每10秒刷入一定比例的脏页到磁盘。
- flush_lru_list checkpoint:从MySQL5.6开始可通过 innodb_page_cleaners 变量指定专门负责脏页刷盘的page cleaner线程的个数,该线程的目的是为了保证lru列表有可用的空闲页。
- async/sync flush checkpoint:同步刷盘还是异步刷盘。例如还有非常多的脏页没刷到磁盘(非常多是多少,有比例控制),这时候会选择同步刷到磁盘,但这很少出现;如果脏页不是很多,可以选择异步刷到磁盘,如果脏页很少,可以暂时不刷脏页到磁盘
- dirty page too much checkpoint:脏页太多时强制触发检查点,目的是为了保证缓存有足够的空闲空间。too much的比例由变量 innodb_max_dirty_pages_pct 控制,MySQL 5.6默认的值为75,即当脏页占缓冲池的百分之75后,就强制刷一部分脏页到磁盘。
由于刷脏页需要一定的时间来完成,所以记录检查点的位置是在每次刷盘结束之后才在redo log中标记的。
LSN超详细分析
LSN称为日志的逻辑序列号(log sequence number),在innodb存储引擎中,lsn占用8个字节。LSN的值会随着日志的写入二逐渐增大。
根据LSN,可以获取到几个有用的信息。
1.数据页的版本信息
2.写入的日志总量,通过LSN开始号码和结束号码可以计算出写入的日志量。
3.可知道检查点的位置
实际上还可以获得很多隐式的信息。
LSN不仅存在于redo log中,还存在于数据页中,在每个数据页的头部,有一个
redo log 的lsn信息可以通过show engine innodb status 来查看。Mysql 5.5 版本的show结果中只有三条记录,没有pages flushed up to。
mysql> show engine innodb stauts
---
LOG
---
Log sequence number 2225502463
Log flushed up to 2225502463
Pages flushed up to 2225502463
Last checkpoint at 2225502463
0 pending log writes, 0 pending chkp writes
3201299 log i/o's done, 0.00 log i/o's/second- log sequence number就是当前的redo log(in buffer)中的lsn;
- log flushed up to是刷到redo log file on disk中的lsn;
- pages flushed up to是已经刷到磁盘数据页上的LSN;
- last checkpoint at是上一次检查点所在位置的LSN。
innodb从执行修改语句开始
- 首先修改内存中的数据页,并在数据页中记录LSN,暂且称之为data_in_buffer_lsn
- 并且在修改数据页的同时(几乎是同时)向redo log in buffer 中写入redo log ,并记录下对应的LSN,暂且称之redo_log_in_buffer_lsn
- 写完buffer中的日志后,当触发了日志刷盘的几种规则是,会向redo log file on disk刷入重做日志,并在该文件中几下对应的LSN,暂且称之为redo_log_on_disk_lsn
- 数据页不可能永远只停留在内存中,在某些情况下,会触发checkpoint来将内存中的脏页(数据脏页和日志脏页)刷到磁盘,所以会在本次checkpoint脏页刷盘结束时,在redo log中记录checkpoint的LSN位置,暂且称之为checkpoint_lsn。
- 要记录checkpoint所在位置很快,只需简单的设置一个标志即可,但是刷数据页并不一定很快,例如这一次checkpoint要刷入的数据页非常多。也就是说要刷入所有的数据页需要一定的时间来完成,中途刷入的每个数据页都会记下当前页所在的LSN,暂且称之为data_page_on_disk_lsn
innodb的恢复行为
在启动innodb的时候,不管是正常关闭还是异常关闭,总是会进行恢复操作。
因为redolog 记录的是数据页的物理变化,因此恢复的时候速度比逻辑日志(如二进制日志)要快很多。而且,innodb自身也做了一定程度的优化,让恢复速度变得更快。
重启innodb时,checkpoint表示已经完整刷到磁盘上data page上的LSN,因此恢复时仅需要恢复从checkpoint开始的日志部分。例如,当数据库在上一次checkpoint的LSN为10000时宕机,且事务是已经提交过的状态。启动数据库时会检查磁盘中数据页的LSN,如果数据页的LSN小于日志中的LSN,则会从检查点开始恢复。
还有一种情况,在宕机前正处于checkpoint的刷盘过程,且数据页的刷盘进度超过了日志页的刷盘进度。这时候一宕机,数据页中记录的LSN就会大于日志页中的LSN,在重启的恢复过程中会检查到这一情况,这时超出日志进度的部分将不会重做,因为这本身就表示已经做过的事情,无需再重做。
另外,事务日志具有幂等性,所以多次操作得到同一结果的行为在日志中只记录一次。而二进制日志不具有幂等性,多次操作会全部记录下来,在恢复的时候会多次执行二进制日志中的记录,速度就慢得多。例如,某记录中id初始值为2,通过update将值设置为了3,后来又设置成了2,在事务日志中记录的将是无变化的页,根本无需恢复;而二进制会记录下两次update操作,恢复时也将执行这两次update操作,速度比事务日志恢复更慢。
uodo log
基本概念
undo log 有两个作用:提供回滚和多个行版本控制(MVCC).
在修改数据的时候,不仅记录了redo,还记录了相对应的undo,如果因为某些原因导致事务失败或回滚了,可以借助该undo进行回滚。undo log和redo log 记录物理日志不一样,他是逻辑日志。可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录。
当执行rollback 时,就可以从undo log 中的逻辑记录读取相应的内容并进行回滚。有时候应用到行版本控制的时候,也是通过undo log 来实现的,当读取的某一行呗其他事物锁定时,他可以从undo log 中分写出改行记录以前的数据是什么,从而提供改行版本控制信息,让用户实现非锁定一致性读取。
undo log 是采用段的方式来记录的,每个undo 操作在记录的时候占用一个undo log segment。
另外 ,undo log 也会产生redo log,因为undo log 也要实现持久性保护。
undo log 的存储方式
innodb 存储引擎对undo 的管理采用段的方式。rollback segment 称为回滚段,每个回滚段中有1024 undo log segement 。
在以前老版本,只支持一个rollback segemnt ,这样就只能记录一个1024 个undo log segemnt 。后来Mysql5.5 可以支持128 个rollback segemnt ,即支持128*1024个undo 操作,还可以通过变量 innodb_undo_log自定义多少个rollback segment,默认值为128。
undo log默认存放在共享表空间中。
[root@xuexi data]# ll /mydata/data/ib*
-rw-rw---- 1 mysql mysql 79691776 Mar 31 01:42 /mydata/data/ibdata1
-rw-rw---- 1 mysql mysql 50331648 Mar 31 01:42 /mydata/data/ib_logfile0
-rw-rw---- 1 mysql mysql 50331648 Mar 31 01:42 /mydata/data/ib_logfile1如果开启了 innodb_file_per_table ,将放在每个表的.ibd文件中。
在MySQL5.6中,undo的存放位置还可以通过变量 innodb_undo_directory 来自定义存放目录,默认值为"."表示datadir。
默认rollback segment全部写在一个文件中,但可以通过设置变量 innodb_undo_tablespaces 平均分配到多少个文件中。该变量默认值为0,即全部写入一个表空间文件。该变量为静态变量,只能在数据库示例停止状态下修改,如写入配置文件或启动时带上对应参数。但是innodb存储引擎在启动过程中提示,不建议修改为非0的值,如下:
2017-03-31 13:16:00 7f665bfab720 InnoDB: Expected to open 3 undo tablespaces but was able
2017-03-31 13:16:00 7f665bfab720 InnoDB: to find only 0 undo tablespaces.
2017-03-31 13:16:00 7f665bfab720 InnoDB: Set the innodb_undo_tablespaces parameter to the
2017-03-31 13:16:00 7f665bfab720 InnoDB: correct value and retry. Suggested value is 0delete 、update操作的内部机制
当食物提交的时候,innodb不会立即删除undo log ,因为后续还可能会用到undo log ,如隔离级别为repeattable read 时,事务读取的都是开启事务时的最新提交行版本,只要该事务不结果,该行版本就不能删除,即undo log不能删除。
但是在事务提交的时候,会将该事务对应的undo log放入到删除列表中,未来通过purge来删除。并且提交事务时,还会判断undo log分配的页是否可以重用,如果可以重用,则会分配给后面来的事务,避免为每个独立的事务分配独立的undo log页而浪费存储空间和性能。
通过undo log 记录 delete 和 update 操作的结果发现:(insert 操作无需分析,就是插入行而已)
delete 操作实际上不会直接删除,而是将delete 对象打上delete flag ,标记为删除,最终的删除操作是purge线程完成的。
update 分为两种情况:update 的列是否为主键列。
如果不是主键列,在undo log中直接反向记录时如何update 的。即update 是直接进行的。
如果是主键列,update分两步执行,先删除该行,再插入一行目标行。
binlog和事务日志的先后顺序及group commit
如果事务不是只读事务,即设计到了数据修改,默认情况下回commit的时候调用fsync()将日志刷到磁盘,保证事务的持久性。
但是一次刷到一个事务的日志性能较低,特别是事务集中的某一时刻时事务量非常大的时候。innodb提供了group commit 功能,可能将多个事务的事务日志通过一次fsync()刷新到磁盘中。
因为事务在提交的时候不仅会记录事务日志。还会记录二进制日志,但是他们谁会先记录呢?二进制日志是MySQL 的上层日志,先于存储引擎的事务日志被写入。
在MySQL5.6以前,当事务提交(即发出commit指令)后,MySQL接收到该信号进入commit prepare阶段;进入prepare阶段后,立即写内存中的二进制日志,写完内存中的二进制日志后就相当于确定了commit操作;然后开始写内存中的事务日志;最后将二进制日志和事务日志刷盘,它们如何刷盘,分别由变量 sync_binlog 和 innodb_flush_log_at_trx_commit 控制。
但因为要保证二进制日志和事务日志的一致性,在提交后的prepare阶段会启用一个prepare_commit_mutex锁来保证它们的顺序性和一致性。但这样会导致开启二进制日志后group commmit失效,特别是在主从复制结构中,几乎都会开启二进制日志。
在MySQL5.6中进行了改进。提交事务时,在存储引擎层的上一层结构中会将事务按序放入一个队列,队列中的第一个事务称为leader,其他事务称为follower,leader控制着follower的行为。虽然顺序还是一样先刷二进制,再刷事务日志,但是机制完全改变了:删除了原来的prepare_commit_mutex行为,也能保证即使开启了二进制日志,group commit也是有效的。
MySQL5.6中分为3个步骤:flush阶段、sync阶段、commit阶段。
- flush阶段:向内存中写入每个事务的二进制日志。
- sync阶段:将内存中的二进制日志刷盘。若队列中有多个事务,那么仅一次fsync操作就完成了二进制日志的刷盘操作。这在MySQL5.6中称为BLGC(binary log group commit)。
- commit阶段:leader根据顺序调用存储引擎层事务的提交,由于innodb本就支持group commit,所以解决了因为锁 prepare_commit_mutex 而导致的group commit失效问题。
在flush阶段写入二进制日志到内存中,但是不是写完就进入sync阶段的,而是要等待一定的时间,多积累几个事务的binlog一起进入sync阶段,等待时间由变量 binlog_max_flush_queue_time 决定,默认值为0表示不等待直接进入sync,设置该变量为一个大于0的值的好处是group中的事务多了,性能会好一些,但是这样会导致事务的响应时间变慢,所以建议不要修改该变量的值,除非事务量非常多并且不断的在写入和更新。
进入到sync阶段,会将binlog从内存中刷入到磁盘,刷入的数量和单独的二进制日志刷盘一样,由变量 sync_binlog 控制。
当有一组事务在进行commit阶段时,其他新事务可以进行flush阶段,它们本就不会相互阻塞,所以group commit会不断生效。当然,group commit的性能和队列中的事务数量有关,如果每次队列中只有1个事务,那么group commit和单独的commit没什么区别,当队列中事务越来越多时,即提交事务越多越快时,group commit的效果越明显。
事务的原子性的实现
事务的一致性的实现
照我个人的理解,在事务处理的ACID属性中,一致性是最基本的属性,其它的三个属性都为了保证一致性而存在的。
所谓一致性的定义,指的是数据处于一种有意义的转态,这种转态是语义上的而不是语法上的。照我个人的理解,在事务处理的ACID属性中,一致性是最基本的属性,其它的三个属性都为了保证一致性而存在的。
在数据库实现的场景中,一致性可分为数据库外部的一致性和数据库内部的一致性。
在数据库实现的场景中,一致性可以分为数据库外部的一致性和数据库内部的一致性。前者由外部应用的编码来保证,即某个应用在执行转帐的数据库操作时,必须在同一个事务内部调用对帐户A和帐户B的操作。如果在这个层次出现错误,这不是数据库本身能够解决的,也不属于我们需要讨论的范围。后者由数据库来保证,即在同一个事务内部的一组操作必须全部执行成功(或者全部失败)。这就是事务处理的原子性。
为了实现原子性,需要通过日志:将所有对数据的更新操作都写入日志,如果一个事务中的一部分操作已经成功,但以后的操作,由于断电/系统崩溃/其它的软硬件错误而无法继续,则通过回溯日志,将已经执行成功的操作撤销,从而达到“全部操作失败”的目的。最常见的场景是,数据库系统崩溃后重启,此时数据库处于不一致的状态,必须先执行一个crash recovery的过程:读取日志进行REDO(重演将所有已经执行成功但尚未写入到磁盘的操作,保证持久性),再对所有到崩溃时尚未成功提交的事务进行UNDO(撤销所有执行了一部分但尚未提交的操作,保证原子性)。crash recovery结束后,数据库恢复到一致性状态,可以继续被使用。
日志的管理和重演是数据库实现中最复杂的部分之一。如果涉及到并行处理和分布式系统(日志的复制和重演是数据库高可用性的基础),会比上述场景还要复杂得多。
MySQL事务隔离级别的实现原理
脏读 一个事务独立另一个事物改写尚未提交的数据。如果改写回滚了,那么第一个事务获取的数据就是无效的。
不可重复读 不可重复读发横在一个事务执行相同的查询两次或两次以上,但每次都得到不同的数据,因为另一个并发事务在两次查询见进行了更新。
幻读 幻读与不可重复读类似。它发生在一个它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录。
不可重复读与幻读的区别
不可重复读的重点是修改:
同样的条件, 你读取过的数据, 再次读取出来发现值不一样了
幻读的重点在于新增或者删除:
同样的条件, 第1次和第2次读出来的记录数不一样
读未提交:一个事务可以读取到另一个事务未提交的修改。这会带来脏读、幻读、不可重复读问题。(基本没用)
读已提交:一个事务只能读取另一个事务已经提交的修改。其避免了脏读,但仍然存在不可重复读和幻读问题。
可重复读:同一个事务中多次读取相同的数据返回的结果是一样的。其避免了脏读和不可重复读问题,但幻读依然存在。
串行化:事务串行执行。避免了以上所有问题。
MVCC
MVCC的全称是“多版本并发控制”。这项技术使得InnoDB的事务隔离级别下执行一致性读操作有了保证,换言之,就是为了查询一些正在被另一个事务更新的行,并且可以看到它们被更新之前的值。这是一个可以用来增强并发性的强大的技术,因为这样的一来的话查询就不用等待另一个事务释放锁。这项技术在数据库领域并不是普遍使用的。一些其它的数据库产品,以及mysql其它的存储引擎并不支持它。
即多版本并发控制技术,它使得大部分支持行锁的事务引擎,不再单纯的使用行锁来进行数据库的并发控制,取而代之的是把数据库的行锁与行的多个版本结合起来,只需要很小的开销,就可以实现非锁定读,从而大大提高数据库系统的并发性能
读锁:也叫共享锁、S锁,若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S 锁。这保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。
写锁:又称排他锁、X锁。若事务T对数据对象A加上X锁,事务T可以读A也可以修改A,其他事务不能再对A加任何锁,直到T释放A上的锁。这保证了其他事务在T释放A上的锁之前不能再读取和修改A。
表锁:操作对象是数据表。Mysql大多数锁策略都支持(常见mysql innodb),是系统开销最低但并发性最低的一个锁策略。事务t对整个表加读锁,则其他事务可读不可写,若加写锁,则其他事务增删改都不行。
行级锁:操作对象是数据表中的一行。是MVCC技术用的比较多的,但在MYISAM用不了,行级锁用mysql的储存引擎实现而不是mysql服务器。但行级锁对系统开销较大,处理高并发较好。
innodb MVCC主要是为Repeatable-Read事务隔离级别做的。在此隔离级别下,A、B客户端所示的数据相互隔离,互相更新不可见
innodb存储的最基本row中包含一些额外的存储信息 DATA_TRX_ID,DATA_ROLL_PTR,DB_ROW_ID,DELETE BIT
6字节的DATA_TRX_ID 标记了最新更新这条行记录的transaction id,每处理一个事务,其值自动+1
7字节的DATA_ROLL_PTR 指向当前记录项的rollback segment的undo log记录,找之前版本的数据就是通过这个指针
- 6字节的DB_ROW_ID,当由innodb自动产生聚集索引时,聚集索引包括这个DB_ROW_ID的值,否则聚集索引中不包括这个值.,这个用于索引当中
DELETE BIT位用于标识该记录是否被删除,这里的不是真正的删除数据,而是标志出来的删除。真正意义的删除是在commit的时候
具体的执行过程
begin->用排他锁锁定该行->记录redo log->记录undo log->修改当前行的值,写事务编号,回滚指针指向undo log中的修改前的行
上述过程确切地说是描述了UPDATE的事务过程,其实undo log分insert和update undo log,因为insert时,原始的数据并不存在,所以回滚时把insert undo log丢弃即可,而update undo log则必须遵守上述过程
SELECT
Innodb 检查每行数据,确保他们符合两个标准
1、InnoDB只查找版本早于当前事务版本的数据行(也就是数据行的版本必须小于等于事务的版本),这确保当前事务读取的行都是事务之前已经存在的,或者是由当前事务创建或修改的行
2、行的删除操作的版本一定是未定义的或者大于当前事务的版本号,确定了当前事务开始之前,行没有被删除
INSERT
InnoDB为每个新增行记录当前系统版本号作为创建ID。
DELETE
InnoDB为每个删除行的记录当前系统版本号作为行的删除ID。
UPDATE
InnoDB复制了一行。这个新行的版本号使用了系统版本号。它也把系统版本号作为了删除行的版本。
说明
insert操作时 “创建时间”=DB_ROW_ID,这时,“删除时间 ”是未定义的;
update时,复制新增行的“创建时间”=DB_ROW_ID,删除时间未定义,旧数据行“创建时间”不变,删除时间=该事务的DB_ROW_ID;
delete操作,相应数据行的“创建时间”不变,删除时间=该事务的DB_ROW_ID;
select操作对两者都不修改,只读相应的数据
对于MVCC的总结
上述更新前建立undo log,根据各种策略读取时非阻塞就是MVCC,undo log 中的行就是MVCC的多版本,这个可能与我们所理解的MVCC有所出入
一般我们认为MVCC有下面几个特点:- 每行数据都存在一个版本,每次数据更新时都更改该版本。
- 修改时Copy出当前版本随意修改,各个事务质检无干扰。
- 保存时比较版本号,如果成功(commit),则覆盖原记录;失败则放弃copy(rollback)
就是每行都有版本号,保存时根据版本号决定是否成功,听起来含有乐观锁的味道,而Innodb的实现方式是:
事务以排他锁的形式修改原始数据
把修改前的数据存放于undo log ,通过回滚指针与主数据关联。
修改成功(commit)啥都不做,失败则恢复undo log 中的数据(rollback)。
比如,如果Transaciton1执行理想的MVCC,修改Row1成功,而修改Row2失败,此时需要回滚Row1,但因为Row1没有被锁定,其数据可能又被Transaction2所修改,如果此时回滚Row1的内容,则会破坏Transaction2的修改结果,导致Transaction2违反ACID。
快照读和当前读
快照读:读取的是快照版本,也就是历史版本
当前读:读取的是最新版本
普通的SELECT就是快照读,而UPDATE、DELETE、INSERT、SELECT ... LOCK IN SHARE MODE、SELECT ... FOR UPDATE是当前读。
一致性非锁定读和锁定读
锁定读
在一个食物中,标准的select语句是不会加锁的,但是有两种情况例外。select... lock in share mode 和 select .. for update
SELECT ... LOCK IN SHARE MODE
给记录假设共享锁,这样一来的话,其它事务只能读不能修改,直到当前事务提交
SELECT ... FOR UPDATE
给索引记录加锁,这种情况下跟UPDATE的加锁情况是一样的
一致性非锁定读
consistent read (一致性读)InnoDB是用多版本来提供查询数据库咋爱某个时间点的快照。如果隔离级别是REPEATABLE READ,那么在同一个事务中的所有一致性读都是读的是事务中第一个这样的读带到的快照;
如果是READ COMMITTED,那么一个事务中的每一个一致性读都会读到它自己刷新的快照版本。
Consistent read(一致性读)是READ COMMITTED和REPEATABLE READ隔离级别下普通SELECT语句默认的模式。一致性读不会给它所访问的表加任何形式的锁,因此其它事务可以同时并发的修改它们。
悲观锁和乐观锁
悲观锁,正如它的名字那样,数据库总是认为别人会去修改它所要操作的数据,因此在数据库处理过程中将数据加锁。其实现依靠数据库底层。
乐观锁,如它的名字那样,总是认为别人不会去修改,只有在提交更新的时候去检查数据的状态。通常是给数据增加一个字段来标识数据的版本。
记录锁 间隙锁
- Record Locks(记录锁):在索引记录上加锁。
- Gap Locks(间隙锁):在索引记录之间加锁,或者在第一个索引记录之前加锁,或者在最后一个索引记录之后加锁。
- Next-Key Locks:在索引记录上加锁,并且在索引记录之前的间隙加锁。它相当于是Record Locks与Gap Locks的一个结合。
在默认的隔离级别中,普通的SELECT用的是一致性读不加锁。而对于锁定读、UPDATE和DELETE,则需要加锁,至于加什么锁视情况而定。如果你对一个唯一索引使用了唯一的检索条件,那么只需锁定索引记录即可;如果你没有使用唯一索引作为检索条件,或者用到了索引范围扫描,那么将会使用间隙锁或者next-key锁以此来阻塞其它会话向这个范围内的间隙插入数据。
数据库为了达到一致性和隔离性,一般采用加锁这种方式。同时数据库又是个高并发的应用,同一时间会有大量的并发访问,如果加锁过度,会极大地降低并发处理能力,所以对于加锁的处理,可以说是数据库对事务处理的精髓所在。这里通过分析M有SQL的InnoDB引擎加锁机制,来抛砖引玉,,让读者更好的理解,在事务处理中数据库到底做了什么。
一次封锁or两段锁
因为有大量的并发访问,为了预防死锁,一般应用中推荐使用一次封锁法,就是在方法的开始阶段,已经预先知道会用那些数据,然后全部锁住,在方法运行之后,在全部解锁。这种方式可以有效地避免循环死锁,但在数据库中却不适用,因为在事务开始阶段,数据库并不知道会用到那些数据。
加锁阶段,在该阶段可以进行加锁操作,在对任何数据进行读操作之前要申请并获得S锁(共享锁,其他事物可以继续加共享锁,但不能加排它锁),在进行写操作之前要申请获得X锁(排它锁,其他事物不能再获得人和锁),加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。
解锁阶段,当事务释放一个封锁之后,事务进入解锁阶段,在该阶段只能进行解锁操作不能在进行加锁操作。
| 事务 | 加锁/解锁处理 |
|---|---|
| begin; | |
| insert into test ..... | 加insert对应的锁 |
| update test set... | 加update对应的锁 |
| delete from test .... | 加delete对应的锁 |
| commit; | 事务提交时,同时释放insert、update、delete对应的锁 |
这种方式虽然无法避免死锁,但是两段锁协议可以保证事务的并发调度是串行化(串行化很重要,尤其是在数据恢复和备份的时候)的。
Read UnCommitted(读取未提交内容)
这种级别,数据库一般都不会用,而且任何操作都不会酒叟,这里就不讨论了。
Read Committed(读取提交内容)
在RC级别中,数据的读取都是不加锁的,但是数据的写入、修改和删除是需要加锁的。效果如下
MySQL> show create table class_teacher \G\
Table: class_teacher
Create Table: CREATE TABLE `class_teacher` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`class_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`teacher_id` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_teacher_id` (`teacher_id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
1 row in set (0.02 sec)
MySQL> select * from class_teacher;
+----+--------------+------------+
| id | class_name | teacher_id |
+----+--------------+------------+
| 1 | 初三一班 | 1 |
| 3 | 初二一班 | 2 |
| 4 | 初二二班 | 2 |
+----+--------------+------------+
由于MySQL的InnoDB默认是使用的RR级别,所以我们先要将该session开启成RC级别,并且设置binlog的模式
SET session transaction isolation level read committed;
SET SESSION binlog_format = 'ROW';(或者是MIXED)
| 事务A | 事务B |
|---|---|
| begin; | begin; |
| update class_teacher set class_name='初三二班' where teacher_id=1; | update class_teacher set class_name='初三三班' where teacher_id=1; |
| ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction | |
| commit; |
为了防止并发过程中的修改冲突,事务A中MySQL给teacher_id=1的数据行加锁,并一直不commit(释放锁),那么事务B也就一直拿不到该行锁,wait直到超时。
这时我们要注意到,teacher_id是有索引的,如果是没有索引的class_name呢?update class_teacher set teacher_id=3 where class_name = '初三一班';
那么MySQL会给整张表的所有数据行的加行锁。这里听起来有点不可思议,但是当sql运行的过程中,MySQL并不知道哪些数据行是 class_name = '初三一班'的(没有索引嘛),如果一个条件无法通过索引快速过滤,存储引擎层面就会将所有记录加锁后返回,再由MySQL Server层进行过滤。
但在实际使用过程当中,MySQL做了一些改进,在MySQL Server过滤条件,发现不满足后,会调用unlock_row方法,把不满足条件的记录释放锁 (违背了二段锁协议的约束)。这样做,保证了最后只会持有满足条件记录上的锁,但是每条记录的加锁操作还是不能省略的。可见即使是MySQL,为了效率也是会违反规范的。(参见《高性能MySQL》中文第三版p181)
这种情况同样适用于MySQL的默认隔离级别RR。所以对一个数据量很大的表做批量修改的时候,如果无法使用相应的索引,MySQL Server过滤数据的的时候特别慢,就会出现虽然没有修改某些行的数据,但是它们还是被锁住了的现象。
Repeatable Read(可重读)
这是MySQL中InnoDB默认的隔离级别。我们姑且分“读”和“写”两个模块来讲解。
读
读就是可重读,可重读这个概念是一事务的多个实例在并发读取数据时,会看到同样的数据行,有点抽象,我们来看一下效果。
RC(不可重读)模式下的展现
| 事务A | 事务B | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| begin; | begin; | |||||||||
select id,class_name,teacher_id from class_teacher where teacher_id=1;
| ||||||||||
update class_teacher set class_name='初三三班' where id=1; | ||||||||||
| commit; | ||||||||||
select id,class_name,teacher_id from class_teacher where teacher_id=1;
读到了事务B修改的数据,和第一次查询的结果不一样,是不可重读的。 | ||||||||||
| commit; |
事务B修改id=1的数据提交之后,事务A同样的查询,后一次和前一次的结果不一样,这就是不可重读(重新读取产生的结果不一样)。这就很可能带来一些问题,那么我们来看看在RR级别中MySQL的表现: