SQL本身并不难学,写查询语句很容易,但是很难写出能够高效运行的查询语句。图片来自Pexels查询优化是一项复杂的工程,涉及到从硬件到参数配置、不同数据库的解析器、优化器实现、SQL语句的执行顺序、索引和统计信息的收集,甚至是应用程序和系统的整体架构。本文介绍了几个关键规则,可以帮助我们编写高效的SQL查询;特别是对于初学者来说,这些规则至少可以让我们避免编写性能不佳的查询语句。以下规则适用于各种关系数据库,包括但不限于:MySQL、Oracle、SQLServer、PostgreSQL和SQLite。规则一:只返回需要的结果。一定要为查询语句指定WHERE条件,过滤掉不需要的数据行。一般来说,OLTP系统一次只需要从大量数据中返回几条记录;指定查询条件可以帮助我们通过索引而不是全表扫描返回结果。在大多数情况下,使用索引时性能会更好,因为索引(B-树、B+树、B*树)执行二分查找并且具有对数时间复杂度而不是线性时间复杂度。下面是一个MySQL聚集索引的示意图:例如,假设每个索引分支节点可以存储100条记录,那么100万(1003)条记录只需要3层B树就可以完成索引。通过索引查找数据时,需要读取索引数据3次(每次磁盘IO读取整个分支节点),加上1次磁盘IO读取数据得到查询结果。相反,使用全表扫描,需要执行的磁盘IO数量可能会高出几个数量级。当数据量增加到亿条(1004)时,B树索引只需要再增加一次索引IO;而全表扫描需要增加几个数量级的IO。出于同样的原因,我们应该避免使用SELECT*FROM,因为它意味着查询表中的所有字段。这种写法通常会导致数据库读取更多的数据,网络也需要传输更多的数据,导致性能下降。规则2:确保查询使用正确的索引。如果缺少合适的索引,即使指定了查询条件,也不会通过索引查找数据。因此,我们首先需要确保创建了合适的索引。一般来说,需要对以下字段进行索引:对WHERE条件中经常出现的字段进行索引,可以避免全表扫描。在索引中加入ORDERBY排序的字段可以避免额外的排序操作。对多表连接查询的关联字段进行索引,可以提高连接查询的性能。在索引中加入GROUPBY分组操作字段,可以利用索引完成分组。即使创建了合适的索引,如果SQL语句写得不好,数据库也不会使用索引。导致索引失效的常见问题包括:对索引字段进行表达式操作或者在WHERE子句中使用函数都会导致索引失效。这种情况还包括不匹配的字段数据类型,例如字符串和整数比较。使用LIKE匹配时,如果通配符出现在左边,则不能使用索引。对于大文本数据的模糊匹配,应该考虑数据库提供的全文搜索功能,甚至可以使用专门的全文搜索引擎(Elasticsearch等)。如果在WHERE条件的字段上创建了索引,尽量设置为NOTNULL;不是所有的数据库在用IS[NOT]NULL判断的时候都可以使用索引。执行计划(也称为查询计划或解释计划)是数据库执行SQL语句的具体步骤,如通过索引或全表扫描访问表中的数据,连接查询的实现方式和连接顺序等。如果SQL语句的性能不理想,首先要查看它的执行计划,并使用执行计划(EXPLAIN)来确保查询使用了正确的索引。规则三:尽量避免使用子查询以MySQL为例,如下查询返回月薪大于部门平均月薪的员工信息:);->过滤器:(e.salary>(select#2))(cost=2.75rows=25)(actualtime=0.232..4.401rows=6loops=1)->Tablescanone(cost=2.75rows=25)(actualtime=0.099..0.190rows=25loops=1)->Select#2(subqueryincondition;dependent)->聚合:avg(employee.salary)(actualtime=0.147..0.149rows=1loops=25)->Indexlookuponemployeeusingidx_emp_dept(dept_id=e.dept_id)(cost=1.12rows=5)(actualtime=0.068..0.104rows=7loops=25)从执行计划可以看出MySQL采用了类似的NestedLoopJoin实现;子查询循环了25次,但实际上可以通过一次扫描计算并缓存每个部门的平均月薪。下面语句将子查询替换为等价的JOIN语句,实现子查询的扩展(SubqueryUnnest):EXPLAINANALYZESELECTe.emp_id,e.emp_nameFROMemployeeJOIN(SELECTdept_id,AVG(salary)ASdept_averageFROMemployeeGROUPBYdept_id)tONe.dept_id=t.dept_idWHEREe.salary>t.dept_average;->Nestedloopinnerjoin(actualtime=0.722..2.354rows=6loops=1)->Tablescanone(cost=2.75rows=25)(actualtime=0.096..0.205rows=25loops=1)->过滤器:(e.salary>t.dept_average)(actualtime=0.068..0.076rows=0loops=25)->Indexlookupontusing(dept_id=e.dept_id)(actualtime=0.011..0.015rows=1loops=25)->Materialize(actualtime=0.048..0.057rows=1loops=25)->Groupaggregate:avg(employee.salary)(actualtime=0.228..0.510rows=5loops=1)->Indexscanoneemployeeusingidx_emp_dept(cost=2.75rows=25)(actualtime=0.181。.0.348rows=25loops=1)重写的查询使用Materialization技术从子查询的结果生成临时内存表;然后将其连接到员工表。从实际执行时间上可以看出这种方法更快。上面的例子会在Oracle和SQLServer中自动执行子查询扩展,两种写法效果一样;类似于PostgreSQL中的MySQL,第一条语句使用NestedLoopJoin,改写为JOIN,然后用HashJoin实现,性能更好。同样,对于IN和EXISTS子查询也可以得出类似的结论。由于不同数据库优化器能力的差异,我们应该尽量避免使用子查询,考虑使用JOIN进行改写。规则四:不要使用OFFSET实现分页分页查询的原则是先跳过指定行数,然后返回Top-N记录。分页查询示意图如下:数据库一般支持FETCH/LIMIT和OFFSET实现Top-N排序和分页查询。当表的数据量很大时,这种方式的分页查询可能会造成性能问题。以MySQL为例:--MySQLSELECT*FROMlarge_tableORDERBYidLIMIT10OFFSETN;上面的查询会随着OFFSET的增加越来越慢;因为即使我们只需要返回10条记录,数据库仍然需要访问并过滤掉N(比如1000000)行记录,即使通过索引也会涉及不必要的扫描操作。对于上面的分页查询,更好的做法是记住上次获取的最大id,然后在下次查询时作为条件传入:--MySQLSELECT*FROMlarge_tableWHEREid>last_idORDERBYidLIMIT10;如果id字段上有索引,这种分页查询的方式基本可以不受数据量的影响。规则五:理解SQL子句的逻辑执行顺序下面是SQL中各个子句的语法顺序,前面括号中的数字代表它们的逻辑执行顺序:(6)SELECT[DISTINCT|ALL]col1,col2,agg_func(col3)ASalias(1)FROMt1JOINt2(2)ON(join_conditions)(3)WHEREwhere_conditions(4)GROUPBYcol1,col2(5)HAVINGhaving_condition(7)UNION[ALL]...(8)ORDERBYcol1ASC,col2DESC(9)OFFSETmROWSFETCHNEXTnum_rowsROWSONLY;也就是说,SQL不会按照写入的顺序先执行SELECT再执行FROM子句。从逻辑上讲,SQL语句的执行顺序是这样的:首先,FROM和JOIN是SQL语句执行的第一步。它们的逻辑结果是一个笛卡尔积,它决定了接下来要操作的数据集。注意,逻辑执行顺序不代表物理执行顺序。实际上,数据库在获取表中的数据之前,会使用ON和WHERE过滤条件来优化访问。其次,应用ON条件过滤上一步的结果并生成新的数据集。然后,再次执行WHERE子句,对上一步的数据集进行过滤。大多数情况下WHERE和ON的作用是一样的,只是外连接查询不同,下面我们举例说明。接下来,根据GROUPBY子句指定的表达式进行分组;同时对每个分组计算聚合函数agg_func的结果。经过GROUPBY处理后,数据集的结构发生了变化,只保留分组字段和聚合函数的结果。如果有GROUPBY子句,可以使用HAVING进一步过滤分组后的结果,通常是聚合函数的结果。接下来,SELECT可以指定要返回的列;如果指定了DISTINCT关键字,则需要对结果集进行去重。还为指定了AS的字段生成别名。如果存在集合运算符(UNION、INTERSECT、EXCEPT)等SELECT语句,执行查询并合并两个结果集。数据库通常可以支持在集合操作中并发执行多个SELECT语句。然后,应用ORDERBY子句对结果进行排序。如果存在GROUPBY子句或DISTINCT关键字,则只能使用分组字段和聚合函数进行排序;否则,可以使用FROM和JOIN表中的任何字段进行排序。最后,OFFSET和FETCH(LIMIT,TOP)限制最终返回的行数。了解SQL的逻辑执行顺序可以帮助我们优化SQL。比如WHERE子句先于HAVING子句执行,那么我们应该尽量使用WHERE进行数据过滤,避免不必要的操作;除非业务需要过滤聚合函数的结果。另外,理解SQL的逻辑执行顺序也可以帮助我们避免一些常见的错误,比如下面的语句:该语句的错误是WHERE条件中引用了列别名;从上面的逻辑顺序可以看出,执行WHERE条件的时候还没有执行SELECT子句,所以还没有生成字段的别名。另外一个需要注意的操作是GROUPBY,例如:--GROUPBYerrorexampleSELECTdept_id,emp_name,AVG(salary)FROMemployeeGROUPBYdept_id;经过GROUPBY处理后,结果集只保留了分组字段和聚合函数的结果,example中的emp_name字段已经不存在了。从业务逻辑上看,按部门分组统计后显示员工姓名是没有意义的。如果需要同时显示员工信息和部门汇总,可以使用窗口函数。如果使用GROUPBY,后面的SELECT、ORDERBY等只能引用分组字段或聚合函数;否则,可以引用FROM和JOIN表中的任何字段。还有一些逻辑问题可能不会直接导致查询出错,而是返回不正确的结果;例如外连接查询中的ON和WHERE条件。下面是一个左外连接查询的例子:SELECTe.emp_name,d.dept_nameFROMemployeeLEFTJOINdepartmentdON(e.dept_id=d.dept_id)WHEREe.emp_name='张飞';emp_name|dept_name|-------|---------|张飞|行政部|SELECTe.emp_name,d.dept_nameFROMemployeeLEFTJOINdepartmentdON(e.dept_id=d.dept_idANDe.emp_name='张飞');emp_name|dept_name|--------|--------|刘备|[NULL]|关羽|[NULL]|张飞|行政部|诸葛亮|[NULL]|...第一个查询在ON中指定clause指定连接条件,通过WHERE子句找到“张飞”的信息。第二个查询将所有过滤条件放在ON子句中,并返回所有员工信息。这是因为左外连接会返回左表中的所有数据,即使在ON子句中指定了员工姓名,也不会生效;而WHERE条件在逻辑上过滤连接操作后的结果。总结SQL优化本质上就是了解优化器的工作原理,并为此创建合适的索引和正确的语句;同时,当优化器不够智能时,手动使其智能化。作者:不剪头发的Tony老师编辑:陶家龙来源:http://002ii.cn/YknfGU