众所周知,MySQL 的 InnoDB 存储引擎支持事务,默认是行锁。得益于这些特性,数据库支持高并发。如果 InnoDB 更新数据使用的不是行锁,而是表锁呢?是的,InnoDB 其实很容易就升级为表锁,届时并发性将大打折扣了。
经过我操作验证,得出行锁升级为表锁的原因之一是: SQL 语句中未使用到索引,或者说使用的索引未被数据库认可(相当于没有使用索引)。
常用的索引有三类:主键、唯一索引、普通索引。主键 不由分说,自带最高效的索引属性;唯一索引 指的是该属性值重复率为0,一般可作为业务主键,例如学号;普通索引 与前者不同的是,属性值的重复率大于0,不能作为唯一指定条件,例如学生姓名。接下来我要说明是 “普通索引对并发的影响”。
为什么我会想到 “普通索引对并发有影响”?这源于微信群抛出的一个问题:
mysql 5.6 在 update 和 delete 的时候,where 条件如果不存在索引字段,那么这个事务是否会导致表锁?
有人回答:
只有主键和唯一索引才是行锁,普通索引是表锁。
我针对 “普通索引是表锁” 进行了验证,结果发现普通索引并不一定会引发表锁,在普通索引中,是否引发表锁取决于普通索引的高效程度。
属性值重复率高
为了突出效果,我将“普通索引”建立在一个“值重复率”高的属性下。以相对极端的方式,扩大对结果的影响。
我会创建一张“分数等级表”,属性有“id”、“score(分数)”、“level(等级)”,模拟一个半自动的业务——“分数”已被自动导入,而“等级”需要手工更新。
操作步骤如下:
- 取消 MySQL 的 事务自动提交
- 建表,id自增,并给“score(分数)”创建普通索引
- 插入分数值,等级为 null
- 开启两个事务 session_1、session_2,两个事务以“score”为条件指定不同值,锁定数据
- session_1 和 session_2 先后更新各自事务锁定内容的“level”
- 观察数据库对两个事务的响应
取消 事务自动提交:
mysql> set autocommit = off;
Query OK, 0 rows affected (0.02 sec)
mysql> show variables like "autocommit";
+--------------------------+-------+
| Variable_name | Value |
+--------------------------+-------+
| autocommit | OFF |
+--------------------------+-------+
1 rows in set (0.01 sec)
建表、创建索引、插入数据:
DROP TABLE IF EXISTS `test1`;
CREATE TABLE `test1` (
`ID` int(5) NOT NULL AUTO_INCREMENT ,
`SCORE` int(3) NOT NULL ,
`LEVEL` int(2) NULL DEFAULT NULL ,
PRIMARY KEY (`ID`)
)ENGINE=InnoDB DEFAULT CHARACTER SET=utf8 COLLATE=utf8_general_ci;
ALTER TABLE `test2` ADD INDEX index_name ( `SCORE` );
INSERT INTO `test1`(`SCORE`) VALUE (100);
……
INSERT INTO `test1`(`SCORE`) VALUE (0);
……
"SCORE" 属性的“值重复率”奇高,达到了 50%,剑走偏锋:
mysql> select * from test1;
+----+-------+-------+
| ID | SCORE | LEVEL |
+----+-------+-------+
| 1 | 100 | NULL |
| 2 | 0 | NULL |
| 5 | 100 | NULL |
| 6 | 100 | NULL |
| 7 | 100 | NULL |
| 8 | 100 | NULL |
| 9 | 100 | NULL |
| 10 | 100 | NULL |
| 11 | 100 | NULL |
| 12 | 100 | NULL |
| 13 | 100 | NULL |
| 14 | 0 | NULL |
| 15 | 0 | NULL |
| 16 | 0 | NULL |
| 17 | 0 | NULL |
| 18 | 0 | NULL |
| 19 | 0 | NULL |
| 20 | 0 | NULL |
| 21 | 0 | NULL |
| 22 | 0 | NULL |
| 23 | 0 | NULL |
| 24 | 100 | NULL |
| 25 | 0 | NULL |
| 26 | 100 | NULL |
| 27 | 0 | NULL |
+----+-------+-------+
25 rows in set
开启两个事务(一个窗口对应一个事务),并选定数据:
-- SESSION_1,选定 SCORE = 100 的数据
mysql> BEGIN;
SELECT t.* FROM `test1` t WHERE t.`SCORE` = 100 FOR UPDATE;
Query OK, 0 rows affected
+----+-------+-------+
| ID | SCORE | LEVEL |
+----+-------+-------+
| 1 | 100 | NULL |
| 5 | 100 | NULL |
| 6 | 100 | NULL |
| 7 | 100 | NULL |
| 8 | 100 | NULL |
| 9 | 100 | NULL |
| 10 | 100 | NULL |
| 11 | 100 | NULL |
| 12 | 100 | NULL |
| 13 | 100 | NULL |
| 24 | 100 | NULL |
| 26 | 100 | NULL |
+----+-------+-------+
12 rows in set
再打开一个窗口:
-- SESSION_2,选定 SCORE = 0 的数据
mysql> BEGIN;
SELECT t.* FROM `test1` t WHERE t.`SCORE` = 0 FOR UPDATE;
Query OK, 0 rows affected
+----+-------+-------+
| ID | SCORE | LEVEL |
+----+-------+-------+
| 2 | 0 | NULL |
| 14 | 0 | NULL |
| 15 | 0 | NULL |
| 16 | 0 | NULL |
| 17 | 0 | NULL |
| 18 | 0 | NULL |
| 19 | 0 | NULL |
| 20 | 0 | NULL |
| 21 | 0 | NULL |
| 22 | 0 | NULL |
| 23 | 0 | NULL |
| 25 | 0 | NULL |
| 27 | 0 | NULL |
+----+-------+-------+
13 rows in set
session_1 窗口,更新“LEVEL”失败:
mysql> UPDATE `test1` SET `LEVEL` = 1 WHERE `SCORE` = 100;
1205 - Lock wait timeout exceeded; try restarting transaction
在之前的操作中,session_1 选择了 SCORE
= 100 的数据,session_2 选择了 SCORE
= 0 的数据,看似两个事务井水不犯河水,但是在 session_1 事务中更新自己锁定的数据失败,只能说明在此时引发了表锁。别着急,刚刚走向了一个极端——索引属性值重复性奇高,接下来走向另一个极端。
属性值重复率低
还是同一张表,将数据删除只剩下两条,“SCORE” 的 “值重复率” 为 0:
mysql> delete from test1 where id > 2;
Query OK, 23 rows affected
mysql> select * from test1;
+----+-------+-------+
| ID | SCORE | LEVEL |
+----+-------+-------+
| 1 | 100 | NULL |
| 2 | 0 | NULL |
+----+-------+-------+
2 rows in set
关闭两个事务操作窗口,重新开启 session_1 和 session_2,并选择各自需要的数据:
-- SESSION_1,选定 SCORE = 100 的数据
mysql> BEGIN;
SELECT t.* FROM `test1` t WHERE t.`SCORE` = 100 FOR UPDATE;
Query OK, 0 rows affected
+----+-------+-------+
| ID | SCORE | LEVEL |
+----+-------+-------+
| 1 | 100 | NULL |
+----+-------+-------+
1 row in set
-- -----------------新窗口----------------- --
-- SESSION_2,选定 SCORE = 0 的数据
mysql> BEGIN;
SELECT t.* FROM `test1` t WHERE t.`SCORE` = 0 FOR UPDATE;
Query OK, 0 rows affected
+----+-------+-------+
| ID | SCORE | LEVEL |
+----+-------+-------+
| 2 | 0 | NULL |
+----+-------+-------+
1 row in set
相同的表结构,相同的操作,两个不同的结果让人出乎意料。第一个结果让人觉得“普通索引”引发表锁,第二个结果推翻了前者,两个操作中,唯一不同的是索引属性的“值重复率”。根据 单一变量 证明法,可以得出结论:当“值重复率”低时,甚至接近主键或者唯一索引的效果,“普通索引”依然是行锁;当“值重复率”高时,MySQL 不会把这个“普通索引”当做索引,即造成了一个没有索引的 SQL,此时引发表锁。
小结
索引不是越多越好,索引存在一个和这个表相关的文件里,占用硬盘空间,宁缺勿滥,每个表都有主键(id),操作能使用主键尽量使用主键。
同 JVM 自动优化 java 代码一样,MySQL 也具有自动优化 SQL 的功能。低效的索引将被忽略,这也就倒逼开发者使用正确且高效的索引。
MySQL 表锁和行锁机制
MySQL的存储引擎是从MyISAM到InnoDB,锁从表锁到行锁。后者的出现从某种程度上是弥补前者的不足。比如:MyISAM不支持事务,InnoDB支持事务。表锁虽然开销小,锁表快,但高并发下性能低。行锁虽然开销大,锁表慢,但高并发下相比之下性能更高。事务和行锁都是在确保数据准确的基础上提高并发的处理能力。
案例分析
目前,MySQL常用的存储引擎是InnoDB,相对于MyISAM而言。InnoDB更适合高并发场景,同时也支持事务处理。我们通过下面这个案例(坑),来了解行锁和表锁。
业务:因为订单重复导入,需要用脚本将订单状态为"待客服确认"且平台是"xxx"的数据批量修改为"已关闭"。
说明:避免直接修改订单表造成数据异常。这里用innodb_lock 表演示InnoDB的行锁。表中有三个字段:id,k(key值),v(value值)。表为:
CREATE TABLE `itdragon_order_list` (
`id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '主键id,默认自增长',
`transaction_id` varchar(150) DEFAULT NULL COMMENT '交易号',
`gross` double DEFAULT NULL COMMENT '毛收入(RMB)',
`net` double DEFAULT NULL COMMENT '净收入(RMB)',
`stock_id` int(11) DEFAULT NULL COMMENT '发货仓库',
`order_status` int(11) DEFAULT NULL COMMENT '订单状态',
`descript` varchar(255) DEFAULT NULL COMMENT '客服备注',
`finance_descript` varchar(255) DEFAULT NULL COMMENT '财务备注',
`create_type` varchar(100) DEFAULT NULL COMMENT '创建类型',
`order_level` int(11) DEFAULT NULL COMMENT '订单级别',
`input_user` varchar(20) DEFAULT NULL COMMENT '录入人',
`input_date` varchar(20) DEFAULT NULL COMMENT '录入时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=10003 DEFAULT CHARSET=utf8;
步骤:
- 第一步:连接数据库,这里为了方便区分命名为Transaction-A,设置autocommit为零,表示需手动提交事务。
- 第二步:Transaction-A,执行update修改id为1的命令。
- 第三步:新增一个连接,命名为Transaction-B,能正常修改id为2的数据。再执行修改id为1的数据命令时,却发现该命令一直处理阻塞等待中。
- 第四步:Transaction-A,执行commit命令。Transaction-B,修改id为1的命令自动执行,等待37.51秒。
# Transaction-A
mysql> set autocommit = 0;
mysql> update innodb_lock set v='1001' where id=1;
mysql> commit;
# Transaction-B
mysql> update innodb_lock set v='2001' where id=2;
Query OK, 1 row affected (0.37 sec)
mysql> update innodb_lock set v='1002' where id=1;
Query OK, 1 row affected (37.51 sec)
总结:多个事务操作同一行数据时,后来的事务处于阻塞等待状态。这样可以避免了脏读等数据一致性的问题。后来的事务可以操作其他行数据,解决了表锁高并发性能低的问题。
有了上面的模拟操作,结果和理论又惊奇的一致,似乎可以放心大胆的实战。。。。。。但现实真的很残酷。
现实:当执行批量修改数据脚本的时候,行锁升级为表锁。其他对订单的操作都处于等待中,,,
原因:InnoDB只有在通过索引条件检索数据时使用行级锁,否则使用表锁!而模拟操作正是通过id去作为检索条件,而id又是MySQL自动创建的唯一索引,所以才忽略了行锁变表锁的情况。
步骤:
第一步:还原问题,Transaction-A,通过k=1更新v。Transaction-B,通过k=2更新v,命令处于阻塞等待状态。
第二步:处理问题,给需要作为查询条件的字段添加索引。用完后可以删掉。
总结:InnoDB的行锁是针对索引加的锁,不是针对记录加的锁。并且该索引不能失效,否则都会从行锁升级为表锁。索引失效的原因后面文章会介绍
Transaction-A
mysql> update innodb_lock set v='1002' where k=1;
mysql> commit;
mysql> create index idx_k on innodb_lock(k);
Transaction-B
mysql> update innodb_lock set v='2002' where k=2;
Query OK, 1 row affected (19.82 sec)
从上面的案例看出,行锁变表锁似乎是一个坑,可MySQL没有这么无聊给你挖坑。这是因为MySQL有自己的执行计划。
当你需要更新一张较大表的大部分甚至全表的数据时。而你又傻乎乎地用索引作为检索条件。一不小心开启了行锁(没毛病啊!保证数据的一致性!)。可MySQL却认为大量对一张表使用行锁,会导致事务执行效率低,从而可能造成其他事务长时间锁等待和更多的锁冲突问题,性能严重下降。所以MySQL会将行锁升级为表锁,即实际上并没有使用索引。
我们仔细想想也能理解,既然整张表的大部分数据都要更新数据,一行一行地加锁效率则更低。其实我们可以通过explain命令查看MySQL的执行计划,你会发现key为null。表明MySQL实际上并没有使用索引,行锁升级为表锁也和上面的结论一致。
行锁
- 行锁的劣势:开销大;加锁慢;会出现死锁
- 行锁的优势:锁的粒度小,发生锁冲突的概率低;处理并发的能力强
- 加锁的方式:自动加锁。对于UPDATE、DELETE和INSERT语句,InnoDB会自动给涉及数据集加排他锁;对于普通SELECT语句,InnoDB不会加任何锁;当然我们也可以显示的加锁:
- 共享锁:select * from tableName where ... + lock in share more
- 排他锁:select * from tableName where ... + for update
InnoDB和MyISAM的最大不同点有两个:一,InnoDB支持事务(transaction);二,默认采用行级锁。加锁可以保证事务的一致性
InnoDB默认采用行锁,在未使用索引字段查询时升级为表锁。MySQL这样设计并不是给你挖坑。它有自己的设计目的。
即便你在条件中使用了索引字段,MySQL会根据自身的执行计划,考虑是否使用索引(所以explain命令中会有possible_key 和 key)。如果MySQL认为全表扫描效率更高,它就不会使用索引,这种情况下InnoDB将使用表锁,而不是行锁。因此,在分析锁冲突时,别忘了检查SQL的执行计划,以确认是否真正使用了索引。
第一种情况:全表更新。事务需要更新大部分或全部数据,且表又比较大。若使用行锁,会导致事务执行效率低,从而可能造成其他事务长时间锁等待和更多的锁冲突。
第二种情况:多表级联。事务涉及多个表,比较复杂的关联查询,很可能引起死锁,造成大量事务回滚。这种情况若能一次性锁定事务涉及的表,从而可以避免死锁、减少数据库因事务回滚带来的开销。