diff --git a/docs/Java/JUC.md b/docs/Java/JUC.md index f1f9a1e..dda264d 100644 --- a/docs/Java/JUC.md +++ b/docs/Java/JUC.md @@ -39,15 +39,15 @@ JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步 synchronized 用的锁是存在 Java 对象头里的。如果对象是数组类型,则虚拟机用 3个字宽(Word)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。在 32 位虚拟机中,1 字宽等于 4 字节,即 32bit;64位虚拟机下,一个字宽大小为 8 字节。 -![](JUC\对象头.jpeg) +![](.\JUC\对象头.jpeg) Java 对象头里的 Mark Word 里默认存储对象的 HashCode、分代年龄和锁标记位。 -![](JUC\对象头存储结构.jpeg) +![](.\JUC\对象头存储结构.jpeg) 在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。 -![](JUC\锁状态变化.jpeg) +![](.\JUC\锁状态变化.jpeg) 当对象状态为偏向锁时,Mark Word存储的是偏向的线程ID。 @@ -79,7 +79,7 @@ Java 对象头里的 Mark Word 里默认存储对象的 HashCode、分代年龄 3. 唤醒当前线程,将当前锁升级成轻量级锁。 -![](JUC\偏向锁的获得和撤销.jpeg) +![](.\JUC\偏向锁的获得和撤销.jpeg) **关闭偏向锁**: @@ -100,7 +100,7 @@ Java 对象头里的 Mark Word 里默认存储对象的 HashCode、分代年龄 轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头, 如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。 -![](JUC\轻量级锁.jpeg) +![](.\JUC\轻量级锁.jpeg) diff --git a/docs/Java/Java.md b/docs/Java/Java.md index a8b08cd..8de3d5a 100644 --- a/docs/Java/Java.md +++ b/docs/Java/Java.md @@ -286,7 +286,7 @@ super的两个含义: **引用拷贝**:引用拷贝就是两个不同的引用指向同一个对象。 -![](.\Java\shallow&deep-copy.png) +![](.\images\shallow&deep-copy.png) @@ -591,7 +591,7 @@ enum Season ## 异常 -![](Java\异常.jpeg) +![](.\images\异常.jpeg) 执行过程中发生的异常分为两大类 diff --git a/docs/Java/images/shallow&deep-copy.png b/docs/Java/images/shallow&deep-copy.png new file mode 100644 index 0000000..1f30a37 Binary files /dev/null and b/docs/Java/images/shallow&deep-copy.png differ diff --git "a/docs/Java/images/\345\274\202\345\270\270.jpeg" "b/docs/Java/images/\345\274\202\345\270\270.jpeg" new file mode 100644 index 0000000..daafa3a Binary files /dev/null and "b/docs/Java/images/\345\274\202\345\270\270.jpeg" differ diff --git a/docs/MySQL/MySQL.md b/docs/MySQL/MySQL.md deleted file mode 100644 index f00251b..0000000 --- a/docs/MySQL/MySQL.md +++ /dev/null @@ -1,1462 +0,0 @@ -## select 语句的执行过程 - -![](MySQL\基础架构.png) - - - -### 连接器 - -作用: - -1. 与客户端进行TCP三次握手建立连接 -2. 校验用户名、密码 -3. 校验权限 - - - -`MySQL`也会“安排”一条线程维护当前客户端的连接,这条线程也会时刻标识着当前连接在干什么工作,可以通过`show processlist;`命令查询所有正在运行的线程。 - - - -MySQL的最大线程数可以通过参数`max-connections`来控制,如果到来的客户端连接超出该值时,新到来的连接都会被拒绝,关于最大连接数的一些命令主要有两条: - -- `show variables like '%max_connections%';`:查询目前`DB`的最大连接数。默认151 -- `set GLOBAL max_connections = 200;`:修改数据库的最大连接数为指定值。 - - - -MySQL 定义了空闲连接的最大空闲时长,由 `wait_timeout` 参数控制的,默认值是 8 小时(28880秒),如果空闲连接超过了这个时间,连接器就会自动将它断开。 - -一个处于空闲状态的连接被服务端主动断开后,这个客户端并不会马上知道,等到客户端在发起下一个请求的时候,才会收到这样的报错“ERROR 2013 (HY000): Lost connection to MySQL server during query”。 - - - -MySQL 的连接也跟 HTTP 一样,有短连接和长连接的概念。长连接的好处就是可以减少建立连接和断开连接的过程,但是,使用长连接后可能会占用内存增多。有两种解决方式: - -第一种,**定期断开长连接**。既然断开连接后就会释放连接占用的内存资源,那么我们可以定期断开长连接。 - -第二种,**客户端主动重置连接**。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 语句查询的结果。 - -MySQL 8.0 版本已经将查询缓存删掉。 - - - -### 解析SQL - -第一件事情,**词法分析**。MySQL 会根据你输入的字符串识别出关键字出来,例如,SQL语句 select username from userinfo,在分析之后,会得到4个Token,其中有2个Keyword,分别为select和from。 - -第二件事情,**语法分析**。根据词法分析的结果,语法解析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法,如果没问题就会构建出 SQL 语法树,这样方便后面模块获取 SQL 类型、表名、字段名、 where 条件等等。 - - - -`SQL`语句分为五大类: - -- `DML`:数据库操作语句,比如`update、delete、insert`等都属于这个分类。 -- `DDL`:数据库定义语句,比如`create、alter、drop`等都属于这个分类。 -- `DQL`:数据库查询语句,比如最常见的`select`就属于这个分类。 -- `DCL`:数据库控制语句,比如`grant、revoke`控制权限的语句都属于这个分类。 -- `TCL`:事务控制语句,例如`commit、rollback、setpoint`等语句属于这个分类。 - - - -存储过程:是指提前编写好的一段较为常用或复杂`SQL`语句,然后指定一个名称存储起来,然后先经过编译、优化,完成后,这个“过程”会被嵌入到`MySQL`中。 - - - -触发器则是一种特殊的存储过程,但[触发器]与[存储过程]的不同点在于:**存储过程需要手动调用后才可执行,而触发器可由某个事件主动触发执行**。在`MySQL`中支持`INSERT、UPDATE、DELETE`三种事件触发,同时也可以通过`AFTER、BEFORE`语句声明触发的时机,是在操作执行之前还是执行之后。 - - - -### 执行SQL - -#### 预处理阶段 - -检查 SQL 查询语句中的表或者字段是否存在; - -将 `select *` 中的 `*` 符号,扩展为表上的所有列; - -#### 优化阶段 - -**优化器主要负责将 SQL 查询语句的执行方案确定下来**,比如在表里面有多个索引的时候,优化器会基于查询成本的考虑,来决定选择使用哪个索引。 - -`MySQL`优化器的一些优化准则如下: - -- 多条件查询时,重排条件先后顺序,将效率更好的字段条件放在前面。 - -- 当表中存在多个索引时,选择效率最高的索引作为本次查询的目标索引。 - -- 使用分页`Limit`关键字时,查询到对应的数据条数后终止扫表。 - -- 多表`join`联查时,对查询表的顺序重新定义,同样以效率为准。 - -- 对于`SQL`中使用函数时,如`count()、max()、min()...`,根据情况选择最优方案。 - -- - `max()`函数:走`B+`树最右侧的节点查询(大的在右,小的在左)。 - - `min()`函数:走`B+`树最左侧的节点查询。 - - `count()`函数:如果是`MyISAM`引擎,直接获取引擎统计的总行数。 - -- 对于`group by`分组排序,会先查询所有数据后再统一排序,而不是一开始就排序。 - - - -#### 执行阶段 - -在执行的过程中,执行器就会和存储引擎交互,交互是以记录为单位的。 - - - -## update 语句的执行过程 - -查询语句的那一套流程,更新语句也是同样会走一遍: - -1. 客户端先通过连接器建立连接,连接器自会判断用户身份、权限校验; -2. 因为这是一条 update 语句,所以不需要经过查询缓存,但是表上有更新语句,是会把整个表的查询缓存清空的,所以说查询缓存很鸡肋,在 MySQL 8.0 就被移除这个功能了; -3. 解析器会通过词法分析识别出关键字 update,表名等等,构建出语法树,接着还会做语法分析,判断输入的语句是否符合 MySQL 语法; -4. 预处理器会判断表和字段是否存在; -5. 优化器确定执行计划; -6. 执行器负责具体执行。 - -与查询流程不一样的是,更新流程还涉及两个重要日志模块:redo log(重做日志)和 bin log(归档日志)。 - -update执行流程:` update T set c=c+1 where ID=2;` - -7. 执行器先找引擎取ID=2这一行。如果ID=2这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。 - -8. 执行器拿到引擎给的行数据,把这个值加上1,比如原来是N,现在就是N+1,得到新的一行数据,再调用引擎接口写入这行新数据。 - -9. 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 undo-log 和 redo log(prepare状态)里面。然后告知执行器执行完成了,随时可以提交事务。 - -10. 执行器生成这个操作的binlog,并把binlog写入磁盘。 - -11. 执行器调用引擎的提交事务接口,引擎把刚刚写入的redo log改成提交(commit)状态,更新完成。 - -![](MySQL\update.png) - -图中浅色框表示是在InnoDB内部执行的,深色框表示是在执行器中执行的。 - -将 redo log 的写入拆成了两个步骤: prepare 和 commit,这就是"两阶段提交"。为了使两个日志之间保持一致: - -1. 当在写bin log之前崩溃时:此时 binlog 还没写,redo log 也还没提交,事务会回滚。 日志保持一致 - -2. 当在写bin log之后崩溃时: 重启恢复后虽没有commit,但满足prepare和binlog完整,自动commit。日志保持一致 - -溃恢复时的判断规则: - -1. 如果 redo log 里面的事务是完整的,也就是已经有了 commit 标识,则直接提交; -2. 如果 redo log 里面的事务只有完整的 prepare,则判断对应的事务 binlog 是否存在并完整: - 1. 如果是,则提交事务; - 2. 否则,回滚事务。 - - - -## MySQL一行记录的存储结构 - -先来看看 MySQL 数据库的文件存放在哪个目录? - -```sh -mysql> SHOW VARIABLES LIKE 'datadir'; -+---------------+-----------------+ -| Variable_name | Value | -+---------------+-----------------+ -| datadir | /var/lib/mysql/ | -+---------------+-----------------+ -1 row in set (0.00 sec) -``` - -我们每创建一个 database(数据库) 都会在 /var/lib/mysql/ 目录里面创建一个以 database 为名的目录,然后保存表结构和表数据的文件都会存放在这个目录里。 - -- db.opt,用来存储当前数据库的默认字符集和字符校验规则。 -- 表名.frm ,数据库表的**表结构**会保存在这个文件。在 MySQL 中建立一张表都会生成一个.frm 文件,该文件是用来保存每个表的元数据信息的,主要包含表结构定义。 -- 表名.ibd,数据库表的**表数据**会保存在这个文件。 MySQL 中每一张表的数据都存放在一个独立的 .ibd 文件。 - - - -### 表空间 - -**表空间由段(segment)、区(extent)、页(page)、行(row)组成**,InnoDB存储引擎的逻辑存储结构大致如下图: - -![](.\MySql\表空间结构.drawio.webp) - -> 记录是按照行来存储的 -> -> InnoDB 的数据是按「页」为单位来读写的,默认每个页的大小为 16KB。一次最少从磁盘中读取 16K 的内容到内存中,一次最少把内存中的 16K 内容刷新到磁盘中。 - - - -**区(extent)** - -我们知道 InnoDB 存储引擎是用 B+ 树来组织数据的。 - -B+ 树中每一层都是通过双向链表连接起来的,如果是以页为单位来分配存储空间,那么链表中相邻的两个页之间的物理位置并不是连续的,可能离得非常远,那么磁盘查询时就会有大量的随机I/O,随机 I/O 是非常慢的。 - -解决这个问题也很简单,就是让链表中相邻的页的物理位置也相邻,这样就可以使用顺序 I/O 了,那么在范围查询(扫描叶子节点)的时候性能就会很高。 - -那具体怎么解决呢? - -**在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区(extent)为单位分配。每个区的大小为 1MB,对于 16KB 的页来说,连续的 64 个页会被划为一个区,这样就使得链表中相邻的页的物理位置也相邻,就能使用顺序 I/O 了**. - - - -表空间是由各个段(segment)组成的,段是由多个区(extent)组成的。段一般分为数据段、索引段和回滚段等。 - - - -### InnoDB 行格式 - -InnoDB 提供了 4 种行格式,分别是 Redundant、Compact、Dynamic和 Compressed 行格式。 - -Compact 行格式: - -![](.\MySql\COMPACT.drawio.webp) - -#### 记录的额外信息 - -##### **变长字段长度列表** - -varchar(n) 和 char(n) 的区别是char 是定长的,varchar 是变长的,变长字段实际存储的数据的长度(大小)是不固定的。 - -所以,在存储数据的时候,也要把数据占用的大小存起来,存到「变长字段长度列表」里面,读取数据的时候才能根据这个「变长字段长度列表」去读取对应长度的数据。其他 TEXT、BLOB 等变长字段也是这么实现的。 - -![](.\MySql\t_test.webp) - -第一条记录: - -- name 列的值为 a,真实数据占用的字节数是 1 字节,十六进制 0x01; -- phone 列的值为 123,真实数据占用的字节数是 3 字节,十六进制 0x03; -- age 列和 id 列不是变长字段,所以这里不用管。 - -这些变长字段的真实数据占用的字节数会按照列的顺序**逆序存放**,所以「变长字段长度列表」里的内容是「 03 01」,而不是 「01 03」。 - -![](.\MySql\变长字段长度列表1.webp) - - - -> **为什么「变长字段长度列表」的信息要按照逆序存放?** - -这个设计是有想法的,主要是因为「记录头信息」中指向下一个记录的指针,指向的是下一条记录的「记录头信息」和「真实数据」之间的位置,这样的好处是向左读就是记录头信息,向右读就是真实数据,比较方便。 - -「变长字段长度列表」中的信息之所以要逆序存放,是因为这样可以**使得位置靠前的记录的真实数据和数据对应的字段长度信息可以同时在一个 CPU Cache Line 中,这样就可以提高 CPU Cache 的命中率**。 - - - -> **每个数据库表的行格式都有「变长字段字节数列表」吗?** - -**当数据表没有变长字段的时候,比如全部都是 int 类型的字段,这时候表里的行格式就不会有「变长字段长度列表」了**,因为没必要,不如去掉以节省空间。 - - - -##### NULL值列表 - -表中的某些列可能会存储 NULL 值,如果把这些 NULL 值都放到记录的真实数据中会比较浪费空间,所以 Compact 行格式把这些值为 NULL 的列存储到 NULL值列表中。 - -如果存在允许 NULL 值的列,则每个列对应一个二进制位(bit),二进制位按照列的顺序**逆序排列**。 - -- 二进制位的值为`1`时,代表该列的值为NULL。 -- 二进制位的值为`0`时,代表该列的值不为NULL。 - -另外,NULL 值列表必须用**整数个字节**的位表示(1字节8位),如果使用的二进制位个数不足整数个字节,则在字节的高位补 `0`。 - -第三条记录 phone 列 和 age 列是 NULL 值,所以,对于第三条数据,NULL 值列表用十六进制表示是 0x06。 - -![](.\MySql\null值列表4.webp) - -> **每个数据库表的行格式都有「NULL 值列表」吗?** - -**当数据表的字段都定义成 NOT NULL 的时候,这时候表里的行格式就不会有 NULL 值列表了**。 - -所以在设计数据库表的时候,通常都是建议将字段设置为 NOT NULL,这样可以至少节省 1 字节的空间 - - - -##### 记录头信息 - -记录头信息中包含的内容很多: - -- delete_mask :标识此条数据是否被删除。从这里可以知道,我们执行 detele 删除记录的时候,并不会真正的删除记录,只是将这个记录的 delete_mask 标记为 1。 -- next_record:下一条记录的位置。从这里可以知道,记录与记录之间是通过链表组织的。在前面我也提到了,指向的是下一条记录的「记录头信息」和「真实数据」之间的位置,这样的好处是向左读就是记录头信息,向右读就是真实数据,比较方便。 -- record_type:表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录 - - - -#### 记录的真实数据 - -记录真实数据部分除了我们定义的字段,还有三个隐藏字段,分别为:row_id、trx_id、roll_pointer。 - -* 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 个字节**。 - -要算 varchar(n) 最大能允许存储的字节数,还要看数据库表的字符集,因为字符集代表着,1个字符要占用多少字节,比如 ascii 字符集, 1 个字符占用 1 字节。 - -存储字段类型为 varchar(n) 的数据时,其实分成了三个部分来存储: - -- 真实数据 -- 真实数据占用的字节数 -- NULL 标识,如果不允许为NULL,这部分不需要 - -所以,我们在算 varchar(n) 中 n 最大值时,需要减去 「变长字段长度列表」和 「NULL 值列表」所占用的字节数的。 - - - -```sql -CREATE TABLE test ( -`name` VARCHAR(65532) NULL -) ENGINE = InnoDB DEFAULT CHARACTER SET = ascii ROW_FORMAT = COMPACT; -``` - -上述例子,在数据库表只有一个 varchar(n) 字段且字符集是 ascii 的情况下,varchar(n) 中 n 最大值 = 65535 - 2 - 1 = 65532。 - - - -### 行溢出后,MySQL 是怎么处理的? - -MySQL 中磁盘和内存交互的基本单位是页,一个页的大小一般是 `16KB`,也就是 `16384字节`,而一个 varchar(n) 类型的列最多可以存储 `65532字节`,一些大对象如 TEXT、BLOB 可能存储更多的数据,这时一个页可能就存不了一条记录。这个时候就会**发生行溢出,多的数据就会存到另外的「溢出页」中**。 - -当发生行溢出时,在记录的真实数据处只会保存该列的一部分数据,而把剩余的数据放在「溢出页」中,然后真实数据处用 20 字节存储指向溢出页的地址,从而可以找到剩余数据所在的页. - - - -## 索引 - -索引分类: - -按「数据结构」分类:**B+tree索引、Hash索引、Full-text索引**。 - -按「物理存储」分类:**聚簇索引(主键索引)、二级索引(辅助索引)**。 - -按「字段特性」分类:**主键索引、唯一索引、普通索引、前缀索引**。 - -按「字段个数」分类:**单列索引、联合索引**。 - -### B+Tree索引 - -***1、B+Tree vs B Tree*** - -B+Tree 只在叶子节点存储数据,而 B 树 的非叶子节点也要存储数据,所以 B+Tree 的单个节点的数据量更小,在相同的**磁盘 I/O 次数**下,就能查询更多的节点。 - -另外,B+Tree 叶子节点采用的是双链表连接,适合 MySQL 中常见的**范围查询**,而 B 树无法做到这一点。 - -B+Tree**插入和删除效率更高**,不会涉及复杂的树的变形 - -***2、B+Tree vs 二叉树*** - -对于有 N 个叶子节点的 B+Tree,其搜索复杂度为`O(logdN)`,其中 d 表示节点允许的最大子节点个数为 d 个。 - -在实际的应用当中, d 值是大于100的,这样就保证了,即使数据达到千万级别时,B+Tree 的高度依然维持在 3~4 层左右,也就是说一次数据查询操作只需要做 3~4 次的磁盘 I/O 操作就能查询到目标数据。 - -而二叉树的每个父节点的儿子节点个数只能是 2 个,意味着其搜索复杂度为 `O(logN)`,这已经比 B+Tree 高出不少,因此二叉树检索到目标数据所经历的磁盘 I/O 次数要更多。如果索引的字段值是按顺序增长的,二叉树会转变为链表结构,检索的过程和全表扫描无异。 - -**3、B+Tree vs 红黑树** - -虽然对比二叉树来说,树高有所降低,但数据量一大时,依旧会有很大的高度。每个节点中只存储一个数据,节点之间还是不连续的,依旧无法利用局部性原理。 - -***4、B+Tree vs Hash*** - -Hash 在做等值查询的时候效率贼快,搜索复杂度为 O(1)。 - -但是 Hash 表不适合做范围查询,它更适合做等值的查询,这也是 B+Tree 索引要比 Hash 表索引有着更广泛的适用场景的原因。 - -### 聚集索引和二级索引 - -![](MySQL\聚集索引和二级索引.webp) - -所以,在查询时使用了二级索引,如果查询的数据能在二级索引里查询的到,那么就不需要回表,这个过程就是覆盖索引。如果查询的数据不在二级索引里,就会先检索二级索引,找到对应的叶子节点,获取到主键值后,然后再检索主键索引,就能查询到数据了,这个过程就是回表。 - -### 主键索引和唯一索引 - -一张表最多只有一个主键索引,索引列的值不允许有空值。 - -唯一索引建立在 UNIQUE 字段上的索引,一张表可以有多个唯一索引,索引列的值必须唯一,但是允许有空值。 - -### 联合索引 - -使用联合索引时,存在**最左匹配原则**,也就是按照最左优先的方式进行索引的匹配。在使用联合索引进行查询的时候,如果不遵循「最左匹配原则」,联合索引会失效。 - -联合索引有一些特殊情况,并不是查询过程使用了联合索引查询,就代表联合索引中的所有字段都用到了联合索引进行索引查询。联合索引的最左匹配原则会一直向右匹配直到遇到「范围查询」就会停止匹配。**也就是范围查询的字段可以用到联合索引,但是在范围查询字段的后面的字段无法用到联合索引**。 - - - -> **`select * from t_table where a > 1 and b = 2`,联合索引(a, b)哪一个字段用到了联合索引的 B+Tree?** - -由于联合索引(二级索引)是先按照 a 字段的值排序的,所以符合 a > 1 条件的二级索引记录肯定是相邻,于是在进行索引扫描的时候,可以定位到符合 a > 1 条件的第一条记录,然后沿着记录所在的链表向后扫描,直到某条记录不符合 a > 1 条件位置。所以 a 字段可以在联合索引的 B+Tree 中进行索引查询。 - -**但是在符合 a > 1 条件的二级索引记录的范围里,b 字段的值是无序的**。所以 b 字段无法利用联合索引进行索引查询。 - -**这条查询语句只有 a 字段用到了联合索引进行索引查询,而 b 字段并没有使用到联合索引**。 - - - -> **`select * from t_table where a >= 1 and b = 2`,联合索引(a, b)哪一个字段用到了联合索引的 B+Tree?** - -由于联合索引(二级索引)是先按照 a 字段的值排序的,所以符合 >= 1 条件的二级索引记录肯定是相邻,于是在进行索引扫描的时候,可以定位到符合 >= 1 条件的第一条记录,然后沿着记录所在的链表向后扫描,直到某条记录不符合 a>= 1 条件位置。所以 a 字段可以在联合索引的 B+Tree 中进行索引查询。 - -虽然在符合 a>= 1 条件的二级索引记录的范围里,b 字段的值是「无序」的,**但是对于符合 a = 1 的二级索引记录的范围里,b 字段的值是「有序」的**. - -于是,在确定需要扫描的二级索引的范围时,当二级索引记录的 a 字段值为 1 时,可以通过 b = 2 条件减少需要扫描的二级索引记录范围(b 字段可以利用联合索引进行索引查询的意思)。也就是说,从符合 a = 1 and b = 2 条件的第一条记录开始扫描,而不需要从第一个 a 字段值为 1 的记录开始扫描。 - -所以,**Q2 这条查询语句 a 和 b 字段都用到了联合索引进行索引查询**。 - - - -> **`SELECT * FROM t_table WHERE a BETWEEN 2 AND 8 AND b = 2`,联合索引(a, b)哪一个字段用到了联合索引的 B+Tree?** - -在 MySQL 中,BETWEEN 包含了 value1 和 value2 边界值,类似于 >= and =<,所以**这条查询语句 a 和 b 字段都用到了联合索引进行索引查询**。 - - - -> **`SELECT * FROM t_user WHERE name like 'j%' and age = 22`,联合索引(name, age)哪一个字段用到了联合索引的 B+Tree?** - -由于联合索引(二级索引)是先按照 name 字段的值排序的,所以前缀为 ‘j’ 的 name 字段的二级索引记录都是相邻的, 于是在进行索引扫描的时候,可以定位到符合前缀为 ‘j’ 的 name 字段的第一条记录,然后沿着记录所在的链表向后扫描,直到某条记录的 name 前缀不为 ‘j’ 为止。 - -虽然在符合前缀为 ‘j’ 的 name 字段的二级索引记录的范围里,age 字段的值是「无序」的,**但是对于符合 name = j 的二级索引记录的范围里,age字段的值是「有序」的** - -所以,**这条查询语句 a 和 b 字段都用到了联合索引进行索引查询**。 - - - -**联合索引的最左匹配原则,在遇到范围查询(如 >、<)的时候,就会停止匹配,也就是范围查询的字段可以用到联合索引,但是在范围查询字段的后面的字段无法用到联合索引。注意,对于 >=、<=、BETWEEN、like 前缀匹配的范围查询,并不会停止匹配**。 - - - -**索引下推:** - -对于联合索引(a, b),在执行 `select * from table where a > 1 and b = 2` 语句的时候,只有 a 字段能用到索引,那在联合索引的 B+Tree 找到第一个满足条件的主键值后,还需要判断其他条件是否满足(看 b 是否等于 2),那是在联合索引里判断?还是回主键索引去判断呢? - -- 在 MySQL 5.6 之前,只能从 ID2 (主键值)开始一个个回表,到「主键索引」上找出数据行,再对比 b 字段值。 -- 而 MySQL 5.6 引入的**索引下推优化**(index condition pushdown), **可以在联合索引遍历过程中,对联合索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数**。 - - - -**联合索引进行排序**: - -这里出一个题目,针对针对下面这条 SQL,你怎么通过索引来提高查询效率呢? - -```sql -select * from order where status = 1 order by create_time asc -``` - -有的同学会认为,单独给 status 建立一个索引就可以了。 - -但是更好的方式给 status 和 create_time 列建立一个联合索引,因为这样可以避免 MySQL 数据库发生文件排序。 - -因为在查询时,如果只用到 status 的索引,但是这条语句还要对 create_time 排序,这时就要用文件排序 filesort,也就是在 SQL 执行计划中,Extra 列会出现 Using filesort。 - -所以,要利用索引的有序性,在 status 和 create_time 列建立联合索引,这样根据 status 筛选后的数据就是按照 create_time 排好序的,避免在文件排序,提高了查询效率。 - - - - - -### 索引设计原则 - -什么时候适合索引? - -1. 针对数据量较大,且查询比较繁琐的表建立索引; - -2. 针对于常作为查询条件(where),排序(order by),分组(group by)操作的字段,建立索引; - -3. 尽量选择区分度高的列作为索引,尽量建立唯一索引,区分度越高使用索引的效率越高; - -4. 如果是字符串类型的字段,字段的长度过长,可以针对字段的特点,建立前缀索引; - -5. 尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以覆盖索引,节省存储空间,避免回表,提高查询效率; - -6. 要控制索引的数量,索引并不是多多益善,索引越多,维护索引结构的代价也就越大,会影响增删改的效率; - -7. 如果索引列不能存储null值,在创建表时使用not null约束它。当优化器知道每列是否包含null值时,它可以更好地确定哪个索引最有效地用于查询。 - -8. 表的主外键或连表字段,必须建立索引,因为能很大程度提升连表查询的性能。 - - - -什么时候不适合索引? - -1. 大量重复值的字段 -2. 当表的数据较少,不应当建立索引,因为数据量不大时,维护索引反而开销更大。 -3. 经常更新的字段,因为索引字段频繁修改,由于要维护 B+Tree的有序性,那么就需要频繁的重建索引,这个过程是会影响数据库性能。 -4. 索引不能参与计算,因此经常带函数查询的字段,并不适合建立索引。 -5. 一张表中的索引数量并不是越多越好,一般控制在`3`,最多不能超过`5`。 -6. 索引的字段值无序时,不推荐建立索引,因为会造成页分裂,尤其是主键索引。 - - - -### 索引优化方法 - -#### 前缀索引优化 - -当字段类型为字符串(varchar, text, longtext等)时,有时候需要索引很长的字符串,导致索引较大,查询是浪费大量的磁盘IO,影响查询效率。此时可以只对字符串的一部分前缀建立索引,节约索引空间,提高索引效率。 - -不过,前缀索引有一定的局限性,例如: - -- order by 就无法使用前缀索引; -- 无法把前缀索引用作覆盖索引; - - - -#### 覆盖索引优化 - -SQL 中 query 的所有字段,在索引 B+Tree 的叶子节点上都能得到,从而不需要通过聚簇索引查询获得,可以避免回表的操作。 - -使用覆盖索引的好处就是,不需要查询出包含整行记录的所有信息,也就减少了大量的 I/O 操作。 - - - -#### 主键索引自增 - -**如果我们使用自增主键**,那么每次插入的新数据就会按顺序添加到当前索引节点的位置,不需要移动已有的数据,当页面写满,就会自动开辟一个新页面。因为每次**插入一条新记录,都是追加操作,不需要重新移动数据**,因此这种插入数据的方法效率非常高。 - -**如果我们使用非自增主键**,由于每次插入主键的索引值都是随机的,因此每次插入新的数据时,就可能会插入到现有数据页中间的某个位置,这将不得不移动其它数据来满足新数据的插入,甚至需要从一个页面复制数据到另外一个页面,我们通常将这种情况称为**页分裂**。**页分裂还有可能会造成大量的内存碎片,导致索引结构不紧凑,从而影响查询效率**。 - - - -主键字段的长度不要太大,因为**主键字段长度越小,意味着二级索引的叶子节点越小(二级索引的叶子节点存放的数据是主键值),这样二级索引占用的空间也就越小**。 - - - -#### 索引最好设置为 NOT NULL - -为了更好的利用索引,索引列要设置为 NOT NULL 约束。有两个原因: - -- 第一原因:索引列存在 NULL 就会导致优化器在做索引选择的时候更加复杂,更加难以优化,因为可为 NULL 的列会使索引、索引统计和值比较都更复杂,比如进行索引统计时,count 会省略值为NULL 的行。 -- 第二个原因:NULL 值是一个没意义的值,但是它会占用物理空间,所以会带来的存储空间的问题,因为 InnoDB 存储记录的时候,如果表中存在允许为 NULL 的字段,那么行格式中**至少会用 1 字节空间存储 NULL 值列表** - - - -### 索引失效 - -1. 左或左右模糊查询 `like %x 或者 like %x%`。 因为索引 B+ 树是按照「索引值」有序排列存储的,只能根据前缀进行比较。 -2. 查询中对索引做了计算、函数、类型转换操作。因为索引保存的是索引字段的原始值,而不是经过函数计算后的值,自然就没办法走索引了。 -3. 联合索引要遵循最左匹配原则 -4. 联合索引中,出现范围查询(>,<),范围查询右侧的列索引失效。 -5. 在 WHERE 子句中,如果在 OR 前的条件列是索引列,而在 OR 后的条件列不是索引列,那么索引会失效。因为 OR 的含义就是两个只要满足一个即可,只要有条件列不是索引列,就会进行全表扫描。 -6. 隐式类型转换 - - - -**索引隐式类型转换**: - -如果索引字段是字符串类型,但是在条件查询中,输入的参数是整型的话,你会在执行计划的结果发现这条语句会走全表扫描; - -但是如果索引字段是整型类型,查询条件中的输入参数即使字符串,是不会导致索引失效,还是可以走索引扫描。 - -MySQL 在遇到字符串和数字比较的时候,会自动把字符串转为数字,然后再进行比较。 - - - -### SQL提示 - -我们在查询时,可以使用mysql的sql提示,加入一些人为的提示来达到优化操作的目的: - -- user index:建议mysql使用哪一个索引完成此次查询(仅仅是建议,mysql内部还会再次进行评估); -- ignore index:忽略指定的索引; -- force index:强制使用索引。 - -```sql -explain select * from tb_user use index(idx_user_pro) where profession=’软件工程’; -explain select * from tb_user use index(idx_user_pro) where profession=’软件工程’; -explain select * from tb_user force index(idx_user_pro) where profession=’软件工程’; -``` - - - -## 事务 - -> **事务的特性** - -* **原子性**:一个事务中的所有操作,要么全部完成,要么全部不完成 - -* **一致性**:是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态。 - -* **隔离性**:多个事务同时使用相同的数据时,不会相互干扰,每个事务都有一个完整的数据空间,对其他并发事务是隔离的 - -* **持久性**:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。 - - - -> **InnoDB 引擎通过什么技术来保证事务的这四个特性的呢?** - -- 持久性是通过 redo log (重做日志)来保证的,宕机后能数据恢复; -- 原子性是通过 undo log(回滚日志) 来保证的,事务能够进行回滚; -- 隔离性是通过 MVCC(多版本并发控制) 或锁机制来保证的; -- 一致性则是通过持久性+原子性+隔离性来保证; - - - -> **并行事务会引发的问题?** - -* **脏读**:读到其他事务未提交的数据; - -* **不可重复读**:一个事务内,前后读取的数据不一致; - -* **幻读**:一个事务中,前后读取的记录数量不一致。事务中同一个查询在不同的时间产生不同的结果集。 - -严重性:脏读 > 不可重读读 > 幻读 - - - -> **隔离级别** - -- **读未提交(read uncommitted)**,指一个事务还没提交时,它做的变更就能被其他事务看到; -- **读提交(read committed)**,指一个事务提交之后,它做的变更才能被其他事务看到; -- **可重复读(repeatable read)**,指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,**MySQL InnoDB 引擎的默认隔离级别**; -- **串行化(serializable)**;会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行; - - - - - -> **这四种隔离级别具体是如何实现的呢?** - -- 对于「读未提交」隔离级别的事务来说,因为可以读到未提交事务修改的数据,所以直接读取最新的数据就好了; -- 对于「串行化」隔离级别的事务来说,通过加读写锁的方式来避免并行访问; -- 对于「读提交」和「可重复读」隔离级别的事务来说,它们是通过 **Read View 来实现的,它们的区别在于创建 Read View 的时机不同,大家可以把 Read View 理解成一个数据快照,就像相机拍照那样,定格某一时刻的风景。「读提交」隔离级别是在「每个语句执行前」都会重新生成一个 Read View,而「可重复读」隔离级别是「启动事务时」生成一个 Read View,然后整个事务期间都在用这个 Read View**。 - - - -> **MVCC** - -* Read View 中的字段:活跃事务(启动但还未提交的事务)id列表、活跃事务中的最小事务id、下一个事务id、创建RV的事务id -* 聚簇索引记录中两个跟事务有关的隐藏列:事务id、旧版本指针 - -![](.\MySql\mvcc.webp) - -一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况: - -- 如果记录的 trx_id 值小于 Read View 中的 `min_trx_id` 值,表示这个版本的记录是在创建 Read View **前**已经提交的事务生成的,所以该版本的记录对当前事务**可见**。 -- 如果记录的 trx_id 值大于等于 Read View 中的 `max_trx_id` 值,表示这个版本的记录是在创建 Read View **后**才启动的事务生成的,所以该版本的记录对当前事务**不可见**。 -- 如果在二者之间,需要判断 trx_id 是否在 m_ids 列表中: - - 如果记录的 trx_id **在** `m_ids` 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务**不可见**。 - - 如果记录的 trx_id **不在** `m_ids`列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务**可见**。 - - - -> **可重复读如何工作的?** - -**可重复读隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View**。 - -如果记录中的 trx_id 小于 Read View中的最小事务id,或者在最小事务id和下一个事务id之间并且不在活跃事务id中,则直接读取记录值; - -如果记录中的 trx_id 在最小事务id和下一个事务id之间并且在活跃事务id中,则**沿着 undo log 旧版本指针往下找旧版本的记录,直到找到 trx_id 「小于」当前事务 的 Read View 中的 min_trx_id 值的第一条记录**。 - - - -> **读已提交如何工作的?** - -**读提交隔离级别是在每次读取数据时,都会生成一个新的 Read View**。 - - - -> **MySQL 可重复读和幻读** - -MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象(并不是完全解决了),解决的方案有两种: - -- 针对**快照读**(普通 select 语句),是**通过 MVCC 方式解决了幻读**,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。 -- 针对**当前读**(select ... for update 等语句,会读取最新的数据),是**通过 next-key lock(记录锁+间隙锁)方式解决了幻读**,因为当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内执行增、删、改时,就会阻塞,所以就很好了避免幻读问题。 - - - -> **MySQL Innodb 中的 MVCC 并不能完全避免幻读现象** - -第一个发生幻读现象的场景: - -在可重复读隔离级别下,事务 A 第一次执行普通的 select 语句时生成了一个 ReadView,之后事务 B 向表中新插入了一条 id = 5 的记录并提交。接着,事务 A 对 id = 5 这条记录进行了更新操作,在这个时刻,这条新记录的 trx_id 隐藏列的值就变成了事务 A 的事务 id,之后事务 A 再使用普通 select 语句去查询这条记录时就可以看到这条记录了,于是就发生了幻读。 - -![](.\MySql\幻读发生.drawio.webp) - -第二个发生幻读现象的场景: - -T1 时刻:事务 A 先执行「快照读语句」:select * from t_test where id > 100 得到了 3 条记录。 - -T2 时刻:事务 B 往插入一个 id= 200 的记录并提交; - -T3 时刻:事务 A 再执行「当前读语句」 select * from t_test where id > 100 for update 就会得到 4 条记录,此时也发生了幻读现象。 - -**要避免这类特殊场景下发生幻读的现象的话,就是尽量在开启事务之后,马上执行 select ... for update 这类当前读的语句**,因为它会对记录加 next-key lock,从而避免其他事务插入一条新记录。 - - - -## 锁 - -### 全局锁 - -使用全局锁 `flush tables with read lock` 后数据库处于只读状态,`unlock tables` 释放全局锁,会话断开全局锁自动释放。 - -应用场景:全库逻辑备份 - -如果数据库的引擎支持的事务支持**可重复读的隔离级别**,那么在备份数据库之前先开启事务,会先创建 Read View,然后整个事务执行期间都在用这个 Read View,而且由于 MVCC 的支持,备份期间业务依然可以对数据进行更新操作。 - -备份数据库的工具是 mysqldump,在使用 mysqldump 时加上 `–single-transaction` 参数的时候,就会在备份数据库之前先开启事务。这种方法只适用于支持「可重复读隔离级别的事务」的存储引擎。 - - - -### 表级锁 - -MySql表级锁:表锁、元数据锁、意向锁、AUTO-INC锁 - -**表锁** - -```sh -//表级别的共享锁,也就是读锁; -lock tables t_student read; - -//表级别的独占锁,也就是写锁; -lock tables t_stuent write; - -// 释放会话所有表锁,会话退出后,也会释放所有表锁 -unlock tables; -``` - -表锁除了会限制别的线程的读写外,也会限制本线程接下来的读写操作。表锁的颗粒度太大,尽量避免使用。 - - - -**元数据锁(MDL)** - -我们不需要显示的使用 MDL,因为当我们对数据库表进行操作时,会自动给这个表加上 MDL。MDL 是为了保证当用户对表执行 CRUD 操作时,防止其他线程对这个表结构做了变更。 - -- 对一张表进行 CRUD 操作时,加的是 MDL 读锁; -- 对一张表做结构变更操作的时候,加的是 MDL 写锁; - -MDL 是在事务提交后才会释放,这意味着**事务执行期间,MDL 是一直持有的**。 - -申请 MDL 锁的操作会形成一个队列,队列中**写锁获取优先级高于读锁**,一旦出现 MDL 写锁等待,会阻塞后续该表的所有 CRUD 操作。 - - - -**意向锁** - -如果没有「意向锁」,那么加「独占表锁」时,就需要遍历表里所有记录,查看是否有记录存在独占锁,这样效率会很慢。 - -那么有了「意向锁」,由于在对记录加独占锁前,先会加上表级别的意向独占锁,那么在加「独占表锁」时,直接查该表是否有意向独占锁,如果有就意味着表里已经有记录被加了独占锁,这样就不用去遍历表里的记录。 - -所以,**意向锁的目的是为了快速判断表里是否有记录被加锁**。 - -- 在使用 InnoDB 引擎的表里对某些记录加上「共享锁」之前,需要先在表级别加上一个「意向共享锁」; -- 在使用 InnoDB 引擎的表里对某些纪录加上「独占锁」之前,需要先在表级别加上一个「意向独占锁」; - -**意向共享锁和意向独占锁是表级锁,不会和行级的共享锁和独占锁发生冲突,而且意向锁之间也不会发生冲突,只会和共享表锁和独占表锁发生冲突。** - - - -**AUTO-INC 锁** - -声明 `AUTO_INCREMENT` 属性的字段数据库自动赋递增的值,主要是通过 AUTO-INC 锁实现的。 - -AUTO-INC 锁是特殊的表锁机制,锁**不再是一个事务提交后才释放,而是再执行完插入语句后就会立即释放**。 - -在插入数据时,会加一个表级别的 AUTO-INC 锁,然后为被 `AUTO_INCREMENT` 修饰的字段赋值递增的值,等插入语句执行完成后,才会把 AUTO-INC 锁释放掉。 - -在 MySQL 5.1.22 版本开始,InnoDB 存储引擎提供了一种**轻量级的锁**来实现自增。一样也是在插入数据的时候,会为被 `AUTO_INCREMENT` 修饰的字段加上轻量级锁,**然后给该字段赋值一个自增的值,就把这个轻量级锁释放了,而不需要等待整个插入语句执行完后才释放锁**。 - - - -### 行级锁 - -共享锁(S锁)满足读读共享,读写互斥。独占锁(X锁)满足写写互斥、读写互斥。 - -在读已提交隔离级别下,行级锁的种类只有记录锁,也就是仅仅把一条记录锁上。 - -在可重复读隔离级别下,行级锁的种类除了有记录锁,还有间隙锁(目的是为了避免幻读) - -**Record Lock** - -记录锁,也就是仅仅把一条记录锁上; - - - -**Gap Lock** - -间隙锁,锁定一个范围,但是不包含记录本身; - -间隙锁,只存在于可重复读隔离级别,**目的是为了解决可重复读隔离级别下幻读的现象**。 - -**间隙锁之间是兼容的,即两个事务可以同时持有包含共同间隙范围的间隙锁,并不存在互斥关系,因为间隙锁的目的是防止插入幻影记录而提出的**。 - - - -**Next-Key Lock** - -临键锁,Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。 - -**如果一个事务获取了 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,是会被阻塞的。** - - - -**插入意向锁** - -一个事务在插入一条记录的时候,需要判断插入位置是否已被其他事务加了间隙锁(next-key lock 也包含间隙锁)。 - -如果有的话,插入操作就会发生**阻塞**,直到拥有间隙锁的那个事务提交为止(释放间隙锁的时刻),在此期间会生成一个**插入意向锁**,表明有事务想在某个区间插入新记录,但是现在处于等待状态。 - - - -### 怎么加行级锁 - -> **什么SQL语句会加行级锁?** - -1. 普通的 select 语句是不会对记录加锁的(除了串行化隔离级别),因为它属于快照读,是通过 MVCC(多版本并发控制)实现的。如果要在查询时对记录加行级锁,可以使用下面这两个方式,这两种查询会加锁的语句称为**锁定读**。 - - ```sql - //对读取的记录加共享锁(S型锁) - select ... lock in share mode; - - //对读取的记录加独占锁(X型锁) - select ... for update; - ``` - - 这两条语句必须在一个事务中,因为当事务提交了,锁就会被释放,所以在使用这两条语句的时候,要加上 begin 或者 start transaction 开启事务的语句。 - -2. update 和 delete 操作都会加行级锁,且锁的类型都是独占锁(X型锁)。 - - - -> **行级锁有哪些?** - -在读已提交隔离级别下,行级锁的种类只有记录锁,也就是仅仅把一条记录锁上。 - -在可重复读隔离级别下,行级锁的种类除了有记录锁,还有间隙锁(目的是为了避免幻读),所以行级锁的种类主要有三类: - -- Record Lock,记录锁,也就是仅仅把一条记录锁上; -- Gap Lock,间隙锁,锁定一个范围,但是不包含记录本身; -- Next-Key Lock:Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。 - - - -> **有什么命令可以分析加了什么锁?** - -`select * from performance_schema.data_locks\G;` - -LOCK_TYPE 中的 RECORD 表示行级锁,而不是记录锁的意思。 - -LOCK_MODE 可以确认是 next-key 锁,还是间隙锁,还是记录锁: - -- 如果 LOCK_MODE 为 `X`,说明是 next-key 锁; -- 如果 LOCK_MODE 为 `X, REC_NOT_GAP`,说明是记录锁; -- 如果 LOCK_MODE 为 `X, GAP`,说明是间隙锁; - -![](.\MySql\事务a加锁分析.webp) - - - -**加锁的对象是索引,加锁的基本单位是 next-key lock**,它是由记录锁和间隙锁组合而成的,**next-key lock 是前开后闭区间,而间隙锁是前开后开区间**。 - -但是,next-key lock 在一些场景下会退化成记录锁或间隙锁。 - -那到底是什么场景呢?总结一句,**在能使用记录锁或者间隙锁就能避免幻读现象的场景下, next-key lock 就会退化成记录锁或间隙锁**。 - - - -#### 唯一索引等值查询 - -当我们用唯一索引进行等值查询的时候,查询的记录存不存在,加锁的规则也会不同: - -- 当查询的记录是「存在」的,在索引树上定位到这一条记录后,将该记录的索引中的 next-key lock 会**退化成「记录锁」**。 -- 当查询的记录是「不存在」的,在索引树找到第一条大于该查询记录的记录后,将该记录的索引中的 next-key lock 会**退化成「间隙锁」**。 - - - -> **为什么唯一索引等值查询并且查询记录存在的场景下,该记录的索引中的 next-key lock 会退化成记录锁?** - -原因就是在唯一索引等值查询并且查询记录存在的场景下,仅靠记录锁也能避免幻读的问题。 - -幻读的定义就是,当一个事务前后两次查询的结果集,不相同时,就认为发生幻读。所以,要避免幻读就是避免结果集某一条记录被其他事务删除,或者有其他事务插入了一条新记录,这样前后两次查询的结果集就不会出现不相同的情况。 - -- 由于主键具有唯一性,所以**其他事务插入 id = 1 的时候,会因为主键冲突,导致无法插入 id = 1 的新记录**。这样事务 A 在多次查询 id = 1 的记录的时候,不会出现前后两次查询的结果集不同,也就避免了幻读的问题。 -- 由于对 id = 1 加了记录锁,**其他事务无法删除该记录**,这样事务 A 在多次查询 id = 1 的记录的时候,不会出现前后两次查询的结果集不同,也就避免了幻读的问题。 - - - -> **为什么唯一索引等值查询并且查询记录「不存在」的场景下,在索引树找到第一条大于该查询记录的记录后,要将该记录的索引中的 next-key lock 会退化成「间隙锁」?** - -原因就是在唯一索引等值查询并且查询记录不存在的场景下,仅靠间隙锁就能避免幻读的问题。 - -- 为什么 id = 5 记录上的主键索引的锁不可以是 next-key lock?如果是 next-key lock,就意味着其他事务无法删除 id = 5 这条记录,但是这次的案例是查询 id = 2 的记录,只要保证前后两次查询 id = 2 的结果集相同,就能避免幻读的问题了,所以即使 id =5 被删除,也不会有什么影响,那就没必须加 next-key lock,因此只需要在 id = 5 加间隙锁,避免其他事务插入 id = 2 的新记录就行了。 -- 为什么不可以针对不存在的记录加记录锁?锁是加在索引上的,而这个场景下查询的记录是不存在的,自然就没办法锁住这条不存在的记录。 - - - -#### 唯一索引范围查询 - -当唯一索引进行范围查询时,**会对每一个扫描到的索引加 next-key 锁,然后如果遇到下面这些情况,会退化成记录锁或者间隙锁**: - -- 情况一:针对「大于等于」的范围查询,因为存在等值查询的条件,那么如果等值查询的记录是存在于表中,那么该记录的索引中的 next-key 锁会**退化成记录锁**。 -- 情况二:针对「小于或者小于等于」的范围查询,要看条件值的记录是否存在于表中: - - 当条件值的记录不在表中,那么不管是「小于」还是「小于等于」条件的范围查询,**扫描到终止范围查询的记录时,该记录的索引的 next-key 锁会退化成间隙锁**,其他扫描到的记录,都是在这些记录的索引上加 next-key 锁。 - - 当条件值的记录在表中,如果是「小于」条件的范围查询,**扫描到终止范围查询的记录时,该记录的索引的 next-key 锁会退化成间隙锁**,其他扫描到的记录,都是在这些记录的索引上加 next-key 锁;如果「小于等于」条件的范围查询,扫描到终止范围查询的记录时,该记录的索引 next-key 锁不会退化成间隙锁。其他扫描到的记录,都是在这些记录的索引上加 next-key 锁。 - - - -#### 非唯一索引等值查询 - -当我们用非唯一索引进行等值查询的时候,**因为存在两个索引,一个是主键索引,一个是非唯一索引(二级索引),所以在加锁时,同时会对这两个索引都加锁,但是对主键索引加锁的时候,只有满足查询条件的记录才会对它们的主键索引加锁**。 - -针对非唯一索引等值查询时,查询的记录存不存在,加锁的规则也会不同: - -- 当查询的记录「存在」时,由于不是唯一索引,所以肯定存在索引值相同的记录,于是**非唯一索引等值查询的过程是一个扫描的过程,直到扫描到第一个不符合条件的二级索引记录就停止扫描,然后在扫描的过程中,对扫描到的二级索引记录加的是 next-key 锁,而对于第一个不符合条件的二级索引记录,该二级索引的 next-key 锁会退化成间隙锁。同时,在符合查询条件的记录的主键索引上加记录锁**。 -- 当查询的记录「不存在」时,**扫描到第一条不符合条件的二级索引记录,该二级索引的 next-key 锁会退化成间隙锁。因为不存在满足查询条件的记录,所以不会对主键索引加锁**。 - - - -> **针对非唯一索引等值查询时,查询的值不存在的情况。** - -执行 `select * from user where age = 25 for update;` 定位到第一条不符合查询条件的二级索引记录,即扫描到 age = 39,于是**该二级索引的 next-key 锁会退化成间隙锁,范围是 (22, 39)**。 - -![](.\MySql\非唯一索引等值查询age=25.drawio.webp) - -其他事务无法插入 age 值为 23、24、25、26、....、38 这些新记录。不过对于插入 age = 22 和 age = 39 记录的语句,在一些情况是可以成功插入的,而一些情况则无法成功插入。 - -**插入语句在插入一条记录之前,需要先定位到该记录在 B+树 的位置,如果插入的位置的下一条记录的索引上有间隙锁,才会发生阻塞**。 - -插入 age = 22 记录的成功和失败的情况分别如下: - -- 当其他事务插入一条 age = 22,id = 3 的记录的时候,在二级索引树上定位到插入的位置,而该位置的下一条是 id = 10、age = 22 的记录,该记录的二级索引上没有间隙锁,所以这条插入语句可以执行成功。 -- 当其他事务插入一条 age = 22,id = 12 的记录的时候,在二级索引树上定位到插入的位置,而该位置的下一条是 id = 20、age = 39 的记录,正好该记录的二级索引上有间隙锁,所以这条插入语句会被阻塞,无法插入成功。 - -插入 age = 39 记录的成功和失败的情况分别如下: - -- 当其他事务插入一条 age = 39,id = 3 的记录的时候,在二级索引树上定位到插入的位置,而该位置的下一条是 id = 20、age = 39 的记录,正好该记录的二级索引上有间隙锁,所以这条插入语句会被阻塞,无法插入成功。 -- 当其他事务插入一条 age = 39,id = 21 的记录的时候,在二级索引树上定位到插入的位置,而该位置的下一条记录不存在,也就没有间隙锁了,所以这条插入语句可以插入成功。 - -所以,**当有一个事务持有二级索引的间隙锁 (22, 39) 时,插入 age = 22 或者 age = 39 记录的语句是否可以执行成功,关键还要考虑插入记录的主键值,因为「二级索引值(age列)+主键值(id列)」才可以确定插入的位置,确定了插入位置后,就要看插入的位置的下一条记录是否有间隙锁,如果有间隙锁,就会发生阻塞,如果没有间隙锁,则可以插入成功**。 - - - -> **针对非唯一索引等值查询时,查询的值存在的情况。** - -执行 `select * from user where age = 22 for update;` - -![](.\MySql\非唯一索引等值查询存在.drawio.webp) - -在 age = 22 这条记录的二级索引上,加了范围为 (21, 22] 的 next-key 锁,意味着其他事务无法更新或者删除 age = 22 的这一些新记录,不过对于插入 age = 21 和 age = 22 新记录的语句,在一些情况是可以成功插入的,而一些情况则无法成功插入。 - -- 是否可以插入 age = 21 的新记录,还要看插入的新记录的 id 值,**如果插入 age = 21 新记录的 id 值小于 5,那么就可以插入成功**,因为此时插入的位置的下一条记录是 id = 5,age = 21 的记录,该记录的二级索引上没有间隙锁。**如果插入 age = 21 新记录的 id 值大于 5,那么就无法插入成功**,因为此时插入的位置的下一条记录是 id = 10,age = 22 的记录,该记录的二级索引上有间隙锁。 -- 是否可以插入 age = 22 的新记录,还要看插入的新记录的 id 值,从 `LOCK_DATA : 22, 10` 可以得知,其他事务插入 age 值为 22 的新记录时,**如果插入的新记录的 id 值小于 10,那么插入语句会发生阻塞;如果插入的新记录的 id 大于 10,还要看该新记录插入的位置的下一条记录是否有间隙锁,如果没有间隙锁则可以插入成功,如果有间隙锁,则无法插入成功**。 - -在 age = 39 这条记录的二级索引上,加了范围 (22, 39) 的间隙锁。意味着其他事务无法插入 age 值为 23、24、..... 、38 的这一些新记录。不过对于插入 age = 22 和 age = 39 记录的语句,在一些情况是可以成功插入的,而一些情况则无法成功插入。 - -- 是否可以插入 age = 22 的新记录,还要看插入的新记录的 id 值,**如果插入 age = 22 新记录的 id 值小于 10,那么插入语句会被阻塞,无法插入**,因为此时插入的位置的下一条记录是 id = 10,age = 22 的记录,该记录的二级索引上有间隙锁( age = 22 这条记录的二级索引上有 next-key 锁)。**如果插入 age = 22 新记录的 id 值大于 10,也无法插入**,因为此时插入的位置的下一条记录是 id = 20,age = 39 的记录,该记录的二级索引上有间隙锁。 -- 是否可以插入 age = 39 的新记录,还要看插入的新记录的 id 值,从 `LOCK_DATA : 39, 20` 可以得知,其他事务插入 age 值为 39 的新记录时,**如果插入的新记录的 id 值小于 20,那么插入语句会发生阻塞,如果插入的新记录的 id 大于 20,则可以插入成功**。 - - - -#### 非唯一索引范围查询 - -**非唯一索引范围查询,索引的 next-key lock 不会有退化为间隙锁和记录锁的情况**,也就是非唯一索引进行范围查询时,对二级索引记录加锁都是加 next-key 锁。 - -执行 `select * from user where age >= 22 for update;` - -![](.\MySql\非唯一索引范围查询age大于等于22.drawio.webp) - -是否可以插入age = 21、age = 22 和 age = 39 的新记录,还需要看新记录的 id 值。 - -> **在 age >= 22 的范围查询中,明明查询 age = 22 的记录存在并且属于等值查询,为什么不会像唯一索引那样,将 age = 22 记录的二级索引上的 next-key 锁退化为记录锁?** - -因为 age 字段是非唯一索引,不具有唯一性,所以如果只加记录锁(记录锁无法防止插入,只能防止删除或者修改),就会导致其他事务插入一条 age = 22 的记录,这样前后两次查询的结果集就不相同了,出现了幻读现象。 - -#### 没有索引的查询 - -**如果锁定读查询语句,没有使用索引列作为查询条件,或者查询语句没有走索引查询,导致扫描是全表扫描。那么,每一条记录的索引上都会加 next-key 锁,这样就相当于锁住的全表,这时如果其他事务对该表进行增、删、改操作的时候,都会被阻塞**。 - -不只是锁定读查询语句不加索引才会导致这种情况,update 和 delete 语句如果查询条件不加索引,那么由于扫描的方式是全表扫描,于是就会对每一条记录的索引上都会加 next-key 锁,这样就相当于锁住的全表。 - -因此,**在线上在执行 update、delete、select ... for update 等具有加锁性质的语句,一定要检查语句是否走了索引,如果是全表扫描的话,会对每一个索引加 next-key 锁,相当于把整个表锁住了**,这是挺严重的问题。 - - - -### Insert语句怎么加行级锁 - -Insert 语句在正常执行时是不会生成锁结构的,它是靠聚簇索引记录自带的 trx_id 隐藏列来作为**隐式锁**来保护记录的。 - -> 什么是隐式锁? - -当事务需要加锁的时,如果这个锁不可能发生冲突,InnoDB会跳过加锁环节,这种机制称为隐式锁。隐式锁是 InnoDB 实现的一种延迟加锁机制,其特点是只有在可能发生冲突时才加锁,从而减少了锁的数量,提高了系统整体性能。 - -隐式锁就是在 Insert 过程中不加锁,只有在特殊情况下,才会将隐式锁转换为显示锁,这里我们列举两个场景。 - -- 如果记录之间加有间隙锁,为了避免幻读,此时是不能插入记录的; -- 如果 Insert 的记录和已有记录存在唯一键冲突,此时也不能插入记录; - -**记录之间加有间隙锁:** - -每插入一条新记录,都需要看一下待插入记录的下一条记录上是否已经被加了间隙锁,如果已加间隙锁,此时会生成一个插入意向锁,然后锁的状态设置为等待状态(*PS:MySQL 加锁时,是先生成锁结构,然后设置锁的状态,如果锁状态是等待状态,并不是意味着事务成功获取到了锁,只有当锁状态为正常状态时,才代表事务成功获取到了锁*),现象就是 Insert 语句会被阻塞。 - -**遇到唯一键冲突:** - -如果在插入新记录时,插入了一个与「已有的记录的主键或者唯一二级索引列值相同」的记录(不过可以有多条记录的唯一二级索引列的值同时为NULL,这里不考虑这种情况),此时插入就会失败,然后对于这条记录加上了 **S 型的锁**。 - -- 如果主键索引重复,插入新记录的事务会给已存在的主键值重复的聚簇索引记录**添加 S 型记录锁**。 -- 如果唯一二级索引重复,插入新记录的事务都会给已存在的二级索引列值重复的二级索引记录**添加 S 型 next-key 锁** - - - -> **两个事务执行过程中,执行了相同的 insert 语句的场景** - -![](.\MySql\唯一索引加锁.drawio.webp) - -两个事务的加锁过程: - -- 事务 A 先插入 order_no 为 1006 的记录,可以插入成功,此时对应的唯一二级索引记录被「隐式锁」保护,此时还没有实际的锁结构(执行完这里的时候,你可以看查 performance_schema.data_locks 信息,可以看到这条记录是没有加任何锁的); -- 接着,事务 B 也插入 order_no 为 1006 的记录,由于事务 A 已经插入 order_no 值为 1006 的记录,所以事务 B 在插入二级索引记录时会遇到重复的唯一二级索引列值,此时事务 B 想获取一个 S 型 next-key 锁,但是事务 A 并未提交,**事务 A 插入的 order_no 值为 1006 的记录上的「隐式锁」会变「显示锁」且锁类型为 X 型的记录锁,所以事务 B 向获取 S 型 next-key 锁时会遇到锁冲突,事务 B 进入阻塞状态**。 - -并发多个事务的时候,第一个事务插入的记录,并不会加锁,而是会用隐式锁保护唯一二级索引的记录。 - -但是当第一个事务还未提交的时候,有其他事务插入了与第一个事务相同的记录,第二个事务就会**被阻塞**,**因为此时第一事务插入的记录中的隐式锁会变为显示锁且类型是 X 型的记录锁,而第二个事务是想对该记录加上 S 型的 next-key 锁,X 型与 S 型的锁是冲突的**,所以导致第二个事务会等待,直到第一个事务提交后,释放了锁。 - - - -## 日志 - -**undo log(回滚日志)**:是 Innodb 存储引擎层生成的日志,实现了事务中的**原子性**,主要**用于事务回滚和 MVCC**。 - -**redo log(重做日志)**:是 Innodb 存储引擎层生成的日志,实现了事务中的**持久性**,主要**用于掉电等故障恢复**; - -**binlog (归档日志)**:是 Server 层生成的日志,主要**用于数据备份和主从复制**; - - - -> **redo log 和 undo log 区别在哪?** - -这两种日志是属于 InnoDB 存储引擎的日志,它们的区别在于: - -- redo log 记录了此次事务「**完成后**」的数据状态,记录的是更新**之后**的值; -- undo log 记录了此次事务「**开始前**」的数据状态,记录的是更新**之前**的值; - -事务提交之前发生了崩溃,重启后会通过 undo log 回滚事务,事务提交之后发生了崩溃,重启后会通过 redo log 恢复事务 - - - -> **redo log 要写到磁盘,数据也要写磁盘,为什么要多此一举?** - -写入 redo log 的方式使用了追加操作, 所以磁盘操作是**顺序写**,而写入数据需要先找到写入位置,然后才写到磁盘,所以磁盘操作是**随机写**。 - -磁盘的「顺序写 」比「随机写」 高效的多,因此 redo log 写入磁盘的开销更小。 - -可以说这是 WAL 技术的另外一个优点:**MySQL 的写操作从磁盘的「随机写」变成了「顺序写」**,提升语句的执行性能。这是因为 MySQL 的写操作并不是立刻更新到磁盘上,而是先记录在日志上,然后在合适的时间再更新到磁盘上 。 - -至此, 针对为什么需要 redo log 这个问题我们有两个答案: - -- **实现事务的持久性,让 MySQL 有 crash-safe(奔溃恢复) 的能力**,能够保证 MySQL 在任何时间段突然崩溃,重启后之前已提交的记录都不会丢失; -- **将写操作从「随机写」变成了「顺序写」**,提升 MySQL 写入磁盘的性能。 - - - -> **缓存在 redo log buffer 里的 redo log 还是在内存中,它什么时候刷新到磁盘?** - -主要有下面几个时机: - -- MySQL 正常关闭时; -- 当 redo log buffer 中记录的写入量大于 redo log buffer 内存空间的一半时,会触发落盘; -- InnoDB 的后台线程每隔 1 秒,将 redo log buffer 持久化到磁盘。 -- 每次事务提交时都将缓存在 redo log buffer 里的 redo log 直接持久化到磁盘 - - - ->**redo log 和 binlog 有什么区别?** - -1. 适用对象不同: - * binlog 是 MySQL 的 Server 层实现的日志,所有存储引擎都可以使用; - * redo log 是 Innodb 存储引擎实现的日志; - -2. 文件格式不同: - - - binlog 有 3 种格式类型,分别是 STATEMENT(默认格式)、ROW、 MIXED,区别如下: - - STATEMENT:每一条修改数据的 SQL 都会被记录到 binlog 中(相当于记录了逻辑操作,所以针对这种格式, binlog 可以称为逻辑日志),主从复制中 slave 端再根据 SQL 语句重现。但 STATEMENT 有动态函数的问题,比如你用了 uuid 或者 now 这些函数,你在主库上执行的结果并不是你在从库执行的结果,这种随时在变的函数会导致复制的数据不一致; - - ROW:记录行数据最终被修改成什么样了(这种格式的日志,就不能称为逻辑日志了),不会出现 STATEMENT 下动态函数的问题。但 ROW 的缺点是每行数据的变化结果都会被记录,比如执行批量 update 语句,更新多少行数据就会产生多少条记录,使 binlog 文件过大,而在 STATEMENT 格式下只会记录一个 update 语句而已; - - MIXED:包含了 STATEMENT 和 ROW 模式,它会根据不同的情况自动使用 ROW 模式和 STATEMENT 模式; - - - redo log 是物理日志,记录的是在某个数据页做了什么修改,比如对 XXX 表空间中的 YYY 数据页 ZZZ 偏移量的地方做了AAA 更新; - -3. 写入方式不同: - - - binlog 是追加写,写满一个文件,就创建一个新的文件继续写,不会覆盖以前的日志,保存的是全量的日志。 - - - redo log 是循环写,日志空间大小是固定,全部写满就从头开始,保存未被刷入磁盘的脏页日志。 - -4. 用途不同: - - - binlog 用于备份恢复、主从复制; - - - redo log 用于掉电等故障恢复。 - - - -> **binlog 什么时候刷盘?** - -在事务提交的时候,执行器把 binlog cache 里的完整事务写入到 binlog 文件中,但是并没有把数据持久化到磁盘,因为数据还缓存在文件系统的 page cache 里,后续由sync_binlog 参数来控制数据库的 binlog 刷到磁盘上的频率。 - - - -### 两阶段提交 - - - -> **为什么需要两阶段提交?** - -redo log 影响主库的数据,binlog 影响从库的数据,所以 redo log 和 binlog 必须保持一致才能保证主从数据一致。 - -MySQL 为了避免出现两份日志之间的逻辑不一致的问题,使用了「两阶段提交」来解决。 - - - -> **两阶段提交的过程是怎样的?** - -两个阶段提交就是**将 redo log 的写入拆成了两个步骤:prepare 和 commit,中间再穿插写入binlog**,具体如下: - -- **prepare 阶段**: 将 redo log 持久化到磁盘(innodb_flush_log_at_trx_commit = 1 的作用),将 redo log 对应的事务状态设置为 prepare; -- **commit 阶段**:将 binlog 持久化到磁盘(sync_binlog = 1 的作用),然后将 redo log 状态设置为 commit。 - -当在写bin log之前崩溃时:此时 binlog 还没写,redo log 也还没提交,事务会回滚。 日志保持一致 - -当在写bin log之后崩溃时: 重启恢复后虽没有commit,但满足prepare和binlog完整,自动commit。日志保持一致 - - - -> **两阶段提交有什么问题?** - -* **磁盘IO次数高**:对于“双1”配置,每个事务提交都会进行两次 fsync(刷盘),一次是 redo log 刷盘,另一次是 binlog 刷盘。 -* **锁竞争激烈**:两阶段提交虽然能够保证「单事务」两个日志的内容一致,但在「多事务」的情况下,却不能保证两者的提交顺序一致,因此,在两阶段提交的流程基础上,还需要加一个锁来保证提交的原子性,从而保证多事务的情况下,两个日志的提交顺序一致。 - - - -### 组提交 - -MySQL 引入了 binlog 组提交(group commit)机制,当有多个事务提交的时候,会将多个 binlog 刷盘操作合并成一个,从而减少磁盘 I/O 的次数。 - -引入了组提交机制后,prepare 阶段不变,只针对 commit 阶段,将 commit 阶段拆分为三个过程: - -- **flush 阶段**:多个事务按进入的顺序将 binlog 从 cache 写入文件(不刷盘); -- **sync 阶段**:对 binlog 文件做 fsync 操作(多个事务的 binlog 合并一次刷盘); -- **commit 阶段**:各个事务按顺序做 InnoDB commit 操作; - -上面的**每个阶段都有一个队列**,每个阶段有锁进行保护,因此保证了事务写入的顺序,第一个进入队列的事务会成为 leader,leader领导所在队列的所有事务,全权负责整队的操作,完成后通知队内其他事务操作结束。 - - - -## SQL优化 - -### 主键优化 - -1. 满足业务需求的情况下,尽量降低主键的长度; - -2. 插入数据时,尽量选择顺序插入,选择使用AUTO_INCREMENT自增主键; - -3. 尽量不要使用UUID做主键或者是其他自然主键,如身份证号; - -4. 业务操作时,避免对主键的修改。 - -### order by优化 - -MySQL的排序,有两种方式: - -![](MySql\orderby优化.webp) - -对于以上的两种排序方式,Using index的性能高,而Using filesort的性能低,我们在优化排序操作时,尽量要优化为 Using index。 - -order by优化原则: - -1. 根据排序字段建立合适的索引,多字段排序时,也遵循最左前缀法则; - -2. 尽量使用覆盖索引; - -3. 多字段排序, 一个升序一个降序,此时需要注意联合索引在创建时的规则(ASC/DESC); - -4. 如果不可避免的出现filesort,大数据量排序时,可以适当增大排序缓冲区大小sort_buffer_size(默认256k)。 - -### group by优化 - -在分组操作中,我们需要通过以下两点进行优化,以提升性能: - -1. 在分组操作时,可以通过索引来提高效率; - -2. 分组操作时,索引的使用也是满足最左前缀法则的。 - -### limit优化 - -在数据量比较大时,如果进行limit分页查询,在查询时,越往后,分页查询效率越低。 - -因为,当在进行分页查询时,如果执行 limit 2000000,10 ,此时需要MySQL排序前2000010 记录,仅仅返回 2000000 - 2000010 的记录,其他记录丢弃,查询排序的代价非常大 。 - -优化思路: 一般分页查询时,通过创建覆盖索引能够比较好地提高性能,可以通过覆盖索引加子查询形式进行优化。 - -```sql -explain select * from tb_sku t , (select id from tb_sku order by id limit 2000000,10) a where t.id = a.id; -``` - - - -### count优化 - -如果数据量很大,在执行count操作时,是非常耗时的。InnoDB 引擎中,它执行 count(*) 的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数。 - - - -count() 是一个聚合函数,对于返回的结果集,一行行地判断,如果 count 函数的参数不是 NULL,累计值就加 1,否则不加,最后返回累计值。 - -![](MySql\count.webp) - -性能: - -```sql -count(*) = count(1) > count(主键字段) > count(字段) -``` - -count(1)、 count(*)、 count(主键字段)在执行的时候,如果表里存在二级索引,优化器就会选择二级索引进行扫描。因为二级索引记录比聚簇索引记录占用更少的存储空间。 - -count(1)时, server 层每从 InnoDB 读取到一条记录,就将 count 变量加 1,不会读取任何字段。 - -**count(`*`) 其实等于 count(`0`)**,也就是说,当你使用 count(`*`) 时,MySQL 会将 `*` 参数转化为参数 0 来处理。**count(\*) 执行过程跟 count(1) 执行过程基本一样的** - -count(字段) 来统计记录个数,它的效率是最差的,会采用全表扫描的方式来统计。 - - - -优化思路: - -1. 近似值:使用 show table status 或者 explain 命令来表进行估算。 -2. 额外表保存计数值(redis或mysql) - - - -### update优化 - -我们主要需要注意一下update语句执行时的注意事项。 - -update course set name = 'javaEE' where id = 1 ; - -当我们在执行删除的SQL语句时,会锁定id为1这一行的数据,然后事务提交之后,行锁释放。 - -当我们开启多个事务,再执行如下SQL时: - -update course set name = 'SpringBoot' where name = 'PHP' ; - -我们发现行锁升级为了表锁。导致该update语句的性能大大降低。 - -Innodb的行锁是针对索引加的锁,不是针对记录加的锁,并且该索引不能失效,否则会从行锁升级成表锁。 - - - -## SQL性能分析 - -### sql执行频率 - -Mysql客户端链接成功后,通过以下命令可以查看当前数据库的 insert/update/delete/select 的访问频次: - -show [session|global] status like ‘com_____’; - -session: 查看当前会话; - -global: 查看全局数据; - -com insert: 插入次数; - -com select: 查询次数; - -com delete: 删除次数; - -com updat: 更新次数; - -通过查看当前数据库是以查询为主,还是以增删改为主,从而为数据库优化提供参考依据,如果以增删改为主,可以考虑不对其进行索引的优化;如果以查询为主,就要考虑对数据库的索引进行优化 - -### 慢查询日志 - -慢查询日志记录了所有执行时间超过指定参数(long_query_time,单位秒,默认10秒)的所有sql日志: - -开启慢查询日志前,需要在mysql的配置文件中(/etc/my.cnf)配置如下信息: - -1. 开启mysql慢日志查询开关: - - ``` - slow_query_log = 1 - ``` - - - -2. 设置慢日志的时间,假设为2秒,超过2秒就会被视为慢查询,记录慢查询日志: - - ``` - long_query_time=2 - ``` - - - -3. 配置完毕后,重新启动mysql服务器进行测试: - - ``` - systemctl restarmysqld - ``` - - - -4. 查看慢查询日志的系统变量,是否打开: - - ``` - show variables like “slow_query_log”; - ``` - - - -5. 查看慢日志文件中(/var/lib/mysql/localhost-slow.log)记录的信息: - - ``` - Tail -f localhost-slow.log - ``` - - - -最终发现,在慢查询日志中,只会记录执行时间超过我们预设时间(2秒)的sql,执行较快的sql不会被记录。 - - - -### Profile 详情 - -show profiles 能够在做SQL优化时帮助我们了解时间都耗费到哪里去了。 - -1. 通过 have_profiling 参数,可以看到mysql是否支持profile 操作: - - ``` - select @@have_profiling; - ``` - - - -2. 通过set 语句在session/global 级别开启profiling: - - ``` - set profiling =1; - ``` - - - -​ 开关打开后,后续执行的sql语句都会被mysql记录,并记录执行时间消耗到哪儿去了。比如执行以下几条sql语句: - -​ select * from tb_user; - -​ select * from tb_user where id = 1; - -​ select * from tb_user where name = '白起'; - -​ select count(*) from tb_sku; - - - -3. 查看每一条sql的耗时基本情况: - - ``` - show profiles; - ``` - - - -4. 查看指定的字段的sql 语句各个阶段的耗时情况: - - ``` - show profile for query Query_ID; - ``` - - - -5. 查看指定字段的sql语句cpu 的使用情况: - - ``` - show profile cpu for query Query_ID; - ``` - - - -### explain 详情 - -EXPLAIN 或者 DESC命令获取 MySQL 如何执行 SELECT 语句的信息,包括在 SELECT 语句执行过程中,表如何连接和连接的顺序。 - -语法 :直接在 select 语句之前加上关键字 explain/desc; - -![](MySQL\explain.webp) - -extra 几个重要的参考指标: - -- Using filesort :当查询语句中包含 group by 操作,而且无法利用索引完成排序操作的时候, 这时不得不选择相应的排序算法进行,甚至可能会通过文件排序,效率是很低的,所以要避免这种问题的出现。 -- Using temporary:使了用临时表保存中间结果,MySQL 在对查询结果排序时使用临时表,常见于排序 order by 和分组查询 group by。效率低,要避免这种问题的出现。 -- Using index:所需数据只需在索引即可全部获得,不须要再到表中取数据,也就是使用了覆盖索引,避免了回表操作,效率不错。 - - - -## 范式 - -第一范式:确保原子性,表中每一个列数据都必须是不可再分的字段。 - -第二范式:确保唯一性,每张表都只描述一种业务属性,一张表只描述一件事。 - -第三范式:确保独立性,表中除主键外,每个字段之间不存在任何依赖,都是独立的。 - -巴斯范式:主键字段独立性,联合主键字段之间不能存在依赖性。 - - - -### 第一范式 - -所有的字段都是基本数据字段,不可进一步拆分。 - -### 第二范式 - -在满足第一范式的基础上,还要满足数据表里的每一条数据记录,都是可唯一标识的。而且所有字段,都必须完全依赖主键,不能只依赖主键的一部分。 - -把只依赖于主键一部分的字段拆分出去,形成新的数据表。 - -### 第三范式 - -在满足第二范式的基础上,不能包含那些可以由非主键字段派生出来的字段,或者说,不能存在依赖于非主键字段的字段。 - -### 巴斯-科德范式(BCNF) - -巴斯-科德范式也被称为`3.5NF`,至于为何不称为第四范式,这主要是由于它是第三范式的补充版,第三范式的要求是:任何非主键字段不能与其他非主键字段间存在依赖关系,也就是要求每个非主键字段之间要具备独立性。而巴斯-科德范式在第三范式的基础上,进一步要求:**任何主属性不能对其他主键子集存在依赖**。也就是规定了联合主键中的某列值,不能与联合主键中的其他列存在依赖关系。 - - - -```sh -+-------------------+---------------+--------+------+--------+ -| classes | class_adviser | name | sex | height | -+-------------------+---------------+--------+------+--------+ -| 计算机-2201班 | 熊竹老师 | 竹子 | 男 | 185cm | -| 金融-2201班 | 竹熊老师 | 熊猫 | 女 | 170cm | -| 计算机-2201班 | 熊竹老师 | 子竹 | 男 | 180cm | -+-------------------+---------------+--------+------+--------+ -``` - -例如这张学生表,此时假设以`classes`班级字段、`class_adviser`班主任字段、`name`学生姓名字段,组合成一个联合主键。在这张表中,一条学生信息中的班主任,取决于学生所在的班级,因此这里需要进一步调整结构: - -```sh -SELECT * FROM `zz_classes`; -+------------+-------------------+---------------+ -| classes_id | classes_name | class_adviser | -+------------+-------------------+---------------+ -| 1 | 计算机-2201班 | 熊竹老师 | -| 2 | 金融-2201班 | 竹熊老师 | -+------------+-------------------+---------------+ - -SELECT * FROM `zz_student`; -+------------+--------+------+--------+ -| classes_id | name | sex | height | -+------------+--------+------+--------+ -| 1 | 竹子 | 男 | 185cm | -| 2 | 熊猫 | 女 | 170cm | -| 1 | 子竹 | 男 | 180cm | -+------------+--------+------+--------+ -``` - -经过结构调整后,原本的学生表则又被拆为了班级表、学生表两张,在学生表中只存储班级`ID`,然后使用`classes_id`班级`ID`和`name`学生姓名两个字段作为联合主键。 - diff --git a/docs/MySQL/MySQL/COMPACT.drawio.webp b/docs/MySQL/MySQL/COMPACT.drawio.webp deleted file mode 100644 index f7598e3..0000000 Binary files a/docs/MySQL/MySQL/COMPACT.drawio.webp and /dev/null differ diff --git a/docs/MySQL/MySQL/count.webp b/docs/MySQL/MySQL/count.webp deleted file mode 100644 index 9aa2cfa..0000000 Binary files a/docs/MySQL/MySQL/count.webp and /dev/null differ diff --git a/docs/MySQL/MySQL/explain.webp b/docs/MySQL/MySQL/explain.webp deleted file mode 100644 index 9720af6..0000000 Binary files a/docs/MySQL/MySQL/explain.webp and /dev/null differ diff --git a/docs/MySQL/MySQL/mvcc.webp b/docs/MySQL/MySQL/mvcc.webp deleted file mode 100644 index 045cffb..0000000 Binary files a/docs/MySQL/MySQL/mvcc.webp and /dev/null differ diff --git "a/docs/MySQL/MySQL/mysql\346\237\245\350\257\242\346\265\201\347\250\213.webp" "b/docs/MySQL/MySQL/mysql\346\237\245\350\257\242\346\265\201\347\250\213.webp" deleted file mode 100644 index 386c6ec..0000000 Binary files "a/docs/MySQL/MySQL/mysql\346\237\245\350\257\242\346\265\201\347\250\213.webp" and /dev/null differ diff --git "a/docs/MySQL/MySQL/null\345\200\274\345\210\227\350\241\2504.webp" "b/docs/MySQL/MySQL/null\345\200\274\345\210\227\350\241\2504.webp" deleted file mode 100644 index 80829b3..0000000 Binary files "a/docs/MySQL/MySQL/null\345\200\274\345\210\227\350\241\2504.webp" and /dev/null differ diff --git "a/docs/MySQL/MySQL/orderby\344\274\230\345\214\226.webp" "b/docs/MySQL/MySQL/orderby\344\274\230\345\214\226.webp" deleted file mode 100644 index 83c763b..0000000 Binary files "a/docs/MySQL/MySQL/orderby\344\274\230\345\214\226.webp" and /dev/null differ diff --git a/docs/MySQL/MySQL/read_view.png b/docs/MySQL/MySQL/read_view.png deleted file mode 100644 index 7a07e90..0000000 Binary files a/docs/MySQL/MySQL/read_view.png and /dev/null differ diff --git a/docs/MySQL/MySQL/redo_log.png b/docs/MySQL/MySQL/redo_log.png deleted file mode 100644 index fdf0b9a..0000000 Binary files a/docs/MySQL/MySQL/redo_log.png and /dev/null differ diff --git a/docs/MySQL/MySQL/t_test.webp b/docs/MySQL/MySQL/t_test.webp deleted file mode 100644 index 94145dd..0000000 Binary files a/docs/MySQL/MySQL/t_test.webp and /dev/null differ diff --git a/docs/MySQL/MySQL/update.png b/docs/MySQL/MySQL/update.png deleted file mode 100644 index 6b88c1e..0000000 Binary files a/docs/MySQL/MySQL/update.png and /dev/null differ diff --git "a/docs/MySQL/MySQL/\344\270\273\345\244\207\346\265\201\347\250\213.png" "b/docs/MySQL/MySQL/\344\270\273\345\244\207\346\265\201\347\250\213.png" deleted file mode 100644 index 2676ad1..0000000 Binary files "a/docs/MySQL/MySQL/\344\270\273\345\244\207\346\265\201\347\250\213.png" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\344\272\213\345\212\241a\345\212\240\351\224\201\345\210\206\346\236\220.webp" "b/docs/MySQL/MySQL/\344\272\213\345\212\241a\345\212\240\351\224\201\345\210\206\346\236\220.webp" deleted file mode 100644 index 97e1f01..0000000 Binary files "a/docs/MySQL/MySQL/\344\272\213\345\212\241a\345\212\240\351\224\201\345\210\206\346\236\220.webp" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\345\217\214M\347\273\223\346\236\204\344\270\273\345\244\207.png" "b/docs/MySQL/MySQL/\345\217\214M\347\273\223\346\236\204\344\270\273\345\244\207.png" deleted file mode 100644 index 8c9871d..0000000 Binary files "a/docs/MySQL/MySQL/\345\217\214M\347\273\223\346\236\204\344\270\273\345\244\207.png" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\345\217\230\351\225\277\345\255\227\346\256\265\351\225\277\345\272\246\345\210\227\350\241\2501.webp" "b/docs/MySQL/MySQL/\345\217\230\351\225\277\345\255\227\346\256\265\351\225\277\345\272\246\345\210\227\350\241\2501.webp" deleted file mode 100644 index ff1d58f..0000000 Binary files "a/docs/MySQL/MySQL/\345\217\230\351\225\277\345\255\227\346\256\265\351\225\277\345\272\246\345\210\227\350\241\2501.webp" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\345\224\257\344\270\200\347\264\242\345\274\225\345\212\240\351\224\201.drawio.webp" "b/docs/MySQL/MySQL/\345\224\257\344\270\200\347\264\242\345\274\225\345\212\240\351\224\201.drawio.webp" deleted file mode 100644 index 7569b67..0000000 Binary files "a/docs/MySQL/MySQL/\345\224\257\344\270\200\347\264\242\345\274\225\345\212\240\351\224\201.drawio.webp" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\345\237\272\347\241\200\346\236\266\346\236\204.png" "b/docs/MySQL/MySQL/\345\237\272\347\241\200\346\236\266\346\236\204.png" deleted file mode 100644 index ae014d6..0000000 Binary files "a/docs/MySQL/MySQL/\345\237\272\347\241\200\346\236\266\346\236\204.png" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\345\271\266\350\241\214\345\244\215\345\210\266.png" "b/docs/MySQL/MySQL/\345\271\266\350\241\214\345\244\215\345\210\266.png" deleted file mode 100644 index bbe4ee1..0000000 Binary files "a/docs/MySQL/MySQL/\345\271\266\350\241\214\345\244\215\345\210\266.png" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\345\271\273\350\257\273\345\217\221\347\224\237.drawio.webp" "b/docs/MySQL/MySQL/\345\271\273\350\257\273\345\217\221\347\224\237.drawio.webp" deleted file mode 100644 index 623a9bc..0000000 Binary files "a/docs/MySQL/MySQL/\345\271\273\350\257\273\345\217\221\347\224\237.drawio.webp" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\346\236\266\346\236\204.jpeg" "b/docs/MySQL/MySQL/\346\236\266\346\236\204.jpeg" deleted file mode 100644 index 2dc942f..0000000 Binary files "a/docs/MySQL/MySQL/\346\236\266\346\236\204.jpeg" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\346\237\245\350\257\242\346\205\242.png" "b/docs/MySQL/MySQL/\346\237\245\350\257\242\346\205\242.png" deleted file mode 100644 index 564bde3..0000000 Binary files "a/docs/MySQL/MySQL/\346\237\245\350\257\242\346\205\242.png" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\200.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\200.png" deleted file mode 100644 index 133056b..0000000 Binary files "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\200.png" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\203.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\203.png" deleted file mode 100644 index 87a8026..0000000 Binary files "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\203.png" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\211.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\211.png" deleted file mode 100644 index 271103b..0000000 Binary files "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\211.png" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\271\235.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\271\235.png" deleted file mode 100644 index 83b4539..0000000 Binary files "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\271\235.png" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\272\214.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\272\214.png" deleted file mode 100644 index fa203da..0000000 Binary files "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\272\214.png" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\272\224.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\272\224.png" deleted file mode 100644 index 60f98b3..0000000 Binary files "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\272\224.png" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\345\205\253.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\345\205\253.png" deleted file mode 100644 index f1d576a..0000000 Binary files "a/docs/MySQL/MySQL/\346\241\210\344\276\213\345\205\253.png" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\345\205\255.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\345\205\255.png" deleted file mode 100644 index 4c73bfd..0000000 Binary files "a/docs/MySQL/MySQL/\346\241\210\344\276\213\345\205\255.png" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\345\233\233.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\345\233\233.png" deleted file mode 100644 index 4b171dd..0000000 Binary files "a/docs/MySQL/MySQL/\346\241\210\344\276\213\345\233\233.png" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\350\201\232\351\233\206\347\264\242\345\274\225\345\222\214\344\272\214\347\272\247\347\264\242\345\274\225.webp" "b/docs/MySQL/MySQL/\350\201\232\351\233\206\347\264\242\345\274\225\345\222\214\344\272\214\347\272\247\347\264\242\345\274\225.webp" deleted file mode 100644 index 0761647..0000000 Binary files "a/docs/MySQL/MySQL/\350\201\232\351\233\206\347\264\242\345\274\225\345\222\214\344\272\214\347\272\247\347\264\242\345\274\225.webp" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\350\241\250\347\251\272\351\227\264\347\273\223\346\236\204.drawio.webp" "b/docs/MySQL/MySQL/\350\241\250\347\251\272\351\227\264\347\273\223\346\236\204.drawio.webp" deleted file mode 100644 index 9500c59..0000000 Binary files "a/docs/MySQL/MySQL/\350\241\250\347\251\272\351\227\264\347\273\223\346\236\204.drawio.webp" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\351\232\224\347\246\273\347\272\247\345\210\253.webp" "b/docs/MySQL/MySQL/\351\232\224\347\246\273\347\272\247\345\210\253.webp" deleted file mode 100644 index 46d0b35..0000000 Binary files "a/docs/MySQL/MySQL/\351\232\224\347\246\273\347\272\247\345\210\253.webp" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\347\255\211\345\200\274\346\237\245\350\257\242age=25.drawio.webp" "b/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\347\255\211\345\200\274\346\237\245\350\257\242age=25.drawio.webp" deleted file mode 100644 index 1dd715e..0000000 Binary files "a/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\347\255\211\345\200\274\346\237\245\350\257\242age=25.drawio.webp" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\347\255\211\345\200\274\346\237\245\350\257\242\345\255\230\345\234\250.drawio.webp" "b/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\347\255\211\345\200\274\346\237\245\350\257\242\345\255\230\345\234\250.drawio.webp" deleted file mode 100644 index 72bf3ad..0000000 Binary files "a/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\347\255\211\345\200\274\346\237\245\350\257\242\345\255\230\345\234\250.drawio.webp" and /dev/null differ diff --git "a/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\350\214\203\345\233\264\346\237\245\350\257\242age\345\244\247\344\272\216\347\255\211\344\272\21622.drawio.webp" "b/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\350\214\203\345\233\264\346\237\245\350\257\242age\345\244\247\344\272\216\347\255\211\344\272\21622.drawio.webp" deleted file mode 100644 index 635616b..0000000 Binary files "a/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\350\214\203\345\233\264\346\237\245\350\257\242age\345\244\247\344\272\216\347\255\211\344\272\21622.drawio.webp" and /dev/null differ diff --git a/docs/Redis/Redis.md b/docs/Redis/Redis.md deleted file mode 100644 index c9621bb..0000000 --- a/docs/Redis/Redis.md +++ /dev/null @@ -1,2574 +0,0 @@ -https://github.com/redis/redis - -https://redis.io/ - -https://redis.com.cn/ - -http://doc.redisfans.com/ - -## Redis基础 - -### Linux版安装 - -1. Linux环境安装Redis必须先具备gcc编译环境 `yum -y install gcc-c++` - -2. /opt 目录下安装redis - - ```sh - wget https://download.redis.io/releases/redis-7.0.0.tar.gz - tar -zxvf redis-7.0.0.tar.gz - cd redis-7.0.0 - make && make install - ``` - -3. 查看默认安装目录:/usr/local/bin,Linux下的/usr/local类似我们Windows系统的C:\Program Files,安装完成后,去/usr/local/bin下查看 - - redis-benchmark:性能测试工具,服务启动后运行该命令,看看自己电脑性能如何 - - redis-check-aof:修复有问题的AOF文件 - - redis-check-dump:修复有问题的dump.rdb文件 - - redis-cli:客户端操作入口 - - redis-sentinel:redis集群使用 - - reids-server:redis服务器启动命令 - -4. 将默认的redis.conf拷贝到自己定义好的一个路径下,比如/myredis ,cp redis.conf /myredis/redis.conf - - 修改redis.conf文件 - - ``` - redis.conf配置文件,改完后确保生效,记得重启 - 1 默认daemonize no 改为 daemonize yes - 2 默认protected-mode yes 改为 protected-mode no - 3 默认bind 127.0.0.1 改为 直接注释掉(默认bind 127.0.0.1只能本机访问)或改成本机IP地址,否则影响远程IP连接 - 4 添加redis密码 改为 requirepass 你自己设置的密码 - ``` - -5. 启动服务 `redis-server 配置文件` - -6. 连接服务 `redis-cli -a 密码 -p 6379` - -7. 关闭服务 单例模式 `redis-cli -a 密码 shutdown` ;多例模式 `redis-cli -p 6379 shutdown` - - - -### 数据结构 - -![](.\Redis\五种数据类型.webp) - -应用场景: - -- String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。 -- List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。 -- Hash 类型:缓存对象、购物车等。 -- Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。 -- Zset 类型:排序场景,比如排行榜、电话和姓名排序等。 - -- BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等; -- HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等; -- GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车; -- Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。 - - - -#### String - -String是redis最基本的数据类型,一个key对应一个value。value可以保存字符串和数字,value最多可以容纳 **512 MB** - -String 类型的底层的数据结构实现主要是 **SDS(简单动态字符串)** - -**应用场景** - -1. 缓存对象 - -2. 常规计数:因为 Redis 处理命令是单线程,所以执行命令的过程是原子的。访问次数、点赞、转发、库存量等(`INCR key`) - -3. 分布式锁(`setnx key value`)setnx (set if not exist) - -4. 共享Session信息:分布式系统中将Session保存到redis中 - - - -#### List - -Redis列表是最简单的字符串列表,按照插入顺序排序。List 类型的底层数据结构是由**双向链表或压缩列表**,最多可以包含`2^32-1`个元素 - -**内部实现** - -List 类型的底层数据结构是由**双向链表或压缩列表**实现的: - -- 如果列表的元素个数小于 `512` 个(默认值,可由 `list-max-ziplist-entries` 配置),列表每个元素的值都小于 `64` 字节(默认值,可由 `list-max-ziplist-value` 配置),Redis 会使用**压缩列表**作为 List 类型的底层数据结构; -- 如果列表的元素不满足上面的条件,Redis 会使用**双向链表**作为 List 类型的底层数据结构; - -但是**在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表**。 - - - -**应用场景**:消息队列 - -消息队列必须满足三个要求:消息保序、处理重复消息、消息可靠 - -- 消息保序:使用 LPUSH + RPOP;阻塞读取:使用 BRPOP; -- 重复消息处理:生产者自行实现全局唯一 ID; -- 消息的可靠性:使用 BRPOPLPUSH,作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存 - -List 作为消息队列有什么缺陷?不支持多个消费者消费同一个消息 - - - -#### Hash - -Redis Hash是一个string类型的field(字段)和value(值)的映射表,Hash特别适合用户存储对象。Redis中每个Hash可以存储2^32-1个键值对 - -**内部实现:** - -Hash 类型的底层数据结构是由**压缩列表或哈希表**实现的: - -- 如果哈希类型元素个数小于 `512` 个(默认值,可由 `hash-max-ziplist-entries` 配置),所有值小于 `64` 字节(默认值,可由 `hash-max-ziplist-value` 配置)的话,Redis 会使用**压缩列表**作为 Hash 类型的底层数据结构; -- 如果哈希类型元素不满足上面条件,Redis 会使用**哈希表**作为 Hash 类型的 底层数据结构。 - -**在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了**。 - - - -**应用场景:** - -1. 缓存对象 - - String + Json也是存储对象的一种方式,那么存储对象时,到底用 String + json 还是用 Hash 呢? - - 一般对象用 String + Json 存储,对象中某些频繁变化的属性可以考虑抽出来用 Hash 类型存储。 - -2. 购物车 - - - -#### Set - -Set 类型是一个无序并唯一的键值集合,它的存储顺序不会按照插入的先后顺序进行存储。 - -一个集合最多可以存储 `2^32-1` 个元素。概念和数学中个的集合基本类似,可以交集,并集,差集等等,所以 Set 类型除了支持集合内的增删改查,同时还支持多个集合取交集、并集、差集。 - -**内部实现:** - -Set 类型的底层数据结构是由**哈希表或整数集合**实现的: - -- 如果集合中的元素都是整数且元素个数小于 `512` (默认值,`set-maxintset-entries`配置)个,Redis 会使用**整数集合**作为 Set 类型的底层数据结构; -- 如果集合中的元素不满足上面条件,则 Redis 使用**哈希表**作为 Set 类型的底层数据结构 - -**应用场景:** - -集合的主要几个特性,**无序、不可重复、支持并交差**等操作。因此 Set 类型比较适合用来数据去重和保障数据的唯一性,还可以用来统计多个集合的交集、差集和并集等,当我们存储的数据是无序并且需要去重的情况下,比较适合使用集合类型进行存储。 - -**Set 的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致 Redis 实例阻塞**。 - -在主从集群中,为了避免主库因为 Set 做聚合计算(交集、差集、并集)时导致主库被阻塞,我们可以选择一个从库完成聚合统计,或者把数据返回给客户端,由客户端来完成聚合统计 - -1. 抽奖:去重功能。key为抽奖活动名,value为员工名称(`spop key 3 或者 SRANDMEMBER key 3 `) - -2. 点赞:一个用户点一次赞。key 是文章id,value 是用户id - -3. 共同好友:交集运算(`sinter key1 key2`) - -#### ZSet - -zset(sorted set:有序集合) - -Redis zset 和 set 一样也是string类型元素的集合,且不允许重复的成员。 - -不同的是**每个元素都会关联一个double类型的分数**,redis正是通过分数来为集合中的成员进行从小到大的排序。 - -zset的成员是唯一的,但分数(score)却可以重复。 - -**内部实现:** - -Zset 类型的底层数据结构是由**压缩列表或跳表**实现的: - -- 如果有序集合的元素个数小于 `128` 个,并且每个元素的值小于 `64` 字节时,Redis 会使用**压缩列表**作为 Zset 类型的底层数据结构; -- 如果有序集合的元素不满足上面的条件,Redis 会使用**跳表**作为 Zset 类型的底层数据结构; - -**在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。** - -**应用场景:** - -1. 排行榜 - -2. 电话、姓名排序 - - 使用有序集合的 `ZRANGEBYLEX` 或 `ZREVRANGEBYLEX` 可以帮助我们实现电话号码或姓名的排序,我们以 `ZRANGEBYLEX` (返回指定成员区间内的成员,按 key 正序排列,分数必须相同)为例。 - - **注意:不要在分数不一致的 SortSet 集合中去使用 ZRANGEBYLEX和 ZREVRANGEBYLEX 指令,因为获取的结果会不准确。** - - 例如,获取132、133开头的电话 - - ```sh - > ZADD phone 0 13300111100 0 13210414300 0 13252110901 - > ZRANGEBYLEX phone [132 (134 - 1) "13200111100" - 2) "13210414300" - 3) "13252110901" - 4) "13300111100" - 5) "13310414300" - 6) "13352110901" - ``` - -3. 延迟队列:使用有序集合(ZSet)的方式来实现延迟消息队列的,ZSet 有一个 Score 属性可以用来存储延迟执行的时间。 - - 使用 zadd score1 value1 命令就可以一直往内存中生产消息。再利用 zrangebysocre 查询符合条件的所有待处理的任务, 通过循环执行队列任务即可。 - -#### 地理空间(GEO) - -Redis GEO主要用于存储地理位置信息,并对存储的信息进行操作,包括:添加地理位置的坐标、获取地理位置的坐标、计算两个位置之间的距离、根据用户给定的经纬度坐标来获取指定范围内的地址位置集合。 - -**内部实现:** - -GEO 本身并没有设计新的底层数据结构,而是直接使用了 **Sorted Set** 集合类型。 - -GEO 类型使用 GeoHash 编码方法实现了经纬度到 Sorted Set 中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为 Sorted Set 元素的权重分数。 - -这样一来,我们就可以把经纬度保存到 Sorted Set 中,利用 Sorted Set 提供的“按权重进行有序范围查找”的特性,实现 LBS 服务中频繁使用的“搜索附近”的需求。 - -**应用场景**:导航定位、打车 - -#### 基数统计(HyperLogLog) - -HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定且是很小的(使用12KB就能计算2^64个不同元素的基数)。但要注意,HyperLogLog 是统计规则是基于概率完成的,不是非常准确,标准误算率是 0.81%。 - -应用场景:去重、计数 - -1. UV:unique visitor 独立访客数 -2. PV:page view 页面浏览量 -3. DAU:daily active user 日活 -4. MAU:月活 - -#### 位图(bitmap) - -Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行`0|1`的设置,表示某个元素的值或者状态,时间复杂度为O(1)。 - -由于 bit 是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用**二值统计的场景**。 - -**内部实现** - -Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。 - -String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态,你可以把 Bitmap 看作是一个 bit 数组。 - -**应用场景** - -1. 签到统计 -2. 判断用户登录态。将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 `GETBIT`判断对应的用户是否在线。 5000 万用户只需要 6 MB 的空间。 -3. 连续签到用户总数。把每天的日期作为 Bitmap 的 key,userId 作为 offset,对应的 bit 位做 『与』运算 - -#### 位域(bitfield) - -通过bitfield命令可以一次性操作多个比特位,它会执行一系列操作并返回一个响应数组,这个数组中的元素对应参数列表中的相应操作的执行结果。主要功能是: - -* 位域修改 -* 溢出控制 - * WRAP:使用回绕(wrap around)方法处理有符号整数和无符号整数溢出情况 - * SAT:使用饱和计算(saturation arithmetic)方法处理溢出,下溢计算的结果为最小的整数值,而上溢计算的结果为最大的整数值 - * fail:命令将拒绝执行那些会导致上溢或者下溢情况出现的计算,并向用户返回空值表示计算未被执行 - -#### Redis流(Stream) - -Redis Stream 主要用于消息队列(MQ,Message Queue) - -* Redis 本身是有一个 **Redis 发布订阅 (pub/sub)** 来实现消息队列的功能,但它有个缺点就是**消息无法持久化**,如果出现网络断开、Redis 宕机等,消息就会被丢弃。 - -* List 实现消息队列的方式不能重复消费,一个消息消费完就会被删除,而且生产者需要自行实现全局唯一 ID,**不支持多播,分组消费** - -而 Redis Stream 支持消息的持久化、支持自动生成全局唯一ID、支持ack确认消息的模式、支持消费组模式等,让消息队列更加的稳定和可靠 - - - -### 常用命令 - -```shell -keys * # 查看当前库所有的key -set key value -get key -exists key # 判断某个key是否存在 -type key # 查看你的key是什么类型 -del key # 删除指定的key数据 是原子的删除,只有删除成功了才会返回删除结果 -unlink key # 非阻塞删除,仅仅将keys从keyspace元数据中删除,真正的删除会在后续异步中操作。 -ttl key # 查看还有多少秒过期,-1表示永不过期,-2表示已过期 -expire key 秒钟 # 为给定的key设置过期时间 -move key dbindex[0-15]# 将当前数据库的key移动到给定的数据库DB当中 -select dbindex # 切换数据库【0-15】,默认为0 -dbsize # 查看当前数据库key的数量 -flushdb # 清空当前库 -flushall # 通杀全部库 -``` - -帮助命令: **`help @类型`** 。例如,`help @string` - - - -### 持久化 - -AOF 文件的内容是操作命令; - -RDB 文件的内容是二进制数据。 - -#### RDB (Redis Database) - -在指定的时间间隔内将内存中的数据集快照写入磁盘,恢复时再将硬盘快照文件直接读回到内存里。 - -Redis的数据都在内存中,保存备份时它执行的是**全量快照**,也就是说,把内存中的所有数据都记录到磁盘中。 - -RDB保存的是dump.rdb文件。 - -**持久化方式** - -* 自动触发:修改 redis.conf 里配置的 `save `。自动执行 bgsave 命令,会创建子进程来生成 RDB 快照文件。 - -* 手动触发:使用`save`或者`bgsave`命令。save在主程序中执行会**阻塞**当前redis服务器,直到持久化工作完成, 执行save命令期间,Redis不能处理其他命令,**线上禁止使用**。bgsave会在后台异步进行快照操作,**不阻塞**。该方式会fork一个子进程由子进程完成持久化过程 - - - -**优势**: - -1. 适合大规模的数据恢复 -2. 按照业务定时备份 -3. 父进程不会执行磁盘I/О或类似操作,会fork一个子进程完成持久化工作,性能高。 -4. RDB文件在内存中的**加载速度要比AOF快很多** - -**劣势**: - -1. 在一定间隔时间做一次备份,所以如果redis意外down掉的话,就会丢失从当前至最近一次快照期间的数据,**快照之间的数据会丢失** -2. 内存数据的全量同步,如果数据量太大会导致IO严重影响服务器性能。因为RDB需要经常fork()以便使用子进程在磁盘上持久化。如果数据集很大,fork()可能会很耗时,并且如果数据集很大并且CPU性能不是很好,可能会导致Redis停止为客户端服务几毫秒甚至一秒钟。 - - - -如何检查修复dump.rdb文件? - -进入到redis安装目录,执行redis-check-rdb命令 `redis-check-rdb ./redisconfig/dump.rdb` - - - -哪些情况会触发RDB快照? - -1. 配置文件中默认的快照配置 -2. 手动save/bgsave命令 -3. 执行flushdb/fulshall命令也会产生dump.rdb文件,但是也会将命令记录到dump.rdb文件中,恢复后依旧是空,无意义 -4. 执行shutdown且没有设置开启AOF持久化 -5. 主从复制时,主节点自动触发 - - - -如何禁用快照? - -1. 动态所有停止RDB保存规则的方法:redis-cli config set value "" -2. 手动修改配置文件 - - - -RDB 在执行快照的时候,数据能修改吗? - -可以的,执行 bgsave 过程中,Redis 依然**可以继续处理操作命令**的,也就是数据是能被修改的,关键的技术就在于**写时复制技术(Copy-On-Write, COW)。** - -执行 bgsave 命令的时候,会通过 fork() 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个,此时如果执行读操作,则主进程和 bgsave 子进程互相不影响。 - -如果主进程执行写操作,则被修改的数据会复制一份副本,主线程在这个数据副本进行修改操作,然后 bgsave 子进程会把原来的数据写入 RDB 文件。 - - - -#### AOF (Append Only File) - -**以日志的形式来记录每个写操作**,将Redis执行过的所有写指令记录下来(读操作不记录),只许追加文件但是不可以改写文件,恢复时,以逐一执行命令的方式来进行数据恢复。 - -默认情况下,redis是没有开启AOF的。 - -**开启:** - -开启AOF功能需要设置配置:appendonly yes - -AOF保存的是 appendonly.aof 文件 - - - -**AOF持久化流程**: - -**先执行写命令,然后记录命令到AOF日志。** - -1. 日志并不是直接写入AOF文件,会将其这些命令先放入AOF缓存中进行保存。这里的AOF缓冲区实际上是内存中的一片区域,存在的目的是当这些命令达到一定量以后再写入磁盘,避免频繁的磁盘IO操作。 - -2. AOF缓冲会根据AOF缓冲区**同步文件的三种写回策略**将命令写入磁盘上的AOF文件。 - -3. 随着写入AOF内容的增加为避免文件膨胀,会根据规则进行命令的合并(**AOF重写**),从而起到AOF文件压缩的目的。 - -4. 当Redis Server服务器重启的时候会对AOF文件载入数据。 - - - -**为什么先执行命令,再把数据写入日志呢?** - -Reids 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这么做其实有两个好处。 - -- **避免额外的检查开销**:因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。 -- **不会阻塞当前写操作命令的执行**:因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。 - -当然,这样做也会带来风险: - -- **数据可能会丢失:** 执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险。 -- **可能阻塞其他操作:** 由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前命令的执行,但因为 AOF 日志也是在主线程中执行,所以当 Redis 把日志文件写入磁盘的时候,还是会阻塞后续的操作无法执行。 - - - -**aof文件**: - -* redis6及之前:appendonly.aof - -* Redis7 Multi Part AOF的设计, 将AOF分为三种类型: - - * BASE: 表示基础AOF,它一般由子进程通过重写产生,该文件最多只有一个。 - * INCR:表示增量AOF,它一般会在AOFRW开始执行时被创建,该文件可能存在多个。 - * HISTORY:表示历史AOF,它由BASE和INCR AOF变化而来,每次AOFRW成功完成时,本次AOFRW之前对应的BASE和INCR AOF都将变为HISTORY,HISTORY类型的AOF会被Redis自动删除。 - - 为了管理这些AOF文件,我们引入了一个manifest (清单)文件来跟踪、管理这些AOF。 - - -异常修复命令:`redis-check-aof --fix incr文件` - - - -**AOF写回策略** - -AOF缓冲会根据AOF缓冲区**同步文件的三种写回策略**将命令写入磁盘上的AOF文件。写回策略: - -1. **ALways**:同步写回,每个写命令执行完立刻同步地将日志写回磁盘。 -2. **everysec**(默认):每秒写回,每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,每隔1秒把缓冲区中的内容写入到磁盘 -3. **no**:操作系统控制的写回,每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘 - - - -**AOF重写机制** - -随着写入AOF内容的增加为避免文件膨胀,会根据规则进行命令的合并(**又称AOF重写**),从而起到**AOF文件压缩的目的。** - -配置项:auto-aof-rewrite-percentage 和 auto-aof-rewrite-min-size 同时满足两个条件时触发重写 - -* 自动触发: 满足配置文件中的选项后,Redis会记录上次重写时的AOF大小,默认配置是当AOF文件大小是上次rewrite后大小的一倍且文件大于64M时 -* 手动触发: `bgrewriteaof` - -**AOF文件重写并不是对AOF件进行重新整理,而是直接读取服务器数据库中现有的键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。** - -**重写原理:** - -1. 触发重写机制后,主进程会创建一个“重写子进程 **bgrewriteaof**”,这个子进程会携带主进程的数据副本(fork子进程时复制页表,父子进程在写操作之前都共享物理内存空间,从而实现数据共享。主进程第一次写时发生写时复制才会进行物理内存的复制)。重写子进程只会对这个内存进行只读,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。 - -2. 与此同时,主进程依然能正常处理命令。但是执行写命令时怎么保证数据一致性呢?在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会**同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」**。 - - ![](.\Redis\aofewwrite.png) - -3. 当“重写子进程”完成重写工作后,它会给父进程发一个信号,父进程收到信号后将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中 - -4. 当追加结束后,redis就会用新AOF文件来代替旧AOF文件,之后再有新的写指令,就都会追加到新的AOF文件中 - - - -Redis 的**重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的**,这么做可以达到两个好处: - -1. 子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程; - -2. 子进程带有主进程的数据副本,这里使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。 - - - -#### 混合持久化 - -RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。 - -AOF 优点是丢失数据少,但是数据恢复不快。 - -混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。 - - - -开启:设置 appendonly 为 yes、aof-use-rdb-preamble 为 yes - - - -当开启了混合持久化时,在 AOF 重写日志时,`fork` 出来的重写子进程会先将与主线程共享的内存数据以 **RDB 方式写入到 AOF 文件**,然后主线程处理的操作命令会被记录在**重写缓冲区**里,重写缓冲区里的增量命令会以 **AOF 方式写入到 AOF 文件**,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。 - -也就是说,使用了混合持久化,AOF 文件的**前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据**。 - - - -#### 纯缓存模式 - -同时关闭RDB+AOF,专心做缓存 - -1. save "" -- 禁用RDB - - 禁用RDB持久化模式下,我们仍然可以使用命令save、bgsave生成RDB文件 - -2. appendonly no -- 禁用AOF - - 禁用AOF持久化模式下,我们仍然可以使用命令bgrewriteaof生成AOF文件 - - - -AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 `fork()` 函数创建一个子进程来处理任务。会有两个阶段会导致阻塞父进程(主线程): - -- 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长; -- 创建完子进程后,如果父进程修改了共享数据中的大 Key,就会发生写时复制,这期间会拷贝物理内存,由于大 Key 占用的物理内存会很大,那么在复制物理内存这一过程,就会比较耗时,所以有可能会阻塞父进程。 - - - -### 事务 - -redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。**Redis 中并没有提供回滚机制 ,并不一定保证原子性** - -1. 开启:以multi开始一个事务 - -2. 入队:将多个命令入队到事务中,接到这些命令并不会立即执行,而是放到等待执行的事务队列里面 - -3. 执行:exec命令触发事务 - -| 性质 | 解释 | -| ---------------- | ------------------------------------------------------------ | -| **不保证原子性** | Redis的事务不保证原子性,也就是不保证所有指令同时成功或同时失败,只有决定是否开始执行全部指令的能力,没有执行到一半进行回滚的能力 | -| 一致性 | redis事务可以保证命令失败的情况下得以回滚,数据能恢复到没有执行之前的样子,是保证一致性的,除非redis进程意外终结 | -| 隔离性 | redis事务是严格遵守隔离性的,原因是redis是单进程单线程模式(v6.0之前),可以保证命令执行过程中不会被其他客户端命令打断 | -| 不保证持久性 | redis持久化策略中不管是RDB还是AOF都是异步执行的,不保证持久性是出于对性能的考虑 | - -* 正常执行:MULTI 标记事务开始 、EXEC 执行事务 -* 放弃事务:MULTI、DISCARD 取消事务 -* 事务全部失败:MULTI后的命令直接报错,EXEC执行也会报错,事务失败,命令全部失效。类似编译错误 -* 事务部分失败:MULTI后的命令没有直接报错(例如,INCR email),EXEC时报错,该条命令失败,其余命令成功。类似运行错误 -* watch监控:使用watch提供乐观锁定 - - - -### 管道 - -管道(pipeline)可以一次性发送多条命令给服务端,**服务端依次处理完毕后,通过一 条响应一次性将结果返回,通过减少客户端与redis的通信次数来实现降低往返延时时间**。pipeline实现的原理是队列,先进先出特性就保证数据的顺序性 - -`cat cmd.txt | redis-cli -a 密码 --pipe` - -pipeline与原生批量命令对比 - -1. 原生批量命令是原子性(例如:mset、mget),pipeline是非原子性的 -2. 原生批量命令一次只能执行一种命令,pipeline支持批量执行不同命令 -3. 原生批量命令是服务端实现,而pipeline需要服务端与客户端共同完成 - -pipeline与事务对比 - -1. 事务具有原子性,管道不具有原子性 -2. 管道一次性将多条命令发送到服务器,事务是一条一条的发,事务只有在接收到exec命令后才会执行 -3. 执行事务时会阻塞其他命令的执行,而执行管道中的命令时不会 - -使用pipeline注意事项 - -1. pipeline缓冲的指令只是会依次执行,不保证原子性,如果执行中指令发生异常,将会继续执行后续的指令 -2. 使用pipeline组装的命令个数不能太多,不然数量过大客户端阻塞的时间可能过久,同时服务端此时也被迫回复一个队列答复,占用很多内存 - - - -### 主从复制(replication) - -主从复制,读写分离,master可以读写,slave以读为主,当master数据变化的时候,自动将新的数据异步同步到其他的slave数据库。 - - - -**配置**:(配从不配主) - -配置从机: - -1. master 如果配置了 `requirepass` 参数,需要密码登录 ,那么slave就要配置 `masterauth` 来设置校验密码,否则的话master会拒绝slave的访问请求; -2. `replicaof 主库IP 主库端口` - - - -**基本操作命令**: - -`info replication` :可以查看节点的主从关系和配置信息 - -`replicaof 主库IP 主库端口` :一般写入进Redis.conf配置文件内,重启后依然生效 - -`slaveof 主库IP 主库端口 `:每次与master断开之后,都需要重新连接,除非你配置进了redis.conf文件;在运行期间修改slave节点的信息,如果该数据库已经是某个主数据库的从数据库,那么会停止和原主数据库的同步关系,转而和新的主数据库同步 - -`slaveof no one` :使当前数据库停止与其他数据库的同步,转成主数据库 - - - -**主从问题演示** - -1. Q:从机可以执行写命令吗? - - A:**不可以,从机只能读** - -2. Q:从机切入点问题?slave是从头开始复制还是从切入点开始复制? - - A: 首次接入全量复制,后续增量复制 - -3. Q:主机shutdown后,从机会上位吗? - - A:**从机不动,原地待命,从机数据可以正常使用,等待主机重启归来** - -4. Q:主机shutdown后,重启后主从关系还在吗?从机还能否顺利复制? - - A:主从关系依然存在,从机依旧是从机,可以顺利复制 - -5. Q:某台从机down后,master继续,从机重启后它能跟上大部队吗? - - A:可以,类似于从机切入点问题 - - - -**复制原理和工作流程:** - -1. 建立链接、协商同步 - - slave启动成功连接到master后会发送一个psync命令(包含主服务器的runID和复制进度offset),表示要进行数据同步 - - master收到psync后,会用 `FULLRESYNC`(包含主服务器runID和复制进度offset) 作为响应命令返回给对方。从服务器收到响应后,会记录这两个值。 - -2. 同步数据,全量复制 - - 主服务器会执行 bgsave 命令来生成 RDB 文件,然后把文件发送给从服务器。 - - 从服务器收到 RDB 文件后,会先清空当前的数据,然后载入 RDB 文件。 - - 这期间的写操作命令并没有记录到刚刚生成的 RDB 文件中,这时主从服务器间的数据就不一致了。那么为了保证主从服务器的数据一致性,**主服务器在下面这三个时间间隙中将收到的写操作命令,写入到 replication buffer 缓冲区里**: - - - 主服务器生成 RDB 文件期间; - - 主服务器发送 RDB 文件给从服务器期间; - - 「从服务器」加载 RDB 文件期间; - -3. master 发送新写命令给 slave - - slave完成 RDB 的载入后,会回复一个确认消息给主服务器。 - - 主服务器将 replication buffer 缓冲区里所记录的写操作命令发送给从服务器,从服务器执行来自主服务器 replication buffer 缓冲区里发来的命令,这时主从服务器的数据就一致了。 - -4. TCP长连接进行命令传播、通信 - - 主从服务器在完成第一次同步后,双方之间就会维护一个 TCP 连接。后续主服务器可以通过这个连接继续将写操作命令传播给从服务器,然后从服务器执行该命令,使得与主服务器的数据库状态相同。 - -5. 增量复制 - - 从服务器在恢复网络后,会发送 psync 命令给主服务器,此时的 psync 命令里的 offset 参数不是 -1; - - 主服务器收到该命令后,然后用 CONTINUE 响应命令告诉从服务器接下来采用增量复制的方式同步数据; - - 然后主服务将主从服务器断线期间,所执行的写命令发送给从服务器,然后从服务器执行这些命令。 - -6. 从机下线,重连续传 - - master会检查backlog里面的offset,master和slave都会保存一个复制的offset还有一个masterId,offset是保存在backlog中的。master只会把已经缓存的offset后面的数据复制给slave,类似断点续传 - - - -**怎么判断 Redis 某个节点是否正常工作?** - -Redis 判断节点是否正常工作,基本都是通过互相的 ping-pong 心态检测机制,如果有一半以上的节点去 ping 一个节点的时候没有 pong 回应,集群就会认为这个节点挂掉了,会断开与这个节点的连接。 - -Redis 主从节点发送的心态间隔是不一样的,而且作用也有一点区别: - -- Redis 主节点默认每隔 10 秒对从节点发送 ping 命令,判断从节点的存活性和连接状态,可通过参数repl-ping-slave-period控制发送频率。 -- Redis 从节点每隔 1 秒发送 replconf ack{offset} 命令,给主节点上报自身当前的复制偏移量,目的是为了: - - 实时监测主从节点网络状态; - - 上报自身复制偏移量, 检查复制数据是否丢失, 如果从节点数据丢失, 再从主节点的复制缓冲区中拉取丢失数据。 - - - -### 哨兵(Sentinel) - -巡查监控后台master主机是否故障,如果故障了根据投票数自动将某一个从库转换为新主库,继续对外服务 - -哨兵会每隔 1 秒给所有主从节点发送 PING 命令,当主从节点收到 PING 命令后,会发送一个响应命令给哨兵,这样就可以判断它们是否在正常运行。 - -**功能:** - -主从监控:监控主从redis库运行是否正常 - -消息通知:哨兵可以将故障转移的结果发送给客户端 - -故障转移:如果master异常,则会进行主从切换,将其中一个slave作为新master - -配置中心:客户端通过连接哨兵来获得当前Redis服务的主节点地址 - - - -**配置:**sentinel.conf - -``` -bind:服务监听地址,用于客户端连接,默认本机地址 -daemonize:是否以后台daemon方式运行 -protected-model:安全保护模式 -port:端口 -logfile:日志文件路径 -pidfile:pid文件路径 -dir:工作目录 -``` - -`sentinel monitor ` : 设置要监控的master服务器,quorum表示最少有几个哨兵认可客观下线,同意故障迁移的法定票数 - -`sentinel auth-pass ` - -其他参数: - -``` -sentinel down-after-milliseconds :指定多少毫秒之后,主节点没有应答哨兵,此时哨兵主观上认为主节点下线 -sentinel parallel-syncs :表示允许并行同步的slave个数,当Master挂了后,哨兵会选出新的Master,此时,剩余的slave会向新的master发起同步数据 -sentinel failover-timeout :故障转移的超时时间,进行故障转移时,如果超过设置的毫秒,表示故障转移失败 -sentinel notification-script :配置当某一事件发生时所需要执行的脚本 -sentinel client-reconfig-script :客户端重新配置主节点参数脚本 -``` - -注意:主机后续可能会变成从机,所以也需要设置 masterauth 项访问密码,不然后续可能报错master_link_status:down - -​ 哨兵可以同时监控多个master,一行一个配置 - - - -**启动哨兵方式:** - -1. `redis-sentinel /path/to/sentinel.conf` -2. `redis-server /path/to/sentinel.conf --sentinel` - - - -文件的内容,在运行期间会被sentinel动态进行更改。Master-Slave切换后,master_redis.conf、slave_redis.conf和sentinel.conf的内容都会发生改变,即master_redis.conf中会多一行slaveof的配置,sentinel.conf的监控目标会随之调换 - - - -**哨兵运行流程和选举原理:** - -当一个主从配置中master失效后,哨兵可以选举出一个新的master用于自动接替原master的工作,主从配置中的其他redis服务器自动指向新的master同步数据 - -* **主观下线**(Subjectively Down, 简称 SDOWN):指的是**单个Sentinel实例对服务器做出的下线判断**,即单个sentinel认为某个服务下线(有可能是接收不到订阅,之间的网络不通等等原因)。如果主节点或者从节点没有在规定的时间内响应哨兵的 PING 命令,哨兵就会将它们标记为「**主观下线**」。 - -* **客观下线**(Objectively Down,简称 ODOWN):当一个哨兵判断主节点为「主观下线」后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。当这个哨兵的**赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后**,这时主节点就会被该哨兵标记为「客观下线」。 - - - -1. 当主节点被判断客观下线后,各个哨兵节点会进行协商,用**raft算法先选举出一个领导者哨兵leader**节点进行failover(故障转移)。**选举的票数大于等于num(sentinels)/2+1时,将成为领导者,如果没有超过,继续选举** -2. 领导者哨兵推动故障切换流程选出一个**新master** - 1. 选举某个slave为新的master,选举规则为:1. **优先级**slave-priority或者replica-priority最高的,2. **复制偏移位置offset最大的**,3. **最小Run ID**的从节点 - 2. sentinel leader会对选举出的新master执行slaveof on one操作,将其提升为master节点。 - 3. sentinel leader向其他slave发送命令,让剩余的slave成为新的master节点的slave - 4. 通过发布者/订阅者机制通知客户端主节点已更换 - 5. sentinel leader会让重新上线的master降级为slave并恢复正常工作 - - - -**整体流程**: - -*1、第一轮投票:判断主节点下线* - -当哨兵集群中的某个哨兵判定主节点下线(主观下线)后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。 - -当这个哨兵的赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后,这时主节点就会被该哨兵标记为「客观下线」。 - -*2、第二轮投票:选出哨兵 leader* - -某个哨兵判定主节点客观下线后,该哨兵就会发起投票,告诉其他哨兵,它想成为 leader,想成为 leader 的哨兵节点,要满足两个条件: - -- 第一,拿到半数以上的赞成票; -- 第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。 - -*3、由哨兵 leader 进行主从故障转移* - -选举出了哨兵 leader 后,就可以进行主从故障转移的过程了。该操作包含以下四个步骤: - -- 第一步:在已下线主节点(旧主节点)属下的所有「从节点」里面,挑选出一个从节点,并将其转换为主节点,选择的规则: - - 过滤掉已经离线的从节点; - - 过滤掉历史网络连接状态不好的从节点; - - 将剩下的从节点,进行三轮考察:优先级、复制进度、ID 号。在每一轮考察过程中,如果找到了一个胜出的从节点,就将其作为新主节点。 -- 第二步:让已下线主节点属下的所有「从节点」修改复制目标,修改为复制「新主节点」; -- 第三步:将新主节点的 IP 地址和信息,通过「发布者/订阅者机制」通知给客户端; -- 第四步:继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点; - - - -### 集群(Cluster) - -Redis集群是一个提供在多个Redis节点间共享数据的程序集,Redis集群可以支持多个master, 每个master又可以挂载多个slave - -由于Cluster自带Sentinel的故障转移机制,内置了高可用的支持,无需再去使用哨兵功能 - -客户端与Redis的节点连接,不再需要连接集群中所有的节点,只需要任意连接集群中的一个可用节点即可 - -槽位slot 负责分配到各个物理服务节点,由对应的集群来负责维护数据、插槽和节点之间的关系 - -集群的密钥空间被分为16384个槽,集群大小上限是16384,建议最大节点约为1000 - - - -**槽位** - -数据和节点之间的抽象出来的,数据映射到槽位,槽位绑定到节点。 - -Redis集群有16384个哈希槽每个key通过CRC16校验后对16384取模来决定放置哪个槽,集群的每个节点负责一部分hash槽 - -![](.\Redis\slot.jpg) - - - -**分片** - -使用Redis集群时我们会将存储的数据分散到多台redis机器上,这称为分片。简言之,集群中的每个Redis实例都被认为是整个数据的一个分片。 - -为了找到给定key的分片,我们对key进行CRC16(key)算法处理并通过对总分片数量取模。然后,使用**确定性哈希函数**,这意味着给定的key**将多次始终映射到同一个分片**,我们可以推断将来读取特定key的位置。 - -槽位和分片的优势是:方便扩缩容和数据分派查找 - - - -**slot槽位映射3种解决方案**: - -* 哈希取余分区: hash(key)%N 在服务器个数固定不变时没有问题,如果需要弹性扩容或故障停机的情况下,取模公式就会发生变化 - -* 一致性哈希算法分区: - - 1. 构建一致性哈希环: 一致性Hash算法是对2^32取模,简单来说,一致性Hash算法将整个哈希值空间组织成一个虚拟的圆环 - 2. 将redis服务器 IP节点映射到哈希环某个位置 - 3. key落到服务器的落键规则:当我们需要存储一个kv键值对时,首先将这个key使用相同的函数Hash计算出哈希值并确定此数据在环上的位置,**从此位置沿环顺时针“行走”**,**第一台遇到的服务器就是其应该定位到的服务器**,并将该键值对存储在该节点上。 - - 优点: - - 1. 容错性:在一致性Hash算法中,如果一台服务器不可用,则受影响的数据仅仅是此服务器到其环空间中前一台服务器(即沿着逆时针方向行走遇到的第一台服务器)之间数据,其它不会受到影响 - 2. 扩展性:数据量增加了,需要增加一台节点NodeX,X的位置在A和B之间,那收到影响的也就是A到X之间的数据,重新把A到X的数据录入到X上即可,不会导致hash取余全部数据重新洗牌。 - - 缺点:一致性Hash算法在服务节点太少时,容易因为节点分布不均匀而造成**数据倾斜**(集中存储在某台服务器上) - -* 哈希槽分区:为解决数据倾斜问题,在数据和节点之间又加入了一层,把这层称为哈希槽(slot)。Redis集群中内置了16384个哈希槽,redis 会根据节点数量大致均等的将哈希槽映射到不同的节点,数据映射到哈希槽,根据操作分配到对应节点。集群会记录节点和槽的对应关系,哈希槽的计算:HASH_SLOT = CRC16(key) mod 16384。 - - - -**为什么集群的最大槽数是16384个?** - -![](Redis\slot.png) - -1. 如果槽位为65536,发送心跳信息的消息头达8k,发送的心跳包过于庞大。 - - 在消息头中最占空间的是myslots[CLUSTER_SLOTS/8]。 当槽位为65536时,这块的大小是: 65536÷8÷1024=8kb - - 在消息头中最占空间的是myslots[CLUSTER_SLOTS/8]。 当槽位为16384时,这块的大小是: 16384÷8÷1024=2kb - - 因为每秒钟,redis节点需要发送一定数量的ping消息作为心跳包,如果槽位为65536,这个ping消息的消息头太大了,浪费带宽。 - -2. redis的集群节点数量基本不可能超过1000个。 - - 集群节点越多,心跳包的消息体内携带的数据越多。如果节点过1000个,也会导致网络拥堵。因此redis作者不建议redis cluster节点数量超过1000个。 那么,对于节点数在1000以内的redis cluster集群,16384个槽位够用了。没有必要拓展到65536个。 - -3. 槽位越小,节点少的情况下,压缩比高,容易传输 - - Redis主节点的配置信息中它所负责的哈希槽是通过一张bitmap的形式来保存的,在传输过程中会对bitmap进行压缩,但是如果bitmap的填充率slots / N很高的话(N表示节点数),bitmap的压缩率就很低。 如果节点数很少,而哈希槽数量很多的话,bitmap的压缩率就很低。 - - - -**集群脑裂导致数据丢失怎么办?** - -脑裂: - -在 Redis 主从架构中,如果主节点的网络突然发生了问题,它与所有的从节点都失联了,但是此时的主节点和客户端的网络是正常的,这个客户端并不知道 Redis 内部已经出现了问题,还在照样的向这个失联的主节点写数据(过程A),此时这些数据被旧主节点缓存到了缓冲区里,因为主从节点之间的网络问题,这些数据都是无法同步给从节点的。 - -这时,哨兵也发现主节点失联了,它就认为主节点挂了(但实际上主节点正常运行,只是网络出问题了),于是哨兵就会在「从节点」中选举出一个 leader 作为主节点,这时集群就有两个主节点了 —— **脑裂出现了**。 - -然后,网络突然好了,哨兵因为之前已经选举出一个新主节点了,它就会把旧主节点降级为从节点(A),然后从节点(A)会向新主节点请求数据同步,**因为第一次同步是全量同步的方式,此时的从节点(A)会清空掉自己本地的数据,然后再做全量同步。所以,之前客户端在过程 A 写入的数据就会丢失了,也就是集群产生脑裂数据丢失的问题**。 - -解决方案: - -当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么**禁止主节点进行写数据**,直接把错误返回给客户端。 - - - -**使用:** - -1. 编写集群配置文件 :redisCluster6381.conf - - ``` - bind 0.0.0.0 - daemonize yes - protected-mode no - port 6381 - logfile "/myredis/cluster/cluster6381.log" - pidfile /myredis/cluster6381.pid - dir /myredis/cluster - dbfilename dump6381.rdb - appendonly yes - appendfilename "appendonly6381.aof" - requirepass 123456 - masterauth 123456 - - cluster-enabled yes - cluster-config-file nodes-6381.conf - cluster-node-timeout 5000 - ``` - -2. 启动主机节点: `redis-server /myredis/cluster/redisCluster6381.conf` - -3. 构建集群关系 - - ```SH - # --cluster-replicas 1 master slave :表示为每个master创建一一个slave节点 - redis-cli -a 123456 --cluster create --cluster-replicas 1 192.168.111.175:6381 192.168.111.175:6382 192:168.111.172:6383 192.168.111.172:6384 192.168.111.174:6385 192.168.111.174:6386 - ``` - -4. 查看集群状态 `cluster nodes` - -5. 配置集群后需要注意槽位,所以操作redis需要防止路由失效 加参数-c `redis-cli -a 123456 -p 6381 -c` - -6. 查看某个key该属于对应的槽位值 `cluster keyslot key` - - - -**主从容错切换**:主机宕机后再次上线不会以主机身份上位,而是以**从节点形式回归**,可以手动故障转移or节点从属调整: `cluster failover` - -**主从扩容**: - -1. 编写配置文件 -2. 启动主机节点 -3. 主节点加入集群: `redis-cli -a 密码 --cluster add-node 节点IP:PORT 集群IP:PORT` -4. 检查集群状况 :`redis-cli -a 密码 --cluster check 集群IP:PORT` -5. 重新分派槽位 :`redis-cli -a 密码 --cluster reshard 集群IP:PORT` ,重新分配成本太高,所以是之前集群节点各自匀出来一部分 -6. 为主节点分配从节点 :`redis-cli -a 密码 --cluster add-node 新slaveIP:PORT 新masterIP:PORT --cluster-slave --cluster-master-id master节点ID` - -**主从缩容**: - -1. 获取从节点节点ID:`redis-cli -a 密码 --cluster check 从节点IP:PORT` -2. 集群中删除从节点:`redis-cli -a 密码 --cluster del-node 从节点IP:PORT 从节点ID` -3. 主节点槽号清空,重新分配:`redis-cli -a 密码 --cluster reshard 预分配节点IP:PORT` -4. 删除主节点:`redis-cli -a 密码 --cluster del-node 主节点IP:PORT 主节点ID` -5. 检查 - - - -集群不保证数据一致性100%OK,是会有数据丢失的情况 - -Redis集群不保证强一致性这意味着在特定的条件下,Redis集群可能会丢掉一些被系统收到的写入请求命令 - -不在同一个slot槽位下的键值无法使用mset、mget等多键操作,可以通过{}来定义同一个组的概念,使key中{}内相同内容的键值对放到一个slot槽位去 - - - -### RedisTemplate - -redis客户端:Redisson、Jedis、lettuce等等,官方推荐使用Redisson。 - -**连接单机** - -1. 依赖 - - ```xml - - - org.springframework.boot - spring-boot-starter-data-redis - - - org.apache.commons - commons-pool2 - - ``` - -2. 配置 - - ```properties - spring.redis.database=0 - # 修改为自己真实IP - spring.redis.host=192.168.111.185 - spring.redis.port=6379 - spring.redis.password=111111 - spring.redis.lettuce.pool.max-active=8 - spring.redis.lettuce.pool.max-wait=-1ms - spring.redis.lettuce.pool.max-idle=8 - spring.redis.lettuce.pool.min-idle=0 - ``` - -3. 配置类 - - ```java - @Configuration - public class RedisConfig - { - /** - * redis序列化的工具配置类,下面这个请一定开启配置 - * @param lettuceConnectionFactory - * @return - */ - @Bean - public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) - { - RedisTemplate redisTemplate = new RedisTemplate<>(); - - redisTemplate.setConnectionFactory(redisConnectionFactory); - //设置key序列化方式string - redisTemplate.setKeySerializer(new StringRedisSerializer()); - //设置value的序列化方式json,使用GenericJackson2JsonRedisSerializer替换默认序列化 - redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); - - redisTemplate.setHashKeySerializer(new StringRedisSerializer()); - redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); - - redisTemplate.afterPropertiesSet(); - - return redisTemplate; - } - } - ``` - -4. 使用 - - ```java - @Resource - private RedisTemplate redisTemplate; - ``` - - - -**连接集群** - -配置 - -```properties -spring.redis.password=111111 -# 获取失败 最大重定向次数 -spring.redis.cluster.max-redirects=3 -spring.redis.lettuce.pool.max-active=8 -spring.redis.lettuce.pool.max-wait=-1ms -spring.redis.lettuce.pool.max-idle=8 -spring.redis.lettuce.pool.min-idle=0 -#支持集群拓扑动态感应刷新,自适应拓扑刷新是否使用所有可用的更新,默认false关闭 -spring.redis.lettuce.cluster.refresh.adaptive=true -#定时刷新 -spring.redis.lettuce.cluster.refresh.period=2000 -spring.redis.cluster.nodes=192.168.111.175:6381,192.168.111.175:6382,192.168.111.172:6383,192.168.111.172:6384,192.168.111.174:6385,192.168.111.174:6386 -``` - - - - - -## Redis高级 - -### Redis单线程 - -redis单线程主要是指Redis的**网络IO和键值对读写是由一个线程来完成**的,Redis在处理客户端的请求时包括**获取 (socket 读)、解析、执行、内容返回 (socket 写**) 等都由一个顺序串行的主线程处理,这就是所谓的“单线程”。这也是Redis对外提供键值存储服务的主要流程。 - -但Redis的其他功能,比如**关闭文件、AOF 刷盘、释放内存**等,其实是由额外的线程执行的。 - -**Redis命令工作线程是单线程的,但是,整个Redis来说,是多线程的;** - - - -之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。 - -后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。 - - - -#### Redis 采用单线程为什么还这么快? - -1. **基于内存操作**: Redis 的所有数据都存在内存中,因此所有的运算都是内存级别的,所以他的性能比较高; -2. **数据结构简单**: Redis 的数据结构是专门设计的,而这些简单的数据结构的查找和操作的时间大部分复杂度都是 0(1),因此性能比较高; -3. Redis **采用单线程模型可以避免了多线程之间的竞争**,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。 -4. **多路复用和非阻塞 I/O**: Redis使用 I/O多路复用功能来监听多个 socket连接客户端,这样就可以使用一个线程连接来处理多个请求,减少线程切换带来的开销,同时也避免了 I/O 阻塞操作; - - - -#### Redis6.0之前一直采用单线程的主要原因 - -1. 使用单线程模型是 Redis 的开发和维护更简单,因为单线程模型方便开发和调试; -2. 即使使用单线程模型也并发的处理多客户端的请求,主要使用的是IO多路复用和非阻塞IO; -3. 对于Redis系统来说,主要的性能瓶颈是内存或者网络带宽而并非 CPU。 - - - -#### 为什么逐渐加入多线程特性? - -删除一个很大的数据时,因为是单线程原子命令操作,这就会导致 Redis 服务卡顿,于是在 Redis 4.0 中就新增了多线程的模块,主要是为了解决删除数据效率比较低的问题的。 - -使用惰性删除可以有效的解决性能问题, 在Redis4.0就引入了多个线程来实现数据的异步惰性删除等功能 - -```sh -unlink key -flushdb async -flushall async -``` - -**但是其处理读写请求的仍然只有一个线程**,所以仍然算是狭义上的单线程。 - -在Redis6/7中引入了I/0多线程的读写,这样就可以更加高效的处理更多的任务了,Redis只是将I/O读写变成了多线程,而命令的执行依旧是由主线程串行执行的,因此在多线程下操作 Redis不会出现线程安全的问题。 - - - -多线程开启: - -1. 设置`io-thread-do-reads`配置项为yes,表示启动多线程。 -2. 设置线程个数 `io-threads`。关于线程数的设置,官方的建议是如果为4核的CPU,建议线程数设置为2或3,如果为8核CPU建议线程数设置为6,安程数一定要小于机器核数,线程数并不是越大越好。 - - - -因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会**额外创建 6 个线程**(*这里的线程数不包括主线程*): - -- Redis-server : Redis的主线程,主要负责执行命令; -- bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务; -- io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。 - - - -### BigKey - -**多大算BigKey?** - -通常我们说的BigKey,不是在值的Key很大,而是指的Key对应的value很大 - -![](.\Redis\阿里云Redis开发规范.jpg) - - - -**大 key 会带来以下四种影响:** - -1. **客户端超时阻塞**。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。 - -2. **引发网络阻塞**。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。 - -3. **阻塞工作线程**。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。 - -4. **内存分布不均**。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。 - - - -**如何发现BigKey?** - -redis-cli --bigkey - -``` -加上 -i 参数,每隔100 条 scan指令就会休眠0.1s. ops就不会剧烈抬升,但是扫描的时间会变长 -redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.1 -``` - -最好选择在从节点上执行该命令。因为主节点上执行时,会阻塞主节点; - -如果没有从节点,那么可以选择在 Redis 实例业务压力的低峰阶段进行扫描查询,以免影响到实例的正常运行;或者可以使用 -i 参数控制扫描间隔,避免长时间扫描降低 Redis 实例的性能。 - -想查询大于10kb的所有key,--bigkeys参数就无能为力了,需要用到memory usage来计算每个键值的字节数 - -`memory usage 键` - - - -**BigKey如何删除?** - -分批次删除和异步删除 - -- String:一般用del,如果过于庞大使用unlink key 删除 - -- hash - - 使用 hscan 每次获取少量field-value,再使用 hdel 删除每个field, 最后删除field-value - -- list - - 使用 ltrim 渐进式逐步删除,直到全部删除完成 - -- set - - 使用 sscan 每次获取部分元素,在使用 srem 命令删除每个元素 - -- zset - - 使用 zscan 每次获取部分元素,在使用 zremrangebyrank 命令删除每个元素 - - - -**大批量往redis里面插入2000W测试数据key** - -Linux Bash下面执行,插入100W数据 - -1. 生成100W条redis批量设置kv的语句(key=kn,value=vn)写入到/tmp目录下的redisTest.txt文件中 - - ```sh - for((i=1;i<=100*10000;i++)); do echo "set ksi v$i" >> /tmp/redisTest.txt ;done; - ``` - -2. 通过redis提供的管道-pipe命令插入100W大批量数据 - - ```sh - cat /tmp/redisTest.txt | /opt/redis-7.0.0/src/redis-cli -h 127.0.0.1 -p 6379-a 111111 --pipe - ``` - - - -**生产上限制 keys * /flushdb/flushall等危险命令以防止误删误用?** - -通过配置设置禁用这些命令,redis.conf在SECURITY这一项中 - -``` -rename-command keys "" -rename-command flushdb "" -rename-command flushall "" -``` - - - -**不用keys *避免卡顿,那该用什么?** `scan, sscan, hscan, zscan` - -``` -scan cursor [MATCH pattern] [COUNT count] -``` - -* cursor : 游标 -* pattern:匹配的模式 -* count:指定数据集返回多少数据,默认10 - -SCAN 命令是一个基于游标的迭代器,每次被调用之后, 都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为 SCAN 命令的游标参数, 以此来延续之前的迭代过程。 - -SCAN的遍历顺序非常特别,它不是从第一维数组的第零位一直遍历到末尾,而是采用了高位进位加法来遍历。之所以使用这样特殊的方式进行遍历,是考虑到字典的扩容和缩容时避免槽位的遍历重复和遗漏。 - - - -**分别说说三种写回策略,在持久化 BigKey 的时候,会影响什么?** - -- Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数; -- Everysec 策略就会创建一个异步任务来执行 fsync() 函数; -- No 策略就是永不执行 fsync() 函数; - -**当使用 Always 策略的时候,如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的**。 - -当使用 Everysec 策略的时候,由于是异步执行 fsync() 函数,所以大 Key 持久化的过程(数据同步磁盘)不会影响主线程。 - -当使用 No 策略的时候,由于永不执行 fsync() 函数,所以大 Key 持久化的过程不会影响主线程 - - - -AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 `fork()` 函数创建一个子进程来处理任务。会有两个阶段会导致阻塞父进程(主线程): - -- 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长; -- 创建完子进程后,如果父进程修改了共享数据中的大 Key,就会发生写时复制,这期间会拷贝物理内存,由于大 Key 占用的物理内存会很大,那么在复制物理内存这一过程,就会比较耗时,所以有可能会阻塞父进程。 - - - -### 缓存双写一致性(缓存更新策略) - -#### 常见的缓存更新策略 - -- Cache Aside(旁路缓存)策略; -- Read/Write Through(读穿 / 写穿)策略; -- Write Back(写回)策略; - - - -**Cache Aside(旁路缓存)** - -Cache Aside(旁路缓存)策略是最常用的,应用程序直接与「数据库、缓存」交互,并负责对缓存的维护,该策略又可以细分为「读策略」和「写策略」。 - -写策略的步骤: - -- 先更新数据库中的数据,再删除缓存中的数据。 - -读策略的步骤: - -- 如果读取的数据命中了缓存,则直接返回数据; -- 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。 - - - -**Read/Write Through(读穿 / 写穿)策略** - -Read/Write Through(读穿 / 写穿)策略原则是应用程序只和缓存交互,不再和数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作由缓存自己代理了。 - -Read Through 策略: - -先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库查询数据,并将结果写入到缓存组件,最后缓存组件将数据返回给应用。 - -Write Through 策略: - -当有数据更新的时候,先查询要写入的数据在缓存中是否已经存在: - -- 如果缓存中数据已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,然后缓存组件告知应用程序更新完成。 -- 如果缓存中数据不存在,直接更新数据库,然后返回; - - - -**Write Back(写回)策略** - -Write Back(写回)策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。 - -Write Back 是计算机体系结构中的设计,比如 CPU 的缓存、操作系统中文件系统的缓存都采用了 Write Back(写回)策略。 - -**Write Back 策略特别适合写多的场景**,因为发生写操作的时候, 只需要更新缓存,就立马返回了。比如,写文件的时候,实际上是写入到文件系统的缓存就返回了,并不会写磁盘。**但是带来的问题是,数据不是强一致性的,而且会有数据丢失的风险**。 - - - -#### **数据库和缓存一致性的几种更新策略** - -##### 1. 先更新数据库,再更新缓存 - -问题: - -1. 更新数据库成功,更新缓存失败,读到redis脏数据 - -2. 多线程下 - - ``` - 【正常逻辑】 - 1 A update mysql 100 - 2 A update redis 100 - 3 B update mysql 80 - 4 B update redis 80 - ============================= - 【异常逻辑】多线程环境下,A、B两个线程有快有慢,有前有后有并行 - 1 A update mysql 100 - 3 B update mysql 80 - 4 B update redis 80 - 2 A update redis 100 - ============================= - 最终结果,mysql和redis数据不一致: mysql80,redis100 - ``` - -##### 2. 先更新缓存,再更新数据库 - -问题: - -1. 多线程下 - - ``` - 【正常逻辑】 - 1 A update redis 100 - 2 A update mysql 100 - 3 B update redis 80 - 4 B update mysql 80 - ==================================== - 【异常逻辑】多线程环境下,A、B两个线程有快有慢有并行 - A update redis 100 - B update redis 80 - B update mysql 80 - A update mysql 100 - ==================================== - ----mysql100,redis80 - ``` - -##### 3. 先删除缓存,再更新数据库 - -问题: - -1. 多线程下 - - ``` - A删除缓存后,B查询操作没有命中缓存,B先把老数据读出来后放到缓存中,然后A更新操作更新了数据库。 - 于是,在缓存中的数据还是老的数据,数据库的数据是新数据 - ``` - -解决方案: - -1. 延时双删策略: - - ``` - #删除缓存 - redis.delKey(X) - #更新数据库 - db.update(X) - #睡眠 - Thread.sleep(N) - #再删除缓存 - redis.delKey(X) - - 线程A删除并更新数据库后等待一段时间,B将数据库数据写入缓存后,A再删除。 - 等待时间大于B读取并写入时间。如何获取?评估耗时;后台监控程序(WatchDog) - 第二次删除可以使用异步删除,可以增加吞吐量 - ``` - -##### 4. 先更新数据库,再删除缓存 - -问题: - -1. 假如缓存删除失败或者来不及,导致请求再次访问redis时缓存命中,读取到的是缓存旧值。 - -2. 多线程下 - - ``` - 假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。 - ``` - - 但是在实际中,这个问题出现的概率并不高。因为缓存的写入通常要远远快于数据库的写入 - - - -解决方案: - -1. 重试机制:引入消息队列把删除缓存要操作的数据加入消息队列,删除缓存失败则从队列中重新读取数据再次删除,删除成功就从队列中移除 -2. 订阅MySql binlog,再操作缓存:更新数据库成功,就会产生一条变更日志,记录在 binlog 里。订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除。 - - - -优先使用**先更新数据库,再删除缓存的方案(先更库→后删存)**。理由如下: - -1. 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力导致打满mysql。 - -2. 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。 - - - -#### cannal - -[alibaba/canal: 阿里巴巴 MySQL binlog 增量订阅&消费组件 (github.com)](https://github.com/alibaba/canal) - -canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送 dump 协议 - -MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal ) - -canal 解析 binary log 对象(原始为 byte 流) - - - -[QuickStart · alibaba/canal Wiki (github.com)](https://github.com/alibaba/canal/wiki/QuickStart) - -1. mysql - - 1. 查看MySql binlog是否开启: show variables like 'log_bin'; - - 2. 开启 Binlog 写入功能 :my.cnf 中配置 - - ``` - [mysqld] - log-bin=mysql-bin # 开启 binlog - binlog-format=ROW # 选择 ROW 模式 - server_id=1 # 配置 MySQL replaction 需要定义,不要和 canal 的 slaveId 重复 - ``` - - * ROW模式 除了记录sql语句之外,还会记录每个字段的变化情况,能够清楚的记录每行数据的变化历史,但会占用较多的空间。 - - * STATEMENT模式只记录了sql语句,但是没有记录上下文信息,在进行数据恢复的时候可能会导致数据的丢失情况; - * MIX模式比较灵活的记录,理论上说当遇到了表结构变更的时候,就会记录为statement模式。当遇到了数据更新或者删除情况下就会变为row模式; - - 3. 重启mysql - - 4. 授权 canal 链接 MySQL 账号具有作为 MySQL slave 的权限, 如果已有账户可直接 grant - -2. cannal服务端 - - 1. 下载cannal - - 2. 修改配置,主机、账户、密码等 - - 3. 启动 - -3. cannal客户端 [ClientExample · alibaba/canal Wiki (github.com)](https://github.com/alibaba/canal/wiki/ClientExample) - - - - - -### 布隆过滤器 Bloom Filter - -布隆过滤器由「初始值都为 0 的 bit 数组」和「 N 个哈希函数」两部分组成,用来快速判断集合是否存在某个元素。 - -当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。 - -布隆过滤器会通过 3 个操作完成标记: - -- 第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值; -- 第二步,将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。 -- 第三步,将每个哈希值在位图数组的对应位置的值设置为 1; - -一个元素如果判断结果:存在--元素可能存在;不存在--元素一定不存在 - -布隆过滤器只能添加元素,不能删除元素,因为布隆过滤器的bit位可能是共享的,删掉元素会影响其他元素导致误判率增加 - - - -应用场景: - -1. 解决缓存穿透问题 -2. 黑白名单校验 - - - -为了解决布隆过滤器不能删除元素的问题,布谷鸟过滤器横空出世。https://www.cs.cmu.edu/~binfan/papers/conext14_cuckoofilter.pdf#:~:text=Cuckoo%20%EF%AC%81lters%20support%20adding%20and%20removing%20items%20dynamically,have%20lower%20space%20overhead%20than%20space-optimized%20Bloom%20%EF%AC%81lters. - - - -### 缓存预热/缓存雪崩/缓存击穿/缓存穿透 - -#### 缓存预热 - -将热点数据提前加载到redis缓存中 - -#### 缓存雪崩 - -redis**故障或**者redis中**大量的缓存数据同时失效,大量请求直接访问数据库,从而导致数据库压力骤增** - -和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。 - -解决: - -1. 大量数据同时过期: - - 1. 均匀设置过期时间或不过期:设置过期时间时可以加上一个随机数 - - 2. 互斥锁:保证同一时间只有一个请求访问数据库来构建缓存 - 3. 后台更新缓存:业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新。 - -2. redis故障 - - 1. 缓存集群高可用:哨兵、集群、持久化 - - 2. 服务降级、熔断 - -#### 缓存穿透 - -**缓存和数据库中都没有数据** - -解决: - -1. 空对象缓存或缺省值:如果发生了缓存穿透,我们可以针对要查询的数据,在Redis里存一个和业务部门商量后确定的缺省值(比如,零、负数、defaultNull等)。 -2. 布隆过滤器:Google布隆过滤器Guava解决缓存穿透 [guava](https://github.com/google/guava/blob/master/guava/src/com/google/common/hash/BloomFilter.java) -3. 非法请求限制:在 API 入口处我们要判断求请求参数是否合理 -4. 增强id复杂度,避免被猜测id规律(可以采用雪花算法) -5. 做好数据的基础格式校验 -6. 加强用户权限校验 -7. 做好热点参数的限流 - -#### 缓存击穿 - -**缓存中没有但数据库中有数据** - -热点key突然失效,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。 - -解决: - -1. 差异失效时间:开辟两块缓存,设置不同的缓存过期时间,主A从B,先更新B再更新A,先查询A没有再查询B -2. 加锁策略,保证同一时间只有一个业务线程更新缓存 -3. 不给热点数据设置过期时间 -4. 后台更新缓存 -5. 接口限流、熔断与降级 - - - -### 缓存过期删除/内存淘汰策略 - -**redis默认内存多少可用?** - -如果不设置最大内存或者设置最大内存大小为0,在64位操作系统下不限制内存大小,在32位操作系统下最多使用3GB内存 - -注意:在64bit系统下,maxmemory设置为0表示不限制redis内存使用 - -一般推荐Redis设置内存为最大物理内存的3/4 - -**如何修改redis内存设置**? - -1. 通过修改文件配置 `maxmemory` - -2. 通过命令修改,但是redis重启后会失效 `config set maxmemory SIZE` - -什么命令查看redis内存使用情况 - -info memory - -config get maxmemory - - - -#### 过期删除策略 - -1. **立即/定时删除**:在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。对内存友好,对CPU不友好 -2. **惰性删除**:不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。对内存不友好,对CPU友好。开启惰性删除淘汰,lazyfree-lazy-eviction=yes -3. **定期删除**:每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。超过一定比例则重复此操作。Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。缺点是难以确定删除操作执行的时长和频率。 - - **Redis 选择「惰性删除+定期删除」这两种策略配和使用**,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。 - - - -#### 内存淘汰策略 - -超过redis设置的最大内存,就会使用内存淘汰策略删除符合条件的key - -LRU:最近最少使用页面置换算法,淘汰**最长时间未被使用**的页面,看页面最后一次被使用到发生调度的时间长短,首先淘汰最长时间未被使用的页面。 - -LFU:最近最不常用页面置换算法,淘汰**一定时期内被访问次数最少**的页面,看一定时间段内页面被使用的频率,淘汰一定时期内被访问次数最少的页 - - - -淘汰策略有哪些(Redis7版本): - -1. noeviction:不淘汰任何key,表示即使内存达到上限也不进行置换,所有能引起内存增加的命令都会返回error -2. 对设置了过期时间的数据中进行淘汰 - 1. LRU - 2. LFU - 3. random - 4. TTL:优先淘汰更早过期的key -3. 全部数据进行淘汰 - 1. random - 2. LRU - 3. LFU - - - - -**如何修改 Redis 内存淘汰策略?** - -1. `config set maxmemory-policy <策略>` 设置之后立即生效,不需要重启 Redis 服务,重启 Redis 之后,设置就会失效。 - -2. 通过修改 Redis 配置文件修改,设置“`maxmemory-policy <策略>`”,它的优点是重启 Redis 服务后配置不会丢失,缺点是必须重启 Redis 服务,设置才能生效。 - - - -**Redis 是如何实现 LRU 算法的?** - -传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。 - -Redis 并没有使用这样的方式实现 LRU 算法,因为传统的 LRU 算法存在两个问题: - -- 需要用链表管理所有的缓存数据,这会带来额外的空间开销; -- 当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。 - -Redis 实现的是一种**近似 LRU 算法**,目的是为了更好的节约内存,它的**实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间**。 - -当 Redis 进行内存淘汰时,会使用**随机采样的方式来淘汰数据**,它是随机取 N 个值,然后**淘汰最久没有使用的那个**。 - -Redis 实现的 LRU 算法的优点: - -- 不用为所有的数据维护一个大链表,节省了空间占用; -- 不用在每次数据访问时都移动链表项,提升了缓存的性能; - - - -**Redis 是如何实现 LFU 算法的?** - -LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息。 - -```c -typedef struct redisObject { - ... - - // 24 bits,用于记录对象的访问信息 - unsigned lru:24; - ... -} robj; -``` - -Redis 对象头中的 lru 字段,在 LRU 算法下和 LFU 算法下使用方式并不相同。 - -**在 LRU 算法中**,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。 - -**在 LFU 算法中**,Redis对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),用来记录 key 的访问时间戳;低 8bit 存储 logc(Logistic Counter),用来记录 key 的访问频次。 - - - -### 分布式锁 - -基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。 - -- 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁; -- 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间; -- 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端; - -满足这三个条件的分布式命令如下: - -```sh -SET lock_key unique_value NX PX 10000 -``` - -而解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。 - -可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。 - -```c -// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放 -if redis.call("get",KEYS[1]) == ARGV[1] then - return redis.call("del",KEYS[1]) -else - return 0 -end -``` - -基于 Redis 实现分布式锁的**优点**: - -1. 性能高效(这是选择缓存实现分布式锁最核心的出发点)。 -2. 实现方便。因为 Redis 提供了 setnx 方法,实现分布式锁很方便。 -3. 避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)。 - -基于 Redis 实现分布式锁的**缺点**: - -1. 超时时间不好设置。可以基于续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间 - -2. Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。 - - - -#### RedLock - -它是基于**多个 Redis 节点**的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。 - -Redlock 算法的基本思路,**是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败**。 - -这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。 - -Redlock 算法加锁三个过程: - -1. 第一步是,客户端获取当前时间(t1)。 - -2. 第二步是,客户端按顺序依次向 N 个 Redis 节点执行加锁操作: - - - 加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。 - - - 如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。 - -3. 第三步是,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。 - -客户端只有在满足下面的这**两个条件**时,才能认为是加锁成功: - -1. 客户端从超过半数(大于等于N/2+1)的Redis节点上成功获取到了锁; - -2. 客户端获取锁的总耗时没有超过锁的过期时间。 - - - - - -### 过期键 - -#### Redis 持久化时,对过期键会如何处理的? - -Redis 持久化文件有两种格式:RDB(Redis Database)和 AOF(Append Only File),下面我们分别来看过期键在这两种格式中的呈现状态。 - -RDB 文件分为两个阶段,RDB 文件生成阶段和加载阶段。 - -- **RDB 文件生成阶段**:从内存状态持久化成 RDB(文件)的时候,会对 key 进行过期检查,**过期的键「不会」被保存到新的 RDB 文件中**,因此 Redis 中的过期键不会对生成新 RDB 文件产生任何影响。 - -- RDB 加载阶段 - - :RDB 加载阶段时,要看服务器是主服务器还是从服务器,分别对应以下两种情况: - - - **如果 Redis 是「主服务器」运行模式的话,在载入 RDB 文件时,程序会对文件中保存的键进行检查,过期键「不会」被载入到数据库中**。所以过期键不会对载入 RDB 文件的主服务器造成影响; - - **如果 Redis 是「从服务器」运行模式的话,在载入 RDB 文件时,不论键是否过期都会被载入到数据库中**。但由于主从服务器在进行数据同步时,从服务器的数据会被清空。所以一般来说,过期键对载入 RDB 文件的从服务器也不会造成影响。 - -AOF 文件分为两个阶段,AOF 文件写入阶段和 AOF 重写阶段。 - -- **AOF 文件写入阶段**:当 Redis 以 AOF 模式持久化时,**如果数据库某个过期键还没被删除,那么 AOF 文件会保留此过期键,当此过期键被删除后,Redis 会向 AOF 文件追加一条 DEL 命令来显式地删除该键值**。 -- **AOF 重写阶段**:执行 AOF 重写时,会对 Redis 中的键值对进行检查,**已过期的键不会被保存到重写后的 AOF 文件中**,因此不会对 AOF 重写造成任何影响。 - - - -#### Redis 主从模式中,对过期键会如何处理? - -当 Redis 运行在主从模式下时,**从库不会进行过期扫描,从库对过期的处理是被动的**。也就是即使从库中的 key 过期了,如果有客户端访问从库时,依然可以得到 key 对应的值,像未过期的键值对一样返回。 - -从库的过期键处理依靠主服务器控制,**主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库**,从库通过执行这条 del 指令来删除过期的 key。 - - - -### 数据结构 - -#### SDS - -Redis 是用 C 语言实现的,但是它没有直接使用 C 语言的 char* 字符数组来实现字符串,而是自己封装了一个名为简单动态字符串(simple dynamic string,SDS) 的数据结构来表示字符串 - -char*缺陷: - -1. 获取字符串长度的时间复杂度为 O(N); - -2. 字符串的结尾是以 “\0” 字符标识,字符串里面不能包含有 “\0” 字符,因此不能保存二进制数据; - -3. 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止; - -![](.\Redis\SDS.webp) - -SDS改进: - -* Redis 的 SDS 结构因为加入了 len 成员变量,**获取字符串长度的时间复杂度为O(1)**。 -* SDS 不需要用 “\0” 字符来标识字符串结尾,**不仅可以保存文本数据,还可以保存二进制数据**,SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 `buf[]` 数组里的数据。 -* 不会发生缓冲区溢出。**当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小** -* 节省内存空间。设计了 5 种sds类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。**能灵活保存不同大小的字符串,从而有效节省内存空间**。**取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐** - - - -#### 链表 - - - -list 结构为链表提供了链表头指针 head、链表尾节点 tail、链表节点数量 len、以及可以自定义实现的 dup(节点值复制)、free(节点值释放)、match(节点值比较) 函数。 - - - -#### 压缩列表 - -压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。 - -但是,压缩列表的缺陷也是有的: - -- **不能保存过多的元素**,否则查询效率就会降低; -- **会记录前一个结点的大小**,新增或修改某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发**连锁更新**的问题。 - - - -压缩列表是 Redis 为了节约内存而开发的,它是**由连续内存块组成的顺序型数据结构**,有点类似于数组。 - - - -> ***zlbytes***,记录整个压缩列表占用对内存字节数; -> -> ***zltail***,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量; -> -> ***zllen***,记录压缩列表包含的节点数量; -> -> ***zlend***,标记压缩列表的结束点,固定值 0xFF(十进制255)。 -> -> ***prevlen***,记录了「前一个节点」的长度,**目的是为了实现从后向前遍历**; -> -> ***encoding***,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。 -> -> ***data***,记录了当前节点的实际数据,类型和长度都由 `encoding` 决定; - - - -在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段(zllen)的长度直接定位,复杂度是 O(1)。而**查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素**。 - - - -##### 连锁更新问题 - -压缩列表里的每个节点中的 prevlen 属性都记录了「前一个节点的长度」,而且 prevlen 属性的空间大小跟前一个节点长度值有关,比如: - -- 如果**前一个节点的长度小于 254 字节**,那么 prevlen 属性需要用 **1 字节的空间**来保存这个长度值; -- 如果**前一个节点的长度大于等于 254 字节**,那么 prevlen 属性需要用 **5 字节的空间**来保存这个长度值;其中第一个字节设置为254,后面4个字节用来存储长度值。如果仅使用2字节编码方案,当长度超过65535字节时,仍然需要设计新的编码方式,这样会增加复杂性。通过统一使用5字节编码来处理长度较大的情况,可以避免这种复杂性,同时保持了设计的一致性。 - - - -压缩列表新增某个元素或修改某个元素时,如果空间不不够,压缩列表占用的内存空间就需要重新分配。**而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化**,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。 - -1. 假设一个压缩列表中有多个连续的、长度在 250~253 之间的节点,因为这些节点长度值小于 254 字节,所以 prevlen 属性需要用 1 字节的空间来保存这个长度值。 -2. 这时,如果将一个长度大于等于 254 字节的新节点加入到压缩列表的表头节点,即新节点将成为 e1 的前置节点。 -3. 因为 e1 节点的 prevlen 属性只有 1 个字节大小,无法保存新节点的长度,此时就需要对压缩列表的空间重分配操作,并将 e1 节点的 prevlen 属性从原来的 1 字节大小扩展为 5 字节大小。 -4. 导致后面节点的 prevlen 属性也必须从 1 字节扩展至 5 字节大小。 - -**连锁更新一旦发生,就会导致压缩列表占用的内存空间要多次重新分配,这就会直接影响到压缩列表的访问性能**。**压缩列表只会用于保存的节点数量不多的场景**,只要节点数量足够小,即使发生连锁更新,也是能接受的。 - - - -#### 哈希表 - - - -哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。 - -Redis 采用了「**链式哈希**」的方法来解决哈希冲突。 - -```c -typedef struct dictEntry { - //键值对中的键 - void *key; - - //键值对中的值 - union { - void *val; - uint64_t u64; - int64_t s64; - double d; - } v; - //指向下一个哈希表节点,形成链表 - struct dictEntry *next; -} dictEntry; -``` - -dictEntry 结构里键值对中的值是一个「联合体 v」定义的,因此,键值对中的值可以是一个指向实际值的指针,或者是一个无符号的 64 位整数或有符号的 64 位整数或double 类的值。这么做的好处是可以节省内存空间,因为当「值」是整数或浮点数时,就可以将值的数据内嵌在 dictEntry 结构里,无需再用一个指针指向实际的值,从而节省了内存空间。 - - - -##### rehash - -在实际使用哈希表时,Redis 定义一个 dict 结构体,这个结构体里定义了**两个哈希表(ht[2])**。 - - - -在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。 - -随着数据逐步增多,触发了 rehash 操作,这个过程分为三步: - -1. 给「哈希表 2」 分配空间,一般会比「哈希表 1」 大一倍(两倍的意思); - -2. 将「哈希表 1 」的数据迁移到「哈希表 2」 中; - -3. 迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。 - -##### 渐进式 rehash - -为了避免 rehash 在数据迁移过程中,因拷贝数据的耗时,影响 Redis 性能的情况,所以 Redis 采用了**渐进式 rehash**,也就是将数据的迁移的工作不再是一次性迁移完成,而是分多次迁移。 - -渐进式 rehash 步骤如下: - -1. 给「哈希表 2」 分配空间; - -2. **在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上**; - -3. 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。 - -在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。 - - - -触发 rehash 操作的条件,主要有两个: - -- **当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。** -- **当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。** - - - -#### 整数集合 - -整数集合是 Set 对象的底层实现之一。当一个 Set 对象只包含整数值元素,并且元素数量不大时,就会使用整数集这个数据结构作为底层实现。 - -整数集合本质上是一块连续内存空间。 - -```c -typedef struct intset { - //编码方式 - uint32_t encoding; - //集合包含的元素数量 - uint32_t length; - //保存元素的数组 - int8_t contents[]; -} intset; -``` - -虽然 contents 被声明为 int8_t 类型的数组,但是实际上 contents 数组并不保存任何 int8_t 类型的元素,contents 数组的真正类型取决于 intset 结构体里的 encoding 属性的值。比如int16_t, int32_t, int64_t. - -##### 升级操作 - -整数集合会有一个升级规则,就是当我们将一个新元素加入到整数集合里面,如果新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进行升级,也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里,当然升级的过程中,也要维持整数集合的有序性。 - -整数集合升级的过程不会重新分配一个新类型的数组,而是在原本的数组上扩展空间,然后在将每个元素按间隔类型大小分割,如果 encoding 属性值为 INTSET_ENC_INT16,则每个元素的间隔就是 16 位。 - - - -整数集合升级的好处是**节省内存资源**。**不支持降级操作**。 - -#### 跳表 - -Redis 只有 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。 - -zset 结构体里有两个数据结构:一个是跳表,一个是哈希表。这样的好处是既能进行高效的范围查询,也能进行高效单点查询。 - -Zset 对象能支持范围查询(如 ZRANGEBYSCORE 操作),这是因为它的数据结构设计采用了跳表,而又能以常数复杂度获取元素权重(如 ZSCORE 操作),这是因为它同时采用了哈希表进行索引。 - - struct zset 中的哈希表只是用于以常数复杂度获取元素权重,大部分操作都是跳表实现的。 - - - -##### 结构设计 - -**跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表**,这样的好处是能快读定位数据。 - - - -> 如果我们要在链表中查找节点 4 这个元素,只能从头开始遍历链表,需要查找 4 次,而使用了跳表后,只需要查找 2 次就能定位到节点 4,因为可以在头节点直接从 L2 层级跳到节点 3,然后再往前遍历找到节点 4。 - -```c -typedef struct zskiplistNode { - //Zset 对象的元素值 - sds ele; - //元素权重值 - double score; - //后向指针 指向前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。 - struct zskiplistNode *backward; - - //节点的level数组,保存每层上的前向指针和跨度 - struct zskiplistLevel { - // 指向下一个跳表节点的指针 - struct zskiplistNode *forward; - // 跨度 跨度实际上是为了计算这个节点在跳表中的排位 - unsigned long span; - } level[]; -} zskiplistNode; -``` - -```c -typedef struct zskiplist { - struct zskiplistNode *header, *tail; - unsigned long length; - int level; -} zskiplist; -``` - - - -##### 查询过程 - -查找一个跳表节点的过程时,**跳表会从头节点的最高层开始,逐一遍历每一层**。在遍历某一层的跳表节点时,会用跳表节点中的 SDS 类型的元素和元素的权重来进行判断,共有两个判断条件: - -- 如果当前节点的权重「小于」要查找的权重时,跳表就会访问该层上的下一个节点。 -- 如果当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点。 - -如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找,这就相当于跳到了下一层接着查找。 - -![](.\Redis\3层跳表-跨度.drawio.webp) - -如果要查找「元素:abcd,权重:4」的节点,查找的过程是这样的: - -1. 先从头节点的最高层开始,L2 指向了「元素:abc,权重:3」节点,这个节点的权重比要查找节点的小,所以要访问该层上的下一个节点; - -2. 但是该层的下一个节点是空节点( leve[2]指向的是空节点),于是就会跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[1]; - -3. 「元素:abc,权重:3」节点的 leve[1] 的下一个指针指向了「元素:abcde,权重:4」的节点,然后将其和要查找的节点比较。虽然「元素:abcde,权重:4」的节点的权重和要查找的权重相同,但是当前节点的 SDS 类型数据「大于」要查找的数据,所以会继续跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[0]; - -4. 「元素:abc,权重:3」节点的 leve[0] 的下一个指针指向了「元素:abcd,权重:4」的节点,该节点正是要查找的节点,查询结束。 - -##### 层数设置 - -**跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN)**。 - -Redis的 **跳表在创建节点的时候,随机生成每个节点的层数**,并没有严格维持相邻两层的节点数量比例为 2 : 1 的情况。 - -具体的做法是,**跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数**。 - -这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。 - -虽然我前面讲解跳表的时候,图中的跳表的「头节点」都是 3 层高,但是其实**如果层高最大限制是 64,那么在创建跳表「头节点」的时候,就会直接创建 64 层高的头节点**。 - - - -#### quicklist - -quicklist 就是「**双向链表 + 压缩列表**」组合,因为一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。 - -quicklist **通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。** - - - -在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。 - -quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险。 - - - -#### listpack - -quicklist 虽然通过控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并没有完全解决连锁更新的问题。压缩列表连锁更新的问题,来源于它的结构设计,所以要想彻底解决这个问题,需要设计一个新的数据结构。 - -Redis 在 5.0 新设计一个数据结构叫 listpack,**目的是替代压缩列表**,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。 - - - -> encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码; -> -> data,实际存放的数据; -> -> len,encoding+data的总长度; - -**listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题**。 - -listpack 一样可以支持从后往前遍历的。详细的算法可以看:https://github.com/antirez/listpack/blob/master/listpack.c 里的lpDecodeBacklen函数,lpDecodeBacklen 函数就可以从当前列表项起始位置的指针开始,向左逐个字节解析,得到前一项的 entry-len 值。 - - - -## 实战 - -### 分布式Session - -将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截。 - -对于 Spring Boot 应用,可以使用 `spring-session-data-redis` 库来实现会话共享 - -1. 添加`Spring Session`和 `redis`的相关依赖 - - ```xml - - org.springframework.session - spring-session-data-redis - - - org.springframework.boot - spring-boot-starter-data-redis - - ``` - -2. 配置 - - ```yml - spring: - redis: - # redis库 - database: 0 - # redis 服务器地址 - host: localhost - # redis 端口号 - port: 6379 - # redis 密码 - password: - # session 使用redis存储 - session: - store-type: redis - ``` - -3. 在 Spring Boot 应用的主类上添加 `@EnableRedisHttpSession` 注解: - - - -### 缓存 - -优点: - -1. 降低后端负载 -2. 提高读写效率,降低响应时间 - -缺点: - -1. 数据一致性问题 -2. 维护成本 - -**先操作数据库,再删除缓存,同时设置缓存时添加过期时间** - -#### 缓存穿透问题 - -缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。 - -常见的解决方案有两种: - -1. 缓存空对象 - - - 优点:实现简单,维护方便 - - - 缺点:额外的内存消耗、可能造成短期的不一致 - - -如果这个数据不存在,将这个数据写入到Redis中,并且将value设置为空字符串,然后设置一个较短的TTL,返回错误信息。当再次发起查询时,先去Redis中判断value是否为空字符串,如果是空字符串,则说明是刚刚我们存的不存在的数据,直接返回错误信息。 - -```java -//先从Redis中查,这里的常量值是固定的前缀 + 店铺id -String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); -//如果不为空(查询到了),则转为Shop类型直接返回 -if (StrUtil.isNotBlank(shopJson)) { - Shop shop = JSONUtil.toBean(shopJson, Shop.class); - return Result.ok(shop); -} -//如果查询到的是空字符串,则说明是我们缓存的空数据 -if (shopJson != null) { - return Result.fail("店铺不存在!!"); -} -//否则去数据库中查 -Shop shop = getById(id); -//查不到返回一个错误信息或者返回空都可以,根据自己的需求来 -if (shop == null){ - //这里的常量值是2分钟 - stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES); - return Result.fail("店铺不存在!!"); -} -//查到了则转为json字符串 -String jsonStr = JSONUtil.toJsonStr(shop); -//并存入redis -stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr,CACHE_SHOP_TTL,TimeUnit.MINUTES); - -//最终把查询到的商户信息返回给前端 -return Result.ok(shop); -``` - -2. 布隆过滤 - - - 优点:内存占用较少,没有多余key - - - - 缺点:实现复杂、存在误判可能 - - - 使用谷歌的guava - -布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中;假设布隆过滤器判断这个数据不存在,则直接返回。 - -这种方式优点在于节约内存空间,但存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突 - -#### 缓存雪崩问题 - -缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。 - -解决方案: - -1. 给不同的Key的TTL添加随机值 - -2. 利用Redis集群提高服务的可用性 - -3. 给缓存业务添加降级限流策略 - -4. 给业务添加多级缓存 - -#### 缓存击穿问题 - -缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。 - -常见的解决方案有两种: - -1. 互斥锁 - -2. 逻辑过期 - -| 解决方案 | 优点 | 缺点 | -| -------- | -------------------------------------- | ---------------------------------------- | -| 互斥锁 | 没有额外内存消耗;保证一致性;实现简单 | 线程需要等待,性能受影响;可能死锁 | -| 逻辑过期 | 线程无需等待、性能好 | 不能保证一致性;有额外内存消耗;实现复杂 | - -**互斥锁思路**: - -进行查询之后,如果没有从缓存中查询到数据,则进行互斥锁的获取,获取互斥锁之后,判断是否获取到了锁,如果没获取到,则休眠一段时间,过一会儿再去尝试,直到获取到锁为止,才能进行查询; -如果获取到了锁的线程,则进行查询,将查询到的数据写入Redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行数据库的逻辑,防止缓存击穿。 - -```java -public Shop solveCacheMutex(Long id){ - // 查询redis中有无数据 - String key = "cache:shop:" + id; - String shopCache = stringRedisTemplate.opsForValue().get(key); - if(StrUtil.isNotBlank(shopCache)){ - // 命中缓存 - return JSONUtil.toBean(shopCache, Shop.class); - } - // 判断缓存穿透问题 - shopCaache如果为“” 命中空缓存 如果为null 需要查询数据库 - if(shopCache != null){ - // 命中空缓存 - return null; - } - // 2.1未命中缓存 尝试获取互斥锁 - String lockKey = "lock:shop:" + id; - Shop shop = null; - try { - boolean lock = tryLock(lockKey); - if(!lock){ - // 获取锁失败 - Thread.sleep(50); - return solveCacheMutex(id); - } - // 获取锁成功 - // 再次检查Redis是否有缓存 - shopCache = stringRedisTemplate.opsForValue().get(key); - if(StrUtil.isNotBlank(shopCache)){ - return JSONUtil.toBean(shopCache, Shop.class); - } - // 查询数据库 - shop = getById(id); - // 店铺不存在 - if(shop == null){ - // 将空值写入Redis - stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES); - return null; - } - // 存储Redis - stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } finally { - // 释放互斥锁 - unLock(lockKey); - } - return shop; -} -// 获取锁 -private boolean tryLock(String key){ - Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); - return BooleanUtil.isTrue(flag); -} -// 释放锁 -private void unLock(String key){ - stringRedisTemplate.delete(key); -} -``` - - - -**逻辑过期思路**: - -我们把过期时间设置在redis的value中。假设线程1去查询缓存,然后从value中判断当前数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,**获得了锁的进程他会开启一个新线程去进行重建缓存数据的逻辑,直到新开的线程完成这个逻辑之后,才会释放锁**,而线程1直接进行返回,假设现在线程3过来访问,由于线程2拿着锁,所以线程3无法获得锁,线程3也直接返回数据(但只能返回旧数据,牺牲了数据一致性,换取性能上的提高),只有等待线程2重建缓存数据之后,其他线程才能返回正确的数据。 -这种方案巧妙在于,异步构建缓存数据,缺点是在重建完缓存数据之前,返回的都是脏数据。 - - - -当用户开始查询redis时,判断是否命中 - -1. 如果没有命中则直接返回空数据,不查询数据库 -2. 如果命中,则将value取出,判断value中的过期时间是否满足 - 1. 如果没有过期,则直接返回redis中的数据 - 2. 如果过期,则在开启独立线程后,直接返回之前的数据,独立线程去重构数据,重构完成后再释放互斥锁 - -```java -private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); -public Shop solveCacheLogicalExpire(Long id){ - // 查询redis中有无数据 - String key = "cache:shop:" + id; - String shopCache = stringRedisTemplate.opsForValue().get(key); - if(StrUtil.isBlank(shopCache)){ - // 未命中返回null - return null; - } - // 命中缓存 检查是否过期 - // 未过期 直接返回 注意这里类型转换 - RedisData redisData = JSONUtil.toBean(shopCache, RedisData.class); - JSONObject jsonObject = (JSONObject) redisData.getData(); // 此处是将Bean对象转ObjectJson - Shop shop = JSONUtil.toBean(jsonObject, Shop.class); - LocalDateTime expireTime = redisData.getExpireTime(); - if(expireTime.isAfter(LocalDateTime.now())){ - return shop; - } - // 过期 - // 获取锁 - String lockKey = "lock:shop:" + id; - boolean lock = tryLock(lockKey); - if(lock){ - // 成功 - // 再次检查Redis缓存是否逻辑过期 - if(expireTime.isAfter(LocalDateTime.now())){ - // 没过期 - return shop; - } - // 开启新线程 - CACHE_REBUILD_EXECUTOR.submit(()->{ - try { - // 重建缓存 - this.saveShop2Redis(id, 20L); - } catch (Exception e) { - throw new RuntimeException(e); - } finally { - unLock(lockKey); - } - }); - - } - // 返回数据 - return shop; -} - -public void saveShop2Redis(Long id, Long expireSeconds){ - RedisData redisData = new RedisData(); - Shop shop = getById(id); - redisData.setData(shop); - redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); - stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData)); -} -``` - - - -### 分布式锁 - -基于redis的分布式锁的实现思路: - -* 利用set nx ex 获取锁,并设置TTL,保存线程标识; -* 释放锁时先判断线程标识是否和自己保存一致,一致则删除锁 - -特性: - -* 利用set nx 满足互斥性 -* 利用set ex 保证故障时锁依然能释放,避免死锁,提高安全性 -* 利用redis集群保证高可用和高并发性 - -基于SETNX实现的分布式锁存在以下问题: - -* 不可重入 -* 不可重试 -* 超时释放 -* 主从一致性 - -```java -public boolean tryLock(Long timeSec) { - // 获取线程标识 - String threadId = ID_PREFIX + Thread.currentThread().getId(); - // 获取锁 - Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId , timeSec, TimeUnit.SECONDS); - - return Boolean.TRUE.equals(success); -} -// 解决误删问题-先判断是不是自己的 -public void unlock() { - // 获取线程标识 - String threadId = ID_PREFIX + Thread.currentThread().getId(); - // 获取锁中的标识 - String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name); - // 判断标识是否一致 - if (id.equals(threadId)) { - // 释放锁 - stringRedisTemplate.delete(KEY_PREFIX + name); - } -} -// 解决上述原子性问题-Lua脚本实现拿锁、判断、删锁是原子操作 -private static final DefaultRedisScript UNLOCK_SCRIPT; -static { - UNLOCK_SCRIPT = new DefaultRedisScript<>(); - UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));//读文件 方便后期维护 - UNLOCK_SCRIPT.setResultType(Long.class); -} -public void unlock() { - // 调用lua脚本 - stringRedisTemplate.execute(UNLOCK_SCRIPT, - Collections.singletonList(KEY_PREFIX + name), - ID_PREFIX + Thread.currentThread().getId() - ); -} -``` - -```lua --- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示 --- 获取锁中的标示,判断是否与当前线程标示一致 -if (redis.call('GET', KEYS[1]) == ARGV[1]) then - -- 一致,则删除锁 - return redis.call('DEL', KEYS[1]) -end --- 不一致,则直接返回 -return 0 -``` - - - -#### Redisson - -**Redisson分布式锁原理** - -可重入:利用hash结构记录线程id和重入次数 - -可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制 - -超时续约:利用watchDog,每个一段时间(releaseTime),重置超时时间。 - -![](Redis\redisson分布式锁.png) - -1. 依赖 - - ```xml - - org.redisson - redisson - 3.13.6 - - ``` - -2. 配置类 - - ```java - @Configuration - public class RedissonConfig { - @Bean - public RedissonClient redissonClient() { - Config config = new Config(); - config.useSingleServer() - .setAddress("redis://localhost:6379"); - return Redisson.create(config); - } - } - ``` - -3. 使用分布式锁 - - ```java - @Resource - private RedissonClient redissonClient; - - @Test - void testRedisson() throws InterruptedException { - //获取可重入锁 - RLock lock = redissonClient.getLock("anyLock"); - //尝试获取锁,三个参数分别是:获取锁的最大等待时间(期间会重试),锁的自动释放时间,时间单位 - boolean success = lock.tryLock(1,10, TimeUnit.SECONDS); - //判断获取锁成功 - if (success) { - try { - System.out.println("执行业务"); - } finally { - //释放锁 - lock.unlock(); - } - } - } - ``` - - - -**Redisson可重入锁原理** - -在分布式锁中,采用hash结构来存储锁,其中外层key表示这把锁是否存在,内层key则记录当前这把锁被哪个线程持有。 - -``` -lock -> {thread: state} #如果持有这把锁的人(同一线程下)再次持有这把锁,那么state会+1 -``` - -获取锁的逻辑: - -```lua -local key = KEYS[1]; -- 锁的key -local threadId = ARGV[1]; -- 线程唯一标识 -local releaseTime = ARGV[2]; -- 锁的自动释放时间 --- 锁不存在 -if (redis.call('exists', key) == 0) then - -- 获取锁并添加线程标识,state设为1 - redis.call('hset', key, threadId, '1'); - -- 设置锁有效期 - redis.call('expire', key, releaseTime); - return 1; -- 返回结果 -end; --- 锁存在,判断threadId是否为自己 -if (redis.call('hexists', key, threadId) == 1) then - -- 锁存在,重入次数 +1,这里用的是hash结构的incrby增长 - redis.call('hincrby', key, thread, 1); - -- 设置锁的有效期 - redis.call('expire', key, releaseTime); - return 1; -- 返回结果 -end; -return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败 -``` - -释放锁的逻辑: - -```lua -local key = KEYS[1]; -local threadId = ARGV[1]; -local releaseTime = ARGV[2]; --- 如果锁不是自己的 -if (redis.call('HEXISTS', key, threadId) == 0) then - return nil; -- 直接返回 -end; --- 锁是自己的,锁计数-1,还是用hincrby,不过自增长的值为-1 -local count = redis.call('hincrby', key, threadId, -1); --- 判断重入次数为多少 -if (count > 0) then - -- 大于0,重置有效期 - redis.call('expire', key, releaseTime); - return nil; -else - -- 否则直接释放锁 - redis.call('del', key); - return nil; -end; -``` - -**MutiLock锁** - -Redisson提出来了MutiLock锁,使用这把锁的话,那我们就不用主从了,每个节点的地位都是一样的,都可以当做是主机,那我们就需要将加锁的逻辑写入到每一个主从节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获取锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性. - - - -### 点赞及点赞排行榜 - -同一个用户只能对同一篇笔记点赞一次,再次点击则取消点赞 - -利用Redis中的set集合来判断是否点赞过,未点赞则点赞数+1,已点赞则点赞数-1 - -```java -//1. 获取当前用户信息 -User loginUser = userService.getLoginUser(request); -Long userId = loginUser.getId(); -//2. 如果当前用户未点赞,则点赞数 +1,同时将用户加入set集合 -String key = APP_LIKED_KEY + id; -Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId.toString()); -if (BooleanUtil.isFalse(isLiked)) { - //点赞数 +1 - boolean success = update().setSql("liked_number = liked_number + 1").eq("id", id).update(); - //将用户加入set集合 - if (success) { - stringRedisTemplate.opsForSet().add(key, userId.toString()); - } - //3. 如果当前用户已点赞,则取消点赞,将用户从set集合中移除 -} else { - //点赞数 -1 - boolean success = update().setSql("liked_number = liked_number - 1").eq("id", id).update(); - if (success){ - //从set集合移除 - stringRedisTemplate.opsForSet().remove(key, userId.toString()); - } -} -``` - -之前的点赞是放到Set集合中,但是Set集合又不能排序,所以这个时候,我们就可以改用SortedSet(Zset) - -由于ZSet没有isMember方法,所以这里只能通过查询score来判断集合中是否有该元素,如果有该元素,则返回值是对应的score,如果没有该元素,则返回值为null - diff --git "a/docs/Redis/Redis/3\345\261\202\350\267\263\350\241\250-\350\267\250\345\272\246.drawio.webp" "b/docs/Redis/Redis/3\345\261\202\350\267\263\350\241\250-\350\267\250\345\272\246.drawio.webp" deleted file mode 100644 index 5f95af2..0000000 Binary files "a/docs/Redis/Redis/3\345\261\202\350\267\263\350\241\250-\350\267\250\345\272\246.drawio.webp" and /dev/null differ diff --git a/docs/Redis/Redis/SDS.webp b/docs/Redis/Redis/SDS.webp deleted file mode 100644 index 1648e7f..0000000 Binary files a/docs/Redis/Redis/SDS.webp and /dev/null differ diff --git a/docs/Redis/Redis/aofewwrite.png b/docs/Redis/Redis/aofewwrite.png deleted file mode 100644 index 2484f3d..0000000 Binary files a/docs/Redis/Redis/aofewwrite.png and /dev/null differ diff --git a/docs/Redis/Redis/lazyfree.png b/docs/Redis/Redis/lazyfree.png deleted file mode 100644 index 2ac8e2a..0000000 Binary files a/docs/Redis/Redis/lazyfree.png and /dev/null differ diff --git a/docs/Redis/Redis/listpack.webp b/docs/Redis/Redis/listpack.webp deleted file mode 100644 index 486575d..0000000 Binary files a/docs/Redis/Redis/listpack.webp and /dev/null differ diff --git a/docs/Redis/Redis/quicklist.webp b/docs/Redis/Redis/quicklist.webp deleted file mode 100644 index c1f721c..0000000 Binary files a/docs/Redis/Redis/quicklist.webp and /dev/null differ diff --git "a/docs/Redis/Redis/redisson\345\210\206\345\270\203\345\274\217\351\224\201.png" "b/docs/Redis/Redis/redisson\345\210\206\345\270\203\345\274\217\351\224\201.png" deleted file mode 100644 index 29921d8..0000000 Binary files "a/docs/Redis/Redis/redisson\345\210\206\345\270\203\345\274\217\351\224\201.png" and /dev/null differ diff --git a/docs/Redis/Redis/rehash.webp b/docs/Redis/Redis/rehash.webp deleted file mode 100644 index 782a1bc..0000000 Binary files a/docs/Redis/Redis/rehash.webp and /dev/null differ diff --git a/docs/Redis/Redis/scan.png b/docs/Redis/Redis/scan.png deleted file mode 100644 index a67266d..0000000 Binary files a/docs/Redis/Redis/scan.png and /dev/null differ diff --git a/docs/Redis/Redis/slot.jpg b/docs/Redis/Redis/slot.jpg deleted file mode 100644 index c9d81dc..0000000 Binary files a/docs/Redis/Redis/slot.jpg and /dev/null differ diff --git a/docs/Redis/Redis/slot.png b/docs/Redis/Redis/slot.png deleted file mode 100644 index 281a146..0000000 Binary files a/docs/Redis/Redis/slot.png and /dev/null differ diff --git a/docs/Redis/Redis/stream.png b/docs/Redis/Redis/stream.png deleted file mode 100644 index 316e449..0000000 Binary files a/docs/Redis/Redis/stream.png and /dev/null differ diff --git "a/docs/Redis/Redis/\344\272\224\347\247\215\346\225\260\346\215\256\347\261\273\345\236\213.webp" "b/docs/Redis/Redis/\344\272\224\347\247\215\346\225\260\346\215\256\347\261\273\345\236\213.webp" deleted file mode 100644 index 0b8bf7d..0000000 Binary files "a/docs/Redis/Redis/\344\272\224\347\247\215\346\225\260\346\215\256\347\261\273\345\236\213.webp" and /dev/null differ diff --git "a/docs/Redis/Redis/\345\215\207\347\272\247\346\223\215\344\275\234.webp" "b/docs/Redis/Redis/\345\215\207\347\272\247\346\223\215\344\275\234.webp" deleted file mode 100644 index e18619c..0000000 Binary files "a/docs/Redis/Redis/\345\215\207\347\272\247\346\223\215\344\275\234.webp" and /dev/null differ diff --git "a/docs/Redis/Redis/\345\216\213\347\274\251\345\210\227\350\241\250.webp" "b/docs/Redis/Redis/\345\216\213\347\274\251\345\210\227\350\241\250.webp" deleted file mode 100644 index e5b524a..0000000 Binary files "a/docs/Redis/Redis/\345\216\213\347\274\251\345\210\227\350\241\250.webp" and /dev/null differ diff --git "a/docs/Redis/Redis/\345\220\216\345\217\260\347\272\277\347\250\213.webp" "b/docs/Redis/Redis/\345\220\216\345\217\260\347\272\277\347\250\213.webp" deleted file mode 100644 index 0d52bbe..0000000 Binary files "a/docs/Redis/Redis/\345\220\216\345\217\260\347\272\277\347\250\213.webp" and /dev/null differ diff --git "a/docs/Redis/Redis/\345\223\210\345\270\214\350\241\250.webp" "b/docs/Redis/Redis/\345\223\210\345\270\214\350\241\250.webp" deleted file mode 100644 index 0b397d1..0000000 Binary files "a/docs/Redis/Redis/\345\223\210\345\270\214\350\241\250.webp" and /dev/null differ diff --git "a/docs/Redis/Redis/\346\225\260\346\215\256\347\273\223\346\236\204.webp" "b/docs/Redis/Redis/\346\225\260\346\215\256\347\273\223\346\236\204.webp" deleted file mode 100644 index 99cf70c..0000000 Binary files "a/docs/Redis/Redis/\346\225\260\346\215\256\347\273\223\346\236\204.webp" and /dev/null differ diff --git "a/docs/Redis/Redis/\350\267\263\350\241\250.webp" "b/docs/Redis/Redis/\350\267\263\350\241\250.webp" deleted file mode 100644 index ace15d0..0000000 Binary files "a/docs/Redis/Redis/\350\267\263\350\241\250.webp" and /dev/null differ diff --git "a/docs/Redis/Redis/\351\200\273\350\276\221\350\277\207\346\234\237.png" "b/docs/Redis/Redis/\351\200\273\350\276\221\350\277\207\346\234\237.png" deleted file mode 100644 index c659b8d..0000000 Binary files "a/docs/Redis/Redis/\351\200\273\350\276\221\350\277\207\346\234\237.png" and /dev/null differ diff --git "a/docs/Redis/Redis/\351\223\276\350\241\250.webp" "b/docs/Redis/Redis/\351\223\276\350\241\250.webp" deleted file mode 100644 index 03affca..0000000 Binary files "a/docs/Redis/Redis/\351\223\276\350\241\250.webp" and /dev/null differ diff --git "a/docs/Redis/Redis/\351\230\277\351\207\214\344\272\221Redis\345\274\200\345\217\221\350\247\204\350\214\203.jpg" "b/docs/Redis/Redis/\351\230\277\351\207\214\344\272\221Redis\345\274\200\345\217\221\350\247\204\350\214\203.jpg" deleted file mode 100644 index b38001a..0000000 Binary files "a/docs/Redis/Redis/\351\230\277\351\207\214\344\272\221Redis\345\274\200\345\217\221\350\247\204\350\214\203.jpg" and /dev/null differ diff --git a/mkdocs.yml b/mkdocs.yml index 4a06b2b..2a4cda3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,14 +15,6 @@ nav: - Java基础: Java/Java.md - Java集合: Java/Java集合.md - JUC: Java/JUC.md -# - JVM: JVM/JVM.md -# - Spring: Spring/Spring6.md -# - SpringBoot: SpringBoot/SpringBoot3.md - - MySQL: MySQL/MySQL.md -# - Redis: Redis/Redis.md -# - MQ: -# - RabbitMQ: MQ/RabbitMQ.md -# - RocketMQ: MQ/RocketMQ.md - 设计模式: 设计模式/设计模式.md # Theme