有一次面试,我问一个自称做过两年业务开发的候选人:一条 SELECT 查询,你通常先写哪部分?他不假思索地说:先写 SELECT,再写 FROM,然后根据报错修修补补。这个答案很诚实,因为很多人都是这么过来的。真正让我想把 SELECT 讲透的,不是这句回答,而是他后来反问的一句:SELECT 不就是从表里拿数据吗,还有什么可学的?
SELECT 虽然只有六个字母,但它背后是 SQL 语言最核心的一种能力:把无序的数据变成你想要的信息集合。用它查一条用户记录是 SELECT,用它跑一张上亿行的报表也是 SELECT。有人在 SELECT 上经常写出慢查询,有人靠 SELECT 在面试里聊出深度。所以我把这些年写查询积累的东西整理出来,不打算照着官方文档念语法,而是把 SELECT 当成一把钥匙,从执行顺序、组合逻辑、窗口函数、慢查询排查,一直聊到周边那些容易翻车的“同名兄弟”。不管你是刚接触数据库,还是写过几年 SQL 但总觉得差点意思,这篇内容应该都能给你一点能直接上手的收获。
1. 别被SELECT骗了:它的真正执行顺序和你写的不一样
很多教程讲 SELECT 都会先给语法结构,select、from、where、group by、having、order by。这没问题,但它只是“书写顺序”。数据库真正执行时,并不是从左到右读你这句话的。
1.1 一条普通查询在数据库内部是怎么“按顺序”跑的
先看一条最简单的工资查询:
sql复制SELECT
emp_id,
salary * 12 AS annual_salary
FROM employee
WHERE department_id = 3
ORDER BY annual_salary DESC;
很多初学者在这里会疑惑:明明 SELECT 在最前面,为什么不能在 WHERE 里用别名 annual_salary?
看执行顺序就明白了。逻辑上的执行顺序大致是:
- 先到 FROM,确定要处理哪张表。
- 然后走 WHERE,过滤行。
- 接着做 GROUP BY 和 HAVING,做分组和分组后的过滤。
- 到了 SELECT,才开始计算结果列,这时候 annual_salary 这个别名才产生。
- 最后才执行 ORDER BY,所以 ORDER BY 里能用 SELECT 里起的别名。
也就是说,WHERE 发生在 SELECT 之前,它根本不认识 annual_salary 这个别名。这不是数据库故意和你作对,而是 SQL 的语义顺序天然如此。
你需要特别小心的是,很多数据库会在内部做执行计划优化,实际的物理执行顺序可能看起来不同,但逻辑语义仍然要遵守上面的顺序。我们写 SQL 时,把这条逻辑顺序放在脑子里,很多看着莫名其妙的报错都能想通。
1.2 WHERE和HAVING的分工,是我见过最多的理解偏差
还有一个高频翻车点:以为 HAVING 是 WHERE 的替代品,或者认为两者差不多。它们其实处理的是不同阶段的过滤。
看一个统计订单状态的例子:
sql复制SELECT
status,
COUNT(*) AS order_cnt
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY status
HAVING COUNT(*) > 100;
WHERE 是在分组之前过滤每一行订单,比如只保留今年创建的订单;HAVING 是在分组之后过滤组,比如只要订单数超过 100 的状态。
所以如果你需要一个“分组结果”层面的过滤条件,比如 COUNT(*) > 100、SUM(amount) > 10000,你只能用 HAVING。反过来,如果只是想找一个订单金额大于 100 的订单行,把它放在 WHERE 里效率更好,因为它提前减少数据的规模,而不是把大量数据分组后再过滤。
有些人为了让 SQL 长得“统一”,硬把 WHERE 能做的事搬进 HAVING。不是不能运行,但会多跑很多无用的行,性能差很多。这个习惯能改还是尽早改。
1.3 SELECT子句的本质:从一堆列里“投影”你要的形状
如果把一张表理解成关系代数里的一个关系,SELECT 做的不是一个一个“掏数据”,而是对整个集合做一次“投影”:从列空间中挑出你需要的列,并允许你在列的基础上计算新表达式。
SELECT 后面能放的东西,远不止字段名。它可以放:
- 普通列:
SELECT user_id - 固定常量:
SELECT user_id, 'active' AS status - 函数计算:
SELECT user_id, UPPER(email) - 算术表达式:
SELECT price * quantity - 标量子查询:
SELECT user_id, (SELECT MAX(amount) FROM orders WHERE orders.user_id = u.id) - 聚合函数:
SELECT COUNT(*) FROM users
这种表达能力,决定了 SELECT 不只是一个“读数据”的关键字,而是一个构造结果集的工具。你用 SELECT DISTINCT 可以去掉重复行,用 SELECT TOP/LIMIT 可以限制返回条数,用 SELECT 里的 CASE WHEN 可以把编码值转换成可读文案。
理解“投影”这个层面,你会意识到写 SELECT 的目标不是把整张表原样搬出来,而是想清楚最终需要什么形状的结果。这比背十条语法规则重要得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 实战里最常用的组合:过滤、连接、临时结果
单独讲 SELECT 语法很容易枯燥,但放回真实业务里就不一样了。数据十有八九不是一张表摆在那里让你随意取,而是要经过条件过滤、多表连接、拆解成临时结果之后再组合。
2.1 WHERE条件不只看结果对不对,还要看它能不能“走近道”
很多人在 WHERE 上的认知停留在“能查出来就行”,但同一条 SQL,数据量大了以后,性能可能差几十倍甚至几百倍。
举个例子:
sql复制SELECT *
FROM orders
WHERE customer_id = 10086
AND status = 'paid'
ORDER BY created_at DESC;
这条语句如果分别在 customer_id 和 status 上有索引,数据库会选择更高效的方式去定位数据。如果两张索引都能用,MySQL 通常会用最挑剔的条件:哪个列能过滤掉更多行,就先用哪个,然后回表再过滤另一个条件。
但换成下面这种写法,情况就变了:
sql复制SELECT *
FROM orders
WHERE DATE(created_at) = '2024-06-01';
如果 created_at 上有索引,DATE() 这个函数包在列上,会让索引失去“快速定位”的能力,因为数据库必须先算出每一行的 DATE(created_at),才能拿去和右侧常量比较。不是不能跑,而是变成全表扫描。
更好的写法是把条件范围化:
sql复制SELECT *
FROM orders
WHERE created_at >= '2024-06-01 00:00:00'
AND created_at < '2024-06-02 00:00:00';
这就是写 WHERE 的“走近道”原则:如果需要让列参与索引快速定位,尽量别把列包在函数里。具体索引失效的细节,我会在后面用一整节展开。
2.2 JOIN的ON与WHERE:差一个位置,结果差出一整行
连接表是 SELECT 最常用的场景之一,但 LEFT JOIN 里 ON 和 WHERE 的位置问题,几乎每隔一阵就会在技术群里看到有人问。
先看一个需求:找出所有用户里没有下过单的人。
有两种常见写法:
sql复制-- 写法A
SELECT
c.id,
c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;
-- 写法B
SELECT
c.id,
c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
AND o.status = 'paid'
WHERE o.id IS NULL;
写法 A 找出的是“所有没有订单记录的用户”。写法 B 先限定了 LEFT JOIN 只连接已支付订单,再查没有这种连接结果的用户,含义是“没有支付过订单的用户”。如果用户有订单但都未支付,写法 A 不会把他列出来,写法 B 会。
这里有个核心原则:LEFT JOIN 时,连接条件放在 ON 里,通常只是影响“右表能不能匹配上”,不会把左表行直接删掉;但如果把它放在 WHERE 里,就变成了对连接后结果的过滤,左表匹配不上的行可能就没了。
实际业务里,想统计“没有某类订单的用户”“某区域外没有活动的用户”这类需求,很容易栽在 ON 和 WHERE 混用上。我的建议是:写之前先在草稿上想清楚,我到底要保留哪些左表行。如果你需要把左表所有行都保留,右表过滤条件尽量放 ON;如果你只需要连接后满足某种条件的行,放 WHERE 没问题,但这时通常用 INNER JOIN 更直白。
2.3 WITH子句:复杂查询不用再叠俄罗斯套娃
早些年写复杂 SELECT,最痛苦的就是多层嵌套。SQL 的嵌套子查询一多,括号一错,整个语句就变成一团乱麻。
现在主流数据库基本都支持 WITH 子句(也叫 CTE,Common Table Expression)。它的作用是先把复杂逻辑拆成一段一段的临时结果,再像变量一样被后续查询使用。
比如要统计每个地区支付成功的订单金额,再找出超过全站平均地区订单金额的地区:
sql复制WITH region_stats AS (
SELECT
region,
SUM(order_amount) AS region_amount
FROM orders
WHERE status = 'paid'
GROUP BY region
)
SELECT
region,
region_amount
FROM region_stats
WHERE region_amount > (SELECT AVG(region_amount) FROM region_stats)
ORDER BY region_amount DESC;
这段逻辑用子查询嵌套也能写,但可读性差很多。WITH 的天然优势,是让每条 SELECT 只负责一个清晰的目的,上面代码里第一个 CTE 先算地区金额,外层 SELECT 再做“比较平均值”的操作。
使用 CTE 时要注意,它不一定意味着性能一定更好。有些数据库会把 CTE 物化成临时结果,有些只是语法展开。如果一段 CTE 被后续查询多次引用,具体是重复执行还是物化,要看执行计划和数据库版本。因此,CTE 最大的价值首先是代码清晰,其次才是可能的性能收益,别盲目迷信。
3. 窗口函数:把SELECT从“做完就散”变成“边看边算”
如果你还停留在 SELECT 必须和 GROUP BY 配合才能做聚合的阶段,那你可能错过了一次查询能力的升级。窗口函数,是近几年我在实际开发里用得最多、也最愿意向人推荐的功能。
3.1 为什么窗口函数值得学
普通聚合函数如 SUM、COUNT,一旦配合 GROUP BY,结果就把多行压成一行,明细行会丢失。而窗口函数既能计算聚合值,又能保留原始行,让你在每一行旁边看到它所在的组、排序位置或累计值。
举个例子,销售表里每个销售员有多条销售记录。你想看到每条记录的金额,同时看到该销售员在所有记录里的累计销售额。
普通 GROUP BY 做不到这一点,因为它会丢失每一条记录。窗口函数可以:
sql复制SELECT
sales_person,
region,
revenue,
SUM(revenue) OVER (PARTITION BY sales_person) AS person_total
FROM sales;
这里 OVER(PARTITION BY sales_person) 可以先对 sales_person 分组,再对每组计算 SUM(revenue),但结果的行数不变。每一行都能同时看到明细 revenue 和个人汇总 person_total。
这种“边看明细边看汇总”的能力,在写报表、对账、排行榜时特别有用。
3.2 ROW_NUMBER、RANK、DENSE_RANK到底怎么选
排行榜是窗口函数最经典的场景。三个函数语法很像,语义差别却很大,很多人在这一步记混。
假设一组分数是 100、99、99、98,分别用三个函数排序:
| 分数 | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 100 | 1 | 1 | 1 |
| 99 | 2 | 2 | 2 |
| 99 | 3 | 2 | 2 |
| 98 | 4 | 4 | 3 |
- ROW_NUMBER() 不管有没有相同值,都给一个连续且不重复的行号。
- RANK() 遇到相同值会并列,但下一个排名会跳过,比如两个并列第 2,下一个是第 4。
- DENSE_RANK() 也允许并列,但排名连续,下一个是第 3。
如果需求是“取每组前 3 条记录”,可以使用 ROW_NUMBER(),即使有并列也会选出固定的 3 条。如果需求是“取分数前 3 名”,并列者都算前 3,这时要选择 RANK() 或 DENSE_RANK(),具体要看是否接受排名数字跳跃。
3.3 取每组Top N的SQL长什么样
窗口函数最经典的应用是“每个分组取前 N 条”。
假设每个地区有多个销售员,要找出每个地区销售额排名前 3 的销售员记录:
sql复制WITH ranked_sales AS (
SELECT
region,
sales_person,
revenue,
ROW_NUMBER() OVER (PARTITION BY region ORDER BY revenue DESC) AS rn
FROM sales
)
SELECT
region,
sales_person,
revenue
FROM ranked_sales
WHERE rn <= 3
ORDER BY region, rn;
这个写法比在业务代码里手动循环每个地区去查一次要高效得多。它先用窗口函数为每一行生成组内排名,然后在外层过滤 rn <= 3。
很多数据库之前的版本不支持窗口函数,不少老项目会写出“子查询统计比自己大的数,再判断个数”这类绕路代码。只要你的数据库版本支持,例如 MySQL 8.0 以上、PostgreSQL、SQL Server、Oracle,我强烈建议直接用窗口函数重写这种需求,无论可读性还是执行计划通常都会更好。
3.4 窗口聚合能保留明细,而GROUP BY会压平数据
窗口函数不仅能排序,还能做移动平均、累计求和、对比上一行值等操作。比如计算每个用户订单金额的累计值:
sql复制SELECT
order_id,
customer_id,
order_amount,
SUM(order_amount) OVER (
PARTITION BY customer_id
ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS cumulative_amount
FROM orders;
这里的 ORDER BY 不会让最终结果输出排序,而是告诉窗口按照什么顺序累加。它表达的是“每个用户从第一单到当前单的累计金额”,这个逻辑在计算转化漏斗、付费阶梯时很常见。
如果不用窗口,你需要把订单聚合后,再回表 JOIN,步骤多且容易错。窗口函数把“过程”保留在结果里,这是它在分析型场景里不可替代的原因。
4. 慢查询复盘:SELECT优化别靠猜
一个 SELECT 写得对不对,看结果就知道。写得好不好,要看它在压力下能不能扛住。判断一个查询能不能扛住,不能靠感觉和运气,得学会看执行计划。
4.1 先看EXPLAIN里最扎眼的几个字段
在 MySQL 中,给查询前面加上 EXPLAIN,就能看到这个语句的大致执行计划。比如:
sql复制EXPLAIN SELECT *
FROM orders
WHERE customer_id = 10086
ORDER BY created_at DESC;
看执行计划时,我通常先看三个地方:
第一个是 type 列。它描述了访问表的方式,从好到差大致有 system、const、eq_ref、ref、range、index、ALL。如果看到 ALL,说明是全表扫描,数据量大时就应该警惕。看到 ref 和 range,一般表示用到了索引定位,属于比较健康的状态。
第二个是 key 列,表示实际用到的索引。如果它显示 NULL,说明这条查询没有可用的索引,大概率在逐行扫描。
第三个是 rows 列,是预估读取的行数。这个数字不是实际行数,但能帮你直观地感受到查询的规模。同样的查询,rows 从 1 万变成 100 万,优化方向显然不同。
还有 extra 列也值得看,出现 Using filesort、Using temporary 时,通常说明查询在排序或者分组时创建了额外的临时结构,可以考虑通过索引设计来消除。
4.2 我见过最多的索引失效现场
执行计划看多了,你会总结出常见的“索引失效现场”。这里列几个我很常见到的真实写法。
第一,函数包裹列。
sql复制SELECT *
FROM orders
WHERE DATE(created_at) = '2024-06-01';
前面说过,列被函数处理以后,数据库没法直接走这个列上的索引树。这是最典型的失效场景。
第二,隐式类型转换。
如果表的 mobile 字段是字符串类型,但你查询时传了数字:
sql复制SELECT *
FROM users
WHERE mobile = 13800138000;
MySQL 会将两者都转成浮点数比较,索引可能就用不上。正确做法是和字段类型匹配,给数字加引号,或者保持字段本身是数值类型。
第三,前导通配符。
sql复制SELECT *
FROM users
WHERE name LIKE '%张';
当通配符出现在字符串开头,数据库不知道匹配的起点,无法有效走索引。如果业务确实需要全文模糊搜索,更适合使用专门的全文索引或搜索引擎,而不是试图让普通 B+ 树索引硬扛。
第四,OR 连接多个不同索引条件。
sql复制SELECT *
FROM orders
WHERE customer_id = 10086 OR status = 'paid';
即使 customer_id 和 status 各自有索引,OR 也可能让优化器无从选择,最终退化为全表扫描。常见的改造是把 OR 拆成 UNION ALL,或者确保所有涉及列都在同一个复合索引里。
4.3 别把SELECT *当成“图省事”
SELECT * 在开发阶段确实很省事,但上线以后它常常是慢查询的帮凶。
原因有三个。
第一,会造成无效的回表。如果索引里已经包含 WHERE 条件所需的列,并且只要查 id 和 status,数据库在辅助索引里就能拿到全部结果,不需要回主表查完整行。一旦写成 SELECT *,就必须拿到所有列,覆盖索引直接失效。
第二,会增加网络和内存负担。SELECT * 会把无用的大字段一起查出来,例如很长的 text 类型、大 JSON,很多时候程序根本不需要。数据量一大,这些字段白白占用缓冲区、内存和带宽。
第三,会让锁范围变大。在读写并发较高的场景,一些数据库实现下,读取的列越少,事务需要关注的资源就越少。无关紧要的大字段被读出来,可能带来额外的锁或日志开销。
我一般建议:只 SELECT 需要的列。代码里多用几秒敲列名,比线上排查一次慢查询要划算。
4.4 分页深翻页是怎么慢的,以及两种优化方案
分页查询大概是所有业务系统里最普遍的 SELECT。浅分页没有问题,深层分页很容易出问题。
看这个常见写法:
sql复制SELECT *
FROM orders
ORDER BY created_at
LIMIT 100000, 20;
它看起来只取 20 条,但数据库需要先找出前 100000 条记录,排序后跳过它们,再返回后面的 20 条。随着页码增大,扫描的数据量越来越大,接口会越来越慢。
优化思路有两种。第一种是延迟关联,也就是先用覆盖索引找到目标行的主键,再回表加载完整行:
sql复制SELECT o.*
FROM orders o
INNER JOIN (
SELECT id
FROM orders
ORDER BY created_at
LIMIT 100000, 20
) t ON o.id = t.id
ORDER BY o.created_at;
子查询只查 id 列,如果 id 有合适的索引支撑排序,它可以快速定位到这一页需要的主键集合,然后外层再按主键去取完整行。很多场景下,这种方式比直接 LIMIT 深分页快得多。
第二种是键集分页(keyset pagination),也就是不依赖页码,而是基于上次查询的最后一条记录继续往后取。
比如上一页最后一条记录的 created_at 是 2024-06-01 10:00:00,id 是 20301:
sql复制SELECT *
FROM orders
WHERE (created_at = '2024-06-01 10:00:00' AND id > 20301)
OR created_at > '2024-06-01 10:00:00'
ORDER BY created_at, id
LIMIT 20;
这种写法能直接利用索引定位到那个位置,再往后取 20 条,无论翻到第几页,性能都很稳定。缺点是它不能支持任意跳页,更适合“上一页/下一页”的产品形态。这两招我会根据业务场景选择,不推荐无脑套用一种。
5. 和SELECT相关的几个“翻车周边”
SELECT 作为关键词,并不只出现在 SELECT 查询里。它还会以各种形态出现在数据复制、函数调用、工具生成乃至其他编程模型里。这些周边内容很容易让人混淆,我单独放在一起聊。
5.1 INSERT INTO SELECT:复制数据前一定要先输出看看
有一天晚上十一点,一位同事往群里发了一条消息:我执行了 INSERT INTO SELECT,想复制一部分订单到备份表,结果插入了全量数据,现在目标表多了一百多万行。你要怎么处理?
INSERT INTO SELECT 本身没什么错,错的是他漏掉了 WHERE 条件,或者 WHERE 条件写错,导致本来只想复制一小批数据,最后把整张表复制了一遍。
正确姿势是我反复强调的:执行任何 INSERT INTO SELECT 之前,先把 SELECT 部分单独跑一遍。
sql复制-- 第一步:先确认要复制哪些行
SELECT COUNT(*)
FROM orders
WHERE created_at < '2023-01-01';
-- 第二步:把结果插入目标表
INSERT INTO orders_archive
(order_id, customer_id, status, order_amount, created_at)
SELECT
order_id, customer_id, status, order_amount, created_at
FROM orders
WHERE created_at < '2023-01-01';
这一步看起来多花一点时间,但能避免绝大多数误操作。尤其是生产环境,最好再包一层事务,插入完成后先检查目标表行数,再决定提交还是回滚。
sql复制START TRANSACTION;
INSERT INTO orders_archive (...)
SELECT ... FROM orders WHERE ...;
-- 检查
SELECT COUNT(*) FROM orders_archive WHERE created_at < '2023-01-01';
-- 确认无误后:
COMMIT;
-- 确认有问题:
-- ROLLBACK;
INSERT INTO SELECT 在目标表结构不完全一致时也很容易报错。要点是显式列出目标列和源列,不要用 SELECT *,这样表结构一变化,你至少能及时发现问题。
5.2 SELECT后面能放的东西比你想的多
有些初学者会把“SELECT 函数”当成一个固定的说法,比如 SELECT COUNT(*)、SELECT NOW()、SELECT SUM(amount)。严格说,SELECT 不是函数,它是一个语句,函数只是它的投影表达式的一部分。
比如你可以直接在 SELECT 后面运行一个单行表达式:
sql复制SELECT
CURRENT_TIMESTAMP AS now_time,
'hello' AS greeting,
1 + 1 AS two;
它同样遵循 SELECT 的原理,只是数据来源不是某张业务表,而是数据库内部提供的一行上下文。Oracle 用户比较熟悉的是:
sql复制SELECT SYSDATE FROM DUAL;
这里的 DUAL 是一个只有一行的特殊表,用来补全 SQL 必须带 FROM 的语法。
更实用的是在普通查询里用标量子查询。比如查询订单时,带上这个订单的最高支付记录金额:
sql复制SELECT
o.order_id,
o.order_amount,
(
SELECT MAX(p.pay_amount)
FROM payments p
WHERE p.order_id = o.order_id
) AS max_pay_amount
FROM orders o;
这类写法的逻辑在于:外层每处理一行订单,都会去关联查找一次支付记录。如果数据量不大,问题不大;如果数据量很大,就可能出现 N+1 式的性能问题。这时候就要考虑窗口函数或 JOIN 重写。
5.3 一条SQL快速生成标准SELECT语句
写查询时,我经常需要把一张表的全字段列出来。手打太累,而且容易漏列。偷懒的办法是直接查数据库的元信息表。
以 MySQL 为例,information_schema.COLUMNS 存着每张表的字段信息:
sql复制SELECT
CONCAT(
'SELECT ',
GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION),
' FROM ',
TABLE_NAME,
';'
) AS select_sql
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'your_db'
AND TABLE_NAME = 'orders'
GROUP BY TABLE_NAME;
执行后会得到一行类似于下面这样的结果:
sql复制SELECT order_id,customer_id,status,created_at FROM orders;
如果担心字段名里有保留字,可以在生成时给每个字段加上反引号:
sql复制SELECT CONCAT('SELECT ', GROUP_CONCAT('`', COLUMN_NAME, '`'
ORDER BY ORDINAL_POSITION),
' FROM `', TABLE_NAME, '`;')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'your_db'
AND TABLE_NAME = 'orders'
GROUP BY TABLE_NAME;
很多图形化工具也内置了类似功能。DataGrip、IDEA 数据库插件里,在表上右键往往有跳转生成 SQL 的入口;Navicat 的查询生成器也能把勾选的字段自动拼出来。自动生成只是起点,真正需要注意的还是生成之后检查条件、排序和性能。
另外一个常见的小场景是:“在旧版桌面数据库里,想给 SELECT 查询加一个连续序号,该怎么办?”有人直接取表内物理记录号来用,例如在 Visual FoxPro 里写 RECNO(),但如果记录在筛选、索引或删除后,物理记录号未必连续,把它当作业务序号会带来风险。更稳妥的做法是先把需要的结果按业务顺序输出成一个临时结果集,再通过自增列或者专门的排序字段生成序号。核心原则是:序号应该依赖于最终结果的顺序,而不是物理存储位置。
5.4 当select不是SQL:别再和poll/epoll搞混了
搜索“select”的时候,你可能会搜到一堆和数据库完全无关的内容,尤其是 Linux 编程里的 select、poll、epoll。我第一次看到这三个词排在一起时也愣了一下,后来才意识到它们只是同名“select”,解决的问题完全不同。
数据库的 SELECT,是结构化查询语言的一部分,用于从关系表里查询数据。Linux 的 select(),是网络编程里的一种输入输出多路复用系统调用,让程序可以同时等待多个文件描述符变成可读、可写或者出现异常;poll() 和 epoll() 是同领域的不同实现方案。
它们的核心区别,用一张表能看得很清楚:
| 能力 | select() | poll() | epoll() |
|---|---|---|---|
| 最大文件描述符数量 | 受限,常见实现为 1024 | 数量可以更大 | 数量可以很大 |
| 每次调用是否都要全量扫描 | 是,开销随数量线性上涨 | 是 | 否,由内核通知活跃事件 |
| 注册回调能力 | 没有 | 没有 | 有,事件驱动 |
| 适合场景 | 少量连接 | 中等数量连接 | 高并发、大规模连接 |
所以如果有人在数据库文章下面和你争论“select 已经过时了应该用 epoll”,他大概率不是在说 SQL。这个区分看起来像常识,但在技术搜索里偏偏特别容易撞车。看到技术文章里出现 select,一定要先确认对方讲的是数据库查询,还是系统调用,或者是某种事件查询语法,不然很容易带着数据库思维把人家的例子看歪。
6. 这些年写SELECT攒下的几条肌肉记忆
写 SELECT 的时间越长,越觉得它不是一个靠记忆关键字就能用得好的东西。最后分享几个我实际养成的习惯,也许你也能用上。
第一,接到一个复杂查询需求时,先不要打开编辑器写代码,先在草稿上回答三个问题:数据在哪些表里?要保留哪些行?要输出哪些列?这三个问题的答案基本决定了整个查询的结构。很多时候 SQL 写不顺,不是语法不熟,而是需求本身没拆清楚。
第二,别一上来就写 SELECT。我会先写 FROM 和 WHERE,把数据范围和过滤条件确定下来,再回头补 SELECT 的列和表达式。这样能避免先选一堆列,最后才发现漏了过滤条件,又回头改一大段代码。
第三,所有临时性查询,尤其是 UPDATE、DELETE、INSERT INTO SELECT 前面的 SELECT 部分,先加 LIMIT 看几行数据是不是你要的,再放开限制执行。宁可多跑一条少跑一条,也不要让一次误操作毁掉半天成果。
第四,遇到慢查询不要直接改 SQL,先看执行计划。很多“调优”最后变成瞎猜,就是因为跳过了 EXPLAIN 这一步。执行计划会告诉你问题出在扫描方式、排序还是连接顺序,方向对了,优化才有效。
第五,对 NULL 保持敬畏。SELECT 里用到的 WHERE、聚合函数、连接条件,只要涉及可空字段,都要想清楚 NULL 对结果的影响。COUNT(field) 不统计 NULL,COUNT(*) 统计每一行;NULL 和任何值比较都是未知,IS NULL 才是正确写法。这类问题在结果看起来“差不多”的时候最容易潜伏,等到线上对不上账才暴露。
SELECT 的确是一把开启数据世界的钥匙,但钥匙能不能打开正确的门,不只取决于你会不会把它插进锁孔,还取决于你懂不懂门背后的结构。希望这篇内容能让你在下一次写 SELECT 时,脑子里多几个判断:先看执行顺序,再想数据形状,然后检查执行计划。这样写出来的 SQL,才能真正经得住业务和时间的考验。
