
做数据库开发这些年我被问得最多的一句话是“我的表明明加了索引查询为什么还是这么慢”问这话的人往往已经在条件列上建了索引但执行计划一拉出来优化器压根没选它。索引创建不是“建了就行”数据查询也不是“写出来就完事”这中间隔着一整套对执行路径、SQL写法和索引特性的理解。这篇是SQL核心操作笔记的第二部分接着上一期的基础语法往下走专门把索引创建和数据查询这两块拆开揉碎。内容包括索引的底层逻辑与实际创建语法、窗口函数和去重查询的实战用法、慢SQL的定位与调优以及日常开发里高频踩坑的排查方法。适合刚把增删改查跑通、正准备进阶的初中级开发者也适合写了好几年SQL但没系统摸过索引原理的从业者。1. 索引的本质与创建策略先把原理想明白再动手1.1 B树索引到底解决什么问题索引本质上是一种额外的数据结构数据库用它来加快数据的定位和检索。MySQL InnoDB和SQL Server的默认索引结构都是B树它把数据按照键值组织成多级树形结构叶子节点存真实数据并串成有序链表非叶子节点只存键值和指针。查找一条记录的时候从根节点顺着指针一层层向下每次都能砍掉一半左右的搜索范围这就是二分查找的思路。拿现实中的场景类比一下《新华字典》按拼音查字你会先翻目录根据拼音定位到页码再翻到对应页而不是从第一页开始逐页翻。全表扫描就是那个“逐页翻”的方式而走索引就是“查目录”。一张几百万行的表B树的层高通常只有3到4层也就是说只要几次磁盘I/O就能定位到目标数据性能差距可以达到几个数量级。这里要区分两个容易搞混的概念聚簇索引和非聚簇索引。InnoDB里聚簇索引的叶子节点直接存整行数据表数据本身就是按主键排序的所以主键查找极快。非聚簇索引的叶子节点存的是主键值查询时先通过索引找到主键再回表去聚簇索引里拿完整数据这个动作叫回表。如果把要查的字段全部塞进索引里就能跳过回表这一步这种索引叫覆盖索引是查询性能优化的一个关键手段。1.2 不同数据库创建索引的语法差异索引创建的语法在不同数据库里大同小异但细节上有区别尤其是SQL Server的“包含列”等特性。MySQL中常见的两种写法等价CREATE INDEX idx_user_name ON user(name); ALTER TABLE user ADD INDEX idx_user_name (name);联合索引也就是多个列的索引写法如下CREATE INDEX idx_user_name_age ON user(name, age);SQL Server中创建索引时可以指定包含列这是一个很实用的特性CREATE NONCLUSTERED INDEX idx_user_name ON [dbo].[user] (name) INCLUDE (age, email);这个语法意味着查询只需要在索引里就能找到name、age、email三列不需要回表。MySQL 8.0也支持了类似的能力虽然实现细节有所不同但思路一致。在Oracle里可以通过CREATE INDEX和ALTER INDEX管理索引还支持位图索引、函数索引等更多类型。不同数据库语法有差异但核心思想是一致的索引列的选择决定了查询效率的上限。这里必须提联合索引的最左前缀原则。联合索引(name, age)相当于自动创建了(name)和(name, age)两套索引查询条件只有name时也能命中该索引但如果只查age索引就白建了。所以联合索引的列顺序要按字段的选择率从高到低排列高频字段放前面这是索引设计里最容易被忽略又最影响性能的规则之一。1.3 索引设计实用指南与避坑什么样的字段应该建索引标准是“高频使用且区分度高”。频繁出现在WHERE条件里的字段、经常用JOIN关联的字段、ORDER BY排序的字段都值得建索引。区分度可以简单地用去重后的数量除以总行数来衡量比如性别字段取值只有“男”“女”两种区分度极低建了索引优化器也大概率不走纯属浪费空间和维护成本。索引不是越多越好。每多一个索引每次INSERT、UPDATE、DELETE时数据库都要额外维护索引树写操作的代价会明显上升这叫写放大。我见过有人为了压榨查询性能给一张表建了十几个索引结果写入场景全部变慢线上报警不断。索引是“用空间换时间”的经典方案但空间是磁盘空间时间是查询时间两者要保持平衡。还有一个经常被忽略的点在MySQL早期版本里建索引的ALTER TABLE操作会锁表大表在线加索引可能导致服务不可用。后来的版本支持了在线DDL但具体行为仍然跟版本和参数配置有关。我的习惯是在低峰期执行索引变更并且先把语句在测试环境的同样数据量上跑一遍估算执行时间避免在生产环境直接闷头执行。2. 数据查询核心操作从执行顺序到高级写法2.1 先搞懂SQL的执行顺序很多人写SQL靠直觉SELECT后面跟着想要的列FROM后面跟着表WHERE后面跟着条件感觉天经地义。但实际上SQL的书写顺序和数据库执行顺序是两个完全不同的概念理解执行顺序是优化查询的第一课。一个标准查询的逻辑执行顺序大致是先FROM和JOIN确定完整的数据集再WHERE过滤掉不满足条件的行接着GROUP BY分组HAVING过滤分组结果然后SELECT计算目标列最后ORDER BY排序如果需要分页再取LIMIT。这个顺序也解释了为什么WHERE里不能使用SELECT中定义的别名因为别名要到SELECT阶段才产生比WHERE晚得多。理解这个顺序你会发现很多查询优化的基本套路。比如“先缩小数据集再关联”永远比“先关联再过滤”高效比如GROUP BY和ORDER BY尽量让字段顺序和索引顺序一致避免额外排序的开销比如HAVING能少用就少用能在WHERE阶段过滤掉的条件就不要拖到分组之后。这些听起来简单但在实际工作中一条慢SQL往往就是这么一点一点优化回来的。2.2 窗口函数分组内分析的利器传统SQL里要做“每个部门薪资排名”“每组分页取前N条”这类需求要么靠子查询加自连接绕来绕去要么干脆在应用层做二次处理。窗口函数出现之后这类问题有了标准解法代码简洁度和可读性都提升了一个量级。窗口函数的核心语法是OVER()子句用它来划定计算范围。看一个常见例子SELECT dept, emp_name, salary, ROW_NUMBER() OVER(PARTITION BY dept ORDER BY salary DESC) AS rn FROM employee;这段SQL按部门分组在每个部门内部按薪资降序编号。如果只要每个部门薪资最高的那位员工外层包一层查询过滤rn1就行。这个场景在“分组Top N”系列问题里属于高频也是面试里几乎必考的SQL考点。窗口函数里几个容易混淆的函数必须说清楚。ROW_NUMBER()给每个分组内的行一个连续不重复的编号遇到并列情况也会强行分出先后RANK()遇到并列时编号相同但后面的编号会跳号比如两个并列第一下一个就是第三名DENSE_RANK()处理并列时不跳号继续加一。选择哪个取决于你的业务到底需不需要跳号。窗口函数还有一个强大场景是累计计算SUM(amount) OVER(PARTITION BY user_id ORDER BY create_time)可以算每个用户的累计消费金额FOREACH行都带上截止到当前时间的汇总值这在订单分析、财务对账里非常实用。需要注意窗口函数执行时通常涉及排序如果分区字段和排序列上建有合适索引性能会明显更好这在做数据量大的报表查询时要提前考虑。2.3 数据去重的几种正确姿势“SQL语句去重”是个热搜常客其实去重的方案要根据业务目标选不是只有DISTINCT一种。最简单的场景是查一张表里有几个不同的城市直接SELECT DISTINCT city就行。但如果你要的是“每个城市取一条完整记录”DISTINCT就不够用了它只能做到去重选中列无法控制保留哪一行。这时候用GROUP BY配合聚合函数或者用窗口函数去重更合适。先看DISTINCT和GROUP BY的区别。两者在简单场景下结果可能一致但语义不同DISTINCT是“对查询结果去重”GROUP BY是“按字段分组后聚合”。比如统计每个状态的订单数量GROUP BY status COUNT(*)就是正解DISTINCT做不到。再看一个实际案例订单表里有重复记录需要删除重复项只保留每个唯一订单号中ID最小的那条。常见的做法是先查出来再删DELETE FROM orders WHERE id NOT IN ( SELECT min_id FROM ( SELECT MIN(id) AS min_id FROM orders GROUP BY order_no ) t );外层是原表内层按订单号分组取最小ID然后把不是最小ID的删掉。这种写法能解决问题但在数据量大的表上要格外谨慎先确认内层结果集大小再分批执行避免锁表时间过长。用窗口函数也能实现同样的效果SELECT * FROM ( SELECT *, ROW_NUMBER() OVER(PARTITION BY order_no ORDER BY id) AS rn FROM orders ) t WHERE rn 1;这个写法在“分组内保留一条”的场景里非常灵活配合ORDER BY条件可以控制保留哪条比如按创建时间倒序保留最近的一条。在实际项目里清洗脏数据、同步任务防重、统计报表去重这几个姿势足够覆盖绝大多数需求了。2.4 多表关联查询JOIN、子查询与EXISTS怎么选多表查询是数据查询绕不开的一环INNER JOIN、LEFT JOIN、子查询和EXISTS各有适用场景。很多人纠结哪个性能更好其实脱离数据分布谈性能都是耍流氓。一般原则是这样的JOIN适合两个表都有比较完整的数据且关联字段有索引的情况它会在内存里做匹配能用上索引就能获得不错的性能。子查询适合先算出一个小的结果集再拿这个集合去主查询里过滤特别是结果集很小的时候写起来特别清晰。举个例子查“购买了某个商品分类的用户ID”可以先子查询出该分类下的商品ID列表再INNER JOIN用户订单表。如果商品分类筛选后结果集很小这种写法简单易懂性能也不错。EXISTS和IN的取舍是一个经典话题。EXISTS用的是半连接语义只要找到一条满足条件的记录就立刻停止扫描而且和子查询关联的方式使得它往往能利用到索引IN则是先把子查询结果全部算出来再和主查询匹配当子查询结果集非常大时内存占用和扫描成本都会上升。实际经验是子查询结果集大、主表小偏向EXISTS子查询结果集小、主表大偏向IN。还有ALL和ANY这两个容易被忽略的关键字。WHERE salary ALL(SELECT salary FROM emp WHERE dept A)表示薪资大于A部门所有人的薪资等价于大于A部门的最高薪资ANY表示大于其中任意一个等价于大于最低薪资。在SQL Server、MySQL、Oracle里语法一致但这类写法在生产环境里不算高频面试题里倒是经常出现理解语义比背结论更重要。3. 慢SQL优化实战定位问题才能对症下药3.1 慢查询日志的开启与使用慢SQL优化第一步是找到那条拖后腿的查询。MySQL提供了慢查询日志默认情况下是关闭的需要在会话或配置里手动开启。SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;把阈值设成1秒意思是执行超过1秒的SQL都会被记录下来。设置完之后查询日志开关和阈值确认一下SHOW VARIABLES LIKE slow_query_log%; SHOW VARIABLES LIKE long_query_time;慢查询日志文件里会记录每条慢SQL的执行时间、锁等待时间和扫描行数拿到这些信息才能针对性优化。需要注意的是long_query_time的设置对已经建立的连接不一定立刻生效要重新建立连接再测试否则逻辑半天怀疑自己改错了。SQL Server里定位慢SQL的方式不太一样没有MySQL那么直观的日志开关通常是查询DMV动态管理视图来获取历史执行统计。比如SELECT TOP 10 total_worker_time/execution_count AS avg_worker_time, execution_count, SUBSTRING(st.text, (qs.statement_start_offset/2)1, ((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.text) ELSE qs.statement_end_offset END - qs.statement_start_offset)/2)1) AS statement_text FROM sys.dm_exec_query_stats qs CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st ORDER BY avg_worker_time DESC;这个查询能捞出一段时间内CPU消耗最高的SQL语句。SQL Server还有执行计划缓存可以通过DMV直接查看某条语句的执行计划和参数排障效率很高。3.2 读懂EXPLAIN执行计划拿到慢SQL后下一步就是看执行计划。MySQL里在SELECT语句前面加上EXPLAIN关键字即可EXPLAIN SELECT * FROM orders WHERE user_id 123;输出结果里有一堆字段真正要重点关注的是这几个type表示访问类型从好到差依次是const、eq_ref、ref、range、index、ALL。ALL代表全表扫描这是性能最差的情况看到ALL就要警觉说明这条查询没走到索引。key字段显示优化器实际选中的索引如果为NULL就说明没用到索引。rows是优化器预估的扫描行数这个数字越大越危险。Extra字段里出现Using filesort或者Using temporary时说明查询额外做了排序或者临时表操作这类操作往往就是慢的根源。比如一条实际遇到的慢查询执行计划显示typeALL、rows200万但条件列上明明有索引后来发现是条件列类型是varchar查询时却传了数字MySQL做了隐式类型转换导致索引失效。修改查询参数类型后type变成了ref扫描行数降到几十行查询时间从1.2秒降到5毫秒这就是执行计划带来的直观价值。SQL Server里看执行计划更直观图形化的执行计划可以一条条点开看每个操作符的预估成本和实际行数重点找表扫描、哈希匹配、排序这些高消耗操作。有时候一条SQL的瓶颈根本不在SQL本身而在某些统计信息过期导致优化器选了错误的执行计划这种情况更新统计信息DBCC UPDATEUSAGE或UPDATE STATISTICS后就能改善。3.3 索引失效的典型场景与应对即使索引建好了查询里有一些特殊写法也会把索引废掉导致优化器放弃使用。我梳理下平时最容易踩的几个坑每个都是实际案例堆出来的。第一个是函数包裹索引列。比如WHERE YEAR(create_time) 2024看起来没问题但实际上create_time列被YEAR()函数处理过索引无法直接使用。解决办法是改写表达式让它变成create_time BETWEEN 2024-01-01 AND 2024-12-31这样既能走索引又满足业务逻辑。第二个是隐式类型转换。varchar类型的字段查询时传数字参数某些数据库会自动转换类型导致索引失效。比如WHERE phone 13800000000phone字段是varchar类型13800000000是数字MySQL会隐式把varchar列转成数字比较索引失效。解决方案是统一用字符串传参WHERE phone 13800000000。第三个是LIKE模糊查询。WHERE name LIKE %张%前导通配符会破坏索引匹配规则没法走索引但WHERE name LIKE 张%是可以走索引的因为前缀确定。需要做包含匹配时可以评估是否需要全文索引或专门的搜索方案而不是硬扛模糊查询。第四个是OR条件。WHERE name 张三 OR age 20即使name和age上都分别有索引OR也可能让优化器选择全表扫描。改写成UNION ALL两个查询分别走各自索引再合并结果往往性能更好。最后一个容易被忽略的是联合索引的左前缀顺序问题。索引(A, B, C)建立时查询条件如果没从A开始索引就用不上这个前面已经提过。放在这里再说一次是因为线上环境里新增一个查询条件有时候就把原有索引的顺序打乱了属于典型的需求变更引发性能事故。4. 查询安全与日常工具问题排查4.1 把SQL注入挡在门外聊完性能必须聊聊安全。稍微接触过Web开发的人应该都听过SQL注入但真正理解它的人不多。SQL注入的本质是用户输入被拼接到SQL语句里改变了原本的SQL语义。举个例子有一个登录查询SELECT * FROM users WHERE name 张三 AND pwd 123456;如果代码是用字符串拼接的方式构造SQL用户输入的name是 OR 11 --拼接之后就变成了SELECT * FROM users WHERE name OR 11 -- AND pwd 123456;11恒真注释符把后面的条件全部注释掉最终这条查询返回了所有用户的数据登录验证形同虚设。防御SQL注入最核心的手段是使用参数化查询。JDBC里用PreparedStatement占位符MyBatis里用#{}而不是${}都能让数据库把参数当数据而不是SQL代码来处理。以MyBatis为例#{} 会被预编译成占位符安全。${} 会直接拼接字符串有注入风险只应在动态表名、排序字段等无法用占位符的场景使用而且必须做严格白名单校验。实际项目里还有几道保险给数据库账号最小权限只授予必要表的SELECT、INSERT、UPDATE权限禁止用root级别的账号跑应用对异常SQL做监控和告警框架层统一加防注入拦截。安全不是某一层的事而是每层的默认习惯。4.2 SQL脚本执行与导入导出的坑日常开发中经常要导入导出SQL文件比如用Navicat导入一份从服务器导出的.sql备份或者用命令行执行初始化脚本。这些操作看起来简单但坑也不少。最常见的是导入大SQL文件时中途失败或超时。MySQL里有一个max_allowed_packet参数控制单个SQL语句的最大包大小如果导出的SQL里包含大量数据的INSERT语句超过这个上限就会被拒绝执行。Navicat里可以在连接属性的高级设置中调大这个值或者用命令行直接导入mysql -u root -p -h localhost db_name backup.sql命令行导入比图形化工具稳得多尤其是几百MB级别的大文件。如果导入过程中还是超时可以检查connect_timeout和wait_timeout参数把超时时间适当调大。执行SQL脚本的时候我习惯先打开事务跑完检查无误再提交特别是批量UPDATE或DELETE之前这条习惯能救你一命。万一执行错了条件少写了一个至少还能回滚不至于直接从全表动手。SQL Server这边也容易遇到日志增长问题大批量导入数据时默认的完整恢复模式会把所有操作记入事务日志日志文件飞速膨胀。临时改成简单恢复模式或者分批提交数据能有效控制日志大小。这里说的日志就是SQL Server的Write-Ahead Logging机制事务提交前先写日志保证崩溃恢复能力但大批量操作时日志量确实是个需要提前评估的点。4.3 高频问题速查表把平时开发群里问得最多的SQL相关问题和排查思路汇总一下做成速查表方便直接查阅定位。常见现象常见原因解决思路查询走了全表扫描性能极差条件列无索引或索引失效EXPLAIN确认类型检查函数包裹、隐式转换、LIKE前导通配符SQL Server登录提示密码已过期数据库密码策略默认定期过期修改密码使账户有效或调整系统密码策略Navicat执行大SQL中途报错max_allowed_packet太小调大参数或改用命令行导入客户端复制SQL时报长度超限工具单次文本长度有限制改用.sql文件执行或命令行方式迁移工具导出的.sql文件无法导入编码或平台差异问题检查字符集设置通常UTF-8问题最多多条重复数据清理困难缺少唯一业务键先定位重复规则再按第2.3节的方法去重慢SQL日志没有任何输出参数未生效或阈值设置太大确认会话连接是否重启调低long_query_time这张表里的大多数问题第一次遇到会觉得头大但排查路线其实都非常固定先定位SQL本身再看执行计划最后检查数据库参数和工具配置。按照这个顺序走绝大多数问题都能在半小时内解决。我个人在实际操作中最深的体会是SQL性能问题很少是单一因素导致的索引设计、查询写法、参数配置三者互相影响改任何一个环节都可能牵动全局。所以遇到慢查询不要急着加索引先看执行计划理解数据库到底在想什么再动手改才是高效的路径。