很多时候我们业务系统实现分页功能可能会用如下sql实现:
select * from employees limit 10000,10;
表示从表 employees 中取出从 10001 行开始的 10 行记录。看似只查询了 10 条记录,实际这条 SQL 是先读取 10010条记录,然后抛弃前 10000 条记录,然后读到后面 10 条想要的数据。因此要查询一张大表比较靠后的数据,执行效率是非常低的。这是典型的深度分页问题。
1、根据自增且连续的主键排序的分页查询
首先来看一个根据自增且连续主键排序的分页查询的例子:
select * from employees limit 90000,5;
该 SQL 表示查询从第 90001开始的五行数据,没添加单独 order by,表示通过主键排序。我们再看表 employees ,因为主键是自增并且连续的,所以可以改写成按照主键去查询从第 90001开始的五行数据,如下:
select * from employees where id > 90000 limit 5;
查询的结果是一致的。我们再对比一下执行计划:
EXPLAIN select * from employees limit 90000,5;
EXPLAIN select * from employees where id > 90000 limit 5;![img](https://www.www.weizhi.cc/i/l/?n=22&i=blog/2446184/202209/2446184-20220904101930692-1052727432.gif)
显然改写后的 SQL 走了索引,而且扫描的行数大大减少,执行效率更高。
但是,这条改写的SQL 在很多场景并不实用,因为表中可能某些记录被删后,主键空缺,导致结果不一致,如下图试验所示(先删除一条前面的记录,然后再测试原 SQL 和优化后的 SQL):
两条 SQL 的结果并不一样,因此,如果主键不连续,不能使用上面描述的优化方法。
另外如果原 SQL 是 order by 非主键的字段,按照上面说的方法改写会导致两条 SQL 的结果不一致。所以这种改写得满足以下两个条件:
2、根据非主键字段排序的分页查询
再看一个根据非主键字段排序的分页查询,SQL 如下:
select * from employees ORDER BY name limit 90000,5;
EXPLAIN select * from employees ORDER BY name limit 90000,5;
发现并没有使用 name 字段的索引(key 字段对应的值为 null),具体原因是:扫描整个索引并查找到没索引的行(可能要遍历多个索引树)的成本比扫描全表的成本更高,所以优化器放弃使用索引。
知道不走索引的原因,那么怎么优化呢?
其实关键是让排序时返回的字段尽可能少,即考虑使用覆盖索引进行优化,所以可以让排序和分页操作先查出主键,然后根据主键查到对应的记录,SQL改写如下:
select * from employees e inner join (select id from employees order by name limit 90000,5) ed on e.id = ed.id;
需要的结果与原 SQL 一致,执行时间减少了一半以上,此时查询和排序都是在覆盖索引树上进行的,所以效率较高。我们再对比优化前后sql的执行计划:
原 SQL 使用的是 file sort 排序,而优化后的 SQL 使用的是索引排序。
示例表:
‐‐ 示例表: CREATE TABLE `t1` ( `id` int(11) NOT NULL AUTO_INCREMENT, `a` int(11) DEFAULT NULL, `b` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_a` (`a`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; create table t2 like t1; ‐‐ 插入一些示例数据 ‐‐ 往t1表插入1万行记录 drop procedure if exists insert_t1; delimiter ;; create procedure insert_t1() begin declare i int; set i=1; while(i<=10000)do insert into t1(a,b) values(i,i); set i=i+1; end while; end;; delimiter ; call insert_t1(); ‐‐ 往t2表插入100行记录 drop procedure if exists insert_t2; delimiter ;; create procedure insert_t2() begin declare i int; set i=1; while(i<=100)do insert into t2(a,b) values(i,i); set i=i+1; end while; end;; delimiter ; call insert_t2();
mysql的表关联常见有两种算法
1、 嵌套循环连接 Nested-Loop Join(NLJ) 算法
一次一行循环地从第一张表(称为驱动表)中读取行,在这行数据中取到关联字段,根据关联字段在另一张表(被驱动表)里取出满足条件的行,然后取出两张表的结果合集。
EXPLAIN select * from t1 inner join t2 on t1.a= t2.a; -- // a字段有索引
从执行计划中可以看到这些信息:
inner join
时,排在前面的表并不一定就是驱动表。right join
时,右表是驱动表,左表是被驱动表,当使用join时,mysql会选择数据量比较小的表作为驱动表,大表作为被驱动表。Using join buffer
则表示使用的 join 算法是 NLJ。上面sql的大致流程如下:
整个过程会读取 t2 表的所有数据(扫描100行),然后遍历这每行数据中字段 a 的值,根据 t2 表中 a 的值索引扫描 t1 表中的对应行(扫描100次 t1 表的索引,1次扫描可以认为最终只扫描 t1 表一行完整数据,也就是总共 t1 表也扫描了100行)。因此整个过程扫描了 200 行。
2、 基于块的嵌套循环连接 Block Nested-Loop Join(BNL)算法
把驱动表的数据读入到 join_buffer
中,然后扫描被驱动表,把被驱动表每一行取出来跟 join_buffer
中的数据做对比。
EXPLAIN select * from t1 inner join t2 on t1.b= t2.b; -- // b字段没有索引
Extra 中 的Using join buffer (Block Nested Loop)
说明该关联查询使用的是 BNL 算法。
上面sql的大致流程如下:
join_buffer
中join_buffer
中的数据做对比整个过程对表 t1 和 t2 都做了一次全表扫描,因此扫描的总行数为10000(表 t1 的数据总量) + 100(表 t2 的数据总量) =10100。并且 join_buffer
里的数据是无序的,因此对表 t1 中的每一行,都要做 100 次判断,所以内存中的判断次数是100 * 10000= 100
万次。
这个例子里表 t2 才 100 行,要是表 t2 是一个大表,join_buffer 放不下怎么办呢?
join_buffer 的大小是由参数 join_buffer_size
设定的,默认值是 256k。如果放不下表 t2 的所有数据话,策略很简单,就是分段放。
比如 t2 表有1000行记录, join_buffer
一次只能放800行数据,那么执行过程就是先往 join_buffer
里放800行记录,然后从 t1 表里取数据跟 join_buffer
中数据对比得到部分结果,然后清空 join_buffer
,再放入 t2 表剩余200行记录,再次从 t1 表里取数据跟 join_buffer
中数据对比。所以就多扫了一次 t1 表。
被驱动表的关联字段没索引为什么要选择使用 BNL 算法而不使用 Nested-Loop Join
呢?
如果上面第二条sql使用 Nested-Loop Join
,那么扫描行数为 100 * 10000 = 100
万次,这个是磁盘扫描。
很显然,用BNL磁盘扫描次数少很多,相比于磁盘扫描,BNL的内存计算会快得多。因此MySQL对于被驱动表的关联字段没索引的关联查询,一般都会使用 BNL 算法。如果有索引一般选择 NLJ 算法,有索引的情况下 NLJ 算法比 BNL算法性能更高.
straight_join
写法固定连接驱动方式,省去mysql优化器自己判断的时间straight_join
解释:straight_join
功能同join类似,但能让左边的表来驱动右边的表,能改表优化器对于联表查询的执行顺序。
比如:select * from t2 straight_join t1 on t2.a = t1.a;
代表指定mysql选着 t2 表作为驱动表。
straight_join
只适用于inner join
,并不适用于left join
,right join。(因为left join
,right join
已经代表指定了表的执行顺序)straight_join
一定要慎重,因为部分情况下人为指定的执行顺序并不一定会比优化引擎要靠谱。对于小表定义的明确:
在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。
原则:小表驱动大表,即小的数据集驱动大的数据集。
In是In后的表先执行(适用于B表小于A表):
select * from A where id in ( select id from B)
Exists是Exists前面的表先执行(适用于A表小于B表):
select * from A where id in ( select id from B)
in:当B表的数据集小于A表的数据集时,in优于exists
select * from A where id in (select id from B) // #等价于: for(select id from B){ select * from A where A.id = B.id }
exists:当A表的数据集小于B表的数据集时,exists优于in
将主查询A的数据,放到子查询B中做条件验证,根据验证结果(true或false)来决定主查询的数据是否保留.
select * from A where exists (select 1 from B where B.id = A.id) // # 等价于: for(select * from A){ select * from B where B.id = A.id } // # A表与B表的ID字段应建立索引
总结:
1、EXISTS (subquery)
只返回TRUE或FALSE,因此子查询中的SELECT *
也可以用SELECT 1替换,官方说法是实际执行时会忽略SELECT清单,因此没有区别
2、EXISTS
子查询的实际执行过程可能经过了优化而不是我们理解上的逐条对比
3、EXISTS
子查询往往也可以用JOIN来代替,何种最优需要具体问题具体分析
count(*)
查询优化-- 临时关闭mysql查询缓存,为了查看sql多次执行的真实时间 set global query_cache_size=0; set global query_cache_type=0; EXPLAIN select count(1) from employees; EXPLAIN select count(id) from employees; EXPLAIN select count(name) from employees; EXPLAIN select count(*) from employees; -- 注意:以上4条sql只有根据某个字段count不会统计字段
经过测试发现:四个sql的执行计划一样,说明这四个sql执行效率应该差不多
1、字段有索引: count(*)
≈count(1)
>count(字段)
>count(主键 id)
字段有索引,count(字段)
统计走二级索引,二级索引存储数据比主键索引少,所以count(字段)
>count(主键 id)
2、字段无索引: count(*)
≈count(1)
>count(主键 id)
>count(字段)
字段没有索引count(字段)
统计走不了索引,count(主键 id)还可以走主键索引,所以count(主键 id)
>count(字段)
count(1)
跟 count(字段)
执行过程类似,不过count(1)
不需要取出字段统计,就用常量1做统计,count(字段)
还需要取出字段,所以理论上count(1)
比count(字段)
会快一点。
count(*)
是例外,mysql并不会把全部字段取出来,而是专门做了优化(5.7版本),不取值,按行累加,效率很高,所以不需要用count(列名)
或count(常量)
来替代 count(*)
。
为什么对于count(id)
,mysql最终选择辅助索引而不是主键聚集索引?因为二级索引相对主键索引存储数据更少,检索性能应该更高,mysql内部做了点优化(应该是在5.7版本才优化)。
当表中数据量非常大的时候,count这种通过计算统计的都会很慢,所以需要一些优化手段。
1、查询mysql自己维护的总行数
对于myisam存储引擎的表做不带where条件的count查询性能是很高的,因为myisam存储引擎的表的总行数会被mysql存储在磁盘上,查询不需要计算.
对于innodb存储引擎的表mysql不会存储表的总记录行数(因为有MVCC机制,后面会讲),查询count需要实时计算.
2、show table status
如果只需要知道表总行数的估计值可以用如下sql查询,性能很高
3、将总数维护到Redis里
插入或删除表数据行的时候同时维护redis里的表总行数key的计数值(用incr或decr命令),但是这种方式可能不准,很难保证表操作和redis操作的事务一致性.
4、增加数据库计数表
插入或删除表数据行的时候同时维护计数表,让他们在同一个事务里操作
is_xxx
的方式命名,数据类型是unsigned tinyint
(1表示是,0表示否)。说明:任何字段如果为非负数,必须是unsigned。注意:POJO类中的任何布尔类型的变量,都不要加is前缀,所以,需要在设置从is_xxx
到Xxx的映射关系。数据库表示是与否的值,使用tinyint类型,坚持is_xxx
的命名方式是为了明确其取值含义与取值范围。正例:表达逻辑删除的字段名is_deleted
,1表示删除,0表示未删除。aliyun_admin
,rdc_config
,level3_name
反例:AliyunAdmin
,rdcConfig
,level_3_name
pk_
即primary key;uk_
即 unique key
;idx_
即index的简称。gmt_create
, gmt_modified
。说明:其中id必为主键,类型为bigint unsigned、单表时自增、步长为1。gmt_create
, gmt_modified
的类型均为datetime类型,前者现在时表示主动式创建,后者过去分词表示被动式更新。alipay_task
/ force_project
/ trade_config
distinct left
(列名, 索引长度))/count(*)
的区分度来确定。order by
的场景,请注意利用索引的有序性。order by
最后的字段是组合索引的一部分,并且放在索引组合顺序的最后,避免出现file_sort
的情况,影响查询性能。正例:where a=? and b=? order by c;
索引:a_b_c
反例:索引如果存在范围查询,那么索引有序性无法利用,如:WHERE a>10 ORDER BY b;
索引a_b
无法排序。using index
。SELECT a.* FROM 表1 a, (select id from 表1 where 条件 LIMIT 100000,20 ) b where a.id=b.id
type=index
,索引物理文件全扫描,速度非常慢,这个index级别比较range还低,与全表扫描是小巫见大巫。where a=? and b=?
,a列的几乎接近于唯一值,那么只需要单建idx_a索引即可。说明:存在非等号和等号混合判断条件时,在建索引时,请把等号条件的列前置。如:where c>? and d=?
那么即使c的区分度更高,也必须把d放在索引的最前列,即建立组合索引idx_d_c
。count(列名)
或count(常量)
来替代count()
,count()
是SQL92定义的标准统计行数的语法,跟数据库无关,跟NULL和非NULL无关。说明:count(*)
会统计值为NULL的行,而count(列名)不会统计此列为NULL值的行。count(distinct col)
计算该列除NULL之外的不重复行数,注意 count(distinct col1, col2)
如果其中一列全为NULL,那么即使另一列有不同的值,也返回为0。count(col)
的返回结果为0,但sum(col)
的返回结果为NULL,因此使用sum()
时需注意NPE问题。正例:可以使用如下方式来避免sum的NPE问题:SELECT IFNULL(SUM(column), 0) FROM table;
NULL<>NULL
的返回结果是NULL,而不是false。2) NULL=NULL
的返回结果是NULL,而不是true。3) NULL<>1
的返回结果是NULL,而不是true。反例:在SQL语句中,如果在null前换行,影响可读性。select * from table where column1 is null and column3 is not null;
而ISNULL(column)
是一个整体,简洁易懂。从性能数据上分析,ISNULL(column)
执行效率更快一些。student_id
是主键,那么成绩表中的student_id
则为外键。如果更新学生表中的student_id
,同时触发成绩表中的student_id
更新,即为级联更新。外键与级联更新适用于单机低并发,不适合分布式、高并发集群;级联更新是强阻塞,存在数据库更新风暴的风险;外键影响数据库的插入速度。select t1.name from table_first as t1 , table_second as t2 where t1.id=t2.id;
反例:在某业务中,由于多表关联查询语句没有加表的别名(或表名)的限制,正常运行两年后,最近在某个表中增加一个同名字段,在预发布环境做数据库变更后,线上查询语句出现出1052异常:Column ‘name’ in field list is ambiguous
。select t1.name from table_first as t1, table_second as t2 where t1.id=t2.id;
SELECT LENGTH(“轻松工作”);
返回为12 SELECT CHARACTER_LENGTH(“轻松工作”);
返回为4 如果需要存储表情,那么选择utf8mb4来进行存储,注意它与utf8编码的区别。TRUNCATE TABLE
比 DELETE 速度快,且使用的系统和事务日志资源少,但TRUNCATE无事务且不触发trigger,有可能造成事故,故不建议在开发代码中使用此语句。说明:TRUNCATE TABLE
在功能上与不带 WHERE 子句的 DELETE 语句相同。*
作为查询的字段列表,需要哪些字段必须明确写明。说明:1)增加查询分析器解析成本。2)增减字段容易与resultMap配置不一致。3)无用字段增加网络消耗,尤其是text类型的字段。is_
,要求在resultMap中进行字段与属性之间的映射。说明:参见定义POJO类以及数据库字段定义规定,在sql.xml
增加映射,是必须的。#{}
,#param#
不要使用${}
此种方式容易出现SQL注入。queryForList(String statementName,int start,int size)
不推荐使用。说明:其实现方式是在数据库取到statementName
对应的SQL语句的所有记录,再通过subList取start,size的子集合。正例:
Map<String, Object> map = new HashMap<>(); map.put(“start”, start); map.put(“size”, size);
gmt_modified
字段值为当前时间。update table set c1=value1,c2=value2,c3=value3;
这是不对的。执行SQL时,不要更新无改动的字段,一是易出错;二是效率低;三是增加binlog存储。@Transactional
事务不要滥用。事务会影响数据库的QPS,另外使用事务的地方需要考虑各方面的回滚方案,包括缓存回滚、搜索引擎回滚、消息补偿、统计修正等。在MySQL中,选择正确的数据类型,对于性能至关重要。一般应该遵循下面两步:
在MySQL数据类型设置方面,尽量用更小的数据类型,因为它们通常有更好的性能,花费更少的硬件资源。并且,尽量 把字段定义为NOT NULL,避免使用NULL.
1、数值类型
优化建议:
INT(10)
类似的方法指定字段显示宽度,直接用INT。AUTO_INCREMENT
。2、日期和时间
优化建议:
CURRENT_TIMESTAMP
作为默认(MySQL5.6以后),MySQL会自动返回记录插入的确切时间。YYYYMMDD HH:MM:SS
的整数,与时区无关,你存了什么,读出来就是什么。3、字符串
优化建议:
PS:INT显示宽度
我们经常会使用命令来创建数据表,而且同时会指定一个长度,如下。但是,这里的长度并非是TINYINT类型存储的最大长度,而是显示的最大长度。
CREATE TABLE `user`( `id` TINYINT(2) UNSIGNED );
这里表示user表的id字段的类型是TINYINT,可以存储的最大数值是255。所以,在存储数据时,如果存入值小于等于255,如200,虽然超过2位,但是没有超出TINYINT类型长度,所以可以正常保存;如果存入值大于255,如500,那么MySQL会自动保存为TINYINT类型的最大值255。
在查询数据时,不管查询结果为何值,都按实际输出。这里TINYINT(2)中2的作用就是,当需要在查询结果前填充0时,命令中加上ZEROFILL就可以实现,如:
`id` TINYINT(2) UNSIGNED ZEROFILL
这样,查询结果如果是5,那输出就是05。如果指定TINYINT(5)
,那输出就是00005,其实实际存储的值还是5,而且存 储的数据不会超过255,只是MySQL输出数据时在前面填充了0。
换句话说,在MySQL命令中,字段的类型长度TINYINT(2)
、INT(11)
不会影响数据的插入,只会在使用ZEROFILL时有 用,让查询结果前填充0。