CategoryResourceRepost/极客时间专栏/MySQL 必知必会/实践篇/12 | 事务:怎么确保关联操作正确执行?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

240 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="12 | 事务:怎么确保关联操作正确执行?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a6/53/a6329fcd8cd566a1a2d772c9468f7753.mp3"></audio>
你好,我是朱晓峰。
我们经常会遇到这样的场景几个相互关联的数据操作必须是全部执行或者全部不执行不可以出现部分执行的情况。比如说你从微信账号里提现100元到银行卡上这个动作就包括了相互关联的2个步骤首先是微信账号减100元然后是银行卡账号加100元这里假设没有手续费。假如因为某种异常这2个操作只执行了一个另外一个没有执行就会出现你的钱少了100元或者你的钱多了100元的情况这肯定是不能接受的。
如何才能确保多个关联操作全部执行呢?这时就要用到事务了。接下来我就重点讲一讲什么是事务,以及如何正确使用事务。
## 什么是事务?
事务是MySQL的一项功能它可以使一组数据操作也叫DML操作是英文Data Manipulation Language的缩写包括SELECT、INSERT、UPDATE和DELETE要么全部执行要么全部不执行不会因为某种异常情况比如硬件故障、停电、网络中断等出现只执行一部分操作的情况。
事务的语法结构如下所示:
```
START TRANSACTION 或者 BEGIN (开始事务)
一组DML语句
COMMIT提交事务
ROLLBACK事务回滚
```
我解释一下这几个关键字。
- **START TRANSACTION和BEGIN**表示开始事务意思是通知MySQL后面的DML操作都是当前事务的一部分。
- **COMMIT**:表示提交事务,意思是执行当前事务的全部操作,让数据更改永久有效。
- **ROLLBACK**:表示回滚当前事务的操作,取消对数据的更改。
事务有4个主要特征分别是原子性atomicity、一致性consistency、持久性durability和隔离性isolation
- 原子性:表示事务中的操作要么全部执行,要么全部不执行,像一个整体,不能从中间打断。
- 一致性:表示数据的完整性不会因为事务的执行而受到破坏。
- 隔离性:表示多个事务同时执行的时候,不互相干扰。不同的隔离级别,相互独立的程度不同。
- 持久性:表示事务对数据的修改是永久有效的,不会因为系统故障而失效。
持久性非常好理解,我就不多说了,接下来我重点讲一讲事务的原子性、一致性和隔离性,这是确保关联操作正确执行的关键。
## 如何确保操作的原子性和数据的一致性?
我借助一个超市的收银员帮顾客结账的简单场景来讲解。在系统中,结算的动作主要就是销售流水的产生和库存的消减。这里会涉及销售流水表和库存表,如下所示:
销售流水表demo.mytrans
<img src="https://static001.geekbang.org/resource/image/bd/1e/bd6a537byy788646d1202167245c1c1e.jpeg" alt="">
库存表demo.inventory
<img src="https://static001.geekbang.org/resource/image/f1/98/f17cfe65a02f4a5a54e4a49e63a35998.jpeg" alt="">
现在假设门店销售了5个商品编号是1的商品这个动作实际上包括了2个相互关联的数据库操作
1. 向流水表中插入一条“1号商品卖了5个”的销售流水
1. 把库存表中的1号商品的库存减5。
这里包含了2个DML操作为了避免意外事件导致的一个操作执行了而另一个没有执行的情况我把它们放到一个事务里面利用事务中数据操作的原子性来确保数据的一致性。
```
mysql&gt; START TRANSACTION; -- 开始事务
Query OK, 0 rows affected (0.00 sec)
mysql&gt; INSERT INTO demo.mytrans VALUES (1,1,5); -- 插入流水
Query OK, 1 row affected (0.00 sec)
mysql&gt; UPDATE demo.inventory SET invquantity = invquantity - 5 WHERE itemnumber = 1; -- 更新库存
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql&gt; COMMIT; -- 提交事务
Query OK, 0 rows affected (0.06 sec)
```
然后我们查询一下结果:
```
mysql&gt; SELECT * FROM demo.mytrans; -- 流水插入成功了
+---------+------------+----------+
| transid | itemnumber | quantity |
+---------+------------+----------+
| 1 | 1 | 5.000 |
+---------+------------+----------+
1 row in set (0.00 sec)
mysql&gt; SELECT * FROM demo.inventory; -- 库存消减成功了
+------------+-------------+
| itemnumber | invquantity |
+------------+-------------+
| 1 | 5.000 |
+------------+-------------+
1 row in set (0.00 sec)
```
这样通过把2个相关操作放到事务里面我们就实现了一个事务操作。
这里有一个坑,我要提醒你一下。**事务并不会自动帮你处理SQL语句执行中的错误**,如果你对事务中的某一步数据操作发生的错误不做处理,继续提交的话,仍然会导致数据不一致。
为了方便你理解,我举个小例子。
假如我们的插入一条销售流水的语句少了一个字段,执行的时候出现错误了,如果我们不对这个错误做回滚处理,继续执行后面的操作,最后提交事务,结果就会出现没有流水但库存消减了的情况:
```
mysql&gt; START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)
mysql&gt; INSERT INTO demo.mytrans VALUES (1,5); -- 这个插入语句出错了
ERROR 1136 (21S01): Column count doesn't match value count at row 1
mysql&gt; UPDATE demo.inventory SET invquantity = invquantity - 5 WHERE itemnumber = 1;
Query OK, 1 row affected (0.00 sec) -- 后面的更新语句仍然执行成功了
Rows matched: 1 Changed: 1 Warnings: 0
mysql&gt; COMMIT;
Query OK, 0 rows affected (0.03 sec) -- 事务提交成功了
```
我们查一下表的内容:
```
mysql&gt; SELECT * FROM demo.mytrans; -- 流水没有插入成功
Empty set (0.16 sec)
mysql&gt; SELECT * FROM demo.inventory; -- 库存消减成功了
+------------+-------------+
| itemnumber | invquantity |
+------------+-------------+
| 1 | 5.000 |
+------------+-------------+
1 row in set (0.00 sec)
```
结果显示,流水插入失败了,但是库存更新成功了,这时候没有销售流水,但是库存却被消减了。
这就是因为没有正确使用事务导致的数据不完整问题。那么,如何使用事务,才能避免这种由于事务中的某一步或者几步操作出现错误,而导致数据不完整的情况发生呢?这就要用到事务中错误处理和回滚了:
- 如果发现事务中的某个操作发生错误,要及时使用回滚;
- 只有事务中的所有操作都可以正常执行,才进行提交。
那这里的关键就是判断操作是不是发生了错误。我们可以通过MySQL的函数ROW_COUNT()的返回来判断一个DML操作是否失败-1表示操作失败否则就表示影响的记录数。
```
mysql&gt; INSERT INTO demo.mytrans VALUES (1,5);
ERROR 1136 (21S01): Column count doesn't match value count at row 1
mysql&gt; SELECT ROW_COUNT();
+-------------+
| ROW_COUNT() |
+-------------+
| -1 |
+-------------+
1 row in set (0.00 sec)
```
另外一个经常会用到事务的地方是存储过程。由于存储过程中包含很多相互关联的数据操作所以会大量使用事务。我们可以在MySQL的存储过程中通过获取SQL错误来决定事务是提交还是回滚
```
mysql&gt; DELIMITER // -- 修改分隔符为 //
mysql&gt; CREATE PROCEDURE demo.mytest() -- 创建存储过程
-&gt; BEGIN -- 开始程序体
-&gt; DECLARE EXIT HANDLER FOR SQLEXCEPTION ROLLBACK; -- 定义SQL操作发生错误是自动回滚
-&gt; START TRANSACTION; -- 开始事务
-&gt; INSERT INTO demo.mytrans VALUES (1,5);
-&gt; UPDATE demo.inventory SET invquantity = invquantity - 5;
-&gt; COMMIT; -- 提交事务
-&gt; END
-&gt; // -- 完成创建存储过程
Query OK, 0 rows affected (0.05 sec)
mysql&gt; DELIMITER ; -- 恢复分隔符为;
mysql&gt; CALL demo.mytest(); -- 调用存储过程
Query OK, 0 rows affected (0.00 sec)
mysql&gt; SELECT * FROM demo.mytrans; -- 销售流水没有插入
Empty set (0.00 sec)
mysql&gt; SELECT * FROM demo.inventory; -- 库存也没有消减,说明事务回滚了
+------------+-------------+
| itemnumber | invquantity |
+------------+-------------+
| 1 | 10.000 |
+------------+-------------+
1 row in set (0.00 sec)
```
这里我们要先通过“DELIMITER //”语句把MySQL语句的结束标识改为“//”(默认语句的结束标识是“;”。这样做的目的是告诉MySQL一直到“//”才是语句的结束否则MySQL会在遇到第一个“;”的时候认为语句已经结束,并且执行。这样就会报错,自然也就没办法创建存储过程了。
创建结束以后,我们还要录入“//”告诉MySQL存储过程创建完成了并且通过“DELIMITER ;”,再把语句结束标识改回到“;”。
关于存储过程我会在后面的课程里给你详细介绍。这里你只需要知道在这个存储过程中我使用了“DECLARE EXIT HANDLER FOR SQLEXCEPTION ROLLBACK;”这个语句来监控SQL语句的执行结果一旦发发生错误就自动回滚并退出。通过这个机制我们就实现了对事务中的SQL操作进行监控如果发现事务中的任何SQL操作发生错误就自动回滚。
总之,**我们要把重要的关联操作放在事务中,确保操作的原子性,并且对失败的操作进行回滚处理**。只有这样,才能真正发挥事务的作用,保证关联操作全部成功或全部失败,最终确保数据的一致性。
## 如何用好事务的隔离性?
接下来,我们再学习下如何用好事务的隔离性。
超市经营者提出门店要支持网上会员销售现在我们假设会员张三是储值会员他的会员卡里有100元。张三用会员卡到门店消费100元他爱人用他的会员卡在网上消费100元。
张三在门店消费结算的时候开启了一个事务A包括这样3个操作
1. 读取卡内金额为100
1. 更新卡内金额为0
1. 插入一条销售流水。
张三的爱人在网上购物开启了一个事务B也来读取卡内金额。如果B读取卡内金额的操作发生在A更新卡内金额之后并且在插入销售流水之前那么B读出的金额应该是多少呢如果B读出0元那么A有可能由于后面的操作失败而回滚。因此B可能会读到一条错误信息而导致本来可以成功的交易失败。有什么办法可以解决这个问题呢
这个时候就会用到MySQL的另外一种机制“锁”。MySQL可以把A中被修改过而且还没有提交的数据锁住让B处于等待状态一直到A提交完成或者失败回滚再释放锁允许B读取这个数据。这样就可以防止因为A回滚而导致B读取错误的可能了。
MySQL中的锁有很多种功能也十分强大。咱们这门课里不要求你掌握锁你只要知道MySQL可以用锁来控制事务对数据的操作就可以了。
通过对锁的使用,可以实现事务之间的相互隔离。**锁的使用方式不同,隔离的程度也不同**。
MySQL支持4种事务隔离等级。
1. READ UNCOMMITTED可以读取事务中还未提交的被更改的数据。
1. READ COMMITTED只能读取事务中已经提交的被更改的数据。
1. REPEATABLE READ表示一个事务中对一个数据读取的值永远跟第一次读取的值一致不受其他事务中数据操作的影响。这也是MySQL的默认选项。
1. SERIALIZABLE表示任何一个事务一旦对某一个数据进行了任何操作那么一直到这个事务结束MySQL都会把这个数据锁住禁止其他事务对这个数据进行任何操作。
一般来讲使用MySQL默认的隔离等级REPEATABLE READ就已经够了。不过也不排除需要对一些关键的数据操作使用最高的隔离等级SERIALIZABLE。
举个例子,在我们的超市项目中,就对每天的日结操作设置了最高的隔离等级。因为日结要进行大量的核心数据计算,包括成本、毛利、毛利率、周转率,等等,并把结果保存起来,作为各类查询、报表系统、决策支持模块的基础,绝对不能出现数据错误。
当然,**计算完成之后,你也不要忘记把隔离等级恢复到系统默认的状态**,否则,会对日常的系统营运效率产生比较大的影响。
事务的隔离性对并发操作非常有用。当许多用户同时操作数据库的时候,隔离性可以确保各个连接之间互相不影响。这里我要提醒你的是,正确设置事务的隔离等级很重要。
一方面,**对于一些核心的数据更改操作,你可能需要较高的隔离等级**,比如涉及金额的修改;另一方面,**你要考虑资源的消耗,不能使系统整体的效率受到太大的影响**。所以,要根据具体的应用场景,正确地使用事务。
## 总结
事务可以确保事务中的一系列操作全部被执行不会被打断或者全部不被执行等待再次执行。事务中的操作具有原子性、一致性、永久性和隔离性的特征。但是这并不意味着被事务包裹起来的一系列DML数据操作就一定会全部成功或者全部失败。你需要对操作是否成功的结果进行判断并通知MySQL针对不同情况分别完成事务提交或者回滚操作才能最终确保事务中的操作全部成功或全部失败。
MySQL支持4种不同的事务隔离等级等级越高消耗的系统资源也越多你要根据实际情况进行设定。
在MySQL中并不是所有的操作都可以回滚。比如创建数据库、创建数据表、删除数据库、删除数据表等这些操作是不可以回滚的所以你在操作的时候要特别小心特别是在删除数据库、数据表时最好先做备份防止误操作。
## 思考题
学完了这节课以后,如果现在有人对你说,事务就是确保事务中的数据操作,要么全部正确执行,要么全部失败,你觉得这句话对吗?为什么?
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你把它分享给你的朋友或同事,我们下节课见。