MySQL锁解决并发问题详解

文章分为以下几个要点

  1. 问题描述以及解决过程
  2. MySQL锁机制
  3. 数据库加锁分析

下面讨论的都是基于MySQL的InnoDB。

0. 问题描述以及解决过程

因为涉及到公司利益问题,所以下面很多代码和数据库信息,进行了缩减和修改,望见谅。

业务场景是优惠券系统规则规定了一个优惠券活动最多可发行多少张优惠券和每个用户最多可领取优惠券数量。

下面列出两张表的结构。

活动表

CREATE TABLE `coupon_activity` (
  `act_id` int(11) NOT NULL AUTO_INCREMENT COMMENT ‘主键‘,
  `act_code` char(6) NOT NULL DEFAULT ‘‘ COMMENT ‘活动编码‘,
  `coup_issue_num` int(11) NOT NULL DEFAULT ‘0‘ COMMENT ‘优惠券发行量‘,
  `coup_per_num` int(11) NOT NULL DEFAULT ‘0‘ COMMENT ‘单个用户可领取数‘,
  PRIMARY KEY (`act_id`),
  UNIQUE KEY `act_code_idx` (`act_code`) COMMENT ‘活动编码唯一索引‘
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

优惠券明细表

CREATE TABLE `coupon_detail` (
  `coup_id` int(11) NOT NULL AUTO_INCREMENT,
  `act_code` char(6) NOT NULL DEFAULT ‘‘ COMMENT ‘活动编号‘,
  `coup_code` char(6) NOT NULL DEFAULT ‘‘ COMMENT ‘优惠券编码‘,
  `coup_user_id` int(11) NOT NULL DEFAULT ‘0‘ COMMENT ‘领取券用户id‘,
  PRIMARY KEY (`coup_id`),
  UNIQUE KEY `coup_code_idx` (`coup_code`) USING BTREE COMMENT ‘优惠券编码唯一索引‘,
  KEY `coup_user_idx` (`coup_user_id`) USING BTREE COMMENT ‘用户id普通索引‘,
  KEY `act_code_idx` (`act_code`) USING BTREE COMMENT ‘活动编码普通索引‘
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT=‘优惠券明细表‘;

假设一个优惠券活动设置的最大发行量为1000张优惠券,单个用户最多可领取1张优惠券。如下

insert into coupon_activity values (1,‘000000‘,1000,1,0);

不考虑到并发的话,用户10领取act_code=‘000000‘活动的优惠券 执行的sql如下。注意#{}里面的字段表示之前的sql查询出来的字段。

begin;
select * from coupon_activity where act_code = ‘000000‘;
select count(coup_id) as count_all from coupon_detail where act_code = #{act_code};
select count(coup_id) as count_per from coupon_detail where coup_user_id = 10 and act_code = #{act_code};
//插入明细表 首先判断是否当前领用量小于活动发行量,当前用户领取量是否小于每个用户可领取数
if(#{count_all} < #{coup_issue_num} && #{count_per} < #{coup_per_num}){
    insert into coupon_detail values(1,act_code,‘000000‘,10);
}
commit;

其实上面的代码不需要用到事务,但是为了体现接下来的并发时的情形,我就加上了事务。

首先我们来讨论,最大发行量发生并发时的问题。

假设现在优惠券领取了999张,此时有两个用户进来领取,也就是两个事务同时进行,

首先两个事务数出活动当前领用量都是999张,所以if判断通过,两个用户都可以执行insert语句,这样的话就会多出一张券没有限制住。

如何来解决这个问题呢,其实比较简单,可以利用乐观锁的方式(后面会详细介绍锁的知识),就是我假设并发不会发生,但是我在update数据的时候我会判断他是否满足条件。 此时我们就要另外借助一个字段来完成coup num current:当前券领用量。每次领券后我们都要更新这个字段,使其加1. 此时我们加个判断看起是否小于最大 发行量。

alter table coupon_activity add coup_num_current int(11) NOT NULL DEFAULT ‘0‘ COMMENT ‘当前券领用量‘;

那么此时的执行代码就变成了下面这样的。

begin;
select * from coupon_activity where act_code = ‘000000‘;
select count(coup_id) as count_per from coupon_detail where coup_user_id = 10 and act_code = #{act_code};
//插入明细表 首先判断是否当前领用量小于活动发行量,当前用户领取量是否小于每个用户可领取数
if(#{count_per} < #{coup_per_num}){
    insert into coupon_detail values(1,act_code,‘000000‘,10);
}
int i = update coupon_activity set coup_num_current = coup_num_current + 1 where act_code = #{act_code} and coup_num_current < #{coup_issue_num}
//如果未有数据更新  (表明不满足coup_num_current < #{coup_issue_num}即已达最大领用量)
if(i == 0){
    throw new Exception("此处是为了让之前的insert回滚");
}
commit;

此时我们解决了最大领用量的并发问题,下面我们来讨论下如何限制住单个用户可领取数,这个要复杂一些。因为设计到的操作要多一些,首先你要统计出已经领取的数量, 然后插入新数据,而不像限制优惠券发行量那样只有一个update语句,所以用之前的那种乐观锁不行,因为统计出来的数据很有可能是脏数据,那么乐观锁不行的话,那用 悲观锁来解决呢?先来分析下,伪代码如下

#统计出单个用户领取该券的数量,上了悲观锁
select count(coup_id) as count_per from coupon_detail where coup_user_id = 10 and act_code = #{act_code} for update;
if(#{count_per} < #{coup_per_num}){
    insert into coupon_detail values(1,act_code,‘000000‘,10);
}

分析一下上面的select count语句可以发现他对coup user id = 10 and act code = ‘000000‘的数据上了锁,但是我们接下来要做的操作是insert操作,而不是update操作。 当两个事务刚进来的时候统计的数据都为0,也没办法给coup user id = 10 and act code = ‘000000‘的数据上锁,所以两个selec count for update 都能执行, 那么后面的insert操作也自然能成功,但是当有数据的时候,其中一个select for update会等待,这样的话就能成功。

这样的话悲观锁也是不行的,但是其实我们再回过头来想一下乐观锁为什么不行,是因为他分为了两个语句,而前面那个语句select count可能会读到脏数据,那么后面的利用某个字段去 update时判断值就有可能不对,那么如何保证统计的数据跟判断保持一致呢,因为mysql处理语句的时候是一条一条处理的,所以我们通过写成一条sql就可以达到前后数据一致问题。

此处我们使用insert的时候统计出当前领取数,并与可领取数进行对比,伪代码如下

select * from coupon_activity where act_code = ‘000000‘;
insert into coupon_detail (coup_id,act_code,coup_code,coup_user_id) select (coup_id,act_code,coup_code,coup_user_id) from (select count(id) as num from coupon_detail where coup_user_id = 10 and act_code = ‘000000‘)temp where temp.num < #{coup_per_num} 

上面这条复杂的sql在高并发时会发生死锁的情况,但是确能得到正确的结果。我们来分析一下死锁的情形。

上面这条语句最里面的select where coup-user-id = 10 and act-code = ‘000000‘ 会锁住这一行数据,但是当数据库没有值的时候,就上不了锁,那么另外一个事务的select也能查询, 但是两个事务都对coup_user-id = 10 and act-code = ‘000000‘上锁了,那么insert的时候两者都处于等待对方释放锁的状态,所以就发生了死锁,数据库解决死锁之后,只有一条数据 插入成功,这样也就得到了我们需要的结果。

在InnoDB中,锁是逐步获得的,因此发生死锁是可能的。发生死锁后,InnoDB一般都能自动检测到,并使一个事务释放锁并回退,另外一个事务获得锁,并继续完成事务。但在涉及外部锁,或涉及表锁的情况下,InnoDB并不能完全自动检测到死锁,这需要通过设置锁等待超时参数innodb lock wait_timeout来解决。

1. mysql锁机制

InnoDB存储引擎既支持行级锁(row-level locking),也支持表级锁,但默认情况下是采用行级锁。

表级锁:开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。 行级锁:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。

innodb 行级锁 record-level lock大致有三种:record lock, gap lock and Next-KeyLocks。

record lock 锁住某一行记录 
gap lock 锁住某一段范围中的记录 next key lock 是前两者效果的叠加。

nnoDB实现了以下两种类型的行锁:

  • 共享锁:允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁;
  • 排他锁:允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。

为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB还有两种内部使用的意向锁(意向共享锁和意向排他锁)。这两种意向锁都是表锁。意向锁是InnoDB自动加的,不需要用户干预。 对于UPDATE、DELETE和INSERT语句,InnoDB会自动给涉及数据集加排他锁;对于普通SELECT语句,InnoDB不会加任意锁。

事务可以通过以下语句显示给记录集加共享锁或者排他锁:

SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE  #共享锁
SELECT * FROM table_name WHERE ... FOR UPDATE  #排他锁

InnoDB的行锁实现的特点:只有通过索引条件检索数据,InnoDB才会使用行级锁,否则,InnoDB将会使用表锁。因为MySQL的行锁是针对索引加的锁, 而不是针对记录加的锁,所以虽然是访问不同行的记录,但是如果是使用相同的索引建,是会出现锁冲突的。

对于键值在条件范围内但并不存在的记录,叫做间隙。InnoDB会对这个间隙加锁,这种锁机制就是所谓的间隙锁(Next-Key锁)。 InnoDB使用间隙锁的目的:一是为了防止幻读,二是为了满足其恢复和复制的需要。

InnoDB如何解决死锁问题的:

在InnoDB中,锁是逐步获得的,因此发生死锁是可能的。发生死锁后,InnoDB一般都能自动检测到,并使一个事务释放锁并回退,另外一个事务获得锁,并继续完成事务。但在涉及外部锁, 或涉及表锁的情况下,InnoDB并不能完全自动检测到死锁,这需要通过设置锁等待超时参数innodb lock wait_timeout来解决。

2. 数据库加锁分析

MySQL InnoDB存储引擎,实现的是基于多版本的并发控制协议——MVCC (Multi-Version Concurrency Control) (注:与MVCC相对的, 是基于锁的并发控制,Lock-Based Concurrency Control)。MVCC最大的好处,相信也是耳熟能详:读不加锁,读写不冲突。 在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能,这也是为什么现阶段,几乎所有的RDBMS,都支持了MVCC。

在MVCC并发控制中,读操作可以分成两类:快照读 (snapshot read)与当前读 (current read)。快照读,读取的是记录的可见版本 (有可能是历史版本), 不用加锁。当前读,读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。

在一个支持MVCC并发控制的系统中,哪些读操作是快照读?哪些操作又是当前读呢?以MySQL InnoDB为例:

快照读:简单的select操作,属于快照读,不加锁。(当然,也有例外,下面会分析)

select * from table where ?;

当前读:特殊的读操作,插入/更新/删除操作,属于当前读,需要加锁。

select * from table where ? lock in share mode;
select * from table where ? for update;
insert into table values (…);
update table set ? where ?;
delete from table where ?;

所有以上的语句,都属于当前读,读取记录的最新版本。并且,读取之后,还需要保证其他并发事务不能修改当前记录,对读取记录加锁。 其中,除了第一条语句,对读取记录加S锁 (共享锁)外,其他的操作,都加的是X锁 (排它锁)。

2.1 事务隔离级别

对锁进行分析前必须要先了解事务隔离级别的关系

详细见, MySQL四种事务隔离级别

| 隔离级别 | 脏读(Dirty Read)| 不可重复读(NonRepeatable Read)| 幻读(Phantom Read)| |:-------------|:-------------:|:-------------:|:-------------:| | 未提交读(Read uncommitted)| 可能 | 可能 | 可能 | | 已提交读(Read committed) | 不可能 | 可能 | 可能 | | 可重复读(Repeatable read) | 不可能 | 不可能 | 可能 | | 可串行化(Serializable) | 不可能 | 不可能 | 不可能 |

  • 未提交读(Read Uncommitted):允许脏读,也就是可能读取到其他会话中未提交事务修改的数据
  • 提交读(Read Committed):只能读取到已经提交的数据。Oracle等多数数据库默认都是该级别 (不重复读)
  • 可重复读(Repeated Read):可重复读。在同一个事务内的查询都是事务开始时刻一致的,InnoDB默认级别。在SQL标准中,该隔离级别消除了不可重复读,但是还存在幻象读
  • 串行读(Serializable):完全串行化的读,每次读都需要获得表级共享锁,读写相互都会阻塞

MySQL InnoDB默认使用的级别是可重复读级别(Repeatable read),查找命令如下

mysql>select @@session.tx_isolation;
+------------------------+
| @@session.tx_isolation |
+------------------------+
| REPEATABLE-READ        |
+------------------------+
 row in set
  • 脏读 :当一个事务进行的操作还未提交时,另外一个事务读到了修改的数据,这就是脏读,但是RR级别事务避免了脏读。
  • 不可重复读 :是指在一个事务内,多次读同一数据。在这个事务还没有结束时,另外一个事务也访问该同一数据。 那么,在第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的的数据可能是不一样的。 这样就发生了在一个事务内两次读到的数据是不一样的,因此称为是不可重复读。但是,RR级别是不会出现不一样的结果的,即使另一个事务提交了修改他也查不到变化。
  • 幻读 :第一个事务对一个表中的数据进行了修改,这种修改涉及到表中的全部数据行。同时,第二个事务也修改这个表中的数据, 这种修改是向表中插入一行新数据。那么,以后就会发生操作第一个事务的用户发现表中还有没有修改的数据行,就好象发生了幻觉一样。

2.2 sql语句加锁分析

参考: MySQL加锁分析

#SQL语句1
select * from table where id = 1;
#SQL语句2
update set age = age + 1 where id = 1;
#SQL语句3
update set age = age + 1 where id = 1 and nickname = ‘hello‘;

首先我们可以确定的是语句1,他是不加锁的,属于快照读。语句2和语句3要复杂些,我们慢慢来分析。

下面我们默认事务级别为可重复读(Repeated Read),因为这是MySQL InnoDB默认级别。

语句2分析:

  1. 如果id是主键或者是索引的话,那么锁定的行只有符合条件的那几行。
  2. 如果id非索引,那么会锁表。

语句3分析:

  1. id或者nickname只要有一个是索引或者是主键的话,那么锁住的行都是符合条件的行。

但是要注意一个情况,如果你查看索引数据值存在大量重复的数据的话(重复的数要是where条件值),那么有可能条件是不会走索引,而是进行全表查询,所以此时锁住的也是全表。

因为索引扫描书超过30%时,会进行全表扫描。

时间: 2024-11-05 08:37:02

MySQL锁解决并发问题详解的相关文章

深入MySQL用户自定义变量:使用详解及其使用场景案例

一.前言 在前段工作中,曾几次收到超级话题积分漏记的用户反馈.通过源码的阅读分析后,发现问题出在高并发分布式场景下的计数器上.计数器的值会影响用户当前行为所获得积分的大小.比如,当用户在某超级话题下连续第n(n即计数器的值)次进行转发帖子时,将会获得与n相关的分数.然而,在第一次改进后问题依然存在.所以,这次在之前的基础上,通过使用MySQL变量的途径来解决该问题. 二.到底MySQL的变量分哪几类? MySQL变量一共分为两大类:用户自定义变量和系统变量.如下: 用户自定义变量 局部变量 会话

乐观锁与悲观锁——解决并发问题(转)

引言 为什么需要锁(并发控制)? 在多用户环境中,在同一时间可能会有多个用户更新相同的记录,这会产生冲突.这就是著名的并发性问题. 典型的冲突有: 丢失更新:一个事务的更新覆盖了其它事务的更新结果,就是所谓的更新丢失.例如:用户A把值从6改为2,用户B把值从2改为6,则用户A丢失了他的更新. 脏读:当一个事务读取其它完成一半事务的记录时,就会发生脏读取.例如:用户A,B看到的值都是6,用户B把值改为2,用户A读到的值仍为6. 为了解决这些并发带来的问题. 我们需要引入并发控制机制. 并发控制机制

乐观锁与悲观锁——解决并发问题

悲观锁(Pessimistic Lock), 顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁.传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁. 乐观锁(Optimistic Lock), 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制.乐观锁适用于

(转)乐观锁与悲观锁——解决并发问题

引言 为什么需要锁(并发控制)? 在多用户环境中,在同一时间可能会有多个用户更新相同的记录,这会产生冲突.这就是著名的并发性问题. 典型的冲突有: 丢失更新:一个事务的更新覆盖了其它事务的更新结果,就是所谓的更新丢失.例如:用户A把值从6改为2,用户B把值从2改为6,则用户A丢失了他的更新. 脏读:当一个事务读取其它完成一半事务的记录时,就会发生脏读取.例如:用户A,B看到的值都是6,用户B把值改为2,用户A读到的值仍为6. 为了解决这些并发带来的问题. 我们需要引入并发控制机制. 并发控制机制

CentOS 6.5 MySQL/MariaDB数据备份与恢复备份详解

MySQL/MariaDB数据备份与恢复备份 数据对我们来说再重要不过了,那我们如何做到对数据尽可能的安全呢,当我们的数据丢失了那又该怎么做呢,所以说数据备份对我们的数据安全性来说太重要了. 数据对我们来说再熟悉不过了,也最平常不过了,我们每天都在接触各色各样的数据,数据记录了我们平常相关的业务信息,所以数据对于我们来说是很重要的,这么重要的数据如果我们的数据丢失了那我们是不是相关的业务都没法进行了呢,这应该是个很麻烦的问题,那我们怎么保护我们的数据的安全呢,这就要用到我们的数据备份了. 如何执

mysql日志文件开启及详解:General_log 和 Binlog

使用记录: SHOW VARIABLES LIKE "general_log%"; SET GLOBAL general_log = 'ON'; SET GLOBAL general_log_file = 'd:\mysql.log'; General_log 详解 1.介绍 开启 general log 将所有到达MySQL Server的SQL语句记录下来. 一般不会开启开功能,因为log的量会非常庞大.但个别情况下可能会临时的开一会儿general log以供排障使用. 相关参数

图解MYSQL JOIN ON,SQL JOIN 详解,数据库sql join语句

对于SQL的Join,在学习起来可能是比较乱的.我们知道,SQL的Join语法有很多inner的,有outer的,有left的,有时候,对于Select出来的结果集是什么样子有点不是很清楚.Coding Horror上有一篇文章(实在不清楚为什么Coding Horror也被墙)通过 文氏图 Venn diagrams 解释了SQL的Join.我觉得清楚易懂,转过来. 假设我们有两张表. Table A 是左边的表. Table B 是右边的表. 其各有四条记录,其中有两条记录是相同的,如下所示

Mysql中关于 group_concat函数详解

group_concat()主要功能:能将相同的行组合起来 完整的语法如下: group_concat([DISTINCT] 要连接的字段 [Order BY ASC/DESC 排序字段] [Separator '分隔符']) 基本查询 Sql代码   select * from aa; +------+------+| id| name |+------+------+|1 | 10||1 | 20||1 | 20||2 | 20||3 | 200 ||3 | 500 |+------+---

MYSQL常用内置函数详解说明

函数中可以将字段名当作变量来用,变量的值就是该列对应的所有值:在整理98在线字典数据时(http://zidian.98zw.com/),有这要一个需求,想从多音字duoyinzi字段值提取第一个拼音作为拼音pinyin字段的值,如:duoyinzi(ā,á,ǎ,à,a),想提取ā作为pinyin的值:数据有好几万条,不想用程序一条条处理,只想用一个sql来实现,后来了解了下MYSQL常用内置函数,是可以做到的:sql:UPDATE ol_zidian set pinyin=LEFT(duoyi