数据库的事务 Recommended
10月 22, 2021
请思考一个我在工作中遇到的一个场景:假设用户参加了一个优惠活动,买了一个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) #
一个事务读取了另一个并行未提交事务写入的数据。
如何解决:
- 加 X 锁。
- 提高隔离级别到 RC,MVCC 会读取被锁定行的最新一份快照数据。
不可重复读(NonRepeatable Read) #
一个事务重新读取之前读取过的数据,发现该数据已经被另一个事务(在初始读之后提交)修改。
如何解决:
- 加 X 锁
- 提高隔离级别 RR,MVCC 会读取事务开始时的快照数据。
幻读(Phantom Read) #
一个事务重新执行一个返回符合一个搜索条件的行集合的查询, 发现满足条件的行集合因为另一个最近提交的事务而发生了改变。不可重复读重点在于update 和 delete,而幻读的重点在于 insert。
如何解决:
- 加 Gap Lock 或 Next-key Lock,不能完全解决,接着往下看。
- 提高事务隔离级别到可串行化,读加共享锁,写加排他锁,读写互斥。
丢失更新 #
两个事务同时执行 读-修改-写入 操作序列,出现了其中一个覆盖了另一个的写入,但是没有包含对方最新值的情况,导致了被覆盖的数据发生了更新丢失。
如何解决:
- 业务层避免这种场景,寻找替代方式。
- 业务层的计算逻辑尝试放到 SQL 里面。
- 通过乐观锁的版本号校验机制发现错误,业务层添加重试和校验。
写倾斜 #
事务先查询数据库,根据返回的结果而作出某些决定,然后修改数据库。在事务提交的时候,支持决定的条件不再成立。写倾斜是幻读的一种情况,是由于读-写事务冲突导致的幻读。写倾斜也可以看做一种更广义的更新丢失问题。即如果两个事务读取同一组对象,然后更新其中的一部分:不同的事务更新不同的对象,可能发生写倾斜;不同的事务更新同一个对象,则可能发生脏写或者更新丢失。
如何解决:
- 提升隔离级别。
- 增加业务代码重试和校验。