MySQL高阶:事务和并发

事务和并发

  • [1. 事务](#1. 事务)
  • [2. 并发和锁定](#2. 并发和锁定)
  • [3. 事务隔离等级](#3. 事务隔离等级)
    • [3.1 读取未提交隔离级别](#3.1 读取未提交隔离级别)
    • [3.2 读取已提交隔离级别](#3.2 读取已提交隔离级别)
    • [3.3 重复读取隔离级别](#3.3 重复读取隔离级别)
    • [3.4 序列化隔离级别](#3.4 序列化隔离级别)
  • [4. 死锁](#4. 死锁)

1. 事务

事务(trasaction)是完成一个完整事件的一系列SQL语句。这一组SQL语句是一条船上的蚂蚱,要不然都成功,要不然都失败。如果一部分执行成功一部分执行失败,那成功的那一部分就会复原(revert)以保持数据的一致性。例如:银行交易:你给朋友转账包含从你账户转出和往他账户转入两个步骤,两步必须同时成功,如果转出成功但转入不成功则转出的金额会返还。

事务有四大特性,总结为 ACID:

  1. Atomicity 原子性,即整体性,不可拆分性(unbreakable),所有语句必须都执行成功事务才算完成,否则只要有语句执行失败,已执行的语句也会被复原。
  2. Consistency 一致性,指的是通过事务我们的数据库将永远保持一致性状态,比如不会出现没有完整订单项目的订单。
  3. Isolation 隔离性,指事务间是相互隔离互不影响的,尤其是需要访问相同数据时。具体而言,如果多个事务要修改相同数据,该数据会被锁定,每次只能被一个事务有权修改,其它事务必须等这个事务执行结束后才能进行。
  4. Durability 持久性,指的是一旦事务执行完毕,这种修改就是永久的,任何停电或系统死机都不会影响这种数据修改。

创建事务

语法规则:用 START TRANSACTION 来开始创建事务,用 COMMIT 来关闭事务(这是两个单独的语句)。

sql 复制代码
START TRANSACTION;
INSERT INTO orders(customer_id, order_date, status)
VALUES(1, '2019-01-01', 1);
-- 主键不需要插入
INSERT INTO order_items
VALUES(last_insert_id(), 1, 2, 3);
COMMIT;
  • 如果有一般执行失败,将全部退回。如果采用'ctrl+enter'进行当前行运行,即使跳过某些行执行,只要执行了COMMIT语句,事务中已经执行的也不会退回。

  • 多数时候是用上面的 START TRANSACTION; + COMMIT; 来创建事务,但当我们想先对事务里语句进行测试/错误检查并因此想在执行结束后手动退回时,可以将最后的 COMMIT; 换成 ROLLBACK;,这会退回事务并撤销所有的更改,完整执行不会有任何变化。

  • 我们执行的每一个语句(可以是增删查改 SELECT、INSERT、UPDATE 或 DELETE 语句),就算没有 STARTTRANSACTION + COMMIT,也都会被 MySQL 包装(wrap)成事务并在没有错误的前提下自动提交,这个过程一个叫做 autocommit 的系统变量控制,默认开启。因为有 autocommit 的存在,当事务只有一个语句时,用不用 START TRANSACTION + COMMIT 都一样,但要将多个语句作为一个事务时就必须要加 START TRANSACTION + COMMIT 来手动包装了。

sql 复制代码
SHOW VARIABLES LIKE 'autocommit';

2. 并发和锁定

现实中常出现多个用户访问相同数据的情况,这被称为"并发"(concurrency),当一个用户企图修改另一个用户正在检索或修改的数据时,并发会成为一个问题。

实例:在不同会话下执行相同的代码。(同一个数据库下的不同连接会话)

sql 复制代码
START TRANSACTION;
UPDATE customers
SET points = points + 10
WHERE customer_id = 1;
COMMIT;
  • 现在有两个会话(注意是两个连接(connection),而不是同一个会话下的两个SQL标签,这两个连接相当于是在模拟两个用户)都要执行这段语句,用 Ctrl+Enter 逐句执行, 当第一个执行到 UPDATE 而还没有 COMMIT提交时,转到第二个会话,执行到UPDATE语句时会出现旋转指针表示在等待执行(若等的时间太久会超时而放弃执行),这时跳回第一个对话 COMMIT 提交,第二个会话的 UDDATE 才不再转圈而得以执行,最后将第二段对话的事务也COMMIT提交,此时刷新顾客表会发现1号顾客的积分多了20分。

所以,可以看到,当一个事务修改一行或多行时,会给这些行上锁,这些锁会阻止其他事务修改这些行,直到前一个事务完成(不管是提交还是退回)为止,由于上述MySQL默认状态下的锁定行为,多数时候不需要担心并发问题,但在一些特殊情况下,默认行为不足以满足你应用里的特定场景,这时你可以修改默认行为,这是我们接下来会学习的。

并发问题

  1. 丢失更新 Lost updates
    例如,当事务A要更新john的所在州而事务B要更新john的积分时,若两个事务都读取了john的记录,在A跟新了州且尚未提交时,B更新了积分,那后执行的B的更新会覆盖先执行的A的更新,州的更新将会丢失。
    解决方法就是前面说的锁定机制,锁定会防止多个事务同时更新同一条数据,必须一个完成的再执行另一个.
  2. 脏读Dirty Reads
    例如,事务A将某顾客的积分从10分增加为20分,但在提交前就被事务B读取了,事务B按照这个尚未提交的顾客积分确定了折扣数额,可之后事务A被退回了,所以该顾客的积分其实仍然是10分,因此事务B等于是读取了一个数据库中从未提交的数据并以此做决定,这被称作为脏读.
    解决办法是设定事务的隔离等级,例如让一个事务无法看见其它事务尚未提交的更新数据,这个下节课会学习。标准SQL有四个隔离等级,比如,我们可以把事务B设为 READ COMMMITED 等级,它将只能读取提交后的数据积分。提交完之后,B事务依次做决定,如果之后积分再修改,这就不是我们考虑的问题了,我们只需要保证B事务读取的是提交后的数据就行了.
  3. 不可重复读取Non-repeating Reads
    上面的隔离能保证只读取提交过的数据,但有时会发生一个事务读取同一个数据两次但两次结果不一致的情况. 例如,事务A的语句里需要读取两次某顾客的积分数据,读取第一次时是10分,此时事务B把该积分更新为0分并提交,然后事务A第二次读取积分为0分,这就发生了不可重复读取或不一致读取.
    1. 一种说法是,我们应该总是依照最新的数据做决定,所以这不是个问题。在商务场景中,我们一般不用担心这个问题.
    2. 另一种说法是,我们应该保持数据一致性,以事务A在开始执行时的数据初始状态为依据来做决定,如果这是我们想要的话,就要增加事务A的隔离等级,让它在执行过程中看不见其它事务的数据更改(即便是提交过的),SQL有个标准隔离等级叫 Repeatable Read 可重复读取,可以保证读取的数据是可重复和一致的,无论过程中其它事务对数据做了何种更改,读取到的都是数据的初始状态.
  4. 幻读Phantom Reads
    例如,事务A要查询所有积分超过10的顾客并向他们发送带折扣码的E-mail,查询后执行结束前,事务B更新了(可能时增删改)数据,然后多了一个满足条件的顾客,事务A执行结束后就会有这么一个满足条件的顾客没有收到折扣码,这就是幻读,Phantom是幽灵的意思,这种突然出现的数据就像幽灵一样,我们在查询中错过了它因为它是在我们查询语句后才更新。
    解决办法取决于想解决的商业问题具体是什么样的,以及把这个顾客包括进事务中有多重要,我们总可以再次执行事务A来让这顾客包含进去。但如果确保我们总是包含了最新的所有满足条件的顾客是至关重要的,我们就要保证查询过程中没有任何其他可能影响查询结果的事务在进行,为此,我们建立另一个隔离等级叫 Serializable 序列化,它让事务能够知晓是否有其它事务正在进行可能影响查询结果的数据更改,并会等待这些事务执行完毕后再执行,这是最高的隔离等级,为我们提供了最高的操作确定性。但 Serializable 序列化 等级是有代价的,当用户和并发增加时,等待的时间会变长,系统会变慢,所以这个隔离等级会影响性能和可扩展性,出于这个原因,我们只有在避免幻读确实必要的情形下才使用这个隔离等级。

四个并发问题:

  1. Lost Updates 丢失更新:两个事务更新同一行,最后提交的事务将覆盖先前所做的更改。
  2. Dirty Reads 脏读:读取了未提交的数据。
  3. Non-repeating Reads 不可重复读取 (或 Inconsistent Read 不一致读取):在事务中读取了相同的数据两次,但得到了不同的结果。
  4. Phantom Reads 幻读:在查询中缺失了一行或多行,因为另一个事务正在修改数据而我们没有意识到事务的修改,我们就像遇见了鬼或者幽灵。

3. 事务隔离等级

为了解决4个并发问题,利用四个标准的事务隔离等级:

  1. Read Uncommitted 读取未提交:无法解决任何一个问题,因为事务间并没有任何隔离,他们甚至可以读取彼此未提交的更改
  2. Read Committed 读取已提交:给予事务一定的隔离,这样我们只能读取已提交的数据,这防止了Dirty Reads脏读,但在这个级别下,事务仍可能读取同个内容两次而得到不同的结果,因为另一个事务可能在两次读取之间更新并提交了数据,也就是它不能防止Non-repeating Reads 不可重复读取 (或 Inconsistent Read 不一致读取)。
  3. Repeatable Read 可重复读取:在这一级别下,我们可以确信不同的读取会返回相同的结果,即便数据在这期间被更改和提交。
  4. Serializable 序列化:可以防止以上所有问题,这一级别还能防止幻读,如果数据在我们执行过程中改变了,我们的事务会等待以获取最新的数据,但这很明显会给服务器增加负担,因为管理等待的事务需要消耗额外的储存和CPU资源。
  • 并发问题 VS 性能和可扩展性:
  1. 更低的隔离级别更容易并发,会有更多用户能在相同时间接触到相同数据,但也因此会有更多的并发问题,另一方面因为用以隔离的锁定更少,性能会更高;相反,更高的隔离等级限制了并发并减少了并发问题,但代价是性能和可扩展性的降低,因为我们需要更多的锁定和资源。
  2. MySQL的默认等级是 Repeatable Read 可重复读取,它可以防止除幻读外的所有并发问题并且比序列化更快,多数情况下应该保持这个默认等级。
  3. 如果对于某个特定的事务,防止幻读至关重要,可以改为 Serializable 序列化。
  4. 对于某些对数据一致性要求不高的批量报告或者对于数据很少更新的情况,同时又想获得更好性能时,可考虑前两种等级。
    总的来说,一般保持默认隔离等级,只在特别需要时才做改变
  • 设定隔离等级的方法:
sql 复制代码
SHOW VARIABLES LIKE 'transaction_isolation';
-- transaction isolation 事务隔离等级,系统默认为REPEATABLE_READ
SET [SESSION]/[GLOBAL] TRANSACTION ISOLATION LEVEL SERIALIZABLE;
  • 不加 SESSION/GLOBAL 则默认设定的是本次会话的下一次事务的隔离等级。
  • 加上 SESSION 就是设置本次会话(连接)之后所有事务的隔离等级。
  • 加上 GLOBAL 就是设置之后所有对话的所有事务的隔离等级。

如果你是个应用开发人员,你的应用内有一个功能或函数可以连接数据库来执行某一事务(可能是利用对象关系映射或是直接连接MySQL),你就可以连接数据库,用 SESSION 关键词设置本次连接的事务的隔离等级,然后执行事务,最后断开连接,这样数据库的其它事务就不会受影响。

3.1 读取未提交隔离级别

建立两个连接1和2来模拟不同的用户,分别执行不同语句。

连接1:

sql 复制代码
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- 单个语句,创建事务代码可以省略 START TRANSACTION COMMIT
SELECT points
FROM customers
WHERE customer_id = 1;

连接2:

sql 复制代码
START TRANSACTION;
UPDATE customers
SET points = 20
WHERE customer_id = 1;
ROLLBACK;  
-- ROLLBACK也算提交,只是效果是撤回
  • 执行步骤:先执行到连接1的设定隔离等级,然后执行到连接2的更新数据(不提交),然后执行连接1的查询语句,然后设定连接2的数据更新ROLLBACK,则连接1查询到的数据为更新数据,不是真实数据,实际数据未更新被撤销了。

3.2 读取已提交隔离级别

Read Committed 读取已提交 等级只会读取别人已提交的数据,所以不会发生脏读,但因为能够读取到执行过程中别人已提交的更改,所以还是会发生不可重复读取(不一致读取)的问题。

读取过程会发生数据更改但是仍然读取情况,利用READ COMMITTED隔离级别即可解决。但是多次读取同一数据过程中,会发生前后读取数据数值不一致情况,这种情况用READ COMMITTED解决不了问题。

连接1:

sql 复制代码
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- 多个语句,创建事务代码不能省略
SELECT points FROM customers WHERE customer_id = 1;
SELECT points FROM customers WHERE customer_id = 1;
COMMIT;

连接2:

sql 复制代码
START TRANSACTION;
UPDATE customers
SET points = 20
WHERE customer_id = 1;
COMMIT;
  • 执行步骤:启动事务,执行第一次查询,得到分数为2293 → 执行连接2的UPDATE 语句并提交 → 再执行连接1的第二次查询,得到分数为20,同一个事务里的两次查询得到不同的结果,发生了 Non-repeating Reads 不可重复读取 (或 Inconsistent Read 不一致读取)。

3.3 重复读取隔离级别

REPEATABLE READ隔离等级可以解决数据读取的不一致性,即使数据发生了更改,再同一事务的多次读取过程中,仍然采用第一次读取的值来赋值后面的查询,从而保证数据的一致性。该隔离等级也是系统默认的隔离等级,但是解决不了幻读的问题。

只需要将上节的代码中事务的隔离等级修改为:REPEATABLE READ即可。

同一个事务内读取会始终保持一致性,但因为可能会忽视正在进行但未提交的,可能影响查询结果的更改而漏掉一些结果,即发生幻读。

3.4 序列化隔离级别

会发生幻读的问题:

用户1:

sql 复制代码
USE sql_store;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM customers WHERE state = 'VA';

用户2:

sql 复制代码
UPDATE customers
SET state = 'VA'
WHERE customer_id = 1;

执行步骤:

  1. 用户2现在正要将1号顾客也改为VA州,已执行UPDATE语句但还没有提交,所以这个更改技术上讲还在内存里;
  2. 此时用户1查询身处VA州的顾客,只会查到2号顾客;
  3. 用户2提交更改;
  4. 若1号用户未提交,再执行一次事务中的查询语句会还是只有2号顾客,因为在 REPEATABLE READ 可重复读取;隔离级别,我们的读取会保持一致性;
  5. 若1号用户提交后再执行一次查询,会得到1号和2号两个顾客的结果,我们之前的查询遗漏了2号顾客,这被称作为幻读。
  • 简单讲就是在这一等级下1号用户的事务只顾读取当前已提交的数据,不能察觉现在正在进行但还未提交的可能对查询结果造成影响的更改,导致遗漏这些新的"幽灵"结果(但一般遗漏这些幽灵结果问题)。

解决方法:将用户1中的事务隔离等级设置为SERIALIZABLE,会检查再内存中的数值。

执行步骤:

  1. 用户2现在正要将1号顾客也改为VA州,已执行UPDATE语句但还没有提交,所以这个更改技术上讲还在内存里
  2. 此时用户1查询身处VA州的顾客,会察觉到用户2的事务正在进行,因而会出现旋转指针等待用户2的完成
  3. 用户2提交更改
  4. 用户1的查询语句执行并返回最新结果:顾客1和顾客2

SERIALIZABLE序列化是最高隔离等级,它等于是把系统变成了一个单用户系统,事务只能一个接一个依次进行,所以所有并发问题(更新丢失、脏读、不一致读取、幻读)都从根本上解决了,但用户和事务越多等待时间就会越漫长,所以,只有对那些避免幻读至关重要的事务使用这个隔离等级。默认的可重复读取等级对大多数场景都有效,最好保持这个默认等级,除非你知道你在干什么(Stick to that, unless you know what you are doing)。

4. 死锁

不管什么隔离等级,事务里的增删改语句都会锁定相关行,如果两个同时在进行的事务分别锁定了对方下一步要使用的行,就会发生死锁,死锁不能完全避免但有一些方法能减少其发生的可能性。

用户1:

sql 复制代码
START TRANSACTION;
UPDATE customers SET state = 'VA' WHERE customer_id = 1;
UPDATE orders SET status = 1 WHERE order_id = 1; 
COMMIT;

用户2:

sql 复制代码
START TRANSACTION;
UPDATE orders SET status = 1 WHERE order_id = 1; 
UPDATE customers SET state = 'VA' WHERE customer_id = 1;
COMMIT;

执行步骤:用户1和2均执行完各自的第一个更改;用户2执行第二个更改,出现旋转指针;用户1执行第二个更改,出现死锁,报错:Error Code: 1213. Deadlock found ......

  • 解决方法:
    死锁如果只是偶尔发生一般不是什么问题,重新尝试或提醒用户重新尝试即可,死锁不可能完全避免,但有一些方法可以最小化其发生的概率:
    1. 注意语句顺序:如果检测到两个事务总是发生死锁,检查它们的代码,这些事务可能是储存过程的一部分,看一下事务里的语句顺序,如果这些事务以相反的顺序更新记录,就很可能出现死锁,为了减少死锁,我们在更新多条记录时可以遵循相同的顺序
    2. 尽量让你的事务小一些,持续时间短一些,这样就不太容易和其他事务相冲突
    3. 如果你的事务要操作非常大的表,运行时间可能会很长,冲突的风险就会很高,看看能不能让这样的事务避开高峰期运行,以避开大量活跃用户
相关推荐
远歌已逝2 小时前
维护在线重做日志(二)
数据库·oracle
qq_433099403 小时前
Ubuntu20.04从零安装IsaacSim/IsaacLab
数据库
Dlwyz3 小时前
redis-击穿、穿透、雪崩
数据库·redis·缓存
工业甲酰苯胺5 小时前
Redis性能优化的18招
数据库·redis·性能优化
没书读了6 小时前
ssm框架-spring-spring声明式事务
java·数据库·spring
i道i7 小时前
MySQL win安装 和 pymysql使用示例
数据库·mysql
小怪兽ysl7 小时前
【PostgreSQL使用pg_filedump工具解析数据文件以恢复数据】
数据库·postgresql
wqq_9922502777 小时前
springboot基于微信小程序的食堂预约点餐系统
数据库·微信小程序·小程序
爱上口袋的天空7 小时前
09 - Clickhouse的SQL操作
数据库·sql·clickhouse
Oak Zhang8 小时前
sharding-jdbc自定义分片算法,表对应关系存储在mysql中,缓存到redis或者本地
redis·mysql·缓存