7.重新认识事务

2022-09-26  本文已影响0人  鸿雁长飞光不度

事务不是凭空产生的,是通过技术手段实现的,目的是为了简化应用层编程模型,屏蔽内部潜在的错误和复杂的并发问题,由数据库提供安全保证。哪些系统都需要事务呢?回答这个问题需要理解事务提供的安全保证和不足,事务虽然对很多人来说不陌生,但是有很多微妙的细节需要考虑,值得仔细研究。

1. 深入理解事务

很多关系型数据库(mysql、postgre)都普遍支持事务,后来非关系数据库兴起,旨在通过新的数据模型,以及内置的复制和分区(这是第5、6章的内容)来改善传统的关系模型,很多新一代的数据库都放弃了事务的保证或者进行了重新定义,提供比以前弱的保证,大规模系统为了性能和高可用性不得不放弃了对事务的支持。(elasticsearch、redis)

1.1 ACID含义

1.1.1 A(原子性)
1.1.2 C(一致性)
1.1.3 I(隔离性)
1.1.4 D (持久性)

1.2 单对象和多对象事务操作

用户可以邮件列表提供未读邮件数量功能,对未读数量做了冗余,事务的原子性隔离性能保证用户看到的始终是一致的,不会出现有未读邮件但是数量是0的问题。

单对象操作也支持原子性,如果需要写入20k的json数据到数据库里面,不会写到10k的时候失败了,导致只有部分写入成功。有些数据库提供了高级原子操作,比如自增、CAS,一般称为称为轻量级事务

一般认为事务是针对多个对象,将多个操作聚合为一个执行单元。

许多分布式数据系统不支持多对象事务,主要是跨分区时难以实现,在高可用或者极致性能场景会带来负面影响。

事务的关键特性是如果发生了意外则完全放弃,而不是部分放弃,这样可以方便的重试。并不是所有的系统都遵循这个理念,在无主节点复制的存储系统,在出错的时候会尽可能多的重试,并不会撤销已完成的工作,恢复工作需要应用程序完成。

很多框架在事务出错后会直接抛出堆栈信息,但之前所有的输入都会被抛弃,但是事务重试也会有问题。

  1. 事务实际执行成功的,但是网络原因返回失败,重试会导致重复执行。
  2. 系统负荷引起的失败,重试会让系统更加糟糕。
  3. 临时故障引起的失败,比如死锁、网络抖动有意义,但永久故障比如违背了数据库约束,重试没有意义。
  4. 重试可能导致业务方逻辑被多次重试执行。

2.弱隔离级别

如果两个事务执行不存在依赖关系,则可能并发执行,如果修改数据是相同的数据,会带来安全隐患,所以数据库一直通过隔离界别来对应用程序隐藏内部的并发问题,但是隔离并不是非常简单的,串行化隔离级别会有严重性能问题,所以一般不用,下面常用隔离级别的介绍。

2.1 Read Committed(已提交读)

2.1.1 实现的功能

这是最基本的隔离级别,提供了两个保证

  1. 读数据库时,只能看到已经提交的数据。(防止脏读)

未提交事务的数据其他事务看不见,这个很容易理解。

  1. 写数据库时,只会覆盖已成功提交的数据。(防止脏写)

两个事务并发修改同一个数据,会推迟第二个事务的更新,直到前面的事务完成。

一个事务涉及多个对象,比如抢购商品场景,更新买家信息,创建收据信息,防止脏写,可以在并发场景下不会出现买家信息和收据上买家信息不一致。

但是不能解决计数器增量问题(先查询原来的数量,然后加一,写数据库),虽然第二个事务依然是第一个执行后写入的,但是结果还是错误的,这不属于脏写,属于数据丢失场景.

2.1.2 实现方式

已提交读非常留下,在Postgre、Oracle、SQL Server是默认的配置。

防止脏写:通过对对象加锁实现,更新时必须先获取到锁,未获取到的事务会等待,时已提交度或者更高隔离界别在数据库内部自己实现的。

防止脏写:维护旧值和当前事务要设置的值两个版本。不采用锁,因为性能问题比较大。

2.2 repeatable read(重复读)

2.1 已提交的不足之处

已提交读不能解决不可重复读的问题,事务开始时读取了一个值,但是会再次读取,中间可能被其他事务修改过并已经提交了,两次读取的内容不一样,比如下面这个例子在转账期间可能看到两个账号加起来的钱是900.

image.png

虽然再次刷新余额总数会正常,但是有些场景这种是不可接收的,这里产生了读倾斜(不可重复读)

  1. 数据库备份:需要数小时才完成,镜像可能包含部分新数据、部分旧数据。
  2. 分析查询和完整性检查:可能会扫描大半个数据库,会导致结果不准确。

所以需要更强的隔离级别:可重读读

2.2 快照级别隔离实现

同样是通过锁的方式实现脏写,对于读采用了MVCC机制。在已提交读隔离级别下,每一个不同的查询单独创建一个快照,快照隔离级别是使用一个快照来运行整个事务。

一致性快照度可见性条件,需要同时满足:

索引和快照级别隔离

2.3 防止更新丢失

两个不同的事务都读取原始数据,修改、重新写入,由于第二个事务并不包含第一个事务的修改内容,提交的修改会丢失第一个事务的改动,比如:

2.3.1 解决方案

对于多副本的数据库,多个节点会并发修改数据,需要单独考虑。

2.4 写倾斜与幻读

2.4.1 写倾斜:

场景:两名医生至少有一人值班,如果两个人同时点了调休按钮,产生两个事务,事务开始检查当前休假医生数,发现是0,进入下一阶段,然后更新状态,都提交成功了,这不是脏写、也不是更新丢失,这种异常被称为写倾斜。

2.4.2 写倾斜产生原因
  1. 先查询数据库找出满足条件的行。
    2.根据查询结果进行下一步操作,可能继续也可能报错终止。
    3.如果继续会发起对数据库的update、insert、delete操作,但是3的执行会第二步做出决定的条件。
2.4.3 解决写倾斜的方案:
select * from doctors where status = on_call for update;
2.4.2 其他写倾斜的例子
  1. 会议室预定系统。一个会议室在同一个时间段内不能被重复预定,快照隔离级别无法阻止用户并发预定。(postgre的范围类型可以完成,其他数据库不行)
  2. 多人游戏。可以通过加锁解决更新丢失问题,即玩家不能同时移动同一个数字。但是锁不能防止玩家将两个不同的数字移动到同一个位置。
  3. 声明一个用户名。网站要求用户提供的用户名不同,事务隔离级别在这里不起作用,可以创建唯一约束解决。
  4. 防止双重开支,支付或者积分系统检查用户花费不能超过限额,并发消费时单个消费不超过限额,但是加起来会超。

这些都需要串行执行,可以用redis提前加锁,比如加锁主体可以是会议室id、账户id、棋盘的位置、正在创建的用户名(没有必要,数据库约束已经足够),通过加锁实现了串行执行,但是也不是最优的,比如会议室不同的阶段其实是可以并发预定的、账户扣钱有限额,但是如果充钱其实是可以不收消费锁的限制的,要考虑锁的粒度和业务场景要求。除了redis锁,如果查询的条件是有符合条件的数据,比如上面的医生值班系统,可以用数据库的select for update,但是对于其他的例子不适合,因为其他例子是预期为空,然后写入数据,一个事务写入的数据对其他事务的原来查询造成影响就是幻读,可重复读隔离级别能够解决幻读问题,但是对幻读引发的写倾斜无能为力。

实体化冲突:

对于预期为空的条件无法select for update,必要的时候可以考虑创建一些记录,方便加锁,比如对于会议预定系统,提前创建好未来几个月的会议室-时间表,然后通过预定条件对具体的记录执行select for update加锁,这种方式容易出错,富有挑战性,不到万不得已不使用

3.串行化

事务提供弱隔离级别能够解决一些并发问题,但是对读倾斜写倾斜幻读带来的问题很棘手,需要串行化,串行化保证多个事务在并发执行的情况下和单个逐个执行的结果一样。串行化实现会有严重性能问题,所以不会所有场景都用。实现串行化的方案有以下三种

3.1 实际串行执行

在一个线程上依照顺序逐个执行事务,这个直白的想法是2007年才被认为是可行的,因为过去一直是多线程提高性能,转向单线程执行的考量如下:

  1. 内存便宜了,许多应用可以将活动数据可以被完全加载到内存中,事务执行速度比磁盘等待速度快很多。
    2.OLTP通常执行很快,只产生少量的读写操作,而运行时间较长的分析查询通常是只读的,可以在可重复读隔离级别下执行,不需要串行化。(redis就是单线程执行,串行执行事务的)
3.1.1采用存储过程封装事务

数据库早期,事务操作设计希望包括用户的所有的操作序列,有查询操作,根据查询操作做不同的写入操作,这种属于交互式的事务处理,交互式的事务处理大量时间花费在应用程序和数据库之间的通信上,如果不并发会导致吞吐量低。单线程执行的数据库不支持交互式多语句事务,只支持一次性批量提交事务的存储过程,统一执行。

image.png
3.1.2 分区

串行执行可以更好的控制并发,但是对于高写入需求的应用容易成为瓶颈,为了扩展多个CPU和多个节点可以对数据进行分区,每个分区有单独的线程执行事务,但是在跨越分区的时候必须对所有分区相关数据加锁,保证串行化,性能会下降很多,且无法增加CPU提高性能。事务是否能只在单分区上执行很大程度上取决于应用层的数据结构。简单的键值数据比较容易切分,而带有多个二级索引的数据则需要大量的跨区协调.

3.1.3 串行执行场景
  1. 事务必须简短,否则一个慢事务会影响其他事务执行。
  2. 仅限数据完全可以加载到内存场景。有些访问很少的数据如果被放在磁盘,在读取的时候会有严重性能问题。
  3. 写入吞吐量足够低,才能在单个CPU上执行。否则需要分区,最好没有跨分区事务或者跨分区事务比重很小。

3.2两阶段加锁

最广泛的串行化算法,在MySQL (InnoDB)和SQL Server中的“可串行化隔离”级别使用。两阶段加锁强制性比防止脏写更高。

因此2PL不仅在井发写操作之间互斥,读取也会和修改产生互斥.

容易产生死锁、需要有检测机制,多个事务等待时间不确定,即使每个事务执行时间很短,多个并发执行等待时间会很长,导致大量的失败。

串行化也要解决幻读的问题

上一篇 下一篇

猜你喜欢

热点阅读