数据库事务、隔离级别和锁

摘抄并用于自查笔记

 

ACID

  Atomicity,原子性。指一组对数据库的改变,要么最终成功执行完成,要不就全部回滚。这就要求数据库系统要实现某种回滚机制,比如redo/undo log。与事务性数据库相比,一些NoSQL数据库也声称支持原子性,但是意义不同,比如Redis事务的原子性的意思更接近于“一组指令被执行时,不受其他指令的干扰”,而不是“可以回滚”。

  Consistency,一致性。指在事务完成的前后,数据都是要在业务意义上是“正确的”。但是,保证业务是否正确是要业务代码来最终保证的,数据库能做的非常有限。目前数据库里实现的约束检查,比如唯一约束、外键约束、一些enum检查、一些数据类型/长度/有效数字的检查等等,对于简单的场景还可以使用。对于复杂的业务约束检查,很难或者不可能实现。真是复杂业务的数据正确性维护一般用 正确的业务代码 + 合法性校验 + 数据库自身的简单合法性防护一起实现。

  Isolation,隔离性。指一组对数据库的并发修改互相不影响。事实上,如果是并发修改的是相关联的,或者就是同一份数据,就必然会相互影响。那么此时可以做的是区分那个优先级更高,高优先级的修改应该覆盖低优先级的修改。实际并不好区分先后。另一种情况是“先读取,再基于读取结果数据进行修改”,比如,先找到可用的库存,先读取,再往上+1。这时,保证隔离性的主要问题并不在于隔离本身,而在于如果将读取作为对数据修改的前提条件,之后在对数据进行修改的一刹那,读取时的前提条件还是否满足 。毕竟读取和写入是两个分开的指令,而在这两个指令中间可能夹杂其他事务对数据的修改。保持隔离性的一个简单做法是保证对关联数据的修改串行化,对应事务性数据库的“Serializable”隔离级别。保证串行化的一种方案是锁,通过锁定可以彻底避免竞争条件。但是大家都能明白加锁对数据库并发性能负面影响很大,所以就衍生出几种弱的隔离性保证——READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ。此外MVCC能够解决一部分锁带来的问题。

  Duration,持久性。指对数据的修改,一旦完成,该结果就应当永远不丢失。

  

  所以,综上,事务性数据库实现的是

  1)支持未完成的数据修改回滚的机制,对应“原子性”

  2)力所能及的数据合法性检查,对应“一致性”

  3)保证数据并发的修改的规则,对应“隔离性”

  4)使用基于持久化存储(磁盘、SSD)的方式对数据进行存储,对应“持久性”

 

隔离级别和并发控制

  SQL92标准定义了四种隔离级别——Read Uncommitted、Read Committed、Repeatable Read和Serializable。定义这4种隔离级别时,制定者主要围绕着基于锁的并发控制来说的。但是后来出现了MVCC,之后主流数据库都开始支持MVCC。

 

最不严格的隔离级别

  Dirty Read,脏读。即一个事务没提交之前的修改可以被另个事务看到。

  Dirty Write,脏写。即一个事务没提交之前的修改可以被另一个事务的修改覆盖掉。

 

  脏读有时可以被接受,但是脏读不被允许。

  如何避免脏写呢 ..... 使用锁。实际上,一般数据库都会使用排他锁来标记要修改的数据(update,delete,select ... for update)。锁的存在可以保证——写要block写。这个规则永远生效。

  在Mysql InnoDB中,这种锁被称为“X锁”。它的特性是,只要有一个事务获取了一条数据的X锁,其他事务如果也想获取这个锁,就必须等待,直到上个事务提交/回滚后释放锁,或者等到超时自动回滚。事务性数据库对于写操作永远需要锁来避免脏写,即使是基于MVCC的数据库,所谓某个隔离级别使用MVCC不需要锁,仅仅是指在读取的时候是否需要锁。

  所以,最不严格的隔离级别的隔离是 允许脏读,但不允许脏写。这种隔离级别被称为 Read Uncommitted。这种隔离级别一般不建议使用。其虽然可以带来性能上的优势,但因为非常容易造成数据由于并发操作带来的问题,所以可以用在不太在意数据正确性的场合。

  

Read Committed 和 Repeatable Read

  这俩的基本原则一样:读不block读和写,写不block读。只不过是发生了并行读写的隔离效果不太一样。此外,他们两个对OLTP(传统的关系型数据库的主要应用)业务代码的编写的影响差不多——他们都无法解决“写前提困境”。

  Read Committed是指一个事务能看到另个事务对一条数据记录已经提交的修改。

  Repeatable Read是指一个事务一旦开始,反复读取一条数据记录,都会得到相同的结果。其直观感觉仿佛是给事务做了一个整个数据库的快照,所以很多时候这种隔离级别又被称为Snapshot Isolation。“快照”的功能在一些场景下非常重要,如:

  1)数据备份。例如数据库S从数据库M中复制数据,但是同时M数据库又被持续修改。S需要拿到一个M的数据快照,但是又不能真的把M停了。

  2)数据合法性检查。例如有两张数据表,一张记录了当时的交易总额,另一张表记录了每个交易的金额,那么在读取数据时,如果没有快照的存在,交易金额的总和可能与当时的交易总额对不上,因为随着检查事务的进行,新的交易记录数据会被提交。这些新的提交会被检查事务看到。

  

  在基于MVCC的数据库中,一般认为只实现了Read Committed和Repeatable Read两个隔离级别。

  此外,值得一提的是幻读的问题。在SQL92标准中提到了Repeatable Read中是可以出现幻读的——即一个事务尽管不能读取到后续其他事务对现有数据的修改,但是能读取插入的新数据。但是,基于MVCC的实现,Repeatable Read 可以完全避免幻读。无论Mysql还是PostgreSQL在Repeatable Read隔离级别都不会出现幻读。

 

 

MVCC

  MVCC是“Multi-Version Concurrency Control”的缩写,即,对数据库的任何修改的提交都不会直接覆盖之前的数据,而是产生一个新的版本与老版本共存,使得读取时完全不加锁。这个版本一般用进行数据操作的事务ID(单调递增)来定义。MVCC大致可以这么实现:

 

  每个数据记录携带两个额外的数据 created_by_txn_id 和 deleted_by_txn_id 。

  当一个数据被 insert 时,created_by_txn_id 记录下插入该数据的事务ID,deleted_by_txn_id 留空。

  当一个数据被 delete 时,该数据的 delete_by_txn_id 记录执行该删除的事务。

  当一个数据被 update 时,原有数据的 deleted_by_txn_id 记录执行该更新的事务ID,并且新增一条新的数据记录,其 created_by_txn_id 记录下更新该数据的事务ID。

  

  在另个事务进行读取时,由隔离级别来控制到底取哪个版本。同时,在读取过程中,完全不用加锁(除非用 SELECT ... FOR UPDATE强行加锁)。这样可以极大降低数据读取时因为冲突被Block的机会。

 

  那么那些多出来的无用数据怎么处理呢?支持MVCC的数据库一般会有一个背景任务来定时清理那些肯定没用的数据。只要一个数据记录的deleted_by_txn_id不为空,并且比当前还没结束的事务ID中最小的一个还要小,该数据记录就可以被清理掉。在MySQL InnoDB中,叫做 “purge”。

  MySQL采用Undo Log的实现。这种实现下,用于存储数据表的B+树节点总是保留最新的数据,而老版本的数据被放在 Undo Log 里,并且以指针的形式关联起来,形成一个链表。这样,在查找老的版本时,需要按链表顺序查找,直到找到 created_by_txn_id <= 当前事务ID的最新那条记录即可。这种实现,在查询时会在B+树查找后多引入一个链表查询,但是清理废弃数据时很简单,只要把Undo Log找到一个合适位置,一刀切了即可。

 

  有了MVCC,Read Committed 和 Repeatable Read 就实现的的很直观了:

  对于 Read Committed,每次读取时,总是取最新的,被提交的那个版本的数据记录。

  对于 Repeatable Read,每次读取时,总是取 created_by_txn_id 小于等于当前事务ID的那些数据记录。这个范围内,如果某一数据多个版本都存在,则取最新的。

 

  Intresting!!! 隔离级别可以是一个Session级别的配置。即每一个Session可以在运行时选择自己希望使用什么隔离级别,也可以随时修改(只要当前没有尚未结束的事务)。每个Session的隔离级别和其他Session是什么隔离级别完全无关。Session只要根据自己的隔离级别,选择用MVCC提供的合适版本即可。

 

  MySQL InnoDB、PostgreSQL、Oracle(从版本4开始)、MS SQL Server(从版本2005开始)都实现了MVCC。注意,MySQL InnoDB尽管一开始就实现了MVCC,但是之前很多人还在使用MyIsam存储引擎,而MyIsam根本不支持事务,更不用说MVCC。直到MySQL5.5.5,InnoDB才成为MySQL默认的存储引擎。因此使用MySQL的隔离级别,先要看MySQL的版本和存储引擎。

 

写前提困境    

  尽管在MVCC的支持下 Read Committed 和 Repeatable Read 都可以得到很好的实现。但是对于某些业务代码来说,在当前事务中看到/看不到其他的事务已经提交的修改,意义不大。这种业务代码一般是这样的:

  1. 先读取一段现有数据

  2. 在这个数据基础上做逻辑判断或者计算

  3. 将计算的结果写会数据库

 

  这样第三步的写入就会依赖第一步的读取。但是在1和3 之间,不管业务代码离的有多近,都无法避免其他事务的并发修改。即,步骤1 的数据正确是步骤3能够在业务上正确的前提。

  在 Repeatable Read 下是解决不了这个问题的,因为在步骤3时,当前事务根本无法看到另外一个事务对数据的修改。这个问题被称为 Lost Updates。

  而 Read Committed 尽管能够看到其他事务已经提交的修改,问题在于,Read Committed,你必须重复写一句 select 语句才能拿到,而不管你反复读取多少次,不管这个  select 离的下面的 update 多近,理论上都无法避免丢失其他事务的修改。

 

  这个问题就是,在修改的事务提交时,无法确保这个修改的前提是否还可靠,这种问题称写前提困境。

  

  解决这类问题有3种办法:

  数据库支持某种代码块,这个代码块的执行是排他的;

  加悲观锁,把期望依赖的数据独占,在修改完成前不允许其他并发修改的发生;

  加乐观锁,在事务提交的一刹那(commit 时),检查修改的依赖是不是没有被修改。

  在事务性数据库中,第一种被称为 Actual Serial Execution,第二种是加锁(手工或者自动);第三种被称为 Serializable Snapshot Isolation,SSI。

 

  1. Actual Serial Execution

  Actual Serial Execution 是一种执行的效果,即一段代码在数据库服务端执行时不会受到其他并发控制的干扰。但要达成这个效果并不简单。

  最简单的方案是整个数据库都只单线程的跑,这样什么并发隔离保护机制都可以不要,所有的数据不会有任何并发修改的问题。一些NoSQL的存储,如Redis都是这样实现的。但是他们这么实现是有原因的,因为他们都是基于内存的存储,其数据操作的延迟相对于网络IO几乎可以忽略不计,所以即使是单线程,配合nonblocking IO,他们的并发性能也可以非常高。但是这个假设对事务性数据库并不成立,因为事务性数据库要操作磁盘/SSD。即便是SSD的写入速度,也会在数量级上低于内存。所以事务性数据库如果强行改成单线程,就会极大损害并发性能。

  此外,单线程存储因为只能使用单线程,所以一个实例只能使用一个CPU核心,在多核的机器上就会浪费资源。所以往往要单机启用多个实例。而一旦启用多个实例就意味着要提前对数据进行 Partition ,分配给多个实例。但是 Partition 会造成单 Partition 查询方便,跨越多个 Partition 的查询麻烦的问题。——比较有局限性,比较适合为特定业务做定制存储。

  另一种办法是用存储过程将业务逻辑包起来丢给数据库执行。但是这样做其实也不现实,因为存储过程本身并不具备原子性和隔离性。为了让存储过程中的执行是排他的,依然需要在存储过程中声明一个事务。如果必要,可以声明当前的事务隔离级别为Serializable以避免写前提困境。——这种做法其实等价于Serializable 隔离级别。

  还有一种是用单SQL语句的事务。比如:update tab1 set counter = counter+1 where id = xxx;   这样写的能保证排他性执行,因为这条语句自身可以成为一个事务,并且因为是UPDATE语句,所以必然会抢占X锁。锁的存在可以确保不会出现写前提困境,但是这样做的前提是有办法把一个业务逻辑用一句修改类SQL表达。一个计数器的逻辑可以,但是复杂一些的业务就不行,或者在语法上可行,但是写的多了调试和维护难。

 

  2. 加锁和基于锁的Serializable

  通过加锁可以有效的排除所有可能的竞争的问题。在MySQL InnoDB中,Serializable 隔离级别是依靠MVCC + 锁。

  简单来说,就是所有的读取都要加上共享锁。

  

  数据库中经典的加锁过程被称为两阶段加锁:

  加锁阶段:在事务过程中,根据不同的SQL指令加锁。

  释放锁阶段:锁定直到这个事务被提交或者回滚(包括等待超时造成回滚)时释放。

 

  基于锁的 Serializable 的实现准则是:读要block写,写也要block读,读不block读

 

  实际上MySQL的Serializable除了锁记录,还会锁记录的间隙,避免意外的插入。这种锁概念上被称为区间锁(Range Lock)。MySQL InnoDB中的叫法是 Gap Lock 和 Next-key Lock。

  上文中有提到基于 MVCC 的 Repeatable Read 可以避免幻读。在基于锁的 Serializable 中做的更强硬,它会直接锁定以避免插入。

 

  在MySQL中,不同的隔离级别内部实现使用不同的 MVCC 读取策略 + 不同种类的锁来完成。

  隔离级别可以自定义:

  SELECT ... LOCK IN SHARE MODE ---- 尝试将查询符合条件的记录加上共享锁,如果锁已经被占了就等待

  SELECT ... FOR UPDATE ---- 尝试将符合查询条件的记录加上与等价 UPDATE 语句一样的锁,包括排他锁和区间锁。

 

  这些语句可以无视当前的隔离级别,完全按照你的心意来加锁。

  在MySQL中 SELECT ... FOR UPDATE 会打破当前的 Repeatable Read 隔离级别,拿到另外一个事务提交的最新的数据。

  基于锁的 Serializable 隔离级别,或者手工加锁,是可以根除任何并发冲突的,但是这是有代价的——大大的增加了锁的数量,同时也就增加了等待锁的时间及死锁的机会。

 

SSI和基于SSI实现的Serializable

  相对于悲观锁的方案,相对应的乐观锁的方式就是SSI——Serialized Snapshot Isolation。他的大致意思是:本质上,整个事务还是 Snapshot Isolation,但事务在进行过程中,除了对数据进行操作外,还要对整个事务的“写前提”——所有修改操作的依赖数据做追踪。当事务被commit时,当前事务会检查这个“写前提”是否被其他事务修改过,如果是,则回滚掉当前事务。

 

  那么,怎么侦测到这个修改已经发生了?

  1)在一个事务进行提交时,对于所有修改的数据,查看MVCC中是否已经有其他的版本已经提交了但是本事务因为 snapshot 机制没有读取到。

  2)事务进行时,标记自身所有读取过的记录(就好像是加共享锁,但是并不真的锁定什么)。另个事务如果提交了一个写操作,则反查这个 写操作影响到的数据有哪些被读取中,并且读取他们的事务还没有提交。

 

实际应用中,为特定业务场景做优化

  有全局数据需要增减。例如库存数量/垫资额度要扣减。此时应该选择Serializable隔离级别或者手工 SELECT ... FOR UPDATE 加锁。但是要特别留意,因为这样做会增加死锁等待/并发修改造成失误失败的问题发生的几率,所以尽量保证事务的粒度尽可能的小。避免一个巨大的事务长时间的执行。

 

  需要读取大量数据。例如业务清算时需要读取一段时间所有的交易记录和资金流水。这种场景不属于OLTP,应该选择 Repeatable Read 隔离级别得到一个“快照”,并标记事务为只读 SET TRANSACTION READ ONLY。这样会让数据库对事务的执行做优化,尽量避免冲突的发生。

 

  海量数据插入到OLTP数据库。比如交易系统把每天用户的资产和收益计算后更新到OLTP数据库让用户访问。此时应该事先一个“业务事务”的概念。即不要依靠数据库的业务,而是依靠一个标记。当一个用户的数据正在更新时,应该避免用户看到部分被更新的数据。只有当数据全部更新完了,最后更新一下标记,让数据对用户可见。同时,数据的更新应该拆解一个个小的事务,避免一个巨大的事务一次性完成更新。

 

  简单的数据读取-更新场景。比如计数器。可以用单行UPDATE SQL 的方式实现。

 

  避免纠结于 Repeatable Read 和 Read Committed 的区别。这两个隔离级别都无法解决“写前提困境”。纠结无用。

 

  对并发冲突或者死锁尝试进行重试。

  在基于锁的实现中,可能会出现锁等待超时回滚;而在基于SSI的实现中,事务提交时可能会检测到并发修改,进而强制回滚事务。无论哪一种,都需要重试。需要编写代码来处理这种重试,并且需要根据业务需求确定重试的驱动者是谁——到底是后端代码,还是前端代码还是用户需要。。

 

  对于MySQL考虑乐观锁

  因为MySQL的隔离级别不支持SSI,所以可以考虑手工实现乐观锁。即自己在数据表里面增加一个version列,并且在更新数据时总是将修改之前的version房子UPDATE语句的where条件里。

  乐观锁的实现是有前提的,即修改的数据和修改前提是同一份数据。如果这个前提不满足就不法实现。

 

注意监控数据库事务的执行情况

  一般监控都能做到监控数据库的CPU、磁盘、IO等资源的占用情况。除此之外,应当注意对事务的执行时间和数量做监控。数据库一般并不限制事务的执行时间(但是会限制事务等待一个锁的时间)。一个执行数小时甚至数天的事务极大概率是有问题的,会带来死锁增加,MVCC垃圾得不到清理等问题。

 

posted @ 2019-08-20 15:24  停不下的时光  阅读(1405)  评论(0编辑  收藏  举报