聊一聊MySQL锁的那些事
1.MySQL锁的基本概念和分类
1.1MySQL锁的概念
我们知道,锁主要是用来处理并发问题的。MySQL存储的资源是一种多用户共享的资源,自然也要涉及到并发问题。当出现并发访问时,那数据库就要用锁来合理的控制用户对资源的访问规则。
1.2MySQL锁的分类
这里我们根据加锁的范围,基本上可以将锁分为3大类,分别是全局锁(数据库级锁)、表级锁和行锁。
- 全局锁:全局锁就是对整个数据库实例加锁,是一个最粗粒度的锁。
- 表级锁:表级锁就是对数据库的某张表加锁,它有两种,分别是表锁和元数据锁(Meta Data Lock, MDL)
- 行锁:行锁就是针对数据库表中行记录的锁。
全局锁和表级锁是有Server层实现的,而行锁是由存储引擎来实现的,不同的存储引擎对行锁的实现是不一样的,比如MyISAM就不支持行锁。下面我们将要介绍的行锁也是基于InnoDB存储引擎的行锁。
2.全局锁
2.1全局锁
MySQL使用Flush tables with read lock(下面我们以FTWRL来代表这个命令)这个命令来提供一个全局锁,加锁之后,以下的这些类型的语句都会被阻塞:数据的增删改、数据定义语句(建表、索引、修改表结构等)、binlog的执行等。即:
全局锁会让整个数据库实例处于只读的状态。
2.2全局锁的使用场景
既然全局锁会让整个库处于只读状态,那它的使用场景是什么呢?或者说它设计的初衷是什么的?
全局锁最典型的使用场景就是做全库的备份,使用全局锁,让其他线程无法对数据库做任何修改,保证备份期间数据的一致性。但是,全局锁的副作用却很大:如果给主库加全局锁,造成主库是只读的,就会使实际业务出现停滞;如果给从库加全局锁,那么从库就不能执行才从主库同步过来的binlog,造成从库数据的延迟严重。
所以,非到万不得以的情况下,我们不要使用全局锁。那不使用全局锁,数据库怎么做备份呢?那么我想问一句,数据库备份为什么要用锁呢?我们接着往下看。
2.3如何备份数据库
数据库备份之所要加锁,其实就是要保证备份期间数据的一致性。否则的话,备份出来的数据就不是某一个时刻的一致的数据,因为在备份过程中仍然会产生数据库中表或者数据的变动。现在我们备份数据库,基本是使用mysqldump来实现,基本命令如下:
mysqldump -uroot -p --all-databases --single-transaction
但是这个命令只有在使用InnoDB存储引擎是才会起作用。我这里说明一下–single-transaction这个参数:
- 此时会将事务的隔离级别置为可重复读,通过执行TART TRANSACTION语句,让整个数据在dump过程中保证数据的一致性,并且不会锁表,但是注意:但是这个不能保证MyISAM表和MEMORY表的数据一致性 ,因为它们不支持事务。
- 为了确保使用–single-transaction命令时,保证dump文件的有效性。需没有下列语句ALTER TABLE, CREATE TABLE, DROP TABLE, RENAME TABLE, TRUNCATE TABLE,因为一致性读不能隔离上述语句。所以如果在dump过程中,使用上述语句,可能会导致dump出来的文件数据不一致或者不可用。
所以不支持事务的引擎,那么备份只能使用全局锁来做。这其实也是我们建议使用InnoDB存储引擎的一个原因。
2.4全局锁与只读(readonly)的比较
使用 set global readonly=true,也可以让全库处于只读状态,那为什么我们还是使用全局锁来实现呢?这主要有两个原因:
- 全局锁和readonly在异常的处理机制上不同。FTWRL执行命令后,如果客户端发生异常断开连接,那么MySQL会自动释放这个全局锁,数据库恢复正常状态;而全库被设置成readonly后,这个状态就会一直保持,除非用命令来手动解除,所以这就可能导致整个库长时间处理只读状态,影响业务,发生事故;
- 在某些时候,readonly常常被用来做其他的表述,比如判断主库还是备库等。
3.表级锁
3.1表锁的使用场景
lock tables … read/write;
unlock tables;
我们使用上面两个语句来对表进行加锁和解锁。表锁典型的使用场景是处理并发请求。你可能会有疑问,明明有行所,为什么还要用这个相对粗粒度的表锁呢?我们确实应该尽量使用行锁,但是并不是所有的存储引擎都支持行锁,在使用不支持行锁的存储引擎的情况下,表锁显然就是最好的处理并发请求的方式。
与FTWRL加全局锁一样,表锁也可以在客户端连接断开时,自动释放锁,避免在特殊情况下,导致表锁一直存在的隐患。
3.2表锁中的读锁和写锁
我们知道,读锁一般是共享锁,写锁一般是独占锁,所以表锁中的读锁和写锁也基本上是这样的规则。
- 假如线程1执行lock tables student read这个语句,那么其他线程都可以读student,但是不可以写student;
- 加入线程2执行lock tables student write这个语句,那么其他线程读写student都会被阻塞(因为写锁是独占锁),更为严格的是,这时候线程2在释放写锁之前,线程2只能读写student这一张表,不能读写其他的表。
3.3元数据锁(MDL)
元数据锁,英文全称 Metadata Lock,简称MDL,它是MySQL在5.5版本中开始引用的,目的是用来保证我们访问或者操作表时的正确性。这里说的正确性,主要是说查询的数据与表机构的正确定。如果我们拿到的数据和表结构对应不上,肯定是不可以的。当我们对一个表进行增删改查(DML)操作时,加MDL读锁,当我们对一个表结构做变更时(DDL),加MDL写锁。
MDL是系统自动加上的,不需要手动执行命令。
3.4MDL的读锁和写锁
和其他读写锁一样,MDL的读锁也是共享锁,写锁也是独占锁;读锁和写锁、写锁和写锁是互斥锁:
- MDL读锁,保证可以有多个线程对同一张表进行增删改查(DML)操作;
- MDL的读锁与写锁互斥,保证在读的时候,其他线程不能进行表结构的修改;写锁与写锁的互次,使得在持有写锁的时候,不能进行表中数据的增删改查,同时也不能有其他线程对该表结构进行修改。
但是MDL读锁和写锁,有个特殊的机制,就是当表有阻塞的写锁时,那么后面的读锁也都要阻塞。举个现实中的例子,A、B、C三个都学都在远眺欣赏远处的风景(读),此时在A、B、C三个同学后面来了一个D同学,D同学个子很高,这样D同学后面来的E、F同学由于D同学的阻挡,导致也无法看到远处的风景,被阻塞了。正式这个机制,我们再表加字段或者索引的时候,处理不好,就会导致线上数据库出现较大的影响,甚至产生生产事故。下面我们以给表加字段的案例,详细说一下这个机制。
3.5如何安全的给对表进行DDL的操作
图中红色标注的代表被阻塞的线程。
session A启动时,要获得表t 一个MDL读锁,session B启动时也要获得一个MDL锁,根据MDL读锁是共享的,所以A和B都能正常运行。session C启动时,要获得表t的一个MDL写锁,但是这是A和B都还持有t的读锁,由于读锁和写锁是互斥的,所以session C被阻塞,那么问题来了,为什么session D也被阻塞了呢?这就是上面我提到的MDL读锁和写锁的一个特殊机制,也就说session C写锁被阻塞后,那么session C后面的读锁也要被session C阻塞。
基于上面的分析,假如一个表的查询语句非常的频繁,而表的数据量也相对较大,那么我们执行session C的时候,这个MDL的写锁(数据量大,阻塞的时间就会长)就会阻塞后面的所有连接MDL锁,更致命的如果客户端还有重试机制,就会导致客户端不停的与数据库建立连接,就会导致把数据库的连接都会消耗殆尽,导致数据库不可用。这就是为什么我们现在客户端连接池都会有个最大连接数的限制,其实我考虑也有一定的原因是在保护数据库。
这时候我们会说,既然给大表添加字段可能会导致数据库的不可用,那么给小表添加字段是不是就不会有这样的问题了呢?其实,给小表的添加字段,也可能会出现问题。我们举个例子分析一下。假如我们有一张表s,它是一张热点表,这时候如果这个表s存在一个长事务,我们称为session A,然后我session B给表s添加字段,这时候仍然会导致session B后面的连接阻塞。为什么呢?因为事务中的MDL锁,是在语句执行开始时申请,但是语句执行结束后并不会马上释放,而是会等到整个事务提交后才会释放释放,所以session B由于session A的长事务,导致拿不到MDL写锁,造成阻塞,session B后面的连接由于session B的阻塞,导致拿不到MDL锁,也就会出现数据库性能的降低,极端情况下,也会造成事务的不可用。
那么应该怎么安全地给小表加字段呢?我们就要解决长事务。我们可以使用
SELECT * FROM information_schema.INNODB_TRX
来查看当前正在执行的事务,如果是长事务,我们可以考虑等事务执行结束再给表添加字段或者直接kill掉这个长事务,然后再给表添加字段。这种解决办法只有在长事务是偶尔存在的情况下,才可能会有效,如果长事务一直存在,即使我们kill掉这个事务,也会马上有新的长事务进来,所以这并不能从根本上解决问题。
但是可喜的是,现在阿里云上的RDS加入了DDL fast fail机制,。即在 alter table 语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到 MDL 写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。之后开发人员或者 DBA 再通过重试命令重复这个过程.其实现方式如下:
alter table tabl_name no_wait/wait 1 add column ...;
在ddl语句中,增加了no_wait/wait 1语法支持。
DDL fast fail并没有解决真正DDL过程中的阻塞问题,但避免了因为DDL操作没有获取锁,进而导致业务其他查询/更新语句阻塞的问题。这个功能是首先在AliSQL上有的,然后MariaDB也合并了这个功能,我们可以尝试使用下。
上面我们只是用给表添加字段来说明了阻塞的问题,其实所有的DDL语句的执行,都会有这样的问题。
同时,这也是我们强调为什么要避免长事务的很重要的原因之一。
4.–single-transaction备份期间的影响
在第2部分中,我们说了使用–single-transaction 方法在从库上做数据库备份,那么如果在一个主库上的表中加入了一个字段,那么在从库备份期间,从库上有会有什么影响呢?
我们先看下数据备份过程中的关键的几个步骤:
# Q1,设置事务的隔离级别是可重复读
Q1:SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
# Q2,启动事务,并且立刻开启事务的一致性视图
Q2:START TRANSACTION WITH CONSISTENT SNAPSHOT;
# Q3,设置事务开始的保存点
/* other tables */
Q3:SAVEPOINT sp;
/* 时刻 1 */
# Q4,拿到t1的表结构
Q4:show create table `t1`;
/* 时刻 2 */
# Q5,查询导入数据
Q5:SELECT * FROM `t1`;
# Q6,回滚到事务的保存点,释放表t1的MDL锁
/* 时刻 3 */
Q6:ROLLBACK TO SAVEPOINT sp;
/* 时刻 4 */
/* other tables */
- 如果binlog是在Q4语句执行之前到达,影响:没有影响,此时备份拿到的是DDL后的数据;
- 如果binlog是Q4和Q5之间的时刻2到达,则表结构被修改过了,影响:Q5 执行的时候,报 Table definition has changed, please retry transaction,现象:mysqldump 终止;
- 如果binlog是在Q5和Q6之间到达,即在时刻2和时刻3之间,由于mysqldump 一直占用着MDL读锁,binlog就会阻塞,影响:主从复制出现延迟,直到Q6执行完成;
- 如果是从Q6执行完成后到达,即时刻4开始,mysqldump 释放了MDL读锁,影响:没有影响,此时备份拿到的是DDL前的表结构。
所以,我们建议,在数据从库备份期间,不要对主库执行DDL的相关命令。
5.行锁(InnoDB)
5.1行锁简要
在第1部分,我们已经提到过,行锁是由存储引擎来实现的,不同的存储引擎对行锁的实现是不一样的,比如MyISAM就不支持行锁,我们这里提到的行锁也是基于InnoDB存储引擎来说明的。
行锁,就是对针对表的数据行所加的锁,比如事务A更新了id=1的这一行,同时,事务B也要更新id=1的这一行,那么事务B必须要等到事务A提交后,才能执行事务B的更新操作。
5.2两阶段锁协议
什么是两阶段锁协议,刚接触这个说法可能会有些懵。在InnoDB事务中,行锁并不是事务启动的时候加上的,而是在真正需要的时候才(比如执行更新语句的时候)加上的,这个行锁一旦加上,并不会马上释放(即:不是语句执行完就结束),而是要等整个事务提交完成或者回滚完成才会事务,这就是两阶段锁协议。为了印证上面的概念,我们做个简单的实验:
我先在有一个文章阅读量的表:
在窗口1中,我希望执行如下语句:
begin;
update t_article_view_num SET view_num=2 WHERE id =1;
这里我估计没有写commit,这样事务就没有提交;
然后我再开一个窗口2,希望执行如下命令:
BEGIN;
update t_article_view_num SET view_num=2 WHERE id =1;
COMMIT;
首先,我执行窗口1中的命令:
然后我再执行窗口2中的命令:
我们可以清楚的看到,窗口2中的更新语句被阻塞了,也就是窗口2中更新语句没有拿到id=1的这行数据的行锁,因为窗口1中的事务没有提交,行锁还没有释放。
5.2实际应用两阶段锁协议
假设在一个简单的电商系统中,客户A要在商户B中购买一双丝袜,丝袜价值20元,我们这个简单的流程操作如下:
- 从客户A的账户中扣除购买丝袜的所需的20元钱;
- 给商户B的账户中增加卖丝袜所得的20元钱;
- 记录一条交易记录。
需要执行的SQL语句如下(这里没有不考虑考虑并发的情况下造成的数据准确性问题):
1:update user_account set 余额=余额-20 where id=客户A;
2: update company_account set 余额=余额+20 where id =商户B;
3: insert into trade_log ......;
假如这时候客户C也要从商户B中购买一双丝袜,那么这两个事务中就都会有一个给商户B增加更新余额的操作,那么我们应该怎么样安排这三条执行语句呢?
根据两阶段锁协议,不论我们怎么安排三条语句的执行顺序,事务中持有的行锁都需要在事务提交后才能释放,所以,如果我们把语句2放到最后,按照3—>2—>1的顺序执行,那么影响商户B余额的这行锁的时间就最少,这样就大大减少了事务之间锁的等待时间,加快了系统的响应时间,提高了并发度。
5.3死锁
关于死锁这个概念,大家可能都不会陌生。当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放锁持有的锁而进入无限等待的状态,我们称为死锁。下面我以一个简单的图形来说明一下死锁。
事务 A 在等待事务 B 释放 id=2 的行锁,而事务 B 在等待事务 A 释放 id=1 的行锁。 事务 A 和事务 B 在互相等待对方的资源释放,就是进入了死锁状态。
5.4死锁的解决策略
5.4.1死锁的解决策略—相互等待,直到超时
发生死锁时,我们可以不做处理,直接让多个线程相互等待,默认死锁的发生,然后我们通过设置innodb_lock_wait_timeout 这个超时参数,超过这个时长,第一个被锁住的线程直接退出,释放锁,让其他线程继续执行。MySQL中,innodb_lock_wait_timeout 的默认值是50s,也就意味着发生死锁后,在线服务至少要等待50S才能正常进行后续的业务,这显然是不可接受的。你可能会说,这简单,我可以把这个值设置成1S,的确,这样是完全能够解决发生死锁时,等待时间过长的问题。但是这有个最大的问题就是,如果没有发生死锁,只是简单的锁等待,只不过是因为事务执行的时间稍微长一些,这样就会发生很多的误伤,显然也是不可取。所以,如果能够提前预防死锁的发生,那就好了。既然我们都想到,我相信MySQL的设计者肯定也想到了,不错,MySQL提供了一个死锁检测的机制,我们接着往下看。
5.4.2死锁的解决策略—主动死锁检测
MySQL直接发起死锁检测,发生死锁后,主动回滚死锁链条中的某条事务,让其他事务能够继续执行。在MySQL中,我们将innodb_deadlock_detect这个参数设置为“on”(默认值也是on),就代表开启死锁检测。注意:MySQL默认是开启死锁检测的。主动死锁检测在死锁发生的时候,能够快速发现并进行处理,保证后续业务的正常进行。说到这里,你可能以为已经万事大吉了,其实,每种方案都可能会产生其他不良的副作用,同样,主动死锁检测也会有其额外的负担,我们具体来看一下。
假如一行数据的更新频率非常非常快,那么每个新来的被锁住的线程,都要主动检测会不会因为自己的加入而导致死锁的发生。简单来说,这里的检测算法就是拿当前被锁住的线程与其他更新这一行的每一个线程做比较来检测,这个时间复杂度就是O(n)。如果有1000个并发线程要更新这一行,那么主动死锁检测就是1000*1000=1百万这个数量级,这是非常恐怖的,即便没有死锁的发生,但是这个检测却要耗费大量的cpu资源。这就是有时候我们看到MySQL数据所在服务器的cpu很高,但是没秒真正执行的事务数量却并不是很多。
所以对于热点行更新导致的性能问题,最根本的原因就是死锁检测导致的。那怎么能够解决这个问题呢?
我首先能能到的就是关闭死锁检测。哈哈,使用这种方式,就又回到了死锁等待直到超时的策略,而这种策略或导致大量的超时,这对业务就是有损的。
其次,既然是因为并发数量太大,导致的死锁检测的数量级大,那我们就控制并发的数量。控制并发数量有两种方法:
- 在客户端直接控制并发的数量,在一个中小型项目中,如果客户端不是太多,这种方式是可以的;但是如果客户端很多,那么即使控制每个客户端并发的数量很小,那么所有客户端汇总起来,整体的并发还是会很大的;
- 直接修改MySQL的源码,在数据库服务端做并发控制。(有难度,需要熟悉MySQL源码及设计原理)
- 减少行的锁冲突。我们可以将一行的逻辑改成多行,来减少行锁的冲突。还是以上面的电商系统,我们可以把商户B的余额分成10行来存储,每行存储一部分的余额,需要汇总的时候,就把10行的余额数据累加起来。这样,我们每次更新商户B的余额时,可以随机选择一条记录进行更新,这样每次冲突的概率就是原来的1/10,减少了锁等待的个数,也就减少了主动死锁检测时cpu的消耗。
5.5小问题
-
假如我要删除article表中前20000行数据,我给出了下面这三种方式:
- 1.在一个连接中直接执行delete from article limt 20000;
- 2.在一个连接中,循环20次 delete from article limit 1000;
- 3.在20个连接中并发(同时)执行delete from article limit 1000;
那么哪种方案更好呢?
第2种方式更好一些。
第1种方式,执行的事务时间比较长,那么锁的时间也就很长,并且大事务还会导致主从的延迟;
第3种方式,会造成锁冲突,产生大量的死锁检测,耗费cpu资源。
-
我们知道,innodb行级锁是通过锁索引记录实现的。如果要update的列没建索引,即使只update一条记录也会锁定整张表吗?比如update article set title=‘mazhonghua’ where title=‘lizhonghua’; title字段无索引。
是的,如果要更新的列没有建索引,那就会锁定整张表。但是如果我们把上面的更细语句改成如下:
update article set title=‘mazhonghua’ where title=‘lizhonghua’ limit 1;情况就又会不一样的。
- 当加上limit1之后 更新语句的执行流程是先去查询在去更新,也就是查询sql为 select * from article where title= “lizhonghua” limit 1 for update,相当于扫描主键索引找到第一个满足title="lizhonghua"的条件为止,此时锁的区间为(0,当前行的id],如果在这个id之后的更新和插入时都不会锁住的,在这个id之前的更新和插入会阻塞,之后则不会阻塞;
- 如果不加limit 1的话,因为此时是整个主键索引全表扫描则整个表锁住了;
- 如果title是普通索引,那么在更新操作时普通索引会锁住的同时,如果更新操作需要回表的话对应的主键索引也会存在锁(主键索引锁临界锁会退化为行锁),普通索引(间隙锁和行锁)
5.6SELECT … LOCK IN SHARE MODE 和SELECT … FOR UPDATE
我们知道,在同一个事务中,我们通过select语句查询结果,然后将查询的结果做一定的加工或者不加工,然后再执行修改或者插入的操作,其实对数据来说是不安全的,或者说数据不一定是正确的。因为其他的事务也同样可以修改或者删除你正在查询的行。所以MySQL的InnoDB引擎提供了两种可以提供安全机制的读锁:
SELECT … LOCK IN SHARE MODE和SELECT … FOR UPDATE,这两个都是行锁。
5.6.1SELECT … LOCK IN SHARE MODE
SELECT … LOCK IN SHARE MODE在读取的行上设置一个共享锁,其他session可以读这些行,其它的事务也可以获得这个共享锁,但是在你提交事务之前,其他sesssion不能修改他们。相反的,如果这些行里有其他还没有提交的事务修改,那么你的SELECT … LOCK IN SHARE MODE这个查询也要等到那个事务提交结束之后才能读,否则就一直在阻塞中。
所以,我们称它为共享锁(S锁,share locks)。如果对某行数据加上共享锁之后,可进行读写操作;其他事务也可以对改数据获得共享锁,但是不能加排他锁,且只能读数据,不能修改数据。下面我们做实验验证一下。
5.6.1.1 验证
我这还是以上面的文章阅读量的表为例。
5.6.1.1.1 当前事务获取共享锁后,其他session可读
第二个session中查询并没有出现阻塞。
5.6.1.1.2 当前事务获取共享锁后,其他事务也可以获取共享锁
我们看到,第二个事务可以拿到共享锁,没有阻塞。
5.6.1.1.3 当前事务获取共享锁后,其他事务不能修改该行数据
第二个事务的更新操作阻塞了,等第一个事务提交,共享锁释放了,第二个事务自动就可以更新了。
5.6.1.1.4 两个事务获取共享锁后,其中一个事务也不能修改该行数据,必须等另一个事务提交释放锁后
我们看到第二个事务中的更细语句阻塞了。
5.6.1.1.5 存在其他没提交的事务修改,当前事务不能获取共享锁
第二个事务获取共享锁时阻塞了。
5.6.2SELECT … FOR UPDATE
SELECT … FOR UPDATE是要获取数据行上的排他锁(X锁,exclusive locks)。事务对数据加上排他锁,那么其他事务就拿不到该数据的任何锁。获取到排他锁的事务既能读取数据,也能修改数据。
5.6.2.1 验证
5.6.2.1.1当前事务获取某行数据排他锁后,他事务可以读
第二个事务的读没有发生阻塞。
5.6.2.1.2当前事务获取某行数据排他锁后,他事务不可以获取共享锁
第二个事务出现阻塞。
5.6.2.1.3当前事务获取某行数据排他锁后,他事务不可以写
第二个事务的修改操作发生阻塞。
5.6.2.1.2当前事务获取某行数据排他锁后,他事务不可以获取排他锁
第二个事务获取排他锁时出现阻塞。
6.悲观锁和乐观锁
6.1 悲观锁和乐观锁的基本原理
上面我提到了,SELECT … FOR UPDATE 可以获得排他锁,其实它也是我们平时锁称为的悲观锁。
所谓悲观锁,就是说很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会加上锁,这样就会导致别人想拿到这个数据就会阻塞知道它拿到锁为止,它是从数据库层面上做并发控制,去加锁。
所谓乐观锁,就是说很乐观,是一种不会阻塞其他线程并发的机制,它不使用数据库的锁机制,正是由于它不会阻塞其他线程,所以不会引发线程的频繁挂起和恢复,能够提高并发能力。虽然它叫乐观锁,其实它并没有锁,它的实现完全是靠CAS(Compare And Swap)原理来实现的。
在CAS原理中,对于多个线程共同的资源,先保存旧值(Old Value),比如进入线程后,查询当前的存量为100个红包,那么先把旧值保存为100,然后经过一定的逻辑处理。当需要扣减红包时,先比较数据的当前值和旧值是否一致,如果一致则进行扣减红包的操作,否则就认为它已经被其他线程修改过了。
6.2 基于悲观锁和乐观锁的秒杀实践
这里我使用springboot+mybatis。
数据表:
DROP TABLE IF EXISTS `t_product`;
CREATE TABLE `t_product` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品id',
`name` varchar(120) NOT NULL COMMENT '商品名称',
`product_num` int(11) NOT NULL COMMENT '库存数量',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='秒杀商品表';
-- ----------------------------
-- Records of t_product
-- ----------------------------
INSERT INTO `t_product` VALUES ('1', 'test1', '100');
INSERT INTO `t_product` VALUES ('2', 'test2', '100');
Mapper层:
@Mapper
public interface ProductMapper {
@Select("select * from t_product where id = #{id}")
Product selectById(@Param("id") Long id);
/**
* 使用原有库存的判断来更新库存
*
* @param id 商品id
* @param num 商品原有库存
* @return
*/
@Update("update t_product set product_num=product_num-1 where id = #{id} and product_num=#{num}")
int updateProductNumByIdWithOldNum(@Param("id") Long id, @Param("num") Integer num);
/**
* 查询时使用悲观锁
*
* @param id
* @return
*/
@Select("select * from t_product where id = #{id} for update")
Product selectByIdWithLock(@Param("id") Long id);
/**
* 直接更新库存
*
* @param id 商品id
* @return
*/
@Update("update t_product set product_num=product_num-1 where id = #{id} ")
int updateProductNumById(@Param("id") Long id);
Service:
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
public Product selectById(Long id) {
return productMapper.selectById(id);
}
/**
* 使用乐观锁进行秒杀
* 隔离级别使用读已提交
* 共进行三次重试
*
* @param productId
* @param userId
*/
@Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_COMMITTED)
public void startSeckilWithOptimismLock(long productId, long userId) {
for (int i = 0; i < 3; i++) {
Product product = productMapper.selectById(productId);
//库存大于0个就可以秒杀
if (product.getProductNum().intValue() > 0) {
int result = productMapper.updateProductNumByIdWithOldNum(product.getId(), product.getProductNum());
if (result == 0) {
System.out.println(userId + "第" + i + "次秒杀失败");
continue;
} else {
System.out.println(userId + "第" + i + "秒杀成功");
break;
}
}
}
}
/**
* 使用悲观锁进行秒杀
* 隔离级别使用读已提交
* 共进行三次重试
*
* @param productId
* @param userId
*/
@Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_COMMITTED)
public void startSeckilWithPessimisticLock(long productId, long userId) {
Product product = productMapper.selectByIdWithLock(productId);
//库存大于0个就可以秒杀
if (product.getProductNum().intValue() > 0) {
int result = productMapper.updateProductNumById(product.getId());
if (result > 0) {
System.out.println(userId + "秒杀成功");
} else {
System.out.println(userId + "秒杀失败");
}
}
}
}
单元测试:
@SpringBootTest
@EnableTransactionManagement
class SkillTests {
@Autowired
private ProductService productService;
@Test
void contextLoads() {
}
/**
* 测试乐观锁秒杀
*/
@Test
public void teststartSeckilWithOptimismLock() {
//秒杀者数量
int userNum = 1000;
final CountDownLatch latch = new CountDownLatch(userNum);
//商品id
final long productId = 1L;
ThreadPoolExecutor executor = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(), Runtime.getRuntime().availableProcessors() + 1, 10L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000));
for (int i = 0; i < userNum; i++) {
final long userId = i;
Runnable task = () -> {
productService.startSeckilWithOptimismLock(productId, userId);
latch.countDown();
};
executor.execute(task);
}
try {
// 等待所有人任务结束
latch.await();
Product product = productService.selectById(productId);
System.out.println("秒杀结束,剩余库存:" + product.getProductNum());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* 测试悲观锁秒杀
*/
@Test
public void startSeckilWithPessimisticLock() {
//秒杀者数量
int userNum = 1000;
final CountDownLatch latch = new CountDownLatch(userNum);
//商品id
final long productId = 2L;
ThreadPoolExecutor executor = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(), Runtime.getRuntime().availableProcessors() + 1, 10L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000));
for (int i = 0; i < userNum; i++) {
final long userId = i;
Runnable task = () -> {
productService.startSeckilWithPessimisticLock(productId, userId);
latch.countDown();
};
executor.execute(task);
}
try {
// 等待所有人任务结束
latch.await();
Product product = productService.selectById(productId);
System.out.println("秒杀结束,剩余库存:" + product.getProductNum());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}