SQL作为关系型数据库的标准语言,是IT从业者的必备技能之一。SQL本身并不难学,写查询语句很容易,但是写出能够高效运行的查询语句就难了。查询优化是一个复杂的工程,涉及到从硬件到参数配置、不同数据库的解析器、优化器实现、SQL语句的执行顺序、索引和统计信息的收集,甚至是应用程序和系统的整体架构。本文介绍了几个关键规则,可以帮助我们编写高效的SQL查询;特别是对于初学者来说,这些规则至少可以让我们避免编写性能不佳的查询语句。以下规则适用于各种关系数据库,包括但不限于:MySQL、Oracle、SQLServer、PostgreSQL和SQLite。1.为了只返回需要的结果,必须为查询语句指定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)来确保查询使用了正确的索引。3、尽量避免使用子查询以MySQL为例,以下查询返回月薪大于部门平均月薪的员工信息:EXPLAINANALYZESELECTemp_id,emp_nameFROMemployeeeWHEREsalary>(SELECTAVG(salary)FROMemployeeWHEREdept_id=e.dept_id);->过滤器:(e.salary>(select#2))(cost=2.75rows=25)(actualtime=0.232..4.401rows=6loops=1)->e上的表扫描(成本=2.75行=25)(实际时间=0.099..0.190行=25循环=1)->选择#2(条件中的子查询;从属)->聚合:avg(employee.salary)(实际时间=0.147..0.149行=1循环=25)->使用idx_emp_dept(dept_id=e.dept_id)对员工进行索引查找(成本=1.12行=5)(实际时间=0.068..0.104行=7循环=25)从执行计划可以看出,MySQL使用了类似的NestedLoopJoin实现;子查询循环了25次,但实际上通过一次扫描就可以计算出每个部门的平均月薪并缓存起来。下面这句将子查询替换为等价的JOIN语句,实现了子查询的展开(SubqueryUnnest):EXPLAINANALYZESELECTe.emp_id,e.emp_nameFROMemployeeeJOIN(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)->Filter:(e.salary>t.dept_average)(actualtime=0.068..0.076rows=0loops=25)->使用(dept_id=e.dept_id)在t上进行索引查找(实际时间=0.011..0.015行=1循环=25)->实现(实际时间=0.048..0.057行=1循环=25)->Groupaggregate:avg(employee.salary)(actualtime=0.228..0.510rows=5loops=1)->使用idx_emp_dept对员工进行索引扫描(cost=2.75rows=25)(actualtime=0.181..0.348行=25个循环=1)重写的查询使用Materialization技术,从子查询的结果中生成一个临时内存表;然后连接到员工表。从实际执行时间可以看出,这种方法比较快。上面的例子会在Oracle和SQLServer中自动进行子查询扩展,两种写法的效果是一样的;在PostgreSQL中,和MySQL类似,第一条语句使用NestedLoopJoin,改写为JOIN,然后使用HashJoin实现,性能更好。同样,对于IN和EXISTS子查询也可以得出类似的结论。由于不同数据库优化器能力的差异,我们应该尽量避免使用子查询,考虑使用JOIN进行改写。4、不要使用OFFSET来实现分页。分页查询的原理是先跳过指定行数,然后返回Top-N记录。分页查询示意图如下:分页查询数据库一般支持FETCH/LIMIT和OFFSET实现Top-N排行榜和分页查询。当表的数据量很大时,这种方式的分页查询可能会造成性能问题。以MySQL为例:--MySQLSELECT*FROMlarge_tableORDERBYidLIMIT10OFFSETN;随着OFFSET的增加,上面的查询会越来越慢;因为即使我们只需要返回10条记录,数据库仍然需要访问和过滤删除N(例如1,000,000)行记录,即使通过索引也会涉及不必要的扫描操作。对于上面的分页查询,更好的办法是记住上次获取的最大id,然后在下一次查询时作为条件传入:--MySQLSELECT*FROMlarge_tableWHEREid>last_idORDERBYidLIMIT10;如果id字段上有索引,这种分页查询方式基本不受数据量的影响。5、理解SQL子句的逻辑执行顺序下面是SQL中各个子句的语法顺序,前面括号中的数字代表它们的逻辑执行顺序:(6)SELECT[DISTINCT|ALL]col1,col2,agg_func(col3)AS别名(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语句,则执行查询并将两个结果集合并。对于一个set操作中的多个SELECT语句,数据库通常可以支持并发执行;然后,应用ORDERBY子句对结果进行排序。如果有GROUPBY子句或DISTINCT关键字,则只能使用分组字段和聚合函数进行排序;否则,可以使用FROM和JOIN表中的任意字段进行排序;最后,OFFSET和FETCH(LIMIT,TOP)限制了最终返回的行数。了解SQL的逻辑执行顺序可以帮助我们优化SQL。比如WHERE子句先于HAVING子句执行,那么我们应该尽量使用WHERE进行数据过滤,避免不必要的操作;除非业务需要过滤聚合函数的结果。另外,理解SQL的逻辑执行顺序也可以帮助我们避免一些常见的错误,比如下面的语句:--错误示例SELECTemp_nameASempnameFROMemployeeWHEREempname='ZhangFei';该语句的错误在于WHERE条件中引用了列别名;从上面的逻辑顺序可以看出,执行WHERE条件的时候还没有执行SELECT子句,也没有生成字段的别名。另外一个需要注意的操作是GROUPBY,例如:--GROUPBY错误示例SELECTdept_id,emp_name,AVG(salary)FROMemployeeGROUPBYdept_id;经过GROUPBY处理后,结果集只保留分组字段和聚合函数,导致示例中的emp_name字段不存在;从业务逻辑的角度来看,按部门分组统计后显示员工姓名是没有意义的。如果需要同时显示员工信息和部门汇总,可以使用窗口函数。如果使用GROUPBY,后面的SELECT、ORDERBY等只能引用分组字段或聚合函数;否则,可以引用FROM和JOIN表中的任何字段。还有一些逻辑问题可能不会直接导致查询失败,而是返回不正确的结果;例如外连接查询中的ON和WHERE条件。下面是一个左外连接查询的例子:SELECTe.emp_name,d.dept_nameFROMemployeeeLEFTJOINdepartmentdON(e.dept_id=d.dept_id)WHEREe.emp_name='张飞';emp_name|dept_name|--------|--------|张飞|行政部|SELECTe.emp_name,d.dept_nameFROMemployeeeLEFTJOINdepartmentdON(e.dept_id=d.dept_idANDe.emp_name='张飞');emp_name|dept_name|--------|----------|刘备|[NULL]|关羽|[NULL]|张飞|行政部|诸葛亮|[NULL]|...第一个查询在ON子句中指定连接条件,同时通过WHERE子句查出“张飞”的信息。第二个查询将所有过滤条件放在ON子句中,并返回所有员工信息。这是因为左外连接会返回左表中的所有数据,即使在ON子句中指定了员工姓名,也不会生效;而WHERE条件在逻辑上过滤连接操作后的结果。总结SQL优化本质上就是了解优化器的工作原理,并为此创建合适的索引和正确的语句;同时,当优化器不够智能时,手动使其智能化。