写在前面

其实在书写存储过程的时候,你已经发现可能会存在一些问题,如“插入的数据违反唯一约束,导致插入失败”、“插入或者更新数据超过字段最大长度,导致操作失败”、“update影响行数和期望结果不一致”等问题。当遇到上述各种异常问题的时候,需要我们捕获异常,并可能回滚当前事务。由于事务在前面已经进行了学习,因此这里主要就学习如何处理异常,也会使用到游标,通过游标来遍历select查询的结果集,然后对每行数据进行处理。

本文主要的学习内容如下:(1)异常分类;(2)内部异常;(3)外部异常;(4)乐观锁解决并发修改数据出错;(5)update影响行数和期望结果不一致时的处理;(6)文章总结。

数据准备

为了后续学习的必要,这里先准备了一张数据表book4,相应的建表语句如下所示:

1
2
drop table if exists book4;
create table book4(a int primary key);

异常分类

MySQL中的异常分为内部异常和外部异常。

在执行SQL语句的时候,可能违反了MySQL的一些约束,导致MySQL内部报错,这些异常是由MySQL内部抛出的,因此称之为MySQL内部异常。常用的内部异常有:数据插入违反唯一约束、非空约束、类型限制、更新数据超时等。

在执行一条Update语句时,可能我们期望返回的受影响行数是1行,但是实际返回的却不是1行,也就是说此时SQL的执行结果和期望结果不一致,这种情况我们将其称之为MySQL外部异常

内部异常

执行SQL语句时,可能违反了MySQL的一些约束,导致MySQL内部报错,因此称之为MySQL内部异常。

例子1:现在有一个需求,向之前的book4表中同时插入2条数据,且放在一个事务中执行,要求最终结果是要么都插入成功,要么都插入失败,不存在其他可能。

第一步,创建存储过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*删除存储过程*/
drop procedure if exists proc1;
/*声明结束符为$*/
delimiter $
/*创建存储过程*/
create procedure proc1(a1 int,a2 int)
begin
start transaction;
insert into book4(a) values(a1);
insert into book4(a) values(a2);
commit;
end $
/*设置结束符为;$*/
delimiter ;

第二步,调用存储过程并查询book4表数据验证效果:

1
2
3
4
5
6
7
8
9
10
11
12
mysql> select * from book4;
Empty set (0.00 sec)

mysql> call proc1(1,1);
ERROR 1062 (23000): Duplicate entry '1' for key 'PRIMARY'
mysql> select * from book4;
+---+
| a |
+---+
| 1 |
+---+
1 row in set (0.00 sec)

这里我们调用了proc1这一存储过程,它的目的是插入两条数据,a的值都是1。由于book4表中a字段是主键,因此在插入第二条数据的时候违反了主键的唯一性约束,MySQL内部抛出了异常,导致第二条数据插入失败,最终只有第一条数据插入成功了,很明显以上执行结果和我们的期望结果不一致,我们所期望的是要么都插入成功,要么都插入失败,不存在其他可能。

例子2:例子1进行修改,使之能够捕获上面因主键约束而导致的异常,并进行回滚操作。

第一步,创建存储过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*删除存储过程*/
drop procedure if exists proc2;
/*声明结束符为$*/
delimiter $
/*创建存储过程*/
create procedure proc2(a1 int,a2 int)
begin
/*声明一个变量,用于标识是否有SQL异常*/
declare hasSqlError int default false;
/*在执行过程中出现任何异常时,设置hasSqlError值为true*/
declare continue handler for sqlexception set hasSqlError=true;
/*开启事务*/
start transaction;
insert into book4(a) values(a1);
insert into book4(a) values(a2);
/*根据hasSqlError来判断是否有异常,如果有就回滚和提交*/
if hasSqlError then
rollback;
else
commit;
end if;
end $
/*设置结束符为;$*/
delimiter ;

可以看到在上面那个存储过程中,有一条核心语句:

1
declare continue handler for sqlexception  set hasSqlError=true;

它的意思就是当SQL有异常发生的时候,它会将变量hasSqlError的值修改为true。

第二步,分别模拟一些情况,以验证上述配置是否有效。

模拟异常情况,执行如下语句:

1
2
3
4
5
6
7
8
mysql> delete  from book4;
Query OK, 1 row affected (0.10 sec)

mysql> call proc2(1,1);
Query OK, 0 rows affected (0.19 sec)

mysql> select * from book4;
Empty set (0.00 sec)

可以看到上面插入2条数据,由于违反主键唯一性原则,因此会触发回滚操作,导致book4表中无数据,此时期望结果和实际结果是一致的。

模拟正常情况,执行如下语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mysql> delete from book4;
Query OK, 0 rows affected (0.00 sec)

mysql> call proc2(1,2);
Query OK, 0 rows affected (0.12 sec)

mysql> select * from book4;
+---+
| a |
+---+
| 1 |
| 2 |
+---+
2 rows in set (0.00 sec)

由于上面插入了2条不同的记录,系统不会抛出内部异常,因此数据就能成功插入。

外部异常

当SQL执行结果和预期不一致的时候,我们就需要对这些情况进行一些处理,如回滚操作,因此这种异常我们就称之为外部异常。

例子3:模拟电商中的下单操作,按照上面的步骤来更新账户余额。

第一步,创建电商中会用到的账户表和订单表,使用的语法格式如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
drop table if exists t_account;
create table t_account(
user_id int primary key comment '用户id',
money decimal(10,2) not null default 0 comment '账户余额'
)comment '账户表';

drop table if exists t_order;
create table t_order(
order_id int primary key auto_increment comment '订单id',
price decimal(10,2) not null default 0 comment '订单金额'
)comment '订单表';

delete from t_account;
insert into t_account(user_id,money) values(1001,1000);

第二步,上面的下单操作涉及到操作上面的账户表,因此可以使用存储过程来模拟进行实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/*删除存储过程*/
drop procedure if exists proc3;
/*声明结束符为$*/
delimiter $
/*创建存储过程*/
create procedure proc3(v_user_id int,v_price decimal(10,2),out v_msg varchar(64))
a:begin
declare v_money decimal(10,2);

/*1、查询余额,并判断余额是否足够*/
select a.money into v_money from t_account as a where a.user_id = v_user_id;
if v_money <=v_price then
set v_msg = '账户余额不足';
/*退出*/
leave a;
end if;

/*模拟耗时5秒*/
select sleep(5);

/*2、余额减去price*/
set v_money = v_money - v_price;
/*3、更新余额*/
start transaction;
update t_account set money = v_money where user_id = v_user_id;

/*4、插入订单明细*/
insert into t_order(price) values(v_price);

/*5、提交事务*/
commit;
set v_msg= '下单成功';
end $
/*设置结束符为;$*/
delimiter ;

可以看到这里用户下单其实分为3个步骤,(1)验证账户余额;(2)修改余额变量;(3)更新余额。

第三步,打开两个CMD窗口,连接MySQL,同时执行如下语句:

1
2
3
use envythink;
call proc3(1001,100,@v_msg);
select @v_msg;

之后查询两个表中的数据,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mysql> select * from t_account;
+---------+-----------+
| user_id | money |
+---------+-----------+
| 1001 | 900.00 |
+---------+-----------+
1 row in set (0.00 sec)

mysql> select * from t_order;
+----+--------+
| id | price |
+----+--------+
| 1 | 100.00 |
| 2 | 100.00 |
+----+--------+
2 rows in set (0.00 sec)

很明显这里出现了非常严重的问题,原始账户有1000元,下单了两次,每次扣款100,账户应该还剩800元,但是这里还剩900元,这是不对的。

分析SQL语句可以知道,这个问题是由2个操作并发而导致的,2个窗口同时调用存储过程3,它们看到的账户余额都是1000,然后就继续往下执行,最终就导致问题产生。

其实这个问题可以使用乐观锁来解决。乐观锁的过程是,用期望值与目标值进行比较,如果相同则更新目标值,否则什么都不做。乐观锁类似于Java中的CAS操作,关于CAS会在Java并发系列文章中进行学习。

这里提供一种解决思路,我们可以在账户表t_account内添加一个version字段,该字段表示版本号,每次更新数据的时候,version字段的值会加1,在更新数据的时候将version作为条件来执行update语句,同时根据update语句执行的影响结果行数来判断执行是否成功。

例子4:使用前面的逻辑来对例子3中的代码进行优化。

第一步,重新创建电商中会用到的账户表和订单表,使用的语法格式如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
drop table if exists t_account;
create table t_account(
user_id int primary key comment '用户id',
money decimal(10,2) not null default 0 comment '账户余额',
version int default 0 comment '版本号,每次更新会加1'
)comment '账户表';

drop table if exists t_order;
create table t_order(
order_id int primary key auto_increment comment '订单id',
price decimal(10,2) not null default 0 comment '订单金额'
)comment '订单表';

delete from t_account;
insert into t_account(user_id,money) values(1001,1000);

第二步,创建新的存储过程,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/*删除存储过程*/
drop procedure if exists proc4;
/*声明结束符为$*/
delimiter $
/*创建存储过程*/
create procedure proc4(v_user_id int,v_price decimal(10,2),out v_msg varchar(64))
a:begin
/*保存当前余额*/
declare v_money decimal(10,2);
/*保存版本号*/
declare v_version int default 0;
/*保存影响的行数*/
declare v_update_count int default 0;

/*1、查询余额,并判断余额是否足够*/
select a.money into v_money from t_account as a where a.user_id = v_user_id;
if v_money <=v_price then
set v_msg = '账户余额不足';
/*退出*/
leave a;
end if;

/*模拟耗时5秒*/
select sleep(5);

/*2、余额减去price*/
set v_money = v_money - v_price;
/*3、更新余额*/
start transaction;
update t_account set money = v_money where user_id = v_user_id;
/*获取上面update影响的函数*/
select row_count() into v_update_count;

/*4、插入订单明细*/
if v_update_count =1 then
insert into t_order(price) values(v_price);
set v_msg= '下单成功';
/*5、提交事务*/
commit;
else
set v_msg= '下单失败,请重试!';
/*6、回滚事务*/
rollback;
end if;
end $
/*设置结束符为;$*/
delimiter ;

在上面的存储过程中,我们使用row_count()函数来获取更新或者插入后受影响的行数,之后将受影响的行数放在v_update_count变量中,接着根据v_update_count是否等于1来判断更新是否成功,如果成功则记录订单信息并提交事务,否则回滚事务。

第三步,打开两个CMD窗口,连接MySQL,同时执行如下语句:

1
2
3
use envythink;
call proc4(1001,100,@v_msg);
select @v_msg;

CMD窗口1中的结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mysql> call proc4(1001,100,@v_msg);
+----------+
| sleep(5) |
+----------+
| 0 |
+----------+
1 row in set (5.00 sec)

Query OK, 0 rows affected (5.00 sec)

mysql> select @v_msg;
+---------------+
| @v_msg |
+---------------+
| 下单成功! |
+---------------+
1 row in set (0.00 sec)

CMD窗口2中的结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mysql> call proc4(1001,100,@v_msg);
+----------+
| sleep(5) |
+----------+
| 0 |
+----------+
1 row in set (5.00 sec)

Query OK, 0 rows affected (5.01 sec)

mysql> select @v_msg;
+-------------------------+
| @v_msg |
+-------------------------+
| 下单失败,请重试! |
+-------------------------+
1 row in set (0.00 sec)

从两个窗口的执行结果就能看出,第一个窗口下单成功,而第二个窗口下单就失败了。

最后查看一下两个数据表,来验证上述结果是否正确:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mysql> select  * from t_account;
+---------+-----------+---------+
| user_id | money | version |
+---------+-----------+---------+
| 1001 | 900.00 | 0 |
+---------+-----------+---------+
1 row in set (0.00 sec)

mysql> select * from t_order;
+----+--------+
| order_id | price |
+----+--------+
| 1 | 100.00 |
+----+--------+
1 row in set (0.00 sec)

文章总结

接下来将对文章进行一个小结,本文主要学习了异常的分类,MySQL异常分为内部异常和外部异常,其中内部异常由MySQL内部触发,而外部异常则是SQL语句执行的结果与期望结果不一致而导致的错误。在此基础上学习了使用如下的语句来捕获MySQL内部的异常:

1
declare continue handler for sqlexception set hasSqlError =true;

开发者可以使用row_count()函数来获取MySQL中insert和update语句所影响的行数;当并发修改数据时可能会出错,因此可以使用乐观锁来解决这个问题,最简单的方式就是添加一个版本号字段,根据这个版本号字段的值来进行判断。还有一点就是begin...end前面可以添加标签,之后开发者就可以使用leave 标签来退出相应的begin...end语句,进而实现原来return的作用。