当前位置: 首页 > news >正文

MySQL进阶篇(存储引擎、索引、视图、SQL性能优化、存储过程、触发器、锁)

MySQL进阶篇

  • 存储引擎篇
    • MySQL体系结构
    • 存储引擎简介
      • 常用存储引擎简介
      • 存储引擎的选择
  • 索引篇
    • 索引简介
    • 索引结构
      • (1)B+Tree索引
      • (2)hash索引
    • 索引分类
    • 索引语法
    • SQL性能分析指标
      • (1)SQL执行频率
      • (2)慢查询日志
      • (3)profile详情
      • (4)explain或desc执行计划
    • 索引使用
      • 引起索引的失效行为
      • SQL提示
      • 覆盖索引
      • 前缀查询(解决长字符串的索引问题)
      • 单列索引和联合索引
    • 索引设计的原则
  • 视图(View)篇
    • 概念和语法
    • 视图检查选项check option
    • 视图的更新
    • 视图的作用
    • 视图练习
  • SQL优化篇
    • 1.插入优化
    • 2.主键优化
    • 3.group by 优化
    • 4.order by 优化
    • 5.count计数函数优化
    • 6.update优化
  • 存储过程
    • 变量
      • 系统变量
      • 用户自定义变量
      • 局部变量
    • 存储过程定义
    • 存储逻辑
    • 存储函数
    • 游标
  • 触发器
    • 全局锁
    • 表级锁
      • 表锁
      • 元数据锁
      • 意向锁
    • 行级锁
      • 行锁
      • 间隙锁
      • 临键锁

存储引擎篇

MySQL体系结构

要想了解存储引擎,首先需要了解一下MySQL的体系结构,如下所示
MySQL体系结构
从上到下,依次为客户端连接层,服务层、引擎层和存储层。
(1)连接层
主要完成一些类似于连接处理、授权认证及相关的安全方案。服务器也会为安全接入的每个用户端验证它所拥有的操作权限。
(2)服务层
主要完成大多数核心功能,如SQL接口,并完成缓存的查询,SQL分析和优化,部分内置函数的执行,所有跨存储引擎的功能也在这一层实现,如过程、函数等。
(3)引擎层、存储引擎真正的负责了MySQL中数据的存储和提取,服务器通过API和存储引擎进行通信。不同的存储引擎具有不同的功能,这样可以根据自己的需要来选取合适的存储引擎。
(4)存储层
主要是将数据存储在文件系统上,并完成与存储引擎的交互。

存储引擎简介

存储引擎就是存储数据、建立索引、更新/查询数据等技术的实现方式。存储引擎是基于表的,而不是基于库的,所以存储引擎也被称为表类型。
在MySQL5.0版本之后,默认的存储引擎类型为InnoDB
例如:在之前的emp表中,创建时并没有指定存储引擎类型,即使用了默认的存储引擎,使用基础篇的查看建表语句。

show create table emp;

emp表建表语句
可以发现存储引擎为InnoDB,而后面的AUTO_INCREMENT=27表示下一条插入的数据分配的主键ID为27。对引擎常用的语法有以下几条:
(1)查看数据库支持的引擎:show engines
MySQL8.0支持的存储引擎
在创建一个表的时候,可以通过一下语句指定该表的存储引擎。
Create table 表名(字段名 类型 comment 字段注释...)engine=存储引擎类型 comment;

常用存储引擎简介

在这里主要介绍InnoDB、MyISAM和Memory三种存储引擎
1.InnoDB
概述:InnoDB是一种兼顾高可靠性和高性能的通用存储引擎,在MySQL 5.5之后,InnoDB是默认的MySQL的存储引擎
特点:(1)DML操作遵循ACID模型,支持事务;
(2)行级锁,提高并发访问性能;
(3)支持外键FOREIGN KEY约束
即:事务、行级锁、外键

在INNODB中,page是磁盘操作的最小单位,大小固定,为16k,而一个区大小也是固定的,为1M,所以一个区能存放64个页
2.MyISAM
概述:MyISAM是MySQL早期支持的默认存储引擎
特点:(1)不支持事务,不支持外键;
(2)支持表锁,不支持行锁;
(3)访问速度快
3.Memory
概述:Memory引擎的表数据时存储在内存中,由于受到硬件问题、或断电问题的影响,只能讲这些表作为临时表进行使用
特点:内存存放、hash索引

存储引擎的选择

InnoDB:是MySQL的默认存储引擎,支持事务、外键、行级锁。如果对事务的完整性有比较高的要求,在并发条件下要求数据一致性,数据操作除了插入和查询之外,还包含很多的更新、删除操作,那么InnoDB存储引擎是个比较合适的选择。
MyISAM:如果应用主要是以读操作和插入操作为主,只有很少的更新和删除操作,并且对事物的完整性、并发性要求不是很高,可以选择MyISAM。
MEMORY:将所有数据保存在内存中,访问速度块,通常用于临时表及缓存。MEMORY的缺陷就是对表的大小有限制,太大的表无法缓存在内存中,而且无法保障数据的安全性。

索引篇

索引简介

索引是一种帮助MySQL高效获取数据数据结构。在数据之外,数据库系统还维护着满足特定查找算法的数据结构,这些数据结构以某种方式指向数据,这就是索引。
索引的优点
(a)提高数据检索的效率,降低数据库的IO成本;
(b)通过索引对数据进行排序,降低数据排序的成本,降低CPU的消耗。
索引的缺点
(a)索引也需要空间进行存储;
(b)在插入和删除操作中,还需要维护索引之间的关系。
但是在所有的优点面前,其缺点几乎可以忽略不计,因为对于一个关系型数据库,查找的次数一般远大于插入和删除的次数。而索引能够大大提升查找的效率。

索引结构

之前提到索引是在存储引擎层实现的,根据存储引擎的不同,其实现方式也不相同,主要可分为以下几类:

(1)B+Tree索引

B树是一种多路平衡查找树,在这里以树的max_degree=5为例,则每个结点有4个值key和5个指针。可以通过数据结构可视化网站 进行可视化。
B树
在插入前四个数的时候,都会放在第一个结点,构成0065 0100 0169 0368,在插入第五个数900的时候,按照排放规则应该是在368后面,但是一个结点最多有4个key,所以中间的数169会向上分裂,形成下面的结构。
在这里插入图片描述
而B+Tree就是在B数的基础上延伸得到,主要变化(1)所有的元素都会出现在叶子结点,非叶子结点只起到一个索引作用。(2)所有叶子结点之间会形成一个单向链表。
在这里插入图片描述
在MySQL中,对经典的B+树进行了优化,在原B+树的基础上,增加了一个指向相邻叶子结点的链表指针,就形成了带有顺序的指针B+Tree,提高区间访问的性能。
在这里插入图片描述

(2)hash索引

hash索引是Memory存储引擎上实现的一种索引数据结构,主要是依照hash函数将键值换算成一个新的hash值,映射到对应的槽位,然后存储在hash表中。这样做的好处就是通常一次映射就可以找到对应的值,大大节约了搜索时间,但是有时候不同键值可能映射到同一个hash值上,这叫做哈希冲突或哈希碰撞,解决的方法就是在其后面构成一个单向链表,找到其hash值后进行判断是否和键值要求数据一致,不一致则按链表向后遍历查找。
在这里插入图片描述
hash索引的特点如下:
(1)hash索引只支持对等比较,不支持范围查询
(2)无法利用索引完成排序操作
(3)查询效率高,通常查询一次即可(前提是不出现哈希碰撞),效率通常比B+TREE高

索引分类

按照索引的类型进行分类。可以分为以下四种

分类含义特点关键字
主键索引针对于表中主键创建的索引默认自动创建,只能有一个PRIMARY
唯一索引避免同一个表中某数据列中数据重复可以有多个UNIQUE
常规索引快速定位特定数据可以有多个
全文索引全文索引超找的是文本中的关键字,而不是比较索引中的值可以有多个FULLTEXT

在InnoDB存储引擎中,根据索引的存储形式,又可以分为以下两类

分类含义特点
聚集索引将数据存储和索引放到一块,索引B+Tree的叶子结点保存了整个行数据必须有,只能有一个
二级索引将数据和索引分开存储,索引B+Tree的叶子结点关联的是对应的主键可以存在多个

聚集索引一定存在,其选取规则如下:
(1)如果存在主键,则主键索引就是聚集索引
(2)如果不存在主键,则使用第一个唯一(UNIQUE)索引作为聚集索引
(3)如果不存在主键和唯一索引,则InnoDB会自动的生成一个rowid作为隐藏的聚集索引
聚集索引的叶子结点上存储的是整行数据,而二级索引的B+树的叶子结点上是对应的主键值。如果先从二级索引出发找到主键值,再根据主键值去聚集索引内查找行数,这个过程称为回表查询)
例如:对用户表user中的name设为二级索引,主键id为聚集索引,执行下列语句

select * from user where name='Arm';

查找流程如下:
回表查询

索引语法

(1)查看索引
show index from 表名
例:查看tb_user表中索引信息

show index from tb_user;

在这里插入图片描述

(2)创建索引
create [unique|fulltext] index 索引名 on 表名(字段名称)
对多个字段创建同一个索引,该索引又称为联合索引

例:在tb_user表中为姓名name和电话phone分别创建索引

create index idx_user_name on tb_user(name);
create index idx_user_phone on tb_user(phone);

在这里插入图片描述
(3)删除索引
drop index 索引名 on 表名
例:删除tb_user表中的idx_user_phone索引

drop index idx_user_phone on tb_user;

在这里插入图片描述

SQL性能分析指标

(1)SQL执行频率

作用:查看当前数据各指令的使用频次。例如:insert、update、delete、select的访问频次。
语法:show [global|session] status like 'Com_______';
在这里插入图片描述
不难发现,查找的次数远大于其他次数。

(2)慢查询日志

作用:慢查询日志内记录了所有执行时间超过指定参数(long_query_time,单位:秒,默认10秒)的SQL语句。MySQL的慢查询日志默认开启
语法:(a)查询慢查询日志的开关show variables like 'slow_query_log;
在这里插入图片描述
(b)开启MySQL慢日志查询开关 set global slow_query_log =1;;
©查询或设置慢查询日志的参数,查询参数(默认为10秒):select @@long_query_time;
在这里插入图片描述
设置慢查询响应时间: set long_query_time=n

set long_query_time=2;
select @@long_query_time;

在这里插入图片描述

(3)profile详情

作用:该语句可以看到每一条SQL语句的耗时详情,通过have_profiling参数可以查看当前MySQL是否支持profile操作,但默认profile是关闭的,通过set profiling=1开启。

(4)explain或desc执行计划

作用:explain或者desc命令获取MySQL如何执行SELECT语句的信息,包括在SELECT语句执行过程中表如何连接和连接的顺序
语法:explain select 字段列表 from 表名 [where 条件]
例:查看查询id=5的用户语句的执行计划

-- 方式一
explain select * from tb_user where id=5;
-- 方式二
desc select * from tb_user where id=5;

在这里插入图片描述

下面介绍上面常用字段的信息:
id:select查询的序列号,表中查询中执行select子句或者是操作表的顺序(id相同,从上往下顺序执行,id不同,值越大,越先执行)id并不是自增的.
select_type:查询的类型
table:表示使用到哪些表
type:性能由高到低依次为:NULL、system、const、eq_ref、ref、range、index、all
(a)NULL:表示不访问任何表

explain select 'A';

在这里插入图片描述

(b)system:表示访问系统表
( c)const表示如果使用主键或者唯一索引进行查询.

explain select * from tb_user where id=1;

在这里插入图片描述
(d)ref:表示使用普通的索引进行查询
possible_key:可能用到的索引
key:实际用到的索引,没有则为null
key_lens:索引中使用的字节数
rows:MySQL认为要查询的行数,在innoDB中是一个估计值
filtered:表示返回结果行数占读取函数的百分比,越大越好
extre:额外信息
以上就是常用的四个SQL性能分析工具,在后续优化SQL时会经常用到

索引使用

引起索引的失效行为

在使用索引之前,我们需要了解索引什么时候可能会失效,下面一一介绍,在这里我们需要做前述准备

-- 表:tb_user
-- 1.给profession,age,status三个字段创立联合索引
create index idx_pro_age_sta on tb_user(profession,age,status);
-- 2.查看当前表中索引结果
show index from tb_user;

在这里插入图片描述
(1)最左前缀法则:在联合索引中,从索引的最左列开始,不能跳过中间列,如果跳过,则其后面的索引失效。
例:使用正确的最左前缀,查看执行计划

explain select tb_user.phone from tb_user where profession='计算机' and age=21 and status='1';

在这里插入图片描述
此时索引长度为50,再试一下跳过age和status字段

explain select tb_user.phone from tb_user where profession='计算机';

在这里插入图片描述
说明profession索引长度为43。接下来试一下使用前两个字段

explain select tb_user.phone from tb_user where profession='计算机' and age=21;

在这里插入图片描述

说明age索引长度为2,而status字段索引长度为50-43-2=5
下面测试一下失效的情况,跳过中间的age字段

explain select tb_user.phone from tb_user where profession='计算机' and status='1';

在这里插入图片描述
可以发现,索引长度为43,说明只用了profession索引,而后面的status索引失效。这就是最左前缀法则。
(2)范围查询:当使用联合索引的时候,如果使用范围查询(>和<,不包括>=和<=),后面的索引会失效
例:

explain select tb_user.phone from tb_user where profession='计算机' and age>21 and status='1';

在这里插入图片描述
索引长度为45,说明只用了前面两个索引,而status失效,解决方案就是尽量使用>=和<=,例如对于上述语句,年龄只能是整数,可以修改为如下SQL语句:
例:

explain select tb_user.phone from tb_user where profession='计算机' and age>=22 and status='1';

在这里插入图片描述
此时索引长度为50,说明三个索引全部使用。
(3)索引列运算:如果对一个索引进行函数运算,那么索引也会失效。
例:

explain select * from tb_user where substring(name,1,1)='张';

在这里插入图片描述
(4)字符串不加引号:如果对一个char或者varchar变量进行匹配时,不加引号,那么该索引也会失效。
例:在phone上创建索引,并进行测试

create index idx_user_phone on tb_user(phone);
explain select name from tb_user where phone=13800138024;

在这里插入图片描述
(5)模糊匹配:如果仅仅是尾部进行模糊匹配,则索引不会失效,如果是头部使用模糊匹配,则会失效。
例:尾部使用模糊匹配

explain select name from tb_user where name like '张%';

在这里插入图片描述
如果在头部使用模糊匹配,则索引会失效

explain select phone from tb_user where name like '%十';

在这里插入图片描述
注:select后跟的如果是单纯的模糊匹配的索引,会正常使用索引
(6)or连接:or连接的条件,需要两侧条件都要是索引,才会生效,如果一侧是索引,一侧不是索引,那么索引不会被用到。

explain select phone from tb_user where name='郑十一' or status='1';

在这里插入图片描述
(7)数据分布影响:MySQL会评估索引和全表扫描的速度,如果全表扫描速度比使用索引快,则不用索引。
例:数据中phone几乎都是138,此时使用全表扫描的速度比使用索引块,所以不使用索引

explain select * from tb_user where phone like '138%';

在这里插入图片描述
使用下列语句检索

explain select * from tb_user where phone like '1380013800%';

在这里插入图片描述
这是因为第一个语句会检索该表中大部分数据,所以全表扫描速度更快,而第二个语句缩小了检索范围,所以使用索引更快,这是MySQL综合评估后的选择结果。

SQL提示

如果一个字段同时设立了多个索引,在查询的时候可以加上一些提示告诉数据库使用哪个索引。
(1)use index:建议使用什么索引:select 字段列表 from 表名 use index(索引名) [where 条件];
(2)ignore index:忽略什么索引:select 字段列表 from 表名 ignore index(索引名) [where 条件];
(3)force index:强制使用什么索引:select 字段列表 from 表名 force index(索引名) [where 条件];
例如,在联合索引的基础上,再额外建立索引idx_user_pro,指向profession。根据profession查询

-- 在profession建立索引
create index idx_user_pro on tb_user(profession);
-- 根据profession查询,查看查询计划
explain select name from tb_user where profession='计算机';

在这里插入图片描述
实际使用的是联合索引,那么如果建议其使用单独的索引,可以使用下列语句

-- 方式一:建议使用单独索引(此情况可能用可能不用)
explain select name from tb_user use index(idx_user_pro)  where profession='计算机';
-- 方式二:忽略联合索引
explain select name from tb_user ignore index(idx_pro_age_sta)  where profession='计算机';
-- 方式三:强制使用单独索引(此情况一定会用)
explain select name from tb_user force index(idx_user_pro)  where profession='计算机';

在这里插入图片描述

覆盖索引

覆盖索引是指一个索引包含了查询所需的所有字段,使得查询可以完全通过索引获取数据而无需回表(不需要访问数据行本身)。其优点如下:减少磁盘I/O;提升查询性能;特别适合只查询少量列的频繁操作。

-- 假设有索引 idx_name_age (name, age)
SELECT name, age FROM users WHERE name = '张三';
-- 这是一个覆盖索引查询,因为索引已经包含name和age字段

前缀查询(解决长字符串的索引问题)

如果有一个长字符串需要作为索引,那这个时候可以截取其前一部分作为索引,依次达到节约空间的目的。
语法:create index 索引名 on 表名(字段名(n));
前缀长度n的选择至关重要,可以根据索引的选择性来决定,选择性是指不重复的所以只和数据表记录总数的比值,越接近1代表查询效率越高。

单列索引和联合索引

在业务场景中,如果存在多个查询条件,考虑针对特定字段建立联合索引,需要考虑多个字段之间的顺序影响。

索引设计的原则

(1)针对数据量较大,且查询比较频繁的表建立索引。
(2)针对于常作为查询条件(where)、排序(order by)、分组(group by)操作的字段建立索引。
(3)尽量选择区分度高的列作为索引,尽量建立唯一索引,区分度越高,使用索引的效率越高。
(4)如果是字符串类型的字段,字段长度较长的情况下可考虑使用前缀索引
(5)尽量使用联合索引,减少单列索引,可以有效避免回表查询
(6)要控制索引的数量,索引并不是越多越好,无效索引会增加插入的成本以及浪费空间
(7)如果索引列不能存储NULL值,请在创建表的时候使用NOT NULL 进行约束

视图(View)篇

概念和语法

概念:视图是一种虚拟存在的表。视图中的数据并不在数据库中实际存在。通俗的将,视图只保存了查询SQL的逻辑,不保存查询结果。
语法:(1)创建视图

create [or replace] view 视图名称 [(列表名称)]
as select 语句
[with [cascaded|local] check option]
-- or replace在创建视图的时候可以不加,但是在修改更新的时候需要加
-- 后面的检查选项在下方给出具体解释

eg:使用视图检索出student表中id<10的用户的id和name

create or replace view stu_v_1 
as select id,name from student where id<=10;

在这里插入图片描述

(2)查看创建视图的语句

show create view 视图名称;

创建视图语句
(3)查看视图
和查表一样,使用select语句即可

select * from 视图名称...;

说明:视图也可以像表一样进行左右连接等操作,但是性能可能会降低。
(4)修改视图

-- 方法一,使用前面的create,此处必须要加or replace
create or replace view 视图名称[(列名名称)] 
as select语句
[with[cascaded|local] check option]
-- 方法二
alter view 视图名称[(列表名称)] 
as select 语句 
[with [local|cascaded] check option]

例:对上面视图进行修改,加入student表的no字段

-- 修改视图-- 方法一
create or replace view stu_v_1 
as select id,name,no from student where id<10;-- 方法二
alter view stu_v_1 
as select id,name,no from student where id<10;

在这里插入图片描述
(5)删除视图

drop view [if exists] 视图名称;

也可以使用insert向视图中插入数据,但是需要值得注意的是,插入的数据真实存放在视图的基表中,因为视图只是一个虚拟表。具体怎么样插入可以通过with cascaded|local check option控制。

视图检查选项check option

当在创建或更新的时候,加入with check option时,mysql会通过视图检查正在更改的每个行,例如插入、更新、删除,使其符合视图的定义。MySQL中如果基于一个视图创建另外一个视图的时候,它还会依赖视图中的规则保证一致性。MySQL提供了两个选项:cascaded和local,默认为cascaded。
(1)cascased:级联,如果视图v2是基于视图v1进行创建的,且v2加上了检查选项with cascaded check option,那么v2执行操作的时候,还会去检查是否满足v1中的where条件。
(2)local:只会检查数据是否满足当前视图的where条件。

------------------------------------------cascaded和local示例---------------------------------------------
例如:创建v1视图,不加检查选项,创建v2,增加cascaded检查选项。

create or replace view stu_v_1 
as select id,name from student where id<=20;
select * from stu_v_1;

在这里插入图片描述
现在向视图v1中插入数据

insert into stu_v_1 values (5,'TOM');
insert into stu_v_1 values (25,'Alice');

这两条语句都能插入成功,因为创建的时候没有加检查选项,所以无需判断其是否满足v1的where条件,即年龄是否不大于 20 20 20岁。
在这里插入图片描述
基于v1创建视图v2,在此基础上加上级联检查选项

create or replace view stu_v_2 
as select id,name from stu_v_1 where id>=10 
with cascaded check option ;

插入 i d = 7 id=7 id=7 i d = 15 id=15 id=15 27 27 27的数据

insert into stu_v_2 values (15,'peter'); -- 插入成功
insert into stu_v_2 values (7,'Jerry');  -- 插入失败
insert into stu_v_2 values (27,'Jerry');  -- 插入失败

只有 i d = 15 id=15 id=15的数据插入成功, i d = 7 id=7 id=7 i d = 27 id=27 id=27的数据插入都会报错:[HY000][1369] CHECK OPTION failed 'itcast.stu_v_2',这是因为v2的检查选项会级联到v1,使其插入的元素需要满足既要满足v2的不小于10还要满足v1的不大于20
接下来再基于v2创建视图v3,看级联是否会向下传递。

create or replace view stu_v_3 
as select id,name from stu_v_2 where id<=15;

此时向v3中插入数据,如果级联向下传递的话,那么id只能在10和15之间。

insert into stu_v_3 values (17,'张起灵');   -- 插入成功 
insert into stu_v_3 values (28,'王胖子');  -- 插入失败

在上述两条SQL插入语句中, i d = 17 id=17 id=17的数据成功插入,这是因为v3没有设置检查选项,插入的 17 17 17虽然不满足 i d < = 15 id<=15 id<=15但是其满足上面的 10 ≤ i d ≤ 20 10\leq id \leq 20 10id20
所以,通过上面的案例可以发现,cascaded级联会检查当前视图和之前分父视图的where条件,而不会向下级联
接下来,同上面分析一下local级联

create or replace view stu_v_4 
as select id,name from student where id<=15;
-- 基于v4创建v5,加local check option
create or replace view stu_v_5 
as select id,name from stu_v_4 where id>=10 
with local check option;

向v5中插入数据

insert into stu_v_5 values (11,'Jerry');   -- 成功
insert into stu_v_5 values (17, 'henry');  -- 成功

上面两条都会插入成功,因为都满足v5的检查条件,虽然也会去检查v4,但是v4没有设置检查条件,且v5的local check option 也不会向上传递。
local

视图的更新

要使得视图可以更新,视图中的行与基础表中的行必须是一对一的关系,如果视图包含以下任何一下,该视图不可更新:
(1)聚合函数或者窗口函数(SUM(),MIN(),MAX(),COUNT()等)
(2)DISTINCT
(3)group by
(4)union 或 union all
(5)having

视图的作用

(1)简便:简化用户对数据的理解,也可以简化他们的操作
(2)安全:数据库可以授权,但是授权的单位是表,不能授权到单独的行和列上,通过视图用户只能查询和修改他们所能见到的数据。
(3)数据独立:视图可帮助用户隔离基表改变的影响。

视图练习

1.为了保证数据库表的安全性,开发人员在操作tb_user表时,只能看到的用户的基本字段,屏蔽手机号和邮箱两个字段。

create or replace view tb_user_view as select id,name,phone,profession,age,gender,status,create_time from tb_user;
select * from tb_user_view;

在这里插入图片描述
2.查询每个学生选修的课程(三张表联查),定义一个视图。表:student、course、student_course

create or replace view stu_course_view as select s.*,c.name 'course_name' from student s,course c,student_course sc where (s.id=sc.student_id) and (c.id=sc.course_id);
select * from stu_course_view;

在这里插入图片描述

SQL优化篇

之前在索引章节学过了SQL的性能评估指标,有执行频次、慢查询日志以及profile详情和explain语句。这里主要使用explain语句,在执行的语句之前加上explain即可查看当前语句的执行情况。根据之前的执行频次可知,在SQL里面执行最多的是select查询语句,所以主要就是优化select。

1.插入优化

①如果插入小批量的数据,例如500-1000条,若使用insert语句执行,优先使用批量插入,而不是使用多条insert语句。
②优先主键顺序插入
原因:这是因为如果不是顺序插入,可能会导致B+树分裂,从而性能降低。且顺序插入也可以提高缓存的命中率

-- ❌ 低效方式(主键乱序插入)
INSERT INTO products VALUES (3, 'C');  -- 主键3
INSERT INTO products VALUES (1, 'A');  -- 主键1(导致B+树调整)
INSERT INTO products VALUES (2, 'B');  -- 主键2-- ✅ 高效方式(主键自增或按顺序插入)
INSERT INTO products VALUES (1, 'A');
INSERT INTO products VALUES (2, 'B');
INSERT INTO products VALUES (3, 'C');

③手动提交事务
在多条insert语句的时候,建议使用事务手动提交,这样一方面会减少日志的开销,另一方面也会减少锁之间的竞争

-- ❌ 低效方式(自动提交,每条都提交事务)
INSERT INTO orders VALUES (1, '2023-01-01', 100);
INSERT INTO orders VALUES (2, '2023-01-02', 200);
...-- ✅ 高效方式(显式事务,批量提交)
BEGIN TRANSACTION;  -- 或 START TRANSACTION(MySQL)
INSERT INTO orders VALUES (1, '2023-01-01', 100);
INSERT INTO orders VALUES (2, '2023-01-02', 200);
...
COMMIT;  -- 一次性提交

④对于大规模的数据,可以使用load进行加载。避免了一条一条的insert。
如果使用load指令,需要遵循以下步骤

-- 1.客户端连接时,加上--load-infile参数
mysql --locl-infile -u root -p
-- 2.设置全局参数local_infile为1
set global local_infile=1;
-- 3.执行load命令
load data local infile '文件' into table '表名' fields terminated by '分隔符' lines terminated by '\n';

2.主键优化

首先在这里需要明白页分裂和页合并的概念。
页分裂:在InnoDB存储引擎中,一个页(page)的大小默认为16KB,如果在用户乱序插入的时候,当插入的数据无法放入当前页​(如非顺序插入导致页已满),InnoDB会分裂页:
(a)将原页数据拆分为两半。
(b)分配新页,重新分配数据。
(c)更新父节点(B+树索引结构)。
这样会导致以下几个问题:
(a)​随机I/O增加:分裂时需要读写多个页,磁盘操作变慢。
(b)​空间碎片化:分裂后页利用率可能降低(如50%)。

例如,加入页已满,插入的主键是[1,3,5,7](设Max_degree=5,即最多有4个key值)
插入主键4,会形成[1,3],[4,5,7],导致页分裂

页合并:当页中数据被删除(或更新导致空间浪费),InnoDB会检查相邻页是否可以合并以提升空间利用率。
为了避免上述现象,对主键设计的时候需要遵循以下原则:
(1)主键尽量短且唯一,并设置AUTO_INCREMENT自增;
(2)避免使用UUID,虽然其具有唯一性,但是其也是无序的,且其占用空间较大,例如身份证;
(3)改动时尽量少对主键或者不对主键做改动;
(4)联合索引时,优先将区分度大的索引放前面。

3.group by 优化

①分组方面进行优化,主要是对经常查询的字段可以建立适当的索引,提升分组的效率
②对于where和group by的联合查询,优先建立联合索引,且索引操作的时候需要满足最左前缀法则。将where中的字段放在联合索引的前面进行条件过滤。

-- 假设常按 status 过滤后按 department 分组
ALTER TABLE employees ADD INDEX (status, department);SELECT department, COUNT(*) 
FROM employees 
WHERE status = 'active' 
GROUP BY department;  -- 命中索引

4.order by 优化

在MySQL中,排序主要分为两类,即using index和using filesort
①using index:通过有序索引顺序扫描直接返回有序数据,不需要额外排序,操作效率高
②using filesort:通过索引或者全表扫描找到满足条件的数据,然后放在排序缓冲区里进行排序,这种情况下排序效率缓慢
二者的区别是:filesort先找到数据,再排序,而index是直接排序,在优化排序的时候,优先优化成using index。
例:在tb_user表中,索引情况如下:在这里插入图片描述
问题1.为什么使用主键ORDER BY 时没有 Using index 和 Using filesort?
在使用主键索引的时候,使用explain查看执行计划的extra列,通常不会显示using index 或者using filesort。如下:

explain select id,name from tb_user order by id;

在这里插入图片描述
(a)为什么没有index?
这是因为主键索引就是聚集索引,而聚集索引在B+树下叶子结点上挂的是当行的数据,所以按照主键排序查找对应的字段,就会直接遍历叶子结点的数据进行查找,如果这是你在order by id后面设置降序排列,即order by id desc。这是Extra列显示的是Backward index scan,即反向索引扫描。using index一般是对于二级索引排序而言。
(b)为什么没有filesort?
聚簇索引的叶子节点天然按照主键排序,因此order by主键 时:
MySQL只需 ​顺序读取聚簇索引的叶子节点,无需额外排序。不会触发 using filesort(filesort是用于非索引列排序的临时操作)
(c)特殊情况:使用聚簇索引(主键索引)会显示using index。

explain select id from tb_user order by id;

在这里插入图片描述
问题2.什么时候会触发using index?
如果使用覆盖索引,查询列包含在索引列内,可以使用using index?

-- 联合索引(profession,age,status)
explain select profession,age from tb_user order by profession;

在这里插入图片描述
问题3.如何优化order by?
①对于经常排序的字段,可以优先考虑建立索引,多余多个字段,优先考虑建立联合索引。
②查询是优先使用覆盖索引,这样可以避免进行回表。

5.count计数函数优化

在聚合函数中,count()函数经常用来统计符合条件的量,而count()函数内通常有以下几种填法:
①count(*):统计的就是表中所有的记录总数,但是InnoDB存储引擎做了优化,不会把所有的行全部取出来。
②count(字段):统计字段值不为空的总数,InnoDB会把每一行记录都取出来进行判断是否为NULL。
③count(1):每一条记录赋值1但是不取值,在服务层累加
④count(主键):统计的也是表中所有的记录总数,但会把主键值取出来
综上,在我们使用的时候,优先使用count(*)或者count(1)

6.update优化

在更新数据的时候,优先根据索引来更新数据,因为InnoDB默认是行级锁,而行级锁的锁是加在索引上的。如果在更新的时候,没有按照索引来筛选数据,那么就会从开始到结束根据条件进行筛选,此时行级锁升级成为表锁,导致速度慢。

存储过程

MySQL中存储过程类似与其他编程语言中的函数,在存储过程中,可以定义一系列的SQL语句,当执行这个存储过程时,MySQL会顺序执行这个存储过程中的SQL语句。在其他编程语言函数中,会有全局变量、局部变量类似的概念,MySQL中也有,具体介绍如下:

变量

系统变量

在MySQL中,系统变量是MySQL服务器提供,不是用户定义的,属于服务器层面。分为全局变量(global)和会话变量(session) 会话变量表示当前会话(控制台)有效。
对于系统变量,提供以下两类方法:
(1)查看系统变量
(a)查看所有系统变量show [session|global] variables;在这里插入图片描述
(b)通过模糊查询查找相应字段的变量show variables like '匹配语句'
例如:查看和事务提交相关的变量

show variables like '%commit%';

在这里插入图片描述
(c)查看具体变量的值select @@变量名
在这里插入图片描述
(2)设置系统变量的值
(a)set [session|global]系统变量名=值;
(b)set @@[session|global] 系统变量名=值;

set @@autocommit =0;

在这里插入图片描述

用户自定义变量

用户自定义变量是指用户根据需要自己定义的变量,用户变量不需要提前声明,在用的时候直接用"@变量名"使用即可,其作用域为当前连接。
(1)赋值操作

-- 方式一
set @变量名=;
-- 方式二
set @变量名 :=;
-- 方式三
select @变量名=;
-- 方式四
select @变量名 :=;
-- 方式五
select 字段名 into @变量名 from 表名;

(2)使用变量select @变量名;

局部变量

在存储函数或存储过程内部,有时候可能会需要一些值作为中间变量、临时变量等,这个时候就可以使用局部变量,其作用范围为定义的存储过程内。访问之前,需要使用 d e c l a r e declare declare关键字进行声明。
(1)声明变量

declare  变量名 数据类型[default 默认值];
-- 类型就是数据库中表字段常用类型,如int,char,varchart等

(2)赋值

-- 方法一
set 变量名=;
-- 方法二
set 变量名 :=;
-- 方法三
select 字段名 into 字段名 from 表名;

存储过程定义

(1)存储过程定义的语法

create procedure 存储过程名([参数列表])
beginSQL语句1;SQL语句2;...
end

(2)调用存储过程

call 存储过程名;

例1:需要查看之前的student表中人数情况,使用存储过程,student表如下所示:
在这里插入图片描述
定义的存储过程如下:

create procedure p1()
begin
select count(*) from student;
end;

调用存储过程,使用call 存储过程即可

call p1;

p1存储过程显示
例2:使用临时变量存储学生人数,并输出该变量

create procedure p2()
begindeclare stu_count int default 0;  -- 声明局部变量select count(*) into stu_count from student;select concat('学生人数为',stu_count,'人') '人数';
end;
call p2;

结果如下:
在这里插入图片描述

存储逻辑

(1)if 存储逻辑

if 条件1 thenSQL语句;
elseif 条件2 thenSQL语句;
...
elseSQL语句;
end if;

例题:根据定义的分数score变量,判断当前分数对应的分数等级,如果score>=85,等级为优秀;如果score>=60,且score<85,等级为及格,否则为不及格。

drop procedure if exists  p3;
create procedure p3()
begindeclare score int default 0;declare grade char(10);set score :=84;if score>=85 thenset grade := '优秀';elseif score>=60 thenset grade := '及格';elseset grade := '不及格';end if;select concat('当前分数为:',score,'等级为:',grade) as '等级';
end;
call p3;

在这里插入图片描述
可以发现这个存储过程有一个弊端,就是score需要在存储过程内指定。而之前的存储过程定义语句中,有可选的参数列表,即在存储过程名称后面的括号内加上in|out|inout 变量名 类型,其中in表示是输入变量,out表示是输出变量,inout表示既是输入,又是输出。
使用该语句改写上面的程序,使得score在调用的时候由用户指定,如下:

-- 使用自定义输入score
drop procedure if exists p4;
create procedure p4(in score int)
begindeclare grade char(10);if score>=85 thenset grade := '优秀';elseif score>=60 thenset grade :='及格';elseset grade :='不及格';end if;select concat('分数:',score,',等级为:',grade) as '等级';
end;
call p4(88);

存储过程P4
而inout类型,通常使用在数据转换上,例如,输入分值是100分的试卷分数,转化成150分值的分数。

drop procedure if exists p5;
create procedure p5(inout score int)
beginset  score := score*1.5;
end;
-- 调用
set @score = 88;
call p5(@score);
select @score;

(2)case存储逻辑
case适合使用在多重判断,在某些场合下,case可以替代if。主要语法格式如下:

-- case格式一
case value
when value1 then statement_1[list];
when value2 thenstatement_2[list];
...
when valuen thenstatement_n[list];
end case;

这种逻辑下,主要判断case后面的字段value是否和下面when中的value匹配,匹配则执行对应的statement。不过主要使用下面这种方式:

case
when search_condition_1 then statement_1[list];
when search_condition_2 thenstatement2[list];
...
when search_condition_n thenstatement_n[list];
end case;

例题:创建存储过程:输入一个月份,返回属于第几个季度。1-3月份属于第一季度,4-6月份属于第二季度,7-9月份属于第三季度,10-12月份属于第四季度。

drop procedure if exists p6;
create procedure p6(in month int)
begindeclare season char(4);casewhen month between 1 and 3 thenset season :='第一季度';when month between 4 and 6 thenset season :='第二季度';when month between 7 and 9 thenset season :='第三季度';when month between 10 and 12 thenset season :='第四季度';elseset season:='不合法';end case;select concat('输入月份:',month,',结果判定为:',season,'!') as '季节判断';
end;
call p6(7);

在这里插入图片描述

(3)while循环逻辑
while循环是有条件的循环,当满足while条件时执行循环,其语法如下:

while 逻辑 DoSQL逻辑;
end while;

例如:创建存储过程,计算1到n的和,n为输入变量

-- while,计算1到n的和
drop procedure if exists p7;
create procedure p7(in n int)
begindeclare sum int default 0;declare raw int default n;while n>0 doset sum := sum+n;set n:= n-1;end while;select concat('1到',raw,'累加和为:',sum) as '累加';
end;
call p7(100);

在这里插入图片描述

(4)repeat循环逻辑
repeat循环是有条件的循环,当满足条件时退出循环。语法如下:

repeatSQL逻辑;until 退出逻辑;
end repeat;

例如:使用repeat实现1到n的求和。

-- repeat 实现1到n的累加和运算
drop procedure if exists p8;
create procedure p8(in n int,out sum int )
beginset sum :=0;repeatset sum:=sum+n;set n:=n-1;until  n<=0end repeat;
end;
call p8(100,@sum);
select concat('求和结果为:',@sum ) as '求和';

在这里插入图片描述
(5)loop循环逻辑
实现简单的循环,如果不在SQL逻辑中增加退出循环的条件,可以用其实现简单的死循环。语法格式如下:

[begin_label:] loopSQL逻辑;
end loop [end_label]

loop可以配合一下两个语句使用:
leave label:结束当前循环体
iterate label:结束本次循环,提前进入下一次循环
其相当于其他编程语言中的break和continue。
例如:计算1到n的累加和。

drop procedure if exists p9;
create procedure p9(in n int)
begindeclare sum int default 0;add_num:loopset sum:=sum+n;set n:=n-1;if n<=0 thenleave add_num;end if;end loop;select concat('累加和为:',sum);
end;
call p9(100);

在这里插入图片描述
例题:计算1到n的偶数之和。

-- 计算1到n的偶数之和
drop procedure if exists p10;
create procedure p10(in n int)
begindeclare sum int default 0;add_even:loopif n<=0 thenleave add_even;elseif n%2=0 thenset sum :=sum+n;set n :=n-1;elseset n :=n-1;iterate add_even;end if;end loop;select concat('累加偶数和为:',sum);
end;
call p10(10);

在这里插入图片描述
综上,如果是条件判断,我们可以考虑使用if逻辑或者case逻辑,如果是循环语句,我们需要考虑退出条件,如果满足条件执行,则使用while循环,如果满足条件退出,则使用repeat循环,如果有选择的执行循环内的某些数据,可以考虑使用loop,如果后续都不满足条件,在loop内可以使用leave结束循环体,如果对于特定数据(例如奇偶数等条件)可以使用iterate跳过本次循环。

存储函数

存储函数是有返回值的存储过程,参数只能是in类型。语法如下:

create function 存储函数名称([参数列表])
returns type [Deterministic|No SQL|Reads SQL Data]
beginSQL逻辑;return ...;
end;
-- Deterministic:相同的输入总是产生相同的结果
-- No SQL:不包含SQL语句
-- Reads SQL Data:包含读取数据的语句,但不包含写入数据的语句。

在MySQL中创建函数时,需要使用DELIMITER命令临时更改分隔符,因为函数体内包含分号,使用DELIMITER定义分隔符。

DELIMITER //  -- 定义分隔符CREATE FUNCTION fun1(n INT) 
RETURNS INT 
DETERMINISTIC
BEGINDECLARE sum INT DEFAULT 0;WHILE n > 0 DOSET sum = sum + n;SET n = n - 1;END WHILE;RETURN sum;
END //DELIMITER ;SET @sum = fun1(100);
SELECT CONCAT('累加和为:', @sum) as '累加和';

在这里插入图片描述

游标

游标和指针类似,但是与之不同的是,游标是在数据库结果集上找对应数据的特定位置。并可以将数据进行返回。
游标常用操作如下:
1.声明游标:declare 游标名称 cursor for 查询语句;
例如在student表中建立声明游标,筛选年龄在20岁以下的学生信息,语句如下

declare cursor_student cursor forselect * from student where age<20;

2.打开游标:open 游标名称;
3.获取游标记录:fetch 游标名称 into 变量[,变量];
这里需要定义变量来接受游标的信息,需要注意,游标的声明需要在变量之后。
4.关闭游标:close 游标名称;
这里会有一个问题,当游标一直遍历寻找到最后一条数据的时候,这个时候下一个就没有数据了,游标应该怎么判断这个条件并进行关闭游标呢?
条件处理程序(Handler):可以用来定义在流程控制结构执行过程中遇到问题时相应的处理步骤。具体语法为:

declare hander_action handler for condition_value[,condition_value]...statement;
-- handler_action--continue:继续执行当前程序--exit:退出当前程序,在游标中,最后没有数据,选择退出程序
-- condition_value   退出条件-- SQLSTATE sqlstate_value:状态码,如游标遍历不到数据错误状态码为02000-- SQLWARNING:所有以01开头的SQLSTATE代码简写-- NOT FOUND:所有以02开头的SQLSTATE代码简写-- SQLEXCEPTION:所有没有被SQLWARNING和NOT FOUND捕获的SQLSTATE代码简写

例:根据游标和存储过程的知识,由用户输入一个年龄(存储过程的输入参数),在tb_user表中找到小于这个年龄的所有用户的姓名和年龄,存储在一个新表tb_user_pro中。

drop procedure if exists p11 ;
create procedure p11(in R_age int)
begin-- 定义变量接收姓名和年龄declare uname varchar(20) ;declare uage int;-- 定义游标,游标需要声明在变量之后declare cursor_age cursor forselect name,age from tb_user where age<R_age;-- 定义条件处理程序declare exit handler for NOT FOUND CLOSE cursor_age;  -- 碰到找不到数据就关闭游标-- 建表drop table if exists tb_user_pro;create table tb_user_pro(name varchar(10) comment '姓名',age int comment '年龄');-- 开启游标open cursor_age;while true dofetch cursor_age into uname,uage;  -- 将cursor扫描的每一行数据加载进uname,uageinsert into tb_user_pro(name, age) values (uname,uage);end while;close cursor_age;
end;

先查看tb_user表内容。
在这里插入图片描述
筛选年龄小于28岁的用户。

call p11(28);

在这里插入图片描述
这样写法过于复杂,如果想要把某表查询结果重新插入新表中,可以使用CTAS语句,语法如下:

Create Table as Select 字段名 from 表名 ;

实现上面的需求,简短写法可以写作

drop procedure if exists p12;
create procedure p12(in uage int)
begin create table tb_user_pro1 as select name,agefrom tb_userwhere age<uage;
end;
call p12(28)

在这里插入图片描述
结果一致。本章完结。

触发器

触发器是与表有关的数据库对象,指在insert/update/delete之前或之后,触发并执行触发器中定义的SQL语句集合。触发器这种特性可以协助应用在数据库段确保数据的完整性、日志记录、数据校验等工作。
使用别名OLD和NEW来引用触发器中发生变化的内容。现在触发器还只支持行级触发,不支持语法级触发。

触发器类型OLD和NEW
UPDATENEW表示更新之后的数据,OLD表示更新之前的数据
DELETEOLD表示将要删除或者已经删除的数据
INSERTNEW表示将要新增或已经新增的数据

触发器创建语法如下:

create trigger 触发器名称
before|after  insert|update|delete
on 表名 for each row  -- 行级触发器
beginSQL语句;
end;

查询触发器:show triggers;
删除触发器:drop triggers [if exists] 触发器名;
例题:创建一个触发器,记录对tb_user_pro表的增删改进行记录,并将修改的内容、修改的时间、修改的原数据保存到logs日志表中。

use itcast;
-- 建表
drop table if exists logs;
create table logs(Time varchar(20) comment '操作时间',type varchar(10) comment '操作类型',comment varchar(100) comment '内容'
)comment '日志表';
-- 定义删除触发器
drop trigger if exists tb_user_delete_trg;
create trigger tb_user_delete_trg
after delete on tb_user_pro
for each row
begininsert into logs(Time, type, comment) values (now(),'delete',concat('删除的数据内容如下:','name:',OLD.name,',age:',OLD.age));
end;
-- 定义插入触发器
drop trigger if exists tb_user_insert_trg;
create trigger tb_user_insert_trg
after insert on tb_user_pro
for each row
begininsert into logs(time, type, comment) values (now(),'insert',concat('插入的内容如下:','name:',NEW.name,',age:',NEW.age));
end;
-- 定义更新触发器
drop trigger if exists tb_user_update_trg;
create trigger tb_user_update_trg
after update on tb_user_pro
for each row
begininsert into logs(time, type, comment) values (now(),'update',concat('修改之前的内容如下:','name:',OLD.name,',age:',OLD.age,'。修改之后的内容如下:','name:',NEW.name,',age:',NEW.age));
end;-- 插入数据
insert into tb_user_pro(name, age) values ('Lily', 23);
-- 删除数据
delete from tb_user_pro where age=23;
-- 更新数据
update tb_user_pro set age =25 where name='Lily';

在这里插入图片描述

锁是计算机协调多个进程和线程并发访问某一资源的机制。在数据库中,除了传统的计算资源(CPU,I/O)等,数据也是重要的资源之一。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。从这个角度来说,锁对数据库而言尤其重要,也更加复杂。
按照锁的粒度,可以将锁分为全局锁、表级锁、行级锁。

全局锁

全局锁是指对整个数据库示例加锁,整个数据库只允许读,不允许写,未提交的事务写操作将被阻塞。其主要使用在对数据库进行备份时启动,为了保证数据之间的一致性。
1.加锁:flush tables with read lock;
2.释放锁:unlock tables;

表级锁

表级锁指对某张表进行加锁,可以分为表锁、元数据锁和意向锁

表锁

表锁可以分为表共享读锁和表独占写锁。如果在一个客户端对一个表加了表共享读锁,则所有客户端都只能读这个表的数据,所有写操作将被阻塞。而一个客户端如果对一个表加了表独占写锁,则加锁的客户端能对这个表进行读和写,而其余客户端都不可以读和写。
1.加表共享读锁 lock table 表名 read
在这里插入图片描述
2.加表独占写锁 lock table 表名 write;
在这里插入图片描述

元数据锁

元数据锁是系统执行时自动添加的,主要是锁住表的结构(列名、索引等),防止修改的时候对表的结果进行修改从而破坏了数据的一致性。

意向锁

InnoDB存储引擎在执行更新操作的时候,会对一个定位的行加上行级锁。试想这样一种情况:假设id是索引,当我们对id=3的数据进行更改时,数据库对id=3的数据加上行级锁,此时如果还需要加上一个表锁,那这个时候需要先去检查是否有行级锁,一般是从前往后找(按索引找),但是如果行级锁定位在特别后面,这样就比较耗时,因此加上一个意向锁。当进行修改的时候,加上行锁的同时加上意向锁,这样加表锁的时候就不用去挨个遍历,直接看意向锁的状态即可。当更新操作执行完成提交事务后,行锁和意向锁会自动释放。
意向锁分为意向共享锁(IS)和意向排它锁(IX)
1.意向共享锁:select ... lock in share mode
2.意向排它锁:执行update、insert、delete和select… for update时

意向共享锁意向排它锁
表共享读锁兼容不兼容
意向共享锁不兼容不兼容

行级锁

行级锁可以分为行锁,间隙锁和临键锁

行锁

行锁就是锁定单个记录的锁,防止其他事务对此行进行update和delete。

间隙锁

锁住索引记录间隙(不含该记录),确保索引记录间隙不变,防止其他事务在这个间隙进行insert,产生幻读。

临键锁

可以看做上面两个的合体,既锁住对应的行数据,还锁住对应的数据间隙。

相关文章:

  • Vue-Router中的三种路由历史模式详解
  • 第一章 项目总览
  • udp 传输实时性测量
  • 4.1.4 基于数据帧做SQL查询
  • RabbitMQ备份与恢复技术详解:策略、工具与最佳实践
  • Qt DateTimeEdit(时间⽇期的微调框)
  • Spring AI 1.0 GA深度解析与最佳实践
  • Spring Event(事件驱动机制)
  • NumPy 2.x 完全指南【二十一】元素重排操作
  • QT使用说明
  • Spring框架学习day3--Spring数据访问层管理(IOC)
  • [mcu]系统频率
  • 深入剖析 Docker 容器化原理与实战应用,开启技术新征程!
  • RuoYi前后端分离框架集成手机短信验证码(一)之后端篇
  • openfeignFeign 客户端禁用 SSL
  • 王树森推荐系统公开课 排序06:粗排模型
  • SAP销售订单批导创建
  • LVS +Keepalived高可用群集
  • 国芯思辰| 国产四通道24位生理电采集模拟前端AFE全面替换ADS1294R,心电贴性能再飞跃
  • 【博客系统】博客系统第十一弹:部署博客系统项目到 Linux 系统
  • wordpress 火车头/网站优化名词解释
  • 汽配公司的网站要怎么做/提升seo排名
  • 域名停靠app网站入口/企业查询软件
  • 成都建设公司网站/网站内容检测
  • 公关策划网站建设/北京seo营销培训
  • 网站建设模板购买/seo流量排名工具