ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SQL WHERE子句完全指南:从底层逻辑到索引优化实战

SQL WHERE子句完全指南:从底层逻辑到索引优化实战 1. WHERE子句的底层逻辑不是过滤而是集合裁剪很多刚接触SQL的人会把WHERE子句理解为筛选符合条件的行这个理解不能说错但它会限制你对SQL的思考深度。我更愿意把WHERE子句看作集合裁剪——它做的事情是从一张表中切出一个满足条件的子集然后再对这个子集做后续的聚合、排序、连接或者其他操作。(1) WHERE子句的求值顺序SQL是一门声明式语言你写出来的语句描述的是我要什么而不是怎么要。但数据库引擎在执行时其实有一个逻辑顺序FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT。这意味着一件很重要的事WHERE子句在GROUP BY和聚合函数之前执行。所以你不能在WHERE里使用别名也不能在WHERE里引用聚合函数的结果因为执行到WHERE这一步的时候聚合还没发生。这个逻辑顺序是理解SQL一切奇怪规则的钥匙。比如为什么WHERE COUNT(*) 1会报错因为WHERE执行时COUNT还没算出来。为什么很多人纠结WHERE和HAVING的区别本质上就是因为WHERE在分组前裁剪HAVING在分组后裁剪。(2) 执行计划里的Filter算子从物理执行的角度看WHERE子句的裁剪可能体现为两种方式一种是索引查找Index Seek直接通过索引定位到符合条件的行另一种是索引扫描过滤Index Scan Filter或者全表扫描过滤Table Scan Filter。你可以用EXPLAIN命令看执行计划如果出现Filter算子说明优化器没能把WHERE条件完全推给索引这个条件就变成了先拿数据再过滤。我经常跟团队说一句话写WHERE子句的时候脑子里要同时过一遍执行计划。不是为了炫技而是因为很多性能问题根源不在索引缺失而是WHERE子句的写法让索引失效了。后面我会专门展开这一点。2. WHERE子句的操作符全解析你真的会用 BETWEEN 和 IN 吗WHERE子句最基础的操作符包括比较运算符、、、、、、逻辑运算符AND、OR、NOT、范围判断BETWEEN、IN、模糊匹配LIKE、空值判断IS NULL。这些看起来简单但实际使用中有一堆细节容易翻车。(1) BETWEEN 的边界陷阱BETWEEN a AND b在绝大多数数据库里是闭区间也就是说包含a和b本身。这个和很多人的直觉相反——他们以为BETWEEN是中间应该排除两端。实际写的时候要记住BETWEEN 10 AND 20等价于col 10 AND col 20。这个细节在业务上特别容易出事。比如统计9月到12月的订单如果你写WHERE order_date BETWEEN 2024-09-01 AND 2024-12-01那12月1日当天的订单会被算进来——但12月还有30天呢你是不是漏了正确做法是-- 错误漏掉12月1日之后的订单 WHERE order_date BETWEEN 2024-09-01 AND 2024-12-01 -- 正确使用左闭右开区间 WHERE order_date 2024-09-01 AND order_date 2024-12-01同样的逻辑对于日期时间类型DATETIME、TIMESTAMPBETWEEN 2024-09-01 AND 2024-12-01意味着包含12月1日的00:00:00这一天的23:59:59反而被排除在外。这就是为什么业界更推荐用和组合来表达日期区间——语义清晰而且彻底避开边界问题。(2) IN 和 EXISTS 的相爱相杀IN后面可以跟一个值列表常量列表也可以跟一个子查询。跟常量列表时优化器一般处理得很好跟子查询时要小心两个问题第一子查询的结果集如果很大IN的效率可能很差第二如果子查询结果包含NULLIN的判断会有意想不到的行为。具体来说col NOT IN (SELECT col2 FROM t2)当子查询结果中有一个NULL时整个NOT IN的结果是空集——一条记录都不会返回。原因在于NULL参与比较时的三值逻辑col NULL的结果是UNKNOWN而WHERE只接受TRUEUNKNOWN会被当作不满足条件。这个坑踩的人太多了尤其是做数据清洗时你以为自己在排除某些ID结果整张表直接被清空了。相比之下EXISTS子查询用的是**半连接Semi Join**语义它只关心存在性不关心具体值而且不会因为NULL产生上述问题。实战中我的习惯是子查询结果集小、且确定无NULL时两者差异不大子查询结果集大或者不确定是否有NULL时优先用EXISTS判断不存在的业务场景比如没有订单的用户直接用NOT EXISTS。-- 容易踩坑的写法 SELECT * FROM users WHERE user_id NOT IN (SELECT user_id FROM orders); -- 推荐写法 SELECT * FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id u.user_id );(3) LIKE 匹配的三个隐藏细节LIKE是模糊查询的标配但很多人不知道三件事第一下划线_是一个通配符匹配任意单个字符。你要查的是字段里真的含下划线的记录比如邮箱前缀user_name直接写LIKE user_name%会把userXname...也匹配出来。正确做法是转义LIKE user\_name% ESCAPE \或者LIKE user$_name% ESCAPE $。第二前导通配符会让索引失效。LIKE %abc和LIKE %abc%基本走不了索引除非数据库支持反向索引或全文索引因为优化器不知道你从哪里开始匹配。而LIKE abc%在前缀匹配的场景下是可以利用索引的。第三大小写敏感性因数据库而异。MySQL默认的排序规则collation通常不区分大小写PostgreSQL默认区分SQL Server默认不区分。这意味着同样的LIKE apple%在三种数据库里可能会返回不同的结果。跨数据库迁移时这个坑特别隐蔽。(4) NULL的判断只有一个正确姿势判断字段是否为NULL只能用IS NULL或IS NOT NULL不能用 NULL或 NULL。这个规则几乎所有SQL新手都会踩一次因为col NULL不会报错它只会返回一个空结果集让你一脸懵。更麻烦的是NULL参与算术运算和逻辑运算时的传染性NULL 1结果是NULLNULL AND TRUE结果是NULL不是FALSENULL OR TRUE结果是TRUE。所以在WHERE里组合条件时一旦某个字段可能存在NULL就必须显式处理否则条件判断可能悄悄失效。比如-- 如果 age 为 NULL这条记录会被丢掉 WHERE age 18 -- 如果业务上确实需要把未知年龄也算进来 WHERE age 18 OR age IS NULL3. WHERE子句的索引优化为什么你的查询慢如蜗牛这一节可能是实战中最值钱的部分。很多慢查询的根源不是数据量大而是WHERE子句的写法让索引失去了用武之地。我总结了日常运维中常见的问题模式。(1) 对索引列做函数运算WHERE DATE(create_time) 2024-12-01这种写法看起来没问题但实际执行时数据库需要对每一行的create_time先执行DATE()函数再比较结果——索引完全失效。正确的做法是对条件本身做变换-- 错误对索引列做函数运算 WHERE DATE(create_time) 2024-12-01 -- 正确让查询条件落在索引列的原值上 WHERE create_time 2024-12-01 00:00:00 AND create_time 2024-12-02 00:00:00同理WHERE YEAR(birthday) 2000应该改写成WHERE birthday 2000-01-01 AND birthday 2001-01-01。记住一个原则索引列保持裸奔状态要变换就变换条件的另一端。(2) 隐式类型转换的杀伤力假设user_id是VARCHAR类型但你的查询条件是WHERE user_id 123456数字类型数据库可能会做隐式转换。在MySQL里字符串列和数字比较时会尝试把字符串转成数字这会导致索引列上发生隐式转换索引失效。更隐蔽的是即使能走索引转换规则也可能让结果出错——字符串123abc转成数字是123。反过来如果字段是数字类型条件传入了字符串同样可能引发隐式转换。跨语言开发时比如Java的PreparedStatement参数类型设置不对这个坑很容易埋进生产环境。排查方法也简单看执行计划里是否出现CAST或CONVERT操作出现就说明发生了隐式转换。(3) 多条件组合时的索引选择很多人以为只要WHERE里有多个条件数据库就会走最有效的那个索引。实际上优化器选择索引时基于统计信息和代价估算而且一个表每次查询通常只会选择一个索引来用不考虑INDEX MERGE的情况下。这意味着如果你有WHERE col_a 1 AND col_b 2应该建联合索引(a, b)而不是单独建(a)和(b)两个索引。关于联合索引的列顺序核心原则是区分度高的列放前面或者说先放能迅速缩小范围的那个条件。如果a的值只有两种可能比如性别b的值有上千种那联合索引应该建(b, a)而非(a, b)。很多开发者的习惯是把查询里写的第一个条件放前面这是不对的索引设计要按选择性来。(4) OR条件与IN条件的索引差异WHERE a 1 OR a 2在某些数据库里可能走不了索引因为OR可能让优化器需要做多次索引查找再合并。写成WHERE a IN (1, 2)通常更容易被优化器处理成一次范围扫描。这个优化看似微小在数据量大时差异明显。如果OR条件涉及多个不同字段比如WHERE a 1 OR b 2基本很难利用单列索引。此时可以考虑UNION ALL拆分两个条件分别查询再合并或者用一个复合索引覆盖两种可能但复合索引也不是银弹。(5) 分页查询需要带上游标式过滤LIMIT OFFSET深分页的慢本质原因是OFFSET越大需要扫描然后丢弃的行越多。比如LIMIT 1000000, 20数据库要先数出1000020行再丢掉前1000000行。优化的思路是用WHERE子句里的上一页最后一条记录来做游标过滤-- 原始深分页写法 SELECT * FROM orders ORDER BY id LIMIT 1000000, 20; -- 游标式写法记住上一页最后一条的id SELECT * FROM orders WHERE id 1006000 ORDER BY id LIMIT 20;这种写法能让数据库直接从id大于某个值的记录开始扫描走主键索引效率提升幅度往往是几十倍到上百倍。4. 子查询中的WHERE关联子查询vs非关联子查询的选择逻辑当WHERE子句里嵌套子查询时最常见的两种形态是非关联子查询子查询独立执行和关联子查询子查询引用了外层表的列。两者的执行逻辑和性能特征完全不同。(1) 非关联子查询的执行逻辑非关联子查询可以先独立执行一次得到结果集后被外层查询反复使用。比如SELECT * FROM products WHERE category_id IN (SELECT category_id FROM categories WHERE status active);这个子查询SELECT category_id FROM categories WHERE status active与外层products表没有任何引用关系所以数据库只需执行一次拿到结果集再做外层过滤。这类子查询性能相对可控但要注意两点第一子查询结果集的大小第二IN列表特别长时比如上万项可能和直接JOIN无异甚至更差。在很多数据库里非关联子查询可以被优化成哈希半连接Hash Semi Join或者EXISTS半连接本质上是把逐行判断变成了批量匹配。这也是为什么我前面推荐用EXISTS——它和其他查询方式的差异在优化器层面比在写法层面更值得关注。(2) 关联子查询的执行逻辑与性能风险关联子查询是另一种动物。比如经典的找出每个分类下最新发布的商品SELECT p1.* FROM products p1 WHERE p1.created_at ( SELECT MAX(p2.created_at) FROM products p2 WHERE p2.category_id p1.category_id );这里的子查询引用了外层表的p1.category_id意味着对于外层表的每一行数据库都可能执行一次子查询。如果外层表有10万行子查询就要执行10万次——除非数据库能做某种物化或者被优化成窗口函数、JOIN等形式否则性能会很糟糕。(3) 什么情况下该把子查询改写成JOIN或窗口函数对于每个分组取最新一条这类需求关联子查询虽然可读性强但在数据量大时性能很差。更优的解法是窗口函数ROW_NUMBER()SELECT * FROM ( SELECT p.*, ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY created_at DESC) AS rn FROM products p ) t WHERE rn 1;这个查询先用窗口函数给每个分类下的商品按时间排号然后再用WHERErn 1取出每组的最新记录。它的执行计划往往比关联子查询高效得多。还有一个经典场景NOT IN子查询和NOT EXISTS的对比。前面说过NOT IN遇到NULL会变空集很多人理解为那就用NOT EXISTS但更准确的理解是NOT EXISTS是半连接的反面——反半连接Anti Semi Join它判断的是外层这一行在子查询里是否找不到匹配项。这种语义更清晰、更安全不容易被NULL坑到。(4) EXISTS语句里的SELECT 1到底发生了什么很多人写EXISTS时习惯SELECT 1也有人写SELECT *还有人写SELECT 具体列。实际上EXISTS只关心子查询是否返回至少一行不关心返回的是哪一列所以写SELECT 1甚至SELECT NULL都可以。优化器在做半连接时通常会忽略投影列直接判断有没有行存在。但有一个注意事项如果子查询里用了ORDER BY或者GROUP BYEXISTS会执行这些操作吗答案是优化器可能会尝试简化但ORDER BY在EXISTS子查询里通常是多余的——你只需要知道存不存在排序毫无意义。保持子查询精简能减轻优化器的负担也能让你自己阅读时更快抓住逻辑。5. 动态SQL中拼接WHERE的条件处理策略在真实的业务系统里WHERE子句很少是写死的——搜索页面、后台管理、报表筛选器都会根据用户选择的过滤条件动态拼接SQL。这里暗藏两个核心问题条件有效性和SQL注入风险。(1) 11和WHERE与AND的拼接难题动态拼接条件时最简单粗暴的做法是先把条件都拼上再在最前面加上WHERE 11。这样所有附加条件直接以AND开头不用判断当前是不是第一个条件。11恒为真所以不影响最终结果。很多老代码里能看到这种风格实测确实方便String sql SELECT * FROM orders WHERE 11; if (status ! null) { sql AND status status ; } if (minAmount ! null) { sql AND amount minAmount; }但11这种做法有几个问题。第一它让SQL语义变得怪异阅读者需要花额外的脑力理解为什么有个恒真条件。第二某些数据库的查询缓存Query Cache可能因为SQL文本差异而失效——虽然现在的现代数据库已经弱化了查询缓存机制但这种写法并不优雅。更推荐的做法是用列表收集真正的过滤条件最后用StringJoiner或者ORM的动态查询API来拼接。或者干脆引入MyBatis的where标签、JPA的Specification等机制这些工具会帮你自动处理前缀问题。(2) 参数化查询永远是第一原则不管你怎么拼接WHERE值部分绝对不能用字符串直接拼。SQL注入的经典原理就是攻击者输入一段精心构造的字符串改变了WHERE子句的语义。最著名的万能密码是 OR 11——如果把它拼进登录查询SELECT * FROM users WHERE username admin AND password OR 11因为OR 11恒为真整条WHERE就失去了校验作用攻击者不需要知道密码就能登录系统。这就是为什么PreparedStatement和参数化查询是最基本的安全底线。// 错误写法 String sql SELECT * FROM users WHERE username username ; // 正确写法 String sql SELECT * FROM users WHERE username ?; PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, username);参数化查询会把用户输入作为数据而不是可执行的SQL片段即使输入 OR 11它也只是被当作一个普通的字符串值去匹配起不到注入效果。任何ORM框架MyBatis、JPA、Hibernate也都应该使用#{}而不是${}来传值。(3) 动态条件中空值过滤和NULL过滤的语义区分业务系统里常见的需求是用户不填某个筛选条件时不限制这个字段用户填了就按这个字段筛。这种条件本身是否为NULL很容易和字段值是否为NULL混淆。举例来说-- 错误用户没填status时会漏掉status为NULL的记录 WHERE status #{status} -- 正确先判断用户是否传了这个条件 WHERE (#{status} IS NULL OR status #{status})很多人会写#{status} IS NULL OR status #{status}这种模式但它有一个潜在风险如果status字段本身允许NULL且用户确实传了statuspending那么status为NULL的记录会被正确排除如果用户没传所有记录都会被保留——这个逻辑本身没问题但要注意WHERE里对字段做OR判断时索引利用率可能会下降。某些数据库会把这种带OR的等值条件处理成Filter而不是Seek。更高级的写法是用动态SQL构造干净的条件列表条件为空就不拼这条路。这种思路能同时保证性能和语义清晰但代码量略多。在实时性能敏感的场景我更倾向于后者。(4) 多条SQL语句执行中的WHERE保护实际业务中还有一类需求是批量更新、批量删除或者在一个事务里执行多条SQL。常见的错误写法是UPDATE和DELETE不带WHERE或者WHERE过于宽泛。比如-- 危险可能把整个表的数据都更新了 UPDATE users SET age age 1; -- 更危险删错数据 DELETE FROM users WHERE id 100 OR id 200;这里需要特别提醒DELETE和UPDATE场景下WHERE的试运行技巧在执行UPDATE或DELETE之前先把WHERE条件搬到SELECT里跑一遍确认你要影响的行数和范围符合预期。比如-- 先查再改 SELECT COUNT(*) FROM users WHERE create_time 2023-01-01; UPDATE users SET status expired WHERE create_time 2023-01-01;这个先查后改的习惯是我给所有新人的第一条规矩。数据库没有后悔药WHERE写宽了影响的可能就是几十万条数据。6. WHERE子句在各种数据库中的方言差异与兼容写法做过多数据库适配的人都知道WHERE子句的通用写法在各大数据库中并非完全通用。以下是我在MySQL、PostgreSQL、SQL Server、Oracle之间切换时经常遇到的差异点。(1) 字符串比较的引号规则SQL标准规定字符串字面量用单引号包裹绝大多数数据库遵循这个规则。但SQL Server对双引号的处理比较特殊默认情况下双引号包裹的是标识符列名、表名而不是字符串。MySQL的默认配置则允许双引号表示字符串取决于ANSI_QUOTES模式。这就导致了一个跨库坑在MySQL上写WHERE name 张三能跑迁到SQL Server就报列名张三不存在。统一规范就是字符串永远用单引号标识符用反引号MySQL或者方括号SQL Server或者双引号PostgreSQL标准。不赌数据库的默认行为能省掉无数跨库排查时间。(2) NULL判断和空字符串的差异Oracle有一个反直觉的设定Oracle里空字符串就等同于NULL。所以在Oracle的WHERE里WHERE col 不会返回任何空字符串的记录因为空字符串本身就是NULL而col NULL永远不成立。MySQL和SQL Server则把当作一个普通的空字符串值与NULL严格区分。这意味着同一套表结构在MySQL里用WHERE phone 能找到手机号为空字符串的记录在Oracle里就什么也找不到必须写WHERE phone IS NULL。跨数据库迁移时这条差异会直接影响业务结果。(3) 大小写敏感性和排序规则前面提到过不同数据库甚至同一数据库不同配置对字符串比较的默认大小写敏感性不同。MySQL的默认排序规则如utf8mb4_general_ci不区分大小写所以WHERE name Alice能匹配到alice。PostgreSQL默认排序规则如C或POSIX是区分大小写的必须写WHERE name Alice才能精确匹配想要不区分就得用WHERE LOWER(name) LOWER(Alice)或者ILIKE。在做账户登录、邮箱匹配这类业务时这个差异尤其致命——如果业务要求邮箱不区分大小写但数据库默认区分大小写用户注册时用的是AliceTest.com登录输入alicetest.com就会匹配失败。解决方式有两种一是统一以全小写形式存储二是在WHERE层显式做LOWER比较但要记得LOWER(col)会让索引失效所以前一种方案更优。(4) 日期时间字面量的写法差异日期字符串在MySQL里可以写成2024-12-01PostgreSQL也支持这种ISO格式SQL Server同样认。但在对日期时间的隐式转换上不同数据库的容忍度不一样。比如MySQL会把2024-12-01自动转成日期类型也能识别2024/12/01这种斜杠格式而PostgreSQL的日期输入解析则更严格不接受一些模棱两可的格式。跨库场景下最稳的写法是ISO 8601格式2024-12-01T12:30:00或者直接全用YYYY-MM-DD HH:MM:SS格式。避免MM/DD/YYYY这种美国式写法不仅在不同数据库里容易翻车在不同地区语义也不同。7. WHERE子句的性能调优实战从执行计划到慢查询日志光会写WHERE还不够要能快速定位为什么这个WHERE跑这么慢并用可靠的手段优化它。我分享一套完整的排查思路这套流程在MySQL和PostgreSQL上高度通用。(1) 第一步慢查询日志抓出恶查询数据库通常都支持开启慢查询日志。MySQL的做法是设置long_query_time和slow_query_log参数PostgreSQL则通过log_min_duration_statement来记录超过指定耗时阈值的语句。先把这些日志打开然后从日志里捞出执行时间长的SQL。-- MySQL 慢查询日志配置 SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; -- 超过1秒的查询记录 SET GLOBAL slow_query_log_file /var/log/mysql/slow.log; -- PostgreSQL 慢查询配置修改postgresql.conf log_min_duration_statement 1000拿到具体SQL后不要直接在还原环境乱试先用EXPLAIN分析执行计划。(2) 第二步EXPLAIN看执行计划关键列以MySQL为例EXPLAIN输出里重点关注的几列是type访问类型、key实际使用的索引、rows预估扫描行数、Extra附加信息。type值含义分析system表只有一行极快const主键/唯一索引等值查询极快eq_ref连接查询中每行只匹配一行很好ref非唯一索引等值匹配较好range索引范围扫描BETWEEN、IN、等可接受index全索引扫描较差ALL全表扫描危险要优化如果type显示为ALL或者key列为NULL说明这条WHERE子句没有用到索引。接下来要看是索引设计不对还是写法限制了索引。(3) 第三步分析Extra列里的危险信号Extra列经常出现几个需要警惕的值Using filesort说明ORDER BY没有用上索引发生额外排序。排序本身不一定慢但大量数据的文件排序要警惕。Using temporary说明查询需要使用临时表常见于GROUP BY、DISTINCT、子查询连接等情况。临时表可能被落到磁盘性能急剧恶化。Using index conditionICP表示索引下推优化生效好现象。Using where表示在存储引擎层返回的数据上做了WHERE过滤如果伴随ALL那基本是全表过滤。看到Using filesort或Using temporary后要回到WHERE和ORDER BY、GROUP BY的组合看有没有建立合适的联合索引。比如SELECT * FROM orders WHERE status paid ORDER BY created_at DESC LIMIT 10;最优索引是(status, created_at)这样WHERE部分用status精确过滤ORDER BY部分能直接用索引的created_at排序一举两得。(4) 第四步用覆盖索引消掉回表覆盖索引指的是索引里已经包含了SELECT需要的所有列这样查询不需要回表读取整行数据直接通过索引页就能拿到结果。-- 假设高频查询是 SELECT id, status FROM orders WHERE user_id 100; -- 设计覆盖索引 CREATE INDEX idx_user_status ON orders(user_id, status);此时执行计划会显示Using index意味着整个查询都在索引上完成性能非常可观。覆盖索引在WHERE条件命中率高、查询频繁的场景里是性价比极高的优化手段。(5) 第五步统计信息与索引失效的隐藏原因有时候你的WHERE写法没问题索引也存在但优化器就是不用。可能是统计信息过期了。MySQL里用ANALYZE TABLE刷新表的统计信息PostgreSQL用ANALYZE命令。统计信息不准优化器对行数的估算就会偏差导致选错索引。还有一种情况是数据分布极度不均衡。比如status字段90%的记录都是active只有10%是expired。如果查询WHERE status active优化器可能觉得全表扫描反而更快因为它知道选择性太差。这种情况下不是索引没用而是业务语义决定了这个条件本身没有区分度。解决办法是结合其他高选择性条件或者重新设计索引。8. 经典误用场景复盘我见过的WHERE子句翻车现场从业这么多年看到过也修复过不少WHERE子句引发的事故。有些是常识性问题有些则相当隐蔽。这里挑几个有代表性的复盘。(1) UPDATE不带WHERE的全表事故我接到过一次最惊险的工单同事执行了一条UPDATE products SET price price * 1.1;本意是给某分类的商品涨价10%但漏掉了WHERE对应的分类条件。结果全表商品价格都涨了10%。之所以能快速恢复是因为业务系统有审计日志我们反推了原价格。这种事故完全可以通过先SELECT后UPDATE的习惯避免。同类的还有DELETE不带WHERE。有一次在测试环境同事执行DELETE FROM orders;以为自己在某个临时表上操作结果清空了整个订单表。测试环境的备份虽然能恢复但恢复本身消耗的时间让人抓狂。(2) 三值逻辑的连锁反应一个统计报表的查询目的是找出未设置手机号的用户-- 错误手机号列存在NULL时结果会缺失 SELECT COUNT(*) FROM users WHERE phone ! 13800000000;这个写法只排除了手机号等于13800000000的记录但phone为NULL的记录因为NULL ! 13800000000的结果是UNKNOWN不会被统计进来。所以最终结果既包含了所有其他手机号的用户也包含phone为NULL的用户——完全背离了找出特定手机号之外的用户的本意。此类问题在数据清洗里尤其常见而且因为不报错很难被发现。(3) OR条件引发的全表扫描一个线上订单查询条件长这样SELECT * FROM orders WHERE status pending OR (status paid AND created_at 2024-01-01);明明status和created_at都有索引但执行计划却走了全表扫描。原因在于这个OR条件横跨了多种索引策略优化器难以把两个分支统一到单一索引路径上干脆选择了全表扫描。优化方式是把OR拆成UNION ALLSELECT * FROM orders WHERE status pending UNION ALL SELECT * FROM orders WHERE status paid AND created_at 2024-01-01;拆分后每条分支都能独立走索引。代价是代码多了一些但对于高并发核心查询这点成本完全值得。(4) 字符集不一致导致的索引失效假象有次排查一个慢查询字段有索引WHERE用的也是等值比较执行计划却显示全表扫描。最后发现是表字段字符集是utf8mb4而查询参数连接串的字符集是latin1数据库在做比较前先做了字符集转换导致索引失效。这类问题很难直觉发现排查思路是如果字段和条件看起来该走索引却没走除了检查函数运算、隐式转换还要检查字符集和排序规则是否一致。9. WHERE子句之外与GROUP BY、HAVING、ORDER BY的协作关系WHERE子句不是孤立存在的它和SQL语句的其他部分协作才能完成复杂查询。理解这种协作关系才能写出语义正确又高效的查询。(1) WHERE与GROUP BY的顺序效应WHERE在分组前完成行级过滤所以它无法使用聚合函数也无法使用分组后的汇总结果。如果你要过滤的是分组后的数据只能用HAVING。一个典型例子是统计订单数大于10的用户SELECT user_id, COUNT(*) AS order_count FROM orders WHERE status ! cancelled -- 先去掉取消的订单 GROUP BY user_id HAVING COUNT(*) 10; -- 再筛出订单数大于10的用户WHERE和HAVING的分工很明确WHERE处理行级条件HAVING处理组级条件。看到有人把两者混用比如在HAVING里写HAVING status paidstatus不是分组列这是不对的——HAVING要对聚合后的组做判断行级条件应该在WHERE里先过滤掉。(2) WHERE与JOIN条件的边界JOIN时连接条件ON和过滤条件WHERE在逻辑上有微妙差别。对于内连接INNER JOINON和WHERE都写体现不出差异因为它们的结果相同但对于外连接LEFT JOIN差异就大了。-- 查询用户和他们的订单只保留已支付的订单 -- 错误把订单过滤条件写在WHERE里会把未支付订单的用户也过滤掉 SELECT u.id, o.id AS order_id FROM users u LEFT JOIN orders o ON o.user_id u.id WHERE o.status paid; -- 正确如果目的是保留所有用户但只显示已支付的订单 SELECT u.id, o.id AS order_id FROM users u LEFT JOIN orders o ON o.user_id u.id AND o.status paid;第一种写法中LEFT JOIN先返回所有用户和匹配的订单未匹配的订单列是NULL随后WHERE把o.status paid不满足的行全部剔除这实际上把LEFT JOIN降级成了INNER JOIN。而把过滤条件放在ON子句里则是在连接阶段就把订单限定了paid未匹配的用户依然保留只是订单列为NULL。这个差异是SQL面试常考题也是实际业务中最容易搞错的JOIN语义点。(3) WHERE与ORDER BY的索引协作ORDER BY的排序如果能用上索引那速度极快。但WHERE和ORDER BY的字段组合需要匹配索引的列顺序。假设有联合索引(a, b)-- 能有效利用索引 SELECT * FROM t WHERE a 1 ORDER BY b; -- 可能无法利用索引前缀匹配规则 SELECT * FROM t WHERE b 1 ORDER BY a;第二条里WHERE用了b联合索引第二列ORDER BY用了a联合索引第一列查询条件跳过了前缀列a导致WHERE部分无法有效走联合索引ORDER BY的排序也未必能直接利用索引。这提醒我们索引设计不能只看WHERE要把WHERE、ORDER BY、GROUP BY三者一起纳入考量。10. 从WHERE子句到查询习惯几条我一直坚持的实践经验最后总结一些我自己坚持多年的SQL编写和审查习惯这些习惯帮我避开了大量生产事故也希望能给你提供一些参考。(1) 先SELECT再UPDATE/DELETE的习惯任何UPDATE和DELETE语句在提交到生产环境之前先把WHERE条件复制到SELECT语句里跑一遍查看影响行数是否在预期范围。这个习惯成本极低收益极高真的一句话能救回一张表的数据。(2) 写WHERE时问自己三个问题第一个问题这个条件能走索引吗如果答案是不能换成等价且能走索引的写法。第二个问题字段里有NULL吗如果有我的比较逻辑符合三值逻辑吗第三个问题我是在改数据还是查数据如果是改删数据我的WHERE是否精确到不能再精确(3) 定期查看慢查询和更新统计信息生产环境的慢查询日志不是摆设建议定期每周或者每月捞一遍耗时排行榜把TOP10的慢查询逐一验证执行计划。同时如果表数据量发生明显变化比如导入了大量历史数据跑一下ANALYZE更新统计信息避免优化器基于过期统计做出糟糕的索引选择。(4) 团队代码审查中强制检查SQL如果团队在做code reviewSQL语句必须被单独审查重点看三样东西WHERE条件中是否有隐式类型转换、是否有函数作用于索引列、UPDATE/DELETE是否带了足够精确的WHERE。把这三条作为硬性检查项能提前拦住绝大多数SQL性能和安全问题。(5) 用ORM也别丢掉原生SQL能力很多人用MyBatis、JPA、Entity Framework久了写SQL的能力会退化。我的建议是即使项目全面使用ORM也要保持对执行计划的好奇心每次ORM生成的SQL如果慢把它捞出来用EXPLAIN看一下。理解ORM帮你生成的WHERE子句长什么样反而能让你写出更合理的查询条件也更有能力处理为什么我的ORM查询这么慢这类问题。WHERE子句在SQL里就像一个阀门控制着进入下一步计算的数据范围。阀门拧得准查询快且语义正确阀门拧歪了轻则返回错误结果重则拖垮整个数据库。多花时间理解它背后的执行逻辑和边界条件这可能是数据库开发中性价比最高的投入。
返回列表