资讯动态

SQL查询语句实战指南:从SELECT基础到性能优化

发布时间:2026/10/22 5:25:47 来源:尧图企业网站定制
直接上结论这是我在后台开发和数据分析工作中每天都在用的SQL查询语句从基础到进阶从简单查询到复杂关联统计全部拆开揉碎讲清楚。不管你是刚接触数据库的新手还是写了几年SQL但总觉得细节不牢的老手这篇文章就是帮你把常用的查询语句一次性理清。正文里面的例子我尽量用了贴近业务的场景比如订单、用户、商品这些最常见的表结构每个SQL你都可以直接抄去改表名和字段用。涉及函数的部分会标注不同数据库的语法差异免得你从MySQL换到SQL Server时踩坑。1. 查询语句的核心SELECT的完整骨架1.1 完整查询语句的执行顺序很多初学者刚学SQL时觉得SELECT就是“查一下”其实一条完整的SQL从写法到执行顺序是有严格逻辑的。我见过太多人把WHERE和HAVING混用、把ORDER BY写在LIMIT后面结果排序错乱。先把这个骨架记住后面所有内容都是在往里面填东西。SELECT DISTINCT 列名列表 FROM 表名 JOIN 另一张表 ON 关联条件 WHERE 过滤条件 GROUP BY 分组字段 HAVING 分组后的过滤条件 ORDER BY 排序字段 LIMIT 行数限制这里最关键的是执行顺序和书写顺序不一样。SQL引擎实际上先做FROM和JOIN把表关联起来然后走WHERE逐行过滤接着GROUP BY分组之后HAVING过滤分组再SELECT投影出列最后才是ORDER BY排序和LIMIT截断。我以前排查一个慢查询反复看WHERE条件没问题后来发现是JOIN把数据量放大得太厉害才知道FROM阶段才是性能瓶颈。日常写查询时只要记住这个口诀先定表再过滤再分组再排序最后限量。1.2 SELECT子句的细节别用星号的坑SELECT *是新手最喜欢写的我也写过但后来在真实项目里吃了亏。有一次只是想把一个表的数据导出来看一看结果表有60多列里面还带着几个TEXT大字段查询直接把数据库IO拖满了。从那以后我定了规矩生产环境查询一律显式列出字段名。显式列名有几个实实在在的好处只读取需要的列IO和内存开销明显降低如果表结构调整SQL不会因为字段缺失而暴露出莫名其妙的问题结果集结构清晰配合ORM映射时不容易出错做UNION等集合操作时列数量和类型必须对齐显式列名方便检查如果你真的想快速了解表的字段结构可以查系统表或者用DESCRIBE命令查看而不是直接SELECT *。1.3 给列起别名的经验别名用AS关键字也可以省略AS直接空格加别名。我习惯写AS因为可读性更好。别名的使用有几个技巧多表关联时同名字段必须用别名区分聚合函数列、计算列必须有别名中文别名在MySQL里可以直接用反引号包裹但在SQL Server里要用中括号SELECT u.nick_name AS 用户昵称, COUNT(o.order_id) AS 订单总数, SUM(o.pay_amount) AS 总消费金额 FROM user u LEFT JOIN orders o ON u.user_id o.user_id GROUP BY u.user_id, u.nick_name;2. WHERE过滤把数据范围锁死2.1 条件运算符的完整对照WHERE子句是查询里用得最多的过滤手段。常用的运算符包括比较运算符、逻辑运算符、范围判断、集合判断、模糊匹配和空值判断。我整理了一个速查表平时写SQL拿不准时可以对照着看运算符含义示例等于WHERE age 18 或 !不等于WHERE status 1 大小比较WHERE price 100BETWEEN ... AND ...闭区间范围WHERE create_time BETWEEN 2024-01-01 AND 2024-01-31IN ()匹配集合内任意值WHERE city_id IN (1, 2, 3)LIKE模糊匹配WHERE nickname LIKE 张%IS NULL判断空值WHERE email IS NULLAND / OR逻辑与 / 或WHERE status 1 AND amount 10NOT取反WHERE NOT status 02.2 LIKE模糊查询的三种通配符模糊查询是排查数据时特别常用的一种方式比如按姓名搜用户、按标题搜文章。通配符有三种%匹配任意长度任意字符包括0个字符。比如LIKE 张%能匹配“张三”也能匹配“张”本身LIKE %张%匹配所有名字中带“张”的_匹配单个任意字符。比如LIKE 张_匹配“张三”但匹配不了“张明伟”[]匹配方括号内任意单个字符只在部分数据库SQL Server里支持。MySQL不支持[]要用了可以用正则函数REGEXP代替重要提醒LIKE查询如果通配符在开头即%关键字这种写法索引基本失效。举个例子搜索一个用户名字段上建的索引WHERE name LIKE 张%还能用索引但WHERE name LIKE %张%就老老实实全表扫描了。数据量小无所谓数据量大了就是几秒和几毫秒的区别。2.3 NULL值处理IS NULL和空字符串是两码事这是极其容易踩坑的一个点。在SQL里NULL代表“未定义”或“未知”它不等于任何值包括它自己。所以判断NULL必须用IS NULL不能用 NULL后者永远不会返回任何行。更隐蔽的问题是NULL与空字符串的区分。比如用户输入了手机号又清空有的接口存NULL有的存这会导致WHERE phone 查不到NULL的行WHERE phone IS NULL查不到空字符串的行。我的处理习惯是在设计表结构时明确规范每个字段都要能预期它处于“没有值”时的存储方式。如果历史数据已经乱了查询时就把两种情况都考虑进去-- 查询手机号未填写的用户兼容NULL和空字符串的写法 SELECT user_id, nick_name FROM user WHERE phone IS NULL OR phone ;2.4 用IN还是用OR遇到要匹配N个值的情况用IN比一串OR条件清晰得多而且性能也更好。比如-- 推荐写法 WHERE status IN (1, 3, 5) -- 不推荐写法 WHERE status 1 OR status 3 OR status 5当IN后面跟的是子查询时如果子查询结果集很大比如超过1000条有些数据库对IN列表数量有限制或者性能下降明显。这时候可以换成JOIN方式去关联后面章节会讲到。3. 排序和限量结果集的控制技巧3.1 ORDER BY多字段排序的优先级排序看起来简单但多字段排序很多人写错了方向。ORDER BY 字段1, 字段2的含义是先按字段1排如果字段1的值相同再按字段2排。这里最容易出错的场景是字段1倒序、字段2正序的混合需求。-- 按金额从大到小排金额相同则按时间从旧到新排 SELECT order_id, user_id, amount FROM orders ORDER BY amount DESC, create_time ASC;注意DESC和ASC分别作用于自己的字段而不是全局。我见过同事写成ORDER BY amount, create_time DESC结果金额是升序时间才是降序完全反了。3.2 TOP N和分页查询不同数据库对限量查询的语法不一样这个要分清数据库语法MySQLLIMIT offset, count 或 LIMIT count OFFSET offsetPostgreSQLLIMIT count OFFSET offsetSQL ServerSELECT TOP N 字段列表OracleFETCH FIRST N ROWS ONLY 或 ROWNUM N分页查询的写法以MySQL为例-- 取第1页每页20条 SELECT * FROM orders ORDER BY create_time DESC LIMIT 0, 20; -- 取第3页每页20条偏移量是 (3-1)*20 40 SELECT * FROM orders ORDER BY create_time DESC LIMIT 40, 20;这里有一个深坑分页查询必须配合ORDER BY。不写ORDER BY时数据库返回的顺序是不确定的每次翻页可能看到重复数据。我排查过线上问题就是有个报表分页没排序用户翻几页下来订单数都对不上。另外MySQL的LIMIT在大偏移量下性能非常差比如LIMIT 100000, 20它需要先读取100020行再丢掉前100000行。数据量大的场景更优的方案是用游标分页或基于主键定位-- 游标式分页记住上一页最后一条记录的ID SELECT * FROM orders WHERE create_time 2024-03-01 10:00:00 ORDER BY create_time DESC LIMIT 20;3.3 DISTINCT去重的正确姿势DISTINCT会对结果集所有列组合去重。它适合快速看有哪些不同的值但不适合在复杂查询里用来“去重留一条”。比如想查询每个用户最新一条订单用DISTINCT根本实现不了因为DISTINCT无法指定保留哪一行。-- 查看订单表里一共有几个支付渠道 SELECT DISTINCT pay_channel FROM orders; -- 这种写法查的是 支付渠道订单状态 的组合去重 SELECT DISTINCT pay_channel, order_status FROM orders;当DISTINCT和聚合函数一起用时语义有细微差别这个放到聚合统计部分详细讲。4. 聚合统计GROUP BY与HAVING的完整使用4.1 五个聚合函数聚合函数把多行数据汇总成一行是报表统计的基石。常用的有五个函数含义注意事项COUNT(*)统计行数包含所有行不论NULLCOUNT(字段)统计该字段非NULL的行数NULL不计入SUM(字段)求和NULL值自动忽略结果仍为数字AVG(字段)求平均值NULL不计入分母MAX/MIN最大值/最小值数值、字符串、日期都可以比较关于COUNT的误区我看到很多人在统计行数时纠结用COUNT(1)还是COUNT(*)其实两者在主流数据库里执行计划几乎一样。真正要区分的是COUNT(*)和COUNT(具体字段)前者的统计口径是行数后者的统计口径是该字段非NULL的数量。4.2 GROUP BY的分组逻辑GROUP BY把相同值的行归为一组每组输出一行。这是个很形象的逻辑列相同放一组每一组算一个统计结果。实践中有一个反复出现的需求按天、按月、按年统计。在MySQL里可以通过DATE_FORMAT函数实现-- 按自然月统计订单量和总金额 SELECT DATE_FORMAT(create_time, %Y-%m) AS month, COUNT(order_id) AS order_count, SUM(pay_amount) AS total_amount FROM orders WHERE create_time 2024-01-01 GROUP BY DATE_FORMAT(create_time, %Y-%m) ORDER BY month;需要注意SELECT里出现的非聚合列原则上必须都出现在GROUP BY里。MySQL默认的ONLY_FULL_GROUP_BY模式如果没开启查询不会报错但结果可能毫无意义。我在某公司的分析库里碰到过这种历史遗留问题很多SQL靠这种“宽松模式”跑出了错误结果后来却没人发现。4.3 WHERE和HAVING的分工很多人在这个点上搞混。一句话区分WHERE在分组之前过滤行作用于每一行原始数据HAVING在分组之后过滤组作用于每组聚合后的结果所以同一条件放哪里语义完全不一样。比如-- 查询金额超过100元的订单再按用户分组统计 -- 这里先用WHERE过滤订单只统计大额订单数 SELECT user_id, COUNT(*) FROM orders WHERE amount 100 GROUP BY user_id; -- 查询总消费金额超过1000元的用户 -- 这里的1000是用户维度的汇总结果只能用HAVING SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id HAVING SUM(amount) 1000;两个SQL看起来都有100和1000这样的数字但一个是行级过滤一个是组级过滤。如果非要用WHERE实现第二个需求没有太好的办法必须派生表或子查询HAVING就是为此而生的。4.4 一个完整的统计案例把前面的知识点串起来写一个贴近实际业务的例子统计2024年第一季度每个支付渠道下消费超过500元的用户数量。SELECT pay_channel, COUNT(DISTINCT user_id) AS active_user_count FROM orders WHERE create_time BETWEEN 2024-01-01 AND 2024-03-31 AND order_status paid GROUP BY pay_channel HAVING COUNT(DISTINCT user_id) 500;这里COUNT(DISTINCT user_id)表示对组内user_id去重后计数避免同一用户下单多次被重复统计。这是一个很常见的需求也是DISTINCT与聚合函数结合的正确场景。5. JOIN多表查询真正体现SQL价值的地方5.1 各种JOIN类型一网打尽单表查询只是基本功实际业务里数据一定分散在多张表中。用户表、订单表、商品表、物流表、评价表……要拿出有意义的信息必须把表关联起来。JOIN的核心逻辑是指定两张表之间的关联条件让一行数据根据条件匹配到另一张表的行。根据匹配方式不同分为几类JOIN类型结果集包含的内容INNER JOIN两张表中都能匹配上的行LEFT JOIN左表全部行 右表匹配行右表无匹配则为NULLRIGHT JOIN右表全部行 左表匹配行左表无匹配则为NULLFULL OUTER JOIN两张表的全部行无匹配处为NULLMySQL原生不直接支持FULL OUTER JOIN需要UNION实现。下面逐个说重点。5.2 INNER JOIN交集查询INNER JOIN是日常最常用的JOIN方式。它只返回两张表中都满足条件的记录。-- 查询有订单记录的用户及其订单信息 SELECT u.user_id, u.nick_name, o.order_id, o.pay_amount FROM user u INNER JOIN orders o ON u.user_id o.user_id ORDER BY o.create_time DESC;这里的INNER可以省略直接写JOIN默认就是内连接。但为了代码可读性我建议显式写INNER。5.3 LEFT JOIN左表为基准LEFT JOIN是另一种高频用法。它返回左表的全部数据右表匹配得上的列补全匹配不上的列是NULL。什么场景用LEFT JOIN最常见的是“某表的数据要全展示另一张表只是补充信息”。例如用户列表要显示每个用户最近的订单时间没有订单的用户也要展示SELECT u.user_id, u.nick_name, MAX(o.create_time) AS last_order_time FROM user u LEFT JOIN orders o ON u.user_id o.user_id GROUP BY u.user_id, u.nick_name ORDER BY last_order_time DESC;实战提醒LEFT JOIN的WHERE条件一定要谨慎。如果写了WHERE o.order_status paid这个条件会把右表没匹配上的NULL行过滤掉LEFT JOIN就变成了INNER JOIN这是我在代码审查里最常抓到的问题。如果需要对右表的字段做条件限制条件放到ON子句里-- 保留所有用户只看每个用户已支付订单的数量 SELECT u.user_id, u.nick_name, COUNT(o.order_id) AS paid_order_count FROM user u LEFT JOIN orders o ON u.user_id o.user_id AND o.order_status paid GROUP BY u.user_id, u.nick_name;5.4 自连接与自身关联自连接经常让人头皮发麻但它威力巨大。它本质上是同一张表当两张表用复制一份再按关联条件匹配。常见的场景包括员工表里查上下级关系、分类表里查父子分类、连续登录天数统计等。举个经典的例子员工表里有emp_id和manager_id该员工的上级查每个员工的姓名及其上级姓名SELECT e.emp_name AS 员工姓名, m.emp_name AS 上级姓名 FROM employee e LEFT JOIN employee m ON e.manager_id m.emp_id;自连接的关键是给同一张表起不同别名加以区分。管理不规范的表可能没有唯一的上级关联ID此时需要用其他业务维度来关联例如按时间区间判断。5.5 多表JOIN的顺序建议三张以上表关联时JOIN顺序会影响性能但一般情况下的最优策略是先通过过滤条件缩小数据量再关联小表到大表。比如-- 查询近30天已支付订单对应的用户昵称和商品名称 SELECT u.nick_name, g.goods_name, o.pay_amount FROM orders o JOIN user u ON o.user_id u.user_id JOIN goods g ON o.goods_id g.goods_id WHERE o.create_time DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND o.order_status paid;这里的JOIN顺序是orders先关联user再关联goods但实际的索引利用才是性能关键。JOIN的关联字段是否有索引对查询性能影响巨大没有索引的JOIN在大表场景下会直接拖垮数据库。这个在后面的优化章节展开。6. 子查询与派生表6.1 三种子查询类型子查询就是嵌套在SQL内部查询按返回值不同划分成三类标量子查询返回单个值一个数字或一个日期。常用在SELECT列或WHERE条件里列子查询返回一列多行。常用在IN关键词后面表子查询返回多行多列即一张临时表。常用在FROM后面作为派生表看三个例子就明白了-- 标量子查询查询商品价格高于平均价格的商品 SELECT goods_name, price FROM goods WHERE price (SELECT AVG(price) FROM goods); -- 列子查询查询下过订单的用户 SELECT user_id, nick_name FROM user WHERE user_id IN (SELECT DISTINCT user_id FROM orders); -- 表子查询按用户分组统计后再过滤 SELECT user_id, total_amount FROM ( SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ) AS t WHERE total_amount 1000;6.2 IN子查询与EXISTS的取舍在“查询A表中与B表有某种关系的记录”时可以用IN也可以EXISTS。很多人纠结它们等价吗一个经验性原则当子查询结果集非常小或者外层表非常大而子查询表也有索引时IN效果尚可当子查询表数据量非常大且外层表每行都需要判断时EXISTS往往更优。因为EXISTS是逐行判断是否存在一旦找到就终止而IN需要先物化完整子查询结果集。不过现在的数据库优化器重写能力比较强很多场景下两者执行计划会被优化成一样。我的建议是写哪个可读性好就先写哪个遇到慢SQL再通过执行计划调整。6.3 派生表与CTE的现代写法传统写法中子查询放在FROM后面被称作派生表。但嵌套多层后用起来很痛苦一段SQL里全是括号和别名容易看花眼。现代SQL提供了WITH子句也叫CTE公共表表达式把每段子查询独立命名代码清晰度大幅提升。WITH monthly_stats AS ( SELECT user_id, DATE_FORMAT(create_time, %Y-%m) AS month, SUM(amount) AS month_total FROM orders GROUP BY user_id, DATE_FORMAT(create_time, %Y-%m) ), user_rank AS ( SELECT user_id, month, month_total, ROW_NUMBER() OVER (PARTITION BY month ORDER BY month_total DESC) AS rn FROM monthly_stats ) SELECT user_id, month, month_total FROM user_rank WHERE rn 3;这个SQL里第一个CTE算每个用户每个月消费额第二个CTE在每月里按消费额排名最后取每月前三名。如果全塞进一个SQL里阅读难度会指数上升用CTE逐步拆解后一目了然。强烈建议日常写复杂报表时多使用CTE。7. 窗口函数数据分析的利器7.1 窗口函数和GROUP BY的区别窗口函数是处理“每条记录保留自身信息同时又能看到统计汇总”这种需求的最好工具。它和GROUP BY最大的区别是GROUP BY会把多行压成一行丢失明细窗口函数不会合并行而是在保留所有行的基础上额外生成一列统计结果。-- 查询每个订单并附带该用户所有订单的总金额 SELECT order_id, user_id, amount, SUM(amount) OVER (PARTITION BY user_id) AS user_total_amount FROM orders;PARTITION BY在这里相当于分组的作用但它不压缩行数。每行都能拿到所属用户的总金额作为参考列。7.2 排名三兄弟排名是窗口函数最典型的使用场景三个函数看起来很像但细节不一样函数排名逻辑示例分数100,99,99,98ROW_NUMBER()顺序编号相同值随机排编号不重复1,2,3,4RANK()相同值排名相同下一位跳跃1,2,2,4DENSE_RANK()相同值排名相同下一位连续1,2,2,3需求是“每个类别里查前几名”三个函数都可以通过PARTITION BY ORDER BY完成-- 每个商品分类下价格最高的3个商品 SELECT goods_id, goods_name, category_id, price FROM ( SELECT goods_id, goods_name, category_id, price, ROW_NUMBER() OVER ( PARTITION BY category_id ORDER BY price DESC ) AS rn FROM goods ) AS t WHERE rn 3;7.3 移动汇总与前后值对比窗口函数的聚合能力还体现在移动计算上。比如计算每个用户最近30天的累计消费变化或者对比上一笔订单的时间间隔-- 获取每个订单与其上一笔订单的下单时间间隔分钟 SELECT order_id, user_id, create_time, LAG(create_time) OVER (PARTITION BY user_id ORDER BY create_time) AS prev_create_time, TIMESTAMPDIFF(MINUTE, LAG(create_time) OVER (PARTITION BY user_id ORDER BY create_time), create_time) AS interval_minutes FROM orders;LAG函数取出前一条记录的值LEAD函数取出后一条记录的值。这在分析用户行为路径、判断连续登录场景中非常实用比自连接写法简洁得多。8. CASE WHEN与常用函数8.1 CASE WHEN的两种写法CASE WHEN本质是条件表达式用于把逻辑判断放进查询里根据条件返回不同值。写法有两种-- 简单CASE表达式等值判断 SELECT order_id, CASE pay_channel WHEN 1 THEN 微信支付 WHEN 2 THEN 支付宝 WHEN 3 THEN 银行卡 ELSE 其他 END AS pay_channel_name FROM orders; -- 搜索CASE表达式支持范围判断 SELECT order_id, amount, CASE WHEN amount 100 THEN 小额 WHEN amount 500 THEN 中额 WHEN amount 1000 THEN 大额 ELSE 超大额 END AS amount_level FROM orders;简单CASE写法更紧凑但只能做等值判断。搜索CASE写法更通用范围、多条件组合都能处理实际使用频率更高。8.2 用CASE WHEN做行转列CASE WHEN一个很实用的场景是做“行转列”把一列中的多个值变成多列。比如统计各业务线的订单量业务线是一个维度列想让它变成几个独立的列SELECT DATE_FORMAT(create_time, %Y-%m) AS month, SUM(CASE WHEN business_line 手机数码 THEN amount ELSE 0 END) AS phone_amount, SUM(CASE WHEN business_line 家用电器 THEN amount ELSE 0 END) AS appliance_amount, SUM(CASE WHEN business_line 服装鞋包 THEN amount ELSE 0 END) AS fashion_amount FROM orders GROUP BY DATE_FORMAT(create_time, %Y-%m);这种写法在报表统计里特别常见不少BI工具底层也是这么干的。8.3 常用函数速查不同数据库的函数名差异很大这里以MySQL为主同时标注通用部分。字符串函数CONCAT(a, -, b) -- 拼接字符串结果为 a-b LENGTH(abc) -- 返回字节长度 CHAR_LENGTH(abc) -- 返回字符个数 LEFT(name, 3) -- 取左边3个字符 RIGHT(name, 3) -- 取右边3个字符 SUBSTRING(name, 2, 3) -- 从第2个字符开始取3个字符 REPLACE(abc, b, x) -- 替换结果为 axc UPPER(name) / LOWER(name) -- 大小写转换日期函数NOW() -- 当前日期时间 CURDATE() -- 当前日期 CURTIME() -- 当前时间 DATE(create_time) -- 从datetime中提取日期部分 DATE_FORMAT(create_time, %Y-%m-%d) -- 格式化日期 DATEDIFF(2024-03-01, 2024-02-01) -- 日期差值结果为29 DATE_ADD(2024-01-01, INTERVAL 1 MONTH) -- 日期加1个月条件与转换函数IF(条件, 值1, 值2) -- 条件成立返回值1否则返回值2 IFNULL(字段, 默认值) -- 字段为NULL时返回默认值 COALESCE(字段1, 字段2, 默认值) -- 返回第一个非NULL值 CAST(amount AS DECIMAL(10,2)) -- 类型转换排查数据时一定要小心日期格式字符串。MySQL里%Y-%m-%d和%y-%m-%d是有区别的后者年份只有两位统计跨世纪数据时会出错。我见过有人把2025年统计成25年最后聚合结果直接错了。9. 查询性能优化让SQL跑得更快9.1 EXPLAIN解读定位慢查询的第一步写SQL不能只看结果对不对还要关心性能。当一个查询慢得无法接受时第一步永远是查看执行计划。MySQL用EXPLAINPostgreSQL用EXPLAIN ANALYZESQL Server用SET STATISTICS PROFILE ON。以MySQL为例EXPLAIN SELECT u.nick_name, o.pay_amount FROM orders o JOIN user u ON o.user_id u.user_id WHERE o.create_time 2024-01-01;重点关注几个字段type访问类型。从好到差依次是system、const、eq_ref、ref、range、index、ALL。看到ALL就说明全表扫描需要警惕key实际使用的索引rows预估扫描的行数ExtraUsing filesort、Using temporary通常意味着查询排序或分组没有走索引存在额外开销9.2 索引使用的几个原则走索引的查询和全表扫描的性能差距可能达到成百上千倍。索引设计是个大话题但日常优化查询能立刻落地的原则有这些WHERE条件里的列优先建索引ORDER BY和GROUP BY的列尽量和索引顺序一致多条件查询时考虑联合索引注意最左前缀原则JOIN的关联字段两端尽量都建索引类型必须一致举个例子WHERE category_id 5 AND price 100如果建了(category_id, price)联合索引过滤效率最高如果单独建category_id索引price还得回表过滤。9.3 三个一定要避免的写法有些写法会导致索引失效查询变慢属于长期踩坑的重灾区函数包裹列WHERE条件中左列被函数包裹后索引失效。比如WHERE DATE(create_time) 2024-01-01全表扫描的可能性很大。正确写法是写范围条件WHERE create_time 2024-01-01 AND create_time 2024-01-02。前缀模糊匹配LIKE %关键字通配符在开头时索引失效前面已经提到。隐式类型转换字段是字符串类型查询条件用数字或者字段是数字类型查询条件用字符串。数据库可能会放弃索引甚至得到错误结果。-- 假如user_id是字符串类型以下写法容易导致索引失效 WHERE user_id 1001; -- 正确写法 WHERE user_id 1001;9.4 分页深度偏移的优化前面的章节提到过大偏移量的LIMIT问题在生产环境中这是一个常见的性能杀手。解决思路有三种记住上一页的最后一条主键用主键定位翻页延迟关联先查询出主键ID再用主键ID关联回原表拿完整行在业务上限制最大可翻页数延迟关联写法示例-- 先只查符合条件的ID再关联回明细 SELECT o.* FROM orders o INNER JOIN ( SELECT order_id FROM orders WHERE create_time 2024-01-01 ORDER BY create_time DESC LIMIT 100000, 20 ) tmp ON o.order_id tmp.order_id;这种方式让最耗时的排序和偏移只发生在一张主键表上数据小、有索引再回表取明细性能可以提升数倍。10. 高频实战场景与完整案例10.1 场景一新用户首单统计需求统计2024年用户的注册时间、首单时间、首单金额。用窗口函数可以非常优雅地完成WITH first_order AS ( SELECT user_id, order_id, pay_amount, create_time, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY create_time ASC) AS rn FROM orders WHERE order_status paid ) SELECT u.user_id, u.register_time, f.create_time AS first_order_time, f.pay_amount AS first_order_amount FROM user u LEFT JOIN first_order f ON u.user_id f.user_id AND f.rn 1 WHERE u.register_time BETWEEN 2024-01-01 AND 2024-12-31;这里LEFT JOIN里带f.rn 1很关键它能在关联阶段直接过滤掉非首单记录同时保留没有下过单的用户。10.2 场景二留存率计算需求统计2024年1月新增用户在其后第1个月、第2个月的留存数量。计算留存率本身可以拆解为两个独立统计再用JOIN拼起来WITH jan_users AS ( SELECT user_id, DATE_FORMAT(register_time, %Y-%m) AS register_month FROM user WHERE register_time 2024-01-01 AND register_time 2024-02-01 ), monthly_activity AS ( SELECT user_id, DATE_FORMAT(create_time, %Y-%m) AS active_month FROM orders WHERE create_time 2024-02-01 ) SELECT j.register_month, COUNT(DISTINCT j.user_id) AS base_users, COUNT(DISTINCT CASE WHEN a.active_month 2024-02 THEN j.user_id END) AS retain_1m, COUNT(DISTINCT CASE WHEN a.active_month 2024-03 THEN j.user_id END) AS retain_2m FROM jan_users j LEFT JOIN monthly_activity a ON j.user_id a.user_id GROUP BY j.register_month;这种SQL在用户运营部门非常常见核心思想是“先圈出新用户集合再判断他们在后续时间段是否有活跃行为”。10.3 场景三订单数据质量检查查询中的自查逻辑也经常用。比如排查金额异常过高、状态为已支付但没有支付时间等畸形数据-- 排查状态与时间矛盾的脏数据 SELECT order_id, user_id, order_status, create_time, pay_time FROM orders WHERE order_status paid AND pay_time IS NULL; -- 排查同一用户在同一秒内产生多笔订单的异常情况 SELECT user_id, create_time, COUNT(*) AS cnt FROM orders GROUP BY user_id, create_time HAVING COUNT(*) 1;这就是我日常排查数据的标准动作先把异常特征写成查询条件快速定位问题行再去查源头逻辑。11. 常见问题与避坑指南这一节整理了我在实际工作中最常遇到的SQL查询问题写成速查表方便在有疑问时直接对照。问题表现根因解决方案查询结果包含重复行多表JOIN造成笛卡尔积放大检查关联条件是否完整是否漏了JOIN条件LEFT JOIN后行数变少WHERE误过滤右表字段右表条件移入ON子句COUNT统计数值不对误用COUNT(字段)与COUNT(*)明确统计口径NULL是否有意义分页出现重复或跳跃没有ORDER BY稳定排序补充分页排序字段查询特别慢未建索引 / 索引失效 / 大偏移分页EXPLAIN分析执行计划日期范围查询部分数据丢失边界条件使用不当用[起始, 下一个起始)左闭右开写法大小写导致匹配不到数据库排序规则区分大小写统一使用LOWER/COLLATE控制子查询性能极差依赖优化器重写但大表场景下没优化好改写为JOIN或CTE再补充两条我在审查别人SQL时见到最多的问题问题一N1查询没有合并成JOIN。很多初级开发者会在循环里逐条执行SQL查询比如先查出100个用户ID再在程序里循环100次查订单表。这种做法在数据量小时没感觉但一旦用户量上百上千数据库压力会瞬间上去。正确做法是用一条IN查询或JOIN把数据全部取出。问题二WHERE条件顺序影响“性能”。虽然数据库优化器会自动调整执行顺序但把过滤效果最强的条件放在前面仍然是好习惯尤其在复杂视图中读取明确、维护清晰出了问题也容易排查。还有一点关于NULL的运算陷阱。NULL参与四则运算时结果依旧是NULL。比如算amount * discount如果discount字段有的是NULL结果就是NULL不是0。处理这类问题要养成习惯先IFNULL兜底SELECT order_id, amount * IFNULL(discount, 1) AS final_amount FROM orders;这个坑在财务核算里特别致命我经手过一个报表就是因为折扣率字段里有NULL值最终金额一片空害得业务部门以为是系统故障。最后SQL查询的进阶方向可以往后探索索引设计与执行计划深挖、存储过程与触发器的业务封装、时序数据库和列式存储的差异等方向。但对于日常开发、数据分析来说把本文这些基础活用熟已经能覆盖绝大多数的真实需求。真想练好除了多用没有捷径。把自己手上最常跑的报表SQL拿出来逐条用EXPLAIN过一遍看看扫描行数能不能再降逐步优化起来比看多少文章都有用。

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

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

免费获取报价 →
↑