一、事务是什么

先来看第一个问题:什么是事务(Transaction)?

事务就是执行一组 SQL 语句。这些 SQL 语句就是一条绳上的蚂蚱,要么一起成功(Commit),要么一起失败(RollBack)。

  • 比如用户 A 向用户 B 转账 10 元;
  • 如果 A 先转出了 10 元,但是在 B 收到之前服务器挂了,就会造成 A 的钱没了,B 的钱也没到账;
  • 如果把这两条语句封装成事务的话,就不会出现上述情况,而是整个过程执行失败,退回 A 的钱结束事务。

事务的特性:

  • Atomicity(原子性):像原子一样不可分割;
  • Consistency(一致性):数据库永远保持一致;
  • Isolation(隔离性):事务之间是相互隔离的;
  • Durability(持久性):事务一旦提交,它的修改就是永久的。

这就是 ACID

下面来看一个栗子:

-- 开启一个事务
start transaction;

insert into orders(customer_id, order_date, status)
values (1, '2020-05-10', 1);

insert into order_items
values (last_insert_id(), 1, 1, 1);

-- 提交就代表结束事务了
commit;

-- 回滚事务
rollback;

其中被transactioncommit包裹着的语句就是事务,也可以使用 rollback包裹,这样的话之前的修改就全部失效。

MYSQL 中,一条 SQL 语句默认就是一个事务:

# 每一个 SQL 语句都被设置成自动提交
show variables like 'autocommit';

二、为什么使用隔离级别

然后是第二个问题:为什么会有事务的隔离级别?

既然已经有了事务,转账啥的问题也都挺好的解决了,那隔离级别优势啥玩意?

什么是并发

在了解这个之前我们先来介绍一下什么是并发:

  • 并发就是多个用户访问数据库,同时对相同的数据进行修改带来的问题。
  • 比如用户 A 创建了事务,还没来得及提交,用户 B 抢先提交了,关键是他们修改的是同一个事务,那么到底应该听谁的呢?

1、默认并发处理

那么 MySQL 默认是怎么处理并发的呢?

  • MySQL 默认会让其中一个事务等待另一个事务先执行完成再执行该事务。
  • 他会先将数据锁定,就是给行或者数据加锁,确保在第一个事务执行完成之前,数据不会被其他事务修改。
  • 所以在默认的情况下是不用担心事务的并发问题的。

2、常见并发问题

但是默认并不是万能的,总有一些问题是它解决不来的,接下来我们就来看一下这些问题。

Lost Updates

第一个问题是丢失更新

什么时候会出现?

两个事物试图更新相同的数据而我们不使用锁时,就会出现这种情况。

造成的结果:后面提交的数据会覆盖掉前面的提交。

但是这种情况 MySQL 会默认处理的,即放在队列中依次执行,所以一般我们不用考虑。

Dirty Reads

第二个问题就是脏读

什么时候会出现?

A 事务读取还没有提交时:另一个事务 B 却将 A 的还没有提交的数据当成了真实存在的数据,如果 A 最后提交了还好,如果 A 回滚了,那么 B 中的数据就是假的,也就是脏的,所以叫脏读。

造成的结果:读取了还未提交的数据,数据是脏的。

Non-repeating Reads

第三个问题是不可重复读

什么时候会出现?

事务 A 执行子查询,在外层查询中先获取指定的值,这个时候事务 B 过来修改了 A 刚才读的数据,但是之后 A 的子查询再次读了这个数据而这个数据两次读到的值不一样了,这就是不可重复读。

造成的结果:两次读取的内容可能会不同。

Phantom Reads

第四个问题是幻读

什么时候会出现?

在事务 A 中使用 where 条件查询,此时事务 B 修改了原来符合条件的数据,使得它现在不符合条件了,或者说增加了几个符合条件的数据,但是事务 A 始终读取的都是原来的数据。

造成的结果:丢失符合条件的某些行,就很玄幻,所以是幻读。

三、怎么做才能解决并发问题

那怎么解决这些并发问题呢? 答案是使用隔离级别对事务进行隔离。

该图左边是事务的隔离等级,右边是这个隔离等级可以解决的问题。标准的 SQL 定义了四个隔离级别,他们分别是:

  • Read Uncommitted,不提交读:他什么问题也解决不了;
  • Read Committed,提交读:可以解决脏读,隔离级别是一次读取的范围,所以无法解决一次事务两次读取中间可能出现的问题;
  • Repeatable Read,可重复读:可以解决前三个问题,可以解决大部分的问题 ;
  • Serializable,序列化:可以解决所有问题,他会等待其他事务执行完毕之后再执行。

有一些注意点:

  • MySQL 默认是可重复读的隔离等级,之前我们也提到了这个默认情况。
  • 隔离界别越高,性能损耗越大,所以要权衡利弊选择隔离级别。

    好,我们现在来回顾总结一下:
# 多个用户同时修改同一个数据称为并发, MYSQL 会自动锁定 update 的内容

# 事务带来的常见的问题
/* - 1.丢失更新: 两个事物同时修改数据,后面提交的数据会覆盖掉前面的提交 - 2.脏读(无效数据读取): 读取到未提交的数据 A事务读取还没有提交时-另一个事务B却将A的还没有提交的数据当成了真实存在的,如果A最后提交了还好,如果A回滚了,那么B中的数据就是假的; 即读取了还未提交的数据,数据是脏的; -- 为了解决这个问题,我们可以提供一定的隔离度,即事务修改的数据不会立即被其他事物看到,除非他已经提交了 -- 标准的 SQL 定义了四个隔离级别 - 针对脏读的就是 提交读 (Read Committed) 只能读取已经提交的数据 - 3.不可重复读取: 两次读取的内容可能会不同 事务A执行子查询,在外层查询中先获取指定的值,这个时候事务B过来修改了A刚才读的数据,但是之后A的子查询再次读了这个数据 而这个数据两次读到的值不一样了.这就是不可重复读 -- 这个时候应该增加隔离级别: 可重复读,达到这个目的 -> 以最开始的那个数据为准,不受事务B修改的影响,隔离级别是事务的级别. - 4.幻读: 丢失符合条件的某些行 在事务A中使用 where 查询,此时事务 B 修改了原来符合条件的数据,使得它现在不符合条件了. 需要的隔离等级: 序列化 Serializable 等级,使该事务可以知道其他事物正在修改数据. 这样他就会等待其他事务修改完之后才会执行事务,缺点就是如果并发比较多,执行会很慢 等级越高,性能消耗越大; MySQL默认是可重复读的隔离等级; */

大部分情况下都保持默认,只在特殊情况下修改隔离等级。

如何设置隔离等级?

-- 查看隔离等级
show variables like 'transaction_isolation';

-- 设置隔离等级
set transaction isolation level SERIALIZABLE ;

-- 在当前会话中设置
set session transaction isolation level serializable ;

四、MySQL 中的锁

数据库遵循的是两段锁协议,将事务分为两个阶段:

  • 加锁阶段;
    • 加锁阶段只允许事务加锁,在对任何事务进行 读操作 之前一定要先申请并获得 S锁(共享锁) ,进行 写操作 之前要申请并获得 X锁(排它锁),如果获得失败,即加锁不成功则必须等待,直到加锁成功才继续执行。
  • 解锁阶段;
    • 当事务释放了一个封锁之后,事务进入解锁阶段,该阶段只能进行解锁操作而不能进行加锁操作。

举一个栗子:

MySQL 中的锁分为行锁表锁,表锁通常是将整个的一张表锁住,会降低并发处理能力,所以一般只在 DDL(数据库定义语言) 中使用。这里主要讨论行锁:

1. Read Committed

RC 级别中,数据的查询是不需要加锁的,但是数据的增删改是需要加锁的。

如果事务一直得不到锁则会一直等待,直到 wait 超时。

如果条件语句没有索引,MySQL 会将该表中的所有数据行加行锁。

但是在实际的应用中,会在 MySQL Server 中进行过滤,调用unlock_row 方法,从而将无关的数据项解锁(但是这违背了两段锁协议)。

对一个数据量很大的表做批量修改的时候,如果无法使用相应的索引,MySQL Server 过滤数据的的时候特别慢,就会出现虽然没有修改某些行的数据,但是它们还是被锁住了的现象。

2. Repeatable Read

RR 解决了在一次事务中,两次读取的内容不一致。

那么他是怎么做得到呢?

2.1 不可重复读和幻读的区别

这里穿插出一个知识点,也是大家容易记混的地方。

  • 不可重复读重点在于 updatedelete
  • 而幻读的重点在于 insert

幻读与不可重复读类似,幻读是查询到了另一个事务已提交的新插入数据,而不可重复读是查询到了另一个事务已提交的更新数据。

简单来说,不可重复读是由于数据修改引起的,幻读是由数据插入或者删除引起的。

在可重复读中,该 sql 第一次读取到数据后,就将这些数据加锁,其它事务无法修改这些数据,就可以实现可重复读了。

但这种方法却无法锁住 insert 的数据,所以当事务 A 先前读取了数据,或者修改了全部数据,事务 B 还是可以 insert 数据提交,这时事务 A 就会发现莫名其妙多了一条之前没有的数据,这就是幻读,不能通过行锁来避免。

需要 Serializable 隔离级别 ,读用读锁,写用写锁,读锁和写锁互斥,这么做可以有效的避免幻读、不可重复读、脏读等问题,但会极大的降低数据库的并发能力。

但是 MySQL 使用了以乐观锁为理论基础的MVCC(多版本并发控制)来避免这两种问题。

2.2 悲观锁和乐观锁

悲观锁 指的是对于数据被外界修改持保守态度,在整个数据处理过程中,将数据处于锁定状态,它依赖于数据库底层提供的锁机制,读取数据时加锁,其它事务无法修改这些数据。修改删除数据时也要加锁,其它事务无法读取这些数据。Serializable 就是使用的悲观锁。

乐观锁 采取更加宽松的策略,相对于悲观锁的高性能开销乐观锁性能很好。大多是基于数据版本记录机制实现的。一般就是在数据表中增加一个字段 version 字段,读取数据时将此版本号一并读出,之后每更新一次,版本号加一。然后将提交的数据版本信息与数据表记录的那个版本信息进行比对,如果大于等于则更新原来的版本号,否则认为是过期数据。

很多文章都说 RR 级别是可重复读的,但无法解决幻读,而只有在 Serializable 级别才能解决幻读。但是其实 RR 级别是可以解决幻读的读问题的。这得益于乐观锁的实现,即 MVCC 。

2.3 “读”与“读”的区别

MySQL中的读,和事务隔离级别中的读,是不一样的。

有的时候我们读取到的数据是历史数据,是不及时的,这种我们叫它 快照读 ,而读取数据库当前版本数据的方式叫做 当前读

  • 快照读:就是 select * from table ….
  • 当前读:特殊的读操作,插入/更新/删除操作,属于当前读,处理的都是当前的数据,需要加锁。

事务的隔离级别实际上就是定义了当前读的级别,MySQL 为了减少锁处理(包括等待其它锁)的时间,提升并发能力,引入了快照读的概念,使得select不用加锁。

2.4 Next-Key 锁

MySQL 使用了 Next-Key 锁来解决当前读中的幻读问题(也就是写)。

Next-Key 锁是行锁GAP(间隙锁)的合并。

那么 GAP 锁又是什么呢?

RR 级别中,事务 A 在 update 后加锁,事务 B 无法插入新数据,这样事务 A 在 update 前后读的数据保持一致,就避免了幻读。这个锁,就是Gap锁。

行锁防止别的事务修改或删除,GAP 锁防止别的事务新增,行锁GAP锁 结合形成的的 Next-Key 锁共同解决了 RR 级别在写数据时的幻读问题。

3. Serializable

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

参考资料: