资讯动态

DQL详解:从SELECT基础到慢SQL优化与注入防御

发布时间:2026/10/2 3:41:08 来源:尧图企业网站定制
最近有个做数据开发的同事问我DQL和平时常写的SELECT语句到底有什么区别为什么文档里总是单独拎出来讲。其实这个问题问得挺有代表性的——很多刚入门的人把SQL当成查数据的一种工具但对DQL在整个SQL语言里的定位、它和DML的边界、以及查询语句的执行顺序并不清楚。等真到处理复杂业务报表、排查慢查询、或者被 SQL注入 问题找上门时才发现当初对这条最简单的查询语句的理解浅了。这篇内容我打算从DQL的角度把最常见的查询写法、背后的执行逻辑、以及平时容易踩的坑一次性说透。覆盖 SELECT基础 、 聚合分组 、 多表连接 、 窗口函数 、 慢SQL优化 和 注入防御 这几个方向适合刚入门SQL的初学者也适合写了不少查询但没系统梳理过执行原理的开发。文里所有示例都以标准SQL写法为主部分特性在不同数据库里语法略有差异我会在对应位置单独说明。1. DQL到底在SQL里扮演什么角色先搞清楚身份再谈写法1.1 四类SQL指令的分工SQL按功能通常被划分成四类指令DDL数据定义语言、DML数据操作语言、DCL数据控制语言和DQL数据查询语言。如果你去翻MySQL、PostgreSQL、SQL Server的官方手册会发现DML底下通常会捎带提一句SELECT但严格从语义上拆分查询这件事专门归DQL管。DQL的全称是Data Query Language核心任务只有一个从表或视图中按条件取出数据并且不改变数据本身。这一点和DML是有本质区别的——DML涉及INSERT、UPDATE、DELETE这些写操作会影响库里的数据状态DQL只做读操作不产生数据修改。理解这个边界很重要因为后续谈事务隔离级别、锁竞争、主从分离架构时判断一个语句是走主库还是走从库、需要加什么锁第一件事就是分辨它是不是DQL。常见的一个误区是认为只要能查出数据都是DQL。实际上像SELECT ... FOR UPDATE这种写法虽然看起来是查询但它会对命中的行加排他锁属于事务性读取在业务上要考虑锁的副作用。另外有些分析型数据库的SELECT语句还可能触发内部缓存刷新或物化视图重算也带写属性。所以判断标准不能只看关键字还要看语句的实际行为。1.2 逻辑执行顺序语法顺序和真实顺序不是一回事大多数人对SELECT的直观印象是先写SELECT再写FROM最后加个WHERE。但数据库引擎解析和执行时走的是一套完全不同的顺序。这个顺序叫 logical query processing order虽然没有一个数据库会完全按逻辑顺序执行但理解它仍然是分析查询结果的唯一正确框架。标准SQL的逻辑执行顺序是FROM确定数据源WHERE对源数据做逐行过滤GROUP BY按指定列分组HAVING对分组后的结果做过滤SELECT计算表达式、别名ORDER BY排序LIMIT/OFFSET截取举个例子一条语句写成这样SELECT department_id, COUNT(*) AS emp_cnt FROM employees WHERE hire_date 2020-01-01 GROUP BY department_id HAVING COUNT(*) 5 ORDER BY emp_cnt DESC;你可能会觉得SELECT在最前面所以先执行。但真实逻辑里FROM先锁定employees表然后WHERE把2020年前入职的人过滤掉接着GROUP BY按部门分组并计算每组的COUNTHAVING再把人数小于等于5的部门剔除最后才轮到SELECT投影出department_id和emp_cnt两列然后排序。这个顺序直接解释了为什么WHERE里不能用聚合函数、不能用SELECT里的别名但HAVING和ORDER BY却可以。原因很简单WHERE执行时分组还没发生聚合函数自然无从谈起SELECT的别名要等SELECT阶段才有WHERE当然看不到。而ORDER BY是最后执行的它能用SELECT阶段的别名甚至能用SELECT里没投影出来的列部分数据库允许。这一块我建议每一位想进阶的开发者都自己动手跑几条带不同过滤条件的语句观察报错信息会记得非常牢。2. WHERE与SELECT的细节陷阱NULL、隐式转换和条件顺序2.1 NULL的三值逻辑是新手最容易翻车的地方SQL里的逻辑判断不是简单的true/false而是true/false/unknown三态NULL参与比较时结果往往是unknown。很多坑都从这个特性里长出来。比如你要查佣金为空的客户第一反应可能是SELECT * FROM customers WHERE commission NULL;这条语句永远查不到任何行。因为NULL NULL的结果不是true而是unknownWHERE只保留结果为true的行。正确写法必须是SELECT * FROM customers WHERE commission IS NULL;反过来查佣金不为空很多人会写WHERE commission ! 100想着把不等于100的挑出来。但这条会把佣金为NULL的行一并过滤掉因为NULL ! 100的结果同样是unknown。如果业务上NULL代表未发放佣金那这个查询结果就完全偏离预期了。正确做法是显式处理SELECT * FROM customers WHERE commission ! 100 OR commission IS NULL;另一个容易踩的是NOT IN的NULL问题。假设有个子查询返回一组ID其中包含NULLSELECT * FROM orders WHERE customer_id NOT IN (SELECT id FROM blocked_customers);只要blocked_customers里有一行的id是NULL整个查询就不会返回任何行。原因是customer_id NOT IN (1, 2, NULL)等价于customer_id ! 1 AND customer_id ! 2 AND customer_id ! NULL而customer_id ! NULL永远是unknown整个AND结果最多只能是unknown不可能为true。遇到这种场景要么在子查询里过滤掉NULL要么改写成NOT EXISTS。我个人的经验是涉及NOT IN的关联子查询默认直接用NOT EXISTS少踩很多坑。2.2 隐式类型转换看似能跑性能与结果都埋雷数据库在比较不同类型的值时会做隐式类型转换。拿MySQL举例如果列是字符串类型你在WHERE里写数字MySQL会尝试把列值转成数字再比这一般能查出结果。但有个副作用是索引可能失效——对列做了函数或类型转换优化器没法直接走索引扫描。反过来也有风险。查询参数是字符串、列是数值时某些数据库会尝试把字符串转成数值如果字符串以数字开头会截断例如100abc会被转成100。如果业务原本要查一个状态码客户端传了带前缀的编码极容易查出脏数据。最稳妥的做法是让参数类型和列类型保持一致让数据库原样比较。如果你控制不了调用方的类型至少要在SQL层给参数显式转换避免在列上做转换。比如列是DATETIME查询参数是2024-01-01最好写成WHERE created_at TIMESTAMP(2024-01-01)而不是WHERE created_at 2024-01-01虽然大多数数据库会自动处理但显式声明类型能避免时区、格式解析上的意外差异。2.3 条件顺序对性能的影响远小于你的直觉不少人在写多条件查询时会刻意把过滤性更强的条件放前面认为这样能更快。实际上现代关系型数据库的优化器会基于统计信息做代价估算它有本事把条件重排成自认为最优的执行计划。除非你写的条件彻底阻止了优化器做等价变换比如在列上套函数否则手动调条件顺序通常无效。真正要留意的不是顺序而是条件的写法。例如把UPPER(name) JOHN这种写法换成name John并配合合适的校对规则往往能大幅提升效率。把查询写成让优化器容易优化的形态比手动排列条件重要得多。3. 聚合与分组GROUP BY背后的统计哲学3.1 聚合函数遇到NULL别以为只是跳过空值这么简单COUNT、SUM、AVG、MAX、MIN这几个聚合函数对NULL的处理各不相同。COUNT(*)统计所有行COUNT(column)只统计该列非NULL的行。这个区别最直接的影响就是总数差异SELECT COUNT(*) AS total, COUNT(commission) AS has_commission FROM sales;当commission列存在大量NULL时两个值会不一致。如果报表展示时把没有佣金也当成一种有效数据COUNT(*)才是明细总量如果只看实际拿到佣金的人数那应该用COUNT(commission)。SUM和AVG遇到NULL会直接跳过这点好理解。但AVG有个隐藏问题如果你期望把NULL当作0来平均直接写AVG结果是错的。比如三笔订单佣金分别是100、200和NULLAVG(commission)返回150而期望的三单平均应该是100。这时必须先对NULL做处理SELECT AVG(COALESCE(commission, 0)) FROM sales;MAX和MIN基本不受NULL影响它们天然忽略NULL。但要注意的是如果列里全是NULLMAX和MIN返回NULL而不是0或空字符串。对这种边界场景前端展示时要做出判断别直接当成0用。3.2 HAVING和WHERE的分工过滤时机决定语义很多人对HAVING的印象是WHERE不能用的条件就放HAVING里这种理解不够准确。WHERE在分组前过滤行HAVING在分组后过滤组两个阶段过滤掉的数据完全不同。拿一个实际业务说假设要统计每个部门里工资大于8000的员工数量并且只要那些高薪员工人数不少于10人的部门SELECT department_id, COUNT(*) AS high_salary_cnt FROM employees WHERE salary 8000 GROUP BY department_id HAVING COUNT(*) 10;这里WHERE过滤的是参与分组的员工HAVING过滤的是分组统计后的结果。如果你把所有条件都堆在HAVING里不是不能跑但性能通常更差因为HAVING阶段已经完成了分组计算数据规模小不了。反过来如果在WHERE里过滤部门人数则根本写不出来因为COUNT(*)在WHERE阶段还不存在。还有个经常被忽略的小问题HAVING里能不能用SELECT别名不同数据库行为不一致。MySQL允许在HAVING里引用SELECT别名但标准SQL和部分数据库不允许。为了让脚本在不同数据库之间迁移更顺畅我的建议是HAVING里别依赖SELECT别名直接用聚合表达式或原始列名。3.3 只查询部分列却不分组一个必须杜绝的写法初学者常写出这样的语句SELECT department_id, employee_name, COUNT(*) FROM employees GROUP BY department_id;这条语句在严格模式比如MySQL的ONLY_FULL_GROUP_BYPostgreSQL默认下会直接报错。因为它选了employee_name但employee_name既没出现在GROUP BY里也没有被聚合函数包裹。逻辑上分组后一个部门有多个员工employee_name根本无法确定取哪一行。这个限制不是数据库故意找麻烦而是为了消除不确定性。如果业务真的想保留组内某个员工的信息可以用ANY_VALUEMySQL或DISTINCT ONPostgreSQL但更推荐的做法是明确用窗口函数或子查询来表达意图而不是碰巧依赖非分组列。4. 多表连接JOIN类型选择与子查询的取舍4.1 四种JOIN适合什么场景所谓JOIN本质是把两张表按某种关联条件横向拼接。INNER JOIN只保留两边都匹配的行LEFT JOIN保留左表全部、右表匹配不上就补NULLRIGHT JOIN反过来FULL OUTER JOIN两边都保留。业务里最常纠结的是INNER JOIN和LEFT JOIN。我的判断原则是看事实表订单、流水、明细和维度表用户、商品、状态编码的主从关系。如果分析对象以事实表为主体维度表是用来补充描述信息的通常用LEFT JOIN因为不想因为维度表缺失导致事实丢失。反过来如果只想看有完整维度信息的数据用INNER JOIN。实际操作里有个特别普遍的坑两表关联字段存在重复值导致结果行数翻倍。比如订单表的一个order_id在支付表里有两条记录LEFT JOIN之后一个订单会出现在两行里。如果后面还有SUM聚合金额会翻倍。我在处理报表时遇到过不少次排查很久才发现问题根源是一个维度的JOIN字段在附表里不唯一。对策是JOIN之前先用DISTINCT或GROUP BY把附表维度的唯一性确认好。4.2 JOIN与子查询不是谁一定好而是看优化器能不能拆很多人纠结用JOIN还是子查询其实现代优化器经常能把简单子查询改写成JOIN。真正影响性能的是你写出来的查询形态是否让优化器容易生成好计划。一般来说相关子查询correlated subquery即子查询里引用外层表字段容易出现逐行执行的问题性能波动比较大。这时改写为JOIN通常更高效-- 相关子查询写法 SELECT e.*, (SELECT MAX(s.sale_date) FROM sales s WHERE s.emp_id e.emp_id) AS last_sale FROM employees e; -- JOIN写法如果目标表语义允许 SELECT e.*, MAX(s.sale_date) AS last_sale FROM employees e LEFT JOIN sales s ON s.emp_id e.emp_id GROUP BY e.emp_id;JOIN改写后如果结果集没有重复性能往往更好因为一次扫描完成关联和聚合。IN和EXISTS的选择也是老生常谈。在小数据集上两者差别很小在数据量大、子查询结果集也大时EXISTS通常更合适因为它只要找到一条匹配就能短路。但也要看具体数据库的优化实现不能一概而论。我的建议是不要背结论拿真实数据量跑EXPLAIN看执行计划中扫描行数和连接类型再拍板。4.3 自连接的两个高频场景同一张表和自己做JOIN叫自连接。最常见的两个场景是上下级关系和连续状态判断。上下级关系直接拿员工表举例SELECT m.name AS manager, e.name AS employee FROM employees e LEFT JOIN employees m ON e.manager_id m.emp_id;这种写法在组织架构表里非常常见只要层级只有固定的1层就足够。如果层级深度不定比如5层、10层都可能有JOIN写起来会非常繁琐那要考虑递归CTE而不是无限多个LEFT JOIN。连续状态判断是另一个经典场景比如找出连续三个月都有订单的客户。核心思路是自连接订单表把第2个月、第3个月的记录分别接上或者用窗口函数处理。写之前想清楚要判断的是自然月连续还是任意间隔30天内业务口径不同SQL复杂度差别很大。5. 排序、去重与分页每个字都能踩坑5.1 ORDER BY的排序规则与NULL去向ORDER BY的默认顺序是ASC字母按照数据库的排序规则走数字按大小走这个没什么争议。有争议的是NULL排在最前还是最后——不同数据库默认行为不一样。MySQL里NULL在ASC时排最前DESC时排最后PostgreSQL默认NULL排最前Oracle默认NULL排最后。跨数据库迁移时这个差异极容易被忽视。想要稳定控制可以在ORDER BY里显式指定ORDER BY commission DESC NULLS LAST; -- PostgreSQL / OracleMySQL 8.0没有NULLS LAST语法可以用ORDER BY ISNULL(commission), commission DESC来模拟。这个细节看着小但报表排序差一位业务方经常能一眼看出来。另一个容易忽略的点是ORDER BY列没出现在SELECT里。标准SQL允许这样写但如果目标表上做了DISTINCT情况就变了。例如SELECT DISTINCT department_id FROM employees ORDER BY salary;在部分数据库里会报错因为DISTINCT之后结果集只保留了department_id而ORDER BY的salary已经不在结果集里。遇到这种需求要么把salary也加进SELECT要么换思路用聚合。5.2 去重DISTINCT不是万能的DISTINCT是对整个SELECT列组合去重而不是只对某一列去重。SELECT DISTINCT department_id FROM employees得到的是不重复的部门但SELECT DISTINCT department_id, name FROM employees去重的是部门和名字的组合同一个部门如果有两个不同名字的人会出现两行。如果只想对某一列去重并取对应行的其他字段最常见的方法是ROW_NUMBER()窗口函数在MySQL 8.0、PostgreSQL、SQL Server都支持SELECT dept_id, emp_name, salary FROM ( SELECT dept_id, emp_name, salary, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn FROM employees ) t WHERE rn 1;这个写法把按部门分组取工资最高的人表达得非常清晰。注意PARTITION BY是分组维度ORDER BY决定组内排序rn1就是取每个组的第一名。这个模式可以用来解决大部分某维度最新一条记录的需求。5.3 深度分页的隐患与改写思路LIMIT/OFFSET分页在数据量小的时候看似没问题但一旦页码深数据库要先把前面所有偏移量扫描出来再丢掉代价很大。一个典型的慢分页SELECT * FROM orders ORDER BY created_at DESC LIMIT 10 OFFSET 100000;这条语句可能要先扫描100010行再丢掉前100000行。优化方式最常见的是基于游标cursor-based分页利用有序字段记录上一次的位置SELECT * FROM orders WHERE created_at 2024-06-01 12:00:00 ORDER BY created_at DESC LIMIT 10;因为只需要沿着索引找到边界扫描量大大减少。如果业务必须用页码跳转可以考虑延迟关联先查出主键ID再回表拿完整数据SELECT o.* FROM orders o JOIN ( SELECT id FROM orders ORDER BY created_at DESC LIMIT 10 OFFSET 100000 ) t ON o.id t.id;子查询里只取主键和排序字段可以利用索引覆盖减少回表成本效果通常比直接LIMIT/OFFSET好很多。6. 窗口函数DQL进阶绕不开的能力6.1 窗口函数和GROUP BY的本质差异窗口函数Window Function和GROUP BY都能做分组统计但结果形态完全不同GROUP BY会把多行压成一行窗口函数不改变行数每一行依然保留只是在每行的旁边多一列基于窗口范围计算出的值。举例来说我们想统计每个部门人数用GROUP BY的话一行一个部门看不到具体员工信息用窗口函数的话每一行员工都保留旁边多一列部门总人数SELECT name, department_id, COUNT(*) OVER (PARTITION BY department_id) AS dept_cnt FROM employees;这个特性在报表场景非常实用——既能看到明细又能看到汇总。窗口函数的经典应用包括按组排序取Top N、计算累计值、移动平均、同比环比。6.2 ROW_NUMBER、RANK、DENSE_RANK三个函数的选择三个函数都用于组内排名但对并列值的处理不同。假设同一部门有两个人的工资都是10000ROW_NUMBER不管是否并列强行给一个递增序号所以两个人会得到1和2RANK并列时给相同排名但下一个排名会跳过。比如1、1、3DENSE_RANK并列时排名相同但下一个紧接。比如1、1、2举一个排行榜的例子就一目了然SELECT name, salary, ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS row_num, RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rank_num, DENSE_RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS dense_num FROM employees;选哪个取决于业务语义。如果只是想唯一标识组内的行用ROW_NUMBER。如果要做并列第一、往后顺延的排名用RANK。如果是竞赛积分排名且希望紧跟名次用DENSE_RANK。我遇到过把RANK当ROW_NUMBER用结果排名缺号引起业务方质疑的情况所以这个差异得提前确认清楚。窗口函数的排序方向也会影响NULL值位置这和ORDER BY的规则一致可以用NULLS LAST等语法控制特定数据库支持。6.3 窗口函数与框架子句窗口函数的ORDER BY还能和ROWS BETWEEN配合形成移动窗口。比如计算最近3天的平均销售额SELECT sale_date, amount, AVG(amount) OVER (ORDER BY sale_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS avg_3day FROM daily_sales;不加窗口框架时默认窗口范围是从分区起始到当前行这对计算累计值running total很自然。如果业务要的是前后各一天可以写成ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING。这块很容易写错建议在固定数据集上逐步验证先跑几条看数值是否符合预期再铺到全量。7. 查询写完之后的第二件事SQL注入与慢SQL优化7.1 SQL注入的防御参数化是底线SQL注入的本质是外部输入被拼接进了SQL语句被数据库当成SQL代码执行。比如登录功能里如果直接拼接字符串输入 OR 11 --就可能绕过用户名密码校验。这个风险在DQL里尤其高因为SELECT语句最常见攻击面也最广。标准的防御手段是参数化查询Prepared Statement。在Python的pymysql里是占位符cursor.execute(SELECT * FROM users WHERE email %s AND status %s, (email, status))在Java的MyBatis里是#{}select idfindUser resultTypeUser SELECT * FROM users WHERE email #{email} /select参数化的核心逻辑是输入只作为数据绑定到SQL里而不是拼接进SQL文本让用户输入没有机会改变语句结构。只要做到这一点绝大多数注入攻击都会被挡掉。还有一个容易被忽略的点ORDER BY、表名、列名这类SQL片段不能参数化因为它们属于字段标识符不是数据。如果业务确实需要动态传入排序字段必须做严格的白名单校验比如只允许固定的几个字段名而不是把用户输入直接拼进ORDER BY。像ordersalary; DROP TABLE这种拼接带来的风险比普通查询参数高好几个等级务必处理干净。7.2 EXPLAIN先看执行计划再谈优化慢SQL优化不是靠猜。拿MySQL举例直接在SQL前面加EXPLAIN关键字就能看到优化器选择的执行计划重点看这几个字段字段关注点type全表扫描是ALL走索引是range或ref性能差异巨大key实际用到的索引NULL说明没走索引rows预估扫描行数越小通常越好Extra出现Using filesort或Using temporary要警觉往往意味着排序或分组没走索引使用范围很窄的查询查看typeALL和rows很大的情况就要考虑加索引或改写查询了。不过EXPLAIN是估算不是实际执行个别时候优化器预估不准特别是涉及多表关联和复杂子查询时。我一般会在测试环境用真实数据量跑EXPLAIN再结合慢查询日志确认实际耗时。7.3 索引优化少就是多但不是越多越好WHERE、JOIN、ORDER BY涉及的列是加索引的主要方向。索引能极大减少扫描行数。但索引不是越多越好原因是每个索引都会占用存储空间并拖慢INSERT、UPDATE的速度因为每次写数据都要同步维护索引。几个常用的判断准则选择性高的列适合做索引。选择性的意思是不同值的个数除以总行数接近1说明能过滤掉大量行联合索引遵循最左前缀原则。查询条件里没有索引最左边的列联合索引就用不上避免在列上做运算或函数后再比较这会让索引失效覆盖索引索引包含查询所需的所有列能避免回表性能最好还有一个实操细节索引不是只能建一个同一个表可以有多个单列索引也可以有联合索引但优化器一次查询通常只能选一个索引做主要扫描。所以联合索引要按查询条件的使用频率和区分度来设计列顺序别指望数据库自动把多个单列索引合并出最优效果。7.4 慢SQL排查的一般流程如果线上出现慢查询我通常按这个顺序排查抓出慢SQL语句。打开慢查询日志或者从数据库的性能表里查最近慢语句用EXPLAIN分析执行计划看扫描行数和访问类型对照查询涉及的表确认数据量和索引情况检查是否可以对SQL做等价改写减少JOIN层数、去掉无谓的排序、避免在索引列上做函数运算考虑调整索引但一定要评估对写入性能的影响这个流程虽然简单但真正能坚持走完的人不多尤其是第4步很多人一看到慢就直接加索引结果问题根本不在索引上。我自己就遇到过一条SQL慢是因为子查询返回了大结果集优化器选择做临时表导致磁盘IO暴涨加索引没用最后改写成JOIN之后立刻降了下来。8. DQL常见场景的实战组合与我的直觉总结到这里DQL的大部分核心模块已经过了一遍单表查询、过滤、聚合、连接、排序分页、窗口函数、性能与安全。实际工作里这些能力很少单独使用更多是组合在一起。比如一个典型报表SELECT DATE_FORMAT(o.created_at, %Y-%m) AS month, o.region, COUNT(DISTINCT o.order_id) AS order_cnt, SUM(oi.amount) AS amount_sum FROM orders o LEFT JOIN order_items oi ON o.order_id oi.order_id WHERE o.status PAID AND o.created_at DATE_SUB(CURDATE(), INTERVAL 12 MONTH) GROUP BY month, o.region ORDER BY month DESC, amount_sum DESC;这种句式几乎概括了DQL的日常主力用法。写的时候我强烈建议先在脑子顺着逻辑执行顺序过一遍数据从哪来先过滤哪批再分哪几组最后怎么排序。把每条语句的执行顺序想清楚之后很多报错和性能问题都能提前规避。另外补充一点个人体会DQL的语法不难难的是用对语义。NULL怎么处理、JOIN会不会产生重复、窗口函数排名取错了、分页深了性能崩溃、外部输入有没有注入风险这些才是真正区分新手和老手的地方。如果你想系统地提高可以把自己手头最常用的几条查询逐个做一次EXPLAIN并且试着把子查询改写为JOIN、把JOIN改写为窗口函数对比不同写法的结果和执行计划。这个过程看似耗时但远比看文档来得深刻。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑