You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

132 KiB

Database

Introduction:收纳技术相关的数据库知识 事务索引SQL优化 等总结!

[TOC]

数据库范式

  • 1NF:所有字段仅包含单值(即单个字段不可在分割使用)

  • 2NF:非键字段必须全完依赖于主键(不能是主键的部分)

  • 3NF:非键字段不能依赖于非键字段(禁止传递依赖)

  • BCNF:并且主属性不依赖于主属性

  • 第四范式4NF:要求把同一表内的多对多关系删除

  • 第五范式5NF:从最终结构重新建立原始结构

四种范式之间的关系:

1NF→2NF消去非主属性对键的部分函数依赖

2NF→3NF消去非主属性对键的传递函数依赖

3NF→BCNF消去主属性对键的传递函数依赖

第一范式(1NF)

列都是不可再分。即实体中的某个属性有多个值时,必须拆分为不同的属性。例如:

用户信息表

编号 姓名 年龄 地址
1 小王 23 浙江省杭州市拱墅区湖州街51号

当实际需求对地址没有特定的要求下,这个用户信息表的每一列都是不可分割的。但是当实际需求对省份或者城市有特别要求时,这个用户信息表中的地址就是可以分割的,改为:

用户信息表

编号 姓名 年龄 省份 城市 详细地址
1 小王 23 浙江省 杭州市 拱墅区湖州街51号

好处

  • 表结构相对清晰
  • 易于查询

第二范式(2NF)

每个表只描述一件事情。满足第一范式( 1NF的情况下每行必须有主键且主键与非主键之间是完全函数依赖关系消除部分子函数依赖。即数据库表中的每一列都和主键的所有属性相关不能只和主键的部分属性相关。完全函数依赖有属性集 XY通过 X 中的所有属性能够推出 Y 中的任意属性,但是 X 的任何真子集,都不能推出 Y 中的任何属性。例如:

学生课程表

学生编号 课程编号 学生名称 课程名称 所在班级 班主任
S1 C1 小王 计算机导论 计算机3班 陈老师
S1 C2 小王 数据结构 计算机3班 陈老师
S2 C1 小马 计算机导论 软件1班 李老师

将学生编号和课程编号作为主键,能确定唯一一条数据,但是学生名称只跟学生编号有关,跟课程编号无关,即不满足完全函数依赖,改为:

学生表

学生编号 学生名称 所在班级 班主任
S1 小王 计算机3班 陈老师
S2 小马 软件1班 李老师

课程表

课程编号 课程名称
C1 计算机导论
C2 数据结构

学生课程关系表

学生编号 课程编号
S1 C1
S1 C2
S2 C1

好处

  • 相对节约空间,当学生表和课程表属性越多,效果越明显
  • 解决插入异常,当新增一门课程时,原表因为没有学生选课,导致无法插入数据
  • 解决更新繁琐,当更改一门课程名称时,原表要更改多条数据
  • 解决删除异常,当学生学完一门课,原表若要清空学生上课信息,课程编号与课程名称的关系可能会丢失

第三范式(3NF)

不存在对非主键列的传递依赖。满足第二范式( 2NF的情况下任何非主属性不依赖于其它非主属性消除传递函数依赖。例如

学生表

学生编号 学生名称 班级编号 班级名字
S1 小王 001 计算机1班
S2 小马 003 计算机3班

学生编号作为主键满足第二范式2NF。通过学生编号 ⇒⇒ 班级编号 ⇒⇒ 班级名字,所以班级编号和班级名字之间存在依赖关系,改为:

学生表

学生编号 学生名称 班级编号
S1 小王 001
S2 小马 003

班级表

班级编号 班级名称
001 计算机1班
003 计算机3班

好处

  • 相对节约空间
  • 解决更新繁琐
  • 解决插入异常,当班级分配了老师,还没分配学生的时候,原表将不可插入数据
  • 解决删除异常,当学生毕业后,若要清空学生信息,班级和老师的关系可能会丢失

巴斯-科德范式BCNF

在3NF基础上消除主属性之间的传递函数依赖,即存在多个主属性时,主属性不依赖于主属性。例如:

配件表

仓库号 配件号 职工号 配件数量
W1 P1 E1 10
W1 P2 E1 10
W2 P1 E2 20

有以下约束:

  • 一个仓库有多个职工
  • 一个职工只在一个仓库
  • 一种配件可以放多个仓库
  • 一个仓库,一个职工管理多个配件,一种配件由唯一一个职工管理

由此,将(仓库号,配件号)作为主键,满足 3NF但是仓库号配件号⇒⇒ 职工号 ⇒⇒ 仓库号,造成传递函数依赖,改为:

仓库表

仓库号 职工号
W1 E1
W2 E2

工作表

职工号 配件号 配件数量
E1 P1 10
E1 P2 10
E2 P1 20

好处

  • 解决一些冗余和一些异常情况

不足

  • 丢失一些函数依赖,如丢失(仓库号,配件号)⇒⇒ 职工号,无法通过单表来确定一个职工号

第四范式4NF

在满足第三范式3NF的前提下表中不能包含一个实体的两个或多个互相独立的多值因子。

当一个表中非主属性相互独立时(即在 3NF 基础上),这些非主属性不应该有多值。即表中不能包含一个实体的两个或多个互相独立的多值因子。例如:

客户联系方式

客户编号 固定电话 移动电话
10 88-123 151
10 80-123 183

一个用户拥有多个固定电话和移动电话,给表的维护带来很多麻烦。比如增加一个固定电话,那么移动电话这一栏就较难维护,改为:

客户电话表

客户编号 电话号码 电话类型
10 88-123 固定电话
10 80-123 固定电话
10 151 移动电话
10 183 移动电话

好处

  • 解决一些异常,使表结构更加合理

第五范式5NF

在关系模式 R 中,每一个连接依赖均有 R 的候选码所隐含。即连接时,所连接的属性都是候选码,例如:

销售表

销售人员 供应商 产品
S1 V1 P1
S2 V2 P2
S1 V1 P1
S2 V2 P2

要想找到某一条数据,必须以(销售人员,供应商,产品)为主键,改为:

销售人员_供应商表

销售人员 供应商
S1 V1
S2 V2

销售人员_产品表

销售人员 产品
S1 P1
S2 P2

供应商_产品表

供应商 产品
V1 P1
V2 P2

好处

  • 解决某些异常操作

连接方式

内连接INNER JOIN

INNER JOIN 一般被译作内连接。内连接查询能将左表(表 A和右表表 B中能关联起来的数据连接后返回。

文氏图

内连接(INNER-JOIN)

示例查询

SELECT A.PK AS A_PK, B.PK AS B_PK,A.Value AS A_Value, B.Value AS B_Value
FROM Table_A A
INNER JOIN Table_B B
ON A.PK = B.PK;

查询结果

+------+------+---------+---------+
| A_PK | B_PK | A_Value | B_Value |
+------+------+---------+---------+
|    1 |    1 | both ab | both ab |
+------+------+---------+---------+
1 row in set (0.00 sec)

注意其中A为Table_A的别名B为Table_B的别名下同。

左连接LEFT JOIN

LEFT JOIN 一般被译作左连接,也写作 LEFT OUTER JOIN。左连接查询会返回左表表 A中所有记录不管右表表 B中有没有关联的数据。在右表中找到的关联数据列也会被一起返回。

文氏图

左连接(LEFT-JOIN)

示例查询

SELECT A.PK AS A_PK, B.PK AS B_PK,A.Value AS A_Value, B.Value AS B_Value
FROM Table_A A
LEFT JOIN Table_B B
ON A.PK = B.PK;

查询结果

+------+------+---------+---------+
| A_PK | B_PK | A_Value | B_Value |
+------+------+---------+---------+
|    1 |    1 | both ab | both ba |
|    2 | NULL | only a  | NULL    |
+------+------+---------+---------+
2 rows in set (0.00 sec)

右连接RIGHT JOIN

RIGHT JOIN 一般被译作右连接,也写作 RIGHT OUTER JOIN。右连接查询会返回右表表 B中所有记录不管左表表 A中有没有关联的数据。在左表中找到的关联数据列也会被一起返回。

文氏图

右连接(RIGHT-JOIN)

示例查询

SELECT A.PK AS A_PK, B.PK AS B_PK,A.Value AS A_Value, B.Value AS B_Value
FROM Table_A A
RIGHT JOIN Table_B B
ON A.PK = B.PK;

查询结果

+------+------+---------+---------+
| A_PK | B_PK | A_Value | B_Value |
+------+------+---------+---------+
|    1 |    1 | both ab | both ba |
| NULL |    3 | NULL    | only b  |
+------+------+---------+---------+
2 rows in set (0.00 sec)

全连接FULL JOIN

FULL JOIN 一般被译作外连接、全连接,实际查询语句中可以写作 FULL OUTER JOIN 或 FULL JOIN。外连接查询能返回左右表里的所有记录其中左右表里能关联起来的记录被连接后返回。MySQL不支持FULL OUTER JOIN

文氏图

全连接(FULL-OUTER-JOIN)

示例查询

SELECT A.PK AS A_PK, B.PK AS B_PK,A.Value AS A_Value, B.Value AS B_Value
FROM Table_A A
FULL OUTER JOIN Table_B B
ON A.PK = B.PK;

查询结果

ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'FULL OUTER JOIN Table_B B
ON A.PK = B.PK' at line 4

注意:我当前示例使用的 MySQL 不支持FULL OUTER JOIN。应当返回的结果使用UNION模拟

mysql> SELECT * 
    -> FROM Table_A
    -> LEFT JOIN Table_B 
    -> ON Table_A.PK = Table_B.PK
    -> UNION ALL
    -> SELECT *
    -> FROM Table_A
    -> RIGHT JOIN Table_B 
    -> ON Table_A.PK = Table_B.PK
    -> WHERE Table_A.PK IS NULL;
+------+---------+------+---------+
| PK   | Value   | PK   | Value   |
+------+---------+------+---------+
|    1 | both ab |    1 | both ba |
|    2 | only a  | NULL | NULL    |
| NULL | NULL    |    3 | only b  |
+------+---------+------+---------+
3 rows in set (0.00 sec)

SQL常见缩影

SQL常用JOIN

LEFT JOIN EXCLUDING INNER JOIN

返回左表有但右表没有关联数据的记录集。

文氏图

LEFT-JOIN-EXCLUDING-INNER-JOIN

示例查询

SELECT A.PK AS A_PK, B.PK AS B_PK,A.Value AS A_Value, B.Value AS B_Value
FROM Table_A A
LEFT JOIN Table_B B
ON A.PK = B.PK
WHERE B.PK IS NULL;

查询结果

+------+------+---------+---------+
| A_PK | B_PK | A_Value | B_Value |
+------+------+---------+---------+
|    2 | NULL | only a  | NULL    |
+------+------+---------+---------+
1 row in set (0.01 sec)

RIGHT JOIN EXCLUDING INNER JOIN

返回右表有但左表没有关联数据的记录集。

文氏图

RIGHT-JOIN-EXCLUDING-INNER-JOIN

示例查询

SELECT A.PK AS A_PK, B.PK AS B_PK,A.Value AS A_Value, B.Value AS B_Value
FROM Table_A A
RIGHT JOIN Table_B B
ON A.PK = B.PK
WHERE A.PK IS NULL;

查询结果

+------+------+---------+---------+
| A_PK | B_PK | A_Value | B_Value |
+------+------+---------+---------+
| NULL |    3 | NULL    | only b  |
+------+------+---------+---------+
1 row in set (0.00 sec)

FULL JOIN EXCLUDING INNER JOIN

返回左表和右表里没有相互关联的记录集。

文氏图

FULL-OUTER-JOIN-EXCLUDING-INNER-JOIN

示例查询

SELECT A.PK AS A_PK, B.PK AS B_PK,A.Value AS A_Value, B.Value AS B_Value
FROM Table_A A
FULL OUTER JOIN Table_B B
ON A.PK = B.PK
WHERE A.PK IS NULL
OR B.PK IS NULL;

因为使用到了 FULL OUTER JOINMySQL 在执行该查询时再次报错。

ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'FULL OUTER JOIN Table_B B
ON A.PK = B.PK
WHERE A.PK IS NULL
OR B.PK IS NULL' at line 4

应当返回的结果(用 UNION 模拟):

mysql> SELECT * 
    -> FROM Table_A
    -> LEFT JOIN Table_B
    -> ON Table_A.PK = Table_B.PK
    -> WHERE Table_B.PK IS NULL
    -> UNION ALL
    -> SELECT *
    -> FROM Table_A
    -> RIGHT JOIN Table_B
    -> ON Table_A.PK = Table_B.PK
    -> WHERE Table_A.PK IS NULL;
+------+--------+------+--------+
| PK   | Value  | PK   | Value  |
+------+--------+------+--------+
|    2 | only a | NULL | NULL   |
| NULL | NULL   |    3 | only b |
+------+--------+------+--------+
2 rows in set (0.00 sec)

SQL所有JOIN

SQL所有JOIN

CROSS JOIN

返回左表与右表之间符合条件的记录的迪卡尔集。

图示

CROSS-JOIN

示例查询

SELECT A.PK AS A_PK, B.PK AS B_PK,A.Value AS A_Value, B.Value AS B_Value
FROM Table_A A
CROSS JOIN Table_B B;

查询结果

+------+------+---------+---------+
| A_PK | B_PK | A_Value | B_Value |
+------+------+---------+---------+
|    1 |    1 | both ab | both ba |
|    2 |    1 | only a  | both ba |
|    1 |    3 | both ab | only b  |
|    2 |    3 | only a  | only b  |
+------+------+---------+---------+
4 rows in set (0.00 sec)

上面讲过的几种 JOIN 查询的结果都可以用 CROSS JOIN 加条件模拟出来,比如 INNER JOIN 对应 CROSS JOIN ... WHERE A.PK = B.PK。

SELF JOIN

返回表与自己连接后符合条件的记录,一般用在表里有一个字段是用主键作为外键的情况。比如 Table_C 的结构与数据如下:

+--------+----------+-------------+
| EMP_ID | EMP_NAME | EMP_SUPV_ID |
+--------+----------+-------------+
|   1001 | Ma       |        NULL |
|   1002 | Zhuang   |        1001 |
+--------+----------+-------------+
2 rows in set (0.00 sec)

EMP_ID 字段表示员工 IDEMP_NAME 字段表示员工姓名EMP_SUPV_ID 表示主管 ID。

示例查询

现在我们想查询所有有主管的员工及其对应的主管 ID 和姓名,就可以用 SELF JOIN 来实现。

SELECT A.EMP_ID AS EMP_ID, A.EMP_NAME AS EMP_NAME, 
    B.EMP_ID AS EMP_SUPV_ID, B.EMP_NAME AS EMP_SUPV_NAME
FROM Table_C A, Table_C B
WHERE A.EMP_SUPV_ID = B.EMP_ID;

查询结果

+--------+----------+-------------+---------------+
| EMP_ID | EMP_NAME | EMP_SUPV_ID | EMP_SUPV_NAME |
+--------+----------+-------------+---------------+
|   1002 | Zhuang   |        1001 | Ma            |
+--------+----------+-------------+---------------+
1 row in set (0.00 sec)

补充说明

  • 文中的图使用 Keynote 绘制
  • 个人的体会是 SQL 里的 JOIN 查询与数学里的求交集、并集等很像SQLite 不支持 RIGHT JOIN 和 FULL OUTER JOIN可以使用 LEFT JOIN 和 UNION 来达到相同的效果
  • MySQL 不支持 FULL OUTER JOIN可以使用 LEFT JOIN 和 UNION 来达到相同的效果

数据库锁

从粒度上来说就是表锁、页锁、行锁。表锁有意向共享锁、意向排他锁、自增锁等。行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如 MyISAM引擎就不支持行锁

按照锁的粒度进行分类MySQL主要包含三种类型级别的锁定机制

  • 全局锁锁的是整个database。由MySQL的SQL layer层实现的
  • 表级锁锁的是某个table。由MySQL的SQL layer层实现的
  • ⾏级锁锁的是某⾏数据也可能锁定⾏之间的间隙。由某些存储引擎实现⽐如InnoDB

表级锁和行级锁的区别:

  • 表级锁:开销⼩,加锁快;不会出现死锁;锁定粒度⼤,发⽣锁冲突的概率最⾼,并发度最低
  • ⾏级锁:开销⼤,加锁慢;会出现死锁;锁定粒度最⼩,发⽣锁冲突的概率最低,并发度也最⾼

全局锁

锁的是整个database。由MySQL的SQL layer层实现的。

行级锁

InnoDB 事务中,行锁通过给索引上的索引项加锁来实现。即只有通过索引条件检索数据,InnoDB 才使用行级锁,否则将使用表锁。行级锁定同样分为两种类型:共享锁排他锁,以及加锁前需要先获得的 意向共享锁意向排他锁。行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。

在 InnoDB 存储引擎中SELECT 操作的不可重复读问题通过 MVCC 得到了解决,而 UPDATE、DELETE 的不可重复读问题通过 Record Lock 解决INSERT 的不可重复读问题是通过 Next-Key LockRecord Lock + Gap Lock解决的。

共享锁(Shared Locks)

共享锁又称为 S锁读锁。若事务T对数据对象A加上 S锁则事务T 只能读A其他事务只能再对A加S锁而不能加X锁直到T释放A上的S锁。这就保证了其他事务可以读A但在T释放A上的S锁之前不能对A做任何修改。

  • select ... lock in share mode: 会加共享锁

排它锁(Exclusive Locks)

排它锁又称为 X锁写锁。若事务T对数据对象A加上X锁则只允许T读取和修改A其它任何事务都不能再对A加任何类型的锁直到T释放A上的锁。它防止任何其它事务获取资源上的锁直到在事务的末尾将资源上的原始锁释放为止。在更新操作(INSERT、UPDATE 或 DELETE)过程中始终应用排它锁。

注意:排他锁会阻止其它事务再对其锁定的数据加读或写的锁,但是不加锁的就没办法控制了。

  • insertupdatedeleteselect ... for update:会加排它锁

MVCC

MVCC主要是通过版本链ReadView来实现的。在Mysql的InnoDB引擎中只有**已提交读(READ COMMITTD)可重复读(REPEATABLE READ)**这两种隔离级别下的事务采用了MVCC机制。

  • 版本链

    在InnoDB引擎表中它的每一行记录中有两个必要的隐藏列

    • DATA_TRX_ID:表示插入或更新该行的最后一个事务的事务标识符,同样删除在内部被视为更新,在该更新中,行中的特殊位被设置为将其标记为已删除。行中会有一个特殊位置来标记删除。
    • DATA_ROLL_PTR:存储了一个指针,它指向这条记录的上一个版本的位置,通过它来获得上一个版本的记录信息。

    作用:解决了读和写的并发执行。

  • ReadView

    ReadView主要存放的是当前事务操作时系统中仍然活跃着的事务事务开启后没有提交或回滚的事务

    • ReadView数据结构ReadView是MySQL底层使用C++代码实现的一个结构体,主要的内部属性如下:

      • trx_ids数组存储的是创建readview时活跃事务链表里所有的事务ID
      • low_limit_id存储的是创建readview时活跃事务链表里最大的事务ID
      • up_limit_id存储的是创建readview时活跃事务链表里最小的事务ID
      • creator_trx_id当前readview所属事务的事务版本号
    • ReadView创建策略对于读提交和可重复读事务隔离级别来说ReadView创建策略是不同的这样才能保证隔离性不同

      • 可重复读隔离级别:事务开启后,第一次查询的时候创建,之后一直不变,直到事务结束
      • 读提交隔离级别:事务开启后,每一次读取都重新创建

    也就是说已提交读隔离级别下的事务在每次查询的开始都会生成一个独立的ReadView而可重复读隔离级别则在第一次读的时候生成一个ReadView之后的读都复用之前的ReadView。

行锁实现算法

  • Record Lock记录锁
  • Gap Lock间隙锁
  • Next-Key Lock间隙锁

Record Lock记录锁

记录锁就是为某行记录加锁,它封锁该行的索引记录:

-- id 列为主键列或唯一索引列
SELECT * FROM t_user WHERE id = 1 FOR UPDATE;

id 为 1 的记录行会被锁住。需要注意:

  • id 列必须为唯一索引列主键列,否则上述语句加的锁就会变成临键锁
  • 同时查询语句必须为精准匹配=),不能为 ><like等,否则也会退化成临键锁

也可以在通过 主键索引唯一索引 对数据行进行 UPDATE 操作时,也会对该行数据加记录锁

-- id 列为主键列或唯一索引列
UPDATE t_user SET age = 50 WHERE id = 1;

Gap Lock间隙锁

间隙锁基于非唯一索引,它锁定一段范围内的索引记录间隙锁基于下面将会提到的Next-Key Locking 算法,请务必牢记:使用间隙锁锁住的是一个区间,而不仅仅是这个区间中的每一条数据

SELECT * FROM t_user WHERE id BETWEN 1 AND 10 FOR UPDATE;
-- 或
SELECT * FROM t_user WHERE id > 1 AND id < 10 FOR UPDATE;

即所有在110区间内的记录行都会被锁住所有id 为 2、3、4、5、6、7、8、9 的数据行的插入会被阻塞,但是 1 和 10 两条记录行并不会被锁住。除了手动加锁外,在执行完某些 SQL后,InnoDB也会自动加间隙锁

幻读原因:因为行锁只能锁住行,但新插入记录这个动作,要更新的是记录之间的“间隙”。所以加入间隙锁来解决幻读。

Next-Key Lock临键锁

临键锁是一种特殊的间隙锁,也可以理解为一种特殊的算法。通过临建锁可以解决幻读的问题。每个数据行上的非唯一索引列上都会存在一把临键锁,当某个事务持有该数据行的临键锁时,会锁住一段左开右闭区间的数据。需要强调的一点是,InnoDB行级锁是基于索引实现的,临键锁只与非唯一索引列有关,在唯一索引列(包括主键列)上不存在临键锁

比如:表信息 t_user(id PK, age KEY, name)

Next-Key-Locks

该表中 age 列潜在的临键锁有:

Next-Key-Locks-临键锁

事务 A 中执行如下命令:

-- 根据非唯一索引列 UPDATE 某条记录
UPDATE t_user SET name = Vladimir WHERE age = 24;
-- 或根据非唯一索引列 锁住某条记录
SELECT * FROM t_user WHERE age = 24 FOR UPDATE;

不管执行了上述 SQL 中的哪一句,之后如果在事务 B 中执行以下命令,则该命令会被阻塞:

INSERT INTO t_user VALUES(100, 26, 'tian');

很明显,事务 A 在对 age 为 24 的列进行 UPDATE 操作的同时,也获取了 (24, 32] 这个区间内的临键锁。

不仅如此,在执行以下 SQL 时,也会陷入阻塞等待:

INSERT INTO table VALUES(100, 30, 'zhang');

那最终我们就可以得知,在根据非唯一索引 对记录行进行 UPDATE \ FOR UPDATE \ LOCK IN SHARE MODE 操作时InnoDB 会获取该记录行的 临键锁 ,并同时获取该记录行下一个区间的间隙锁

事务 A在执行了上述的 SQL 后,最终被锁住的记录区间为 (10, 32)

表级锁

MySQL 里面表级别的锁有这几种:

  • 表锁
  • 元数据锁MDL
  • 意向锁
  • AUTO-INC 锁

表锁

  • 表锁会限制别的线程的读写外
  • 表锁也会限制本线程接下来的读写操作

如果我们想对学生表t_student加表锁可以使用下面的命令

-- 表级别的共享锁,也就是读锁
lock tables t_student read;
-- 表级别的独占锁,也就是写锁
lock tables t_stuent wirte;

不过尽量避免在使用 InnoDB 引擎的表使用表锁,因为表锁的颗粒度太大,会影响并发性能,InnoDB 的优势在于实现了颗粒度更细的行级锁。要释放表锁,可以使用下面这条命令,会释放当前会话的所有表锁:

unlock tables

元数据锁MDL

MDL 是为了保证当用户对表执行 CRUD 操作时,防止其他线程对这个表结构做了变更。不需要显示的使用 MDL因为当我们对数据库表进行操作时会自动给这个表加上 MDL

  • 对一张表进行 CRUD 操作时,加的是 MDL 读锁

    当有线程在执行 select 语句( 加 MDL 读锁)的期间,如果有其他线程要更改该表的结构( 申请 MDL 写锁),那么将会被阻塞,直到执行完 select 语句( 释放 MDL 读锁)。

  • 对一张表做结构变更操作的时候,加的是 MDL 写锁

    当有线程对表结构进行变更( 加 MDL 写锁)的期间,如果有其他线程执行了 CRUD 操作( 申请 MDL 读锁),那么就会被阻塞,直到表结构变更完成( 释放 MDL 写锁)。

MDL 是在事务提交后才会释放,这意味着事务执行期间MDL 是一直持有的。申请 MDL 锁的操作会形成一个队列,队列中写锁获取优先级高于读锁,一旦出现 MDL 写锁等待,会阻塞后续该表的所有 CRUD 操作。

意向锁

  • 在使用 InnoDB 引擎的表里对某些记录加上「共享锁」之前,需要先在表级别加上一个「意向共享锁」;
  • 在使用 InnoDB 引擎的表里对某些纪录加上「独占锁」之前,需要先在表级别加上一个「意向独占锁」;

也就是,当执行插入、更新、删除操作,需要先对表加上「意向共享锁」,然后对该记录加独占锁。而普通的 select 是不会加行级锁的,普通的 select 语句是利用 MVCC 实现一致性读是无锁的。不过select 也是可以对记录加共享锁和独占锁的,具体方式如下:

-- 先在表上加上意向共享锁,然后对读取的记录加独占锁
select ... lock in share mode;
-- 先表上加上意向独占锁,然后对读取的记录加独占锁
select ... for update;

意向共享锁和意向独占锁是表级锁,不会和行级的共享锁和独占锁发生冲突,而且意向锁之间也不会发生冲突,只会和共享表锁(*lock tables … read*)和独占表锁(*lock tables … write*)发生冲突。

表锁和行锁是满足读读共享、读写互斥、写写互斥的。如果没有「意向锁」,那么加「独占表锁」时,就需要遍历表里所有记录,查看是否有记录存在独占锁,这样效率会很慢。那么有了「意向锁」,由于在对记录加独占锁前,先会加上表级别的意向独占锁,那么在加「独占表锁」时,直接查该表是否有意向独占锁,如果有就意味着表里已经有记录被加了独占锁,这样就不用去遍历表里的记录。所以,意向锁的目的是为了快速判断表里是否有记录被加锁

AUTO-INC锁(自增长锁)

在为某个字段声明 AUTO_INCREMENT 属性时,之后可以在插入数据时,可以不指定该字段的值,数据库会自动给该字段赋值递增的值,这主要是通过 AUTO-INC 锁实现的。

AUTO-INC 锁是特殊的表锁机制,锁不是再一个事务提交后才释放,而是再执行完插入语句后就会立即释放在插入数据时,会加一个表级别的 AUTO-INC 锁,然后为被 AUTO_INCREMENT 修饰的字段赋值递增的值,等插入语句执行完成后,才会把 AUTO-INC 锁释放掉。

AUTO-INC 锁再对大量数据进行插入的时候,会影响插入性能,因为另一个事务中的插入会被阻塞。因此, 在 MySQL 5.1.22 版本开始InnoDB 存储引擎提供了一种轻量级的锁来实现自增。一样也是在插入数据的时候,会为被 AUTO_INCREMENT 修饰的字段加上轻量级锁,然后给该字段赋值一个自增的值,就把这个轻量级锁释放了,而不需要等待整个插入语句执行完后才释放锁

InnoDB 存储引擎提供了个 innodb_autoinc_lock_mode 的系统变量,是用来控制选择用 AUTO-INC 锁,还是轻量级的锁。

  • 当 innodb_autoinc_lock_mode = 0就采用 AUTO-INC 锁
  • 当 innodb_autoinc_lock_mode = 2就采用轻量级锁
  • 当 innodb_autoinc_lock_mode = 1这个是默认值两种锁混着用如果能够确定插入记录的数量就采用轻量级锁不确定时就采用 AUTO-INC 锁

不过,当 innodb_autoinc_lock_mode = 2 是性能最高的方式,但是会带来一定的问题。因为并发插入的存在,在每次插入时,自增长的值可能不是连续的,这在有主从复制的场景中是不安全的

死锁

当两个及以上的事务,双方都在等待对方释放已经持有的锁或因为加锁顺序不一致造成循环等待锁资源,就会出现“死锁”。常见的报错信息为 ” Deadlock found when trying to get lock...”。MySQL 出现死锁的几个要素为:

  • 两个或者两个以上事务
  • 每个事务都已经持有锁并且申请新的锁
  • 锁资源同时只能被同一个事务持有或者不兼容
  • 事务之间因为持有锁和申请锁导致彼此循环等待

预防死锁

  • innodb_lock_wait_timeout 等待锁超时回滚事务

    直观方法是在两个事务相互等待时,当一个等待时间超过设置的某一阀值时,对其中一个事务进行回滚,另一个事务就能继续执行。

  • wait-for graph算法来主动进行死锁检测

    每当加锁请求无法立即满足需要并进入等待时wait-for graph算法都会被触发。wait-for graph要求数据库保存以下两种信息

    • 锁的信息链表
    • 事务等待链表

解决死锁

  • 等待事务超时,主动回滚
  • 进行死锁检查,主动回滚某条事务,让别的事务能继续走下去

下面提供一种方法,解决死锁的状态:

-- 查看正在被锁的事务
SELECT * FROM INFORMATION_SCHEMA.INNODB_TRX;

解决死锁

--上图trx_mysql_thread_id列的值
kill trx_mysql_thread_id;

锁问题

脏读

脏读指的是不同事务下,当前事务可以读取到另外事务未提交的数据。

例如T1 修改一个数据T2 随后读取这个数据。如果 T1 撤销了这次修改,那么 T2 读取的数据是脏数据。

img

不可重复读

不可重复读指的是同一事务内多次读取同一数据集合,读取到的数据是不一样的情况。

例如T2 读取一个数据T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。

img

在 InnoDB 存储引擎中:

  • SELECT:操作的不可重复读问题通过 MVCC 得到了解决的
  • UPDATE/DELETE:操作的不可重复读问题是通过 Record Lock 解决的
  • INSERT:操作的不可重复读问题是通过 Next-Key LockRecord Lock + Gap Lock解决的

幻读

幻读是指在同一事务下,连续执行两次同样的 sql 语句可能返回不同的结果,第二次的 sql 语句可能会返回之前不存在的行。幻影读是一种特殊的不可重复读问题。

丢失更新

一个事务的更新操作会被另一个事务的更新操作所覆盖。

例如T1 和 T2 两个事务都对一个数据进行修改T1 先修改T2 随后修改T2 的修改覆盖了 T1 的修改。

img

这类型问题可以通过给 SELECT 操作加上排他锁来解决,不过这可能会引入性能问题,具体使用要视业务场景而定。

数据库事务

什么叫事务?

事务是一系列对系统中数据进行访问与更新的操作组成的一个程序逻辑单元。即不可分割的许多基础数据库操作。

事务特性(ACID)

原子性Atomicity

事务是最小的执行单位,不允许分割。原子性确保动作要么全部完成,要么完全不起作用。

原子性是依赖于回滚日志(undo log)实现的。当事务对数据库进行修改时,InnoDB会生成对应的 undo log;如果事务执行失败或调用了 rollback,导致事务需要回滚,便可以利用 undo log 中的信息将数据回滚到修改之前的样子。undo log属于逻辑日志它记录的是sql执行相关的信息。当发生回滚时InnoDB 会根据 undo log 的内容做与之前相反的工作:

  • 对于每个insert,回滚时会执行delete
  • 对于每个 delete,回滚时会执行insert
  • 对于每个 update,回滚时会执行一个相反的 update,把数据改回去

update操作为例:当事务执行update时,其生成的undo log中会包含被修改行的主键、修改了哪些列、这些列在修改前后的值等信息,回滚时便可以使用这些信息将数据还原到update之前的状态。

一致性Consistency

事务开始前和结束后,数据库的完整性约束没有被破坏。比如A向B转账不可能A扣了钱B却没收到。

一致性是事务追求的最终目标,原子性、持久性和隔离性其实都是为了保证数据库状态的一致性。当然,都是数据库层面的保障,一致性的实现也需要应用层面进行保障。也就是你的业务,比如购买操作只扣除用户的余额,不减库存,肯定无法保证状态的一致。

隔离性Isolation

并发访问数据库时,一个事务不被其他事务所干扰。

隔离性是通过 MVCC(多版本并发控制) 实现。InnoDB采用的MVCC实现方式是在需要时通过undo日志构造出历史版本。

持久性Durability

一个事务被提交之后。对数据库中数据的改变是持久的,即使数据库发生故障。

InnnoDB有很多 log持久性靠的是redo log。持久性肯定和写有关,MySQL 里经常说到的 WAL技术,WAL的全称是Write-Ahead Logging,它的关键点就是先写日志,再写磁盘。

redo log

当有一条记录要更新时,InnoDB引擎就会先把记录写到redo log(并更新内存),这个时候更新就算完成了。在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。redo log有两个特点

  • 大小固定,循环写
  • crash-safe

对于redo log是有两阶段的:commitprepare如果不使用“两阶段提交”,数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。

Buffer Pool

InnoDB还提供了缓存Buffer Pool中包含了磁盘中部分数据页的映射,作为访问数据库的缓冲:

  • 当读取数据时,会先从Buffer Pool中读取,如果Buffer Pool中没有则从磁盘读取后放入Buffer Pool
  • 当向数据库写入数据时,会首先写入Buffer PoolBuffer Pool中修改的数据会定期刷新到磁盘中

Buffer Pool 的使用大大提高了读写数据的效率,但是也带了新的问题:如果MySQL宕机,而此时 Buffer Pool中修改的数据还没有刷新到磁盘,就会导致数据的丢失,事务的持久性无法保证。

所以加入了 redo log。 当数据修改时,除了修改Buffer Pool中的数据,还会在redo log记录这次操作。当事务提交时,会调用fsync接口对redo log进行刷盘。如果MySQL宕机,重启时可以读取redo log中的数据,对数据库进行恢复。

redo log采用的是WALWrite-ahead logging,预写式日志),所有修改先写入日志,再更新到Buffer Pool保证了数据不会因MySQL宕机而丢失从而满足了持久性要求。而且这样做还有两个优点

  • 刷脏页是随机IOredo log 顺序IO
  • 刷脏页以Page为单位一个Page上的修改整页都要写而redo log 只包含真正需要写入的,无效 IO 减少

隔离级别

数据库事务隔离级别有4种由低到高为Read uncommittedRead committedRepeatable readSerializable

事务并发问题

在事务的并发操作中,不做隔离操作则可能会出现 脏读、不可重复读、幻读 问题:

  • 脏读事务A中读到了事务B中未提交的更新数据内容。然后B回滚操作那么A读取到的数据是脏数据
  • 不可重复读事务A读到事务B已经提交后的数据。即事务A多次读取同一数据时返回结果不一致
  • 幻读事物A执行select后事物B增或删了一条数据事务A再执行同一条SQL后发现多或少了一条数据

小结:不可重复读的和幻读很容易混淆,不可重复读侧重于修改幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表。

默认隔离级别

  • Oracle仅有Serializable(串行化)和Read Committed(读已提交)两种隔离方式,默认选择读已提交的方式
  • MySQL默认为Repeatable Read可重读

数据库中的锁

  • 共享锁Share locks简记为S锁:也称读锁事务A对对象T加s锁其他事务也只能对T加S多个事务可以同时读但不能有写操作直到A释放S锁。
  • 排它锁Exclusivelocks简记为X锁:也称写锁事务A对对象T加X锁以后其他事务不能对T加任何锁只有事务A可以读写对象T直到A释放X锁。
  • 更新锁简记为U锁用来预定要对此对象施加X锁它允许其他事务读但不允许再施加U锁或X锁当被读取的对象将要被更新时则升级为X锁主要是用来防止死锁的。因为使用共享锁时修改数据的操作分为两步首先获得一个共享锁读取数据然后将共享锁升级为排它锁然后再执行修改操作。这样如果同时有两个或多个事务同时对一个对象申请了共享锁在修改数据的时候这些事务都要将共享锁升级为排它锁。这些事务都不会释放共享锁而是一直等待对方释放这样就造成了死锁。如果一个数据在修改前直接申请更新锁在数据修改的时候再升级为排它锁就可以避免死锁。

InnoDB存储引擎下的四种隔离级别发生问题的可能性如下:

隔离级别 第一类丢失更新 第二类丢失更新 脏读 不可重复读 幻读
Read Uncommitted(读未提交) 不可能 可能 可能 可能 可能
Read Committed(读已提交) 不可能 可能 不可能 可能 可能
Repeatable Read(可重复读) 不可能 不可能 不可能 不可能 可能
Serializable(串行化) 不可能 不可能 不可能 不可能 不可能

Read Uncommitted(读未提交)

即读取到了其它事务未提交的内容。在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。本隔离级别很少用于实际应用,因为它的性能也不比其他级别好多少。读取未提交的数据,也被称之为脏读Dirty Read

特点:最低级别,任何情况都无法保证

读未提交的数据库锁情况

  • 读取数据:未加锁
  • 写入数据:只对数据增加行级共享锁

Read Committed(读已提交)

即读取到了其它事务已提交的内容。一个事务只能看见已经提交事务所做的改变。这种隔离级别也支持所谓的不可重复读Nonrepeatable Read因为同一事务的其他实例在该实例处理期间可能会有新的commit所以同一select可能返回不同结果。

特点:避免脏读

读已提交的数据库锁情况

  • 读取数据:加行级共享锁(读到时才加锁),读完后立即释放
  • 写入数据:在更新时的瞬间对其加行级排它锁,直到事务结束才释放

Read Committed隔离级别下的加锁分析

隔离级别的实现与锁机制密不可分所以需要引入锁的概念首先我们看下InnoDB存储引擎提供的两种标准的行级锁

  • 共享锁(S Lock)又称为读锁可以允许多个事务并发的读取同一资源互不干扰。即如果一个事务T对数据A加上共享锁后其他事务只能对A再加共享锁不能再加排他锁只能读数据不能修改数据
  • 排他锁(X Lock): 又称为写锁如果事务T对数据A加上排他锁后其他事务不能再对A加上任何类型的锁获取排他锁的事务既能读数据也能修改数据

注意 共享锁和排他锁是不相容的。

Repeatable Read(可重复读)

它确保同一事务的多个实例在并发读取数据时,会看到同样的数据行。但会导致**幻读 Phantom Read**问题。

幻读 是户读取某一范围的数据行时,另一个事务又在该范围内插入了新行,当该用户再读取该范围的数据行时,会发现有新的“幻影” 行。

特点避免脏读、不可重复读。MySQL默认事务隔离级别

可重复读的数据库锁情况

  • 读取数据:开始读取的瞬间对其增加行级共享锁,直到事务结束才释放
  • 写入数据:开始更新的瞬间对其增加行级排他锁,直到事务结束才释放

Serializable(可串行化)

指一个事务在执行过程中完全看不到其他事务对数据库所做的更新。当两个事务同时操作数据库中相同数据时,如果第一个事务已经在访问该数据,第二个事务只能停下来等待,必须等到第一个事务结束后才能恢复运行。因此这两个事务实际上是串行化方式运行。

特点:避免脏读、不可重复读、幻读

可序列化的数据库锁情况

  • 读取数据:先对其加表级共享锁 ,直到事务结束才释放
  • 写入数据:先对其加表级排他锁 ,直到事务结束才释放

Spring事务

查看 mysql 事务隔离级别:show variables like 'tx_iso%';

实现方式

在Spring中事务有两种实现方式

  • 编程式事务管理 编程式事务管理使用TransactionTemplate或直接使用底层的PlatformTransactionManager
  • 声明式事务管理 建立在AOP之上的。其本质是对方法前后进行拦截,然后在目标方法开始之前创建或者加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务。声明式事务管理不需要入侵代码,通过@Transactional就可以进行事务操作,更快捷而且简单

提交方式

默认情况下,数据库处于自动提交模式。每一条语句处于一个单独的事务中,在这条语句执行完毕时,如果执行成功则隐式的提交事务,如果执行失败则隐式的回滚事务。 对于正常的事务管理是一组相关的操作处于一个事务之中因此必须关闭数据库的自动提交模式。不过这个我们不用担心spring会将底层连接的自动提交特性设置为false。也就是在使用spring进行事物管理的时候spring会将是否自动提交设置为false等价于JDBC中的 connection.setAutoCommit(false);,在执行完之后在进行提交,connection.commit();

事务隔离级别

隔离级别是指若干个并发的事务之间的隔离程度。

@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public void addGoods(){
	......
}

枚举类Isolation中定义了五种隔离级别

  • DEFAULT:默认值。表示使用底层数据库的默认隔离级别。对大部分数据库而言,通常这值就是READ_COMMITTED
  • READ_UNCOMMITTED:该隔离级别表示一个事务可以读取另一个事务修改但还没有提交的数据。该级别不能防止脏读,不可重复读和幻读,因此很少使用该隔离级别
  • READ_COMMITTED:该隔离级别表示一个事务只能读取另一个事务已经提交的数据。该级别可以防止脏读,这也是大多数情况下的推荐值
  • REPEATABLE_READ:该隔离级别表示一个事务在整个过程中可以多次重复执行某个查询,并且每次返回的记录都相同。该级别可以防止脏读和不可重复读
  • SERIALIZABLE:所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别

事务传播行为

事务的传播性一般用在事务嵌套的场景,如一个事务方法里面调用了另外一个事务方法,那两个方法是各自作为独立的方法提交还是内层事务合并到外层事务一起提交,这就需要事务传播机制配置来确定怎么样执行。

@Transactional(propagation=Propagation.REQUIRED)
public void addGoods(){
	......
}

枚举类Propagation中定义了七种事务传播机制如下

  • REQUIREDrequired要求Spring默认当前存在事务,则加入该事务;当前没有事务,则创建一个新的事务
  • REQUIRES_NEWrequires_new要求新的创建一个新事务,如果存在当前事务,则挂起该事务
  • SUPPORTSsupports支持如果当前存在事务,则加入当前事务;如果当前没有事务,就以非事务方法执行
  • NOT_SUPPORTEDnot_supported不支持始终以非事务方式执行,如果当前存在事务,则挂起当前事务
  • NEVERnever都不不使用事务,如果当前事务存在,则抛出异常
  • MANDATORYmandatory强制如果当前存在事务,则加入当前事务;如果当前事务不存在,则抛出异常
  • NESTEDnested嵌套如果当前事务存在则在嵌套事务中执行否则REQUIRED的操作一样开启一个事务

事务回滚规则

指示spring事务管理器回滚一个事务的推荐方法是在当前事务的上下文内抛出异常。spring事务管理器会捕捉任何未处理的异常然后依据规则决定是否回滚抛出异常的事务。 默认配置下spring只有在抛出的异常为运行时unchecked异常时才回滚该事务也就是抛出的异常为RuntimeException的子类(Errors也会导致事务回滚)而抛出checked异常则不会导致事务回滚。 可以明确的配置在抛出那些异常时回滚事务包括checked异常。也可以明确定义那些异常抛出时不回滚事务。

事务常用配置

  • readOnly

    该属性用于设置当前事务是否为只读事务设置为true表示只读false则表示可读写默认值为false。例如@Transactional(readOnly=true)

  • rollbackFor

    该属性用于设置需要进行回滚的异常类数组,当方法中抛出指定异常数组中的异常时,则进行事务回滚。例如:指定单一异常类:@Transactional(rollbackFor=RuntimeException.class)指定多个异常类:@Transactional(rollbackFor={RuntimeException.class, Exception.class})

  • rollbackForClassName

    该属性用于设置需要进行回滚的异常类名称数组,当方法中抛出指定异常名称数组中的异常时,则进行事务回滚。例如:指定单一异常类名称@Transactional(rollbackForClassName=”RuntimeException”)指定多个异常类名称:@Transactional(rollbackForClassName={“RuntimeException”,”Exception”})

  • noRollbackFor

    该属性用于设置不需要进行回滚的异常类数组,当方法中抛出指定异常数组中的异常时,不进行事务回滚。例如:指定单一异常类:@Transactional(noRollbackFor=RuntimeException.class)指定多个异常类:@Transactional(noRollbackFor={RuntimeException.class, Exception.class})

  • noRollbackForClassName

    该属性用于设置不需要进行回滚的异常类名称数组,当方法中抛出指定异常名称数组中的异常时,不进行事务回滚。例如:指定单一异常类名称:@Transactional(noRollbackForClassName=”RuntimeException”)指定多个异常类名称:@Transactional(noRollbackForClassName={“RuntimeException”,”Exception”})

  • propagation

    该属性用于设置事务的传播行为。例如:@Transactional(propagation=Propagation.NOT_SUPPORTED,readOnly=true)

  • isolation

    该属性用于设置底层数据库的事务隔离级别,事务隔离级别用于处理多事务并发的情况,通常使用数据库的默认隔离级别即可,基本不需要进行设置

  • timeout

    该属性用于设置事务的超时秒数,默认值为-1表示永不超时

事物注意事项

  • 要根据实际的需求来决定是否要使用事物,最好是在编码之前就考虑好,不然到以后就难以维护
  • 如果使用了事物,请务必进行事物测试,因为很多情况下以为事物是生效的,但是实际上可能未生效
  • 事物@Transactional的使用要放再类的公共(public)方法中,需要注意的是在 protected、private 方法上使用 @Transactional 注解,它也不会报错(IDEA会有提示),但事务无效
  • 事物@Transactional是不会对该方法里面的子方法生效也就是你在公共方法A声明的事物@Transactional但是在A方法中有个子方法B和C其中方法B进行了数据操作但是该异常被B自己处理了这样的话事物是不会生效的反之B方法声明的事物@Transactional但是公共方法A却未声明事物的话也是不会生效的如果想事物生效需要将子方法的事务控制交给调用的方法在子方法中使用rollbackFor注解指定需要回滚的异常或者将异常抛出交给调用的方法处理。一句话就是在使用事物的异常由调用者进行处理
  • 事物@Transactional由spring控制的时候它会在抛出异常的时候进行回滚。如果自己使用catch捕获了处理了是不生效的如果想生效可以进行手动回滚或者在catch里面将异常抛出比如throw new RuntimeException();

失效场景

  • @Transactional 应用在非 public 修饰的方法上
  • 数据库引擎要不支持事务
  • 由于propagation 设置错误,导致注解失效
  • rollbackFor 设置错误,@Transactional 注解失效
  • 方法之间的互相调用也会导致@Transactional失效
  • 异常被你的 catch“吃了”导致@Transactional失效

select for update

for update是一种行级锁,又叫排它锁。一旦用户对某个行施加了行级加锁,则该用户可以查询也可以更新被加锁的数据行,其它用户只能查询但不能更新被加锁的数据行。

  • 修改sql:在 selectsql 尾部添加 for update。如:select * from job_info where id = 1 for update;
  • 启用事务:为 service 添加注解 @Transactional

只有当出现如下之一的条件,才会释放共享更新锁:

  1. 执行提交COMMIT语句
  2. 退出数据库LOG OFF
  3. 程序停止运行

假设有个表单products 里面有id 跟name 二个栏位id 是主键。

-- 例1: 明确指定主键并且有此数据row lock
SELECT * FROM products WHERE id='3' FOR UPDATE;
-- 例2: 明确指定主键若查无此数据无lock
SELECT * FROM products WHERE id='-1' FOR UPDATE;
-- 例2: 无主键table lock
SELECT * FROM products WHERE name='Mouse' FOR UPDATE;
-- 例3: 主键不明确table lock
SELECT * FROM products WHERE id<>'3' FOR UPDATE;
-- 例4: 主键不明确table lock
SELECT * FROM products WHERE id LIKE '3' FOR UPDATE;

注意

  • FOR UPDATE 仅适用于InnoDB且必须在事务区块(start sta/COMMIT)中才能生效
  • 要测试锁定的状况可以利用MySQL 的Command Mode ,开二个视窗来做测试

索引机制

索引是为了加速对表中数据行的检索而创建的一种分散存储的(不连续的)数据结构,硬盘级的。

索引意义索引能极大的减少存储引擎需要扫描的数据量索引可以把随机IO变成顺序IO。索引可以帮助我们在进行分组、排序等操作时避免使用临时表。正确的创建合适的索引是提升数据库查询性能的基础。

索引结构

Hash索引

Hash索引

原理

  • 事先将索引通过 hash算法后得到的hash值(即磁盘文件指针存到hash表中
  • 在进行查询时将索引通过hash算法得到hash值与hash表中的hash值比对。通过磁盘文件指针只要一次磁盘IO就能找到要的值

例如在第一个表中要查找col=6的值。hash(6) 得到值比对hash表就能得到89。性能非常高。

存在问题

  • 但是hash表索引存在问题如果要查询带范围的条件时hash索引就歇菜了

优点

  • 快速查询参与索引的字段只要进行Hash运算之后就可以快速定位到该记录时间复杂度约为1

缺点

  • 哈希索引只包含哈希值和行指针,所以不能用索引中的值来避免读取行
  • 哈希索引数据并不是按照索引值顺序存储的,所以也就无法用于排序和范围查询
  • 哈希索引也不支持部分索引列查询,因为哈希索引始终是使用索引列的全部数据进行哈希计算的
  • 哈希索引只支持等值比较查询,如=IN()<=>操作
  • 如果哈希冲突较多,一些索引的维护操作的代价也会更高

B-Tree索引

背景二叉查找树查询的时间复杂度是O(logN)查找速度最快和比较次数较少。但用于数据库索引当数据量过大不可能将所有索引加载进内存使用二叉树会导致磁盘IO过于频繁最坏的情况下磁盘IO的次数由树的高度来决定。

B-Tree(平衡多路查找树)对二叉树进行了横向扩展,能很好解决红黑树中遗留的高度问题,使树结构更加矮胖使得一次IO能加载更多关键字对比在内存中完成减少了磁盘IO次数更适用于大型数据库但是为了保持自平衡插入或者删除元素都会导致节点发生裂变反应有时候会非常麻烦。

索引结构-B-Tree

案例分析模拟下查找key为29的data的过程

  • 根据根结点指针读取文件目录的根磁盘块1。【磁盘IO操作第1次
  • 磁盘块1存储1735和三个指针数据。我们发现17<29<35因此我们找到指针p2
  • 根据p2指针我们定位并读取磁盘块3。【磁盘IO操作2次
  • 磁盘块3存储2630和三个指针数据。我们发现26<29<30因此我们找到指针p2
  • 根据p2指针我们定位并读取磁盘块8。【磁盘IO操作3次
  • 磁盘块8中存储2829。我们找到29获取29所对应的数据data

存在问题

  • 对范围查找没有更简单的方法。可以用B+Tree解决

  • 每行数据量很大时会导致B-Tree深度较大进而影响查询效率。可以用B+Tree解决

B+Tree索引

B+树是B-树的变体也是一种多路搜索树。其定义基本与B-树相同,除了:

  • 非叶子结点的子树指针与关键字个数相同
  • 非叶子结点的子树指针P[i],指向关键字值属于[K[i], K[i+1])的子树B-树是开区间)
  • 为所有叶子结点增加一个链指针
  • 所有关键字都在叶子结点出现

索引结构-B+Tree

剖析如上图在叶子节点上注意是MySQL已经有成双向箭头原生B+Tree是单向的而且从左到右是递增顺序的所以很好的解决了 > 和 < 这类查找问题。

B+的搜索与B-树也基本相同区别是B+树只有达到叶子结点才命中B-树可以在非叶子结点命中),其性能也等价于在关键字全集做一次二分查找。

B+的特性:

  • 所有关键字都出现在叶子结点的链表中(稠密索引),且链表中的关键字恰好是有序的
  • 不可能在非叶子结点命中
  • 非叶子结点相当于是叶子结点的索引(稀疏索引),叶子结点相当于是存储(关键字)数据的数据层
  • 更适合文件索引系统

优点

  • 单次请求涉及的磁盘IO次数少出度d大且非叶子节点不包含表数据树的高度小
  • 查询效率稳定(任何关键字的查询必须走从根结点到叶子结点,查询路径长度相同)
  • 遍历效率高(从符合条件的某个叶子节点开始遍历即可)

缺点

B+树最大的性能问题在于会产生大量的随机IO主要存在以下两种情况

  • 主键不是有序递增的,导致每次插入数据产生大量的数据迁移和空间碎片
  • 即使主键是有序递增的,大量写请求的分布仍是随机的

B*Tree

是B+树的变体在B+树的非根和非叶子结点再增加指向兄弟的指针;

索引机制-B星树

B*树定义了非叶子结点关键字个数至少为(2/3)M即块的最低使用率为2/3代替B+树的1/2

B+树的分裂

当一个结点满时分配一个新的结点并将原结点中1/2的数据复制到新结点最后在父结点中增加新结点的指针B+树的分裂只影响原结点和父结点,而不会影响兄弟结点,所以它不需要指向兄弟的指针。

B*树的分裂

当一个结点满时如果它的下一个兄弟结点未满那么将一部分数据移到兄弟结点中再在原结点插入关键字最后修改父结点中兄弟结点的关键字因为兄弟结点的关键字范围改变了如果兄弟也满了则在原结点与兄弟结点之间增加新结点并各复制1/3的数据到新结点最后在父结点增加新结点的指针所以B*树分配新结点的概率比B+树要低,空间使用率更高。

索引类型

普通索引

普通索引(单列索引):单列索引是最基本的索引,它没有任何限制。

-- 直接创建索引
CREATE INDEX index_name ON table_name(col_name);
-- 修改表结构的方式添加索引
ALTER TABLE table_name ADD INDEX index_name(col_name);

-- 创建表的时候同时创建索引
CREATE TABLE `news` (
    `id` int(11) NOT NULL AUTO_INCREMENT ,
    `title` varchar(255)  NOT NULL ,
    `content` varchar(255)  NULL ,
    `time` varchar(20) NULL DEFAULT NULL ,
    PRIMARY KEY (`id`),
    INDEX index_name (title(255))
)

-- 删除索引
DROP INDEX index_name ON table_name;
--  或
alter table `表名` drop index 索引名;

唯一索引

唯一索引和普通索引类似,主要的区别在于,唯一索引限制列的值必须唯一,但允许存在空值(只允许存在一条空值)。如果在已经有数据的表上添加唯一性索引的话:

-- 创建单个索引
CREATE UNIQUE INDEX index_name ON table_name(col_name);
-- 创建多个索引
CREATE UNIQUE INDEX index_name on table_name(col_name,...);

-- 修改表结构
-- 单个
ALTER TABLE table_name ADD UNIQUE index index_name(col_name);
-- 多个
ALTER TABLE table_name ADD UNIQUE index index_name(col_name,...);

主键索引

主键索引是一种特殊的唯一索引,一个表只能有一个主键,不允许有空值。一般是在建表的时候同时创建主键索引:

-- 主键索引(创建表时添加)
CREATE TABLE `news` (
    `id` int(11) NOT NULL AUTO_INCREMENT ,
    `title` varchar(255)  NOT NULL ,
    `content` varchar(255)  NULL ,
    `time` varchar(20) NULL DEFAULT NULL ,
    PRIMARY KEY (`id`)
)

-- 主键索引(创建表后添加)
CREATE TABLE `order` (
    `orderId` varchar(36) NOT NULL,
    `productId` varchar(36)  NOT NULL ,
    `time` varchar(20) NULL DEFAULT NULL
)
alter table `order` add primary key(`orderId`);

组合索引

复合索引(组合索引):复合索引是在多个字段上创建的索引。复合索引遵守“最左前缀”原则****即在查询条件中使用了复合索引的第一个字段,索引才会被使用。因此,在复合索引中索引列的顺序至关重要。

-- 创建一个复合索引
create index index_name on table_name(col_name1,col_name2,...);

-- 修改表结构的方式添加索引
alter table table_name add index index_name(col_name,col_name2,...);

全文索引

在一般情况下,模糊查询都是通过 like 的方式进行查询。但是,对于海量数据,这并不是一个好办法,在 like "value%" 可以使用索引,但是对于 like "%value%" 这样的方式,执行全表查询,这在数据量小的表,不存在性能问题,但是对于海量数据,全表扫描是非常可怕的事情,所以 like 进行模糊匹配性能很差。

这种情况下,需要考虑使用全文搜索的方式进行优化。全文搜索在 MySQL 中是一个 FULLTEXT 类型索引。FULLTEXT 索引在 MySQL 5.6 版本之后支持 InnoDB而之前的版本只支持 MyISAM 表。目前只有char、varchartext 列上可以创建全文索引。

-- 创建表的适合添加全文索引
CREATE TABLE `news` (
    `id` int(11) NOT NULL AUTO_INCREMENT ,
    `title` varchar(255)  NOT NULL ,
    `content` text  NOT NULL ,
    `time` varchar(20) NULL DEFAULT NULL ,
     PRIMARY KEY (`id`),
    FULLTEXT (content)
)

-- 修改表结构添加全文索引
ALTER TABLE table_name ADD FULLTEXT index_fulltext_content(col_name);

失效场景

场景一where语句中包含or时可能会导致索引失效

使用or并不是一定会使索引失效你需要看or左右两边的查询列是否命中相同的索引。

-- 假设user表中的user_id列有索引age列没有索引
-- 能命中索引
select * from user where user_id = 1 or user_id = 2;
-- 无法命中索引
select * from user where user_id = 1 or age = 20;
-- 假设age列也有索引的话依然是无法命中索引的
select * from user where user_id = 1 or age = 20;

可以根据情况尽量使用union all或者in来代替这两个语句的执行效率也比or好些。

场景二where语句中索引列使用了负向查询可能会导致索引失效

负向查询包括NOT、!=、<>、!<、!>、NOT IN、NOT LIKE等。其实负向查询并不绝对会索引失效这要看MySQL优化器的判断全表扫描或者走索引哪个成本低了。

场景三索引字段可以为null使用is null或is not null时可能会导致索引失效

其实单个索引字段使用is null或is not null时是可以命中索引的。

场景四:在索引列上使用内置函数,一定会导致索引失效

比如下面语句中索引列login_time上使用了函数会索引失效

select * from user where DATE_ADD(login_time, INTERVAL 1 DAY) = 7;

场景五:隐式类型转换导致的索引失效

如下面语句中索引列user_id为varchar类型不会命中索引

select * from user where user_id = 12;

场景六:对索引列进行运算,一定会导致索引失效

运算如+-*/等,如下:

select * from user where age - 1 = 10;

优化的话,要把运算放在值上,或者在应用程序中直接算好,比如:

select * from user where age = 10 - 1;

场景七like通配符可能会导致索引失效

like查询以%开头时,会导致索引失效。解决办法有两种:

  • 将%移到后面,如:
select * from user where `name` like '李%';
  • 利用覆盖索引来命中索引:
select name from user where `name` like '%李%';

场景八联合索引中where中索引列违背最左匹配原则一定会导致索引失效

当创建一个联合索引的时候,如(k1,k2,k3),相当于创建了(k1)、(k1,k2)和(k1,k2,k3)三个索引,这就是最左匹配原则。比如下面的语句就不会命中索引:

select * from t where k2=2;
select * from t where k3=3;
select * from t where k2=2 and k3=3;

下面的语句只会命中索引(k1)

select * from t where k1=1 and k3=3;

MySQL日志

生产优化

  • 在生产上,建议 innodb_flush_log_at_trx_commit 设置成 1可以让每次事务的 redo log 都持久化到磁盘上。保证异常重启后redo log 不丢失
  • 建议 sync_binlog 设置成 1可以让每次事务的 binlog 都持久化到磁盘上。保证异常重启后binlog 不丢失

IO性能优化

  • binlog_group_commit_sync_delay:表示延迟多少微秒后,再执行 fsync
  • binlog_group_commit_sync_no_delay_count:表示累计多少次后,在调用 fsync

MySQL 出现了 IO 的性能问题,可以考虑下面的优化策略:

  • 设置 binlog_group_commit_sync_delaybinlog_group_commit_sync_no_delay_count。可以使用故意等待来减少,binlog 的写盘次数,没有数据丢失的风险,但是会有客户端响应变慢的风险
  • 设置 sync_binlog 设置为 100~1000 之间的某个值。这样做存在的风险是可能造成 binlog 丢失
  • 设置 innodb_flush_log_at_trx_commit = 2,可能会丢数据

重做日志redo log

重做日志redo log是InnoDB引擎层的日志用来记录事务操作引起数据的变化记录的是数据页的物理修改。

在MySQL里如果我们要执行一条更新语句。执行完成之后数据不会立马写入磁盘因为这样对磁盘IO的开销比较大。MySQL里面有一种叫做WALWrite-Ahead Logging就是先写日志再写磁盘。就是当有一条记录需要更新的时候InnoDB 会先写redo log 里面并更新内存这个时候更新的操作就算完成了。之后MySQL会在合适的时候将操作记录 flush 到磁盘上面。当然 flush 的条件可能是系统比较空闲,或者是 redo log 空间不足时。redo log 文件的大小是固定的比如可以是由4个1GB文件组成的集合。

作用

确保事务的持久性。防止在发生故障的时间点尚有脏页未写入磁盘在重启mysql服务的时候根据redo log进行重做从而达到事务的持久性这一特性。

写入流程

为了控制 redo log 的写入策略innodb_flush_log_at_trx_commit 会有下面 3 中取值:

  • 0每次提交事务只写在 redo log buffer 中
  • 1每次提交事务持久化到磁盘
  • 2每次提交事务写到 文件系统的 page cache 中

刷盘场景

redo log 实际的触发 fsync 操作写盘包含以下几个场景:

  • 后台每隔 1 秒钟的线程轮询
  • innodb_flush_log_at_trx_commit 设置成 1 时,事务提交时触发
  • innodb_log_buffer_size 是设置 redo log 大小的参数。当 redo log buffer 达到 innodb_log_buffer_size / 2 时,也会触发一次 fsync

二进制日志bin log

binlog 用于记录数据库执行的写入性操作(不包括查询)信息,以二进制的形式保存在磁盘中。binlogmysql的逻辑日志,并且由 Server 层进行记录,使用任何存储引擎的 mysql 数据库都会记录 binlog 日志。

  • 逻辑日志可以简单理解为记录的就是sql语句 。
  • 物理日志mysql 数据最终是保存在数据页中的,物理日志记录的就是数据页变更 。

binlog 是通过追加的方式进行写入的,可以通过max_binlog_size 参数设置每个 binlog文件的大小,当文件大小达到给定值之后,会生成新的文件来保存日志。

作用

  • 用于复制在主从复制中从库利用主库上的binlog进行重播实现主从同步
  • 用于数据库的基于时间点的还原

使用场景

在实际应用中, binlog 的主要使用场景有两个,分别是 主从复制数据恢复

  • 主从复制 :在 Master 端开启 binlog ,然后将 binlog发送到各个 Slave 端, Slave 端重放 binlog 从而达到主从数据一致
  • 数据恢复 :通过使用 mysqlbinlog 工具来恢复数据

刷盘时机

对于 InnoDB 存储引擎而言,只有在事务提交时才会记录biglog ,此时记录还在内存中,那么 biglog是什么时候刷到磁盘中的呢?mysql 通过 sync_binlog 参数控制 biglog 的刷盘时机,取值范围是 0-N

  • sync_binlog=0:不去强制要求,由系统自行判断何时写入磁盘;
  • sync_binlog=1:每次 commit 的时候都要将 binlog 写入磁盘;
  • sync_binlog=N(N>1)每N个事务才会将 binlog 写入磁盘。

从上面可以看出, sync_binlog 最安全的是设置是 1 ,这也是MySQL 5.7.7之后版本的默认值。但是设置一个大一些的值可以提升数据库性能,因此实际情况下也可以将值适当调大,牺牲一定的一致性来获取更好的性能。

日志格式

binlog 日志有三种格式,分别为 STATMENTROWMIXED

MySQL 5.7.7 之前,默认的格式是 STATEMENT MySQL 5.7.7 之后,默认值是 ROW。日志格式通过 binlog-format 指定。

  • STATMENT:基于SQL 语句的复制( statement-based replication, SBR )每一条会修改数据的sql语句会记录到binlog 中 。

    • 优点:不需要记录每一行的变化,减少了 binlog 日志量,节约了 IO , 从而提高了性能;
    • 缺点在某些情况下会导致主从数据不一致比如执行sysdate() 、 slepp() 等 。
  • ROW:基于行的复制(row-based replication, RBR )不记录每条sql语句的上下文信息仅需记录哪条数据被修改了 。

    • 优点不会出现某些特定情况下的存储过程、或function、或trigger的调用和触发无法被正确复制的问题
    • 缺点:会产生大量的日志,尤其是alter table 的时候会让日志暴涨
  • MIXED:基于STATMENTROW 两种模式的混合复制(mixed-based replication, MBR ),一般的复制使用STATEMENT 模式保存 binlog ,对于 STATEMENT 模式无法复制的操作使用 ROW 模式保存 binlog

回滚日志undo log

作用

保证数据的原子性保存了事务发生之前的数据的一个版本可以用于回滚同时可以提供多版本并发控制下的读MVCC也即非锁定读。

错误日志error log

慢查询日志slow query log

一般查询日志general log

中继日志relay log

InnoDB

线程

数据页

数据页主要是用来存储表中记录的,它在磁盘中是用双向链表相连的,方便查找,能够非常快速得从一个数据页,定位到另一个数据页。

  • 写操作时,先将数据写到内存的某个批次中,然后再将该批次的数据一次性刷到磁盘上。如下图所示:

    InnoDB-数据页-写操作

  • 读操作时,从磁盘上一次读一批数据,然后加载到内存当中,以后就在内存中操作。如下图所示:

    InnoDB-数据页-读操作

磁盘中各数据页的整体结构如下图所示:

InnoDB-数据页

通常情况下,单个数据页默认的大小是16kb。当然,我们也可以通过参数:innodb_page_size,来重新设置大小。不过,一般情况下,用它的默认值就够了。单个数据页包含内容如下:

InnoDB-单个数据页内容

文件头部

通过前面介绍的行记录中下一条记录的位置页目录innodb能非常快速的定位某一条记录。但有个前提条件就是用户记录必须在同一个数据页当中。

如果用户记录非常多,在第一个数据页找不到我们想要的数据,需要到另外一页找该怎么办呢?这时就需要使用文件头部了。它里面包含了多个信息但我只列出了其中4个最关键的信息

  • 页号
  • 上一页页号
  • 下一页页号
  • 页类型

顾名思义innodb是通过页号、上一页页号和下一页页号来串联不同数据页的。如下图所示

InnoDB-文件头部

不同的数据页之间通过上一页页号和下一页页号构成了双向链表。这样就能从前向后一页页查找所有的数据了。此外页类型也是一个非常重要的字段它包含了多种类型其中比较出名的有数据页、索引页目录项页、溢出页、undo日志页等。

页头部

比如一页数据到底保存了多条记录,或者页目录到底使用了多个槽等。这些信息是实时统计,还是事先统计好了,保存到某个地方?为了性能考虑,上面的这些统计数据,当然是先统计好,保存到一个地方。后面需要用到该数据时,再读取出来会更好。这个保存统计数据的地方,就是页头部。当然页头部不仅仅只保存:槽的数量、记录条数等信息。它还记录了:

  • 已删除记录所占的字节数
  • 最后插入记录的位置
  • 最大事务id
  • 索引id
  • 索引层级

最大和最小记录

在一个数据页当中,如果存在多条用户记录,它们是通过下一条记录的位置相连的。不过有个问题如果才能快速找到最大的记录和最小的记录呢这就需要在保存用户记录的同时也保存最大和最小记录了。最大记录保存到Supremum记录中。最小记录保存在Infimum记录中。

在保存用户记录时数据库会自动创建两条额外的记录Supremum 和 Infimum。它们之间的关系如下图所示

InnoDB-最大和最小记录

从图中可以看出用户数据是从最小记录开始,通过下一条记录的位置,从小到大,一步步查找,最后找到最大记录为止。

用户记录

对于新申请的数据页用户记录是空的。当插入数据时innodb会将一部分空闲空间分配给用户记录。用户记录是innodb的重中之重我们平时保存到数据库中的数据就存储在它里面。其实在innodb支持的数据行格式有四种

  • compact行格式
  • redundant行格式
  • dynamic行格式
  • compressed行格式

以compact行格式为例

InnoDB-compact行格式

一条用户记录主要包含三部分内容:

  • 记录额外信息它包含了变长字段、null值列表和记录头信息
  • 隐藏列它包含了行id、事务id和回滚点
  • 真正的数据列:包含真正的用户数据,可以有很多列

额外信息

额外信息并非真正的用户数据,它是为了辅助存数据用的。

  • 变长字段列表

    有些数据如果直接存会有问题比如如果某个字段是varchar或text类型它的长度不固定可以根据存入数据的长度不同而随之变化。如果不在一个地方记录数据真正的长度innodb很可能不知道要分配多少空间。假如都按某个固定长度分配空间但实际数据又没占多少空间岂不是会浪费所以需要在变长字段中记录某个变长字段占用的字节数方便按需分配空间。

  • null值列表

    数据库中有些字段的值允许为null如果把每个字段的null值都保存到用户记录中显然有些浪费存储空间。有没有办法只简单的标记一下不存储实际的null值呢答案将为null的字段保存到null值列表。在列表中用二进制的值1表示该字段允许为null用0表示不允许为null。它只占用了1位就能表示某个字符是否为null确实可以节省很多存储空间。

  • 记录头信息

    记录头信息用于描述一些特殊的属性。它主要包含:

    • deleted_flag即删除标记用于标记该记录是否被删除了
    • min_rec_flag即最小目录标记它是非叶子节点中的最小目录标记
    • n_owned即拥有的记录数记录该组索引记录的条数
    • heap_no即堆上的位置它表示当前记录在堆上的位置
    • record_type即记录类型其中0表示普通记录1表示非叶子节点2表示Infrimum记录 3表示Supremum记录
    • next_record即下一条记录的位置

隐藏列

数据库在保存一条用户记录时,会自动创建一些隐藏列。如下图所示:

InnoDB-隐藏列

目前innodb自动创建的隐藏列有三种

  • db_row_id即行id它是一条记录的唯一标识。
  • db_trx_id即事务id它是事务的唯一标识。
  • db_roll_ptr即回滚点它用于事务回滚。

如果表中有主键则用主键做行id无需额外创建。如果表中没有主键假如有不为null的unique唯一键则用它做为行id同样无需额外创建。如果表中既没有主键又没有唯一键则数据库会自动创建行id。也就是说在innodb中隐藏列中事务id回滚点是一定会被创建的但行id要根据实际情况决定。

真正数据列

真正的数据列中存储了用户的真实数据,它可以包含很多列的数据。

页目录

从上面可以看出,如果我们要查询某条记录的话,数据库会从最小记录开始,一条条查找所有记录。如果中途找到了,则直接返回该记录。如果一直找到最大记录,还没有找到想要的记录,则返回空。

但效率会不会有点低?这不是要对整页用户数据进行扫描吗?

这就需要使用页目录了。说白了,就是把一页用户记录分为若干组,每一组的最大记录都保存到一个地方,这个地方就是页目录。每一组的最大记录叫做。由此可见,页目录是有多个槽组成的。所下图所示:

InnoDB-页目录

假设一页的数据分为4组这样在页目录中就对应了4个槽每个槽中都保存了该组数据的最大值。这样就能通过二分查找比较槽中的记录跟需要找到的记录的大小。如果用户需要查找的记录小于当前槽中的记录则向上查找上一个槽。如果用户需要查找的记录大于当前槽中的记录则向下查找下一个槽。如此一来就能通过二分查找快速的定位需要查找的记录了。

文件尾部

数据库的数据是以数据页为单位加载到内存中如果数据有更新的话需要刷新到磁盘上。但如果某一天比较倒霉程序在刷新到磁盘的过程中出现了异常比如进程被kill掉了或者服务器被重启了。这时候数据可能只刷新了一部分如何判断上次刷盘的数据是完整的呢这就需要用到文件尾部。它里面记录了页面的校验和

在数据刷新到磁盘之前,会先计算一个页面的校验和。后面如果数据有更新的话,会计算一个新值。文件头部中也会记录这个校验和,由于文件头部在前面,会先被刷新到磁盘上。

接下来,刷新用户记录到磁盘的时候,假设刷新了一部分,恰好程序出现异常了。这时,文件尾部的校验和,还是一个旧值。数据库会去校验,文件尾部的校验和,不等于文件头部的新值,说明该数据页的数据是不完整的。

Buffer Pool

InnoDB为了解决磁盘IO问题,MySQL需要申请一块内存空间,这块内存空间称为Buffer Pool

Buffer-Pool

缓存页

Buffer Pool申请下来后,Buffer Pool里面放什么,要怎么规划?

MySQL数据是以页为单位,每页默认16KB,称为数据页,在Buffer Pool里面会划分出若干个缓存页与数据页对应。

Buffer-Pool-缓存页

描述数据

如何知道缓存页对应那个数据页呢?

所以还需要缓存页的元数据信息,可以称为描述数据,它与缓存页一一对应,包含一些所属表空间、数据页的编号、Buffer Pool中的地址等等。

Buffer-Pool-描述数据

后续对数据的增删改查都是在Buffer Pool里操作

  • 查询:从磁盘加载到缓存,后续直接查缓存
  • 插入:直接写入缓存
  • 更新删除:缓存中存在直接更新,不存在加载数据页到缓存更新

MySQL宕机数据不就全丢了吗?

InnoDB提供了WAL技术Write-Ahead Logging通过redo logMySQL拥有了崩溃恢复能力。再配合空闲时,会有异步线程做缓存页刷盘,保证数据的持久性与完整性。

Buffer-Pool-Write-Ahead-Logging

直接更新数据的缓存页称为脏页,缓存页刷盘后称为干净页

Free链表

MySQL数据库启动时,按照设置的Buffer Pool大小,去找操作系统申请一块内存区域,作为Buffer Pool假设申请了512MB)。申请完毕后,会按照默认缓存页的16KB以及对应的800Byte的描述数据,在Buffer Pool中划分出来一个一个的缓存页和它们对应的描述数据。

Buffer-Pool-Free链表

MySQL运行起来后,会不停的执行增删改查,需要从磁盘读取一个一个的数据页放入Buffer Pool对应的缓存页里,把数据缓存起来,以后就可以在内存里执行增删改查。

Buffer-Pool-Free链表-增删改查

但是这个过程必然涉及一个问题,哪些缓存页是空闲的

为了解决这个问题,我们使用链表结构,把空闲缓存页的描述数据放入链表中,这个链表称为free链表。针对free链表我们要做如下设计:

Buffer-Pool-Free链表设计

  • 新增free基础节点
  • 描述数据添加free节点指针

最终呈现出来的,是由空闲缓存页的描述数据组成的free链表。

Buffer-Pool-Free链表组成

有了free链表之后,我们只需要从free链表获取一个描述数据,就可以获取到对应的缓存页。

Buffer-Pool-Free链表-获取描述数据

描述数据缓存页写入数据后,就将该描述数据移出free链表。

缓存页哈希表

查询数据时,如何在Buffer Pool里快速定位到对应的缓存页呢?难道需要一个非空闲的描述数据链表,再通过表空间号+数据页编号遍历查找吗?这样做也可以实现,但是效率不太高,时间复杂度是O(N)

Buffer-Pool-缓存页哈希表

所以我们可以换一个结构,使用哈希表来缓存它们间的映射关系,时间复杂度是O(1)

Buffer-Pool-缓存页哈希表-复杂度

表空间号+数据页号,作为一个key,然后缓存页的地址作为value。每次加载数据页到空闲缓存页时,就写入一条映射关系到缓存页哈希表中。

Buffer-Pool-缓存页哈希表-映射关系

后续的查询,就可以通过缓存页哈希表路由定位了。

Flush链表

还记得之前有说过「空闲时会有异步线程做缓存页刷盘,保证数据的持久性与完整性」吗?

新问题来了,难道每次把Buffer Pool里所有的缓存页都刷入磁盘吗?当然不能这样做,磁盘IO开销太大了,应该把脏页刷入磁盘才对(更新过的缓存页)。可是我们怎么知道,那些缓存页是脏页?很简单,参照free链表,弄个flush链表出来就好了,只要缓存页被更新,就将它的描述数据加入flush链表。针对flush链表我们要做如下设计:

  • 新增flush基础节点
  • 描述数据添加flush节点指针

Buffer-Pool-Flush链表

最终呈现出来的,是由更新过数据的缓存页描述数据组成的flush链表。

Buffer-Pool-Flush链表-缓存页

后续异步线程都从flush链表刷缓存页,当Buffer Pool内存不足时,也会优先刷flush链表里的缓存页。

LRU链表

目前看来Buffer Pool的功能已经比较完善了。

Buffer-Pool-LRU链表

但是仔细思考下,发现还有一个问题没处理。MySQL数据库随着系统的运行会不停的把磁盘上的数据页加载到空闲的缓存页里去,因此free链表中的空闲缓存页会越来越少,直到没有,最后磁盘的数据页无法加载。

Buffer-Pool-LRU链表-无法加载

为了解决这个问题,我们需要淘汰缓存页,腾出空闲缓存页。可是我们要优先淘汰哪些缓存页?总不能一股脑直接全部淘汰吧?这里就要借鉴LRU算法思想,把最近最少使用的缓存页淘汰(命中率低),提供LRU链表出来。针对LRU链表我们要做如下设计:

  • 新增LRU基础节点
  • 描述数据添加LRU节点指针

Buffer-Pool-LRU链表-结构

实现思路也很简单,只要是查询或修改过缓存页,就把该缓存页的描述数据放入链表头部,也就说近期访问的数据一定在链表头部。

Buffer-Pool-LRU链表-节点

free链表为空的时候,直接淘汰LRU链表尾部缓存页即可。

LRU链表优化

麻雀虽小五脏俱全,基本Buffer Pool里与缓存页相关的组件齐全了。

Buffer-Pool-LRU链表优化

但是缓存页淘汰这里还有点问题,如果仅仅只是使用LRU链表的机制,有两个场景会让热点数据被淘汰。

  • 预读机制

    InnoDB使用两种预读算法来提高I/O性能线性预读linear read-ahead和随机预读randomread-ahead

  • 全表扫描

    预读机制是指MySQL加载数据页时,可能会把它相邻的数据页一并加载进来(局部性原理)。这样会带来一个问题,预读进来的数据页,其实我们没有访问,但是它却排在前面。

    Buffer-Pool-LRU链表优化-全表扫描

    正常来说,淘汰缓存页时,应该把这个预读的淘汰,结果却把尾部的淘汰了,这是不合理的。我们接着来看第二个场景全表扫描,如果表数据量大,大量的数据页会把空闲缓存页用完。最终LRU链表前面都是全表扫描的数据,之前频繁访问的热点数据全部到队尾了,淘汰缓存页时就把热点数据页给淘汰了。

    图片

    为了解决上述的问题,我们需要给LRU链表做冷热数据分离设计,把LRU链表按一定比例,分为冷热区域,热区域称为young区域,冷区域称为old区域。

以7:3为例young区域70%old区域30%

图片

如上图所示数据页第一次加载进缓存页的时候是先放入冷数据区域的头部如果1秒后再次访问缓存页则会移动到热区域的头部。这样就保证了预读机制全表扫描加载的数据都在链表队尾。

  • young区域其实还可以做一个小优化,为了防止young区域节点频繁移动到表头

  • young区域前面1/4被访问不会移动到链表头部,只有后面的3/4被访问了才会

记住是按照某个比例将LRU链表分成两部分,不是某些节点固定是young区域的,某些节点固定是old区域的,随着程序的运行,某个节点所属的区域也可能发生变化。

  • InnoDB在LRU列表中引入了midpoint参数。新读取的页并不会直接放在LRU列表的首部而是放在LRU列表的midpoint位置即 innodb_old_blocks_pct这个点的设置。默认是37%最小是5最大是95如果内存比较大的话可以将这个数值调低通常会调成20也就是说20%的是冷数据块。目的是为了保护热区数据不被刷出内存。
  • InnoDB还引入了innodb_old_blocks_time参数控制成为热数据的所需时间默认是1000ms也就是1s也就是数据在1s内没有被刷走就调入热区。

Change Buffer

可变缓冲区Change Buffer在内存中可变缓冲区是InnoDB缓冲池的一部分在磁盘上它是系统表空间的一部分因此即使在数据库重新启动之后索引更改也会保持缓冲状态。

可变缓冲区是一种特殊的数据结构,当受影响的页不在缓冲池中时,缓存对辅助索引页的更改。

Log Buffer

日志缓冲区Log Buffer 主要保存写到redo log(重放日志)的数据。周期性的将缓冲区内的数据写入redo日志中。将内存中的数据写入磁盘的行为由innodb_log_at_trx_commit 和 innodb_log_at_timeout 调节。较大的redo日志缓冲区允许大型事务在事务提交前不进行写磁盘操作。

变量innodb_log_buffer_size (默认 16M)

InnoDB日志

Redo Log(重做日志)

Undo Log

数据切分

水平切分

水平切分又称为 Sharding它是将同一个表中的记录拆分到多个结构相同的表中。当一个表的数据不断增多时Sharding 是必然的选择,它可以将数据分布到集群的不同节点上,从而缓存单个数据库的压力。

img

垂直切分

垂直切分是将一张表按列分成多个表,通常是按照列的关系密集程度进行切分,也可以利用垂直气氛将经常被使用的列喝不经常被使用的列切分到不同的表中。在数据库的层面使用垂直切分将按数据库中表的密集程度部署到不通的库中,例如将原来电商数据部署库垂直切分称商品数据库、用户数据库等。

img

Sharding策略

  • 哈希取模hash(key)%N
  • 范围:可以是 ID 范围也可以是时间范围
  • 映射表:使用单独的一个数据库来存储映射关系

Sharding存在的问题

  • 事务问题:使用分布式事务来解决,比如 XA 接口

  • 连接:可以将原来的连接分解成多个单表查询,然后在用户程序中进行连接。

  • 唯一性

    • 使用全局唯一 ID GUID
    • 为每个分片指定一个 ID 范围
    • 分布式 ID 生成器(如 Twitter 的 Snowflake 算法)

SQL优化

优化步骤

第1步通过慢查日志等定位那些执行效率较低的SQL语句

第2步explain分析SQL的执行计划

需要重点关注typerowsfilteredextra

  • type:由上至下,效率越来越高

    • ALL:全表扫描
    • index:索引全扫描
    • range:索引范围扫描,常用语<<=>=betweenin等操作
    • ref:使用非唯一索引扫描或唯一索引前缀扫描,返回单条记录,常出现在关联查询中
    • eq_ref类似ref区别在于使用的是唯一索引使用主键的关联查询
    • const/system:单条记录,系统会把匹配行中的其他列作为常数处理,如主键或唯一索引查询
    • nullMySQL不访问任何表或索引直接返回结果

    虽然上至下效率越来越高但是根据cost模型假设有两个索引idx1(a, b, c),idx2(a, c)SQL为select * from t where a = 1 and b in (1, 2) order by c;如果走idx1那么是type为range如果走idx2那么type是ref当需要扫描的行数使用idx2大约是idx1的5倍以上时会用idx1否则会用idx2

  • Extra

    • Using filesortMySQL需要额外的一次传递以找出如何按排序顺序检索行。通过根据联接类型浏览所有行并为所有匹配WHERE子句的行保存排序关键字和行的指针来完成排序。然后关键字被排序并按排序顺序检索行。
    • Using temporary:使用了临时表保存中间结果,性能特别差,需要重点优化
    • Using index:表示相应的 select 操作中使用了覆盖索引Coveing Index,避免访问了表的数据行,效率不错!如果同时出现 using where意味着无法直接通过索引查找来查询到符合条件的数据。
    • Using index conditionMySQL5.6之后新增的ICPusing index condtion就是使用了ICP索引下推在存储引擎层进行数据过滤而不是在服务层过滤利用索引现有的数据减少回表的数据。

第3步show profile 分析

了解SQL执行的线程的状态及消耗的时间。默认是关闭的开启语句“set profiling = 1;”

SHOW PROFILES ;
SHOW PROFILE FOR QUERY  #{id};

第4步trace

trace分析优化器如何选择执行计划通过trace文件能够进一步了解为什么优惠券选择A执行计划而不选择B执行计划。

set optimizer_trace="enabled=on";
set optimizer_trace_max_mem_size=1000000;
select * from information_schema.optimizer_trace;

第5步确定问题并采用相应的措施

  • 优化索引
  • 优化SQL语句修改SQL、IN 查询分段、时间查询分段、基于上一次数据过滤
  • 改用其他实现方式ES、数仓等
  • 数据碎片处理

特殊需求

批量去重插入

问题MySQL批量插入如何不插入重复数据

解决方案1insert ignore into

当插入数据时如出现错误时如重复数据将不返回错误只以警告形式返回。所以使用ignore请确保语句本身没有问题否则也会被忽略掉。例如

INSERT IGNORE INTO user (name) VALUES ('telami') 

这种方法很简便,但是有一种可能,就是插入不是因为重复数据报错,而是因为其他原因报错的,也同样被忽略了~

解决方案2on duplicate key update

当primary或者unique重复时则执行update语句,如update后为无用语句,如id=id则同1功能相同但错误不会被忽略掉。在公众号顶级架构师后台回复“架构整洁”获取一份惊喜礼包。例如为了实现name重复的数据插入不报错可使用一下语句

INSERT INTO user (name) VALUES ('telami') ON duplicate KEY UPDATE id = id 

这种方法有个前提条件,就是,需要插入的约束,需要是主键或者唯一约束(在你的业务中那个要作为唯一的判断就将那个字段设置为唯一约束也就是unique key)。

解决方案3insert … select … where not exist

根据select的条件判断是否插入可以不光通过primaryunique来判断,也可通过其它条件。例如:

INSERT INTO user (name) SELECT 'telami' FROM dual WHERE NOT EXISTS (SELECT id FROM user WHERE id = 1) 

这种方法其实就是使用了MySQL的一个临时表的方式,但是里面使用到了子查询,效率也会有一点点影响,如果能使用上面的就不使用这个。

解决方案4replace into

如果存在primary or unique相同的记录,则先删除掉。再插入新记录。

REPLACE INTO user SELECT 1, 'telami' FROM books 

这种方法就是不管原来有没有相同的记录,都会先删除掉然后再插入。选择的是第二种方式

<insert id="batchSaveUser" parameterType="list">
    insert into user (id,username,mobile_number)
    values
    <foreach collection="list" item="item" index="index" separator=",">
        (
            #{item.id},
            #{item.username},
            #{item.mobileNumber}
        )
    </foreach>
    ON duplicate KEY UPDATE id = id
</insert>

这里用的是Mybatis批量插入的一个操作mobile_number已经加了唯一约束。这样在批量插入时,如果存在手机号相同的话,是不会再插入了的。

场景分析

案例1最左匹配

索引

KEY `idx_shopid_orderno` (`shop_id`,`order_no`)

SQL语句

select * from _t where orderno='xxx';

查询匹配从左往右匹配,要使用order_no走索引,必须查询条件携带shop_id或者索引(shop_id,order_no)调换前后顺序。

案例2隐式转换

索引

KEY `idx_mobile` (`mobile`)

SQL语句

select * from _user where mobile=12345678901;

隐式转换相当于在索引上做运算会让索引失效。mobile是字符类型使用了数字应该使用字符串匹配否则MySQL会用到隐式替换导致索引失效。

案例3大分页

索引

KEY `idx_a_b_c` (`a`, `b`, `c`)

SQL语句

select * from _t where a = 1 and b = 2 order by c desc limit 10000, 10;

对于大分页的场景,可以优先让产品优化需求,如果没有优化的,有如下两种优化方式:

  • 把上一次的最后一条数据也即上面的c传过来然后做“c < xxx”处理但是这种一般需要改接口协议并不一定可行
  • 采用延迟关联的方式进行处理减少SQL回表但是要记得索引需要完全覆盖才有效果SQL改动如下
SELECT t1.* FROM _t t1, (SELECT id FROM _t WHERE a=1 AND b=2 ORDER BY c DESC LIMIT 10000,10) t2  WHERE t1.id=t2.id;

案例4in+order by

索引

KEY `idx_shopid_status_created` (`shop_id`, `order_status`, `created_at`)

SQL语句

SELECT * FROM _order WHERE shop_id = 1 AND order_status IN ( 1, 2, 3 ) ORDER BY created_at DESC LIMIT 10

in查询在MySQL底层是通过n*m的方式去搜索类似union但是效率比union高。in查询在进行cost代价计算时代价 = 元组数 * IO平均值是通过将in包含的数值一条条去查询获取元组数的因此这个计算过程会比较的慢所以MySQL设置了个临界值(eq_range_index_dive_limit)5.6之后超过这个临界值后该列的cost就不参与计算了。

因此会导致执行计划选择不准确。默认是200即in条件超过了200个数据会导致in的代价计算存在问题可能会导致Mysql选择的索引不准确。处理方式可以(order_status, created_at)互换前后顺序并且调整SQL为延迟关联。

案例5范围查询索引失效

范围查询阻断,后续字段不能走索引。

索引

KEY `idx_shopid_created_status` (`shop_id`, `created_at`, `order_status`)

SQL语句

SELECT * FROM _order WHERE shop_id=1 AND created_at > '2021-01-01 00:00:00' AND order_status=10;

范围查询还有“IN、between”。

案例6避免使用非快速索引

不等于、不包含不能用到索引的快速搜索。

select * from _order where shop_id=1 and order_status not in (1,2);
select * from _order where shop_id=1 and order_status != 1;

在索引上,避免使用NOT!=<>!<!>NOT EXISTSNOT INNOT LIKE等。

案例7优化器选择索引失效

如果要求访问的数据量很小则优化器还是会选择辅助索引但是当访问的数据占整个表中数据的蛮大一部分时一般是20%左右),优化器会选择通过聚集索引来查找数据。

select * from _order where  order_status = 1

查询出所有未支付的订单,一般这种订单是很少的,即使建了索引,也没法使用索引。

案例8复杂查询

select sum(amt) from _t where a = 1 and b in (1, 2, 3) and c > '2020-01-01';
select * from _t where a = 1 and b in (1, 2, 3) and c > '2020-01-01' limit 10;

如果是统计某些数据可能改用数仓进行解决如果是业务上就有那么复杂的查询可能就不建议继续走SQL了而是采用其他的方式进行解决比如使用ES等进行解决。

案例9asc和desc混用

select * from _t where a=1 order by b desc, c asc

desc 和asc混用时会导致索引失效

案例10大数据

对于推送业务的数据存储可能数据量会很大如果在方案的选择上最终选择存储在MySQL上并且做7天等有效期的保存。那么需要注意频繁的清理数据会照成数据碎片需要联系DBA进行数据碎片处理。

MySQL原理

架构设计

MySQL架构设计

从上面的示意图可以看出MySQL从上到下包含了客户端、Server层和存储引擎层

  • 客户端可以是我们常用的MySQL命令行窗口或者是Java的客户端程序等
  • Server层连接器、查询缓存、分析器、优化器和执行器等。大部分MySQL对用户提供的功能都在这一层实现包括了内置函数的实现存储过程、触发器、视图等
  • 存储层存储引擎层负责数据的存储和提取存储引擎的实现是插件式的。也就是说用户可以选择自己所需要的存储引擎如InnoDB、MyISAM等

连接器

连接器是MySQL服务端对外的门户当我们使用命令行黑窗口或者JDBC的Connection.connect()连接到MySQL Server端时会校验用户名和密码然后会查询用户对应的权限列表。当连接建立后后续的权限范围就在此时确定了如果连接没有断开的情况下更改了用户的权限此时对于该连接也不生效。

查询缓存

当连接建立完成后执行select 语句的时候就会来到查询缓存。MySQL会将Select 语句为 KEY将查询结果为VALUE 的形式保存在内存中。如果匹配到对应的 KEY 就会直接从内存中返回结果。

但是常我们不会使用MySQL自身的查询缓存因为当有一条Update 或 Insert 的改表语句时,就会清空对该表的所有查询缓存。缓存的粒度比较大,可以考虑类似 Redis 的分布式缓存做业务数据的缓存。在MySQL 8.0 中,查询缓存直接被移除了。

分析器

如果在查询缓存中没有查到数据就要真正的开始执行SQL语句了。分析器首先会做“词法分析”。词法分析就是识别上面字符串id、name 是表的字段名T 是表的名称等等。之后就是语法分析如果SQL有语法错误在此时就会报错。

优化器

当分析器处理过之后MySQL就知道SQL 要干什么了但是此时还需要优化器对待执行的SQL 进行优化。当然MySQL 提供的优化器相比其他几款商用收费的数据库来说还是比较弱的。当然MySQL 的优化器还是可以对 join 操作,表达式计算等等进行优化,本篇不做过多的介绍。

执行器

执行阶段,首先会检查当前用户有没有权限操作该 SQL 语句。如果有,则继续执行后续的操作。

存储引擎

InnoDB 和 MyISAM 的比较

  • 事务InnoDB 是事务型的,可以使用 Commit 和 Rollback 语句。
  • 并发MyISAM 只支持表级锁,而 InnoDB 还支持行级锁。
  • 外键InnoDB 支持外键。
  • 备份InnoDB 支持在线热备份。
  • 崩溃恢复MyISAM 崩溃后发生损坏的概率比 InnoDB 高很多,而且恢复的速度也更慢。
  • 其它特性MyISAM 支持压缩表和空间数据索引。

InnoDB引擎

InnoDB 是一个事务安全的存储引擎它具备提交、回滚以及崩溃恢复的功能以保护用户数据。InnoDB 的行级别锁定保证数据一致性提升了它的多用户并发数以及性能。InnoDB 将用户数据存储在聚集索引中以减少基于主键的普通查询所带来的 I/O 开销。为了保证数据的完整性InnoDB 还支持外键约束。默认使用B+TREE数据结构存储索引。

特点

  • 支持事务支持4个事务隔离ACID级别
  • 行级锁定(更新时锁定当前行)
  • 读写阻塞与事务隔离级别相关
  • 既能缓存索引又能缓存数据
  • 支持外键
  • InnoDB更消耗资源读取速度没有MyISAM快
  • 在InnoDB中存在着缓冲管理通过缓冲池将索引和数据全部缓存起来加快查询的速度
  • 对于InnoDB类型的表其数据的物理组织形式是聚簇表。所有的数据按照主键来组织。数据和索引放在一块都位于B+数的叶子节点上

业务场景

  • 需要支持事务的场景(银行转账之类)
  • 适合高并发,行级锁定对高并发有很好的适应能力,但需要确保查询是通过索引完成的
  • 数据修改较频繁的业务

InnoDB引擎调优

  • 主键尽可能小否则会给Secondary index带来负担
  • 避免全表扫描,这会造成锁表
  • 尽可能缓存所有的索引和数据减少IO操作
  • 避免主键更新,这会造成大量的数据移动

MyISAM引擎

MyISAM既不支持事务、也不支持外键、其优势是访问速度快但是表级别的锁定限制了它在读写负载方面的性能因此它经常应用于只读或者以读为主的数据场景。默认使用B+TREE数据结构存储索引。

特点

  • 不支持事务
  • 表级锁定(更新时锁定整个表)
  • 读写互相阻塞(写入时阻塞读入、读时阻塞写入;但是读不会互相阻塞)
  • 只会缓存索引通过key_buffer_size缓存索引但是不会缓存数据
  • 不支持外键
  • 读取速度快

业务场景

  • 不需要支持事务的场景(像银行转账之类的不可行)
  • 一般读数据的较多的业务
  • 数据修改相对较少的业务
  • 数据一致性要求不是很高的业务

MyISAM引擎调优

  • 设置合适索引
  • 启用延迟写入,尽量一次大批量写入,而非频繁写入
  • 尽量顺序insert数据让数据写入到尾部减少阻塞
  • 降低并发数,高并发使用排队机制
  • MyISAM的count只有全表扫描比较高效带有其它条件都需要进行实际数据访问

复制

主从复制

主要涉及三个线程:

  • binlog 线程负责将主服务器上的数据更改写入二进制日志Binary log
  • I/O 线程:负责从主服务器上读取- 二进制日志并写入从服务器的中继日志Relay log
  • SQL 线程负责读取中继日志解析出主服务器已经执行的数据更改并在从服务器中重放Replay

img

读写分离

主服务器处理写操作以及实时性要求比较高的读操作,而从服务器处理读操作。读写分离能提高性能的原因在于:

  • 主从服务器负责各自的读和写,极大程度缓解了锁的争用
  • 从服务器可以使用 MyISAM提升查询性能以及节约系统开销
  • 增加冗余,提高可用性

读写分离常用代理方式来实现,代理服务器接收应用层传来的读写请求,然后决定转发到哪个服务器。

img

查询过程

MySQL查询过程

mysql复制原理

基于语句的复制

基于语句的复制模式下主库会记录那些造成数据更改的查询当备库读取并重放这些事件时实际上只把主库上执行过的SQL再执行一遍。

  • 优点

    最明显的好处是实现相当简单。理论上讲简单地记录和执行这些语句能够让备库保持同步。另外好处是binlog日志里的事件更加紧凑所以相对而言基于语句的模式不会使用太多带宽。一条更新好几兆数据的语句在二进制日志里可能只占用几十字节。

  • 缺点

    有些数据更新语句可能依赖其他因素。例如同一条sql在主库和备库上执行的时间可能稍微或很不相同因此在传输的binlog日志中除了查询语句还包括一些元数据信息如当前的时间戳。即便如此还存在着一些无法被正确复制的SQL例如使用CURRENT_USER()函数语句。存储过程和触发器在使用基于语句的复制模式时也可能存在问题。另外一个问题是更新必须是串行的。这需要更多的锁。并且不是所有的存储引擎都支持这种复制模式。

基于行的复制

MySQL5.1开始支持基于行的复制,这种方式会将实际数据记录在二进制日志中,跟其他数据库的实现比较相像。

  • 优点

    最大的好处是可以正确的复制每一行,一些语句可以呗更加有效地复制。由于无需重放更新主库数据的查询,使用基于行的复制模式能够更高效地复制数据。重放一些查询的代价会很高

  • 缺点

    全表更行,使用基于行复制开销会大很多,因为每一行数据都会呗记录到二进制日志中,这使得二进制日志时间非常庞大

高可用方案

我们在考虑MySQL数据库的高可用架构时主要考虑如下几方面

  • 如果数据库发生了宕机或者意外中断等故障,能尽快恢复数据库的可用性,尽可能的减少停机时间,保证业务不会因为数据库的故障而中断
  • 用作备份、只读副本等功能的非主节点的数据应该和主节点的数据实时或者最终保持一致
  • 当业务发生数据库切换时,切换前后的数据库内容应当一致,不会因为数据缺失或者数据不一致而影响业务

关于对高可用的分级我们暂不做详细的讨论,这里只讨论常用高可用方案的优缺点以及选型。

随着人们对数据一致性要求不断的提高越来越多的方法被尝试用来解决分布式数据一致性的问题如MySQL自身的优化、MySQL集群架构的优化、Paxos、Raft、2PC算法的引入等。

而使用分布式算法用来解决MySQL数据库数据一致性问题的方法也越来越被人们所接受一系列成熟的产品如PhxSQL、MariaDB Galera Cluster、Percona XtraDB Cluster等越来越多的被大规模使用。

随着官方MySQL Group Replication的GA使用分布式协议来解决数据一致性问题已经成为了主流的方向。期望越来越多优秀的解决方案被提出MySQL高可用问题也可以被更好的解决。

主从或主主半同步复制

使用双节点数据库搭建单向或者双向的半同步复制。在5.7以后的版本中由于lossless replication、logical多线程复制等一些列新特性的引入使得MySQL原生半同步复制更加可靠。常见架构如下

主从或主主半同步复制

通常会和Proxy、Keepalived等第三方软件同时使用即可以用来监控数据库的健康又可以执行一系列管理命令。如果主库发生故障切换到备库后仍然可以继续使用数据库。

优点

  • 架构比较简单,使用原生半同步复制作为数据同步的依据
  • 双节点,没有主机宕机后的选主问题,直接切换即可
  • 双节点,需求资源少,部署简单

缺点

  • 完全依赖于半同步复制,如果半同步复制退化为异步复制,数据一致性无法得到保证
  • 需要额外考虑HAProxy、Keepalived的高可用机制

半同步复制优化

半同步复制机制是可靠的。如果半同步复制一直是生效的,那么可以认为数据是一致的。但是由于网络波动等一些客观原因,导致半同步复制发生超时而切换为异步复制,这时便不能保证数据的一致性。所以尽可能的保证半同步复制,就可以提高数据的一致性。

该方案同样使用双节点架构,但是在原有半同复制的基础上做了功能上的优化,使半同步复制的机制变得更加可靠。可参考的优化方案如下:

双通道复制

双通道复制

半同步复制由于发生超时后,复制断开,当再次建立起复制时,同时建立两条通道,其中一条半同步复制通道从当前位置开始复制,保证从机知道当前主机执行的进度。另外一条异步复制通道开始追补从机落后的数据。当异步复制通道追赶到半同步复制的起始位置时,恢复半同步复制。

binlog文件服务器

binlog文件服务器

搭建两条半同步复制通道,其中连接文件服务器的半同步通道正常情况下不启用,当主从的半同步复制发生网络问题退化后,启动与文件服务器的半同步复制通道。当主从半同步复制恢复后,关闭与文件服务器的半同步复制通道。

优点

  • 双节点,需求资源少,部署简单
  • 架构简单,没有选主的问题,直接切换即可
  • 相比于原生复制,优化后的半同步复制更能保证数据的一致性

缺点

  • 需要修改内核源码或者使用MySQL通信协议。需要对源码有一定的了解并能做一定程度的二次开发
  • 依旧依赖于半同步复制,没有从根本上解决数据一致性问题

高可用架构优化

将双节点数据库扩展到多节点数据库,或者多节点数据库集群。可以根据自己的需要选择一主两从、一主多从或者多主多从的集群。由于半同步复制,存在接收到一个从机的成功应答即认为半同步复制成功的特性,所以多从半同步复制的可靠性要优于单从半同步复制的可靠性。并且多节点同时宕机的几率也要小于单节点宕机的几率,所以多节点架构在一定程度上可以认为高可用性是好于双节点架构。

但由于数据库数量较多所以需要数据库管理软件来保证数据库的可维护性。可以选择MMM、MHA或者各个版本的Proxy等等。常见方案如下

MHA+多节点集群

MHA+多节点集群

MHA Manager会定时探测集群中的master节点当master出现故障时它可以自动将最新数据的slave提升为新的master然后将所有其他的slave重新指向新的master整个故障转移过程对应用程序完全透明。MHA Node运行在每台MySQL服务器上主要作用是切换时处理二进制日志确保切换尽量少丢数据。MHA也可以扩展到如下的多节点集群

MHA-Manager

优点

  • 可以进行故障的自动检测和转移
  • 可扩展性较好可以根据需要扩展MySQL的节点数量和结构
  • 相比于双节点的MySQL复制三节点/多节点的MySQL发生不可用的概率更低

缺点

  • 至少需要三节点,相对于双节点需要更多的资源
  • 逻辑较为复杂,发生故障后排查问题,定位问题更加困难
  • 数据一致性仍然靠原生半同步复制保证,仍然存在数据不一致的风险
  • 可能因为网络分区发生脑裂现象。
  • 在此我向大家推荐一个架构学习交流群。交流学习群号575745314 里面会分享一些资深架构师录制的视频录像有SpringMyBatisNetty源码分析高并发、高性能、分布式、微服务架构的原理JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源目前受益良多

ZooKeeper+Proxy

ZooKeeper使用分布式算法保证集群数据的一致性使用ZooKeeper可以有效的保证Proxy的高可用性可以较好地避免网络分区现象的产生。

ZooKeeper+Proxy

优点

  • 较好的保证了整个系统的高可用性包括Proxy、MySQL
  • 扩展性较好,可以扩展为大规模集群

缺点

  • 数据一致性仍然依赖于原生的mysql半同步复制
  • 引入ZK整个系统的逻辑变得更加复杂

共享存储

共享存储实现了数据库服务器和存储设备的解耦不同数据库之间的数据同步不再依赖于MySQL的原生复制功能而是通过磁盘数据同步的手段来保证数据的一致性。

SAN共享储存

SAN的概念是允许存储设备和处理器服务器之间建立直接的高速网络与LAN相比连接通过这种连接实现数据的集中式存储。常用架构如下

SAN共享储存

使用共享存储时MySQL服务器能够正常挂载文件系统并操作如果主库发生宕机备库可以挂载相同的文件系统保证主库和备库使用相同的数据。

优点

  • 两节点即可,部署简单,切换逻辑简单
  • 很好的保证数据的强一致性
  • 不会因为MySQL的逻辑错误发生数据不一致的情况

缺点

  • 需要考虑共享存储的高可用
  • 价格昂贵

DRBD磁盘复制

DRBD是一种基于软件、基于网络的块复制存储解决方案主要用于对服务器之间的磁盘、分区、逻辑卷等进行数据镜像当用户将数据写入本地磁盘时还会将数据发送到网络中另一台主机的磁盘上这样的本地主机(主节点)与远程主机(备节点)的数据就可以保证实时同步。常用架构如下:

DRBD磁盘复制

当本地主机出现问题远程主机上还保留着一份相同的数据可以继续使用保证了数据的安全。DRBD是Linux内核模块实现的快级别的同步复制技术可以与SAN达到相同的共享存储效果。

优点

  • 两节点即可,部署简单,切换逻辑简单
  • 相比于SAN储存网络价格低廉
  • 保证数据的强一致性

缺点

  • 对IO性能影响较大
  • 从库不提供读操作

分布式协议

分布式协议可以很好地解决数据一致性问题。比较常见的方案如下:

MySQL Cluster

MySQL Cluster是官方集群的部署方案通过使用NDB存储引擎实时备份冗余数据实现数据库的高可用性和数据一致性。

MySQL-Cluster

优点

  • 全部使用官方组件,不依赖于第三方软件
  • 可以实现数据的强一致性

缺点

  • 国内使用的较少
  • 配置较复杂需要使用NDB储存引擎与MySQL常规引擎存在一定差异
  • 至少三节点

Galera

基于Galera的MySQL高可用集群 是多主数据同步的MySQL集群解决方案使用简单没有单点故障可用性高。常见架构如下

MySQL-Galera

优点

  • 多主写入,无延迟复制,能保证数据强一致性
  • 有成熟的社区,有互联网公司在大规模的使用
  • 自动故障转移,自动添加、剔除节点

缺点

  • 需要为原生MySQL节点打wsrep补丁
  • 只支持innodb储存引擎
  • 至少三节点

Paxos

Paxos算法解决的问题是一个分布式系统如何就某个值决议达成一致。这个算法被认为是同类算法中最有效的。Paxos与MySQL相结合可以实现在分布式的MySQL数据的强一致性。常见架构如下

MySQL-Paxos

优点

  • 多主写入,无延迟复制,能保证数据强一致性
  • 有成熟理论基础
  • 自动故障转移,自动添加、剔除节点

缺点

  • 只支持InnoDB储存引擎
  • 至少三节点
  • 在此我向大家推荐一个架构学习交流群。交流学习群号575745314 里面会分享一些资深架构师录制的视频录像有SpringMyBatisNetty源码分析高并发、高性能、分布式、微服务架构的原理JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源目前受益良多

主从延迟

在实际的生产环境中由单台MySQL作为独立的数据库是完全不能满足实际需求的无论是在安全性高可用性以及高并发等各个方面。因此一般来说都是通过集群主从复制Master-Slave的方式来同步数据再通过读写分离MySQL-Proxy来提升数据库的并发负载能力进行部署与实施。总结MySQL主从集群带来的作用是

  • 提高数据库负载能力,主库执行读写任务(增删改),备库仅做查询
  • 提高系统读写性能、可扩展性和高可用性
  • 数据备份与容灾,备库在异地,主库不存在了,备库可以立即接管,无须恢复时间

MySQL主从集群

biglog

binlog是什么有什么作用

用于记录数据库执行的写入性操作(不包括查询)信息以二进制的形式保存在磁盘中。可以简单理解为记录的就是sql语句。binlog 是 mysql 的逻辑日志,并且由 Server层进行记录,使用任何存储引擎的 mysql 数据库都会记录 binlog 日志。在实际应用中, binlog 的主要使用场景有两个:

  • 用于主从复制在主从结构中binlog 作为操作记录从 master 被发送到 slaveslave服务器从 master 接收到的日志保存到 relay log 中
  • 用于数据备份在数据库备份文件生成后binlog保存了数据库备份后的详细信息以便下一次备份能从备份点开始

日志格式

binlog日志有三种格式分别为STATMENT 、 ROW 和 MIXED。在 MySQL 5.7.7 之前,默认的格式是 STATEMENT MySQL 5.7.7 之后,默认值是 ROW。日志格式通过 binlog-format 指定。

  • STATMENT :基于 SQL 语句的复制每一条会修改数据的sql语句会记录到 binlog 中
  • ROW :基于行的复制
  • MIXED :基于 STATMENT 和 ROW 两种模式的混合复制,比如一般的数据操作使用 row 格式保存,有些表结构的变更语句,使用 statement 来记录

我们还可以通过mysql提供的查看工具mysqlbinlog查看文件中的内容例如

mysqlbinlog mysql-bin.00001 | more

binlog文件大小和个数会不断的增加后缀名会按序号递增例如mysql-bin.00002等。

主从复制原理

主从复制原理

可以看到mysql主从复制需要三个线程masterbinlog dump thread、slaveI/O thread 、SQL thread

  • binlog dump线程 主库中有数据更新时根据设置的binlog格式将更新的事件类型写入到主库的binlog文件中并创建log dump线程通知slave有数据更新。当I/O线程请求日志内容时将此时的binlog名称和当前更新的位置同时传给slave的I/O线程
  • I/O线程 该线程会连接到master向log dump线程请求一份指定binlog文件位置的副本并将请求回来的binlog存到本地的relay log中
  • SQL线程 该线程检测到relay log有更新后会读取并在本地做redo操作将发生在主库的事件在本地重新执行一遍来保证主从数据同步

基本过程总结

  • 主库写入数据并且生成binlog文件。该过程中MySQL将事务串行的写入二进制日志即使事务中的语句都是交叉执行的
  • 在事件写入二进制日志完成后master通知存储引擎提交事务
  • 从库服务器上的IO线程连接Master服务器请求从执行binlog日志文件中的指定位置开始读取binlog至从库
  • 主库接收到从库IO线程请求后其上复制的IO线程会根据Slave的请求信息分批读取binlog文件然后返回给从库的IO线程
  • Slave服务器的IO线程获取到Master服务器上IO线程发送的日志内容、日志文件及位置点后会将binlog日志内容依次写到Slave端自身的Relay Log即中继日志文件的最末端并将新的binlog文件名和位置记录到master-info文件中以便下一次读取master端新binlog日志时能告诉Master服务器从新binlog日志的指定文件及位置开始读取新的binlog日志内容
  • 从库服务器的SQL线程会实时监测到本地Relay Log中新增了日志内容然后把RelayLog中的日志翻译成SQL并且按照顺序执行SQL来更新从库的数据
  • 从库在relay-log.info中记录当前应用中继日志的文件名和位置点以便下一次数据复制

并行复制

在MySQL 5.6版本之前Slave服务器上有两个线程I/O线程和SQL线程。I/O线程负责接收二进制日志SQL线程进行回放二进制日志。如果在MySQL 5.6版本开启并行复制功能那么SQL线程就变为了coordinator线程coordinator线程主要负责以前两部分的内容

并行复制

上图的红色框框部分就是实现并行复制的关键所在。这意味着coordinator线程并不是仅将日志发送给worker线程自己也可以回放日志但是所有可以并行的操作交付由worker线程完成。coordinator线程与worker是典型的生产者与消费者模型。

coordinator线程

不过到MySQL 5.7才可称为真正的并行复制这其中最为主要的原因就是slave服务器的回放与主机是一致的即master服务器上是怎么并行执行的slave上就怎样进行并行回放。不再有库的并行复制限制对于二进制日志格式也无特殊的要求。为了兼容MySQL 5.6基于库的并行复制5.7引入了新的变量slave-parallel-type,其可以配置的值有:

  • DATABASE:默认值,基于库的并行复制方式
  • LOGICAL_CLOCK:基于组提交的并行复制方式

按库并行

每个 worker 线程对应一个 hash 表用于保存当前正在这个worker的执行队列里的事务所涉及到的库。其中hash表里的key是数据库名用于决定分发策略。该策略的优点是构建hash值快只需要库名同时对于binlog的格式没有要求。但这个策略的效果只有在主库上存在多个DB且各个DB的压力均衡的情况下这个策略效果好。因此对于主库上的表都放在同一个DB或者不同DB的热点不同则起不到多大效果

按库并行

组提交优化

该特性如下:

  • 能够同一组里提交的事务,定不会修改同一行
  • 主库上可以并行执行的事务,从库上也一定可以并行执行

具体是如何实现的:

  • 在同一组里面一起提交的事务,会有一个相同的commit_id,下一组为commit_id+1,该commit_id会直接写到binlog中
  • 在从库使用时,相同commit_id的事务会被分发到多个worker并行执行直到这一组相同的commit_id执行结束后coordinator再取下一批

主从延迟

根据主从复制的原理可以看出,两者之间是存在一定时间的数据不一致,也就是所谓的主从延迟。导致主从延迟的时间点:

  • 主库 A 执行完成一个事务,写入 binlog该时刻记为T1
  • 传给从库B从库接受完这个binlog的时刻记为T2
  • 从库B执行完这个事务该时刻记为T3

那么所谓主从延迟就是同一个事务从库执行完成的时间和主库执行完成的时间之间的差值即T3-T1。我们也可以通过在从库执行show slave status,返回结果会显示seconds_behind_master,表示当前从库延迟了多少秒。

seconds_behind_master如何计算的

  • 每一个事务的binlog都有一个时间字段用于记录主库上写入的时间
  • 从库取出当前正在执行的事务的时间字段,跟当前系统的时间进行相减,得到的就是seconds_behind_master也就是前面所描述的T3-T1

为什么会主从延迟?

正常情况下如果网络不延迟那么日志从主库传给从库的时间是相当短所以T2-T1可以基本忽略。最直接的影响就是从库消费中转日志relaylog的时间段而造成原因一般是以下几种

  • 从库的机器性能比主库要差

    比如将20台主库放在4台机器把从库放在一台机器。这个时候进行更新操作由于更新时会触发大量读操作导致从库机器上的多个从库争夺资源导致主从延迟。不过目前大部分部署都是采取主从使用相同规格的机器部署

  • 从库的压力大

    按照正常的策略读写分离主库提供写能力从库提供读能力。将进行大量查询放在从库上结果导致从库上耗费了大量的CPU资源进而影响了同步速度造成主从延迟。对于这种情况可以通过一主多从分担读压力也可以采取binlog输出到外部系统比如Hadoop让外部系统提供查询能力

  • 大事务的执行

    一旦执行大事务那么主库必须要等到事务完成之后才会写入binlog。比如主库执行了一条insert … select非常大的插入操作该操作产生了近几百G的binlog文件传输到只读节点进而导致了只读节点出现应用binlog延迟。因此DBA经常会提醒开发不要一次性地试用delete语句删除大量数据尽可能控制数量分批进行

  • 主库的DDL(alter、drop、create)

    • 只读节点与主库的DDL同步是串行进行如果DDL操作在主库执行时间很长那么从库也会消耗同样的时间比如在主库对一张500W的表添加一个字段耗费了10分钟那么从节点上也会耗费10分钟
    • 从节点上有一个执行时间非常长的的查询正在执行那么这个查询会堵塞来自主库的DDL表被锁直到查询结束为止进而导致了从节点的数据延迟
  • 锁冲突

    锁冲突问题也可能导致从节点的SQL线程执行慢比如从机上有一些select .... for update的SQL或者使用了MyISAM引擎等

  • 从库的复制能力

    一般场景中,因偶然情况导致从库延迟了几分钟,都会在从库恢复之后追上主库。但若是从库执行速度低于主库,且主库持续具有压力,就会导致长时间主从延迟,很有可能就是从库复制能力的问题。

从库上的执行,即sql_thread更新逻辑在5.6版本之前是只支持单线程那么在主库并发高、TPS高时就会出现较大的主从延迟。因此MySQL自5.7版本后就已经支持并行复制了。可以在从服务上设置 slave_parallel_workers为一个大于0的数然后把slave_parallel_type参数设置为LOGICAL_CLOCK,这就可以了

mysql> show variables like 'slave_parallel%';
+------------------------+----------+
| Variable_name          | Value    |
+------------------------+----------+
| slave_parallel_type    | DATABASE |
| slave_parallel_workers | 0        |
+------------------------+----------+

怎么减少主从延迟?

主从同步问题永远都是一致性和性能的权衡,得看实际的应用场景,若想要减少主从延迟的时间,可以采取下面的办法:

  • 降低多线程大事务并发的概率,优化业务逻辑
  • 优化SQL避免慢SQL减少批量操作建议写脚本以update-sleep这样的形式完成
  • 提高从库机器的配置减少主库写binlog和从库读binlog的效率差
  • 尽量采用短的链路也就是主库和从库服务器的距离尽量要短提升端口带宽减少binlog传输的网络延时
  • 实时性要求的业务读强制走主库,从库只做灾备,备份

ClickHouse