> 文章列表 > 你一定能看懂的SQL事务及其实现原理

你一定能看懂的SQL事务及其实现原理

你一定能看懂的SQL事务及其实现原理

一。概念

事务数据库执行原子操作的基本单位。一个事务中的多个修改,则要么全部成功执行,要么全部不执行。

关于事务的 MYSQL 官网的解释

Transactions are atomic units of work that can be *committed* or *rolled back*. When a transaction makes multiple changes to the database, either all the changes succeed when the transaction is committed, or all the changes are undone when the transaction is rolled back.

一句话概括 事务的四大特性:

  • 原子性:多个变更操作,要么全做,要么全不做(基于undo log实现事务回滚)
  • 持久性:数据修改是永久的,即便系统故障也不会丢失(基于data和redo log实现故障后恢复)
  • 隔离性:保证多事务并发时不相互干扰(可以调整不同的隔离级别,基于锁、MVCC的并发控制实现)
  • 一致性:事务过程中要么全用旧值,要么全用新值,不允许新旧值混合(基于原子性和隔离性实现)

二。四大特性(ACID)

关于ACID的 MYSQL 官网的解释

Transactions are *atomic* units of work that can be *committed* or *rolled back*. When a transaction makes multiple changes to the database, either all the changes succeed when the transaction is committed, or all the changes are undone when the transaction is rolled back.

The database remains in a consistent state at all times — after each commit or rollback, and while transactions are in progress. If related data is being updated across multiple tables, queries see either all old values or all new values, not a mix of old and new values.

Transactions are protected (isolated) from each other while they are in progress; they cannot interfere with each other or see each other’s uncommitted data. This isolation is achieved through the *locking* mechanism. Experienced users can adjust the *isolation level*, trading off less protection in favor of increased performance and *concurrency*, when they can be sure that the transactions really do not interfere with each other.

The results of transactions are durable: once a commit operation succeeds, the changes made by that transaction are safe from power failures, system crashes, race conditions, or other potential dangers that many non-database applications are vulnerable to. Durability typically involves writing to disk storage, with a certain amount of redundancy to protect against power failures or software crashes during write operations. (In InnoDB, the *doublewrite buffer* assists with durability.)

1. 原子性(A)

一个事务(transaction)中的所有操作,要么全做,要么全不做,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。

a.如何实现原子性?

开始执行事务操作时(beigin),数据库底层开始undo日志记录。在做每一个会对数据产生影响的地方,undo日志中都会记录下修改前的数据,例如:

  • 执行delete一条数据的时候,需要记录这条数据的信息,发生回滚的时候,重新插入这条旧数据

  • 执行update一条数据的时候,需要记录之前的旧值,发生回滚的时候,将记录还原为旧值

  • 执行insert一条数据的时候,需要这条记录的主键,发生回滚的时候,根据主键执行delete操作

当事务成功结束后(commit),则会清空undo日志,等待下一次事务执行。

2.持久性(D)

事务一旦提交成功,对数据的修改就是永久的,即便系统故障也不会丢失。

a.持久性要解决什么问题?

在MySQL这类的非内存数据库中,表数据是持久化在磁盘文件,同时为了提高效率,处理中的数据会加载在内存中。此外,内存中修改的数据不会实时回写磁盘,而是根据MYSQL的更新策略回写到磁盘文件。因此,事务提交成功,并不能保证数据能持久化保存在磁盘文件。需要在考虑在事务提交完后,数据还未写会磁盘文件时,发生系统崩溃或者软件崩溃导致内存数据丢失的情况。

b. 如何实现持久性?

在MYSQL中使用Redo log解决上面的问题。

当做数据修改的时候,不仅在内存中操作,还会在Redo log中记录这次操作。当事务提交的时候,会将Redo log日志回写redo日志文件。如果数据库崩溃,则会将Redo log中的内容恢复到数据库中,再根据undo log和bin log内容决定回滚数据还是提交数据。

c.为什么不直接写到data文件?

为什么要通过redo来实现持久化,而不直接写把此次修改的数据持久化到date文件?(非标准,个人观点)

  1. 磁盘数据文件缓存到内存时,不是对一条条数据进行组织而是按照实际块进行缓存
  2. 同一个数据块可能有多个事务正在使用,不能随时将数据块回写到数据库,需要加锁或者判断状态
  3. 一个事务很可能不仅仅修改一个表,如果同时修改多个表则可能同时回写很多个内存块

而持久化redo log则只需要向redo文件追加一条事务的redo记录即可。

2.一致性(C)

在事务开始之前和事务结束以后,数据库的完整性没有被破坏。即:写入的数据必须完全符合所有的预设规则,包含数据的精确度、串联性以及后续数据库可以自发性地完成预定的工作。

a.如何实现一致性

基于原子性和隔离性实现

3.隔离性(I)

事务在进行过程中应该相互隔离,保证多个事务间不能相互干扰,即不能看到对方未提交的数据。

3.1 并发问题

实现隔离性目的是为了解决多个事务并发时的一些问题,下面是关于并发引发的几类问题总结

3.1.1 脏读

事务A读取了事务B修改的数据,若事务B发生回滚,事务A依然使用回滚的数据,称 事务A 脏读。

时间 事务A 事务B
T1 开始事务A(Data=500)
T2 开始事务B(Data=500)
T3 修改数据(Data=1000)
T4 读取数据(Data=1000)
T5 回滚事务(Data=500)
T6 使用Data=1000继续事务

注意:T4时刻,事务A读到的是未提交的数据(Data=1000)

3.1.2 脏写

事务B修改一条数据还未提交,事务A修改同一条数据未提交,事务B回滚(数据变成事务B修改前的数据),事务A提交。称 事务A 脏写(即:写入的不是事务A想要的数据)。

时间 事务A 事务B
T1 开始事务A(Data=500)
T2 修改数据(Data=2000)
T3 开始事务B(Data=500)
T4 修改数据(Data=1000)
T5 回滚事务(Data=500)
T6 提交事务(写入Data=500)
3.1.3 不可重复读

事务A读取数据后,事务B修改并提交同一个数据,事务A再次读取数据。此时事务A两次读数据不一致。称 事务A 不可重复读。

时间 事务A 事务B
T1 开始事务A(Data=500)
T2 读取数据(Data=500)
T3 开始事务B(Data=500)
T4 修改数据(Data=1000)
T5 提交事务(Data=1000)
T6 读取数据(Data=1000)
3.1.4 幻读

事务A读取了两次数据,在这两次的读取过程中事务B增删记录,事务A的两次读取出来的集合不一样。称 事务A 幻读(和不可重复读的些微区别在于:这里是集合操作,可以是读/写,比如统计记录条数、全表修改字段)

时间 事务A 事务B
T1 开始事务A(100条数据)
T2 统计数据(100条数据)
T3 开始事务B(100条数据)
T4 插入数据(新增10条数据)
T5 提交事务(110条数据)
T6 统计数据(110条数据)
3.1.5 丢失更新
A. 第一类丢失更新(回滚丢失,Lost update)

事务A期间,事务B对数据进行了更新并提交;事务A发生回滚,覆盖了事务B已经提交的数据

时间 事务A 事务B
T1 开始事务A(Data=500)
T2 读取数据(Data=500)
T3 开始事务B(Data=500)
T4 修改数据(Data=1000)
T5 提交事务(Data=1000)
T6 回滚事务(Data=500)
B. 第二类丢失更新(覆盖丢失/两次更新问题,Second lost update)

事务A期间,事务B对数据进行更新并提交;在事务A提交之后,覆盖了事务B已经提交的数据。

时间 事务A 事务B
T1 开始事务A(Data=500)
T2 读取数据(Data=500)
T3 开始事务B(Data=500)
T4 修改数据(Data=1000)
T5 提交事务(Data=1000)
T6 修改数据(Data=600)
T7 提交(Data=600)
3.1.6 小结

上面的每种并发问题之间都有一些细微的差别。

  • 脏读:读到脏的数据(回滚了的数据)
  • 脏写:写入脏的数据(被回滚还原的数据)
  • 不可重复读:第一次读到修改前数据,第二次读到修改后数据(两次读数据不一致)
  • 幻读:第一次操作修改前集合数据,第二次操作修改后集合数据(两次数据结果不一致,和不可重复读区别在于一者为读数据和一者为读/写集合)
  • 丢失更新:
    • 第一类丢失更新:因为回滚,把其他事务更新的数据覆盖了
    • 第二类丢失更新:因为并发更新,把其他事务更新的数据覆盖了

3.2 解决并发问题的三种实现

3.2.1 悲观锁

理论

在关系数据库管理系统里,悲观并发控制(又名“悲观锁”,PessimisticConcurrency Control,缩写“PCC”)是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作读某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。

事实上我们常说的悲观锁并不是一种实际的锁,而是一种并发控制的思想。悲观并发控制对于数据被修改持悲观(保守)的态度,认为数据被外界访问时,必然(较大概率)会产生冲突,所以在数据处理的过程中都采用加锁的方式来保证对资源的独占。

实现流程

  1. 开始事务后,按照操作类型给需要加锁的数据申请加某一类锁:例如共享行锁等
  2. 加锁成功则继续后面的操作,如果数据已经被加了其他的锁,而且和现在要加的锁冲突,则会加锁失败(例如已经加了排他锁),此时需等待其他的锁释放(可能出现死锁)
  3. 完成事务后释放所加的锁

优缺点

  • 优点:

    悲观并发控制采取的是保守策略:“先取锁,成功了才访问数据”,这保证了数据获取和修改都是有序进行的,因此适合在写多读少的环境中使用。当然使用悲观锁无法维持非常高的性能,但是在乐观锁也无法提供更好的性能前提下,悲观锁却可以做到保证数据的安全性。

  • 缺点:

    由于需要加锁,而且可能面临锁冲突甚至死锁的问题,悲观并发控制增加了系统的额外开销,降低了系统的效率,同时也会降低了系统的并行性。

3.2.2 乐观锁

理论

在关系数据库管理系统里,乐观并发控制(又名“乐观锁”,OptimisticConcurrency Control,缩写“OCC”)是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。

乐观观锁同样不是一种实际的锁,是一种并发控制的思想。乐观并发控制对数据修改持乐观态度,认为即使在并发环境中,外界对数据的操作一般是不会造成冲突,所以并不会去加锁,而是在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,则让返回冲突信息,让用户决定如何去做下一步,比如说重试或者回滚。

实现方式

CAS(比较与交换,Compare and swap) 是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。实现非阻塞同步的方案称为“无锁编程算法”( Non-blocking algorithm)。

乐观锁的实现就类似于上面的过程,主要有以下几种方式:

  1. 版本号标记:在表中新增一个字段:version,用于保存版本号。获取数据的时候同时获取版本号,然后更新数据的时候用以下命令:updatexxx set version=version+1,… where … version=“old version” and …。这时候通过判断返回结果的影响行数是否为0来判断是否更新成功,更新失败则说明有其他请求已经更新了数据了。
  2. 时间戳标记:和版本号一样,只是通过时间戳来判断。一般来说很多数据表都会有更新时间这一个字段,通过这个字段来判断就不用再新增一个字段了。
  3. 待更新字段:如果没有时间戳字段,而且不想新增字段,那可以考虑用待更新字段来判断,因为更新数据一般都会发生变化,那更新前可以拿要更新的字段的旧值和数据库的现值进行比对,没有变化则更新。
  4. 所有字段标记:数据表所有字段都用来判断。这种相当于就、不仅仅对某几个字段做加锁了,而是对整个数据行加锁,只要本行数据发生变化,就不进行更新。

优缺点

  • 优点:

    乐观并发控制没有实际加锁,所以没有额外开销,也不错出现死锁问题,适用于读多写少的并发场景,因为没有额外开销,所以能极大提高数据库的性能。

  • 缺点:

    乐观并发控制不适合于写多读少的并发场景下,因为会出现很多的写冲突,导致数据写入要多次等待重试,在这种情况下,其开销实际上是比悲观锁更高的。而且乐观锁的业务逻辑比悲观锁要更为复杂,业务逻辑上要考虑到失败,等待重试的情况,而且也无法避免其他第三方系统对数据库的直接修改的情况。

3.2.3 MVCC(多版本并发控制)

3.3 四个隔离级别(隔离级别从低到高)

3.3.1 读未提交

在事务之间提供最低的隔离级别。一个事务正在读数据时,允许其他事务同时进行读写操作。这种做法虽然能提升性能,但是以不太可靠的结果为代价,比如出现脏读。

请谨慎使用此隔离级别,并注意结果可能不一致或不可再现,这取决于其他并发事务此时正在做什么。通常,具有此隔离级别的事务只执行查询,不执行插入、更新或删除操作。

参考:https://dev.mysql.com/doc/refman/8.0/en/glossary.html#glos_read_uncommitted

3.3.2 读已提交

一种隔离级别,它使用一种锁定策略,在事务之间放松一些保护,以提高性能。事务不能看到其他事务中未提交的数据,但可以看到当前事务启动后另一个事务提交的数据。因此,事务永远不会看到任何坏数据,但是它看到的数据可能在一定程度上取决于其他事务的时间。

当具有此隔离级别的事务执行 UPDATE...WHERE...DELETE...WHERE... 操作,其他事务可能必须等待。的成交方式代码表

参考:https://dev.mysql.com/doc/refman/8.0/en/glossary.html#glos_read_committed

3.3.3 可重复读

InnoDB 的默认隔离级别。它可以防止查询的任何行被其他事务更改,从而避免不可重复读取的场景,但不会避免虚读。它使用适度严格的锁定策略,以便事务中的所有查询看到来自同一快照的数据,即事务启动时的数据。

当具有此隔离级别的事务执行UPDATE...WHEREDELETE...WHERE...SELECT...FOR UPDATELOCK IN SHARE MODE 操作,其他事务可能必须等待。

参考:https://dev.mysql.com/doc/refman/8.0/en/glossary.html#glos_repeatable_read

3.3.4 串行化

使用最保守锁定策略的隔离级别,以防止任何其他事务插入或更改由该事务读取的数据,直到该事务完成。通过这种方式,同一个查询可以在事务中反复运行,并且确保每次都检索相同的结果集。任何更改自当前事务开始以来由另一个事务提交的数据的尝试都会导致当前事务等待。

这是SQL标准指定的默认隔离级别。在实践中,这种严格程度很少出现

参考:https://dev.mysql.com/doc/refman/8.0/en/glossary.html#glos_serializable

3.3.5 小结
隔离级别 脏读 不可重复读 幻读 示例
读未提交 不解决 不解决 不解决
读已提交 能解决 不解决 不解决
可重复读 能解决 能解决 不解决
串行化 能解决 能解决 能解决

参考:

  • 图文并茂讲解Mysql事务实现原理
  • MYSQL官方说明
    你一定能看懂的SQL事务及其实现原理