执行一条 select 语句,期间发生了什么?
MySQL 执行流程是怎样的?
- MySQL 的架构共分为两层:Server 层和存储引擎层。
- Server 层负责建立连接、分析和执行 SQL。MySQL 大多数的核心功能模块都在这实现,主要包括连接器,查询缓存、解析器、预处理器、优化器、执行器等。
- 存储引擎层负责数据的存储和提取。支持 InnoDB、MyISAM、Memory 等多个存储引擎,不同的存储引擎共用一个 Server 层。现在最常用的存储引擎是 InnoDB,从 MySQL 5.5 版本开始, InnoDB 成为了 MySQL 的默认存储引擎。
第一步:连接器
- 连接器的工作主要如下:
- MySQL是基于TCP协议进行传输的,需要与客户端进行 TCP 三次握手建立连接;
- 校验客户端的用户名和密码,如果用户名或密码不对,则会报错;
- 如果用户名和密码都对了,会读取该用户的权限,然后后面的权限逻辑判断都基于此时读取到的权限;
-
查看MySQL服务被多少客户端连接了?
- 执行
show processlist
命令进行查看。
- 执行
-
空闲连接会一直占用着吗?
- MySQL 定义了空闲连接的最大空闲时长,由
wait_timeout
参数控制的,默认值是 8 小时(28880秒)。
- MySQL 定义了空闲连接的最大空闲时长,由
-
MySQL 的连接数有限制吗?
-
MySQL的连接和HTTP一样,也有长连接和短链接的概念。
// 短连接 连接 mysql 服务(TCP 三次握手) 执行sql 断开 mysql 服务(TCP 四次挥手) // 长连接 连接 mysql 服务(TCP 三次握手) 执行sql 执行sql 执行sql .... 断开 mysql 服务(TCP 四次挥手)
-
MySQL 服务支持的最大连接数由 max_connections 参数控制,比如我的 MySQL 服务默认是 151 个,超过这个值,系统就会拒绝接下来的连接请求,并报错提示“Too many connections”。
-
-
怎么解决长连接占用内存的问题?
- 第一种,定期断开长连接。既然断开连接后就会释放连接占用的内存资源,那么我们可以定期断开长连接。
- 第二种,客户端主动重置连接。MySQL 5.7 版本实现了
mysql_reset_connection()
函数的接口,注意这是接口函数不是命令,那么当客户端执行了一个很大的操作后,在代码里调用 mysql_reset_connection 函数来重置连接,达到释放内存的效果。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。
第二步:查询缓存
- 连接器的工作完成后,客户端就可以向 MySQL 服务发送 SQL 语句了,MySQL 服务收到 SQL 语句后,就会解析出 SQL 语句的第一个字段,看看是什么类型的语句。
- 如果 SQL 是查询语句(select 语句),MySQL 就会先去查询缓存( Query Cache )里查找缓存数据,看看之前有没有执行过这一条命令,这个查询缓存是以 key-value 形式保存在内存中的,key 为 SQL 查询语句,value 为 SQL 语句查询的结果。如果查询的语句命中查询缓存,那么就会直接返回 value 给客户端。如果查询的语句没有命中查询缓存中,那么就要往下继续执行,等执行完后,查询的结果就会被存入查询缓存中。
- 对于更新比较频繁的表,查询缓存的命中率很低的,因为只要一个表有更新操作,那么这个表的查询缓存就会被清空。所以,MySQL 8.0 版本直接将查询缓存删掉了。
第三步:解析器 解析SQL
- 第一件事情,词法分析。MySQL 会根据你输入的字符串识别出关键字出来,构建出 SQL 语法树,这样方便后面模块获取 SQL 类型、表名、字段名、 where 条件等等。
- 第二件事情,语法分析。根据词法分析的结果,语法解析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法。
第四步:执行 SQL
-
经过解析器后,接着就要进入执行 SQL 查询语句的流程了,每条
SELECT
查询语句流程主要可以分为下面这三个阶段:-
prepare 阶段,也就是预处理阶段;
-
optimize 阶段,也就是优化阶段;
-
execute 阶段,也就是执行阶段;
-
-
预处理器
- 检查 SQL 查询语句中的表或者字段是否存在;
- 将
select *
中的*
符号,扩展为表上的所有列;(SELECT * 意为查询所有列)
-
优化器
- 负责将 SQL 查询语句的执行方案确定下来,选择查询成本最小的执行计划,比如在表里面有多个索引的时候,优化器会基于查询成本的考虑,来决定选择使用哪个索引。如果想要知道优化器选择了哪个索引,我们可以在查询语句最前面加个
explain
命令,这样就会输出这条 SQL 语句的执行计划。
- 负责将 SQL 查询语句的执行方案确定下来,选择查询成本最小的执行计划,比如在表里面有多个索引的时候,优化器会基于查询成本的考虑,来决定选择使用哪个索引。如果想要知道优化器选择了哪个索引,我们可以在查询语句最前面加个
-
执行器
-
根据执行计划执行 SQL 查询语句,从存储引擎读取记录,返回给客户端;在执行的过程中,执行器就会和存储引擎交互了,交互是以记录为单位的。
-
用三种方式执行过程,体现执行器和存储引擎的交互过程:
-
主键索引查询
select * from product where id = 1;
- 执行器第一次查询,会调用 read_first_record 函数指针指向的函数,因为优化器选择的访问类型为 const,这个函数指针被指向为 InnoDB 引擎索引查询的接口,把条件
id = 1
交给存储引擎,让存储引擎定位符合条件的第一条记录。 - 存储引擎通过主键索引的 B+ 树结构定位到 id = 1的第一条记录,如果记录是不存在的,就会向执行器上报记录找不到的错误,然后查询结束。如果记录是存在的,就会将记录返回给执行器;
- 执行器从存储引擎读到记录后,接着判断记录是否符合查询条件,如果符合则发送给客户端,如果不符合则跳过该记录。
- 执行器查询的过程是一个 while 循环,所以还会再查一次,但是这次因为不是第一次查询了,所以会调用 read_record 函数指针指向的函数,因为优化器选择的访问类型为 const,这个函数指针被指向为一个永远返回 - 1 的函数,所以当调用该函数的时候,执行器就退出循环,也就是结束查询了。
- 执行器第一次查询,会调用 read_first_record 函数指针指向的函数,因为优化器选择的访问类型为 const,这个函数指针被指向为 InnoDB 引擎索引查询的接口,把条件
-
全表扫描
select * from product where name = 'iphone';
- 执行器第一次查询,会调用 read_first_record 函数指针指向的函数,因为优化器选择的访问类型为 all,这个函数指针被指向为 InnoDB 引擎全扫描的接口,让存储引擎读取表中的第一条记录;
- 执行器会判断读到的这条记录的 name 是不是 iphone,如果不是则跳过;如果是则将记录发给客户的(是的没错,Server 层每从存储引擎读到一条记录就会发送给客户端,之所以客户端显示的时候是直接显示所有记录的,是因为客户端是等查询语句查询完成后,才会显示出所有的记录)。
- 执行器查询的过程是一个 while 循环,所以还会再查一次,会调用 read_record 函数指针指向的函数,因为优化器选择的访问类型为 all,read_record 函数指针指向的还是 InnoDB 引擎全扫描的接口,所以接着向存储引擎层要求继续读刚才那条记录的下一条记录,存储引擎把下一条记录取出后就将其返回给执行器(Server层),执行器继续判断条件,不符合查询条件即跳过该记录,否则发送到客户端;
- 一直重复上述过程,直到存储引擎把表中的所有记录读完,向执行器(Server层) 返回了读取完毕的信息;
- 执行器收到存储引擎报告的查询完毕的信息,退出循环,停止查询。
-
索引下推
索引下推能够减少二级索引在查询时的回表操作,提高查询的效率,因为它将 Server 层部分负责的事情,交给存储引擎层去处理了。
select * from t_user where age > 20 and reward = 100000;
联合索引当遇到范围查询 (>、<) 就会停止匹配,也就是 age 字段能用到联合索引,但是 reward 字段则无法利用到索引。
不使用索引下推优化:
- Server 层首先调用存储引擎的接口定位到满足查询条件的第一条二级索引记录,也就是定位到 age > 20 的第一条记录;
- 存储引擎根据二级索引的 B+ 树快速定位到这条记录后,获取主键值,然后进行回表操作,将完整的记录返回给 Server 层;
- Server 层在判断该记录的 reward 是否等于 100000,如果成立则将其发送给客户端;否则跳过该记录;
- 接着,继续向存储引擎索要下一条记录,存储引擎在二级索引定位到记录后,获取主键值,然后回表操作,将完整的记录返回给 Server 层;
- 如此往复,直到存储引擎把表中的所有记录读完。
使用索引下推优化:
- Server 层首先调用存储引擎的接口定位到满足查询条件的第一条二级索引记录,也就是定位到 age > 20 的第一条记录;
- 存储引擎定位到二级索引后,先不执行回表操作,而是先判断一下该索引中包含的列(reward列)的条件(reward 是否等于 100000)是否成立。如果条件不成立,则直接跳过该二级索引。如果成立,则执行回表操作,将完成记录返回给 Server 层。
- Server 层在判断其他的查询条件(本次查询没有其他条件)是否成立,如果成立则将其发送给客户端;否则跳过该记录,然后向存储引擎索要下一条记录。
- 如此往复,直到存储引擎把表中的所有记录读完。
-
-
MySQL 一行记录是怎么存储的?
MySQL 的数据存放在哪个文件?
mysql> SHOW VARIABLES LIKE 'datadir';
+---------------+-----------------+
| Variable_name | Value |
+---------------+-----------------+
| datadir | /var/lib/mysql/ |
+---------------+-----------------+
1 row in set (0.00 sec)
- 进入/var/lib/mysql/my_test目录,创建一个t_order表,会随之创建三个文件:
db.opt
,用来存储当前数据库的默认字符集和字符校验规则。t_order.frm
,t_order 的表结构会保存在这个文件。在 MySQL 中建立一张表都会生成一个.frm 文件,该文件是用来保存每个表的元数据信息的,主要包含表结构定义。t_order.ibd
,t_order 的表数据会保存在这个文件。表数据既可以存在共享表空间文件(文件名:ibdata1)里,也可以存放在独占表空间文件(文件名:表名字.ibd)。- 参数 innodb_file_per_table 控制表数据存在哪里,从 MySQL 5.6.6 版本开始,它的默认值就是 1 了,那么会将存储的数据、索引等信息单独存储在一个独占表空间(.ibd 文件)。
表空间文件的结构是怎么样的?
表空间由段(segment)、区(extent)、页(page)、行(row)组成。
- 行:数据库表中的记录都是按行(row)进行存放的,每行记录根据不同的行格式,有不同的存储结构。
- 页:InnoDB 的数据是按「页」为单位来读写的,也就是说,当需要读一条记录的时候,并不是将这个行记录从磁盘读出来,而是以页为单位,将其整体读入内存。默认每个页的大小为 16KB,也就是最多能保证 16KB 的连续存储空间。
- 区:B + 树每一层都是通过双向链表连接的,如果只使用页来存储空间,可能链表中相邻的两个页之间物理位置并不是连续的。在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区(extent)为单位分配。每个区的大小为 1MB,对于 16KB 的页来说,连续的 64 个页会被划为一个区,这样就使得链表中相邻的页的物理位置也相邻,就能使用顺序 I/O 了。
- 段:表空间是由各个段(segment)组成的,段是由多个区(extent)组成的。段一般分为数据段、索引段和回滚段等。
- 索引段:存放 B + 树的非叶子节点的区的集合;
- 数据段:存放 B + 树的叶子节点的区的集合;
- 回滚段:存放的是回滚数据的区的集合。
InnoDB行格式有哪些?
InnoDB 提供了 4 种行格式,分别是 Redundant、Compact(紧凑)、Dynamic和 Compressed 行格式。
- Redundant 是很古老的行格式了, MySQL 5.0 版本之前用的行格式。
- 由于 Redundant 不是一种紧凑的行格式,所以 MySQL 5.0 之后引入了 Compact 行记录存储方式,Compact 是一种紧凑的行格式,设计的初衷就是为了让一个数据页中可以存放更多的行记录,从 MySQL 5.1 版本之后,行格式默认设置成 Compact。
- Dynamic 和 Compressed 两个都是紧凑的行格式,它们的行格式都和 Compact 差不多,因为都是基于 Compact 改进一点东西。从 MySQL5.7 版本之后,默认使用 Dynamic 行格式。
COMPACT行格式长什么样?
-
记录的额外信息
-
其中包含 3 个部分:变长字段长度列表、NULL 值列表、记录头信息。
- 变长字段长度列表:在存储数据的时候,也要把数据占用的大小存到「变长字段长度列表」里面,读取数据的时候才能根据这个「变长字段长度列表」去读取对应长度的数据。
- **变长字段长度列表、NULL值列表的信息要按照逆序存放。**逆序存放主要是因为记录头信息中指向下一个记录的指针,指向的是下一条记录的「记录头信息」和「真实数据」之间的位置,这样的好处是向左读就是记录头信息,向右读就是真实数据,比较方便。这样使得位置靠前的记录的真实数据和数据对应的字段长度信息可以同时在一个 CPU Cache Line 中,这样就可以提高 CPU Cache 的命中率。
- 变长字段列表不是必须的。当数据表没有变长字段的时候,比如全部都是 int 类型的字段,就会去掉节省空间。
- NULL值列表:表中的某些列可能会存储 NULL 值,如果把这些 NULL 值都放到记录的真实数据中会比较浪费空间,所以 Compact 行格式把这些值为 NULL 的列存储到 NULL值列表中。
- 如果存在允许 NULL 值的列,则每个列对应一个二进制位(bit),二进制位按照列的顺序逆序排列。0表示不为NULL,1表示为NULL。
- NULL 值列表也不是必须的。当数据表的字段都定义成 NOT NULL 的时候,这时候表里的行格式就不会有 NULL 值列表了。
- NULL 值列表的空间不是固定 1 字节的。当一条记录有 9 个字段值都是 NULL,那么就会创建 2 字节空间的NULL 值列表,以此类推。
- 记录头信息:有几个比较重要的记录头信息。
- delete_mask :标识此条数据是否被删除。从这里可以知道,我们执行 detele 删除记录的时候,并不会真正的删除记录,只是将这个记录的 delete_mask 标记为 1。
- next_record:下一条记录的位置。从这里可以知道,**记录与记录之间是通过链表组织的。**在前面我也提到了,指向的是下一条记录的「记录头信息」和「真实数据」之间的位置,这样的好处是向左读就是记录头信息,向右读就是真实数据,比较方便。
- record_type:表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录。
- 变长字段长度列表:在存储数据的时候,也要把数据占用的大小存到「变长字段长度列表」里面,读取数据的时候才能根据这个「变长字段长度列表」去读取对应长度的数据。
-
记录的真实数据
trx_id 和 roll_pointer主要为MVCC服务。
-
row_id
:如果我们建表的时候指定了主键或者唯一约束列,那么就没有 row_id 隐藏字段了。如果既没有指定主键,又没有唯一约束,那么 InnoDB 就会为记录添加 row_id 隐藏字段。row_id不是必需的,占用 6 个字节。 -
trx_id
:事务id,表示这个数据是由哪个事务生成的。 trx_id是必需的,占用 6 个字节。 -
roll_pointer
:这条记录上一个版本的指针。roll_pointer 是必需的,占用 7 个字节。
-
varchar(n)中n最大取值为多少?
- MySQL 规定除了 TEXT、BLOBs 这种大对象类型之外,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过 65535 个字节(16位)。
- varchar(n) 字段类型的 n 代表的是最多存储的字符数量,并不是字节大小。所以要看数据库表的字符集,因为字符集代表着1个字符要占用多少字节,比如 ascii 字符集, 1 个字符占用 1 字节,那么 varchar(100) 意味着最大能允许存储 100 字节的数据。
- 单字段的情况:只有一个 varchar(n) 类型的列且字符集是 ascii。varchar(n) 中 n 最大值 = 65535 - 2(「变长字段长度列表」所占字节数) - 1(「NULL值列表」所占字节数) = 65532。
- 计算变长字段长度列表:「变长字段长度列表」所占用的字节数 = 所有「变长字段长度」占用的字节数之和。
- 条件一:如果变长字段允许存储的最大字节数小于等于 255 字节,就会用 1 字节表示「变长字段长度」;条件二:如果变长字段允许存储的最大字节数大于 255 字节,就会用 2 字节表示「变长字段长度」;
- 反观我们这里字段类型是 varchar(65535) ,字符集是 ascii,所以代表着变长字段允许存储的最大字节数是 65535,符合条件二,所以会用 2 字节来表示「变长字段长度」。
- 计算NULL值列表:创建表时,如果字段是允许为 NULL 的,用 1 字节来表示「NULL 值列表」。
- 计算变长字段长度列表:「变长字段长度列表」所占用的字节数 = 所有「变长字段长度」占用的字节数之和。
- 多字段的情况:如果有多个字段的话,要保证所有字段的长度 + 变长字段长度列表所占用的字节数 + NULL值列表所占用的字节数 <= 65535。
- 单字段的情况:只有一个 varchar(n) 类型的列且字符集是 ascii。varchar(n) 中 n 最大值 = 65535 - 2(「变长字段长度列表」所占字节数) - 1(「NULL值列表」所占字节数) = 65532。
行溢出后,MySQL 是怎么处理的?
-
如果一个数据页存不了一条记录,InnoDB 存储引擎会自动将溢出的数据存放到「溢出页」中。在一般情况下,InnoDB 的数据都是存放在 「数据页」中。但是当发生行溢出时,溢出的数据会存放到「溢出页」中。
-
当发生行溢出时,在记录的真实数据处只会保存该列的一部分数据,而把剩余的数据放在「溢出页」中,然后真实数据处用 20 字节存储指向溢出页的地址,从而可以找到剩余数据所在的页。
-
Compressed 和 Dynamic 这两个行格式采用完全的行溢出方式,记录的真实数据处不会存储该列的一部分数据,只存储 20 个字节的指针来指向溢出页。实际的数据都存储在溢出页中。
MySQL有哪些锁?
全局锁
- 使用全局锁,整个数据库就处于只读状态了。
- 应用场景:主要应用于做全库逻辑备份,这样在备份数据库期间,不会因为数据或表结构的更新,而出现备份文件的数据与预期的不一样。
- 缺点:业务只能读数据,而不能更新数据,这样会造成业务停滞。
- 避免方法:使用可重复读的隔离级别,在备份数据库之前先开启事务,会先创建 Read View,然后整个事务执行期间都在用这个 Read View,而且由于 MVCC 的支持,备份期间业务依然可以对数据进行更新操作。
表级锁
-
MySQL 里面表级别的锁有这几种:
-
表锁;
-
元数据锁(MDL);
-
意向锁;
-
AUTO-INC 锁;
-
-
表锁
- 表锁也有表级别的共享锁(读锁),表级别的独占锁(写锁)。
- 表锁除了会限制别的线程的读写外,也会限制本线程接下来的读写操作。
- 如果本线程对学生表加了「共享表锁」,那么本线程接下来如果要对学生表执行写操作的语句,是会被阻塞的,当然其他线程对学生表进行写操作时也会被阻塞,直到锁被释放。
- 不过尽量避免在使用 InnoDB 引擎的表使用表锁,因为表锁的颗粒度太大,会影响并发性能。
-
元数据锁(MDL)
-
对一张表进行 CRUD 操作时,加的是 MDL 读锁;
- 只读,不能做结构的修改。
-
对一张表做结构变更操作的时候,加的是 MDL 写锁;
- 只写,修改表结构时不能通过CRUD读取数据。
-
MDL 不需要显示调用,那它是在什么时候释放的?
- MDL 是在事务提交后才会释放,这意味着事务执行期间,MDL 是一直持有的。
-
为什么线程因为申请不到 MDL 写锁,而导致后续的申请读锁的查询操作也会被阻塞?
- 这是因为申请 MDL 锁的操作会形成一个队列,队列中写锁获取优先级高于读锁,一旦出现 MDL 写锁等待,会阻塞后续该表的所有 CRUD 操作(MDL读锁)。
-
所以为了能安全的对表结构进行变更,在对表结构变更前,先要看看数据库中的长事务,是否有事务已经对表加上了 MDL 读锁,如果可以考虑 kill 掉这个长事务,然后再做表结构的变更。
-
-
意向锁
- 意向锁的目的是为了快速判断表里是否有记录被加锁。
- 在使用 InnoDB 引擎的表里行级别加上「共享锁」之前,需要先在表级别加上一个「意向共享锁」;
- 在使用 InnoDB 引擎的表里行级别加上「独占锁」之前,需要先在表级别加上一个「意向独占锁」;
- 如果没有「意向锁」,那么加「独占表锁」时,就需要遍历表里所有记录,查看是否有记录存在独占锁,这样效率会很慢。那么有了「意向锁」,由于在对记录加独占锁前,先会加上表级别的意向独占锁,那么在加「独占表锁」时,直接查该表是否有意向独占锁,如果有就意味着表里已经有记录被加了独占锁,这样就不用去遍历表里的记录。
- 意向共享锁和意向独占锁是表级锁,不会和行级的共享锁和独占锁发生冲突,而且意向锁之间也不会发生冲突,只会和共享表锁(lock tables … read)和独占表锁(lock tables … write)发生冲突。
- 表锁和行锁是满足读读共享、读写互斥、写写互斥的。
- 意向锁的目的是为了快速判断表里是否有记录被加锁。
-
AUTO-INC 锁(自动增长)
- 表里的主键通常都会设置成自增的,这是通过对主键字段声明
AUTO_INCREMENT
属性实现的。之后在插入数据时,可以不指定主键的值,数据库会自动给主键赋值递增的值,这主要是通过 AUTO-INC 锁实现的。 - 在插入数据时,会加一个表级别的 AUTO-INC 锁,然后为被
AUTO_INCREMENT
修饰的字段赋值递增的值,等插入语句执行完成后,才会把 AUTO-INC 锁释放掉。 - 在 MySQL 5.1.22 版本开始,InnoDB 存储引擎提供了一种轻量级的锁来实现自增。一样也是在插入数据的时候,会为被
AUTO_INCREMENT
修饰的字段加上轻量级锁,然后给该字段赋值一个自增的值,就把这个轻量级锁释放了,而不需要等待整个插入语句执行完后才释放锁。
- 表里的主键通常都会设置成自增的,这是通过对主键字段声明
行级锁
行级锁的类型主要有三类:
-
Record Lock,记录锁,也就是仅仅把一条记录锁上;而且记录锁是有 S 锁和 X 锁之分的。
-
Gap Lock,间隙锁,锁定一个范围,但是不包含记录本身;
- 间隙锁之间是兼容的,即两个事务可以同时持有包含共同间隙范围的间隙锁,并不存在互斥关系,因为间隙锁的目的是防止插入幻影记录而提出的。
-
Next-Key Lock:Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。
- next-key lock 即能保护记录,又能阻止其他事务将新纪录插入到被保护记录前面的间隙中。
- next-key lock 是包含间隙锁+记录锁的,如果一个事务获取了 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,是会被阻塞的。
-
插入意向锁:插入意向锁名字虽然有意向锁,但是它并不是意向锁,它是一种特殊的间隙锁,属于行级别锁。
Redis数据结构
Redis 提供了丰富的数据类型,常见的有五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
-
String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。
-
List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
-
Hash 类型:缓存对象、购物车等。
-
Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
-
Zset 类型:排序场景,比如排行榜、电话和姓名排序等。
-
BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
-
HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;
-
GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车;
-
Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。
五种常见的 Redis 数据类型是怎么实现?
-
String
String 类型的底层的数据结构实现主要是 SDS(简单动态字符串)。 SDS 和我们认识的 C 字符串不太一样,之所以没有使用 C 语言的字符串表示,因为 SDS 相比于 C 的原生字符串:
- SDS 不仅可以保存文本数据,还可以保存二进制数据。
- SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 buf[] 数组里的数据。
- SDS 获取字符串长度的时间复杂度是 O(1)。
- SDS 结构里用 len 属性记录了字符串长度,所以复杂度为 O(1)。
- Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。、
- 因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容。
- SDS 不仅可以保存文本数据,还可以保存二进制数据。
-
List
List 类型的底层数据结构是由双向链表或压缩列表实现的
- 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用压缩列表作为 List 类型的底层数据结构;
- 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;
在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。
-
Hash
Hash 类型的底层数据结构是由压缩列表或哈希表实现的
- 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;
- 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构。
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
-
Set
Set 类型的底层数据结构是由哈希表或整数集合实现的:
- 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
- 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。
-
ZSet
Zset 类型的底层数据结构是由压缩列表或跳表实现的:
- 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
- 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
Redis线程模型
-
Redis是单线程吗?
-
Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。
-
Redis 在启动的时候,是会启动后台线程(BIO)。
-
Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务;
-
Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。例如执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。
-
-
之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。
-
后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。
-
关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:
- BIO_CLOSE_FILE,关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd) ,将文件关闭;
- BIO_AOF_FSYNC,AOF刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘,
- BIO_LAZY_FREE,lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象
-
-
Redis单线程模式是怎样的?
-
图中的蓝色部分是一个事件循环,是由主线程负责的,可以看到网络 I/O 和命令处理都是单线程。 Redis 初始化的时候,会做下面这几件事情:
- 首先,调用 epoll_create() 创建一个 epoll 对象和调用 socket() 创建一个服务端 socket
- 然后,调用 bind() 绑定端口和调用 listen() 监听该 socket;
- 然后,将调用 epoll_ctl() 将 listen socket 加入到 epoll,同时注册「连接事件」处理函数。
初始化完后,主线程就进入到一个事件循环函数,主要会做以下事情:
- 首先,先调用处理发送队列函数,看是发送队列里是否有任务,如果有发送任务,则通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。
- 接着,调用 epoll_wait 函数等待事件的到来:
- 如果是连接事件到来,则会调用连接事件处理函数,该函数会做这些事情:调用 accpet 获取已连接的 socket -> 调用 epoll_ctl 将已连接的 socket 加入到 epoll -> 注册「读事件」处理函数;
- 如果是读事件到来,则会调用读事件处理函数,该函数会做这些事情:调用 read 获取客户端发送的数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送;
- 如果是写事件到来,则会调用写事件处理函数,该函数会做这些事情:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。
-
-
Redis采用单线程为什么(网络I/O和执行命令)还这么快?
- Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或网络带宽,而并非 CPU,自然就采用单线程的解决方案了;
- Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
- Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求。
- IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。
-
Redis6.0之前为什么使用单线程?
单线程的程序是无法利用服务器的多核 CPU 的,那么早期 Redis 版本的主要工作(网络 I/O 和执行命令)为什么还要使用单线程呢?
- CPU 并不是制约 Redis 性能表现的瓶颈所在,更多情况下是受到内存大小和网络I/O的限制,所以 Redis 核心网络模型使用单线程并没有什么问题。
- 使用了单线程后,可维护性高,多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。
-
Redis6.0之后为什么引入了多线程?
虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。
-
为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理,所以大家不要误解 Redis 有多线程同时执行命令。
-
Redis 官方表示,Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上。
-
子序列问题
问题:
题目 | 关键点 |
---|---|
300. 最长递增子序列 - 力扣(LeetCode) | dp数组含义,dp[j]的利用 |
674. 最长连续递增序列 - 力扣(LeetCode) | dp数组含义 |
718. 最长重复子数组 - 力扣(LeetCode) | 二维dp数组的利用 |
1143. 最长公共子序列 - 力扣(LeetCode) | 三个方向(子问题)推导出的二维dp数组 |
-
确定dp数组及其下标含义:
dp[i] : 以nums[i]结尾的最长递增子序列的长度。
-
递推公式:dp[j]为0 ~ i - 1每个位置上,以nums[j]结尾的最长的子序列长度。
位置i的最长升序子序列等于j从0到i-1各个位置的最长升序子序列 + 1 的最大值。
if(nums[i] > nums[j]) dp[i] = Math.max(dp[i] , dp[j] + 1);
-
初始化:由于是子序列,dp数组全部初始化为1。
-
顺序:遍历i的顺序一定是从前往后,因为dp[i]需要0 ~ i- 1推导而来。遍历j的顺序随意。
-
举例推导dp数组:
class Solution {
public int lengthOfLIS(int[] nums) {
//dp[i] : 以nums[i]结尾的最长递增子序列的长度。
int n = nums.length;
int [] dp = new int [n];
Arrays.fill(dp , 1);
for(int i = 0 ; i < n ; i ++){
for(int j = 0 ; j < i ; j ++){
if(nums[i] > nums[j])
dp[i] = Math.max(dp[i] , dp[j] + 1);
}
System.out.println("当前的dp[" + i + "]表示以" + nums[i] + "结尾的最长递增子序列是" + dp[i]);
}
int ans = 0 ;
for(int i = 0 ; i < n ; i ++){
ans = Math.max(ans , dp[i]);
}
return ans;
}
}
-
-
dp数组下标以及含义:dp[i]表示以nums[i]结尾的最长连续递增子序列的长度。
-
递推公式:由于是连续递增子序列,本题dp[i]的状态由dp[i - 1]推导而来。而不需要dp[j]作为辅助。如果 nums[i] > nums[i - 1],那么以 i 为结尾的连续递增的子序列长度 一定等于 以i - 1为结尾的连续递增的子序列长度 + 1 。
dp[i] = dp[i - 1] + 1
-
初始化:由于是子序列,所以全部初始化为1。
-
遍历顺序,dp[i]由dp[i - 1]推导而来,顺序遍历。
-
举例推导dp数组。
-
class Solution {
public int findLengthOfLCIS(int[] nums) {
int n = nums.length;
int[] dp = new int [n];
Arrays.fill(dp , 1);
for(int i = 1; i < n ; i ++){
if(nums[i] > nums[i - 1]){
dp[i] = dp[i - 1] + 1;
}
//System.out.println("当前的dp[" + i + "]表示以" + nums[i] + "结尾的最长递增子序列是" + dp[i]);
}
int ans = 0 ;
for(int i = 0 ; i < n ; i ++){
ans = Math.max(ans, dp[i]);
}
return ans;
}
}
-
-
这里的子数组就是连续子序列。
-
确定dp数组及其下标含义:
dp[i - 1][j - 1]
表示以nums1[i]为结尾和以nums2[j]为结尾的最长重复子数组。 -
确定递推公式:
dp[i][j] = dp[i - 1][j - 1] + 1
,两个数组的状态都由前面的数组推导而来。 -
确定初始化:根据
dp[i][j]
的定义,dp[i][0] 和dp[0][j]
其实都是没有意义的,所以都初始化为0。 -
确定遍历顺序:外层for循环遍历A,内层for循环遍历B。
-
举例推导dp
class Solution { public int findLength(int[] nums1, int[] nums2) { int ans = 0; int m = nums1.length; int n = nums2.length; int dp [][] = new int [m + 1][n + 1]; for(int i = 1 ; i <= m ; i ++){ for(int j = 1; j <= n ; j ++){ if(nums1[i - 1] == nums2[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1; if(ans < dp[i][j]) ans = dp[i][j]; //System.out.println("dp[" + i + "][" + j + "]表示以" + "nums1[" + (i - 1) + "]和" +"nums2[" + (j - 1) +"]结尾的公共最长子数组是" + dp[i][j]); } } return ans; } }
-
-
-
dp数组及其下标含义:
dp[i][j]
:长度为[0, i - 1]的字符串text1与长度为[0, j - 1]的字符串text2的最长公共子序列为dp[i][j]
-
递推公式:分为两种情况
text1[i - 1] 与 text2[j - 1]相同,text1[i - 1] 与 text2[j - 1]不相同
text1[i - 1] 与 text2[j - 1]相同:那么找到了一个公共元素,所以
dp[i][j] = dp[i - 1][j - 1] + 1;
text1[i - 1] 与 text2[j - 1]不相同:那就看看 text1[0, i - 2] 与 text2[0, j - 1] 的最长公共子序列、 text1[0, i - 1] 与text2[0, j - 2]的最长公共子序列,取最大的。(text1往前找一个或者text2往前找一个,取最大即可。本质是重叠子问题)
即:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
-
初始化:根据定义,
dp[0][j]
或dp[i][0]
没有意义。所以初始化为0。 -
遍历顺序:从前往后遍历。可以看出,有三个方向可以推出
dp[i][j]
,那么为了在递推的过程中,这三个方向都是经过计算的数值,所以要从前向后,从上到下来遍历这个矩阵。 -
举例推导dp数组
class Solution { public int longestCommonSubsequence(String text1, String text2) { int ans = 0; int m = text1.length(); int n = text2.length(); int [][] dp = new int [m + 1][n + 1]; for(int i = 1; i <= m ; i ++){ for(int j = 1 ; j <= n ; j ++){ char t1 = text1.charAt(i - 1); char t2 = text2.charAt(j - 1); if(t1 == t2){ dp[i][j] = dp[i - 1][j - 1] + 1; }else { dp[i][j] = Math.max(dp[i - 1][j] , dp[i][j - 1]); } if(ans < dp[i][j]) ans = dp[i][j]; //System.out.println("dp[" + i + "][" + j + "]表示以" + "text1[" + (i - 1) + "]和" +"text2[" + (j - 1) +"]结尾的最长公共子序列是" + dp[i][j]); } } return ans; } }
-