本地事务
事务特性:ACID,其中C一致性是目的,AID是手段。
实现隔离性
写锁:数据加了写锁,其他事务不能写也不能读。
读锁:数据加了读锁,其他事务不能加写锁可以加读锁,可以允许自己升级为写锁。
范围锁:对某个范围加写锁,范围内数据不能写入。
隔离级别
以锁为手段来实现隔离性才是数据库表现出不同隔离级别的根本原因。
可串行化:对事务所有读、写数据加上三种锁。
可重复读:不加范围锁,会有幻读问题。
幻读是指在事务执行过程中,两个完全相同的范围查询得到了不同的结果集。譬如现在准备统计一下 Fenix"s Bookstore 中售价小于 100 元的书有多少本,会执行以下第一条 SQL 语句:
SELECT count(1) FROM books WHERE price < 100
INSERT INTO books(name,price) VALUES ("深入理解Java虚拟机",90)
SELECT count(1) FROM books WHERE price < 100
两次执行之间有另外一个事务在数据库插入了一本小于 100 元的书籍,那这两次相同的查询就会得到不
一样的结果,原因是可重复读没有范围锁来禁止在该范围内插入新的数据,这是一个事务受到其他事务影响,隔离性被破坏的表现。
读已提交:写锁会一直持续到事务结束,读锁在查询操作完成后马上释放。有不可重复读问题,读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化。
读已提交:有脏读问题。
MVCC
MVCC是并发访问控制技术,解决了读写冲突问题(幻读)。
基本思路是对数据库的修改不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存。
“版本”可以理解为每一行记录存在俩看不见的字段:CREATE_VERRSION和DELETE_VERSION,这两个字段都是事务ID,事务ID是全局递增的值,根据以下规则写入数据。
- 插入数据:CREATE_VERRSION记录插入数据的事务ID,DELETE_VERSION为空。
- 删除数据:DELETE_VERSION记录删除数据的事务ID,CREATE_VERRSION为空。
- 修改数据:将修改数据视为“删除旧数据,插入新数据”,将原有数据复制一份,原有数据DELETE_VERSION记录修改数据的事务ID。复制出来的新数据CREATE_VERSION记录修改数据的事务ID。
此时,有另一个事务读取这些发生了变化的数据,根据隔离级别决定读取哪个版本的数据。
- 可重复读:在“总是读取CREATE_VERSION小于等于当前事务ID的数据”前提下,如果数据有多个版本,读取事务ID最大的。
- 读已提交:总是读取最新版本,即最近被Commit版本的数据。
MVCC是针对“读+写”的优化,“写+写”只能加锁解决。竞争激烈的情况下,乐观锁可能更慢。
MVCC超售问题
数据库采用的是MVCC方案,是否有可能出现以下这种超售情况
初始quantity值为10,事务T1和事务T2都想要将quantity减8
SELECT quantity FROM books WHERE id=1
SELECT quantity FROM books WHERE id=1
UPDATE books SET quantity=2 WHERE id=1
commit
UPDATE books SET quantity=2 WHERE id=1
commit
这种写法会出现超售,相当于卖了两次8本书。
之前提到过,MVCC只解决“读-写”事务的情况(也就是解决可重复读级别下的幻读),在“写-写”的场景中它是不适用的。
也正是为了解决这类情况,InnoDB之类采用MVCC的引擎,都会提供诸如“lock in share mode”的语法,让开发者在“写-写”的场景中显式加共享锁,让数据库进行当前读而非快照读。
以MySQL为例,把代码修改为这样,它就可以保证T1的Update语句被T2的共享锁阻塞了,达到避免超售的目的了。
SELECT quantity FROM books WHERE id=1 lock in share mode;
SELECT quantity FROM books WHERE id=1 lock in share mode;
全局事务
2PC
假如你平时以声明式事务来编码,那它与本地事务看起来可能没什么区别,都是标个@Transactional注解而已,但如果以编程式事务来实现的话,就能在写法上看出差异,伪代码如下所示:
public void buyBook(PaymentBill bill) {
userTransaction.begin();
warehouseTransaction.begin();
businessTransaction.begin();
try {
userAccountService.pay(bill.getMoney());
warehouseService.deliver(bill.getItems());
businessAccountService.receipt(bill.getMoney());
userTransaction.commit();
warehouseTransaction.commit();
businessTransaction.commit();
} catch(Exception e) {
userTransaction.rollback();
warehouseTransaction.rollback();
businessTransaction.rollback();
}
}
从代码上可看出,程序的目的是要做三次事务提交,但实际上代码并不能这样写,试想一下,如果在
businessTransaction.commit()中出现错误,代码转到catch块中执行,此时userTransaction和
warehouseTransaction已经完成提交,再去调用rollback()方法已经无济于事,这将导致一部分数据被提
交,另一部分被回滚,整个事务的一致性也就无法保证了。
为了解决这个问题,XA 将事务提交拆分成为两阶段过程:
- 准备阶段:又作投票阶段,协调者询问事务的所有参与者是否准备好提交,参与者如果准备好提交则回复Prepared。对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,不释放隔离性,继续持有锁。
- 提交阶段:又作执行阶段,协调者在上一阶段收到Prepared消息,先自己在本地持久化事务状态为Commit,操作完成之后向所有参与者发送Commit指令;否则,任意一个参与者回复了 Non-Prepared 消息,或任意一个参与者超时未回复,协调者将将自己的事务状态持久化为 Abort 之后,向所有参与者发送 Abort 指令,参与者立即执行回滚操作。
缺点
-
单点问题:协调者在两段提交中具有举足轻重的作用,协调者等待参与者回复时可以有超时机制,允许参与者宕机,但参与者等待协调者指令时无法做超时处理。一旦宕机的不是其中某个参与者,而是协调者的话,所有参与者都会受到影响。如果协调者一直没有恢复,没有正常发送 Commit 或者 Rollback 的指令,那所有参与者都必须一直等待。
-
性能问题:两段提交过程中,所有参与者相当于被绑定成为一个统一调度的整体,期间要经过两次远程服务调用,三次数据持久化(准备阶段写重做日志,协调者做状态持久化,提交阶段在日志写入 Commit Record),整个过程将持续到参与者集群中最慢的那一个处理操作结束为止,这决定了两段式提交的性能通常都较差。
-
一致性风险
3PC
三段式提交把原本的两段式提交的准备阶段细分为两个阶段,分别称为CanCommit、PreCommit,提交阶段改为
DoCommit阶段。CanCommit是询问阶段,协调者让每个参与者根据自身状态评估事务是否可能完成。
将准备阶段一分为二的理由是:协调者发出开始准备的消息,参与者开始写重做日志,如果此时,某一个参与者宣布无法完成,相当于大家做了一轮无用功。
因此,在事务需要回滚的场景中,三段式的性能通常是要比两段式好很多的,但在事务能够正常提交的场景中,两者的性能都依然很差,甚至三段式因为多了一次询问,还要稍微更差一些。
同样也是由于事务失败回滚概率变小的原因,在三段式提交中,如果在 PreCommit 阶段之后发生了协调者宕机,即参与者没有能等到 DoCommit 的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待,这就相当于避免了协调者单点问题的风险。
分布式事务
CAP与ACID
柔性事务与最终一致性。
可靠事件队列
最大努力一次提交:将最有可能出错的业务以本地事务的方式完成后,采用不断重试的方式促使分布式事务中的其他关联业务全部完成。
TCC事务
如果业务需要隔离,该方案天生适合用于需要强隔离性的分布式事务中。
TCC 较为烦琐,它是一种业务侵入式较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认/释放消费资源”两个子过程。如同 TCC 的名字所示,它分为以下三个阶段。
- Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。
- Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。
- Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行,也需要满足幂等性。
Reference
《凤凰架构》