数据库的事务

数据库的事务

Oct 22, 2021
数据库, MySQL

请思考一个我在工作中遇到的一个场景:假设用户参加了一个优惠活动,买了一个30 天的会员卡,然后活动又送了一个 7 天的体验卡,最终我要交付给用户 37 天的会员权益时间。如果这两笔订单同时请求系统进行交付,那如何能够一直正确的交付 37天,并正确计算各订单的开始结束时间?(公司 MySQL 的隔离级别是 RC)

进阶问题:用户在下单之前已经是生效的会员了,又该如何解决?

此时我们必须要借助事务才能解决这种问题。那么事务是什么?我理解事务就是对数据的“一顿操作”,它满足 ACID 四个特点。

事务的特点(ACID) #

原子性(Atomicity) #

一个事务的所有数据操作是一个不可再进行拆分的步骤,要么全部成功,要么全部失败。

MySQL 通过回滚日志(undo log)实现了原子性的特点。事务的每一条数据的修改都对应了一条 undo log,回滚日志除了能够在发生错误或者用户执行 ROLLBACK 时提供回滚相关的信息,它还能够在整个系统发生崩溃、数据库进程直接被杀死后,当用户再次启动数据库进程时,还能够立刻通过查询回滚日志将之前未完成的事务进行回滚,所以事务在提交前会确保 undo log 写到了磁盘上。

一致性(Consistency) #

数据的结果符合应该的预期,比如一个事务给用户充值了 30 天的会员,那么用户最终到账就应该是 30 天,而不是 300 天,另一个事务回收了用户的时间,用户的会员权益就应该归零。这是一个现实中的预期的结果,所以很多时候需要程序员自己将现实与数据结合做一个判断。

隔离性(Isolation) #

每个事务之间的操作是相互隔离的,不会相互影响。就像排队一个一个执行一样。数据库为了解决这个问题,提供了事务隔离级别和 MVCC 来进行并发控制,后面会详细介绍。

持久性(Durability) #

事务提交后,数据就会永久保存下来,无论之后发生什么事故,写入磁盘中的数据都不会丢失。

MySQL 通过重做日志(redo log)实现了事务的持久性。redo log 会把事务在执行过程中的所有修改都记录下来,redo log 会先写到 log buffer 的缓冲区里,log buffer 的大小是有限的,当大小到达一定量时会通过一定频率顺序地写入到磁盘。redo log 占用的磁盘空间在它对应的脏页已经被刷新到磁盘后即可被覆盖。事务在提交之前会确保 redo log 已经写到了磁盘里,这样就算数据库宕机,都可以在恢复时从 redo log 恢复数据。

另外,MySQL 是先记录 redo log,然后再记录 undo log,然后再提交事务。

事务的状态 #

活动的(Active) #

事务正在执行过程中。

部分提交的(Partially Commited) #

事务在内存中的操作已经完成,但是还没有最终写到磁盘上。

失败的(Failed) #

事务由于 SQL 错误、操作系统、网络等原因执行失败了,停止了执行。

中止的(Aborted) #

事务失败之后需要进行回滚,回滚的操作执行完毕后,数据恢复到事务之前的状态,当前事务就进入了中止状态。

提交的(Commited) #

成功执行的事务,数据写到了磁盘上,就说事务处于提交状态。

事务隔离级别 #

不是只要是事务就可以解决所有问题,事务有不同的隔离级别,不同的级别可以解决不同的并发问题。很容易判断出,级别越高,支持的并发能力越差,但是数据的准确性越高。所以有的时候需要根据业务场景来判断需要多高的隔离级别。

读未提交(Read uncommitted) #

事务可以读取到其他事务未提交的数据。

读已提交(Read committed) #

事务只能读到其他事务已提交的数据。假设事务 A 开始执行,读取到的一行记录 R1,然后 R1 被事务 B 更新了,此时事务 A 再次读取 R1,R1 的值就是事务 B 更新后的值。是 PG 的默认隔离级别。

可重复读(Repeatable read) #

事务开始执行后,重复读取到的同一记录的数据,都不会被其他事务修改。是 MySQL 中 InnoDB 默认的隔离级别。

可序列化(Serializable ) #

读加共享锁,写加排他锁,读写互斥。使用的悲观锁的理论,实现简单,数据更加安全,但是并发能力非常差。如果你的业务并发的特别少或者没有并发,同时又要求数据及时可靠的话,可以使用这种模式。

设置隔离级别 #

InnoDB 的设置方法如下:

SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE}
  • SESSION:表示修改的事务隔离级别将应用于当前 session(当前 cmd 窗口)内的所有事务;
  • GLOBAL:表示修改的事务隔离级别将应用于所有 session(全局)中的所有事务,且当前已经存在的 session 不受影响;
  • 如果省略 SESSION 和 GLOBAL,表示修改的事务隔离级别将应用于当前 session 内的下一个还未开始的事务。

查询全局和会话的事务隔离级别:

SELECT @@global.tx_isolation;
SELECT @@session.tx_isolation;

MVCC #

说完隔离级别再插播一条 MVCC 的介绍,也叫多版本并发控制(Multi-Version Concurrency Control),MVCC 的核心就是生成一个 ReadView(也叫一致性视图或快照)。数据库的事务有时会通过一系列不加锁而去读快照数据的方式,达到了不同的隔离界别,MySQL 和 PG 都有不同的 MVCC 实现。

并发常见问题 #

隔离级别脏读不可重复读幻读序列化异常
读未提交允许,但不在 PG 中可能可能可能
读已提交不可能可能可能可能
可重复读不可能不可能允许,但不在 PG 中可能
可序列化不可能不可能不可能不可能

脏写 #

一个事务 A 写入了数据,另一个事务 B 回滚了数据,导致最后事务 A 最终读到的数据没有生效。任何一种隔离级别都不能接受脏写的现象,

脏读(Dirty Read) #

一个事务读取了另一个并行未提交事务写入的数据。

如何解决:

  1. 加 X 锁。
  2. 提高隔离级别到 RC,MVCC 会读取被锁定行的最新一份快照数据。

不可重复读(NonRepeatable Read) #

一个事务重新读取之前读取过的数据,发现该数据已经被另一个事务(在初始读之后提交)修改。

如何解决:

  1. 加 X 锁
  2. 提高隔离级别 RR,MVCC 会读取事务开始时的快照数据。

幻读(Phantom Read) #

一个事务重新执行一个返回符合一个搜索条件的行集合的查询, 发现满足条件的行集合因为另一个最近提交的事务而发生了改变。不可重复读重点在于update 和 delete,而幻读的重点在于 insert。

如何解决:

  1. 加 Gap Lock 或 Next-key Lock,不能完全解决,接着往下看。
  2. 提高事务隔离级别到可串行化,读加共享锁,写加排他锁,读写互斥。

丢失更新 #

两个事务同时执行 读-修改-写入 操作序列,出现了其中一个覆盖了另一个的写入,但是没有包含对方最新值的情况,导致了被覆盖的数据发生了更新丢失。

如何解决:

  1. 业务层避免这种场景,寻找替代方式。
  2. 业务层的计算逻辑尝试放到 SQL 里面。
  3. 通过乐观锁的版本号校验机制发现错误,业务层添加重试和校验。

写倾斜 #

事务先查询数据库,根据返回的结果而作出某些决定,然后修改数据库。在事务提交的时候,支持决定的条件不再成立。写倾斜是幻读的一种情况,是由于读-写事务冲突导致的幻读。写倾斜也可以看做一种更广义的更新丢失问题。即如果两个事务读取同一组对象,然后更新其中的一部分:不同的事务更新不同的对象,可能发生写倾斜;不同的事务更新同一个对象,则可能发生脏写或者更新丢失。

如何解决:

  1. 提升隔离级别。
  2. 增加业务代码重试和校验。
本文共 2814 字,上次修改于 Jan 16, 2024,以 CC 署名-非商业性使用-禁止演绎 4.0 国际 协议进行许可。

相关文章

» 数据库的锁

» 数据库的 join 连接类型

» Vagrant 虚拟机 Ubuntu16.04 安装 MariaDB

» Kafka 和 RabbitMQ 对比

» Redis 的分布式锁使用注意