问题分析
忽然间被运营滴滴说某个活动的报名人数超过了限制人数,问怎么回事,我一会儿还挺蒙的,我明明有在报名的操做以前设置了检查若是超过报名人数代码逻辑会抛错继续报名的呀。php
而后我又打开数据库看了一下,出现了如下的状况:mysql
因而状况就很明了了,这明显就是并发控制没有作好。为了叙述清楚这个状况,下面讲述一下业务逻辑:首先是从meeting表查是否报名已满,若是未满,则开始事务,将signed字段自增1,而后把参会记录插入到meeting_member表,提交事务。这里其实是出现了丢失更新,举例以下。sql
T1 | T2 | 数据库中signed的值 |
---|---|---|
BEGIN; | 0 | |
SELECT signed FROM meeting WHERE meeting_id=xx; (读出来的值为0) | BEGIN; | |
UPDATE meeting SET signed=signed+1 WHERE meeting_id=xx; | SELECT signed FROM meeting WHERE meeting_id=xx; (读出来的值也为0) | |
COMMIT; | UPDATE meeting SET signed=signed+1 WHERE meeting_id=xx; | |
COMMIT; | 1 |
能够看到,若是T1没有提交,T2会读取到原来的值,最终出现T1的更新丢失的问题。对应到具体场景就是,若是有两个事务,第一个读取、而后更新,但尚未提交,这时候开始了第二个事务,他读取到的就是第一个事务更新前的数据,一样进行自增,这是T1提交,T2也提交,可是signed只增长了1。thinkphp
(中间有进行实验,可是因为填这个坑花的时间太长,就不把实验过程放上来了,结果跟上表中罗列的一致)数据库
PS
一开始我立刻联想到的是事务的隔离级别以及脏读、不可重复读、幻读之类的问题,实际上这里出现的是第二类丢失更新1问题,丢失更新是指两个事务更新数据时可能会覆盖对方的更新,并非脏读(T2错误读取到T1已经修改但未提交的数据)、不可重复读(T1进行两次读,中间T2对数据进行修改并提交,T1两次读到的数据不一样)、幻读(T1修改了表中符合某种条件的数据,T2又新增了一条符合这种条件的数据,T1会发现还有没有修改的数据)2。这就是为何即便mysql已是可重复读(Repeatable Read)的事务隔离等级,但仍是会出现丢失更新的缘由3。并发
这里蛮坑的,我往事务隔离等级这个方向想了好久,才发现方向根本不对,可重复读已经解决了脏读和不可重复读的问题4,问题不是出在事务隔离等级上,而是应该在这里加上一个锁的机制。这里又有新的疑惑,为何有了事务(锁协议是事务的一种实现方式),还须要另外的锁呢?这里考虑到粒度的问题5。所以最后应该使用的解决方法是悲观锁或者乐观锁。thinkphp5
解决思路
解决方法实际上比较简单(简单个屁),只须要加上悲观锁或者乐观锁就能够了。值得一提的是,其实把事务隔离等级调成未提交读或者可串行化也能解决问题,但若是使用未提交读那会是一个倒退,可串行化会形成并发性能的严重降低,因此不采用。性能
若是对比乐观锁和悲观锁,乐观锁须要代码实现(增长一个版本字段),而悲观锁能够用数据库原生的方式实现。悲观锁实现较为简单,但悲观锁的并发性能不如乐观锁。spa
悲观锁介绍
悲观的意思是,每次获取数据的时候,都会担忧数据被修改,因此每次获取数据的时候都会进行加锁。在当前场景下,悲观锁就是在读取目前的signed时,给这个数据加上行级的排他锁,而后再进行更新。若是在T1尚未提交时,T2(一个一样步骤的事务)想要读取这个数据,由于他想要得到的是一个排它锁,因为T1还未提交,T1持有的排它锁会阻塞T2,T2只能等待T1提交以后才能读这一个数据。.net
排他锁
在mysql中,排他锁能够这样使用:
SELECT ... FOR UPDATE;
mysql会对查询结果集中每行都添加排它锁,在事务操做中,更新和删除操做会自动加上排他锁6。
若是数据被加上了排他锁,那么若是查询中不管是请求共享锁或是排他锁,都会由于以前的排他锁而被阻塞,直到以前的排他锁由于事务提交或回滚释放。若是不带任何锁的SELECT语句,不管查询的数据是否有被加锁(包括共享锁和排他锁),都可以进行查询而不被阻塞。具体的表现能够见7。
行锁和表锁
mysql对表的锁有两种不一样的粒度,分别是表锁和行锁。行锁是粒度最小的锁,InnoDB支持行锁和表锁,而MyISAM只支持表锁。这里特地提出来,是由于并非SELECT ... FOR UPDATE;
就是行级锁,只有查询到数据、根据主键和/或对非主键含索引进行查询时才能使用行级锁,其余状况使用的仍是表锁。具体缘由是,InnoDB行锁是经过对索引的索引项加锁来实现的,这点值得注意。例如这里要实现行锁,就要对这个字段建索引。
实验
讲了那么多,作一个实验验证一下。
首先看一下mysql8.0默认的事务隔离级别。(注意8.0与5.x有分别)
select @@global.transaction_isolation,@@transaction_isolation;
能够看到默认的事务隔离级别是Repeatable Read(可重复读)。
而后新开两个查询链接,这里使用university数据库为例(上课用惯了),事务里进行一个查询和更新操做,以下所示。
BEGIN; SELECT * FROM instructor WHERE name='Srinivasan' FOR UPDATE; UPDATE instructor SET salary=65001 WHERE ID=10101; -- ROLLBACK; COMMIT;
先在第一个链接里执行前两行,开始一个事务T1,而后进行查询并加锁。注意这里对name这一列进行了索引,因此能够实现行级的锁。能够看到T1可以正常进行查询。
而后用另外一个链接也开始一个事务,对这一个数据进行查询。能够看到查询被阻塞了(没有结果返回)。(实际上,等待一段时间(默认50s)后,就会出现当前会话锁等待超时)
回到第一个窗口继续第一个事务的更新操做,这时候第二个事务中的查询和更新操做继续被阻塞。若是第二个事务查询另外一行的数据,则不会被阻塞。
当第一个事务提交或者回滚时,锁被释放,第二个事务立刻能够进行查询和更新操做。
thinkphp5.0实现
讲到这么久,终于步入正题。这里也是有一点感慨,实现就两行代码,实际考虑的东西、涉及到的内容远远不止这么点。
在tp5的用法中比较简单,只须要在链式查询中加入lock(true)
就能够。具体代码如:
Db::name('user')->where('id',1)->lock(true)->find(); //指定使用共享锁 Db::name('user')->where('id',1)->lock('lock in share mode')->find();
在模型中也可用,用法同数据库方法。
坑点
文档中有提到8
就会自动在生成的SQL语句最后加上
FOR UPDATE
或者FOR UPDATE NOWAIT
(Oracle数据库)。
说明实现的方法就是加上FOR UPDATE
,但没说明是行锁仍是表锁,也没有说明要先开启事务才能使用,若是对数据库不够熟悉(例如一年前的我)看到这里就会一脸懵逼。另外,据闻tp6已经实现了乐观锁(?),同时,tp5也能够经过traits来实现乐观锁。
参考
https://blog.csdn.net/paopaopotter/article/details/79259686 “数据库第一类第二类丢失更新” ↩︎
https://blog.csdn.net/yishizuofei/article/details/79453588 “脏读、丢失更新、不可重复读、幻读” ↩︎
https://zhuanlan.zhihu.com/p/67210493 “Mysql RR级别依然可能丢失更新数据” ↩︎
https://www.cnblogs.com/zhoujinyi/p/3437475.HTML “MySQL 四种事务隔离级的说明” ↩︎
https://blog.csdn.net/Scrat_Kong/article/details/84454519 “为何有了事务还须要乐观锁和悲观锁?” ↩︎
https://blog.csdn.net/tigernorth/article/details/7948539 “MySQL锁的用法之行级锁” ↩︎
https://blog.csdn.net/She_lock/article/details/82022431 “mysql读锁(共享锁)与写锁(排他锁)” ↩︎
https://www.kancloud.cn/manual/thinkphp5/118086 “ThinkPHP5.0彻底开发手册-lock” ↩︎