嘘~ 正在从服务器偷取页面 . . .

数据库 八股文


1. MySQL

1.1 MySQL 引擎

MySQL 5.5之前,MyISAM 引擎是 MySQL 的默认存储引擎。

5.5版本之后,MySQL 引入了 InnoDB(事务性数据库引擎),作为默认引擎。

InnoDB 的优势:

  • MyISAM 只有表级锁(table-level locking),而 InnoDB 支持行级锁(row-level locking)和表级锁,默认为行级锁;
  • InnoDB 提供事务支持,具有提交(commit)和回滚(rollback)事务的能力;
  • InnoDB 支持外键;
  • 使用 InnoDB 的数据库在异常崩溃后,数据库重新启动的时候会保证数据库恢复到崩溃前的状态;
  • InnoDB 支持 MVCC。

在正常的开发中,选择默认的 InnoDB 没有什么问题。

1.1.1 数据结构

MySQL 中,数据存储在物理磁盘上,数据操作在内存中执行,所以不能对磁盘反复读写。

InnoDB 将数据划分为若干页,以页作为磁盘与内存交互的基本单位,一般页的大小为16KB。一次至少读取1页数据到内存中或者将1页数据写入磁盘,减少内存与磁盘的交互次数。这是一种典型的缓存设计思想。

时间维度:如果一条数据正在在被使用,那么在接下来一段时间内大概率还会再被使用。可以认为热点数据缓存都属于这种思路的实现;

空间维度:如果一条数据正在在被使用,那么存储在它附近的数据大概率也会很快被使用。InnoDB 的数据页操作系统的页缓存则是这种思路的体现。

1.1.2 InnoDB 行格式

MySQL 以记录(一行数据)为单位向数据表中插入数据,这些记录在磁盘上的存放方式称为行格式

MySQL 支持4种不同类型的行格式:CompactRedundantDynamicCompressed。 我们可以在创建或修改表的语句中指定行格式。

Redundant 是比较老的数据格式,Compressed 不能应用在System data;所以 Compact 和 Dynamic 应用较广泛。Version 5.6 默认使用 Compact,Version 5.7 默认使用 Dynamic。

Compact 行格式示意图

从上图可以看出,一条完整的记录包含记录的额外信息记录的真实数据两部分。

额外信息
# 示例数据库结构
mysql> CREATE TABLE record_format_demo (
    ->     c1 VARCHAR(10),
    ->     c2 VARCHAR(10) NOT NULL,
    ->     c3 CHAR(10),
    ->     c4 VARCHAR(10)
    -> ) CHARSET=ascii ROW_FORMAT=COMPACT;
Query OK, 0 rows affected (0.03 sec)

# 示例数据
mysql> SELECT * FROM record_format_demo;
+------+-----+------+------+
| c1   | c2  | c3   | c4   |
+------+-----+------+------+
| aaaa | bbb | cc   | d    |
| eeee | fff | NULL | NULL |
+------+-----+------+------+
2 rows in set (0.00 sec)

存储内部结构示例图

变长字段长度列表

MySQL 中支持一些变长数据类型(比如VARCHAR(M)TEXT等)(CHAR为固定字长数据类型),它们存储数据占用的存储空间不是固定的,而是会随着存储内容的变化而变化。

变长字段占用的存储空间要包含:

  1. 真正的数据内容;
  2. 占用的字节数。

在 Compact 行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按照列的顺序逆序存放(只储存非 NULL 列内容的长度)。

NULL 值列表

对于可为NULL的列,为了节约存储空间,不会将NULL值保存在记录的真实数据部分。而是会将其保存在记录的额外信息里面的NULL值列表中。

先统计表中允许存储NULL值的列,然后将每个允许存储NULL值的列对应一个二进制位(1:值为NULL,0:值不为NULL)用来表示是否存储NULL值,并按照逆序排列。规定 NULL 值列表必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补0。

记录头信息

记录头信息是由固定的5个字节(40位)组成, 不同的位代表不同的含义。

记录头示例图

名称 大小 bit 描述
预留位1 1 没有使用
预留位2 1 没有使用
delete_mask 1 标记该记录是否被删除
min_rec_mask 1 B+树的每层非叶子节点中的最小记录都会添加该标记
n_owned 4 表示当前记录拥有的记录数
heap_no 13 表示当前记录在记录堆的位置信息
record_type 3 表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录
next_record 16 表示下一条记录的相对位置
  • delete_mask:标记着当前记录是否被删除,0表示未删除,1表示删除。未删除的记录不会立即从磁盘上移除,而是先打上删除标记,所有被删除的记录会组成一个垃圾链表。之后新插入的记录可能会重用垃圾链表占用的空间,因此垃圾链表占用的存储空间也被称为可重用空间

  • heap_no:表示当前记录在本页中的位置,InnoDB 自动为每页加上两条虚拟记录,一条是最小记录,另一条是最大记录。这由5字节大小的记录头信息8字节大小的固定部分(其实内容是 infimum 或者 supremum)组成的。这两条记录被单独放在Infimum + Supremum的部分。

  • next_record:表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。可以简单理解为是一个单向链表,最小记录的下一个是第一条记录,最后一条记录的下一个是最大记录;

地址偏移指向示意图

在删除记录的时候,也可以类比成将一个节点从链表中删除。

record1 指向了 record3

记录的真实数据

除了本身的数据之外,还有隐藏列的数据:

列名 是否必须 占用空间 描述
DB_ROW_ID 6字节 行ID,唯一标识一条记录(在没有指定主键的时候生效)
DB_TRX_ID 6字节 事务ID
DB_ROLL_PTR 7字节 回滚指针

对于 CHAR(M) 类型的列来说,当列采用的是定长字符集时,该列占用的字节数不会被加到变长字段长度列表,而如果采用变长字符集时,该列占用的字节数也会被加到变长字段长度列表。 还需要注意,变长字符集的CHAR(M)类型的列要求至少占用M个字节,而VARCHAR(M)却没有这个要求。比如对于使用utf8字符集的CHAR(10)的列来说,该列存储的数据字节长度的范围是10~30个字节,即使我们向该列中存储一个空字符串也会占用10个字节。

行溢出

MySQL 对一条记录占用的最大存储空间有限制,除了BLOB或者TEXT类型的列,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节。可以不严谨的认为,一行记录占用的存储空间不能超过65535个字节

这65535个字节除了列本身的数据之外,还包括其他的数据(storage overhead),比如存储一个 VARCHAR(M) 类型的列,需要占用3部分存储空间:

  1. 真实数据;
  2. 真实数据占用字节的长度;
  3. NULL值标识,如果该列有NOT NULL属性则可以没有这部分存储空间。
记录数据过多

MySQL 中磁盘与内存交互的基本单位是页,一般为16KB,16384个字节,而一行记录最大可以占用65535个字节,这就造成了一页存不下一行数据的情况。在 Compact 和 Redundant 行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中,然后记录的真实数据处用20个字节存储指向这些页的地址,从而可以找到剩余数据所在的页。

分页储存示例

其他行格式

MySQL中默认的行格式就是DynamicDynamicCompressed行格式和Compact行格式很像,只是在处理行溢出数据上有差异。DynamicCompressed行格式不会在记录的真实数据出存放前768个字节,而是将所有字节都存储在其它页面中。Compressed行格式会采用压缩算法对页面进行压缩,以节省空间。

1.1.3 InnoDB 数据页结构

数据页在结构上可以划分为多个部分:

数据页结构

名称 中文名 占用空间大小 简单描述
File Header 文件头部 38字节 页的一些通用信息
Page Header 页面头部 56字节 数据页专有的一些信息
Infimum + Supremum 最小记录和最大记录 26字节 两个虚拟的行记录
User Records 用户记录 不确定 实际存储的行记录内容
Free Space 空闲空间 不确定 页中尚未使用的空间
Page Directory 页面目录 不确定 页中的某些记录的相对位置
File Trailer 文件尾部 8字节 校验页是否完整

用户自己的存储的数据会按照对应的行格式存在User Records中。实际上,新生成的页面是没有User Records的,只有当我们第一次插入数据时,才会从Free Space划一个记录大小的空间给User Records。当Free Space用完之后,就意味着当前的数据页也使用完了。

Page Directory

MySQL 使用了Page Directory(页目录)来解决查询需要遍历链表的问题(数据通过链表存储)。

大致原理如下:

  1. 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组;

  2. 每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的 n_owned 属性表示该组内共有几条记录;

  3. 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页尾部的地方,这个地方就是所谓的Page Directory

分组示例图

查找指定主键值的记录的过程分为两步:

  1. 通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的记录;
  2. 通过记录的next_record属性遍历该槽所在的组中的各个记录。

Page Header专门用来存储数据页相关的各种状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等。固定占用56个字节。

File Header

File Header用来描述各种页都适用的一些通用信息。

我们重点关注其中几个属性:

  1. FIL_PAGE_SPACE_OR_CHKSUM:当前页面的校验和(checksum)。对于很长的字节串,我们可以通过某种算法来计算一个比较短的值来代表这个很长的字节串,这个值被称为校验和。通过校验和可以大幅度提升字符串等值比较的效率;
  2. FIL_PAGE_OFFSET:每一个页都有一个唯一的页号,InnoDB通过页号来可以定位一个页;
  3. FIL_PAGE_PREVFIL_PAGE_NEXT:表示本页的上一个和下一个页的页号,形成一个双向链表。

数据页

File Trailer

MySQL 中内存和磁盘的基本交互单位是页。如果内存中页被修改,那么某个时刻一定会将内存页同步到磁盘中。如果在同步的过程中,系统出现问题,就可能导致磁盘中的页数据没能完全同步,也就是发生了脏页的情况。为了避免这种问题,每个页的尾部都加上了File Trailer来校验页的完整性。由8个字节组成:

  1. 前4个字节代表页的校验和,这个部分和 File Header 中的校验和相对应。简单理解,就是File HeaderFile Trailer都有校验和,如果两者一致则表示数据页是完整的。否则,则表示数据页是脏页
  2. 后4个字节代表页面被最后修改时对应的日志序列位置(LSN)这个部分也是为了校验页的完整性的。

1.2 MySQL 三大日志

1.2.1 binlog

binlog 是 MySQL sever 层维护的一种二进制日志,主要是记录对数据更新或潜在发生更新的 SQL 语句,并以”事务”的形式保存在磁盘中。

主要使用途径:

  1. 主从复制:replication 在 master 端开启 binlog,master 把它的二进制日志传递给 slaves 来达到 master-slave 数据一致的目的;

  2. 数据恢复:通过 binlog 工具来恢复数据。

记录格式

binlog日志有三种格式,可以通过binlog_format参数指定。

statement

指定statement,记录的内容是SQL语句原文,比如执行一条update T set update_time=now() where id=1

优点:在同步数据的时候,会直接执行对应的SQL语句。只需要记录执行语句的细节和上下文环境,避免了记录每一行的变化,在一些修改记录较多的情况下相比 ROW level 能大大减少 binlog 日志量,节约 IO,提高性能;还可以用于实时的还原;同时主从版本可以不一样,从服务器版本可以比主服务器版本高。

缺点:主从复制时,存在部分函数(如sleep)及存储过程在 slave 上会出现与 master 结果不一致的情况。以上面的语句为例,在执行的过程中会出现时间now()的问题,在同步的时获取的时间不是当时的时间。并且为了保证 SQL 语句能在 slave 上正确执行,必须记录上下文信息。

row

row格式记录的内容看不到详细信息,要通过MySQL binlog工具解析出来。

为了保证数据的一致性,通常使用 row 格式记录。但是这种格式,需要更大的容量来记录,比较占用空间,恢复与同步时会更消耗IO资源,影响执行速度。

所有的执行的语句在日志中都将以每行记录的修改细节来记录,因此,可能会产生大量的日志内容,干扰内容也较多。特别是当执行alter table 之类的语句的时候,由于表结构修改,每条记录都发生改变,那么该表每一条记录都会记录到日志中,实际等于重建了表。

statement 存储的记录

row 存储的记录

mixed

新版本的 MySQL 对 row level 做了优化,并不是所有的修改都会以 row level 记录,遇到表结构变更的时候会以 statement 模式来记录;如果 SQL 语句是 update 或 delete 等修改数据的语句,那还是会记录所有行的变更。因此,一般使用 row level 即可。

写入时机

事务执行过程中,先把日志写到binlog cache,事务提交的时候,再把binlog cache写到binlog文件中。

一个事务的binlog不能被拆开,无论这个事务多大,也要确保一次性写入,所以系统会给每个线程分配一个块内存作为binlog cache。通过binlog_cache_size参数控制单个线程 binlog cache 大小,如果存储内容超过了这个参数,就要暂存到磁盘(Swap)。

通过 write 和 fsync 分别将 binlog 写入 page cache 缓存,将缓存持久化到磁盘。

writefsync的时机,可以由参数sync_binlog控制,默认是0。为0的时候,表示每次提交事务都只write,由系统自行判断什么时候执行fsync

为了安全起见,可以设置为1,表示每次提交事务都会执行fsync,就如同 redo log 日志刷盘流程 一样。

还有一种折中方式,可以设置为N(N>1),表示每次提交事务都write,但累积N个事务后才fsync

binlog 写入时机

1.2.2 redo log

在系统宕机时,MySQL 会读取 redo log 中的内容恢复数据。

每条 redo 记录由“表空间号+数据页号+偏移量+修改数据长度+具体修改的数据”组成。

刷盘时间

redo log 原理图示

(注意:缓冲池和查询缓冲不是一个东西)

InnoDB 存储引擎为 redo log 的刷盘策略提供了 innodb_flush_log_at_trx_commit 参数,它支持三种策略:

  • 0 :设置为 0 的时候,表示每次事务提交时不进行刷盘操作;
  • 1 :设置为 1 的时候,表示每次事务提交时都将进行刷盘操作(默认值);
  • 2 :设置为 2 的时候,表示每次事务提交时都只把 redo log buffer 内容写入 page cache。

innodb_flush_log_at_trx_commit 参数默认为 1 ,也就是说当事务提交时会调用 fsync 对 redo log 进行刷盘

另外,InnoDB 存储引擎有一个后台线程,每隔1秒,就会把 redo log buffer 中的内容写到文件系统缓存(page cache),然后调用 fsync 刷盘。一个没有提交事务的 redo log 记录,也可能会刷盘。

还有一种情况,当 redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动刷盘。

日志文件组

硬盘上存储的 redo log 日志文件不只一个,而是以一个日志文件组的形式出现的,每个的redo日志文件大小都是一样的。

可以配置为一组4个文件,每个文件的大小是 1GB,整个 redo log 日志文件组可以记录4G的内容。采用环形数组形式,从头开始写,写到末尾又回到头循环写。

redo log 写入图示

日志文件组中还有两个重要的属性,分别是 write pos、checkpoint

  • write pos:当前记录的位置,一边写一边后移;
  • checkpoint:当前要擦除的位置,也是往后推移。

每次刷盘 redo log 记录到日志文件组中,write pos 位置就会后移更新。每次 MySQL 加载日志文件组恢复数据时,会清空加载过的 redo log 记录,并把 checkpoint 后移更新。

write poscheckpoint 之间的还空着的部分可以用来写入新的 redo log 记录。如果 write pos 追上 checkpoint ,表示日志文件组满了,这时候不能再写入新的 redo log 记录,MySQL 得停下来,清空一些记录,把 checkpoint 推进一下。

原理图示

为什么用 redo log

可能有人会疑惑,为什么我需要使用 redo log 来修改数据,为什么不修改的同时直接刷盘进数据页。

数据页大小是16KB,刷盘比较耗时,可能就修改了数据页里的几 Byte 数据,没有必要对数据页进行刷盘。而且数据页刷盘是随机写,因为一个数据页对应的位置可能在硬盘文件的随机位置,所以性能很差。

如果是写 redo log,一行记录可能就占几十 Byte,只包含表空间号、数据页号、磁盘文件偏移量、更新值,再加上是顺序写,所以刷盘速度很快。

redo log 形式记录修改内容,性能会远远超过刷数据页的方式,这也让数据库的并发能力更强。

1.2.3 两阶段提交

redo log(重做日志)让InnoDB存储引擎拥有了崩溃恢复能力。

binlog(归档日志)保证了MySQL集群架构的数据一致性。

这样就会出现问题,redo log 是在事务执行的过程中写入,binlog 在事务执行结束才会写入,这样会产生不一致的问题。

为了解决两份日志之间的逻辑一致问题,InnoDB存储引擎使用两阶段提交方案。将redo log的写入拆成了两个步骤preparecommit,这就是两阶段提交

使用两阶段提交后,写入binlog时发生异常也不会有影响,因为MySQL根据redo log日志恢复数据时,发现redo log还处于prepare阶段,并且没有对应binlog日志,就会回滚该事务。

日志回滚图示

1.2.4 undo log

在 MySQL 中,恢复机制是通过 回滚日志(undo log) 实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作。

如果执行过程中遇到异常的话,利用 回滚日志 中的信息将数据回滚到修改之前的样子!并且,回滚日志会先于数据持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚将之前未完成的事务。

MySQL InnoDB 引擎使用 redo log(重做日志) 保证事务的持久性,使用 undo log(回滚日志) 来保证事务的原子性

1.3 锁机制

MySQL 三类锁级别:全局锁、表级锁、行级锁。

  • 表级锁: MySQL 中锁定 粒度最大 的一种锁,对当前操作的整张表加锁,实现简单,资源消耗也比较少,加锁快,不会出现死锁。其锁定粒度最大,触发锁冲突的概率最高,并发度最低,MyISAM 和 InnoDB 引擎都支持表级锁。
  • 行级锁: MySQL 中锁定 粒度最小 的一种锁,只针对当前操作的行进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁。

1.4 MySQL 规范

1.4.1 MySQL 命名规范

  • 所有数据库对象名称必须使用小写字母并用下划线分割;
  • 所有数据库对象名称禁止使用 MySQL 保留关键字(如果表名中包含关键字查询时,需要将其用单引号括起来);
  • 数据库对象的命名要能做到见名识意,并且最好不要超过 32 个字符;
  • 临时库表必须以tmp_为前缀并以日期为后缀,备份表必须以bak_为前缀并以日期 (时间戳) 为后缀;
  • 所有存储相同数据的列名和列类型必须一致(一般作为关联列,如果查询时关联列类型不一致会自动进行数据类型隐式转换,会造成列上的索引失效,导致查询效率降低)。

1.4.2 数据规范

尽量控制单表数据大小

建议将数据量控制在500万以内,过大会造成修改表结构,备份,恢复都会有很大的问题。

可以用历史数据归档(应用于日志数据),分库分表(应用于业务数据)等手段来控制数据量大小。

禁止存储文件/图片

通常文件很大,会短时间内造成数据量快速增长,数据库进行数据库读取时,通常会进行大量的随机 IO 操作,文件很大时,IO 操作很耗时。

通常存储于文件服务器,数据库只存储文件地址信息。

避免使用 TEXT/BLOB

MySQL 内存临时表不支持 TEXT、BLOB 这样的大数据类型,如果查询中包含这样的数据,在排序等操作时,就不能使用内存临时表,必须使用磁盘临时表进行。对于这种数据,MySQL 还是要进行二次查询,会使 SQL 性能变得很差。

建议把 BLOB 或是 TEXT 列分离到单独的扩展表中,查询时一定不要使用select *而只需要取出必要的列,不需要 TEXT 列的数据时不要对该列进行查询。

MySQL 对索引字段长度是有限制的,所以 TEXT 类型只能使用前缀索引,并且 TEXT 列上是不能有默认值的。

TIMESTAMP & DATETIME

TIMESTAMP 存储的时间范围 1970-01-01 00:00:01 ~ 2038-01-19-03:14:07,TIMESTAMP 占用 4 字节和 INT 相同,但比 INT 可读性高。超出 TIMESTAMP 取值范围的使用 DATETIME 类型存储。

经常会有人用字符串存储日期型的数据(不正确的做法)

  • 缺点 1:无法用日期函数进行计算和比较;
  • 缺点 2:用字符串存储日期要占用更多的空间。

1.4.3 慢查询

MySQL 的慢查询日志是 MySQL 提供的一种日志记录,它用来记录在 MySQL 中响应时间超过阀值的语句,具体指运行时间超过long_query_time 值的 SQL,则会被记录到慢查询日志中。long_query_time 的默认值为10,意思是运行10s以上的语句。

默认情况,MySQL 数据库并不启动慢查询日志,需要手动设置这个参数。如果不是调优需要的话,一般不建议启动该参数,因为开启慢查询日志会或多或少带来一定的性能影响。慢查询日志支持将日志记录写入文件,也支持将日志记录写入数据库表。

对于慢查询,可以通过几种策略进行优化:

优化数据库结构
  1. 将字段很多的表分解成多个表,如果有些字段的使用频率很低,可以将这些字段分离出来形成新表。因为当一个表的数据量很大时,会由于使用频率低的字段的存在而变慢;
  2. 对于需要经常联合查询的表,可以建立中间表以提高查询效率。通过建立中间表,把需要经常联合查询的数据插入到中间表中,然后将原来的联合查询改为对中间表的查询,以此来提高查询效率。
分解关联查询

很多高性能的应用都会对关联查询进行分解,可以对每一个表进行一次单表查询,然后将查询结果在应用程序中进行关联,很多场景下这样会更高效。

SELECT * FROM tag 
        JOIN tag_post ON tag_id = tag.id
        JOIN post ON tag_post.post_id = post.id
        WHERE tag.tag = 'mysql';
# 分解为:
     SELECT * FROM tag WHERE tag = 'mysql';
     SELECT * FROM tag_post WHERE tag_id = 1234;
     SELECT * FROM post WHERE post.id in (123,456,567);
优化 limit 分页

对于分页查询,在数据量大的时候,会导致前面的大量数据被浪费。

SELECT
	id,
	title 
FROM
	collect 
	LIMIT 90000,
	10;
# 在查找需要数据的同时,需要查找90010的数据,前面的数据将会被浪费

方法一:先查找出主键 id 值

SELECT id, title FROM collect WHERE id >=( SELECT id FROM collect ORDER BY id LIMIT 90000, 1 ) 
LIMIT 10;

方法二:“关延迟联”

“关延迟联”让 MySQL 扫描尽可能少的页面,获取需要的记录后再根据关联列回原表查询需要的所有列。这个技术也可以用在优化关联查询中的 limit。

SELECT
	news.id,
	news.description 
FROM
	news
	INNER JOIN ( SELECT id FROM news ORDER BY title LIMIT 50000, 5 ) AS myNew USING ( id );

方法三:建立复合索引 acct_id 和 create_time

select * from acct_trans_log WHERE acct_id = 3095 order by create_time desc limit 0,10

1.5 MySQL 索引

1.5.1 explain

使用 EXPLAIN 对特定 SQL 语句进行分析,结果会出现供我们分析的信息列。

# 数据库结构和数据
-- ----------------------------
-- Table structure for book
-- ----------------------------
DROP TABLE IF EXISTS `book`;
CREATE TABLE `book`  (
  `id` bigint(0) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '对于这本书的描述/简介',
  `number` int(0) NULL DEFAULT NULL COMMENT '这本书剩余的数量',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

INSERT INTO `book`(`id`, `name`, `description`, `number`) VALUES (1, '红楼梦', '神瑛侍者和一株❀的故事', 48);
INSERT INTO `book`(`id`, `name`, `description`, `number`) VALUES (2, '西游记', '四个人向西天进发', 10);
INSERT INTO `book`(`id`, `name`, `description`, `number`) VALUES (3, '鲁迅全集', '还没有补充', 0);

# EXPLAIN 执行语句
EXPLAIN SELECT * FROM book WHERE `name` = '红楼梦';

explain 执行结果

type 字段

type 表示访问字段数据的方式,从性能好到差的排序为:

  • system,表中只有一行数据(系统表),这是 const 类型的特殊情况;
  • const,最多返回一条匹配的数据,在查询的最开始读取;
  • eq_ref,对于前面的每一行,从该表中读取一行数据;
  • ref,对于前面的每一行,从该表中读取匹配索引值的所有数据行;
  • fulltext,通过 FULLTEXT 索引查找数据;
  • ref_or_null,与 ref 类似,额外加上 NULL 值查找;
  • index_merge,使用索引合并优化技术,此时 key 列显示使用的所有索引;
  • unique_subquery,替代以下情况时的 eq_ref:value IN (SELECT primary_key FROM single_table WHERE some_expr);
  • index_subquery,与 unique_subquery 类似,用于子查询中的非唯一索引:value IN (SELECT key_column FROM
  • single_table WHERE some_expr);
  • range,使用索引查找范围值;
  • index,与 ALL 类型相同,只不过扫描的是索引;
  • ALL,全表扫描,通常表示存在性能问题。
Extra 字段

extra 通常包含额外信息,可以帮助我们处理性能问题:

  • Using where,表示将经过 WHERE 条件过滤后的数据传递给下个数据表或者返回客户端。如果访问类型为 ALL 或者 index,而 Extra 字段不是 Using where,意味着查询语句可能存在问题(除非就是想要获取全部数据);
  • Using index condition,表示通过索引访问表之前,基于查询条件中的索引字段进行一次过滤,只返回必要的索引项。这也就是索引条件下推优化;
  • Using index,表示直接通过索引即可返回所需的字段信息(index-only scan),不需要访问表。对于 InnoDB,如果通过主键获取数据,不会显示 Using index,但是仍然是 index-only scan。此时,访问类型为 index,key 字段显示为 PRIMARY;
  • Using filesort,意味着需要执行额外的排序操作,通常需要占用大量的内存或者磁盘;
  • Using temporary,意味着需要创建临时表保存中间结果。
其他字段
列名 作用
id 语句中 SELECT 的序号
如果是 UNION 操作的结果,显示为 NULL;此时 table 列显示为 <unionM,N>
select_type SELECT 的类型,包括:
- SIMPLE,不涉及 UNION 或者子查询的简单查询;
- PRIMARY,最外层 SELECT;
- UNION,UNION 中第二个或之后的 SELECT;
- DEPENDENT UNION,UNION 中第二个或之后的 SELECT,该 SELECT 依赖于外部查询;
- UNION RESULT,UNION 操作的结果;
- SUBQUERY,子查询中的第一个 SELECT;
- DEPENDENT SUBQUERY,子查询中的第一个 SELECT,该 SELECT 依赖于外部查询;
- DERIVED,派生表,即 FROM 中的子查询;
- DEPENDENT DERIVED,依赖于其他表的派生表;
- MATERIALIZED,物化子查询;
- UNCACHEABLE SUBQUERY,无法缓存结果的子查询,对于外部表中的每一行都需要重新查询;
- UNION 中第二个或之后的 SELECT,该 UNION属于 UNCACHEABLE SUBQUERY。
partitions 对于分区表而言,表示数据行所在的分区;普通表显示为 NULL
possible_keys 可能用到的索引,实际上不一定使用
key 实际使用的索引
key_len 实际使用的索引的长度
ref 用于和 key 中的索引进行比较的字段或者常量,从而判断是否返回数据行
rows 执行查询需要检查的行数,对于 InnoDB 是一个估计值。
filtered 根据查询条件过滤之后行数百分比,rows × filtered 表示进入下一步处理的行数。

1.5.2 索引底层

InnoDB 使用 B+ Tree 作为索引的底层。

对于底层的选择,对于会出现重复数据,不要选择 Hash 作为底层,否则碰撞会让效率大大降低。

并且 Hash 索引不能进行范围查询,B+ Tree 可以,因为 Hash 的索引是无序的,B+ Tree 树是有序的;Hash 也不支持模糊查询和联合索引的最左侧原则。

算法复杂度分析

1.5.3 索引类型

主键索引

Primary Key,也就是熟知的主键(主键具备索引功能,创建或设置主键的时候,MySQL 会自动添加一个与主键对应的唯一索引,不需要再做额外的添加)。

InnoDB 的表中,当没有显示的指定表的主键时,InnoDB 会自动先检查表中是否有唯一索引且不允许存在null值的字段,如果有,则选择该字段为默认的主键,否则 InnoDB 将会自动创建一个 6Byte 的自增主键row_id

主键索引示例

二级索引

二级索引又称为辅助索引,是因为二级索引的叶子节点存储的数据是主键。也就是说,通过二级索引,可以定位主键的位置。

  1. 唯一索引(Unique Key) :唯一索引也是一 种约束。唯一索引的属性列不能出现重复的数据,但是允许数据为 NULL,一张表允许创建多个唯一索引。 建立唯一索引的目的大部分时候都是为了该属性列的数据的唯一性,而不是为了查询效率;
  2. 普通索引(Index)普通索引的唯一作用就是为了快速查询数据,一张表允许创建多个普通索引,并允许数据重复和 NULL
  3. 前缀索引(Prefix) :前缀索引只适用于字符串类型的数据。前缀索引是对文本的前几个字符创建索引,相比普通索引建立的数据更小, 因为只取前几个字符;
  4. 全文索引(Full Text) :全文索引主要是为了检索大文本数据中的关键字的信息,是目前搜索引擎数据库使用的一种技术。MySQL 5.6 之前只有 MyISAM 引擎支持全文索引,5.6 之后 InnoDB 也支持了全文索引。

二级索引图示

1.5.4 索引分类

聚集索引

聚集索引即索引结构和数据一起存放的索引。

在 MySQL 中,InnoDB 引擎的表的.ibd文件就包含了该表的索引和数据,对于 InnoDB 引擎表来说,该表的索引(B+ 树)的每个非叶子节点存储索引,叶子节点存储索引和索引对应的数据。

优点:

  1. 数据访问更快,聚集索引将索引和数据保存在同一个 B+ 树中,比非聚集索引更快;
  2. 对于主键排序查找和范围查找速度非常快(因为自身有排序);
  3. 因为数据和索引一起,节省了大量的 IO 操作。

缺点:

  1. 插入速度严重依赖插入顺序,按照主键顺序插入时最快的方式,否则会出现页分裂,严重影响性能。因为,一般 InnoDB 表定义一个自增 ID 列为主键;
  2. 更新主键代价很高,一般的主键不可更新,否则会导致更新的行被移动;
  3. 二级索引访问需要两次索引查找。
非聚集索引

非聚集索引制定了表中记录的逻辑顺序,但是记录的物理和索引不一定一致(在逻辑上数据是按顺序排存放的,但是物理上在真实的存储器中是散列存放的)。

更新代价比聚集索引要小 。非聚集索引的更新代价就没有聚集索引那么大了,非聚集索引的叶子节点是不存放数据的。

优点:

两种索引都采用B+树结构,非聚集索引的叶子层并不和实际数据页相重叠,而采用叶子层包含一个指向表中的记录在数据页中的指针方式。非聚集索引层次多,不会造成数据重排。所以如果表的读操作远远多于写操作,那么就可以使用非聚集索引。

特点:

  • 聚集索引一个表只能有一个,而非聚集索引一个表可以存在多个;
  • 聚集索引存储记录是物理上连续存在,而非聚集索引是逻辑上的连续,物理存储并不连续。

缺点:

  1. 跟聚集索引一样,非聚集索引也依赖于有序的数据;
  2. 可能会二次查询(回表) :当查到索引对应的指针或主键后,可能需要根据指针或主键再到数据文件或表中查询。
覆盖索引

在非聚集索引中,可能会有二次查询的情况,但是不一定,会出现覆盖索引的情况。

SELECT id FROM table WHERE id=1;

主键索引本身的 key 就是主键,查到返回就行了。这种情况就称之为覆盖索引了。

联合索引

两个或更多个列上的索引被称作复合(联合)索引。

一个查询可以只使用索引中的一部份,但只能是最左侧部分。例如索引是 key index (a,b,c)。可以支持 a | a,b| a,b,c 3种组合进行查找,但不支持 b,c 进行查找。当最左侧字段是常量引用时,索引就十分有效。

联合索引的创建,能在某些情况下让单索引效率得到提升。

1.5.5 索引失效

如果出现以下几种情况,索引会有失效的情况:

  1. 类型不一致,隐式转换导致索引失效;
  2. 不等于(!= || <>)索引失效;
  3. is null 可以使用索引,is not null 无法使用索引;
  4. like 以通配符%开头索引失效,(页面搜索严禁左模糊或者全模糊,如果需要请走搜索引擎来解决《阿里巴巴规范手册》);
  5. OR 前后存在非索引的列,索引失效;
  6. 不同字段对应的字符集不一致。

1.5.6 索引规范

索引可以提高效率同样可以降低效率。索引可以增加查询效率,但同样也会降低插入和更新的效率,甚至有些情况下会降低查询效率。因为 MySQL 优化器在选择如何优化查询时,会根据统一信息,对每一个可以用到的索引来进行评估,以生成出一个最好的执行计划,如果同时有很多个索引都可以用于查询,就会增加 MySQL 优化器生成执行计划的时间,同样会降低查询性能。

对于索引的数量不建议超过五个。

对于索引的建议列:

  • 出现在 SELECT、UPDATE、DELETE 语句的 WHERE 从句中的列;
  • 包含在 ORDER BY、GROUP BY、DISTINCT 中的字段;
  • 并不要将符合 1 和 2 中的字段的列都建立一个索引, 通常将 1、2 中的字段建立联合索引效果更好;
  • 使用列类型小的创建索引;
  • 多表 join 的关联列(连接表的数量尽量不要超过3张,因为每增加一次表就相当于多了一层循环)。

避免使用双%号的查询条件。如:a like '%123%',(如果无前置%,只有后置%,是可以用到列上的索引的)。

1.6 MySQL 流程

  • 连接器: 身份认证和权限相关(登录 MySQL);
  • 查询缓存: 执行查询语句的时候,会先查询缓存(MySQL 8.0 版本后移除,因为这个功能不太实用);
  • 分析器: 没有命中缓存的话,SQL 语句就会经过分析器,分析器说白了就是要先看你的 SQL 语句要干嘛,再检查你的 SQL 语句语法是否正确;
  • 优化器: 按照 MySQL 认为最优的方案去执行。

流程示例图

MySQL 主要分为 Server 层和存储引擎层:

  • Server 层:主要包括连接器、查询缓存、分析器、优化器、执行器等,所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图,函数等,还有一个通用的日志模块 binlog 日志模块;
  • 存储引擎: 主要负责数据的存储和读取,采用可以替换的插件式架构,支持 InnoDB、MyISAM、Memory 等多个存储引擎,其中 InnoDB 引擎有自有的日志模块 redolog 模块。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始就被当做默认存储引擎了。

2. Redis

简单来说Redis 就是一个使用 C 语言开发的非关系型数据库,不过与传统数据库不同的是Redis 的数据是存在内存中的,读写速度非常快,因此被广泛应用于缓存方向。

另外,Redis 除了做缓存之外,也经常用来做分布式锁,甚至是消息队列Redis 提供了多种数据类型来支持不同的业务场景,支持事务 、持久化、Lua 脚本、多种集群方案

2.1 Redis 线程

Redis 基于 Reactor 模式来设计开发了自己的一套高效的事件处理模型 (Netty 的线程模型也基于 Reactor 模式)。由于文件事件处理器(file event handler)是单线程方式运行的,所以我们一般都说 Redis 是单线程模型。

Redis 通过IO 多路复用程序 来监听来自客户端的连接(或者说是监听多个 socket),根据套接字目前执行的任务来为套接字关联不同的事件处理器。 I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗

虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。

为什么 Redis 这么快?:

  • 完全基于内存,数据存在内存中,绝大部分请求是纯粹的内存操作,避免了通过磁盘 IO 读取到内存的开销;
  • 数据结构简单,对数据操作也简单。Redis 中的数据结构是专门进行设计的,每种数据结构都有一种或多种数据结构来支持。Redis 依赖这些灵活的数据结构,来提升读写性能;
  • 采用单线程,省去了很多上下文切换的时间以及 CPU 消耗,不存在竞争条件,不用去考虑各种锁的问题,不存在加锁释放锁操作,也不会出现死锁而导致的性能消耗;
  • 使用基于 IO 多路复用机制的线程模型,处理并发链接;
  • Redis 直接自己构建了 VM 机制 ,避免调用系统函数的时候,浪费时间去移动和请求。

处理流程图示

2.1.1 Redis “单线程”

Redis 的单线程,指的是网络请求模块使用一个线程来处理,即一个线程处理所有网络请求,其他模块仍用了多个线程。

CPU 不是 Redis 的瓶颈,Redis 的瓶颈最有可能是机器内存或者网络带宽。单线程容易实现,所以最开始采用单线程策略。使用单线程的方式无法发挥多核 CPU 性能,可以通过在单机开多个 Redis 实例来解决这个问题。

Redis 在 4.0 之后的版本中已经加入对多线程的支持。Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主处理之外的其他线程来“异步处理”。大体上来说,Redis 6.0 之前主要还是单线程处理。

多线程模型虽然在某些方面表现优异,却引入程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。

2.1.2 Redis “多线程”

为了处理网络 I/O 模块带来的 CPU 耗时,Redis 6.0 引入多线程,减少网络I/O阻塞带来的性能损耗。

默认情况下 Redis 是关闭多线程的,可以在 conf 文件进行配置开启:

io-threads-do-reads yes

io-threads # 线程数

## 官方建议的线程数设置:4核的机器建议设置为2或3个线程,8核的建议设置为6个线程,线程数一定要小于机器核数,尽量不超过8个。

Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行,也就不存在并发安全问题。

2.2 持久化

Redis 提供两种持久化方式(将内存中的数据存入磁盘,防止数据丢失)。

2.2.1 RDB

在指定的时间间隔内将内存中的数据集快照写入磁盘, 也就是生成 Snapshot 快照,恢复时将快照文件直接读入内存中。

在 redis.conf 中配置文件名称,默认为 dump.rdb,默认的位置是 Redis 的安装目录。

Redis 单独创建(fork)一个子进程进行持久化,首先将数据写入一个临时文件中,待持久化过程都结束了,再用这个临时文件替换上次持久化好的文件。

整个过程中,主进程不进行任何 IO 操作,确保了极高的性能。如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感,RDB 方式比 AOF 方式更加高效。RDB 的缺点是最后一次持久化后的数据可能丢失

流程图示

默认保存策略 config

在以下情况,RDB 会被触发:

  • save :save 时只进行保存,全部阻塞。手动保存的选择,不建议使用;

  • bgsave:Redis 会在后台异步进行快照操作,快照同时还可以响应客户端请求;

  • 可以通过 lastsave 命令获取最后一次成功执行快照的时间;

  • flushall 也会执行,不过生成的文件为空,没有意义。

优势:

  • 适合大规模的数据恢复;

  • 对数据完整性和一致性要求不高更适合使用;

  • 节省磁盘空间;

  • 恢复速度快。

劣势:

  • Fork 的时候,内存中的数据被克隆了一份,大致 2 倍的膨胀性需要考虑;

  • 虽然 Redis 在 fork 时使用了写时拷贝技术,但是如果数据庞大时还是比较消耗性能;

  • 在备份周期在一定间隔时间做一次备份,所以如果 Redis 意外 down 掉,就会丢失最后一次快照后的所有修改。

2.2.2 AOF

鉴于 RDB 的劣势,AOF 已经成为主流的方式,默认没有开启。

以日志的形式来记录每个写操作(增量保存),将 Redis 执行过的所有写指令记录下来 (读操作不记录), 只许追加文件但不可以改写文件,redis 启动之初会读取该文件重新构建数据。换言之,redis 重启根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。

运行流程示例

正常恢复:

  • 修改默认的 appendonly no,改为 yes;
  • 将有数据的 aof 文件复制一份保存到对应目录 (查看目录:config get dir);
  • 恢复:重启 redis 然后重新加载。
# 设置备份频率
appendfsync always    # 每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
appendfsync everysec  # 每秒钟同步一次,显式地将多个写命令同步到硬盘
appendfsync no        # 让操作系统决定何时进行同步

AOF 采用文件追加方式,文件会越来越大为避免出现此种情况,新增了重写机制,当 AOF 文件的大小超过所设定的阈值时,Redis 就会启动 AOF 文件的内容压缩,只保留可以恢复数据的最小指令集,可以使用命令 bgrewriteaof。

AOF 文件持续增长而过大时,会 fork 出一条新进程来将文件重写 (也是先写临时文件最后再 rename),Redis 4.0 版本后的重写,是指把 rdb 的快照,以二进制的形式附在新的 aof 头部,作为已有的历史数据,替换掉原来的流水账操作,节省磁盘。

AOF文件重写并不需要对现有的AOF文件进行任何读取、分享和写入操作,而是通过读取服务器当前的数据库状态来实现

Redis 会记录上次重写时的 AOF 大小,默认配置是当 AOF 文件大小是上次 rewrite 后大小的一倍且文件大于 64M 时触发。

auto-aof-rewrite-percentage:100% 
# 设置重写的基准值,文件是原来重写后文件的 2 倍时触发。

auto-aof-rewrite-min-size:64
# 设置重写的基准值,最小文件 64MB。达到这个值开始重写。

# 系统载入时或者上次重写完毕时,Redis 会记录此时 AOF 大小,设为 base_size,
# 如果 Redis 的 AOF 当前大小 >= base_size + base_size * 100% (默认) 
# 且当前大小 >=64mb (默认) 的情况下,Redis 会对 AOF 进行重写。

重写流程图示

优势:

  • 备份机制更稳健,丢失数据概率更低;

  • 可读的日志文本,通过操作 AOF 稳健,可以处理误操作。

劣势:

  • 比起 RDB 占用更多的磁盘空间;

  • 恢复备份速度要慢;

  • 每次读写都同步的话,有一定的性能压力;

  • 存在个别 Bug,造成恢复不能。

2.2.3 官方建议

Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。

如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。

这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。缺点在于 AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。

性能建议:

  • RDB 文件只用作后备用途,建议只在 Slave 上持久化 RDB 文件,15分钟备份一次就够了,只保留 save 9001 这条规则;
  • 如果使用 AOF,好处是在最恶劣情况下也只会丢失不超过两秒数据,启动脚本较简单,只 load 自己的 AOF 文件就可以了;
  • AOF 代价:
    • 是带来了持续的 IO;
    • AOF rewrite 的最后,将 rewrite 过程中产生的新数据写到新文件造成的阻塞几乎是不可避免的。
  • 只要硬盘许可,应该尽量减少 AOF rewrite 的频率,AOF 重写的基础大小可以设到 5G 以上。默认超过原大小100%大小时重写可以改到适当的数值。

2.3 内存淘汰

Redis 通过过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key,过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。

typedef struct redisDb {
    ...
    dict *dict;     // 数据库键空间,保存着数据库中所有键值对
    dict *expires;   // 过期字典,保存着键的过期时间
    ...
} redisDb;
// 过期字典数据结构

2.3.1 删除策略

  1. 惰性删除:只在取出 key 的时候对数据进行过期检查。对 CPU 最友好,但是可能会造成太多过期 key 没有被删除,增加内存负担。
  2. 定期删除: 每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。

Redis 采用惰性删除+定期删除的方式。

2.3.2 淘汰策略

早期 Redis 六种淘汰策略:

  1. noeviction:不淘汰任何数据,当内存不足时,新增操作会报错,Redis 默认内存淘汰策略;
  2. allkeys-lru:淘汰整个键值中最久未使用的键值;
  3. allkeys-random:随机淘汰任意键值;
  4. volatile-lru:淘汰所有设置了过期时间的键值中最久未使用的键值(使用 LRU 算法淘汰过期键值);
  5. volatile-random:随机淘汰设置了过期时间的任意键值;
  6. volatile-ttl:优先淘汰更早过期的键值。

Redis 4.0 后增加两种:

  1. volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰;
  2. allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key。

LRU 和 LFU 算法:

  • LRU(Least Recently Used):淘汰很久没被访问过的数据,以最近一次访问时间作为参考;
  • LFU(Least Frequently Used):淘汰最近一段时间被访问次数最少的数据,以次数作为参考。

大部分情况使用 LRU 算法,如果存在热点数据,使用 LFU 算法相关策略可能会更好。

2.4 常见问题

2.4.1 缓存穿透

大量请求的 key 根本不存在于缓存中,导致请求直接查询数据库,越过缓存。

解决方法:

  1. 对每一个查不到的 key,都在 Redis 中进行缓存。如果会对不同 key 进行请求,依然无法从根本上解决问题;

  2. 对于一些具有特定格式 key,可以使用规则进行过滤;

  3. 布隆过滤器:

    • 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值);

    • 根据得到的哈希值,在位数组中把对应下标的值置为1。

    布隆过滤器存在误判情况:布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在

    原理图示

    2.4.2 缓存雪崩

缓存在同一时间大面积的失效,后面的请求都直接落到了数据库上,造成数据库短时间内承受大量请求

有一些被大量访问数据(热点缓存)在某一时刻大面积失效,导致对应的请求直接落到了数据库上

针对 Redis 服务不可用的情况:

  1. 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用;
  2. 限流,避免同时处理大量的请求。

针对热点缓存失效的情况:

  1. 分析用户行为,尽量让失效时间点均匀分布。避免缓存雪崩的出现;
  2. 采用加锁计数,或者使用合理的队列数量来避免缓存失效时对数据库造成太大的压力。这种办法虽然能缓解数据库的压力,但是同时又降低了系统的吞吐量;
  3. 如果是因为某台缓存服务器宕机,可以考虑做主备,比如:redis主备,但是双缓存涉及到更新事务的问题,update可能读到脏数据,需要好好解决;
  4. 缓存永不失效。

2.5 Redis 事务

Redis 通过 MULTI、EXEC、WATCH 等命令来实现事务(transaction)功能。

  • 单独的隔离操作 :事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断;

  • 没有隔离级别的概念 :队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行。Redis 本身也是单线程处理。所以就不会产生我们使用关系型数据库需要关注的脏读,幻读,重复读的问题;

  • 不保证原子性 :事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚 (这点跟 MySQL 区别很大)。

  • 持久化具有一定缺陷:

    • 在单纯的内存模式下,事务肯定是不持久的;
    • 在 RDB 模式下,服务器可能在事务执行之后、RDB 文件更新之前的这段时间失败,所以 RDB 模式下的 Redis 事务也是不持久的;
    • 在 AOF 的总是SYNC模式下,事务的每条命令在执行成功之后,都会立即调用 fsync 或 fdatasync 将事务数据写入到 AOF 文件。但是,这种保存是由后台线程进行的,主线程不会阻塞直到保存成功,所以从命令执行成功到数据保存到硬盘之间,还是有一段非常小的间隔,所以这种模式下的事务也是不持久的。
# MULTI 开启事务
redis> MULTI
OK
# 事务开启之后,执行的命令添加到事务队列中
redis> SET "name" "Practical Common Lisp"
QUEUED
redis> GET "name"
QUEUED
redis> SET "author" "Peter Seibel"
QUEUED
redis> GET "author"
QUEUED
# EXEC 执行事务
redis> EXEC
1) OK
2) "Practical Common Lisp"
3) OK
4) "Peter Seibel"

WATCH 提供乐观锁,针对某一个键,如果在事务队列中,检测出被修改,事务执行失败。

Redis 事务并不支持回滚功能,导致 Redis 不保证原子性。Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面。换而言之,从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。

因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。

2.6 主从复制

主机数据更新后根据配置和策略, 自动同步到备机的 master/slave 机制,Master 以写为主,Slave 以读为主,主从复制节点间数据是全量的。实现读写分离,减轻服务器的压力。

在另一个 Redis 实例中使用 SLAVEOF 命令,就会成为别人的从机。

2.6.1 复制

主从节点通过复制的操作,保证两者数据统一。在 Redis 2.8 之后,对原先的复制操作进行了优化。

复制的过程中,Redis 存在心跳检测,在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令。

老复制

Redis的复制功能分为同步(sync)和命令传播(command propagate)两个操作。

当客户端向从服务器发送SLAVEOF命令,要求从服务器复制主服务器时,从服务器首先需要执行同步操作。

收到 SYNC 命令的主服务器执行 BGSAVE 命令,在后台生成一个 RDB 文件,并使用一个缓冲区记录从现在开始执行的所有写命令。当主服务器的 BGSAVE 命令执行完毕时,主服务器会将 BGSAVE 命令生成的 RDB 文件发送给从服务器,从服务器接收并载入这个 RDB 文件。

sync 执行图示

在主服务器数据被修改时,为了让主从服务器再次回到一致状态,会执行命令传播。主服务器会将自己执行的写命令,也即是造成主从服务器不一致的那条写命令,发送给从服务器执行,当从服务器执行了相同的写命令之后,主从服务器将再次回到一致状态。

缺点:如果主服务器断线,从服务器通过自动重连接重新连上了主服务器,并继续复制主服务器。如果相差的数据只有一部分,整个复制的策略就显得十分低效(SYNC 命令是一个非常耗费资源的操作)。

新复制

Redis 2.8 开始,使用 PSYNC 命令代替 SYNC 命令来执行复制时的同步操作。

初次复制的情况和 SYNC 基本一致,都是完整重同步。

部分重同步则用于处理断线后重复制情况,当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态。

PSYNC 示例

PSYNC 执行步骤

复制偏移量

执行复制的双方——主服务器和从服务器会分别维护一个复制偏移量,主服务器每次向从服务器传播N个字节的数据时,就将自己的复制偏移量的值加上N,从服务器每次收到主服务器传播来的N个字节的数据时,就将自己的复制偏移量的值加上N。

偏移量图示

如果主从服务器处于一致状态,那么主从服务器两者的偏移量总是相同的;如果主从服务器两者的偏移量并不相同,那么说明主从服务器并未处于一致状态。

复制积压缓冲区

执行部分重同步的话,主服务器通过复制积压缓冲区补偿从服务器A在断线期间丢失部分的数据。

复制积压缓冲区是由主服务器维护的一个固定长度(fixed-size)先进先出(FIFO)队列,默认大小为 1MB。可以根据需要调整复制积压缓冲区的大小。为了安全起见,可以将复制积压缓冲区的大小设为2 * second * write_size_per_second,这样可以保证绝大部分断线情况都能用部分重同步来处理。

如果 offset 偏移量之后的数据已经不存在于复制积压缓冲区,那么主服务器将对从服务器执行完整重同步操作。

服务器运行 ID

每个 Redis 服务器,不论主服务器还是从服务,都会有自己的运行 ID(RID)。运行 ID 在服务器启动时自动生成,由40个随机的十六进制字符组成。

如果从服务器保存的运行 ID 和当前连接的主服务器的运行 ID 并不相同,那么说明从服务器断线之前复制的主服务器并不是当前连接的这个主服务器,主服务器将对从服务器执行完整重同步操作。

2.6.2 哨兵模式

由一个或多个 Sentinel 实例(instance)组成的 Sentinel 系统(system)可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。

当一个 master 宕机后,后面的 slave 可以立刻升为 master,其后面的 slave 不用做任何修改。用 slaveof no one 指令将从机变为主机。哨兵模式能够后台监控主机是否故障,如果故障了根据投票数自动将从库转换为主库。

服务器与 Sentinel 系统

为了不丢失__sentinel__:hello频道的信息,Sentinel 必须专门用一个订阅连接来接收该频道的信息。

除了订阅频道之外,Sentinel 还必须向主服务器发送命令,与主服务器进行通信,所以 Sentinel 还必须向主服务器创建命令连接。

Sentinel 需要与多个实例创建多个网络连接,所以Sentinel使用的是异步连接。

双连接

2.6.3 Redis 集群

Redis 的哨兵模式基本已经可以实现高可用,读写分离 ,但是哨兵模式下每台 Redis 服务器都存储相同的数据,浪费内存。在 Redis 3.0上加入了 Cluster 集群模式,实现了 Redis 的分布式存储(每台 Redis 节点上存储不同的内容)。

客户端可以跟任意的节点进行连接,将数据存储在不同的节点位置。

image-20200531184321294

Redis 本身是使用 Hash 进行数据的存储,Cluster 集群模式中引入哈希槽(hash slot)。

Redis 集群有16384个哈希槽,每个 key 通过 CRC16 校验后对16384取模来决定放置哪个槽。集群的每个节点负责一部分 hash 槽。

如果当前集群有三个节点:

  • 节点 A 包含0到5460号哈希槽;
  • 节点 B 包含5461到10922号哈希槽;
  • 节点 C 包含10923到16383号哈希槽。

在 Redis 的每一个节点上,都有这么两个东西,一个是插槽(slot),它的的取值范围是:0-16383。还有一个就是 cluster,可以理解为是一个集群管理的插件。当我们的存取的 Key 到达的时候,Redis 会根据 CRC16 的算法得出一个结果,然后把结果对16384求余数,这样每个 key 都会对应一个编号在0-16383之间的哈希槽。

  • 所有的 redis 节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽;
  • 节点的 fail 是通过集群中超过半数的节点检测失效时才生效;
  • 客户端与 Redis 节点直连,不需要中间代理层.客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可。

2.7 双写一致性

在业务的使用中,我们需要保证缓存和数据库保持一致,因此会指定一系列相关策略。

2.7.1 更新/删除缓存

淘汰 cache

优点:操作简单,无论更新操作是否复杂,直接将缓存中的旧值淘汰;

缺点:淘汰 cache 后,下一次查询无法在 cache 中查到,会有一次 cache miss,这时需要重新读取数据库。

更新 cache

更新 chache 的意思就是将更新操作也放到缓冲中执行,并不是数据库中的值更新后再将最新值传到缓存

优点:命中率高,直接更新缓存,不会有 cache miss 的情况;

缺点:更新 cache 消耗较大

对于更新情况复杂的缓存,考虑到开销问题,更推荐删除缓存。

缓存/数据库

不管是选择先更新缓存还是先更新数据库,都会出现“ABBA”问题,即两个线程同时修改出现的脏读问题。

对同一个数据的修改,要以串行化的方式先后执行。

2.7.2 执行顺序

对于淘汰缓存,依然会存在各种问题,需要通过消息队列等其他手段进行优化。

淘汰缓存,更新数据库

在并发量较大的情况下,采用异步更新缓存的策略:

  1. A 线程进行写操作,淘汰缓存,但由于网络或其它原因,还未更新数据库或正在更新;  
  2. B 线程进行读操作,此时缓存已经被淘汰,从数据库中读取数据,但 B 线程只从数据库中读取数据,并不将这个数据放入缓存中,不会导致缓存与数据库的不一致;
  3. A 线程更新数据库后,通过订阅 binlog 来异步更新缓存。

可以下面方法进行优化:

  1. 串行化:

    保证对同一个数据的读写严格按照先后顺序串行化进行,避免并发时,多个线程同时对同一数据进行操作时带来的数据不一致性;

  2. 延时双删+设置缓存:

    在 A 线程更新完数据之后,休眠 M 秒(时间需要大于业务中读取数据库的时间),之后再次进行缓存淘汰。需要引入“重试”机制防止淘汰失败。

针对双删导致的效率问题,可以使用“异步淘汰”的策略,将休眠M秒以及二次淘汰放在另一个线程中,A线程在更新完数据库后,可以直接返回成功而不用等待。

异步淘汰 图示

更新数据库,淘汰缓存

可能会出现数据库未更新或者缓存没有及时清除,导致的不一致情况。

推荐使用这种策略:

  • 如果先行淘汰缓存,可能会出现缓存穿透问题,导致大量请求直接访问数据库,造成负担;
  • 延时双删的具体业务时间难以掌握。

参考文章

  1. JavaGuide
  2. MySQL 存储引擎 InnoDB 详解
  3. MySQL 行格式选择
  4. MySQL InnoDB 锁算法
  5. MySQL 执行计划
  6. MySQL 字符集不一致导致索引失效
  7. 深入解析 MySQL binlog
  8. binlog 详解
  9. MySQL 数据库教程天花板
  10. MySQL 聚集索引和非聚集索引
  11. MySQL 主键还需要建立索引吗
  12. MySQL 联合索引
  13. 常见 MySQL 的慢查询优化方式
  14. MySQL 中 USING 用法
  15. 一文揭秘单线程的 Redis 为什么这么快
  16. 《面试八股文》之 Redis 16卷
  17. Redis 持久化机制
  18. Redis 学习核心实战
  19. 缓存雪崩,缓存穿透解决方案
  20. Redis 事务为什么不支持回滚
  21. Redis 设计与实现
  22. 如何保证缓存与数据库的一致性
  23. Redis 的三种集群模式
  24. CRC-8 和 CRC-16 算法

文章作者: 陈鑫扬
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 陈鑫扬 !
评论
  目录