分布式相关的内容非常多, 本次分享主要是从分布式事务切入的, 所以主要讲的是分布式的环境下, 怎么做事务; 要讲分布式事务, 我觉得还是有必要提一下单机事务的, 单机事务的很多特性其实在分布式事务中也需要考虑到; 那么今天在这里, 我们主要从两方面来解读下单机事务:
- 是什么? : 事务是什么?
- 怎么做?: 事务是怎么实现的?
1. 事务是什么?
我们的系统大体能分为两类:
a. 数据密集型;
b. 计算密集型;
我们日常工作中更多接触到的是数据密集型, 也就无可避免的接触到数据持久话的问题了, 现在来一起看下几个场景:
- 在持久化时, 程序崩溃了;
- 在持久化时, 服务器崩溃了;
- 在持久化时, 程序与DB的链接断开了;
- 持久化使用了多线程, 线程A把线程B的持久化数据覆盖了, 甚至最终持久化的数据有1/3是线程A的, 2/3是线程B的, 形成了数据混淆;
- 在数据读取时, 把持久化一半的记录读取出来了;
- etc;
正因为我们的程序运行的环境是不稳定的, 所以上面的种种场景都在日常生产中可能遇到的;
而正因为系统的不稳定了, 造成了原本预期结果是true | false, 结果返回的是未知状态, 因为程序奔溃了, 我也不知道持久化到哪部分数据了; 也就是形成了三态问题;
那业务系统为了数据的可靠性, 能不能处理这个三态问题呢? 答案是能的, 但是这个成本是很高的, 需要在业务代码中嵌入许多非业务相关的代码来保证数据的完整性, 同时也需要非常多的测试来验证这个方案的可行性;
那么有没有更好的解决方案呢?
业务系统原本的预期就是true | false, 那么只需要数据库层面把中间态解决掉就可以了, 这也就引出了事务是什么以及做了什么:
事务把业务系统的同一批操作抽象为一个逻辑单元, 该逻辑单元的所有操作要么都成功(commit), 否则就是全部失败(abort), 然后回滚(rollback), 不会出现中间态;
这样做的好处就是对业务系统的反馈只会是成功或者失败;
2. 事务是怎么实现的?
说到事务就不得不提到他的四个特性了:
2.1 ACID
单机事务的特性:(详见维基百科)
- Atomicity(原子性):一个事务(transaction)中的所有操作,或者全部完成,或者全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。即,事务不可分割、不可约简。
- Consistency(一致性):在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设约束、触发器、级联回滚等。
- Isolation(隔离性):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括未提交读(Read uncommitted)、提交读(read committed)、可重复读(repeatable read)和串行化(Serializable)。
- Durability(持久性):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
2.1 原子性解读
看到原子性第一反应其实多线程中的原子性: 保证一个操作是原子的, 该操作的中间态是对其他线程不可见的; 但是事务的原子性不是用来解决并发的, 并发性是由隔离性来保证;
原子性解决的是中间的态的问题, 事务中的一批写操作在执行到某一个写时, 因为网络或者其他原因, 写失败了, 那么该事务就应该被中止, 并且数据库必须撤销该事务迄今为止所有的写入;
所以原子性更多的是约束了事务可以中止的, 并且中止的时候会撤销所有该事务未提交的写入操作;
2.2 一致性解读
一致性必须要说一下, 一致性这个词在不同的语境中, 被赋予了不同含义:
- 在讨论数据库HA的时候主从复制的一致性, 或者异步复制的最终一致性, 这里强调的是两个数据的值是相同的;
- 在CAP中的一致性, 指的是线性一致, 这个后面会讲;
而事务的一致性怎么解读呢?
维基百科中的解释是: 在事务开始之前和事务结束以后,数据库的完整性没有被破坏;
数据库的完整性怎么理解? 就是持久的数据是符合我们的一定的预期的, 而这个预期在后续的写入中没有被破坏掉, 也就是我们对持久化的数据的预期是始终成立的; 而维基百科中提到的约束, 触发器等都是来保证我们的这种预期始终成立;
看到这里, 是不是发现数据库的一致性其实是由应用程序来定义的, 什么数据是有效的, 而什么数据是无效的, 数据库纯粹只是用来持久化数据的;
2.3 持久性解读
数据库的持久性其实就是找个地方存储数据, 保证事务完成以后, 这部分已经持久化的数据不会丢失; 在单机事中, 持久性说的是,只有当该事务中的所有写入操作的数据都已经落盘了, 事务才会提交成功; 如果是数据库集群, 表示这部分数据已经复制到了一些节点上;
但是单机事务, 并不能完美保证这部分数据不丢失, 比如你这机房被炸了, 你的数据也就丢了, 所有才有多副本存储的; 但是多副本也只是降低了数据丢失的概率;
2.4 隔离性解读
隔离性放到最后是因为这个比较复杂, 并且内容也比较多; 隔离性可以简单的分为两类: 弱隔离级别和强隔离级别; 我们先来说说日常生产中用的比较多的弱隔离级别:
2.4.1 弱隔离级别
为什么说弱隔离性在生产中比较常见的呢? 因为我们日常接触的大部分都是读多写少的业务; 也就是两个事务大部分情况下是并行执行的, 不会产生数据竞争, 这种场景下弱隔离级别就十分有用了;
2.4.1.1读已提交
读已提交的隔离级别是我们最常用到的, 因为他保证了:
- 单个事务只能读取其他事务已经提交的数据(保证了没有脏读);
- 单个事务只能覆盖其他事务已经提交的数据(保证了没有脏写);
说一下脏读和脏写:
- 脏读很好理解: 就是读取了其他事务还未提交的数据; 为什么要防止脏读其实最大的原因是如果另一个事务中止了, 需要做回滚, 但是因为你当前事务已经读取了回滚事务操作的数据, 当前事务的提交将导致回滚是无效的;
- 脏写: 脏写就是如果一个事务写入了数据, 但是还未提交事务, 但是已经被另一个事务的写入给覆盖了; 脏写问题更严重, 举个例子: 小明和小红同时编辑同一个文章, 我们来看一下脏写的问题:
<colgroup><col span="1" width="426"><col span="1" width="426"></colgroup>
小明 | 小红 |
---|---|
begin transaction | Begin transaction; |
Select article; | Select article; |
update title = '小明'; | |
update title = '小红'; | |
Update author = '小红'; | |
Commit transaction; | |
Update author = '小明'; | |
Commit transaction; |
最终的结果会变成title是小红, author是小明, 可以看到如果有脏写, 来自不同事务的冲突, 导致数据被混淆了;
2.4.1.2 读已提交的实现
- 脏写的防止: 写入需要获取操作对象的锁, 当该对象锁被其他事务占有时, 当前事务必须等待, 直到事务完成或中止时才释放锁;
- 脏读的防止: 一种方式是读也需要申请锁, 如果该对象已经被其他事务锁定了, 当前事务需要等待; 但是读锁的释放是在读取完成之后立即释放的; 这种方式其实并不好, 因为只要存在一个写入的长事务, 必然会阻塞其他只读事务; 所有就有了方式二: 既然只需读取其他事务已提交的数据, 那么只要读取当前事务写入前的数据就可以了; 这种方式就是当有写入事务正在执行的时候, 数据库会做一个快照保存当前写入事务写入前的值, 当有读取事务进来时, 只需要读取快照即可;只有当写入事务提交了, 读取事务才会去读取最新值;
- 所以读已提交的实现, 读不阻塞写, 写不阻塞读;
读已提交存在的问题
读已提交隔离级别看上去已经十分美好了: 因为他同时防止了脏读和脏写, 但是我们来看下这个例子:
拿用户购买基金以后, 需要扣账户余额与加资产举例(假设账户表与资产表都在同一个DB上):
<colgroup><col span="1" width="426"><col span="1" width="426"></colgroup>
投资事务 | 查询事务 |
---|---|
Begin transaction | |
Select position;(1000) | |
Begin transaction; | |
update account = 500; | |
update position = 1500; | |
Commit transaction | |
select account;(500) | |
Commit transaction | |
因为是读已提交的隔离级别, 所以在投资事务未提交前, 用户先看了下资产数据, 只有1000, 在投资事务提交以后, 用户看了账户余额是500, 导致用户以为自己少了500的资产;
这种问题被称为不可重复读, 这种问题在我们业务上其实也是存在的, 比如需要查询并进行校验的时候, 一个长事务的两次读取可能会读取到不同的值, 导致本次操作失败的问题;
2.4.1.3 不可重复读的解决方案(可重复读)
这个问题还是读写并发的问题, 在不考虑加锁的情况下, 可以参考读已提交的快照隔离来实现:读已提交的实现是考虑了写事务包了读事务的时候, 怎么来处理脏读; 不可重复的问题是读事务包了写事务;那么只保留一份快照是不可取的, 因为一个读事务可能会包掉多个写事务, 会导致连快照都已经被覆盖的情况; 所以不可重复读的解决方案就是保存多份数据快照:这种方案也就是MVCC(多版本并发控制);
MVCC实现快照隔离
数据库会默认给每张表加上两个属性: created_by 和 deleted_by, 这两个属性分别写入的是该条记录是由哪个事务创建的(created_by), 由哪个事务更新的(deleted_by), 每次新建事务的时候数据库会默认分配事务ID;
- insert 只会写入created_by;
- Delete 会写入deleted_by;
- update会被解析为 delete + insert;
怎么通过created_by 和 deleted_by来实现可重复读呢?
多版本控制类似于下面的存储:
<colgroup><col span="1" width="284"><col span="1" width="284"><col span="1" width="284"></colgroup>
data | created_by | deleted_by |
---|---|---|
A | 4 | |
D | 3 | 4 |
C | 2 | 3 |
B | 1 | 2 |
A | 0 | 1 |
Deleted_by只有事务真正提交时才会生成快照, 也就是所有中止操作都不会生成快照;
只要在该事务开始前的快照, 都是对该事务可见的; 在该事务开始后的快照, 对该事务是不可见的;
通过deleted_by关联created_by可以找到该个事务之前所有的快照, 那么可以通过[delete_by, created_by]这个区间来判断, 当前需要读取哪份快照了;
我们通过前面的购买基金的例子来说明:(假设account和position的created_by为0)
<colgroup><col span="1" width="213"><col span="1" width="213"><col span="1" width="213"><col span="1" width="213"></colgroup>
投资事务1 | 读取事务1 | 投资事务2 | 读取事务2 |
---|---|---|---|
Begin;t_id = 1; | |||
Begin;t_id = 2; | select account = 1000; | ||
update account = 500; | |||
Udpdate position = 1500; | |||
Commit; | |||
Begin;t_id = 3; | Begin;t_id = 4; | ||
Update account = 1000; | Select account = 500; | ||
select position = 1000;select account = 1000; | commit; | Select account = 500; | |
Commit; | commit; |
account最终的快照:
<colgroup><col width="284" span="1"><col width="284" span="1"><col width="284" span="1"></colgroup>
| account | create_by | deleted_by |
| 1000 | 3 | |
| 500 | 2 | 3 |
| 1000 | 0 | 2 |
position最终的快照:
<colgroup><col span="1" width="284"><col span="1" width="284"><col span="1" width="284"></colgroup>
position | created_by | deleted_by |
---|---|---|
1500 | 2 | |
1000 | 0 | 2 |
先来看读取事务2: 该条事务开始时, 数据库会先列出当前所有还未提交的事务集合[1,3,4], 然后再判断account的当前快照[0,2], 因为4>2, 所以第一次读取到的是500; 第二次读取的时候, 因为事务3已经提交了, 所以当前account的快照是[0-3], 如果这个时候仅仅只是判断4>3, 那读取到的应该是1000; 但是因为在事务开始的时候已经记录了[1,3,4]是还未提交的, 所以应该过滤掉事务3提交所产生的快照, 所以第二次读取的仍然是500;
2.4.1.4 写并发引发的问题
快照隔离主要解决是读写并发时, 只读事务能看到什么, 但是还有一种情形其实也是很常见的, 那就是写写并发了, 比如金融场景中很常见的加资产: 如果一个用户购买了2个产品, 需要给他加2次资产, 并更新总资产值;
<colgroup><col span="1" width="426"><col span="1" width="426"></colgroup>
事务1 | 事务2 |
---|---|
Begin; | Begin; |
select position = 600; | |
Update position = 1100; | select position = 600; |
Commit; | Update position = 1100; |
commit; |
不管是读已提交或者是可重复读这种场景都是正常, 但是当这两个事务提交完成以后, 用户会发现自己账户少了500的资产, 为什么呢, 因为事务2把事务1的结果覆盖了, 这种情况叫丢失更新;
如何避免呢?
- 原子写, update position = position + 500; 现在数据库基本都支持这种原子写了;
- 显示锁定: select for update;
- CAS: 加乐观锁 update position = 1100 where position = 600;
还有一种更神奇的场景: 当我们需要在一个事务里操作多个对象的时候, 比如购买产品需要先扣余额, 再加资产;
账户初始金额是1000;
<colgroup><col span="1" width="426"><col span="1" width="426"></colgroup>
事务1 | 事务2 |
---|---|
Begin; | Begin; |
Select account = 1000; | |
if(account > 0) then; | Select account = 1000; |
update account = 0; | if(account > 0) then; |
update position = position + 1000; | update account = 0; |
commit; | update position = position + 1000; |
commit; |
在事务中先判断余额是否足够, 如果单个事务执行, 上面的逻辑没有任何问题; 但是当两个事务并发的时候, 只要有一个事务生效了, 另一个事务的前提其实已经不满足了, 但是现在并发的结果是, 明明只有1000块, 却购买了2000块的产品并成功加了资产; 这种问题为写偏差, 也称为幻读, 写偏差的类型其实很好总结的:
- select符合条件的记录, 然后做判断;
- 如果第一个条件是满足的, 就进行后续写入操作;
解决方案:
- 对先决条件加锁, 即select for update;
- 有些场景里是没法加锁的, 比如用户余额这种场景, 那这个时候怎么办呢? 我们可以人为的在数据库中引入一个可加锁的对象, 比如扣款申请单, 那么用户加资产就可以拆分为 扣余额 和 加资产两个不同的事务, 每个事务都可以引入原子写了;
当然 以上两个问题都可以用串行化的隔离级别来搞定, 但是这个隔离级别效率实在太低了;
2.4.2 强隔离级别
只有串行化(Serializable)是强隔离性的, 字面意思就是如果事务并发了, 一个时间点只会有一个事务在运行, 其他都需要等待, 正因为串行化执行, 性能非常低, 所有在实践中运用的比较少, 甚至于有些数据库, 比如Oracle中压根就没实现, 虽然有可序列化这个级别, 但是真正的实现确是快照隔离(在下面的弱隔离级别中会讨论);
2.4.2.1 2PL
两阶段锁(注意不是2阶段提交): 即当多个事务操作需要写入同一个对象时(修改或删除), 需要单独占有这个对象(可以理解为JAVA中的独占锁):
- 如果对象A已经被事务1读取了, 这时候事务B需要操作对象A, 必须等事务A提交或者终止;
- 如果对象A已经被事务1操作了, 这时候事务B需要读取对象A, 必须等事务A提交或者终止;
所以两阶段锁是即阻塞读也阻塞写, 来保证没有脏读与脏写;
2PL的实现
2PL的实现主要是通过对数据库中每个对象加锁来实现的, 锁分为共享模式和独占模式;
- 事务会以共享锁来读取对象, 但是如果这个对象已经被其他事务使用独占锁锁定了, 则当前的读事务必须等待;
- 事务需要操作对象时, 必须获取独占锁; 但是如果当前对象已经有其他事务获取了共享锁或者独占锁, 则当前事务必须等待;
- 事务先以共享锁读取了对象, 但是写入对象时, 需要将共享锁升级为独占锁; 锁升级过程与获取独占锁一致;
- 事务获取锁以后, 必须持有至事务结束(commit | abort)才能释放锁; 这也是两阶段锁的名称来源: 一阶段(事务开始或者执行时)获取锁, 二阶段(事务结束)释放锁;
既然用到了锁, 且一个事务中可能需要去获取多把锁, 那么就很有可能发生死锁: 事务A持有A对象的锁, 需要去获取B对象的锁, 但是事务B已经持有了B对象的锁, 需要去获取A对象的锁, 这个时候就会发生死锁;
死锁发生以后, 数据库会自动检测并中止其中一个事务, 以便让另一个事务继续进行, 中止的事务的重试有应用系统完成;这也是两阶段锁效率低下的一个原因;
2.4.3 串行化快照隔离(SSI)
弱隔离级别会有脏读, 不可重复读, 幻读等问题, 但是采用强隔离级别就会有性能问题, 那么有没有一种方案能兼容这两者的优点呢?那就是2008年才被提出来的串行化快照隔离;
网友评论