资讯详情

SQL单表查询实战:条件过滤、排序分页与聚合分组详解

📅 2026/10/14 18:33:36 | 华诺云谱 👁 阅读
SQL单表查询实战:条件过滤、排序分页与聚合分组详解
SQL 基础查询这事儿看着简单但真到了写复杂业务报表、排查慢查询、或者跟别人对数据口径的时候才知道基础扎实有多重要。我见过不少人一开始 SKIP 了这部分觉得SELECT * FROM table就完事儿了结果后面遇到 NULL 值统计错了、分页排序乱了、GROUP BY 写出来的结果跟 Excel 里手工算的对不上回头再来补课反而走了不少弯路。这篇是“SQL 大师之路”系列的第五篇咱们就把SELECT查询这层窗户纸彻底捅破从最简单的单表查询讲起把条件过滤、排序、分页、聚合分组这些基本功练扎实。内容不搞花活全是能直接落地的东西适合刚入门的朋友系统过一遍也适合干了两年活但有些细节没吃透的朋友查漏补缺。1. 内容整体设计与思路拆解1.1 为什么单表查询才是真正的分水岭很多人有个误区觉得 SQL 难在 JOIN、难在子查询、难在窗口函数。但以我带人的经验来看真正的分水岭恰恰是单表查询。JOIN 写错了大不了结果集变宽但 WHERE 条件写错了那是直接拿错误数据去做决策影响的是真金白银。单表查询的核心就三件事取哪些行过滤、取哪些列投影、以什么顺序和粒度呈现排序与分组。这三件事搞明白了后面学 JOIN、学子查询本质上就是在单表查询的思维上叠加维度而已。我经常跟新人说你把单表查询练到条件反射看到任何一个查询需求能在十秒内说出该用哪几个关键字你就已经超过市面上大半的“SQL Boy”了。1.2 这套学习路径背后的逻辑我特意把内容按照“查询基本结构 → 条件过滤 → 排序分页 → 聚合分组”的顺序来安排这个顺序是有讲究的。先让你会读查询语句再让你能精确锁定数据行然后让你把结果按商业逻辑排好序、切好页最后才是把多条记录“折叠”成汇总统计。每一步都在给下一步打地基。比如没有先讲好 WHERE直接讲 GROUP BY你根本看不懂为什么WHERE必须在GROUP BY之前执行没有讲好 ORDER BY分析聚合结果时就很难快速发现数据异常。这套路径走完你至少能独立完成业务上 80% 的取数需求——报表统计、数据核对、临时分析、数据清洗前的探查全都能覆盖。1.3 准备一个可以反复折腾的实验环境学 SQL 最重要的是动手。我建议你本地装一个 MySQL 8.x 或者 PostgreSQL 15这两个是当前最主流、资料也最多的开源数据库。如果你机器上啥都没有用 Docker 起一个最快docker run --name sql-lab -e MYSQL_ROOT_PASSWORDyourpass -p 3306:3306 -d mysql:8.0后面演示我都用 MySQL 语法PostgreSQL 绝大多数场景兼容。为了让你能照着敲我建立一个专门用于练习的示例库包含一张用户表和一张订单表数据量不大但足够把基础查询的各种情况都跑一遍。下面这一节我就带你把环境准备好顺便把数据的“体感”建立起来。2. 核心细节解析与实操要点2.1 SELECT 语句的完整执行逻辑先从最容易被忽视的说起——SQL 的书写顺序和它的执行顺序完全是两回事。这是新手最容易懵的地方。你写出来的语句是这个顺序SELECT 列 FROM 表 WHERE 行过滤 GROUP BY 分组 HAVING 组过滤 ORDER BY 排序 LIMIT 分页但数据库引擎实际执行的时候顺序是这样的先定位 FROM 指定的表再通过 WHERE 把整表数据过滤一遍然后 GROUP BY 分组接着 HAVING 对分组结果做过滤之后 SELECT 计算你要的列和表达式最后 ORDER BY 排序、LIMIT 截断这个顺序搞不清楚你会踩很多看似“玄学”的坑。最典型的就是你想在 WHERE 里用 SELECT 里刚起的别名结果报错“未知列”。原因很简单——WHERE 执行的时候SELECT 还没开始算呢。这一类细节我会在后面结合例子反复提到它就是通往“大师之路”的垫脚石。2.2 WHERE 条件里的三个常见陷阱先说 NULL 值。SQL 里的 NULL 不是 0也不是空字符串它代表“未知”。你用WHERE name NULL查不到任何数据因为 NULL 和任何值比较结果都是“未知”而不是 TRUE。想查 NULL 必须用IS NULL。这个坑我在实际工作中见过太多次了——统计“未填写手机号的用户”时写WHERE phone ! 结果把phone IS NULL的用户全漏掉了数据对不上账排查了半天。再说字符串比较。MySQL 的默认排序规则下字符串比较通常不区分大小写但你要是在生产库里遇到区分大小写的排序规则WHERE name abc和WHERE name ABC查出来的可能不一样。所以我建议在代码里保持大小写一致别指望数据库帮你做转换。还有日期过滤。新手最容易写WHERE create_time 2024-01-01 AND create_time 2024-12-31但create_time是带时分秒的这样写会把 2024-12-31 当天所有记录漏掉一半。更稳妥的写法是WHERE create_time 2024-01-01 00:00:00 AND create_time 2025-01-01 00:00:00这个“左闭右开”的习惯是数据开发老手和新手的一个明显分界线。2.3 排序和分页里的性能暗坑ORDER BY 看着简单但有两个点值得注意。第一如果你排序的字段没有索引数据量大时会产生“文件排序”体现在慢查询日志里就是Using filesort。第二分页越往深处翻越慢经典的LIMIT 1000000, 20会让数据库先读出一百多万行再扔掉这是极大的浪费。解决方法是“延迟关联”或者基于上次查询的最后一条记录来做“游标分页”这一点我在第四节会用实例演示。2.4 聚合查询的执行细节GROUP BY 会把多行压成一行所以 SELECT 里能出现的列是有严格限制的。在标准 SQL 里SELECT 的列必须是分组列或者聚合函数否则语义上就不成立。MySQL 默认允许你写出“不合规”的查询它会随机取一条非分组列的值——这个特性叫ONLY_FULL_GROUP_BY没开启时的宽松模式。很多新人就在这里翻车同一个 SQL 在测试库跑出 A 结果在生产库跑出 B 结果原因就是两组数据库的这个 SQL 模式配置不一样。我的建议是把这个模式打开让数据库帮你拦下不严谨的写法SET SESSION sql_mode ONLY_FULL_GROUP_BY;看起来是给自己找麻烦实际上是在培养肌肉记忆。3. 实操过程与核心环节实现3.1 快速搭建实验数据为了让下面的演示可复现先把环境准备好。我用 MySQL 8.0 作为演示环境先创建库表CREATE DATABASE shop_demo CHARACTER SET utf8mb4; USE shop_demo; CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, age INT, city VARCHAR(50), created_at DATETIME ); CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, product_name VARCHAR(100), amount DECIMAL(10,2), order_date DATE, status TINYINT );插入一些有代表性的测试数据。这里我特意掺入了一些 NULL 和重复值方便演示各种边界情况INSERT INTO users (name, age, city, created_at) VALUES (张三, 25, 北京, 2024-01-03 10:00:00), (李四, NULL, 上海, 2024-02-15 14:30:00), (王五, 30, 北京, 2024-03-20 09:00:00), (赵六, NULL, NULL, 2024-04-10 20:15:00), (孙七, 28, 上海, 2024-05-01 08:00:00), (张三, 35, 广州, 2024-06-11 12:00:00); INSERT INTO orders (user_id, product_name, amount, order_date, status) VALUES (1, 手机, 3999.00, 2024-01-05, 1), (1, 耳机, 299.00, 2024-01-20, 1), (2, 电脑, 6999.00, 2024-02-18, 2), (3, 手机, 3999.00, 2024-03-22, 1), (4, 键盘, 199.00, 2024-04-12, 3), (5, 显示器, 1299.00, 2024-05-03, 1), (5, 鼠标, 99.00, 2024-05-10, 2), (6, 手机, 3999.00, 2024-06-15, 1);注意这里users表里我故意让“张三”出现了两次模拟现实中“同名用户”的存在李四和赵六的 age 是 NULL赵六的 city 也是 NULL。这些数据就是后续所有练习的“磨刀石”。3.2 基础查询的完整练习手册我先带你把最基本的 SELECT 过一遍。第一类全表扫描SELECT * FROM users;日常工作中我极少用SELECT *但在数据探查阶段它是了解表结构的最快路径。拿到一张陌生表先DESC table;看字段类型再SELECT * FROM table LIMIT 50;肉眼扫一遍数据分布这是标准动作。接下来是精确投影SELECT name, age, city FROM users;这一步看着简单但它是“只取所需列”的习惯起点。生产环境里大宽表动辄上百个字段全查出来不仅浪费 IO还会让网络传输变慢。能用 3 列解决的事不要 SELECT *。然后是别名SELECT name AS 用户姓名, age AS 年龄, city AS 城市 FROM users;AS是可省略的但写上可读性更好。注意中文别名虽然能用但在复杂 SQL 中会给后续解析带来麻烦项目里尽量用英文别名展示层再做映射。过滤条件这里多写几种对比着看效果-- 等值与不等值 SELECT * FROM users WHERE city 北京; SELECT * FROM users WHERE age ! 25; -- 范围 SELECT * FROM users WHERE age BETWEEN 25 AND 30; -- 集合匹配 SELECT * FROM users WHERE city IN (北京, 上海); -- 模糊匹配 SELECT * FROM users WHERE name LIKE 张%;重点看 LIKE。%匹配任意多个字符_匹配单个字符。这里有个业务上的常见诉求查“所有姓张的”。直接写LIKE 张%结果也是对的。但如果数据里有姓“章”的同音不同字就会被漏掉这是业务口径问题需要跟需求方确认清楚不是 SQL 本身能解决的。再说说模糊查询的性能问题。LIKE %张这种前置通配符写法无法利用普通索引一定会全表扫描。如果这个表有几百万行这个查询就会把数据库 CPU 打满。真要处理这种场景更合适的方案是引入全文索引或者专门的搜索引擎而不是死磕 SQL。去重是另一个高频小知识点SELECT DISTINCT city FROM users;但 DISTINCT 有它的适用边界。当你 SELECT 多列时它是对“多列的组合”去重而不是对某一列去重。这一点很多人用错。比如你想知道“一共有几个不同的城市”上面那句没问题但你要是SELECT DISTINCT name, city你得到的是“同名同城”去重后的组合不是“唯一城市列表”。业务上对不上的时候先检查这里。3.3 排序分页的实战演示排序是查询的“最后一公里”直接把结果按业务逻辑摆好。单列排序很简单SELECT name, age, city FROM users ORDER BY age DESC;多列排序才是真正体现思考深度的。比如“先按城市排再按年龄从大到小排”SELECT name, age, city FROM users ORDER BY city ASC, age DESC;这里有个细节ORDER BY可以写列名也可以写 SELECT 里列的序号。比如ORDER BY 2, 3 DESC表示按第二列排完再按第三列排。知道有这么回事就行代码里最好还是写明确的列名否则后续别人改 SELECT 列表时很容易把排序逻辑悄悄改坏。NULL 值排序是个容易忽略的坑。MySQL 默认 NULL 在升序时排在最前面降序时排在最后面。如果你想把 NULL 固定排最后在 MySQL 里得这么写SELECT name, age FROM users ORDER BY ISNULL(age), age;这个需求很常见——“把年龄未知的人放最后其余按年龄排”。分页最标准的写法SELECT name, age, city FROM users ORDER BY id LIMIT 2 OFFSET 2;LIMIT 后面是“取多少行”OFFSET 后面是“跳过多少行”。你也许见过LIMIT 2, 2的写法那只是 OFFSET 在前、LIMIT 在后的简写形式和新写法等价但可读性差一些。我建议统一用LIMIT ... OFFSET ...风格。深分页性能问题在这里给个完整解法。假设你要查第 50000 页每页 20 条最笨的写法SELECT * FROM orders ORDER BY id LIMIT 999980, 20;数据库得数出 999980 行再丢掉这个操作越到后面越慢。可以改成“先定位到起点再往后取”的延迟关联写法SELECT o.* FROM orders o JOIN (SELECT id FROM orders ORDER BY id LIMIT 999980, 20) t ON o.id t.id;子查询先只取主键因为主键走索引覆盖扫描速度快很多再用主键回表拿整行整体效率会好一个量级。在订单表这种上千万行的场景下这个优化往往能把几十秒的查询拉回几百毫秒。更好用的方案是“游标分页”-- 假设上一次取到的最大的 id 是 160 SELECT * FROM orders WHERE id 160 ORDER BY id LIMIT 20;这种方式不依赖 OFFSET每页都是从头开始走索引页数越深优势越明显。但它要求排序字段是唯一且单调的通常就是主键。如果业务上有复杂排序条件可以先按这个思路取到主键集合再二次排序实现成本也不算高。3.4 聚合与分组从“明细”到“洞察”聚合函数是基础查询里最实用的部分它把一堆行折叠成一行。我把最常用的聚合用法串一遍。统计数量SELECT COUNT(*) FROM users; SELECT COUNT(age) FROM users;这个区别值得反复强调。COUNT(*)统计的是行数不管某个字段是不是 NULLCOUNT(age)统计的是 age 字段非 NULL 的行数。在刚才的数据里COUNT(*)是 6COUNT(age)是 4因为李四和赵六的 age 是 NULL。如果你做用户数统计时用了COUNT(age)数据就会莫名其妙变少这是很多报表对不上账的根源。求和与平均SELECT SUM(amount), AVG(amount) FROM orders; SELECT AVG(age) FROM users;注意 AVG 同样会忽略 NULL 行。如果你希望把 NULL 当作 0 参与计算必须先COALESCE(age, 0)转换否则平均数的“分母”大小会跟你预期不一致。这种细节做数据审计时能救命。分组统计是聚合查询里的重头戏SELECT city, COUNT(*) AS user_count, AVG(age) AS avg_age FROM users GROUP BY city;这张表里city的分布是北京 2 人、上海 2 人、广州 1 人、NULL 1 人。GROUP BY 会把 NULL 也单独归成一个分组这一个行为在不同数据库里一致但很多新人没想到空值也会形成一组。如果想只保留人数超过 1 的城市用 HAVINGSELECT city, COUNT(*) AS user_count FROM users GROUP BY city HAVING COUNT(*) 1;HAVING 和 WHERE 的区别要刻进脑子里WHERE 是在分组前过滤原始行HAVING 是在分组后过滤分组结果。就像做菜WHERE 是“选食材”HAVING 是“选成品菜”。WHERE 能用索引加速HAVING 只能等分组算完再过滤所以能写在 WHERE 里的条件就不要放到 HAVING。结合案例看一个稍有业务感的查询——统计订单状态为已完成status 1的用户数以及每个用户的订单总额再筛选出消费超过 1000 的用户SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders WHERE status 1 GROUP BY user_id HAVING SUM(amount) 1000 ORDER BY total_amount DESC;这个查询同时用到了 WHERE、GROUP BY、HAVING、ORDER BY且顺序完全符合前面说的执行逻辑。写完之后可以自己默念一遍执行顺序先整表扫描然后筛出 status 1 的记录再按 user_id 分组算每组的订单数和总额接着过滤总额大于 1000 的分组最后按总额从高到低排序。每一步都能跟数据对上这个查询你才算真正吃透了。4. 常见问题与排查技巧实录基础查询代码量不大但实际跑起来问题一点不少。这里整理几个我工作中高频遇到的坑以及对应的排查思路。4.1 NULL 参与运算导致结果异常有一回某同事统计“订单折扣率”SQL 大致是SELECT order_id, discount / original_price AS rate FROM orders;结果出来的 rate 是空的他以为是数据有问题查了半天。真实原因很简单——有一批订单的 discount 字段是 NULL任何数值跟 NULL 做四则运算结果只能还是 NULL。数据库不会报错、不会告警它就静悄悄地给你返回一个 NULL。排查这类问题先看字段是否有 NULL用这种写法快速定位SELECT COUNT(*) AS total, COUNT(discount) AS has_discount FROM orders;如果total和has_discount数量不一致就能确认 NULL 是元凶。处理方案是明确“无折扣”的语义是当作 0 还是跳过这批订单。如果当作 0就用COALESCE(discount, 0)如果跳过就在 WHERE 里加discount IS NOT NULL。这里没有标准答案完全取决于业务口径但不管选哪个都要在代码里写出来而不是靠运气。4.2 分组后结果莫名其妙少了一部分GROUP BY 写完发现结果只显示部分分组第一反应往往是“数据丢了”。其实大概率是 HAVING 过滤掉了你不想要的分组或者 WHERE 在分组前就把数据筛掉了。排查思路是“逐步打开”——先去掉 HAVING 跑一遍再去掉 WHERE 跑一遍看结果分别在哪个环节发生了变化。这个习惯能帮你快速定位是数据源本身没有该分组还是条件误伤。另一个我常遇到的情况是“分组列没选全”。比如按city分组但个别城市的数据里存在city名不同但实际同一城市的情况比如“北京市”和“北京”GROUP BY 会把它们分成两组。这属于数据质量问题SQL 层面可以直接 CASE WHEN 做归类SELECT CASE WHEN city IN (北京, 北京市) THEN 北京 ELSE city END AS city_group, COUNT(*) FROM users GROUP BY city_group;注意这里 MySQL 允许在 GROUP BY 里用 SELECT 的别名这是它的扩展语法PostgreSQL 同样支持但某些数据库可能不认。跨数据库写代码时稳妥起见把 CASE WHEN 完整地再写一遍到 GROUP BY 里。4.3 排序结果跟预期不一致ORDER BY 排序出问题常见有三类。第一类是字符串排序的“字典序”问题比如ORDER BY amount DESC如果 amount 字段是 VARCHAR 类型那3999会排在9999后面因为字符串比较是一个字符一个字符比的——先比第一位1 比 9 小所以 199 反而在 999 前面。这不是 SQL 错了是类型选错了。修复方案是改字段类型为 DECIMAL或者在排序时做转换SELECT id, amount FROM orders ORDER BY CAST(amount AS DECIMAL(10,2)) DESC;第二类是大小写排序。不同排序规则下大写字母和小写字母的先后顺序可能不同。同样的语句在 Linux 上的 MySQL 和在 Windows 上的 MySQL跑出来的结果可能顺序不一致。排查时先用SHOW VARIABLES LIKE collation%;确认库的排序规则。第三类是“看起来没排序”。这就是典型的执行顺序问题——如果你同时用了 GROUP BY 和 ORDER BYGROUP BY 执行在 ORDER BY 之前而且当 SELECT 里既有分组列又有普通列时MySQL 可能不会按你直觉中的顺序排。稳妥做法是排序字段要么是分组列要么是聚合函数的结果例如SELECT city, COUNT(*) AS cnt FROM users GROUP BY city ORDER BY cnt DESC;这里的 ORDER BY cnt 是对聚合结果排序语义明确不会出幺蛾子。4.4 条件过滤时“该有的数据”没查出来你写了个查询凭经验“肯定有数据”结果返回空集。优先检查三件事。第一字符串里是否有不可见字符。比如从 Excel 粘贴过来的数据某些单元格可能带了换行符或者空格你WHERE name 张三查不到可能实际存的是张三 。用SELECT name, LENGTH(name) FROM users WHERE name LIKE 张%;看一眼长度就知道了。第二日期字段的格式。MySQL 中 DATE、DATETIME、TIMESTAMP 的显示格式可能不同但核心是别拿字符串去跟日期比较时用了错误的格式。最稳的写法是WHERE order_date DATE(2024-01-05)让数据库帮忙做类型转换而不是依赖隐式转换。第三个常见场景是等值匹配时字段里存在大小写或全半角差异。遇到“查不出来”的问题我的排查习惯是先不加任何 WHERE只SELECT * FROM 表 LIMIT 20;把原始数据捞出来肉眼看一遍。很多时候答案一眼就能看出来根本不用猜。4.5 深分页查询越跑越慢这个在前面讲过了但我想再从排查的角度补充一个判断技巧。看到分页查询慢先看执行计划EXPLAIN SELECT * FROM orders ORDER BY id LIMIT 999980, 20;如果type列是ALL或者Extra列出现Using filesort说明这条查询没办法有效利用索引。解决办法就是我前面说的延迟关联或游标分页。日常业务里如果产品非要支持“跳转到第 N 页”建议对页码做上限限制比如最多翻到 1000 页防止有人恶意刷全表。实际上绝大多数用户最多翻到几十页就不会再往下了设计上完全可以限制死。4.6 到底什么时候该用 DISTINCT什么时候该用 GROUP BY这两个功能在去重场景下很容易混。简单说DISTINCT 是“对结果集去重”GROUP BY 是“按列分组并聚合”。如果你只想看“有哪些城市”用SELECT DISTINCT city FROM users;更直观。如果你想看“每个城市有多少人”必须用 GROUP BY。如果你“去重之后还要配对统计”GROUP BY 几乎是唯一选择。另一个建议是不要轻易在SELECT DISTINCT *这种写法上偷懒。它去重的是整行一旦表结构新增字段去重逻辑就悄悄变了。代码里明确写出要 DISTINCT 的列既是对自己负责也是给接手的人减少困惑。5. 进一步扩展的方向基础查询练完之后下一步我建议按这个顺序往下走先学会多表 JOIN把两张表通过主外键关联起来这是所有业务系统的核心数据模型然后再学子查询理解 EXISTS 和 IN 的差异接着是窗口函数它能在不折叠行的前提下做排名、累计、同比环比是数据分析场景的利器。在学习方法上我给你一个自己一直在用的习惯每学一个语法不要只背例子要把“执行顺序”在脑子里过一遍然后用EXPLAIN验证一次执行计划。SQL 是一门实践性极强的语言眼过千遍不如手过一遍。你把这篇文章里的每条 SQL 亲手敲一遍再改改 WHERE、换换 GROUP BY 的列很快就能找到手感。最后分享一条我踩过很多次坑之后才悟出来的经验写 SQL 之前先花一分钟问清楚业务口径——NULL 怎么算、重复数据去不去、时间范围包不包含边界。口径定清楚了SQL 写起来就很顺口径模糊越写越乱最后还得返工。这不是技巧问题是工作习惯问题但恰恰是这种习惯决定了你能不能从“会写 SQL”进阶到“写得一手好 SQL”。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑