单机事务

2020-05-14  本文已影响0人  java菜鸟进阶

分布式相关的内容非常多, 本次分享主要是从分布式事务切入的, 所以主要讲的是分布式的环境下, 怎么做事务; 要讲分布式事务, 我觉得还是有必要提一下单机事务的, 单机事务的很多特性其实在分布式事务中也需要考虑到; 那么今天在这里, 我们主要从两方面来解读下单机事务:

  1. 是什么? : 事务是什么?
  2. 怎么做?: 事务是怎么实现的?

1. 事务是什么?

我们的系统大体能分为两类:

a. 数据密集型;

b. 计算密集型;

我们日常工作中更多接触到的是数据密集型, 也就无可避免的接触到数据持久话的问题了, 现在来一起看下几个场景:

  1. 在持久化时, 程序崩溃了;
  2. 在持久化时, 服务器崩溃了;
  3. 在持久化时, 程序与DB的链接断开了;
  4. 持久化使用了多线程, 线程A把线程B的持久化数据覆盖了, 甚至最终持久化的数据有1/3是线程A的, 2/3是线程B的, 形成了数据混淆;
  5. 在数据读取时, 把持久化一半的记录读取出来了;
  6. etc;

正因为我们的程序运行的环境是不稳定的, 所以上面的种种场景都在日常生产中可能遇到的;

而正因为系统的不稳定了, 造成了原本预期结果是true | false, 结果返回的是未知状态, 因为程序奔溃了, 我也不知道持久化到哪部分数据了; 也就是形成了三态问题;

那业务系统为了数据的可靠性, 能不能处理这个三态问题呢? 答案是能的, 但是这个成本是很高的, 需要在业务代码中嵌入许多非业务相关的代码来保证数据的完整性, 同时也需要非常多的测试来验证这个方案的可行性;

那么有没有更好的解决方案呢?

业务系统原本的预期就是true | false, 那么只需要数据库层面把中间态解决掉就可以了, 这也就引出了事务是什么以及做了什么:

事务把业务系统的同一批操作抽象为一个逻辑单元, 该逻辑单元的所有操作要么都成功(commit), 否则就是全部失败(abort), 然后回滚(rollback), 不会出现中间态;

这样做的好处就是对业务系统的反馈只会是成功或者失败;

2. 事务是怎么实现的?

说到事务就不得不提到他的四个特性了:

2.1 ACID

单机事务的特性:(详见维基百科)

2.1 原子性解读

看到原子性第一反应其实多线程中的原子性: 保证一个操作是原子的, 该操作的中间态是对其他线程不可见的; 但是事务的原子性不是用来解决并发的, 并发性是由隔离性来保证;

原子性解决的是中间的态的问题, 事务中的一批写操作在执行到某一个写时, 因为网络或者其他原因, 写失败了, 那么该事务就应该被中止, 并且数据库必须撤销该事务迄今为止所有的写入;

所以原子性更多的是约束了事务可以中止的, 并且中止的时候会撤销所有该事务未提交的写入操作;

2.2 一致性解读

一致性必须要说一下, 一致性这个词在不同的语境中, 被赋予了不同含义:

  1. 在讨论数据库HA的时候主从复制的一致性, 或者异步复制的最终一致性, 这里强调的是两个数据的值是相同的;
  2. 在CAP中的一致性, 指的是线性一致, 这个后面会讲;

而事务的一致性怎么解读呢?

维基百科中的解释是: 在事务开始之前和事务结束以后,数据库的完整性没有被破坏;

数据库的完整性怎么理解? 就是持久的数据是符合我们的一定的预期的, 而这个预期在后续的写入中没有被破坏掉, 也就是我们对持久化的数据的预期是始终成立的; 而维基百科中提到的约束, 触发器等都是来保证我们的这种预期始终成立;

看到这里, 是不是发现数据库的一致性其实是由应用程序来定义的, 什么数据是有效的, 而什么数据是无效的, 数据库纯粹只是用来持久化数据的;

2.3 持久性解读

数据库的持久性其实就是找个地方存储数据, 保证事务完成以后, 这部分已经持久化的数据不会丢失; 在单机事中, 持久性说的是,只有当该事务中的所有写入操作的数据都已经落盘了, 事务才会提交成功; 如果是数据库集群, 表示这部分数据已经复制到了一些节点上;

但是单机事务, 并不能完美保证这部分数据不丢失, 比如你这机房被炸了, 你的数据也就丢了, 所有才有多副本存储的; 但是多副本也只是降低了数据丢失的概率;

2.4 隔离性解读

隔离性放到最后是因为这个比较复杂, 并且内容也比较多; 隔离性可以简单的分为两类: 弱隔离级别和强隔离级别; 我们先来说说日常生产中用的比较多的弱隔离级别:

2.4.1 弱隔离级别

为什么说弱隔离性在生产中比较常见的呢? 因为我们日常接触的大部分都是读多写少的业务; 也就是两个事务大部分情况下是并行执行的, 不会产生数据竞争, 这种场景下弱隔离级别就十分有用了;

2.4.1.1读已提交

读已提交的隔离级别是我们最常用到的, 因为他保证了:

  1. 单个事务只能读取其他事务已经提交的数据(保证了没有脏读);
  2. 单个事务只能覆盖其他事务已经提交的数据(保证了没有脏写);

说一下脏读和脏写:

  1. 脏读很好理解: 就是读取了其他事务还未提交的数据; 为什么要防止脏读其实最大的原因是如果另一个事务中止了, 需要做回滚, 但是因为你当前事务已经读取了回滚事务操作的数据, 当前事务的提交将导致回滚是无效的;
  2. 脏写: 脏写就是如果一个事务写入了数据, 但是还未提交事务, 但是已经被另一个事务的写入给覆盖了; 脏写问题更严重, 举个例子: 小明和小红同时编辑同一个文章, 我们来看一下脏写的问题:

<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 读已提交的实现

  1. 脏写的防止: 写入需要获取操作对象的锁, 当该对象锁被其他事务占有时, 当前事务必须等待, 直到事务完成或中止时才释放锁;
  2. 脏读的防止: 一种方式是读也需要申请锁, 如果该对象已经被其他事务锁定了, 当前事务需要等待; 但是读锁的释放是在读取完成之后立即释放的; 这种方式其实并不好, 因为只要存在一个写入的长事务, 必然会阻塞其他只读事务; 所有就有了方式二: 既然只需读取其他事务已提交的数据, 那么只要读取当前事务写入前的数据就可以了; 这种方式就是当有写入事务正在执行的时候, 数据库会做一个快照保存当前写入事务写入前的值, 当有读取事务进来时, 只需要读取快照即可;只有当写入事务提交了, 读取事务才会去读取最新值;
  3. 所以读已提交的实现, 读不阻塞写, 写不阻塞读;

读已提交存在的问题

读已提交隔离级别看上去已经十分美好了: 因为他同时防止了脏读和脏写, 但是我们来看下这个例子:

拿用户购买基金以后, 需要扣账户余额与加资产举例(假设账户表与资产表都在同一个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;

  1. insert 只会写入created_by;
  2. Delete 会写入deleted_by;
  3. 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的结果覆盖了, 这种情况叫丢失更新;

如何避免呢?

  1. 原子写, update position = position + 500; 现在数据库基本都支持这种原子写了;
  2. 显示锁定: select for update;
  3. 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块的产品并成功加了资产; 这种问题为写偏差, 也称为幻读, 写偏差的类型其实很好总结的:

  1. select符合条件的记录, 然后做判断;
  2. 如果第一个条件是满足的, 就进行后续写入操作;

解决方案:

  1. 对先决条件加锁, 即select for update;
  2. 有些场景里是没法加锁的, 比如用户余额这种场景, 那这个时候怎么办呢? 我们可以人为的在数据库中引入一个可加锁的对象, 比如扣款申请单, 那么用户加资产就可以拆分为 扣余额 和 加资产两个不同的事务, 每个事务都可以引入原子写了;

当然 以上两个问题都可以用串行化的隔离级别来搞定, 但是这个隔离级别效率实在太低了;

2.4.2 强隔离级别

只有串行化(Serializable)是强隔离性的, 字面意思就是如果事务并发了, 一个时间点只会有一个事务在运行, 其他都需要等待, 正因为串行化执行, 性能非常低, 所有在实践中运用的比较少, 甚至于有些数据库, 比如Oracle中压根就没实现, 虽然有可序列化这个级别, 但是真正的实现确是快照隔离(在下面的弱隔离级别中会讨论);

2.4.2.1 2PL

两阶段锁(注意不是2阶段提交): 即当多个事务操作需要写入同一个对象时(修改或删除), 需要单独占有这个对象(可以理解为JAVA中的独占锁):

  1. 如果对象A已经被事务1读取了, 这时候事务B需要操作对象A, 必须等事务A提交或者终止;
  2. 如果对象A已经被事务1操作了, 这时候事务B需要读取对象A, 必须等事务A提交或者终止;

所以两阶段锁是即阻塞读也阻塞写, 来保证没有脏读与脏写;

2PL的实现

2PL的实现主要是通过对数据库中每个对象加锁来实现的, 锁分为共享模式和独占模式;

  1. 事务会以共享锁来读取对象, 但是如果这个对象已经被其他事务使用独占锁锁定了, 则当前的读事务必须等待;
  2. 事务需要操作对象时, 必须获取独占锁; 但是如果当前对象已经有其他事务获取了共享锁或者独占锁, 则当前事务必须等待;
  3. 事务先以共享锁读取了对象, 但是写入对象时, 需要将共享锁升级为独占锁; 锁升级过程与获取独占锁一致;
  4. 事务获取锁以后, 必须持有至事务结束(commit | abort)才能释放锁; 这也是两阶段锁的名称来源: 一阶段(事务开始或者执行时)获取锁, 二阶段(事务结束)释放锁;

既然用到了锁, 且一个事务中可能需要去获取多把锁, 那么就很有可能发生死锁: 事务A持有A对象的锁, 需要去获取B对象的锁, 但是事务B已经持有了B对象的锁, 需要去获取A对象的锁, 这个时候就会发生死锁;

死锁发生以后, 数据库会自动检测并中止其中一个事务, 以便让另一个事务继续进行, 中止的事务的重试有应用系统完成;这也是两阶段锁效率低下的一个原因;

2.4.3 串行化快照隔离(SSI)

弱隔离级别会有脏读, 不可重复读, 幻读等问题, 但是采用强隔离级别就会有性能问题, 那么有没有一种方案能兼容这两者的优点呢?那就是2008年才被提出来的串行化快照隔离;

上一篇下一篇

猜你喜欢

热点阅读