05_2、【Java面试-数据库篇】(下)

119 阅读15分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第10天,点击查看活动详情

4. 索引

要求

  • 了解常见索引与它们的适用场景,尤其是 B+Tree 索引的特点

  • 掌握索引用于排序,以及失效情况

  • 掌握索引用于筛选,以及失效情况

  • 理解索引条件下推

  • 理解二级索引覆盖

索引基础

常见索引

  • 哈希索引

  * 理想时间复杂度为 O(1)O(1)

  * 适用场景:适用于等值查询的场景,内存数据的索引

  * 典型实现:Redis,MySQL 的 memory 引擎

  • 平衡二叉树索引 

  * 查询和更新的时间复杂度都是 O(log2(n))O(log_2(n))

  * 适用场景:适用于等值查询以及范围查询;适合内存数据的索引,但不适合磁盘数据的索引,可以认为树的高度决定了磁盘 I/O 的次数,百万数据树高约为 20

  • BTree 索引

  * BTree 其实就是 n 叉树,分叉多意味着节点中的孩子(key)多,树高自然就降低了

  * 分叉数由页大小和行(包括 key 与 value)大小决定

    * 假设页大小为 16k,每行 40 个字节,那么分叉数就为 16k / 40 ≈ 410

    * 而分叉为 410,则百万数据树高约为3,仅 3 次 I/O 就能找到所需数据

  * 局部性原理:每次 I/O 按页为单位读取数据,把多个 key 相邻的行放在同一页中(每页就是树上一个节点),能进一步减少 I/O

  • B+ 树索引 

  * 在 BTree 的基础上做了改进,索引上只存储 key,这样能进一步增加分叉数,假设 key 占 13 个字节,那么一页数据分叉数可以到 1260,树高可以进一步下降为 2

****** **树高计算公式 ****

  • log10(N)/ log10(M)log*_ *{10}(N) /  log*_ *{10}(M) 其中 N 为数据行数,M 为分叉数

BTree vs B+Tree

  • 无论 BTree 还是 B+Tree,每个叶子节点到根节点距离都相同

  • BTree key 及 value 在每个节点上,无论叶子还是非叶子节点

image-20210901170943656.png

  • B+Tree 普通节点只存 key,叶子节点才存储 key 和 value,因此分叉数可以更多

  * 不过也请注意,普通节点上的 key 有的会与叶子节点的 key 重复

  • B+Tree 必须到达叶子节点才能找到 value

  • B+Tree 叶子节点用链表连接,可以方便范围查询及全表遍历

image-20210901170954328.png

注:这两张图都是仅画了 key,未画 value

B+Tree 新增 key

假设阶数(m)为5

  1. 若为空树,那么直接创建一个节点,插入 key 即可,此时这个叶子结点也是根结点。例如,插入 5

image-20210901174939408.png

  1. 插入时,若当前结点 key 的个数小于阶数,则插入结束

  2. 依次插入 8、10、15,按 key 大小升序

image-20210901175021697.png

  1. 插入 16,这时到达了阶数限制,所以要进行分裂

image-20210901175057315.png

  1. 叶子节点分裂规则:将这个叶子结点分裂成左右两个叶子结点,左叶子结点包含前 m/2 个(2个)记录,右结点包含剩下的记录,将中间的 key 进位到父结点中。注意:中间的 key 仍会保留在叶子节点一份

image-20210901175106713.png 6. 插入 17

image-20210901175333804.png

  1. 插入 18,这时当前结点的 key 个数到达 5,进行分裂

image-20210901175352807.png

  1. 分裂成两个结点,左结点 2 个记录,右结点 3 个记录,key 16 进位到父结点中

image-20210901175413305.png

  1. 插入 19、20、21、22、6、9

image-20210901175440205.png

  1. 插入 7,当前结点的 key 个数到达 5,需要分裂

image-20210901175518737-16304901199481.png

  1. 分裂后 key 7 进入到父结点中,这时父节点 key 个数也到达 5

image-20210901175544893.png

  1. 非叶子节点分裂规则:左子结点包含前 (m-1)/2 个 key,将中间的 key 进位到父结点中(不保留),右子节点包含剩余的 key

image-20210901175617464.png

B+Tree 查询 key

以查询 15 为例

  • 第一次 I/O

image-20210901175721826.png

  • 第二次 I/O

image-20210901180422491.png

  • 第三次 I/O

image-20210901175801859.png

B+Tree 删除叶子节点 key

  1. 初始状态

image-20210901180320860.png

  1. 删完有富余。即删除后结点的key的个数 > m/2 – 1,删除操作结束,例如删除 22

image-20210901180331158.png

  1. 删完没富余,但兄弟节点有富余。即兄弟结点 key 有富余( > m/2 – 1 ),向兄弟结点借一个记录,同时替换父节点,例如删除 15

image-20210901180356515.png

  1. 兄弟节点也不富余,合并兄弟叶子节点。即兄弟节点合并成一个新的叶子结点,并删除父结点中的key,将当前结点指向父结点,例如删除 7

image-20210901180405393.png

  1. 也需要删除非叶子节点中的 7,并替换父节点保证区间仍有效

image-20210901180422491.png

  1. 左右兄弟都不够借,合并

B+Tree 删除非叶子节点 key

接着上面的操作

  1. 非叶子节点 key 的个数 > m/2 – 1,则删除操作结束,否则执行 2

  2. 兄弟结点有富余,父结点 key 下移,兄弟结点 key 上移,删除结束,否则执行 3

  3. 兄弟节点没富余,当前结点和兄弟结点及父结点合并成一个新的结点。重复 1

image-20210901180516139.png

命中索引

准备数据

  1. 修改 MySQL 配置文件,在 [mysqld] 下添加 secure_file_priv= 重启 MySQL 服务器,让选项生效
  1. 执行 db.sql 内的脚本,建表
  1. 执行 LOAD DATA INFILE 'D:\\big_person.txt' INTO TABLE big_person; 注意实际路径根据情况修改

    * 测试表 big_person(此表数据量较大,如果与其它表数据一起提供不好管理,故单独提供),数据行数 100 万条,列个数 15 列。为了更快速导入数据,这里采用了 load data infile 命令配合 *.txt 格式数据

索引用于排序


/* 测试单列索引并不能在多列排序时加速 */

create index first_idx on big_person(first_name);

create index last_idx on big_person(last_name);

explain select * from big_person order by last_name, first_name limit 10/* 多列排序需要用组合索引 */

alter table big_person drop index first_idx;

alter table big_person drop index last_idx;

create index last_first_idx on big_person(last_name,first_name);




/* 多列排序需要遵循最左前缀原则, 第1个查询可以利用索引,第2,3查询不能利用索引 */

explain select * from big_person order by last_name, first_name limit 10; 

explain select * from big_person order by first_name, last_name limit 10; 

explain select * from big_person order by first_name limit 10/* 多列排序升降序需要一致,查询1可以利用索引,查询2不能利用索引*/

explain select * from big_person order by last_name desc, first_name desc limit 10; 

explain select * from big_person order by last_name desc, first_name asc limit 10

****** **最左前缀原则 ****

若建立组合索引 (a,b,c),则可以利用到索引的排序条件是:

  • order by a

> * order by a, b

  • order by a, b, c

索引用于 where 筛选


/* 模糊查询需要遵循字符串最左前缀原则,查询2可以利用索引,查询1,3不能利用索引 */

explain SELECT * FROM big_person WHERE first_name LIKE 'dav%' LIMIT 5;

explain SELECT * FROM big_person WHERE last_name LIKE 'dav%' LIMIT 5;

explain SELECT * FROM big_person WHERE last_name LIKE '%dav' LIMIT 5;



/* 组合索引需要遵循最左前缀原则,查询1,2可以利用索引,查询3,4不能利用索引 */

create index province_city_county_idx on big_person(province,city,county);

explain SELECT * FROM big_person WHERE province = '上海' AND city='宜兰县' AND county='中西区';

explain SELECT * FROM big_person WHERE county='中西区' AND city='宜兰县' AND province = '上海';

explain SELECT * FROM big_person WHERE city='宜兰县' AND county='中西区';

explain SELECT * FROM big_person WHERE county='中西区';




/* 函数及计算问题,一旦在字段上应用了计算或函数,都会造成索引失效。查询2可以利用索引,查询1不能利用索引 */

create index birthday_idx on big_person(birthday);

explain SELECT * FROM big_person WHERE ADDDATE(birthday,1)='2005-02-10';

explain SELECT * FROM big_person WHERE birthday=ADDDATE('2005-02-10',-1);




/* 隐式类型转换问题

* 查询1会发生隐式类型转换等价于在phone上应用了函数,造成索引失效

* 查询2字段与值类型相同不会类型转换,可以利用索引

*/

create index phone_idx on big_person(phone);

explain SELECT * FROM big_person WHERE phone = 13000013934;

explain SELECT * FROM big_person WHERE phone = '13000013934';

****** **最左前缀原则(leftmost prefix) ****

若建立组合索引 (a,b,c),则可以利用到索引的查询条件是:

  • where a = ?

> * where a = ? and b = ? (注意与条件的先后次序无关,也可以是 where b = ? and a = ?,只要出现即可)

  • where a = ? and b = ? and c = ? (注意事项同上)

>

> 不能利用**的例子:

>

> * where b = ?

  • where b = ? and c = ?

> * where c = ?

特殊情况:

  • where a = ? and c = ?(a = ? 会利用索引,但 c = ? 不能利用索引加速,会触发索引条件下推)

索引条件下推


/* 查询 1,2,3,4 都能利用索引,但 4 相当于部分利用了索引,会触发索引条件下推 */

explain SELECT * FROM big_person WHERE province = '上海';

explain SELECT * FROM big_person WHERE province = '上海' AND city='嘉兴市';

explain SELECT * FROM big_person WHERE province = '上海' AND city='嘉兴市' AND county='中西区';

explain SELECT * FROM big_person WHERE province = '上海' AND county='中西区';

****** **索引条件下推 ****

  • MySQL 执行条件判断的时机有两处:

*> ** 服务层(上层,不包括索引实现)

  * 引擎层(下层,包括了索引实现,可以利用)

*> ** 上面查询 4 中有 province 条件能够利用索引,在引擎层执行,但 county 条件仍然要交给服务层处理

  • 在 5.6 之前,服务层需要判断所有记录的 county 条件,性能非常低

> * 5.6 以后,引擎层会先根据 province 条件过滤,满足条件的记录才在服务层处理 county 条件

我们现在用的是 5.6 以上版本,所以没有体会,可以用下面的语句关闭索引下推优化,再测试一下性能


SET optimizer_switch = 'index_condition_pushdown=off';

SELECT * FROM big_person WHERE province = '上海' AND county='中西区';

二级索引覆盖


explain SELECT * FROM big_person WHERE province = '上海' AND city='宜兰县' AND county= '中西区';

explain SELECT id,province,city,county FROM big_person WHERE province = '上海' AND city='宜兰县' AND county='中西区';

根据查询条件查询 1,2 都会先走二级索引,但是二级索引仅包含了 (province, city, county) 和 id 信息

  • 查询 1 是 select * ,因此还有一些字段二级索引中没有,需要回表(查询聚簇索引)来获取其它字段信息

  • 查询 2 的 select 中明确指出了需要哪些字段,这些字段在二级索引都有,就避免了回表查询

其它注意事项

  • 表连接需要在连接字段上建立索引

  • 不要迷信网上说法,具体情况具体分析

例如:


create index first_idx on big_person(first_name);




/* 不会利用索引,因为优化器发现查询记录数太多,还不如直接全表扫描 */

explain SELECT * FROM big_person WHERE first_name > 'Jenni';




/* 会利用索引,因为优化器发现查询记录数不太多 */

explain SELECT * FROM big_person WHERE first_name > 'Willia';




/* 同一字段的不同值利用 or 连接,会利用索引 */

explain select * from big_person where id = 1 or id = 190839;




/* 不同字段利用 or 连接,会利用索引(底层分别用了两个索引) */

explain select * from big_person where first_name = 'David' or last_name = 'Thomas';




/* in 会利用索引 */

explain select * from big_person where first_name in ('Mark', 'Kevin','David'); 




/* not in 不会利用索引的情况 */

explain select * from big_person where first_name not in ('Mark', 'Kevin','David');



/* not in 会利用索引的情况 */

explain select id from big_person where first_name not in ('Mark', 'Kevin','David');

  • 以上实验基于 5.7.27,其它如 !=、is null、is not null 是否使用索引都会跟版本、实际数据相关,以优化器结果为准

5. 查询语句执行流程

要求

  • 了解查询语句执行流程

执行 SQL 语句 select * from user where id = 1 时发生了什么

image-20210902082718756.png

  1. 连接器:负责建立连接、检查权限、连接超时时间由 wait_timeout 控制,默认 8 小时

  2. 查询缓存:会将 SQL 和查询结果以键值对方式进行缓存,修改操作会以表单位导致缓存失效

  3. 分析器:词法、语法分析

  4. 优化器:决定用哪个索引,决定表的连接顺序等

  5. 执行器:根据存储引擎类型,调用存储引擎接口

  6. 存储引擎:数据的读写接口,索引、表都在此层实现

6. undo log 与 redo log

要求

  • 理解 undo log 的作用

  • 理解 redo log 的作用

undo log

  • 回滚数据,以行为单位,记录数据每次的变更,一行记录有多个版本并存

  • 多版本并发控制,即快照读(也称为一致性读),让查询操作可以去访问历史版本

image-20210902083051903.png

  1. 每个事务会按照开始时间,分配一个单调递增的事务编号 trx id

  2. 每次事务的改动都会以行为单位记入回滚日志,包括当时的事务编号,改动的值等

  3. 查询操作,事务编号大于自己的数据是不可见的,事务编号小于等于自己的数据才是可见的

   * 例如图中红色事务看不到 trx id=102 以及 trx id=101 的数据,只有 trx id=99 的数据对它可见

redo log

redo log 的作用主要是实现 ACID 中的持久性,保证提交的数据不丢失

  • 它记录了事务提交的变更操作,服务器意外宕机重启时,利用 redo log 进行回放,重新执行已提交的变更操作

  • 事务提交时,首先将变更写入 redo log,事务就视为成功。至于数据页(表、索引)上的变更,可以放在后面慢慢做

  * 数据页上的变更宕机丢失也没事,因为 redo log 里已经记录了

  * 数据页在磁盘上位置随机,写入速度慢,redo log 的写入是顺序的速度快

它由两部分组成,内存中的 redo log buffer,磁盘上的 redo log file

  • redo log file 由一组文件组成,当写满了会循环覆盖较旧的日志,这意味着不能无限依赖 redo log,更早的数据恢复需要 binlog 

  • buffer 和 file 两部分组成意味着,写入了文件才真正安全,同步策略由参数 innodb_flush_log_at_trx_commit  控制

  * 0 - 每隔 1s 将日志 write and flush 到磁盘 

  * 1 - 每次事务提交将日志 write and flush(默认值)

  * 2 - 每次事务提交将日志 write,每隔 1s flush 到磁盘,意味着 write 意味着写入操作系统缓存,如果 MySQL 挂了,而操作系统没挂,那么数据不会丢失

7. 锁

要求

  • 了解全局锁

  • 了解表级锁

  • 掌握行级锁

全局锁

用作全量备份时,保证表与表之间的数据一致性

如果不加任何包含,数据备份时就可能产生不一致的情况,如下图所示

image-20210902090302805.png

全局锁的语法:


flush tables with read lock;

  • 使用全局读锁锁定所有数据库的所有表。这时会阻塞其它所有 DML 以及 DDL 操作,这样可以避免备份过程中的数据不一致。接下来可以执行备份,最后用 unlock tables 来解锁

****** **注意 ****

但 flush tables 属于比较重的操作,可以使用 --single-transaction 参数来完成不加锁的一致性备份(仅针对 InnoDB 引擎的表)

mysqldump --single-transaction -uroot -p test > 1.sql

表级锁 - 表锁

  • 语法:加锁 lock tables 表名 read/write,解锁 unlock tables

  • 缺点:粒度较粗,在 InnoDB 引擎很少使用

表级锁 - 元数据锁

  • 即 metadata-lock(MDL),主要是为了避免 DML 与 DDL 冲突,DML 的元数据锁之间不互斥

  • 加元数据锁的几种情况

  * lock tables read/write,类型为 SHARED_READ_ONLY 和 SHARED_NO_READ_WRITE

  * alter table,类型为 EXCLUSIVE,与其它 MDL 都互斥

  * select,select … lock in share mode,类型为 SHARED_READ

  * insert,update,delete,select for update,类型为 SHARED_WRITE 

  • 查看元数据锁(适用于 MySQL 8.0 以上版本)

  * select object_type,object_schema,object_name,lock_type,lock_duration from performance_schema.metadata_locks;

**表级锁 - IS(意向共享) 与 IX(意向排他) **

  • 主要是避免 DML 与表锁冲突,DML 主要目的是加行锁,为了让表锁不用检查每行数据是否加锁,加意向锁(表级)来减少表锁的判断,意向锁之间不会互斥

  • 加意向表锁的几种情况

  * select  … lock in share mode 会加 IS 锁

  * insert,update,delete, select … for update 会加 IX 锁

  • 查看意向表锁(适用于 MySQL 8.0 以上版本)

  * select object_schema,object_name,index_name,lock_type,lock_mode,lock_data from performance_schema.data_locks;

行级锁

  • 种类

  * 行锁 – 在 RC 下,锁住的是行,防止其他事务对此行 update 或 delete

  * 间隙锁 – 在 RR 下,锁住的是间隙,防止其他事务在这个间隙 insert 产生幻读

  * 临键锁 – 在 RR 下,锁住的是前面间隙+行,特定条件下可优化为行锁

  • 查看行级锁

  * select object_schema,object_name,index_name,lock_type,lock_mode,lock_data from performance_schema.data_locks where object_name='表名';

****** **注意 ****

  • 它们锁定的其实都是索引**上的行与间隙,根据索引的有序性来确定间隙

测试数据


create table t (id int primary key, name varchar(10),age int, key (name)); 

insert into t values(1, 'zhangsan',18); 

insert into t values(2, 'lisi',20); 

insert into t values(3, 'wangwu',21); 

insert into t values(4, 'zhangsan', 17); 

insert into t values(8,'zhang',18);

insert into t values(12,'zhang',20);

说明

  • 1,2,3,4 之间其实并不可能有间隙

> * 4 与 8 之间有间隙

  • 8 与 12 之间有间隙

> * 12 与正无穷大之间有间隙

  • 其实我们的例子中还有负无穷大与 1 之间的间隙,想避免负数可以通过建表时选择数据类型为 unsigned int

间隙锁例子

事务1:


begin;

select * from t where id = 9 for update; /* 锁住的是 8 与 12 之间的间隙 */

事务2:


update t set age=100 where id = 8; /* 不会阻塞 */

update t set age=100 where id = 12; /* 不会阻塞 */

insert into t values(10,'aaa',18); /* 会阻塞 */

临键锁和记录锁例子

事务1:


begin;

select * from t where id >= 8 for update;

  • 临键锁锁定的是左开右闭的区间,与上条查询条件相关的区间有 (4,8],(8,12],(12,+∞)

  • 临键锁在某些条件下可以被优化为记录锁,例如 (4,8] 被优化为只针对 8 的记录锁,前面的区间不会锁住

事务2:


insert into t values(7,'aaa',18); /* 不会阻塞 */

update t set age=100 where id = 8; /* 会阻塞 */

insert into t values(10,'aaa',18); /* 会阻塞 */

update t set age=100 where id = 12; /* 会阻塞 */

insert into t values(13,'aaa',18); /* 会阻塞 */