数据库的事务
注意:此文章已超过 1 年未更新,部分信息可能已经过时。
请思考一个我在工作中遇到的一个场景:假设用户参加了一个优惠活动,买了一个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 里面。
- 通过乐观锁的版本号校验机制发现错误,业务层添加重试和校验。
- 通过悲观锁的排他锁实现。
写倾斜
事务先查询数据库,根据返回的结果而作出某些决定,然后修改数据库。在事务提交的时候,支持决定的条件不再成立。写倾斜是幻读的一种情况,是由于读-写事务冲突导致的幻读。写倾斜也可以看做一种更广义的更新丢失问题。即如果两个事务读取同一组对象,然后更新其中的一部分:不同的事务更新不同的对象,可能发生写倾斜;不同的事务更新同一个对象,则可能发生脏写或者更新丢失。
如何解决:
- 提升隔离级别。
- 增加业务代码重试和校验。