本地事务 ######## .. sidebar:: 目录 .. contents:: 本地事务(Local Transactions):: 本地事务是指仅操作特定单一事务资源的、不需要 “全局事务管理器” 进行协调的事务。 本地事务是最基础的一种事务处理方案,通常只适用于单个服务使用单个数据源的场景, 它是直接依赖于数据源(通常是数据库系统)本身的事务能力来工作的。 事务的开启、终止、提交、回滚、嵌套、设置隔离级别、乃至与应用代码贴近的传播方式,全部都要依赖底层数据库的支持 崩溃恢复 ======== 未提交事务:: 程序还没修改完三个数据,数据库已经将其中一个或两个数据的变动写入了磁盘 此时『应用程序』出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次不完整的购物操作, 将已经修改过的数据从磁盘中恢复成没有改过的样子,以保证原子性。 已提交事务:: 程序已经修改完三个数据,数据库还未将全部三个数据的变动都写入到磁盘 此时『数据库』出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次完整的购物操作, 将还没来得及写入磁盘的那部分数据重新写入,以保证持久性。 * 崩溃恢复(Crash Recovery,也有称作 Failure Recovery 或 Transaction Recovery) 实现事务的原子性和持久性的主流方案:: 1. Commit Logging 2. Shadow Paging 3. ARIES提出的 “Write-Ahead Logging” 的日志改进方案 Commit Logging方案:: 顺序追加的文件写入方式,是最高效的写入方式 1. 日志记录全部都安全落盘 然后在日志中增加一条 “Commit Record” 代表事务成功提交 2. 数据库根据日志上的信息对真正的数据进行修改 全部修改完成后,在日志中加入一条 “End Record” 表示事务已完成持久化 1. 日志一旦成功写入 Commit Record,那整个事务就是成功的 即使修改数据时崩溃了,重启后根据已经写入磁盘的日志信息恢复现场、继续修改数据即可,这保证了持久性。 2. 如果日志没有写入成功就发生崩溃 系统重启后会看到一部分没有 Commit Record 的日志, 那将这部分日志标记为回滚状态即可,整个事务就像完全没有发生过一样,这保证了原子性。 实例代表: 阿里的 OceanBase 缺陷: 所有对数据的真实修改都必须发生在事务提交、日志写入了 Commit Record 之后 对提升数据库的性能是很不利的 `Shadow Paging `_ :: 1. 先将数据复制一份副本,保留原数据,修改副本数据 对数据的变动并不是直接就地修改原先的数据,而是先将数据复制一副本,保留原数据,修改副本数据。 在事务过程中,被修改的数据同时存在两份,一份修改前的,一份是修改后的,这是 “影子”(Shadow)这名字由来 2. 当事务成功提交,所有数据的修改都成功持久化之后 最后一步要修改数据的引用指针,将引用从原数据改为新复制出来修改后的副本 最后的 “修改指针” 这个操作将被认为是原子操作,所以 Shadow Paging 也可以保证原子性和持久性。 注: Shadow Paging 相对简单,但涉及到隔离性与锁时 Shadow Paging 实现的事务并发能力相对有限,因此在高性能的数据库中应用不多。 实例代表: 轻量级数据库 SQLite Version 3 不足: 在原子性与持久性上,Shadow Paging 并没有什么问题。它的不足主要出现在隔离性与并发能力上面 最关键的不足是一个 Page 上的数据同一时间只能被一个事务所修改, 否则如果允许多个事务同时修改 Page 上的数据,其中一个提交另一个不提交就没法往下做了, 如此高的串行度决定了整体并发能力必定低下。 FORCE 和 STEAL:: 按照事务提交时点为界,分为了 FORCE 和 STEAL 两类 FORCE: 事务提交后 1. 如要求变动数据必须同时完成写入则称为 FORCE 2. 如不强制变动数据必须同时完成写入则称为 NO-FORCE 现实中绝大多数数据库采用的都是 NO-FORCE 策略: 只要有了日志,变动数据随时可以持久化 从优化磁盘 I/O 性能考虑,没有必要强制数据写入立即进行。 STEAL: 在事务提交前: 1. 允许变动数据提前写入则称为 STEAL 2. 不允许则称为 NO-STEAL 从优化磁盘 I/O 性能考虑: 允许数据提前写入,有利于利用空闲 I/O 资源,也有利于节省数据库缓存区的内存。 实例说明: 1. Commit Logging 允许 NO-FORCE,但不允许 STEAL 2. Write-Ahead Logging 允许 NO-FORCE,也允许 STEAL 说明: 1. FORCE -> 每次提交数据是否必须【马上】落盘 2. STEAL -> 未提交数据是否可以【提前】落盘 数据库按照 “是否允许 FORCE 和 STEAL” 可以产生四种组合: 1. NO-FORCE & NO-STEAL: 重做日志 2. NO-FORCE & STEAL: 最快, 需要重做日志&回滚日志 3. FORCE & NO-STEAL: 最慢, 不需要日志 4. FORCE & STEAL: 回滚日志 .. image:: https://img.zhaoweiguo.com/knowledge/images/architectures/distributes/transaction1.jpg Write-Ahead Logging方案:: Write-Ahead Logging 是 ARIES 理论的一部分, 整套 ARIES 拥有严谨、高性能等很多的优点,但这些也是以复杂性为代价的 ARIES: Algorithms for Recovery and Isolation Exploiting Semantics,基于语义的恢复与隔离算法 允许 STEAL 的解决办法是增加了一种称为 Undo Log 的日志(“回滚日志”) 当变动数据写入磁盘前,必须先记录 Undo Log, 以便在事务回滚或者崩溃恢复时,根据 Undo Log 对提前写入的数据变动进行擦除。 此前「Commit Logging方案」中用于崩溃恢复时重演数据变动的日志,被命名为 Redo Log,一般翻译为 “重做日志” Write-Ahead Logging 在崩溃恢复时,会以此经历以下三个阶段: 1. 分析阶段(Analysis) 开始扫描日志,找出所有没有 End Record 的事务, 组成待恢复的事务集合(一般包括 Transaction Table 和 Dirty Page Table) 2. 重做阶段(Redo) 产生的待恢复的事务集合来重演历史(Repeat History), 找出所有包含 Commit Record 的日志,将它们写入磁盘,写入完成后增加一条 End Record,然后移除出待恢复事务集合 3. 回滚阶段(Undo) 剩下的都是需要回滚的事务(被称为 Loser),根据 Undo Log 中的信息回滚这些事务 注: 重做阶段和回滚阶段的操作都应该设计为幂等的 mysql、sqlite、postgresql、sql server 等数据库都实现了 WAL 机制 mysql 的先写日志和 redis 的后写日志,这两种有什么区别吗:: MySQL(及其他基于日志的 DMBS)写日志的目的,与 Redis 中 AOF 日志并不相同。 虽然两者都是为了崩溃恢复服务的, 但 MySQL 必须保证崩溃恢复的原子性与持久性 就必须保证每一个操作(事务)的成功完成,必须是以在日志中写入了该操作的完整的信息为标志。 而 Redis 中的 AOF 日志是为服务崩溃之后,尽可能重演历史来恢复现场 但并不强求绝对精确地恢复每个已进行的修改操作,即并不会保证持久性 这是因为哪怕将 AOF 设置为 always,也不能保证绝对不出现写完数据之后,写日志之前不会出现崩溃。 但也正因为操作是优先于日志来完成的,才让 Redis 有更高的性能上限。 隔离性(Isolation) ================= 现代数据库都提供了以下三种锁:: 1. 写锁(Write Lock,也叫做排他锁 eXclusive Lock,简写为 X-Lock) 只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。 2. 读锁(Read Lock,也叫做共享锁 Shared Lock,简写为 S-Lock) 多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁, 所以其他事务不能对该数据进行写入,但仍然可以读取。 3. 范围锁(Range Lock) 对于某个范围直接加排他锁,在这个范围内的数据不能被读取,也不能被写入。 实例: SELECT * FROM books WHERE price < 100 FOR UPDATE; 加了范围锁,不仅无法修改该范围内已有数据,也不能在该范围内新增或删除任何数据, 这是一组排他锁的集合无法做到的。 本地事务的四种隔离级别:: 1. 可串行化(Serializable) 串行化访问提供了强度最高的隔离性,ANSI/ISO SQL-92 中定义的最高等级的隔离级别便是可串行化 不考虑性能优化的话,对事务所有读、写的数据全都加上读锁、写锁和范围锁即可 (这种可串行化的实现方案称为 Two-Phase Lock) 并发控制理论(Concurrency Control)决定了隔离程度与并发能力是相互抵触的, 隔离程度越高,并发访问时的吞吐量就越低 2. 可重复读(Repeatable Read) 可重复读的意思就是对事务所涉及到的数据加读锁和写锁,并且一直持续到事务结束,但不再加范围锁。 可重复读比可串行化弱化的地方在于`幻读问题(Phantom Reads)` `幻读问题(Phantom Reads)`是指在事务执行的过程中,两个完全相同的范围查询得到了不同的结果集。 示例: SELECT count(1) FROM books WHERE price < 100; // 事务:T1, 时间顺序:1 INSERT INTO books(name,price) VALUES ('深入理解Java虚拟机',90) // 事务:T2, 时间顺序:2 SELECT count(1) FROM books WHERE price < 100; // 事务:T1, 时间顺序:3 这就是一个事务遭到其他事务影响,隔离性被破坏的表现。 3. 读已提交(Read Committed) 读已提交对事务涉及到的数据加的写锁,一直持续到事务结束,但加的读锁在查询操作完成后就马上会释放。 一个事务只能读到其他事务已经提交的数据,所以叫提交读。 读已提交比可重复读弱化的地方在于`不可重复读问题(Non-Repeatable Reads)` `不可重复读问题(Non-Repeatable Reads)`是指在事务执行过程中,对同一行数据的两次查询得到了不同的结果。 SELECT * FROM books WHERE id = 1; // 事务: T1, 时间顺序: 1 UPDATE books SET price = 110 WHERE ID = 1; COMMIT; // 事务: T2, 时间顺序: 2 SELECT * FROM books WHERE id = 1; COMMIT; // 事务: T1, 时间顺序: 3 4. 读未提交(Read Uncommitted) 读未提交对事务涉及到的数据只加写锁,这会一直持续到事务结束,但完全不加读锁。 读未提交比读已提交弱化的地方在于`脏读问题(Dirty Reads)` `脏读问题(Dirty Reads)`: 是指在事务执行的过程中,一个事务读取到了另一个事务未提交的数据 因为读取的很可能是中间过程的脏数据,而不是最终数据。 示例: SELECT * FROM books WHERE id = 1; // 事务: T2, 时间顺序:1 UPDATE books SET price = 90 WHERE ID = 1; // 事务: T1, 时间顺序:2(注意没有COMMIT) SELECT * FROM books WHERE id = 1; // 事务: T2, 时间顺序:3(模拟购书的操作的逻辑) ROLLBACK; // 事务: T1, 时间顺序:4 5. 完全不隔离 即读、写锁都不加 比读未提交比有`脏写问题(Dirty Write)` `脏写问题(Dirty Write)`即一个事务没提交之前的修改可以被另外一个事务的修改覆盖掉 脏写已经不单纯是隔离性上的问题了,它会导致事务的原子性都无法实现 所以一般隔离级别不会包括它,会把读未提交看作是最低级的隔离级别 .. note:: 不同隔离级别以及幻读、脏读等问题都只是表面现象,它们是各种锁在不同加锁时间上组合应用所产生的结果,锁才是根本的原因。 范围锁可以阻塞其他事物的读锁和写锁。但是为何在串行化的隔离级别下,需要三个锁都加上?只加范围锁不行吗:: X、S、Range 三种锁是基于理论的描述,而不是基于具体某种数据库。 这里的关键区别是具体数据库可以选择自己可能的方式去实现范围锁,以达到进一步的细分功能或提升性能等目的。 以 MySQL/InnoDB 为例: 默认隔离级别是可重复读,RR(读读) 级别也没有幻读问题,原因是: MySQL 有两种范围锁的实现,分别是`间隙锁(Gap Lock)`和`后码锁(Next-Key Lock)` Gap Lock 在 RR 级别的只读事务中出现,只锁定记录之间的空隙而不锁定记录本身 这点使得 RR 级别只读事务也没有幻读问题, 而 Next-key Lock 则同时锁定记录本身和记录间的间隙。 所以,在理论描述写读写锁 + 范围锁全部加上是合适的。 在具体数据库实践中则有宽松点的余地,你可以认为只锁了一个 Next-key Lock 也可以认为是同时锁了 Record Lock + Gap Lock,这两者其实是等价的 MVCC ---- * 多版本并发控制: Multi-Version Concurrency Control,MVCC .. note:: MVCC 是一种读取优化策略,它的 “无锁” 是特指读取时不需要加锁。MVCC 的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。 基础原理:: “版本” 是个关键词。 可理解为数据库中每一行记录都存在两个看不见的字段: CREATE_VERSION 和 DELETE_VERSION: 这两个字段记录的值都是事务 ID(事务 ID 是一个全局严格递增的数值) 1. 数据被插入时: CREATE_VERSION 记录插入数据的事务 ID,DELETE_VERSION 为空 2. 数据被删除时: DELETE_VERSION 记录删除数据的事务 ID,CREATE_VERSION 为空 3. 数据被修改时: 将修改视为 “删除旧数据,插入新数据”,即: 先将原有数据复制一份,原有数据的 DELETE_VERSION 记录修改数据的事务 ID,CREATE_VERSION 为空 复制出来的新数据的 CREATE_VERSION 记录修改数据的事务 ID,DELETE_VERSION 为空 当有另外一个事务要读取这些发生了变化的数据时,会根据隔离级别来决定到底应该读取哪个版本的数据: 1. 可重复读: 总是读取 CREATE_VERSION 小于或等于当前事务 ID 的记录 在这个前提下,如果数据仍有多个版本,则取最新(事务 ID 最大)的 2. 读已提交: 总是取最新的版本即可,即最近被 Commit 的那个版本的数据记录 3. 另外两个隔离级别都没有必要用到 MVCC 读未提交: 直接修改原始数据即可,其他事务查看数据的时候立刻可以查看到,根本无需版本字段 可串行化: 本来的语义就是要阻塞其他事务的读取操作 .. note:: MVCC 是只针对 “读 + 写” 场景的优化,如果是两个事务同时修改数据,即 “写 + 写” 的情况,那就没有多少优化的空间了,加锁几乎是唯一可行的解决方案。“写 + 写”问题优化可参考“乐观加锁”(Optimistic Locking)或 “悲观加锁”(Pessimistic Locking)