⭐《MySQL 实战45讲》笔记

[TOC]mysql

一. 索引与优化

本篇内容主要来自极客时间《MySQL实战45讲》中的:算法

  • 04 - 深刻浅出索引(上)
  • 05 - 深刻浅出索引(下)

基本数据存储模型

  • 有序数组
  • 哈希表
  • 搜索树
  • 跳表sql

    Redis 的有序集合使用的就是这个结构
  • LSM树 等

有序数组:

优势: 查找很快, 支持范围查询数据库

缺点: 插入代价高, 必须逻辑上移动后续的全部记录segmentfault

搜索树:

  • 二叉搜索树

    搜索效率最高, 但实际并不采用, 由于索引是存在磁盘的.数组

    假设一棵树高为20, 一次搜索就须要20个数据块, 对应磁盘就是20次随机查找. 对于普通硬盘来讲, 一次寻址约 10ms, 则一次搜索就须要 20x10ms = 200ms.若是要让一个查询尽可能少读磁盘, 那就必须尽可能少地查询数据块, 应该使用下面的多叉树.缓存

  • 多叉树session

    为了减小磁盘访问次数, 可使用 "N叉"树, 这里的 N 取决于数据块的大小.并发

    以 InnoDB 中 一个整数字段为例, 这个N差很少是1200.函数

    计算方法: 补充!!

    若是树高为4, 则能够存储 1200^3 个值, 考虑树根数据块基本都在内存中, 所以一次搜索只须要3次磁盘查找, 考虑到第2层数据块也有很大几率在内存中, 那么访问磁盘次数就更少了.

引伸: InnoDB 里N叉树中的N如何调整

N = 页page的大小 / 每一个索引项大小

N叉树中非叶子节点存放的是索引信息, 每一个索引项包含Key和Point指针及其余辅助数据, 其中Point指针固定大小6字节, 默认索引页的大小是16KB. 所以主键为int时, int占用4个字节, 加上辅助数据差很少每一个索引项占用13字节, 所以非叶子节点大约能够存储 16k/13 ≈ 1260 个左右.

N的大小大体是根据上述式子决定的, 所以若要调整N, 则有2个防线:

  • MySQL 5.6之后能够修改 page 大小, 参数: innodb_page_size

    未测试
  • 经过修改Key字段类型, 好比 int 占用4字节, bitint 占用8字节.

哈希表

优势: 新增和查找都很快

缺点: 没法进行范围遍历, 必须一个一个查找.

InnoDB 索引的存储结构

索引组织表: 按照主键顺序, 以索引形式存放的表.

InnoDB 使用了 B+树 做为索引的存储结构.

InnoDB 中的索引, 按照叶子节点内容来区分, 分为两类:

  1. 主键索引(聚簇索引, clustered index)
  2. 非主键索引(二级索引, secondary index)

InnoDB 中 B+ 树的叶子节点存放的是 , 一页里面能够存多个行.

这里的页指的是 InnoDB 的页, 而非磁盘页, 默认大小是 16KB.

索引的维护涉及 插入删除, 这两个操做可能致使 页分裂页合并 的问题.

  • 插入: 若是插入不是有序递增的, 就须要逻辑上移动插入点后面的数据. 更糟糕的是, 若是插入点所在的数据块已满, 根据B+树的算法, 此时须要进行 页分裂 操做(新申请一个页, 将部分数据挪动过去). 页分裂 操做除了影响性能外, 还会影响页的利用率, 下降了约 50% 的利用率.
  • 删除: 当两个相邻页因为删除元素致使利用率很低后, 会将数据页作合并, 合并的过程能够理解为页分裂的逆过程.

索引可能由于删除或页分裂的缘由致使数据页有空洞, 而重建索引的过程会建立一个新的索引, 并将数据顺序插入, 使得索引更紧凑, 空间利用率更高.


Q. 为何表删除了一半数据, 文件大小却没变?

A. 简单回答一下.

删除时仅仅是将数据从所在的数据页上标记删除, 遗留的空位还会保留着, 供后续插入新记录时直接存放.

这种状况能够考虑重建索引以减小磁盘空间占用

optimize table 表名;
-- 或
alter table 表名 engine=InnoDB;

注意 alter table 表名 = engine=InnoDB; 会加 MDL 读锁.

若是是 MySQL 5.7, 则会使用 OnlineDDL, 避免长时间的 MDL 锁致使业务不可用.


Q. 主键索引和非主键索引的区别

A. 主要区别在于:

  • 主键索引(叶子节点)存储的是行记录, 非主键索引(叶子节点)存储的是对应主键的内容.
  • 非主键索引查询时, 须要先在该索引上查找到对应主键, 再去主键索引查找, 这个过程叫作回表.所以在应用中应尽可能使用主键索引, 避免多一次回表

Q. 非主键索引中字段值相同的索引项是如何存储的?

A. 结论: 独立存储.

以索引c为例, id是主键, 假设有两个记录 (c=10, id=1), (c=10, id=2), 这其实在索引c上是两条不一样的索引项, 它的存放顺序是先按照c递增, c等值状况下再按照id递增, 所以能够理解为索引c 是 (c, id)


Q. 若不给表设置主键会怎样?

A. InnoDB 会为每一行隐式分配一个 RowId 做为主键. 因此其实仍是有主键索引的


Q. 联合索引的存储结构是怎样的?

A. 《高性能MySQL 第三版》P144,关于索引类型的插图,说明了联合索引是N个字段组合成一个索引的。


Q. 在联合索引中多个字段顺序是怎样的?

A. 以 (a,b) 为例, id 是主键. 则在该索引上, 是先按照 a 递增, 再根据 b 递增, 最后根据 id 递增的顺序排序.

能够和下面写到的 最左前缀 一块儿理解.


Q. 若是表用到了联合主键, 那么在二级索引中是如何存储的?

A. 假设联合主键是 (a,b), 此时表中还有个字段 c, 能够分3种状况考虑:

  1. 若是创建了索引 (c), 则先按照 c递增, 其次 a 递增, 最后是 b 递增.
  2. 若是创建了索引 (c,a), 那么顺序同1, 这种状况下是不必单首创建 (c,a), 而只须要索引(c)便可
  3. 若是创建了索引 (c,b), 那么会先按照 c递增, 而后是 b 递增, 最后是 a 递增.

索引的选择

主键的选择

主键尽可能使用自增主键, 缘由:

  • 自增主键是有序递增的, 往索引插入时都是追加操做, 避免了页分裂的问题, 而业务上的主键通常不知足有序递增.
  • 自增主键一般是 int not null primary key auto_incrementbigint not null primary key auto_increment, 使用整形作主键只须要4个字节, 使用长整型则是8个字节.
  • 主键的字段越小, 普通索引的叶子节点也就越小, 占用的空间就越小.

所以从性能存储空间看, 自增主键一般是最好的选择.


那么何时能够考虑用业务字段做为主键:

  1. 没有其余二级索引(无需考虑二级索引叶子节点大小)
  2. 业务字段惟一

↑ 这就是典型的 KV 场景了, 考虑到查询时尽可能用主键索引, 避免回表, 此时就能够将这个索引设置为主键.

覆盖索引

当查询语句中涉及的全部字段都在同一个索引中, 此时因为只须要在该索引树上查找而不须要回表, 这成为覆盖索引.

覆盖索引能够减小树的搜索次数, 显著提高性能, 所以是经常使用的优化手段.

注意: 索引的维护是有代价的, 所以是否新增冗余索引来支持覆盖索引时须要权衡考量.

以索引 (code, name) 为例, 当使用以下语句时是能够用到覆盖索引, 避免回表的:

select name from 表 where code = "xxx";

-- 或

select id from 表 where code = "xxx";

Q. 是否有必要为了覆盖索引而设立联合索引?

A. 分状况:

  • 若是是高频请求, 那么能够创建联合索引来使用覆盖索引优化, 避免回表
  • 若是是低频请求, 若已有现成的可利用最左前缀优化的索引, 或单独索引, 则不必. 此时索引带来的优化好处可能已经被维护索引的代价盖掉了.

最左前缀

最左前缀指的是联合索引的前几个字段, 以及字符串索引的前几个字符.

因为索引是以B+树结构存储的, 而B+树这种索引结构是能够利用索引的最左前缀来定位记录的.

以 (name, age) 这个联合索引为例, 它的大体示意图以下:

能够看出索引项的顺序是按照索引定义的字段顺序来排序的.

如下语句会用到上面的这个索引的最左前缀:

-- 联合索引上的最左N个字段
select * from 表 where name = "xx";

-- 字符串的最左N个字符
select * from 表 where name like '张%';

Q. 联合索引上的字段顺序如何肯定?

A. 优先考虑复用能力, 其次考虑存储空间.

原则1: 若是经过调整顺序能够少建立一个索引, 那么一般就会优先考虑调整后的这个顺序了.

原则2: 优先考虑原则1, 其次应考虑空间占用.

以联合索引 (a,b) 为例, 因为最左前缀优化的缘由, 在该表上就不须要单独再创建索引 (a) 了, 所以这种状况只须要创建一个联合索引 (a,b) 便可.

可是, 若是此时一样须要用到索引 (b), 那么这时候有两个选择:

  1. 建立 (a,b) 及 (b)
  2. 建立 (b,a) 及 (a)

此时若字段a比较大, 则应考虑方案1, 不然应考虑方案2.


索引下推 index condition pushdown

对于联合索引, 对于不知足最左前缀的部分, 在某些状况下是能够用到 索引下推 的.

索引下推: 在索引遍历过程当中, 利用索引中已有的字段过滤不知足条件的记录, 避免每次判断都回表.

先明确:

  • 索引下推 是在 MySQL 5.6 引入的.
  • 在 explain 的时候能够在 Extra 看到 Using index condition , 说明能够用到索引下推

    "能够"用, 但不必定用/没有.

    这个地方还不大明确

以索引 (name, age) 为例, 查看一下SQL语句:

select * from 表 where name like '张%' and age > 20;

此时会先利用索引, 快速找到 name以"张"开头的记录, 而后依次向右遍历:

  • 如果在 MySQL 5.6 之前, 则须要一个一个回表并筛选 age > 20 的记录
  • 如果在 MySQL 5.6 及之后, 则根据 索引下推 则会在索引遍历过程当中对索引包含的字段先作判断, 过滤不知足条件的记录, 减小回表次数.

Change Buffer 之普通索引和惟一索引的选择

前提: 业务能保证记录是惟一的状况下, 才须要考虑.

理解这部份内容的 意义:

在遇到大量插入数据慢, 内存命中率低的状况下, 多一个排查思路.

相关配置:

## 最大占用 innodb_buffer_poll 内存空间的百分比
innodb_change_buffer_max_size=50

Change Buffer

  • 只会针对普通索引 (确定是二级索引了)
  • 可以在不影响数据一致性前提下将数据更新操做(DML, insert/update/delete)缓存在 Change Buffer 中, 而无需当即读取(磁盘)数据页. 当下次须要访问这个数据页的时候, 会将该数据页读取到内存中, 再将这些缓存的操做应用上去.
  • 记录的操做存储在 Change Buffer 中, 它占用的是InnoDB Buffer Pool, 同时它是可持久化的.
  • Change Buffer 减小的是随机读的次数(无需每次更新都读取), 若在读取记录前保存在该Buffer中操做越多, 则受益更大. 所以它同时也提升了内存利用效率(所以读取数据页是会占用内存空间的)
  • 从磁盘读取索引数据页并将Change Buffer缓存的操做应用上去, 这个过程称为 Merge
  • Merge 发生的状况:

    1. 读取记录时应用Change Buffer
    2. 后台线程按期Merge
    3. 正常关闭(shutdown)数据库
想象一下, 一张表有4,5个普通二级索引, 这些索引的使用率并不高.

同时该表会频繁更新数据, 若没有Change Buffer, 那么每次更新操做维护二级索引时都须要从磁盘读入索引对应的数据页, 而有了Change Buffer后只需将这些操做保存在该Buffer中, 极大减小了磁盘随机读次数, 最后统一Merge便可.


查询过程的区别:

  • 普通索引

    从索引树根目录, 逐层查找对应记录所在数据页.

    若不在内存中, 则须要先从磁盘上读入内存.

    若数据所在页已经在内存中, 则读取该记录, 并向右遍历直到不符合条件. 因为数据的读取是以数据页为单位(默认16KB), 所以这个过程是在内存中, 对性能影响极小, 除非是记录恰好在数据页的最后一条.(考虑到几率, 能够忽略)

  • 惟一索引

    相似普通索引, 只是在找到对应一条记录后就中止了.

结论: 在查询过程当中性能区别不大.


更新过程的区别:

若数据都在内存中则没有什么区别, 所以如下只讨论不在内存中的状况.

  • 普通索引

    将更新语句记录在 Change Buffer 中, 更新结束.

  • 惟一索引

    因为更新操做须要判断是否违反数据一致性约束, 所以没法使用 Change Buffer, 须要先将数据页从磁盘读取到内存, 进行判断, 再作更新操做.


Q. Change Buffer 何时会成为负优化?

A. 在下述普通索引场景:

当每次更新操做后立刻读取, 因为更新操做会缓存在Change Buffer中, 下一次立刻读取时须要当即 Merge.

此时反而多了维护 Change Buffer的代价, 同时随机访问IO不会减小.


Q. Change Buffer 适合什么场景?

A. 写多读少业务

Change Buffer 会将更新的操做缓存起来, 缓存得越多, 则在 Merge 操做的时候收益就越大.

常见业务模型: 帐单类, 日志类系统.

联合索引的字段顺序 - 根据区分度

当须要建立联合索引的状况下, 在 不考虑索引复用 前提, 且 字段顺序不影响索引完整使用 前提下, 如何肯定联合索引中的字段顺序?

!!! 注意这里的前提:

  1. 不考虑索引复用
  2. 字段顺序不影响索引完整使用

    若是是 where a = xx order by b 这类语句, 那么直接就是联合索引 (a, b) 了.

此时应该按照字段的区分度, 区分度高的在前.

以索引 (status, product_id) 为例, 分别查看其区分度:

SELECT
    COUNT(DISTINCT status)/COUNT(*) as status_disc,
    COUNT(DISTINCT product_id)/COUNT(*) as product_id_disc
FROM
    表名;

当前这个例子很清楚, status 就几种取值, 基数很小, 区分度不好, 所以应该创建联合索引 (product_id, status)

如何建立字符串索引

通常有如下几种选择:

  1. 完整索引

    最耗费空间

  2. 前缀索引, 只选择前N个字符

    适用: 前N个字符具备足够区分度的状况.

    缺点: 增长额外扫描行数, 同时没法使用覆盖索引.

  3. 字符串倒序 + 前缀索引

    适用: 字符串前N个字符区分度不够的状况下, 且后N个字符有足够区分度

    存储: 存储的时候直接存储倒序的字符串

    使用: update 表 set s = reverse("123456");

    缺点: 除了前缀索引的缺点外, 每次更新/查找都须要额外的 reverse 函数调用消耗, 同时没法利用索引进行范围查找.

  4. 额外字段存储hash值

    存储: 新增额外字段存储字符串对应的hash值, 若使用 crc32 函数, 则额外占用4个字节

    优势: 查找性能稳定, 基本在 O(1)

    使用: 因为hash值会冲突, 所以查找时除了hash字段判断外, 还要判断原始字符串是否一致. select * from 表 where s_hash = crc32("123456") and s = "123456";

    缺点: 占用额外的存储空间, 没法利用索引进行范围查找

索引建立命令

CREATE TABLE 时建立

CREATE TABLE IF NOT EXISTS `users` (
    -- 省略字段定义
    
    PRIMARY KEY (`id`),
    UNIQUE KEY `users_phone` (`phone`),
    KEY `users_name` (`name`),
) Engine=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci

ALTER TABLE用来建立普通索引、UNIQUE索引或PRIMARY KEY索引。

ALTER TABLE table_name ADD INDEX index_name (column_list)
-- 可忽略索引名
-- ALTER TABLE table_name ADD INDEX (column_list)

ALTER TABLE table_name ADD UNIQUE (column_list)

ALTER TABLE table_name ADD PRIMARY KEY (column_list)

-- 一个语句建多个索引
ALTER TABLE HeadOfState ADD PRIMARY KEY (ID), ADD INDEX (LastName,FirstName);

其中table_name是要增长索引的表名,column_list指出对哪些列进行索引,多列时各列之间用逗号分隔。索引名index_name可选,缺省时,MySQL将根据第一个索引列赋一个名称。另外,ALTER TABLE容许在单个语句中更改多个表,所以能够在同时建立多个索引。

CREATE INDEX可对表增长普通索引或UNIQUE索引。

CREATE INDEX index_name ON table_name (column_list)

CREATE UNIQUE INDEX index_name ON table_name (column_list)

索引失效状况

索引失效的状况我的认为主要是如下状况:

  1. 区分度过低, 致使优化器认为全表扫描会更快.
  2. 对索引字段使用函数、进行计算、类型转换

    WHERE a + 1 = 2 这种语句也会致使索引 a 失效, 此时应该改写 SQL 语句为: WHERE a = 1
  3. 包括显式转换及隐式转换

    若是字段 phone 是 char 类型, 那么 WHERE phone = 18612345678 一样可能会致使索引失效, 应该改写成 WHERE phone = '18612345678 '
  4. 不知足最左前缀

    包括联合索引和字符串最左前缀

  5. 索引列存在NULL且查询条件是 is not null, 若索引没有覆盖查询字段和查询条件时, 此时会符合如下的<u>状况6</u>, 致使全表扫描.

    如下是我的测试

    -- UserName 是 varchar, nullable
    
    explain select Uid from new_light_user where UserName is  null;
    -- SIMPLE    new_light_user    ref    UserName    UserName    768    const    10    Using where; Using index
    
    explain select * from new_light_user where UserName is not null;
    -- SIMPLE    new_light_user    ALL    UserName    null         null  null   17979    Using where
  6. 根据查询条件, 没法使用索引快速定位, 但可使用索引扫描时, 若innodb认为代价太大也会直接走全表扫描.

其余注意点

索引设计规范

  • 单表索引建议控制在5个之内
  • 但索引字段不容许超过5个
  • 索引字段要定义为 NOT NULL, 不然:

    1. 占用额外存储空间(1字节)
    2. 致使索引的使用更加复杂, 在某些状况下会致使索引失效
    3. 条件判断更麻烦, 须要 IS NULL, IS NOT NULL
  • 区分度不高的字段不建议创建索引

    除非查询值的筛选力度很高, 好比 status = 0 (表示未完成), 由于大多数值是 1, 所以这种状况下建索引仍是有意义的.

  • 创建联合索引时, 优先将区分度高的字段放前面.

二. 加锁规则及案例

本文内容主要是 《MySQL实战45讲》 课程中第 20,21,30 课程的我的笔记及相关理解.

主要是对于加锁规则的理解及分析.

如下仅针对 MySQL 的 InnoDB 引擎.

MyISM 引擎就只有表锁

基本概念

锁的种类

MySQL 中的锁主要分为:

  • 全局锁

    flush tables with read lock;
  • 表级锁

    • 表锁

      lock table 表名 read;
      lock table 表名 write;
    • 元数据锁(Meta Data Lock, MDL)

      在 MySQL 5.5 引入 MDL 锁.

      MySQL 5.6 之后支持 OnlineDDL

  • 行锁
还有个自增锁, 后续补充.

意向锁在此先不作讨论.

表级锁

元数据锁 MDL

MDL支持的版本:

  • 在 MySQL 5.5 引入 MDL 锁.
  • MySQL 5.6 之后支持 OnlineDDL.

MDL锁目的: 控制对表元数据修改的并发.

MDL锁类型分为:

  1. MDL 读锁(读锁之间不冲突)
  2. MDL 写锁(读写锁冲突, 写锁之间也冲突)

普通的增删改查会自动获取MDL读锁, 而对表的字段修改或建立索引等修改表元数据的操做会自动获取MDL写锁, 在此期间增删改查就会被阻塞掉.

OnlineDDL 是一种近似不锁表的特性, 它的过程以下:

  1. 获取MDL写锁

    这个期间会阻塞

  2. 降级为MDL读锁
  3. 执行DDL语句

    大部分时间消耗在这里, 好比重建表(alter table 表 Engine=Innodb)时, 须要将数据从旧表按主键顺序逐一添加到新表, 而大部分时间就消耗在这里.

    同时在此期间, 全部对数据库的增删改操做都会记录在特定日志中, 待这部分执行完毕后再应用这些日志, 从而保证数据一致性.

  4. 升级为MDL写锁

    这个期间会也阻塞

  5. 释放MDL写锁

也就是说 OnlineDDL 其实仍是会锁表, 但只会在开始跟结束的时候锁, 中间大部分时间是不锁的.

对于 ALTER TABLE 表名 Engine=Innodb 这种DDL操做:

  • 5.6以前是在Server层面上经过建立临时表来实现的(锁表+建立临时表+拷贝数据+替换表)
  • 5.7及以后的OnlineDDL是在InnoDB层面上处理的, 它会建立临时文件.

部分DDL操做不支持OnlineDDL, 好比添加全文索引(FULLTEXT)和空间索引(SPATIAL)

InnoDB 中的锁

行锁

行锁也叫作记录锁, 这个锁是加在具体的索引项上的.

行锁分为两种:

  • 读锁: 共享锁
  • 写锁: 排它锁

行锁冲突状况:

  • 读锁与写锁冲突
  • 写锁与写锁冲突

须要明确:

  • 锁的对象是索引

间隙锁

记录之间是存在间隙的, 这个间隙也是能够加上锁实体, 称为间隙锁.

间隙锁存在的目的: 解决幻读问题.

间隙锁冲突状况:

  • 间隙锁之间是不冲突的, 它们都是为了防止插入新的记录.
  • 间隙锁与插入操做(插入意向锁)产生冲突

须要明确:

  • 间隙锁仅在 可重复读隔离级别下才存在.
  • 间隙锁的概念是动态的

    对间隙(a,b)加锁后, 存在间隙锁 (a,b).

    此时若 a 不存在(删除), 则间隙锁会向左延伸直到找到一条记录.

    若b不存在了(删除), 则间隙锁会向右延伸直到找到一条记录.

    假设主键上存在记录 id=5 和 id=10 和 id=15 的3条记录, 当存在某个间隙锁 (10,15) 时, 若咱们将 id=10 这一行删掉, 则间隙锁 (10, 15) 会动态扩展成 (5, 15), 此时想要插入 id=7 的记录会被阻塞住.

    此处的删除指的是事务提交后, 不然间隙锁依旧是 (10,15)

next-key lock

next-key lock = 行锁 + 间隙锁

next-key lock 的加锁顺序:

  1. 先加间隙锁
  2. 再加行锁
若是加完间隙锁后, 再加行锁时被阻塞进入锁等待时, 间隙锁在此期间是不会释放的.

两阶段锁协议

两阶段锁协议指的是:

  1. 在用到的时候会加锁
  2. 在事务提交的时候才会释放锁

了解这个协议的启发在于:

  • 在一个事务中须要对多个资源进行加锁时, 应尽可能把最可能形成锁冲突的放在最后, 这边能够避免持有这个锁的时间太久致使线程长时间等待, 下降并发度.

索引搜索

索引搜索指的是就是:

  1. 在索引树上利用树搜索快速定位找到第一个值
  2. 而后向左或向右遍历

order by desc 就是用最大的值来找第一个

order by 就是用最小的值来找第一个

等值查询

等值查询指的是:

  • 在索引树上利用树搜索快速定位 xx=yy的过程

    where xx > yy 时, 也是先找到 xx = yy 这条记录, 这一个步骤是等值查询.但后续的向右遍历则属于范围查询.
  • 以及在找到具体记录后, 使用 xx=yy 向右遍历的过程.

加锁规则

该部分源自《MySQL实战45讲》中的 《21-为何我只改了一行的语句, 锁这么多》

如下仅针对 MySQL 的 InnoDB 引擎在 可重复读隔离级别, 具体MySQL版本:

  • 5.x 系列 <= 5.7.24
  • 8.0 系列 <=8.0.13

如下测试若未指定, 则默认使用如下表, 相关案例为了不污染原始数据, 所以在不影响测试结果前提下, 都放在事务中执行, 且最终不提交.

create table c20(
    id int not null primary key, 
    c int default null, 
    d int default null, 
    key `c`(`c`)
) Engine=InnoDB;

insert into c20 values(0,0,0),(5,5,5),(10,10,10),(15,15,15),(20,20,20),(25,25,25);

/*
+----+------+------+
| id | c    | d    |
+----+------+------+
|  0 |    0 |    0 |
|  5 |    5 |    5 |
| 10 |   10 |   10 |
| 15 |   15 |   15 |
| 20 |   20 |   20 |
| 25 |   25 |   25 |
+----+------+------+
*/

2个"原则", 2个"优化", 1个"BUG"

  1. 原则1: 加锁的基本单位是next-key lock, 前开后闭区间
  2. 原则2: 访问到的对象才会加锁

    select id from t where c = 15 lock in share mode;

    加读锁时, 覆盖索引优化状况下, 不会访问主键索引, 所以若是要经过 lock in share mode 给行加锁避免数据被修改, 那就须要绕过索引优化, 如 select 一个不在索引中的值.

    但若是改为 for update , 则 mysql 认为接下来会更新数据, 所以会将对应主键索引也一块儿锁了

  3. 优化1: 索引上的等值查询, 对惟一索引加锁时, next-key lock 会退化为行锁

    select * from t where id = 10 for update;

    引擎会在主键索引上查找到 id=10 这一行, 这一个操做是等值查询.

    锁范围是

  4. 优化2: 索引上的等值查询, 向右遍历时且最后一个值不知足等值条件时, next-key Lock 会退化为间隙锁

    select * from t where c = 10 for update;

    因为索引c是普通索引, 引擎在找到 c=10 这一条索引项后继续向右遍历到 c=15 这一条, 此时锁范围是 (5, 10], (10, 15)

  5. BUG 1: 惟一索引上的范围查询会访问到不知足条件的第一个值

    id> 10 and id <=15, 这时候会访问 id=15 以及下一个记录.
对索引上的更新操做, 本质上是 删除+插入

读提交与可重复读的加锁区别

  1. 读提交下没有间隙锁
  2. 读提交下有一个针对 update 语句的 "semi-consistent" read 优化.

    若是 update 语句碰到一个已经被锁了的行, 会读入最新的版本, 而后判断是否是知足查询条件, 若知足则进入锁等待, 若不知足则直接跳过.

    注意这个策略对 delete 是无效的.

  3. ?????? 语句执行过程当中加上的行锁, 会在语句执行完成后将"不知足条件的行"上的行锁直接释放, 无需等到事务提交.

insert into ... select ... 加锁

https://time.geekbang.org/col...

在可重复读隔离级别, binlog_format = statement 时, 该语句会对被 select 的那个表访问到的记录和间隙加锁

小伙子, 很危险的.

生产环境大表复制数据通常用 pt-archiver 工具来处理, 避免 insert ... select ... 锁致使的长阻塞.

pt-archiver: 数据归档工具

或者简单用 select ... into outfile 和 load data infile 组合来代替 insert ... select 完成插入操做.

简单例子

例子1

begin;
select * from c20 where id=5 for update;

在主键索引 id 上快速查找到 id=5 这一行是等值查询

例子2

begin;
select * from c20 where id > 9 and id < 12 for update;

在主键索引 id 上找到首个大于 9 的值, 这个过程实际上是在索引树上快速找到 id=9 这条记录(不存在), 找到了 (5,10) 这个间隙, 这个过程是等值查询.

而后向右遍历, 在遍历过程当中就不是等值查询了, 依次扫描到 id=10 , id=15 这两个记录, 其中 id=15 不符合条件, 所以最终锁范围是 (5,10], (10, 15]

例子3

begin;
select * from c20 where id > 9 and id < 12 order by id desc for update;

根据语义 order by id desc, 优化器必须先找到第一个 id < 12 的值, 在主键索引树上快速查找 id=12 的值(不存在), 此时是向右遍历到 id=15, 根据优化2, 仅加了间隙锁 (10,15) , 这个过程是等值查询.

接着向左遍历, 遍历过程就不是等值查询了, 最终锁范围是: (0,5], (5, 10], (10, 15)

我的理解:

  1. 因为有 order by id desc, 所以首先是等值查询 id=12 不存在, 向右遍历不知足, 优化, 所以加了间隙锁 (10, 15)
  2. 向左遍历到 id=10, next-key lock, (5,10]
  3. 向左遍历到 id=5, next-key lock, (0,5], 不知足条件, 中止遍历

例子4

begin;
select * from c20 where c>=15 and c<=20 order by c desc lock in share mode;

执行过程:

  1. 在索引c上搜索 c=20 这一行, 因为索引c是普通索引, 所以此处的查找条件是 <u>最右边c=20</u> 的行, 所以须要继续向右遍历, 直到找到 c=25 这一行, 这个过程是等值查询. 根据优化2, 锁的范围是 (20, 25)?
  2. 接着再向左遍历, 以后的过程就不是等值查询了.

我的理解:

  1. 因为 order by c desc, 所以首先等值查询 c=20 存在, 加锁 (15, 20]
  2. 向右遍历到 c=25, 不知足, 但可优化, 加锁 (20,25)
  3. 向左遍历到 c=15, 加锁 (10, 15]
  4. 向左遍历到 c=10, 加锁 (5,10]

例子5

begin;
select * from c20 where c<=20 order by c desc lock in share mode;

这里留意一下 , 加锁范围并非 (20, 25], (15, 20], (10,15], (5,10], (0, 5], (-∞, 5], 而是

...........

..........

.........

........

.......

......

.....

......

.......

........

.........

..........

...........

全部行锁+间隙锁.

具体为何, 其实只要 explain 看一下就明白了.

+------+-------------+-------+------+---------------+------+---------+------+------+-----------------------------+
| id   | select_type | table | type | possible_keys | key  | key_len | ref  | rows | Extra                       |
+------+-------------+-------+------+---------------+------+---------+------+------+-----------------------------+
|    1 | SIMPLE      | c20   | ALL  | c             | NULL | NULL    | NULL |   14 | Using where; Using filesort |
+------+-------------+-------+------+---------------+------+---------+------+------+-----------------------------+

但若是是 c<=19, 则会使用索引 c, 这说明 innodb 引擎有本身一套规则用于"估算"当前使用二级索引仍是主键索引哪一个开销会更小.

explain select * from c20 where c<=19 order by c desc lock in share mode;
+------+-------------+-------+-------+---------------+------+---------+------+------+-------------+
| id   | select_type | table | type  | possible_keys | key  | key_len | ref  | rows | Extra       |
+------+-------------+-------+-------+---------------+------+---------+------+------+-------------+
|    1 | SIMPLE      | c20   | range | c             | c    | 5       | NULL |    4 | Using where |
+------+-------------+-------+-------+---------------+------+---------+------+------+-------------+

例子6

begin;
select * from c20 where c>=10 and c<15 for update;

加锁范围是

  • 索引 c 的 (5,10], (10,15]

    这里对索引 c 的 15 好像是退化成行锁了, 不是很理解.
  • 主键索引的 [10]

    访问到的才会加锁, 因为没有访问主键 id=15, 所以不会对齐加锁.

例子7 - 我的不理解的地方

-- T1 事务A
begin;
select * from c20 where id>=15 and id<=20 order by id desc lock in share mode;

-- T2 事务B
begin;
update c20 set d=d+1 where id=25;    -- OK
insert into c20 values(21,21,21);    -- 阻塞

-- T3 事务A 人为制造死锁, 方便查看锁状态
update c20 set d=d+1 where id=25;    -- OK
/*
此时 事务B 提示:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
*/

我的不理解的:

根据order by id desc, T1 时刻事务A首先在主键索引上搜索 id=20 这一行, 正常来讲主键索引上 id=20 的只有一行, 不必向右遍历.

加锁范围:

  • (5,10]
  • (10,15]
  • (15,20]
  • (20,25)
mysql> show engine innodb status
------------------------
LATEST DETECTED DEADLOCK
------------------------
2019-09-27 10:34:29 0xe2e8
*** (1) TRANSACTION:
TRANSACTION 1645, ACTIVE 100 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1080, 4 row lock(s), undo log entries 1
MySQL thread id 82, OS thread handle 77904, query id 61115 localhost ::1 root update
insert into c20 values(21,21,21)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 23 page no 3 n bits 80 index PRIMARY of table `test_yjx`.`c20` trx id 1645 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 7 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 4; hex 80000019; asc     ;;
 1: len 6; hex 00000000066d; asc      m;;
 2: len 7; hex 6e0000019a0110; asc n      ;;
 3: len 4; hex 80000019; asc     ;;
 4: len 4; hex 8000001a; asc     ;;

*** (2) TRANSACTION:
TRANSACTION 1646, ACTIVE 271 sec starting index read
mysql tables in use 1, locked 1
5 lock struct(s), heap size 1080, 5 row lock(s)
MySQL thread id 81, OS thread handle 58088, query id 61120 localhost ::1 root updating
update c20 set d=d+1 where id=25
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 23 page no 3 n bits 80 index PRIMARY of table `test_yjx`.`c20` trx id 1646 lock mode S locks gap before rec
Record lock, heap no 7 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 4; hex 80000019; asc     ;;
 1: len 6; hex 00000000066d; asc      m;;
 2: len 7; hex 6e0000019a0110; asc n      ;;
 3: len 4; hex 80000019; asc     ;;
 4: len 4; hex 8000001a; asc     ;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 23 page no 3 n bits 80 index PRIMARY of table `test_yjx`.`c20` trx id 1646 lock_mode X locks rec but not gap waiting
Record lock, heap no 7 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 4; hex 80000019; asc     ;;
 1: len 6; hex 00000000066d; asc      m;;
 2: len 7; hex 6e0000019a0110; asc n      ;;
 3: len 4; hex 80000019; asc     ;;
 4: len 4; hex 8000001a; asc     ;;

*** WE ROLL BACK TRANSACTION (1)

上述的:

  • (1) TRANSACTION(事务1) 指的是事务B
  • (2) TRANSACTION(事务2) 指的是事务A

注意与上面的 事务A, 事务B 顺序是相反了, 别看错了.

分析:

  • (1) TRANSACTION

    • insert into c20 values(21,21,21) 最后一句执行语句
  • (1) WAITING FOR THIS LOCK TO BE GRANTED

    • index PRIMARY of table test_yjx.c20 说明在等表 c20 主键索引上的锁
    • lock_mode X locks gap before rec insert intention waiting 说明在插入一条记录, 试图插入一个意向锁, 与间隙锁产生冲突了
    • 0: len 4; hex 80000019; asc ;; 冲突的间隙锁: 16进制的 19, 即 10进制的 id=25 左边的间隙.
  • (2) TRANSACTION 事务2信息

    • update c20 set d=d+1 where id=25 最后一句执行语句
  • (2) HOLDS THE LOCK(S) 事务2持有锁的信息

    • index PRIMARY of table test_yjx.c20 说明持有c20表主键索引上的锁
    • lock mode S locks gap before rec 说明只有间隙锁
    • 0: len 4; hex 80000019; asc ;; 间隙锁: id=25 左边的间隙
  • (2) WAITING FOR THIS LOCK TO BE GRANTED: 事务2正在等待的锁

    • index PRIMARY of table test_yjx.c20 说明在等待 c20 表主键索引上的锁
    • lock_mode X locks rec but not gap waiting 须要对行加写锁
    • 0: len 4; hex 80000019; asc ;; 等待给 id=25 加行锁(写)
  • WE ROLL BACK TRANSACTION (1) 表示回滚了事务1

我的猜想实际状况是:

  1. 首先找到 id=20 这一条记录, 因为bug, 引擎认为可能存在不止一条的 id=20 的记录(即将其认为是普通索引), 所以向右遍历, 找到了 id=25 这一行, 因为此时是等值查询, 根据优化2, 锁退化为间隙锁, 即 (20,25)
  2. 以后正常向左遍历.

没法证明本身的猜想. 已在课程21和课程30留下如下留言, 等待解答(或者无人解答). 2019年9月27日

-- T1 事务A
begin;
select * from c20 where id>=15 and id<=20 order by id desc lock in share mode;

-- T2 事务B
begin;
update c20 set d=d+1 where id=25;    -- OK
insert into c20 values(21,21,21);    -- 阻塞

不能理解, 为何事务A执行的语句会给 间隙(20,25) 加上锁.
经过 show engine innodb status; 查看发现事务A确实持有上述间隙锁.
经过 explain select * from c20 where id>=15 and id<=20 order by id desc lock in share mode; 查看 Extra 也没有 filesort, key=PRIMARY, 所以我的认为是按照主键索引向左遍历获得结果.

按照个人理解, 因为 order by id desc , 所以首先是在主键索引上搜索 id=20, 同时因为主键索引上这个值是惟一的, 所以没必要向右遍历. 然而事实上它确实这么作了, 这让我想到了 BUG1: 主键索引上的范围查询会遍历到不知足条件的第一个.
可是这一步的搜索过程应该是等值查询才对, 彻底一脸懵住了...
不知道老师如今还能看到这条评论不?

加锁案例

案例: 主键索引 - 等值查询 - 间隙锁

-- T1 事务A
begin;
update c20 set d=d+1 where id=7;
/*
1. 在主键索引上不存在id=7记录, 根据规则1: 加锁基本单位是 next-key lock, 所以加锁范围是(5,10]
2. 因为id=7是一个等值查询, 根据优化2, id=10不知足条件, 所以锁退化为间隙锁 (5,10)
*/

-- T2 事务B
begin;
insert into c20 values(8,8,8);        -- 阻塞
update c20 set d=d+1 where id=10;    -- OK
对应课程的案例一

案例: 非惟一索引 - 等值查询 - 间隙锁

-- T1 事务A
begin;
update c20 set d=d+1 where c=7;
/* 分析
1. 加锁基本单位是next-key lock, 加锁范围就是 (5,10]   -- 此时只是分析过程, 并不是加锁过程
2. 根据优化2, 索引上的等值查询(c=7)向右遍历且最后一个值不知足条件时, next-key lock 退化为间隙锁, 加锁范围变为 (5, 10)
3. 因为是在索引c上查询, 所以加锁范围其实是索引 c 上的 ((5,5), (10,10)) , 格式 (c, id)
*/

-- T2 事务B
begin;
insert into c20 values(4,5,4);    -- OK
insert into c20 values(6,5,4);    -- 被间隙锁堵住
insert into c20 values(9,10,9);    -- 被间隙锁堵住
insert into c20 values(11,10,9);    -- OK

案例: 非惟一索引 - 等值查询 - 覆盖索引

关注重点: 覆盖索引优化致使无需回表的状况对主键索引影响

-- T1 事务A
begin;
select id from c20 where c = 5 lock in share mode;    
-- 索引c是普通索引, 所以会扫描到 c=10 这一行, 所以加锁范围是 (0,5], (5,10)
-- 同时因为优化2: 索引上的等值查询向右遍历且最后一个值不知足条件时next-key lock退化为间隙锁, 即加锁范围实际是  (0,5], (5,10)
-- 注意, 该条查询因为只 select id, 实际只访问了索引c, 并无访问到主键索引, 根据规则2: 访问到的对象才会加锁, 所以最终只对索引c 的范围 (0,5], (5,10) 加锁

-- T2 事务B
begin;
update c20 set d=d+1 where id=5;    -- OK, 由于覆盖索引优化致使并无给主键索引上加锁
insert into c20 values(7,7,7);
对应课程的案例二

注意, 上面是使用 lock in share mode 加读锁, 所以会被覆盖索引优化.

若是使用 for update, mysql认为你接下来要更新行, 所以也会锁上对应的主键索引.

案例: 非主键索引 - 范围查询 - 对主键的影响

关注重点在于: 普通索引上的范围查询时对不符合条件的索引加锁时, 是否会对对应的主键索引产生影响.

-- T1 事务A
begin;
select * from c20 where c>=10 and c<11 for update;
/*
1. 首先查找到 c=10 这一行, 锁范围 (5,10]
2. 接着向右遍历(这时候不是等值查询, 是遍历查询), 找到 c=15 这一行, 不符合条件, 查询结束. 根据规则2: 只有访问到的对象才会加锁, 因为不须要访问c=15对应的主键索引项, 所以这里的锁范围是索引c上的 (5,10], (10,15], 以及主键上的行锁[10]
*/

-- T2 事务B
begin;
select * from c20 where c=15 for update;     -- 阻塞
select * from c20 where id=15 for update;    -- OK

加锁范围

  • 索引 c

    • (5,10]
    • (10,15]
  • 主键

    • [10]

案例: 主键索引 - 范围锁

-- T1 事务A
begin;
select * from c20 where id>=10 and id<11 for update;
/*
1. 首先在主键索引上查找 id=10 这一行, 根据优化1: 索引上的等值查询在对惟一索引加锁时, next-key lock 退化为行锁, 此时加锁范围是 [10]
2. 继续向右遍历到下一个 id=15 的行, 此时并不是等值查询, 所以加锁范围是 [10], (10,15]
*/

-- T2 事务B
begin;
insert into c20 values(8,8,8);        -- OK
insert into c20 values(13,13,13);    -- 阻塞
update c20 set d=d+1 where id=15;    -- 阻塞
对应课程案例三

这里要注意, 事务A首次定位查找id=10这一行的时候是等值查询, 然后续向右扫描到id=15的时候是范围查询判断.

主键索引的加锁范围

  • [10]
  • (10,15]

案例: 非惟一索引 - 范围锁

-- T1 事务A
begin;
select * from c20 where c >= 10 and c < 11 for update;
/*
1. 首先在索引c上找到 c=10 这一行, 加上锁 (5,10]
2. 向右遍历找到 c=15 这一行, 不知足条件, 最终加锁范围是 索引c上的 (5,10], (10,15], 及主键索引 [5]
*/

-- T2 事务B
begin;
insert into c20 values(8,8,8);        -- 阻塞
update c20 set d=d+1 where c=15;    -- 阻塞
update c20 set d=d+1 where id=15;    -- 阻塞
对应课程案例四

主键的加锁范围

  • (5,10]
  • (10,15]

案例: 惟一索引 - 范围锁 - bug

-- T1 事务A
begin;
select * from c20 where id>10 and id<=15 for update

-- T2 事务B
begin;
update c20 set d=d+1 where id=20;    -- 阻塞
insert into c20 values(16,16,16);    -- 阻塞

顺便提一下:

begin;
select * from c20 where id>10 and id<15 for update;
/*
1. 在主键索引上找到id=15这一行, 不知足条件, 根据原则1, 加锁 (10,15]
*/

对应课程案例五

主键的加锁范围

  • (10,15]
  • (15,20]

案例: 非惟一索引 - 等值

-- T1 事务A
begin;
insert into c20 values(30,10,30);
commit;
/*
在索引c上, 此时有两行 c=10 的行
因为二级索引上保存着主键的值, 所以并不会有两行彻底一致的行, 以下:
c    0    5    10    10    15    20    25
id    0    5    10    30    15    20    25

此时两个 (c=10, id=10) 和 (c=10, id=30) 之间也是存在间隙的
*/

-- T2 事务B
begin;
delete from c20 where c=10;
/*
1. 首先找到索引c上 (c=10, id=10) 这一行, 加锁 (5,10]
2. 向右遍历, 找到 (c=10, id=30) 这一行, 加锁 ( (c=10,id=10), (c=10,id=30) ]
3. 向右遍历, 找到 c=20 这一行, 根据优化2, 索引上的等值查询向右遍历且最后一个值不匹配时, next-key lock 退化为间隙锁, 即加锁 (10,15)
4. 总的加锁范围是 (5,10], ( (c=10,id=10), (c=10,id=30) ], (10,15]
*/

-- T3 事务C
begin;
insert into c20 values(12,12,12);    -- 阻塞
update c20 set d=d+1 where c=15;    -- OK


-- T4 扫尾, 无视
delete from c20 where id=30;
对应课程案例六

delete 的加锁逻辑跟 select ... for update 是相似的.

事务 B 对索引 c 的加锁范围

  • (5,10]
  • (10,15)

案例: 非惟一索引 - limit

-- T0 初始环境
insert into c20 values(30,10,30);

-- T1 事务A
begin;
delete from c20 where c=10 limit 2;
/*
1. 找到 c=10 的第一条, 加锁 (5,10]
2. 向右遍历, 找到 c=10,id=30 的记录, 加锁 ( (c=10,id=10), (c=10,id=30) ], 此时知足 limit 2
*/

-- T2, 事务B
begin;
insert into c20 values(12,12,12);    -- OK

若是不加 limit 2 则会继续向右遍历找到 c=15 的记录, 新增长锁范围 (10,15)

对应课程案例七

指导意义:

  • 在删除数据时尽可能加 limit, 不只能够控制删除的条数, 还能够减少加锁的范围.

案例: 死锁例子

-- T1 事务A
begin;
select id from c20 where c=10 lock in share mode;
/*
1. 在索引c上找到 c=10 这一行, 因为覆盖索引的优化, 没有回表, 所以只会在索引c上加锁 (5,10]
2. 向右遍历, 找到 c=15, 不知足, 根据优化2, 加锁范围退化为 (10,15)
3. 总的加锁范围是在索引c上的 (5,10], (10,15)
*/

-- T2 事务B
begin;
update c20 set d=d+1 where c=10;    -- 阻塞
/*
1. 找到 c=10 这一行, 试图加上锁 (5,10], 按照顺序先加上间隙锁(5,10), 因为间隙锁之间不冲突, OK. 以后再加上 [10] 的行锁, 但被T1时刻的事务A阻塞了, 进入锁等待
*/

-- T3 事务A
insert into t values(8,8,8);    -- OK, 但形成 事务B 回滚
/*
往 (5,10) 这个间隙插入行, 此时与 T2时刻事务B 加的间隙锁产生冲突.
同时因为 事务B 也在等待 T1时刻事务A 加的行锁, 两个事务间存在循环资源依赖, 形成死锁.
此时事务B被回滚了, 报错以下:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
*/
对应课程案例八

案例: 非主键索引 - 逆序

-- T1 事务A
begin;
select * from c20 where c>=15 and c<=20 order by c desc lock in share mode;
/*
1. 在索引c上找到 c=20 这一行, 加锁 (15,20]
2. 向左遍历, 找到 c=15 这一行, 加锁 (10,15]
3. 继续向左遍历, 找到 c=10 这一行, 因为不知足优化条件, 所以直接加锁 (5,10], 不知足查询条件, 中止遍历. 
4. 最终加锁范围是 (5,10], (10,15], (15, 20]
*/

-- T2 事务B
insert into c20 values(6,6,6);    -- 阻塞
对应课程的上期答疑

索引 c 的加锁范围

  • (5,10]
  • (10,15]
  • (15,20]
  • (20, 25)

案例: 读提交级别 - semi-consistent 优化

-- 表结构
create table t(a int not null, b int default null)Engine=Innodb;
insert into t values(1,1),(2,2),(3,3),(4,4),(5,5);

-- T1 事务A
set session transaction isolation level read committed;
begin;
update t set a=6 where b=1;
/*
b没有索引, 所以全表扫描, 对主键索引上全部行加上行锁
*/

-- T2 事务B
set session transaction isolation level read committed;
begin;
update t set a=7 where b=2;    -- OK
/*
在读提交隔离级别下, 若是 update 语句碰到一个已经被锁了的行, 会读入最新的版本, 而后判断是否是知足查询条件, 若知足则进入锁等待, 若不知足则直接跳过.
*/
delete from t where b=3;    -- 阻塞
/*
注意这个策略对 delete 是无效的, 所以delete语句被阻塞
*/
对应课程评论下方 @时隐时现 2019-01-30 的留言

案例: 主键索引 - 动态间隙锁 - delete

-- T1 事务A
begin;
select * from c20 where id>10 and id<=15 for update;
/*
加锁 (10,15], (15, 20]???
*/

-- T2 事务B 注意此处没加 begin, 是立刻执行并提交的单个事务.
delete from c20 where id=10;    -- OK
/*
事务A在T1时刻加的间隙锁 (10,15) 此时动态扩展成 (5,15)
*/

-- T3 事务C
insert into c20 values(10,10,10);    -- 阻塞
/*
被新的间隙锁堵住了
*/
对应课程评论下方 @Geek_9ca34e 2019-01-09 的留言

若是将上方的 T2时刻的事务B 和 T3时刻的事务C 合并在一个事务里, 则不会出现这种状况.

我的理解是, 事务未提交时, 期间删除/修改的数据仅仅是标记删除/修改, 此时记录还在, 所以间隙锁范围不变.

只有在事务提价后才会进行实际的删除/修改, 所以间隙锁才"会动态扩大范围"

案例: 普通索引 - 动态间隙锁 - update

-- T1 事务A
begin;
select c from c20 where c>5 lock in share mode;
/*
找到 c=5, 不知足, 向右遍历找到 c=10, 加锁 (5,10], 继续遍历, 继续加锁...
*/

-- T2 事务B
update c20 set c=1 where c=5;    -- OK
/*
删除了 c=5 这一行, 致使 T1时刻事务A 加的间隙锁 (5,10) 变为 (1,10)
*/

-- T3 事务C
update c20 set c=5 where c=1;    -- 阻塞
/*
将 update 理解为两步:
1. 插入 (c=5, id=5) 这个记录    -- 被间隙锁阻塞
2. 删除 (c=1, id=5) 这个记录
*/

案例: 非主键索引 - IN - 等值查询

begin;
select id from c20 where c in (5,20,10) lock in share mode;

经过 explain 分析语句:

mysql> explain select id from c20 where c in (5,20,10) lock in share mode;
+----+-------------+-------+-------+---------------+------+---------+------+------+---------------------
| id | select_type | table | type  | possible_keys | key  | key_len | ref  | rows | Extra     
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+---------
|  1 | SIMPLE      | c20   | range | c             | c    | 5       | NULL |    3 | Using where; Using index |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+---------
1 row in set, 1 warning (0.00 sec)
显示结果太长, 所以将 partitions, filtered 列删除了

结果分析:

  • 使用了索引 c
  • rows = 3 说明这3个值都是经过 B+ 树搜索定位的

语句分析:

  1. 在索引c上查找 c=5, 加锁 (0,5], 向右遍历找到 c=10, 不知足条件, 根据优化2, 加锁 (5,10)
  2. 在索引c上查找 c=10, 相似步骤1, 加锁 (5,10], (10, 15)
  3. 在索引c上查找 c=20, 加锁 (15,20], (20, 25)

注意上述锁是一个个逐步加上去的, 而非一次性所有加上去.

考虑如下语句:

begin;
select id from c20 where c in (5,20,10) order by id desc for update;

根据语义 order by id desc, 会依次查找 c=20, c=10, c=5.

因为加锁顺序相反, 所以若是这两个语句并发执行的时候就有可能发生死锁.

相关命令

查看最后一个死锁现场

show engine innodb status;

查看 LATEST DETECTED DEADLOCK 这一节, 记录了最后一次死锁信息.

示例

------------------------
LATEST DETECTED DEADLOCK
------------------------
2019-09-24 16:24:18 0x5484
*** (1) TRANSACTION:
TRANSACTION 1400, ACTIVE 191 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1080, 3 row lock(s)
MySQL thread id 54, OS thread handle 74124, query id 36912 localhost ::1 root updating
update c20 set d=d+1 where c=10
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 23 page no 4 n bits 80 index c of table `test_yjx`.`c20` trx id 1400 lock_mode X waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 8000000a; asc     ;;
 1: len 4; hex 8000000a; asc     ;;

*** (2) TRANSACTION:
TRANSACTION 1401, ACTIVE 196 sec inserting
mysql tables in use 1, locked 1
5 lock struct(s), heap size 1080, 3 row lock(s), undo log entries 1
MySQL thread id 53, OS thread handle 21636, query id 36916 localhost ::1 root update
insert into c20 values(8,8,8)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 23 page no 4 n bits 80 index c of table `test_yjx`.`c20` trx id 1401 lock mode S
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 8000000a; asc     ;;
 1: len 4; hex 8000000a; asc     ;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 23 page no 4 n bits 80 index c of table `test_yjx`.`c20` trx id 1401 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 8000000a; asc     ;;
 1: len 4; hex 8000000a; asc     ;;

*** WE ROLL BACK TRANSACTION (1)

结果分为3个部分:

  • (1) TRANSACTION 第一个事务的信息

    • WAITING FOR THIS LOCK TO BE GRANTED, 表示这个事务在等待的锁资源
  • (2) TRANSACTION 第二个事务的信息

    • HOLDS THE LOCK(S) 显示该事务持有哪些锁
  • WE ROLL BACK TRANSACTION (1) 死锁检测的处理: 回滚了第一个事务

第一个事务的信息中:

  • update c20 set d=d+1 where c=10 致使死锁时执行的最后一条 sql 语句
  • WAITING FOR THIS LOCK TO BE GRANTED

    • index c of table test_yjx.c20, 说明在等的是表 c20 的索引 c 上面的锁
    • lock_mode X waiting 表示这个语句要本身加一个写锁, 当前状态是等待中.
    • Record lock 说明这是一个记录锁
    • n_fields 2 表示这个记录是两列, 即 字段c 和 主键字段 id
    • 0: len 4; hex 8000000a; asc ;; 是第一个字段(即字段c), 值(忽略里面的8)是十六进制 a, 即 10

      值 8000000a 中的 8...我也不理解为何, 先忽略
    • 1: len 4; hex 8000000a; asc ;; 是第二个字段(即字段id), 值是 10
    • 上面两行里的 asc 表示, 接下来要打印出值里面的"可打印字符", 但10不是可打印字符, 所以就显示空格

      这里不太理解
  • 第一个事务信息只显示出等锁的状态, 在等待 (c=10, id=10) 这一行的锁
  • 没有显示当前事务持有的锁, 但能够从第二个事务中推测出来.

第二个事务的信息中:

  • insert into c20 values(8,8,8) 致使死锁时最后执行的语句
  • HOLDS THE LOCK(S)

    • index c of table test_yjx.c20 trx id 1401 lock mode S 表示锁是在表 c20 的索引 c 上, 加的是读锁
    • hex 8000000a;表示这个事务持有 c=10 这个记录锁
  • WAITING FOR THIS LOCK TO BE GRANTED

    • index c of table test_yjx.c20 trx id 1401 lock_mode X locks gap before rec insert intention waiting

      • insert intention 表示试图插入一个记录, 这是一个插入意向锁, 与间隙锁产生锁冲突
      • gap before rec 表示这是一个间隙锁, 而不是记录锁.

补充:

  • lock_mode X waiting 表示 next-key lock
  • lock_mode X locks rec but not gap 表示只有行锁
  • locks gap before rec 就是只有间隙锁

从上面信息能够知道:

  • 第一个事务

    • 推测出持有间隙锁 (?, 10)
    • 试图更新 c=10 这一行, 但被索引c 的 行锁 c=10 阻塞了
  • 第二个事务

    • 持有行锁 c=10
    • 试图插入 (8,8,8), 但被间隙锁 (?, 10) 阻塞了
  • 检测到死锁时, InnoDB 认为 第二个事务回滚成本更高, 所以回滚了第一个事务.

待整理

案例

-- 前提: 表 T 上有普通索引 k

-- 语句1
select * from T where k in (1,2,3,4,5);

-- 语句2
select * from T where k between 1 and 5;

这两条语句的区别是:

语句1: 在索引k上进行了5次树查找

语句2: 在索引k上进行了1次树查找(k=1), 以后向右遍历直到id>5

很明显, 语句2 性能会更好.

三. WAL 机制及脏页刷新

文章连接: https://segmentfault.com/a/11...

本部分主要来自: 极客时间《MySQL实战45讲》的第12讲 - 为何个人MySQL会“抖”一下

WAL(Write-Ahead Loggin)

WAL 是预写式日志, 关键点在于先写日志再写磁盘.

在对数据页进行修改时, 经过将"修改了什么"这个操做记录在日志中, 而没必要立刻将更改内容刷新到磁盘上, 从而将随机写转换为顺序写, 提升了性能.

但由此带来的问题是, 内存中的数据页会和磁盘上的数据页内容不一致, 此时将内存中的这种数据页称为 脏页

Redo Log(重作日志)

这里的日志指的是Redo Log(重作日志), 这个日志是循环写入的.

它记录的是在某个数据页上作了什么修改, 这个日志会携带一个LSN, 同时每一个数据页上也会记录一个LSN(日志序列号).

这个日志序列号(LSN)能够用于数据页是不是脏页的判断, 好比说 write pos对应的LSN比某个数据页的LSN大, 则这个数据页确定是干净页, 同时当脏页提早刷到磁盘时, 在应用Redo Log能够识别是否刷过并跳过.

这里有两个关键位置点:

  • write pos 当前记录的位置, 一边写一边后移.
  • checkpoint 是当前要擦除的位置, 擦除记录前要把记录更新到数据文件.

脏页

当内存数据页和磁盘数据页内容不一致的时候, 将内存页称为"脏页".
内存数据页写入磁盘后, 两边内容一致, 此时称为"干净页".
将内存数据页写入磁盘的这个操做叫作"刷脏页"(flush).

InnoDB是以缓冲池(Buffer Pool)来管理内存的, 缓冲池中的内存页有3种状态:

  • 未被使用
  • 已被使用, 而且是干净页
  • 已被使用, 而且是脏页

因为InnoDB的策略一般是尽可能使用内存, 所以长时间运行的数据库中的内存页基本都是被使用的, 未被使用的内存页不多.

刷脏页(flush)

时机

刷脏页的时机:

  1. Redo Log写满了, 须要将 checkpoint 向前推动, 以便继续写入日志

    checkpoint 向前推动时, 须要将推动区间涉及的全部脏页刷新到磁盘.

  2. 内存不足, 须要淘汰一些内存页(最久未使用的)给别的数据页使用.

    此时若是是干净页, 则直接拿来复用.

    若是是脏页, 则须要先刷新到磁盘(直接写入磁盘, 不用管Redo Log, 后续Redo Log刷脏页时会判断对应数据页是否已刷新到磁盘), 使之成为干净页再拿来使用.

  3. 数据库系统空闲时

    固然平时忙的时候也会尽可能刷脏页.

  4. 数据库正常关闭

    此时须要将全部脏页刷新到磁盘.

InnoDB须要控制脏页比例来避免Redo Log写满以及单次淘汰过多脏页过多的状况.

Redo Log 写满

这种状况尽可能避免, 所以此时系统就不接受更新, 全部更新语句都会被堵住, 此时更新数为0.

对于敏感业务来讲, 这是不能接受的.

此时须要将 write pos 向前推动, 推动范围内Redo Log涉及的全部脏页都须要flush到磁盘中.

Redo Log设置太小或写太慢的问题: 此时因为Redo Log频繁写满, 会致使频繁触发flush脏页, 影响tps.

内存不足

这种状况实际上是常态.

当从磁盘读取的数据页在内存中没有内存时, 就须要到缓冲池中申请一个内存页, 这时候根据LRU(最近最少使用算法)就须要淘汰掉一个内存页来使用.

此时淘汰的是脏页, 则须要将脏页刷新到磁盘, 变成干净页后才能复用.

注意, 这个过程 Write Pos 位置是不会向前推动的.

当一个查询要淘汰的脏页数太多, 会致使查询的响应时间明显变长.

策略

InnoDB 控制刷脏页的策略主要参考:

  • 脏页比例

    当脏页比例接近或超过参数 innodb_max_dirty_pages_pct 时, 则会全力, 不然按照百分比.

  • redo log 写盘速度

    N = (write pos 位置的日志序号 - checkpoint对应序号), 当N越大, 则刷盘速度越快.

最终刷盘速度取上述二者中最快的.

参数 innodb_io_capacity

InnoDB 有一个关键参数: innodb_io_capacity, 该参数是用于告知InnoDB你的磁盘能力, 该值一般建议设置为磁盘的写IOPS.

该参数在 MySQL 5.5 及后续版本才能够调整.

测试磁盘的IOPS:

fio -filename=/data/tmp/test_randrw -direct=1 -iodepth 1 -thread -rw=randrw -ioengine=psync -bs=16k -size=500M -numjobs=10 -runtime=10 -group_reporting -name=mytest
注意, 上面的 -filename 要指定具体的文件名, 千万不要指定分区, 不然会致使分区不可用, 须要从新格式化.

innodb_io_capacity 通常参考 写能力的IOPS

innodb_io_capacity 设置太低致使的性能问题案例:

MySQL写入速度很慢, TPS很低, 可是数据库主机的IO压力并不大.

innodb_io_capacity 设置太小时, InnoDB会认为磁盘性能差, 致使刷脏页很慢, 甚至比脏页生成速度还慢, 就会形成脏页累积, 影响查询和更新性能.

innodb_io_capacity 大小设置:

  • 配置小, 此时因为InnoDB认为你的磁盘性能差, 所以刷脏页频率会更高, 以此来确保内存中的脏页比例较少.
  • 配置大, InnoDB认为磁盘性能好, 所以刷脏页频率会下降, 抖动的频率也会下降.

参数innodb_max_dirty_pages_pct

innodb_max_dirty_pages_pct 指的是脏页比例上限(默认值是75%), 内存中的脏页比例越是接近该值, 则InnoDB刷盘速度会越接近全力.

如何计算内存中的脏页比例:

show global status like 'Innodb_buffer_pool_pages%';

脏页比例 = 100 * Innodb_buffer_pool_pages_dirty / Innodb_buffer_pool_pages_total 的值

参数 innodb_flush_neighbors

当刷脏页时, 若脏页旁边的数据页也是脏页, 则会连带刷新, 注意这个机制是会蔓延的.

innodb_flush_neighbors=1 时开启该机制, 默认是1, 但在 MySQL 8.0 中默认值是 0.

因为机械硬盘时代的IOPS通常只有几百, 该机制能够有效减小不少随机IO, 提升系统性能.

但在固态硬盘时代, 此时IOPS高达几千, 此时IOPS每每不是瓶颈, "只刷本身"能够更快执行完查询操做, 减小SQL语句的响应时间.

若是Redo Log 设置过小

这里有一个案例:

测试在作压力测试时, 刚开始 insert, update 很快, 可是一会就变慢且响应延迟很高.

↑ 出现这种状况大部分是由于 Redo Log 设置过小引发的.

由于此时 Redo Log 写满后须要将 checkpoint 前推, 此时须要刷脏页, 可能还会连坐(innodb_flush_neighbors=1), 数据库"抖"的频率变高.

其实此时内存的脏页比例可能还很低, 并无充分利用到大内存优点, 此时须要频繁flush, 性能会变差.

同时, 若是Redo Log中存在change buffer, 一样须要作相应的merge操做, 致使 change buffer 发挥不出做用.

对于实际场景:

在一台高性能机器上配置了很是小的Redo Log.

此时因为每次都很快写满Redo Log, 此时Write Pos会一直追着Check Point, 所以系统就会中止全部更新, 去推动 Check Point.

此时看到的现象就是: 磁盘压力很小, 可是数据库出现间歇性性能降低.

待整理

ORDER BY 的工做方式

关键字:

  • Using filesort, sort_buffer sort_buffer_size, 磁盘临时文件
  • 全字段排序, OPTIMIZER_TRACE,sort_mode,num_of_tmp_files
  • rowid 排序, max_length_for_sort_data

原文: https://time.geekbang.org/col...

TODO

相关文章
相关标签/搜索