事务案例

我们创建了一个简单的表t,并插入一行,然后对这一行做修改。
mysql> CREATE TABLE `t` ( `id` int(11) NOT NULL primary key auto_increment, `a` int(11) DEFAULT NULL ) ENGINE=InnoDB; insert into t values(1,2);
这时候,表t里有唯一的一行数据(1,2)。假设,我现在要执行:
mysql> update t set a=2 where id=1;
你会看到这样的结果:
notion image
结果显示,匹配(rows matched)了一行,修改(Changed)了0行。
仅从现象上看,MySQL内部在处理这个命令的时候,可以有以下三种选择:
  1. 更新都是先读后写的,MySQL读出数据,发现a的值本来就是2,不更新,直接返回,执行结束;
  1. MySQL调用了InnoDB引擎提供的“修改为(1,2)”这个接口,但是引擎发现值与原来相同,不更新,直接返回;
  1. InnoDB认真执行了“把这个值修改成(1,2)"这个操作,该加锁的加锁,该更新的更新。
你觉得实际情况会是以上哪种呢?你可否用构造实验的方式,来证明你的结论?进一步地,可以思考一下,MySQL为什么要选择这种策略呢?
 
第一个选项是,MySQL读出数据,发现值与原来相同,不更新,直接返回,执行结束。这里我们可以用一个锁实验来确认。
假设,当前表t里的值是(1,2)。
锁验证方式
锁验证方式
 
session B的update 语句被blocked了,加锁这个动作是InnoDB才能做的,所以排除选项1。
第二个选项是,MySQL调用了InnoDB引擎提供的接口,但是引擎发现值与原来相同,不更新,直接返回。有没有这种可能呢?这里我用一个可见性实验来确认。
假设当前表里的值是(1,2)。
可见性验证方式
可见性验证方式
 
session A的第二个select 语句是一致性读(快照读),它是不能看见session B的更新的。
现在它返回的是(1,3),表示它看见了某个新的版本,这个版本只能是session A自己的update语句做更新的时候生成。
所以,我们上期思考题的答案应该是选项3,即:InnoDB认真执行了“把这个值修改成(1,2)"这个操作,该加锁的加锁,该更新的更新。
然后你会说,MySQL怎么这么笨,就不会更新前判断一下值是不是相同吗?如果判断一下,不就不用浪费InnoDB操作,多去更新一次了?
其实MySQL是确认了的。只是在这个语句里面,MySQL认为读出来的值,只有一个确定的 (id=1), 而要写的是(a=3),只从这两个信息是看不出来“不需要修改”的。
作为验证,你可以看一下下面这个例子。
可见性验证方式--对照
可见性验证方式--对照
 
补充说明:
上面我们的验证结果都是在binlog_format=statement格式下进行的。
如果是binlog_format=row 并且binlog_row_image=FULL的时候,由于MySQL需要在binlog里面记录所有的字段,所以在读数据的时候就会把所有数据都读出来了。
根据上面说的规则,“既然读了数据,就会判断”, 因此在这时候,select * from t where id=1,结果就是“返回 (1,2)”。
同理,如果是binlog_row_image=NOBLOB, 会读出除blob 外的所有字段,在我们这个例子里,结果还是“返回 (1,2)”。
对应的代码如图15所示。这是MySQL 5.6版本引入的,在此之前我没有看过。所以,特此说明。
binlog_row_image=FULL读字段逻辑
binlog_row_image=FULL读字段逻辑
 
类似的, 如果表中有timestamp字段而且设置了自动更新的话,那么更新“别的字段”的时候,MySQL会读入所有涉及的字段,这样通过判断,就会发现不需要修改。
 
业务上有这样的需求,A、B两个用户,如果互相关注,则成为好友。设计上是有两张表,一个是like表,一个是friend表,like表有user_idliker_id两个字段,我设置为复合唯一索引即uk_user_id_liker_id。语句执行逻辑是这样的:
以A关注B为例:第一步,先查询对方有没有关注自己(B有没有关注A)select * from like where user_id = B and liker_id = A;如果有,则成为好友insert into friend;没有,则只是单向关注关系insert into like;
但是如果A、B同时关注对方,会出现不会成为好友的情况。因为上面第1步,双方都没关注对方。第1步即使使用了排他锁也不行,因为记录不存在,行锁无法生效。请问这种情况,在MySQL锁层面有没有办法处理?
CREATE TABLE `like` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `liker_id` int(11) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_id_liker_id` (`user_id`,`liker_id`) ) ENGINE=InnoDB; CREATE TABLE `friend` ( id` int(11) NOT NULL AUTO_INCREMENT, `friend_1_id` int(11) NOT NULL, `firned_2_id` int(11) NOT NULL, UNIQUE KEY `uk_friend` (`friend_1_id`,`firned_2_id`) PRIMARY KEY (`id`) ) ENGINE=InnoDB;
并发“喜欢”逻辑操作顺序
并发“喜欢”逻辑操作顺序
这个结果对业务来说就是bug了。因为在业务设定里面,这两个逻辑都执行完成以后,是应该在friend表里面插入一行记录的。
首先,要给“like”表增加一个字段,比如叫作 relation_ship,并设为整型,取值1、2、3。
值是1的时候,表示user_id 关注 liker_id;值是2的时候,表示liker_id 关注 user_id;值是3的时候,表示互相关注。
然后,当 A关注B的时候,逻辑改成如下所示的样子:
应用代码里面,比较A和B的大小,如果A<B,就执行下面的逻辑
mysql> begin; /*启动事务*/ insert into `like`(user_id, liker_id, relation_ship) values(A, B, 1) on duplicate key update relation_ship=relation_ship | 1; select relation_ship from `like` where user_id=A and liker_id=B; /*代码中判断返回的 relation_ship, 如果是1,事务结束,执行 commit 如果是3,则执行下面这两个语句: */ insert ignore into friend(friend_1_id, friend_2_id) values(A,B); commit;
如果A>B,则执行下面的逻辑
mysql> begin; /*启动事务*/ insert into `like`(user_id, liker_id, relation_ship) values(B, A, 2) on duplicate key update relation_ship=relation_ship | 2; select relation_ship from `like` where user_id=B and liker_id=A; /*代码中判断返回的 relation_ship, 如果是2,事务结束,执行 commit 如果是3,则执行下面这两个语句: */ insert ignore into friend(friend_1_id, friend_2_id) values(B,A); commit;
这个设计里,让“like”表里的数据保证user_id < liker_id,这样不论是A关注B,还是B关注A,在操作“like”表的时候,如果反向的关系已经存在,就会出现行锁冲突。
然后,insert … on duplicate语句,确保了在事务内部,执行了这个SQL语句后,就强行占住了这个行锁,之后的select 判断relation_ship这个逻辑时就确保了是在行锁保护下的读操作。
操作符 “|” 是按位或,连同最后一句insert语句里的ignore,是为了保证重复调用时的幂等性
这样,即使在双方“同时”执行关注操作,最终数据库里的结果,也是like表里面有一条关于A和B的记录,而且relation_ship的值是3, 并且friend表里面也有了A和B的这条记录。
不知道你会不会吐槽:之前明明还说尽量不要使用唯一索引,结果这个例子一上来我就创建了两个。这里我要再和你说明一下,之前文章我们讨论的,是在“业务开发保证不会插入重复记录”的情况下,着重要解决性能问题的时候,才建议尽量使用普通索引。
而像这个例子里,按照这个设计,业务根本就是保证“我一定会插入重复数据,数据库一定要要有唯一性约束”,这时就没啥好说的了,唯一索引建起来吧。