1. 事务简介

事务的核心是锁与并发

事务单元之间由于访问对象的互斥性,会造成等待。事务单元之间的关系就4种:即读写、写读、读读、写写

处理多个事务单元的方法:

  1. 排队:优点是不需要冲突控制,缺点是一个事务慢了,所有的事务都慢了
  2. 并行处理:不冲突的事务单元可以并行执行。共享数据有冲突可以采用排他锁
  3. 读并行:在2的基础上,对读的操作都可以并行。采用读写锁。读锁就是共享锁,读操作可以并行化。
  4. 读写并行:读的时候不加读锁,写可以直接写,取消读。代价是读可能存在不一致。

为了完成第四点,写不阻塞读,就采用了新的 读写并发策略,即现在主流数据库实现的方式:
MVCC——多版本并发控制:每一次写是在新的位置写,在写得时候仍然可以并发读。只考虑写写的冲突,不考虑包含读的读写。这样可以支持大的并发事务,可以支持大量的并发读。代价是系统实现的复杂度大大增加。MVCC要考虑日志何时删除的问题。

2. 事务处理常见问题

2.1 多个事务谁先谁后

MVCC中就是根据日志来获取相应版本号的数据。
可以采用逻辑时间戳(自增号),每次读写更新这个ID号来保证事务单元的先后顺序。只是用于保证先后顺序。
SCN(Oracle)
Trx_id(Innodb)

2.2 故障恢复

由于业务属性不匹配或者系统崩溃需要进行故障恢复。主要依靠回滚,在回滚的时候不能对事务进行操作。

2.3 死锁检测

处理方法:

  1. 碰撞检测:申请锁的时候查看是否可能发生碰撞,若发生碰撞则结束一方即可,效率比较高,主流数据库都以这种方法为主 2.等锁超时:长事务采用锁超时则不太好

3.单机事务

3.1 单机事务

从ACID开始分析

  • 原子性,可以利用UNDO日志来回滚,记录逆向操作。

  • 一致性: happen before,视点关系。通过对使用对象锁定使得其他事务单元不干扰现有事务。即一个事务单元只有在全部完成后才可见。缺点是并发度上不去(如果访问互斥资源,事务按顺序,排队),因此引入了隔离性
  • 隔离性:为了提高性能,对强一致性的破坏。

SQL92标准不同隔离级别如下(本质上是不同读写锁的应用):

  1. 序列化(顺序的):只用排他锁,大家只能一个个按顺序来
  2. 可重复读(REPEATABLE READ):读读可以并行,但是读锁不能被写锁升级,即不支持读写并行
  3. 读已提交(RC):原来的读锁可以升级为写锁。支持读读并行(可重复读)和读写并行(不可重复读)。相比2的情况会出现不可重复读的情况。注意不支持写读并行(先写得再读是不可以并行度的)
  4. 读未提交(READ UNCOMMITTED):此时只加写锁,读不加锁。读读并行,读写并行和写读并行(会读到写过程中的数据)。最后即所有写串行,读并行。读到中间状态是不太好的,一般不采用这种隔离级别。

其他隔离性:
快照隔离性(MVCC):读事务直接从回滚段里面读取相应的视点,即ver2。可以保证读到一致性的数据,同时具备读未提交隔离性的优点,即支持除了写写以外的其他所有并行。快照隔离性使得支持不用锁而支持并行。快照隔离性关键是快照读,即通过读UNDO日志的记录来完成。快照隔离性就是通过使用MVCC(多版本并发控制)来实现的。 MVCC建议使用在读多的场景,如果写多需要写大量的UNDO日志增加系统延迟。

不同版本保存在UNDO日志段,对UNDO日志中的版本控制也要注意并发的控制。也可以采取乐观的方式或者悲观的方式。

由于SQL92标准没有考虑完善,所以一般快照隔离性性会归类到读已提交或者读未提交中。

  • 持久性:事务提交后,物化在持久化存储中。持久性和延迟之间要权衡一个。数据不丢可以采用RAID。RAID控制器可以保证写多个磁盘时同时成功或是失败。

有时候TPS高,认为写入到内存就认为事务提交成功(不太负责任)。解决办法:采用组提交(group commit),比如一个COMMIT来了先不提交,例如等到5个提交,再统一物化到磁盘上。一般根据实际业务场景有个经验值。

3.2 单机事务的典型异常应对策略

1.业务属性不匹配:通过原子性、一致性和回滚来解决
2.系统DOWN机:进入RECOVERY模式,本身RECOVERY模式也是原子性的,而且RECOVERY的时候也会记录日志。进行到一半的事务都要进行回滚。完成RECOVERY才能进行其他事务处理。

3.3 事务的调优原则

  1. 在不影响业务应用的前提下减少锁的覆盖范围(采用更加细粒度的锁)来提升并行度::例如从Myisam表锁到Innodb行锁;原位锁到MVCC,将一个大锁拆成多个版本的小锁;
  2. 增加锁上可以并行的线程数:例如读锁和写锁的分离来允许并行读取数据;
  3. 选择正确锁类型:悲观锁和乐观锁。

个人理解:乐观锁严格意义上不能算是锁,是MVCC的一种实现方法吧,就是读写数据都不上锁,遇到读写冲突就回滚。不过平凡回滚效率肯定低,所以争用多还是要用悲观锁,在操作数据之前要拿到对应的锁。

知乎上fleuria的回答不错可以看看:

MVCC 可以保证不阻塞地读到一致的数据。但是,MVCC 并没有对实现细节做约束,为此不同的数据库的语义有所不同,比如:
postgres 是严格地无锁,对写操作也是乐观并发控制;在表中保存同一行数据记录的多个不同版本,每次写操作,都是创建,而回避更新;在事务提交时,按版本号检查当前事务提交的数据是否存在写冲突,则抛异常告知用户,回滚事务;
innodb 则只对读无锁,写操作仍是上锁的悲观并发控制,这也意味着,innodb 中只能见到因死锁和不变性约束而回滚,而见不到因为写冲突而回滚;不像 postgres 那样对数据修改在表中创建新纪录,而是每行数据只在表中保留一份,在更新数据时上行锁,同时将旧版数据写入 undo log;表和 undo log 中行数据都记录着事务ID,在检索时,只读取来自当前已提交的事务的行数据;
可见 MVCC 中的写操作仍可以按悲观并发控制实现,而 CAS 的写操作只能是乐观并发控制。

还有一个不同在于,MVCC 在语境中倾向于 “对多行数据打快照造平行宇宙”,然而 CAS 一般只是保护单行数据而已。比如 mongodb 有 CAS 的支持,但不能说这是 MVCC。

作者:fleuria
链接:https://www.zhihu.com/question/27876575/answer/62496641
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

3.4 补充知识

3.4.1 2PL

2PC(two phase commit)
2PL(two phase lock): 在哪个转账例子中,就是在修改是时候先锁定数据,完成后再解锁数据。解锁过程中是无法进入这个事务单元内部的。外部看不到中间结果。

3.4.2 死锁的扩展U锁

死锁回忆:
例: update set a=a-1 where id =100;
该SQL有2步,先获取id=100的a,上读锁,然后修改就要使用写锁。例如2个人都对该行数据一起上读锁,再一起要求获取写锁,就发生了互相等待的死锁。这时候我们就需要引入update锁,简称U锁。

U锁(更新锁):数据库提前做判断,如果事务内存在写操作,就不先获取读锁再获取写锁,而是直接获取写锁,这样就不会出现等待别人释放读锁的问题了。

3.4.3 MVCC补充

在多版本并发控制中,使用写写并行时会存在一个写覆盖的问题。例如下图中,BOB和SMITH互相转账100。微观上进行写版本操作的时候是有先后的,假设都是BOB先。那么BOB在SMITH账户相关的版本写入上,由于无法感知SMITH账户上的版本情况,因此直接写入SMITH账户多100元的版本覆盖了原本SMITH账户减100元的版本,从而导致SMITH账户没扣钱却多了100.这在BOB账户上也同样成立。

解决方法:
乐观锁并发方案:让版本低的并发更新回滚。有点事并发低性能好,缺点是并发高的时候失败率高,需要不断地重试。

查看下图,就可以发现,与之前相比之所以能解决这个问题是因为采用了版本号的判断,全局上按照事务到的先后顺序采用唯一的版本号(1个事务对应1个版本号)。因此在此处,事务2开始操作的时候直接写的是版本3。注意点:一个事务操作对应一个版本,1个版本号在BOB账户和SMITH账户下各有一个。
基本过程是:

  1. 产生事务1的版本2,操作为给BOB减100(该版本2对应BOB账户)
  2. 产生事务2的版本3(因为比事务1晚到),操作是给SMITH减100(该版本3对应SMITH账户)
  3. 事务1继续操作,想在SMITH账户上写版本2的让SMITH加100元的操作,但是由于版本号比SMITH账户小,因此就回滚所有操作。之后再重试。
  4. 事务2继续操作,给BOB账户下写版本3,即让BOB账户加100. 事务2在自己扣100的时候变成了版本3(因为这个是顺序发生在事务1的版本2之后的,没有同时写),而不是原来的版本2,

一句话概括:所谓的写写并行,也就是在事务级别让写写并行,通过版本号控制让多个并发写事务在事务单元内部进行顺序操作,从而避免数据覆盖带来的问题。