数据库事务1(引入)

“魔鬼隐藏在细节中。”

数据库事务为了什么? 为了容错和并发问题。

应用程序会遇到各种各样的错误:

  1. 机器挂了,数据库ip变了,内存爆了等等天灾人祸以及潜在的网络风险,机器运行风险导致 数据库的crud做到一半,但是没有做完。
  1. 并发: 多个请求打到数据库上,可能会导致包括但不限于这些问题:
  • 读:读取到中间态的数据,无意义的数据。
  • 写:写入被覆盖,但是不自知
  • 其他的竞争导致的问题

解决上述问题的一个利器—-事务

事务是对一系列数据库操作的集合,他在逻辑上被看做是一个操作,不可分割,要么完全成功,要么全部失败回滚。

对于应用来说,它可以小心的重试而不会导致每一次重试留下一些不可恢复的痕迹,在某一定程度上简化了应用程序的逻辑。

他对应用程序提供了一系列的保证,通常被称为ACID(原子性 atomicity、一致性consistency、隔离性isolation、持久性Durability)。

但实际上对于ACID所提供的保证的定义没有那么明确,各个数据库在实现过程中可能会重新定义了ACID,或者说采用了一套更为弱的保证,比如BASE,但它同样模糊,具体实现要具体分析。

ps:BASE理论。 基本可用性(Basically Available),软状态(soft-status),eventual consistency(最终一致性)

[tagps,todo] 应用程序中多线程操作也有原子性,这个原子性和并发编程的原子性的异同?

  • atomicity 原子性 如果一个操作具有原子性,其实就意味着它要么成功,要么失败,不会因为一些crach,网络问题导致一个操作只做了一半,如果失败,数据库必须把之前的所有写入操作全部回滚。 可以说除了原子性外,可终止,可撤销性也是非常好的形容词。

[tagps,todo] 一致性的概念在别的地方也会有出现,之后整理一下,并进行对比吧。

  • consistency 一致性这里的一致性是指数据模型中要求的一致性。最为典型的例子是转账,这里的一致性是指在转账前和转账后,两个账户的总钱数是一致的。

    数据库只是一个工具,可以用外键,约束等等方式去协助保证一致性,但真正的一致性还是由数据模型去定义,用应用程序去最终保证的,数据库只是负责存储,假设在转账的时候收取手续费,那么最终两个账户的钱与一开始的钱数就是不一致的,数据库可察觉不到复杂的业务逻辑,也没有合理的手段去保证抠掉的手续费与两个账户的money加来一致(其实是可以的,加张表,但没必要)。而且目前以个人使用情况来说,数据的外键,check约束等等在实践中已经很少用了,一般会把保证数据一致性的任务放置在应用层,利于水平的扩展,利于减少数据查询,和数据库死锁的发生。所以严格来说一致性是应用程序的属性,AID是数据库的属性,应用程序可以用数据库的A和I去实现C。

  • isolation 隔离性多个客户端请求数据库会存在并发读写数据的问题。对于每个请求,最理想的情况下,当然是每个请求都是相互不影响,比如相互占有的资源不冲突,或者在时间上依次独占自己所要的资源,不然一定会发生并发问题(显然),随便一个内存中的并发问题都会在数据库中重现。

    最为简单的例子就是某个字段自增的例子,若是在应用程序中先取当前值,后写如当前值+1,一个字段自增100次,也不一定会从0到100。如果这种冲突发生在内存中,我们会使用加锁,阻止指令重排,compare and swap 等等手段去保证多个程序逻辑上的同步,现在冲突发生在数据库层,解决的思想大同小异,但是结合数据库自身的一些特性,方式上会有些出入。数据库事务中提出各个隔离级别是接下来讨论的重点.

  • durability 持久性 事务持久性的保证 就是一旦事务成功,数据都要保存到数据库中,无论是之后数据库crash了,服务器重启了,一旦数据库说事务完成了,那就是一个对上层应用程序的保证,说我已经完成并把结果记录下来了。

    但是这个保证也只是在 事务提交-->事务完成 这个时间段,要是后续硬盘出了什么问题导致数据丢失,持久性不能也没必要保证。

    其实刚看到这个特性的时候觉得持久性不是理所当然的吗,我已经提交的数据还能丢?接触其他数据库之后才有所感触,例如redis,es,kafka等等中间件,他们都涉及数据的保存,但是都面临着性能和数据丢失的问题,且开放给用户配置抉择,比如redis的刷盘机制,aof快照(但也不是wal),或者es的refresh,flush的时间配置,为了高吞吐和低延迟,很多数据库都会设有写入缓存(类似)和刷盘策略,且不配置预写日志,如果机器crash,那数据就可能丢失了。之后日志环节会讨论innodb的做法,也蛮有参考价值的。

[todo] 之后写总结,暂时没有感受

事务的一个特点就是 在事务执行过程中,一旦发生了不可预知的错误,那么会放弃整个事务的执行。

哪怕说是一个事务中前99个读写操作都成功,最后一个操作抛出异常,也会让整个事务回滚。这种一刀切的策略确实简化了编程模型(一般大事务也不推荐),但是其效率确实不会是最高的。如果少数操作异常了,乐观情况下,重试少数就能从错误中恢复,或者说重试前99个操作的代价很高,得跑个一小时,那么放弃整个事务就有些得不偿失了。所以也不是所有的系统都会全部回滚 eg:无主节点复制的数据存储 [todo]

发生异常之后要不要回滚,如何回滚也是需要讨论的地方。

  • 事务已经完成了,但是在返回接受方的时候,网络异常了或者网关有什么问题,导致客户端抛出了异常,此时不应重试。[todo 那如何处理呢?]
  • 错误是由系统资源不足导致的,比如 磁盘满了,内存炸了,连接池炸了。这个时候不停的重试反而会导致资源的挤兑,所以一般会设置个上限,设置个重试等待时间,指数回退等。
  • 重试是没过业务逻辑校验,或者主键冲突等等应用程序层面,逻辑上的错误,重试就毫无意义。
  • 由死锁,网络问题,并发隔离问题发生的可恢复的故障才值得去重试
  • 如果事务中有不可撤销的操作,比如发邮件,消息通知等等,那么若是之后发生异常回滚,消息又会发一遍。 可以尝试两阶段提交[2pc][todo]

引入的话就不谈了,总而言之,朴素直观的强隔离级别会造成大多数时候不可接受的性能损耗。所以在保证性能的基础上,数据库事务往往牺牲一些隔离性,让上层应用自己处理部分并发问题。认识和理解这些弱隔离级别会引发的一些不直观但常见的问题是此文的主要目的。

简单来看,这一隔离级别意味着 每个事务只能读取到别的事务已经提交的数据。 但从细节上说,

  1. 读 只能读已提交数据 (避免脏读)
  2. 写 只能覆盖已提交数据 (避免脏写)
  • 脏读

    反过来说,就是事务A能看到其他事务T未提交的数据,意味着事务A可能会基于这种可能运行时短暂存在的,或是不稳定的随时可能回滚的数据在做自己的逻辑和决策,这显然会导致事务A得出的数据要么是过时的,要么根本是错误的。

    eg: 数据库中 x = 1,事务A其中一步需要读取x值,事务B,是个job,其中一步需要将x+1,n步之后之后要再 *2。 事务B 首先启动,将x+1 ,即2 写入数据库。 事务A紧随其后,读到x=2。 事务B因某些原因执行失败,将x回滚为1。 事务A整个执行成功。 但是 *2是基于x=2做的逻辑,x=2 只是事务B的瞬时产生的数据,而且还失败回滚了。事务A的运行不仅毫无意义,还会产生不明所以的垃圾数据。

  • 脏写(脏更新?)

考虑两个事务A,T同时写入同一数据,若是T能看到未提交的数据,也意味着A会写入其未提交的数据。若是各个事务顺利执行。那ok,后写就覆盖先写的。

万一发生了异常,其中一个事务(就假设是最后一个写入数据的事务)需要回滚数据,那么在其之前未提交的事务写入的数据就像没写过一样,但是他们都执行成功了,却没有留下一丝痕迹(至少最后的数据是最后未回滚事务的插入的吧)。

另一个会发生问题的情况是在多对象写入的情况下(第一列是主键): 1.(1,a,a),(2,a,b),(3,b,c) 2.(1,a,x),(2,a,y),(3,b,z) 上述两组数据假设在两个事务中同时进行update,结果可能不是纯粹的1或2,而有可能是两组数据的"杂交",这在一些写入关联数据的场景下是不可接受的。

  • 解决脏写 行锁。对要修改的对象加互斥锁,若是对象已加锁,则等待,直到锁被释放。

  • 解决脏读

    • 加锁。加上读写锁,写锁定,读共享。获取读资源需要原先没有锁或者只有读锁,获取写资源需要没有锁。但是在某些情况下,一个长时间的批量写入会锁住资源,让某些只读事务长期等待。
    • 记录写入前的快照。在这个事务完成前,所有的读取都从历史快照中获取。

读已提交是一个非常流行的隔离级别。这是Oracle 11g,PostgreSQL,SQL Server 2012,MemSQL和其他许多数据库的默认设置。

其实读已提交已经解决了很大一部分并发问题,他保证了事务的原子性,保证事务只能被已经成功的事务影响,让失败和正在运行的事务不至于侵入其他事务中。

但是还是会存在问题,一个经典的例子:转账。小A把自己的银行X全部的钱500元转到尚未存款的银行Y。显然小A作为观察者,他和银行X,Y都会对500元存款进行读或者写,当然对于转账这个操作而言,银行必然要保证数据的一致性,不会给你转丢,最多转失败,但小A的观察结果在不同的时间节点上却呈现了不同的结果,假设转账成功,有以下三种情况:

  1. X银行App上在转账中,所以还是500元,切出去看Y银行存款,发现Y银行500元到账了,这时候X+Y的存款数就到500+500=1000
  2. 先打开Y银行,没钱,在操作X银行转账,转账成功,刷新银行X,发现余额为0,这时候X+Y的存款数就只有0+0=0
  3. 正常操作,收到手机短信或提示转账成功后,统一再去查看两个银行的存款,发现 0+500,刚好500

在上述例子中,因为一次查询在转账前,一次在转账后,会出现前好像多了或者少了的情况,本质上是由于查询时机的问题,简答来说就是读取了事务发生前和事务发生后的数据,对这些状态不一致的数据处理得出的结果也必然是有偏差的。当然之后再次查询,就是会得到一致的数据,所以仅仅控制事务提交与否,可以满足部分场景,但是在别的一些场景下就是会得到一些让人误解的数据了。

比如:备份、分析查询和完整性检查

在数据库层面上来说,就是每个事务开始到结束,需要读取到的数据不变;具体而言就是每个事务只能看到自己开始事务之前,已经提交的其他事务对数据库的影响,在自己事务开启后,但还未提交的其他事务应该不能让自己看到。

快照隔离是一个很有意思的解决方法:每个事务中的读取不直接读取数据库,都从一致性快照中读取。即使数据后面发生了删除,修改,并成功提交了,也是去读之前的快照内容。

  • 解决脏写。 和读已提交一样,加入写锁,阻塞写相同对象的写操作。

  • 解决幻读。

    MVCC(多版本并发控制)

    • 比较直观的解释就是 对于一个事务A中修改的数据,直接修改原有数据,而是创建一条新数据,创建一条指向原数据的引用,来应对其他事务可能的读取。

    • 多版本体现在哪里? 对于事务A而言,他最少有多少个版本,取决于他提交过程中有多少个其他的事务在“观察”他。 //todo 画图

    • 和读已提交实现的区别? 若是读已提交也是用快照实现的话,区别就在于读已提交的快照数据在数据提交或是回滚之后,旧版本的快照数据就失效了,而可重复读不行,他需要当前全局占用观测最old的事务id已经比其孩子新(孩子比自己新,所有的读取都会读孩子)才能说已经没用了。

    • 实现

      以innodb为例,简单来说,每条数据都会附有两个字段,创建这条数据的事务id,删除这条数据的事务id(逻辑时间,后续会用时间代指事务id),父级。
      以RR隔离级别为例
      select: 会查询比创建时间比当前事务小,且(未被删除,就是删除时间为空或 没看到被删除,就是删除时间大于当前事务id)的所有最新的记录(相同一条存在多条历史快照记录,只去最新的,用parent关联)。
      update:假设只命中一条,对于老数据,则会删除时间设为当前事务id,与此同时会新建一条记录,创建时间设置为当前事务id,且父级指向旧数据。
      insert:插入一条数据,设置创建时间为当前事务id。

参考资料:

主要参考
DDIA