先说个我自己的经历。有一年线上系统做例行压测,数据库连接池在高峰期被挤爆,慢查询日志里翻出来,前十名几乎全是同一类 SQL:SELECT * FROM order_info WHERE user_id = ?。单看这一条好像没毛病,user_id 上有索引,数据量也不算离谱。但等我把执行计划拉出来,发现每条查询都在回表,而且因为表里有二十几个字段,其中有几个是 TEXT 类型的大字段,每查一次就要把几兆的数据从磁盘拖到内存再吐给应用层。一个用户点开订单列表,背后少说跑了七八条这样的查询,不慢才怪。
后来我改成了只查列表页真正需要的五个字段,压测数据立刻就正常了。那时候我就意识到,SQL 里最不起眼的 SELECT *,其实一直站在一场持续几十年的技术路线之争的交叉点上。今天这篇文章,我就想借着这个关键词,把 SELECT 的来龙去脉、底层原理和常见坑聊透,尤其是为什么一帮数据库老前辈会为了一颗星号吵这么多年。
1. 为什么一行 SELECT * 能牵出一个“战争”?
1.1 关系模型问世:从“怎么存”到“怎么查”
时间回到 1970 年,IBM 研究员 Edgar F. Codd 发表了一篇论文,题目是 A Relational Model of Data for Large Shared Data Banks。这篇论文第一次把数据组织成二维表,也就是“关系”,用集合论和谓词逻辑来处理查询。这在当时是颠覆性的:早年的网状数据库和层次数据库,你要查一条数据,得先知道它在存储结构里的物理路径,像游标一样沿着链表一层层挪过去。关系模型不是,它让你直接描述“我要什么”,而不是“我要怎么顺着指针找”。
这个转变看似简单,但它让数据库从“面向程序员的存储结构”变成了“面向业务问题的查询引擎”。Codd 的模型也为后来 SQL 的诞生打下了基础。IBM 的 Donald Chamberlin 和 Raymond Boyce 在 1970 年代初期开发了 SEQUEL,也就是 SQL 的前身,用来在 System R 原型上实现关系查询。那时候他们设计的语法里,SELECT 就承担了“投影”这个操作:从一张表里选出你关心的列。
1.2 星号不是偷懒,是历史遗留选择
SELECT * 里的星号,在早期标准里并没有被特殊推崇,它更像一个“把所有列都列出来”的语法糖。真正让星号流行起来的,是交互式查询工具的普及。早期数据库管理员和数据分析师在终端上想快速看一张表长什么样,最快的方式就是 SELECT * FROM users LIMIT 10。它不需要你记住这张表有哪些字段,也不会因为你少写一个字段就报错。
但问题也出在这里。一个语法如果是为了“临时看一眼”设计的,就不该被大规模写进生产代码里。可现实是,很多 ORM 框架和代码生成器在生成查询时,默认拼出来的就是 SELECT *,久而久之,星号成了很多业务代码的默认选项。它不是不能用,而是你在生产环境里每一行 SELECT *,都在把底层表结构的变化风险和查询性能的开销,悄悄转嫁给应用层。
1.3 为什么说这是一场持续 50 年的“战争”
你可能会问,一个查询语法,怎么就成战争了?其实这场战争不是发生在星号上,而是发生在关系模型和后续各种查询范式之间。从 1970 年 Codd 的论文开始,关系型数据库花了差不多二十年,才用 SQL 统一了商业数据库市场。但 SQL 标准内部一直有分歧:不同数据库对 SELECT 语法、索引实现、事务隔离级别的理解都不一样。
到了互联网时代,又出现了 NoSQL 对关系模型的挑战。MongoDB、Redis、Elasticsearch 这些存储引擎都在说“你不用再写复杂的 JOIN 和 SELECT 了”。但最后大家发现,业务分析、报表、后台管理这些场景,还是绕不开 SQL。于是又有了“NewSQL”、列式存储数据库、云原生数据仓库。PostgreSQL、ClickHouse、Snowflake、Doris 这些都是围绕“如何更快地执行 SELECT” 在做文章。所以说,你敲下的每一行 SELECT *,背后其实是关系模型和查询引擎这五十年的演进史。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 底层原理:SELECT 从输入到输出的整个旅程
2.1 SQL 引擎的五个阶段:解析、绑定、优化、执行、返回
一条 SELECT 语句从客户端发出,到数据库返回结果集,中间其实经历了好几个阶段。第一步是解析(Parsing),SQL 引擎会把字符串拆成 token,生成抽象语法树。这一步如果语法有问题,会直接报错。第二步是绑定(Binding),也就是把语法树里的表名、列名对应到真实的系统目录,检查权限和列是否存在。
绑定之后是最关键的一步:逻辑优化和物理优化。逻辑优化会把你的 SQL 改写成更合理的形式,比如把子查询重写成 JOIN,把 IN 改写成 EXISTS 或者反过来的等价形式。物理优化则会根据统计信息来决定访问路径:是走全表扫描,还是走索引扫描;是使用嵌套循环连接,还是哈希连接。最后一步才是真正的执行和结果返回。
在这个过程里,SELECT * 并不是一个简单的“多取几个字段”的问题。它在绑定阶段就必须把所有列都展开,在优化阶段也会影响优化器的选择。比如你想用覆盖索引来避免回表,但 SELECT * 要求返回所有列,而索引里不可能覆盖住全部字段,优化器只能放弃覆盖路径,老老实实回表。
2.2 扫描与索引:SELECT * 如何悄悄拖垮性能
我用一个具体例子来说明。假设有一张用户表:
sql复制CREATE TABLE users (
id INT PRIMARY KEY,
username VARCHAR(64),
email VARCHAR(128),
bio TEXT,
created_at TIMESTAMP
);
CREATE INDEX idx_username ON users(username);
如果你执行 SELECT username FROM users WHERE username = 'zhangsan',数据库可以从 idx_username 索引里直接拿到 username 的值,不需要再去主表里读取整行数据。这就是覆盖索引的好处。
但如果写成 SELECT * FROM users WHERE username = 'zhangsan',索引里只有 username 和主键 id,没有 email、bio,那数据库就必须根据索引里的主键,回到主表的 B+ 树去读完整行,这个动作叫回表。回表次数越多,查询越慢。更糟的是,如果 bio 是 TEXT 类型,单行数据可能非常大,一次回表就要读不少磁盘页。
在 MySQL 的 InnoDB 引擎里,行数据是聚簇索引的形式组织在主键上的,二级索引的叶子节点存的是主键值。回表本身并不是致命的,但如果你的 WHERE 条件筛选出来的行数很多,比如几百上千行,每次都要随机读磁盘,那就可能把几十个数据页都读一遍。相比只查索引里的两个字段,性能差距可能是几个数量级。
2.3 代价估算:传输列宽越宽,成本越高
很多人只关注磁盘 I/O,忽略了网络传输和内存拷贝。数据库执行完查询后,要把结果集返回给客户端。如果一张表有 30 个字段,其中还有 TEXT、JSON 这种大字段,那么即使只返回 1000 行,总数据量也可能达到 50MB 甚至更多。这些数据要先从存储引擎读出来,经过数据库服务端的内存缓冲,再通过网络协议发给应用服务器。应用服务器还要将它们解析成对象,放进内存里。
这一步的代价与列宽直接相关。你写 SELECT *,等于告诉数据库“把所有字段都传到客户端”,哪怕你根本不用其中的 25 个字段。这样的浪费在单次请求里看不出来,但在高频接口里会成倍放大。举个直观的例子:一个订单列表接口,如果只需要 id、order_no、amount、status 四个字段,但接口代码里写的是 SELECT *,那么每次查询都会多传几个大字段,比如 remark、callback_url、raw_data。
压测时我给同样的接口改了 SQL,只查四个字段,吞吐量提升 30% 以上,P99 延迟也降了不少。代价估算不只是数据库优化器的事,也是每个写 SQL 的人应该心里有数的事。
3. 从实践中来:SELECT 查询优化与隐患排查
3.1 明确列名:改写 SELECT * 的几个具体收益
把 SELECT * 改成明确列名,第一个收益是可维护性。如果表结构发生变更,比如加了一个字段,SELECT * 返回的结果集结构也会变化,可能导致应用层的反序列化逻辑或者其他下游系统出问题。而明确列名是稳定的契约:你查什么,结果集就有什么。
第二个收益是性能,刚才已经说过了。第三个收益是安全。明确列名可以减少敏感字段被意外返回的概率。比如一张用户表里有 password_hash、payment_token 这类字段,如果在某个查询里不小心用了 SELECT * 而且查询结果被记录到日志里,那风险就很大。别以为这种事离你很远,现实里真的发生过。
所以我的习惯是:业务代码里一律写明确列名。只有临时排查数据、或者做交互式探索的时候,才会用 SELECT * 加 LIMIT。
3.2 注意大小写、引号和隐式转换
搜索热词里有一个问题很典型:select原表数据字段时候不区分大小写么? 这个问题的答案取决于数据库。MySQL 在 Linux 上,表名区分大小写,列名不区分;PostgreSQL 在标准 SQL 里,未加引号的标识符会被折叠成小写,所以你写 SELECT UserName FROM users 其实会去查 username,但如果你在建表时用了带引号的 "UserName",那么查询时大小写就对不上了。Oracle 则默认把未加引号的标识符转换成大写。
这就引出几个实用建议:第一,建表时统一用一个小写加下划线的命名规范;第二,查询时不要依赖数据库自动折叠或者自动转换;第三,如果表或列名使用了保留字,必要时加上反引号或者双引号,但尽量从源头避免这种命名。
另一个常见的坑是隐式类型转换。例如 user_id 是 INT 类型,但前端传了一个字符串 "123",你写 WHERE user_id = '123',很多数据库会把字符串转成数字,这个还行。但如果你是反过来,在索引列上做函数操作,比如 WHERE DATE(created_at) = '2024-01-01',那索引就会失效,数据库只能全表扫。你要改成范围查询:WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02',才能让索引派上用场。
3.3 分页和 limit:控制返回结果集的大小
SELECT * 和 LIMIT 一起用,临时看看数据没问题,但业务查询里还是要谨慎。很多新手在写列表接口的时候,喜欢直接查全表,然后在 Java 或 Go 代码里做内存分页。如果表数据量只有几千行,感觉不出来;一旦到了百万行,每次接口都全量查出来,再丢弃大部分数据,数据库和网络都吃不消。
正确做法是把分页条件下推到 SQL 里:
sql复制SELECT id, order_no, amount
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
不过 OFFSET 大了也有问题,因为它还是要扫描并丢弃前 N 行。更优的方案是基于游标的分页,比如 WHERE created_at < ? ORDER BY created_at DESC LIMIT 20。如果你的业务场景对分页深度要求很高,可以考虑用这种方式。
还有一个容易忽略的点:ORDER BY 的列最好和索引匹配。如果你在 orders 表上建了 (status, created_at) 的联合索引,那上面的查询就能走索引,先按 status 过滤,再按 created_at 排序,不需要额外的 filesort。我就见过不少因为 ORDER BY 字段和索引顺序不一致而突然变慢的查询。
4. 周边生态:select 在各语言和工具中的“同名不同命”
4.1 数据库 select、Python select JSON、系统调用 select/poll/epoll,千万别混
很多人第一次接触 select 是在 SQL 里,后来发现编程语言里有各种同名函数。最容易搞混的一类是操作系统级 I/O 多路复用里的 select、poll、epoll。这里的 select 不是数据库查询,而是一种内核提供的处理多路 I/O 事件的能力:你可以把一堆 socket 文件描述符交给内核,让它告诉我们哪些可以读、哪些可以写。
为什么要把它们放在一起说?因为网上搜 select 相关问题时,经常出现两拨完全不同的内容。一边是 SQL 优化,一边是网络编程。如果你不搞清楚语境,很容易把两边的内容混在一起看。
简单区分一下:数据库的 SELECT 是声明式的数据查询语言,属于 SQL;Linux 网络编程里的 select 和 poll 是系统调用,epoll 是 Linux 特有的增强版。三者的核心问题分别是“查什么数据”“怎样管理多个连接”和“怎样高效管理海量连接”。虽然都不是一个东西,但设计理念有相似之处:都是让你从一堆候选者里选出自己关心的对象。这个相似点也是很多搜索词混乱的根源。
Python 里的 select 模块也是封装了系统调用,它的用法和数据库一点关系都没有。所以你在排查 Python 报错时,如果看到 select.error,先去看是不是 socket 连接出问题,而不是 SQL。
4.2 常见问题速查表与排查思路
下面这张表整理了我遇到过的、或者网上高频出现的 SELECT 相关问题和解决思路,方便你直接对号入座。
| 问题现象 | 可能原因 | 解决建议 |
|---|---|---|
SELECT * 导致慢查询 |
回表、大字段传输 | 改为明确列名,建立覆盖索引 |
| 查询结果字段大小写不对 | 数据库标识符折叠规则不同 | 统一小写下划线命名,必要时加引号 |
INSERT INTO ... SELECT 数据量太大 |
目标表和源表隐式转换或未加条件 | 先 EXPLAIN,加上 WHERE 条件,分批提交 |
| 使用 VFP 生成序号字段时不生效 | FoxPro 的 SQL 语法较特殊 | 使用 RECNO() 或自增列,避免依赖通用 SQL 关键字 |
| IDE 里写 SELECT 没有自动补全列名 | ORM 插件不识别映射 | 检查数据源配置,使用数据库客户端插件,或手动维护列名 |
搜索热词里的 pdflatex not found |
这不是 SQL 问题,而是 Pandoc/LaTeX 工具链缺失 | 安装合适版本的 LaTeX 发行版,或在文档工具里更换 pdf-engine |
el-select 模糊搜索又支持手动输入 |
前端组件和 SQL 查询是两码事 | 配置 filterable 配合 allow-create,数据源仍来自接口查询 |
| Oracle 查询时大小写不敏感但结果为空 | 列名被转成了大写 | 检查 JDBC 配置和字段元数据,使用大写列名或加引号 |
这张表里的项目看着杂,但它们都在提醒同一件事:遇到 select 相关报错,先分清上下文,再定位问题。很多人花了一晚上去调数据库 SQL,结果发现是文档工具缺了一个 pdflatex 可执行文件,这种折腾我见得多了。
5. 我的实操体会:如何在真实项目中把 SELECT 用得更好
5.1 从一次慢查询到索引重构的复盘
有次我负责一个报表模块,某个接口要从一张五百万行的业务表里查数据。原来的 SQL 是这样的:
sql复制SELECT *
FROM biz_record
WHERE update_time BETWEEN ? AND ?
ORDER BY id DESC;
表上只有一个主键索引,update_time 没建索引,所以每次查这个时间范围都会全表扫描,还要做一次文件排序。我当时的做法是,先把业务上真正需要的列列出来,再对 update_time 和 id 建立联合索引。因为排序用的是 id,而主要过滤条件是 update_time,最后建了一个 (update_time, id) 的联合索引。
改完之后,EXPLAIN 显示执行计划从 type=ALL 变成了 type=range,Extra 里不再有 Using filesort。接口响应时间从原来的 3 秒左右降到了 300 毫秒内。
这里的关键并不是不能用 SELECT *,而是你要清楚每一步的代价。如果一个查询只跑一次,而且数据量很小,那 SELECT * 完全没问题。但如果它处在高频路径上,就必须像审视代码一样审视 SQL。
5.2 给新人的几个建议
第一,写 SQL 之前先想清楚你要哪些字段,不要依赖 * 来兜底。你写出的每一列,都应该有它的用途。第二,学会看 EXPLAIN 和慢查询日志,这是数据库给你的体检报告。分析执行计划时,先看 type,再 key,再看 rows 和 Extra。第三,不要迷信“加了索引就一定快”,索引列的顺序、查询条件的过滤性、数据分布统计信息,都会影响最终效果。
另外,我特别建议你在本地数据库准备一份生产环境的脱敏数据,用真实的字段名和索引结构去验证你的 SELECT。纸上谈兵很容易,真跑一次才知道索引有没有被用上。
5.3 持续五十年的问题,今天依然值得认真对待
回过头来看,从 Codd 的关系模型到今天的云原生数据库,SELECT 的语法骨架基本没有变过。这本身是件很了不起的事:五十年前的查询语言,今天依然能承载互联网级别的流量。但技术越老,越容易被人轻视。很多人在乎微服务框架、容器编排、大模型应用,却忘了最基础的 SQL 可能是整个系统的最大瓶颈。
我在实际踩过几次坑之后,养成了一个习惯:每段新写的 SQL,都要先跑一遍执行计划,确认它没有隐藏的全表扫描和回表。给自己定一个规则,业务代码里禁止出现 SELECT *,如果临时排查数据,可以手动敲 SELECT *,但一定要加 LIMIT。这个约定不复杂,却能让团队省掉一半以上的数据库事故。
最后再分享一个小技巧:如果你要改一条线上慢查询,先不要急着删 SQL,把慢查询日志里的原始 SQL 连同当时的执行计划一起保存下来,改完以后对比优化前后的 rows 和耗时。这个对比记录,比任何理论都更有说服力。
