从0到1理解数据库事务(上):并发问题与隔离级别

最近准备写一篇关于Spanner事务的分享,因此先分享一些基础知识,涉及ACID、隔离级别、MVCC、锁,因为太长,只好拆分红上下两篇:git

  • 上:并发问题与隔离级别 主要讲事务所要解决的问题、思路,先理解为何须要事务以及事务并发控制中面临的问题。
  • 下:隔离级别实现——MVCC与锁 隔离性是为了更好地作到并发控制,事务的并发表现会对业务有直接影响,因此这篇会详细讲如何实现隔离,主要是讲两种主流技术方案——MVCC与锁,理解了MVCC与锁,就能够触类旁通地看各类数据库并发控制方案,并理解每种实现能解决的问题以及须要开发者本身注意的并发问题,以更好支撑业务开发。

文章开始前先给一个小思考,考虑一个状况: 像下面这样实现User提现100元,是否必定不会出问题?程序员

  1. Start Transaction
  2. SELECT balance FROM users WHERE user_name=x; (这次读取在Transaction中)
  3. 在代码中判断balance是否大于等于100
  4. 若是小于100元,End Transaction而且返回余额不足提现失败
  5. 若是大于等于100元,则 UPDATE users SET balance = balance - 100 WHERE user_name=x; 而后Commit Transaction,返回提现成功

若是你已经很理解数据库事务了,必定知道什么状况有问题,以及为何出现这个问题,这篇文章对你太入门,不用继续看。若是不太清楚,那但愿你看完上、下两篇就很是理解了,不然就是我写得太烂。github

1、从新理解 ACID

1. 数据操做中面临的问题

技术中的全部方案一定是为了解决特定问题,先理解问题再看方案,学起来更简单、理解更深刻,因此先从数据库面临的问题提及。 首先,要理解为何数据库会有事务的需求,先理解数据库要解决的根本问题不是存储,存储问题已经被文件系统解决了,数据库的目的是如何帮助开发者更可靠、更快速、更便利地使用存储,更好地帮助开发者完成业务,业务中一个高频需求是:有一批连续的操做,这一批操做要么所有成功,要么就能够像没有发生过同样,不要因为部分未成功而致使脏数据产生。若是没有事务,咱们处理用户下单的业务场景,就要超级多的代码去handle各类错误、清理各类脏数据、避免可能的bug,好比下单成功却因为数据库宕机致使没有扣款。为了提升开发效率、下降开发成本,就须要数据库能提供一种保证:将一组操做看做一个单元,这一单元能够所有成功,在部分失败的状况下,能够彻底回滚,就像没有发生,这一组操做称为事务(Transaction)。 可是仅仅作到上面那一点是不够的,由于这一个简单需求,其实引入了另外一个问题,请注意重点——“一组操做”,事务中可能存在着多个独立操做,他们组合为一组操做,理解多线程编程的同窗必定会立刻想到,这就会出现经典的并发问题,多个事务间若是不进行并发控制,就会产生各类意外结果,这不是使用者想要的。redis

总结一下,数据操做中面临的问题:数据库

  1. 如何将一组操做看做一个总体,要么所有成功,要么所有回滚。
  2. 如何在知足上一条需求的状况下,可以对它进行并发控制,保证不要出现意外结果。

2. 咱们须要什么:ACID

ACID 是为了解决上述问题所总结出,为保证事务是正确可靠的,所必须具有的四个特性:编程

1. 原子性(Atomicity) 事务中的原子性是一个经常被你们误解的特性,由于这个原子性的意思和咱们一般语境下的原子性不太同样,大多数时候原子性是指一条不可再分割、不会被中断影响的指令,好比读取一个内存地址的值、将值写回内存地址、redis的SETNX(set if not exists),这些操做都符合咱们常说的原子性。 但是事务中的原子性,并非指事务具备不被中断影响的特色,它仅仅是指,事务中的全部操做应该被看做不可分割的一组指令,任何一个指令不能独立存在,要么所有成功执行,要么所有不发生(也就是回滚)。 还有不少同窗对这里所说的“成功执行”有误解,成功执行是指数据库层面的,而不是业务层面的,举个例子,客户购买商品A,但是在购买时,商家恰好下架了商品,那么此时执行 update products set price=100 where product_id=A and status=销售中 ,因为product的status已经变成“下架”,致使被更新的行数为0,这个算成功执行吗?算!数据库不报错、不宕机、正常运行就是成功,更新行数为0是数据库的正常返回结果,这在业务上是失败,在数据库层面是成功,这种状况数据库不会执行回滚,须要程序员判断更新行数,若是为0,手动回滚。 若是数据库因为硬件或者系统问题发生宕机、报错,这样才算是指令执行失败,此时数据库会重试或者直接回滚,而后将错误返回给开发者。 原子性不止为开发者保证了事务的可靠性(不会由于数据库出错而产生脏数据),还能让开发者手动回滚,提供了业务的便利性。多线程

2. 一致性(Consistency) 这个名词也是至关使人困惑,与数据库主从复制中所说的“一致性”不一样,主从复制的一致性是指多个副本间是否完成同步、数据相同,而这里的一致性是指事务是否产生非预期中间状态或结果。好比脏读和不可重复读,产生了非预期中间状态,脏写与丢失修改则产生了非预期结果。一致性其实是由后面的隔离性去进一步保证的,隔离性达到要求,则能够知足一致性。也就是说,隔离不足会致使事务不知足一致性要求,因此务必理解各个隔离级别,才能少写Bug。并发

3. 隔离性(Isolation) 简单来讲,隔离性就是多个事务互不影响,感受不到对方存在,这个特性就是为了作并发控制。在多线程编程中,若是你们都读写同一块数据,那么久可能出现最终数据不一致,也就是每条线程均可能被别的线程影响了。按理说,最严格的隔离性实现就是彻底感知不到其余并发事务的存在,多个并发事务不管如何调度,结果都与串行执行同样。为了达到串行效果,目前采用的方式通常是两阶段加锁(Two Phase Locking),可是读写都加锁效率很是低,读写之间只能排队执行,有时候为了效率,原则是能够妥协的,因而隔离性并不严格,它被分为了多种级别,从高到低分别为:ide

  • ⬇️可串行化(Serializable)
  • ⬇️可重复读(Read Repeatable)
  • ⬇️已提交读(Read Committed)
  • ⬇️未提交读(Read Uncommitted)

每个级别都只是指导标准,每一个数据库对其的实现都有差别,有的数据库在Read Committed级别时,就已经实现了Read Repeatable的效果,有的数据库干脆不提供Read Uncommitted级别。 在隔离级别为Serializable时,就会感受到事务像一个完彻底全的原子操做,不被任何中断、并发所影响。 不少开发者理解的事务可能就在Serializable级别,你们误觉得事务都是可串行化的,其实并非,大多数的数据库默认隔离级别都不是可串行化,大多数在Read Repeatable或者Read Committed,要是按照可串行化的思惟去编程,却用着低于可串行化的隔离级别,就很容易写出致使数据在业务层面不一致的代码,因此开发者必定要理解各个隔离级别及其原理,更好地支撑业务开发,下面会仔细地讲隔离级别及其实现。oop

4. 持久性(Duration) 这是ACID中最好理解的,即事务成功提交后,对数据的修改永久的,即便系统发生故障,也不会丢失,这里所说的故障,也只是通常错误好比宕机、系统Bug、断电,若是是硬盘损毁,那就没办法,数据必定会丢失。

2、并发问题与隔离级别

在讨论各个隔离级别的实现以前,先看一下在事务并发执行时,隔离不足会致使的问题。

脏写(Dirty Write)

还未提交的事务写了另外一个未提交事务所写过的数据,称为脏写,好比: 两个并发执行的事务A、B,A写了x,在A还未提交前,B也写了x,而后A提交,此时虽然B尚未提交,可是A也会发现本身写的x不见了。

脏写

不少地方用“覆盖”去形容脏写,可是我以为不太适合,由于覆盖暗示了一种前后链条,某个事务写了数据,在昨天就提交了,今天有事务来写同一个数据,能够称之为覆盖,昨天的数据成为历史,但这不是脏写,因此更适合的形容多是“擦除”,事务发现本身的提交被别人擦除,好像不存在。 脏写是事务必定不容许发生的,因此无论是哪一个隔离级别都必定不容许脏写

脏读(Dirty Read)

因为事务的可回滚特性,所以commit前的任何读写,都有被撤销的可能,假如某个事物读取了还未commit事务的写数据,后来对方回滚了,那么读到的就是脏数据,由于它已经不存在了。

脏读
避免脏读能够采用加锁或者快照读的解决方案。在**已提交读(Read Committed)**级别就能够避免脏读,由于读到的必定是已经Commit的数据。在业务开发中,虽然有未提交读(Read Uncommitted),可是几乎是没有人会用的,读到脏数据通常对业务是很大的伤害,因此有的数据库干脆都不支持未提交读,好比PostgreSQL。

不可重复读(Non-Repeatable Read)

事务A读取一个值,可是没有对它进行任何修改,另外一个并发事务B修改了这个值而且提交了,事务A再去读,发现已经不是本身第一次读到的值了,是B修改后的值,就是不可重复读。 简单来讲就是第一次读的值,啥都没作,下次读它也有可能发生变化。

不可重复读
通常数据库使用MVCC,在事务的第一条语句开始时生成Read View,事务以后的全部读取,都是基于同一个Read View,以此避免不可重复读问题。

幻读(Phantom)

与不可重复读很是相似,事务A查询一个范围的值,另外一个并发事务B往这个范围中插入了数据并提交,而后事务A再查询相同范围,发现多了一条记录,或者某条记录被别的事务删除,事务A发现少了一条记录。

幻读

幻读容易与不可重复读混淆,区别它们只须要记住不可重复读面向的是“同一条记录”,而幻读面向的是“同一个范围”。 MVCC虽然使用快照的方式解决了不可重复读,可是仍是不能避免幻读,幻读须要经过范围锁解决,可能你们会以为很奇怪,为何快照读没法避免幻读,这个会在下一篇文章中详细讲。

SQL标准中有对于各个隔离级别所容许出现的问题做出规定:

SQL标准
除了以上4个问题外,下面还有3个问题,更偏向业务层面,不过也是因为隔离不足引发的:

读误差(Read Skew)

Skew能够理解为不一致,所以读误差能够理解为读结果违反业务一致性,好比X、Y两个帐户余额都为50,他们总和为100,事务A读X余额为50,而后事务B从X转帐50到Y而后提交,事务A在B提交后读Y发现余额为100,那么它们总和变成了150,此时违反业务一致性。

Read Skew

写误差(Write Skew)

写误差能够理解为事务commit以前写前提被破坏,致使写入了违反业务一致性的数据,网上有个很好的简称为写前提困境,也就是读出某些数据,做为另外一些写入的前提条件,可是在提交前,读入的数据就已被别的事务修改并提交,这个事务并不知道,而后commit了本身的另外一些写入,写前提在commit前就被修改,致使写入结果违反业务一致性。 写误差发生在写前提与写入目标不相同的情境下。 这是业务开发中最容易出错地方,若是开发者不太理解隔离级别,也不知道目前使用的是哪一个隔离级别,极可能写出有写误差的代码,形成业务不一致。 举个例子: 信用卡系统对不一样等级的会员有积分加成,3级会员则每次都3倍积分,同时,会有定时任务检查当积分不知足要求时,就会降级。 首先,会员进行了刷卡消费,此时要计算积分,开启了事务A,读到会员等级为3,与此同时定时任务也开始了,读到会员积分为2800,已经不知足3000分应该降级为2级,而后将会员等级降级为2而且commit,因为事务A读到的等级为3,它仍是按照3倍积分为会员增长了积分,会员赚了,多亏那个程序员不理解他使用的事务隔离级别,出现了业务不一致。

Write Skew

丢失更新(Lost Updates)

因为未提交事务之间看不到对方的修改,所以都以一个旧前提去更新同一个数据,致使最后的提交结果是错误值。 假设有支付宝帐户X,余额100元,事务A、B同时向X分别充值10元、20元,最后结果应该为130元,可是因为丢失更新,最后是110元。

丢失更新
丢失更新与写误差很类似,都是因为写前提被改变,他们区别是,丢失更新是在同一个数据的最终不一致,而写误差的冲突不在同一个数据,是在不一样数据中的最终不一致

这一篇讲到的全部问题都会在下一篇讲隔离级别实现中获得解决,理解隔离级别实现,有助于选择合适的隔离级别,或者在代码层面有意识地避免隔离级别不足所带来的问题。

参考资料

相关文章
相关标签/搜索