1. 这个问题到底在问什么:不是“为什么慢”,而是“为什么不该用”
“为什么大家都说SELECT * 效率低”——这句话表面看是个技术疑问,但实际是数据库领域里一个被反复咀嚼、却常被浅层理解的经典认知陷阱。它背后真正牵扯的,远不止是“多查几个字段就变慢”这么简单。我带过不少刚转行做后端或数据分析的新人,他们第一次在代码审查里被导师标红SELECT * FROM users时,第一反应往往是:“就查个全表,又没几万条数据,能慢到哪去?”结果上线两周,报表接口平均响应从300ms飙到2.8秒,DBA半夜打电话过来问“你是不是加了新SQL”。这时候才意识到:效率低,从来不是单点问题,而是一连串隐性成本叠加后的系统性坍塌。
核心关键词——SELECT *、效率低、数据库性能、网络传输、索引失效、维护成本——这些词必须放在真实生产环境里才能被真正理解。它不只影响查询速度,更会悄悄腐蚀应用的可扩展性、可读性、可维护性,甚至埋下数据安全漏洞。比如某次我们接手一个老系统,发现其用户中心模块所有接口都用SELECT *,结果当DBA给users表新增一个avatar_blob字段(存二进制头像)后,原本毫秒级的登录校验接口突然卡顿,因为每次认证都要把几MB的图片数据从磁盘读出、经网卡传到应用层、再被Java对象序列化——而这个字段,登录逻辑根本用不到。
所以这篇文章不打算罗列教科书式的“四点原因”,而是带你回到真实战场:从一次线上慢查排查开始,还原SELECT *如何在磁盘IO、内存缓存、网络带宽、执行计划、代码耦合、团队协作六个维度同时失守;告诉你哪些场景下它“看似无害”,实则已为三个月后的故障埋下伏笔;更重要的是,给出一套可落地的替代方案——不是“别用*”,而是“在什么条件下用什么替代,为什么这个替代更稳”。
适合谁读?如果你写SQL时习惯先敲SELECT *再删字段,或者review别人代码时只看逻辑对不对、不看SQL写了啥,又或者你负责的系统最近出现不明原因的CPU飙升、连接池打满、缓存命中率断崖下跌——那这篇就是为你写的。它不假设你懂执行计划,但要求你愿意花15分钟,看清自己每天敲下的那行星号,到底在数据库里掀起了多大风浪。
2. SELECT * 的六重隐性成本:比“慢”更致命的是“不可控”
很多人以为SELECT *的代价只是“多返回几个字段”,于是得出结论:“小表无所谓”“内网不差这点带宽”。这种想法错在把数据库当成单机文件读取器。真实生产环境里,SELECT *触发的是一整套连锁反应,每一环都在 silently 消耗系统资源。下面这六重成本,我在三个不同行业的项目中都亲眼见过它们单独或组合爆发。
2.1 磁盘IO与缓冲区污染:你以为读的是数据,其实是在刷盘
数据库最贵的操作永远是磁盘IO。而SELECT *让这个问题雪上加霜。举个具体例子:某电商订单表orders有23个字段,其中order_detail_json是TEXT类型,平均长度1.2MB。业务方只要查订单状态和金额,却写了SELECT * FROM orders WHERE order_id = 'xxx'。执行时发生了什么?
- MySQL的InnoDB引擎按页(Page)为单位读取数据,默认页大小16KB。但为了加载
order_detail_json这个大字段,InnoDB必须把整个记录所在的页全部载入Buffer Pool(内存缓冲区)。 - 更糟的是,这个1.2MB的JSON字段本身可能跨多个页存储(InnoDB对大字段采用off-page存储),导致一次查询实际触发7~12次随机磁盘IO。
- 而Buffer Pool空间有限。这些大字段占满内存后,真正高频访问的
order_status、created_at等小字段对应的热页就被挤出缓存,后续查询不得不重新刷盘。
提示:用
SHOW ENGINE INNODB STATUS\G查看Buffer Pool的Database pages和Free buffers比例。如果后者长期低于5%,且Pages read ahead数值飙升,大概率有SELECT *在疯狂污染缓存。
我实测过:在同等QPS下,将SELECT *改为SELECT order_id, status, amount, created_at后,Buffer Pool的Hit Ratio从82%升至99.3%,磁盘IO等待时间下降67%。这不是“快一点”,而是让数据库从“频繁喘不过气”变成“稳定呼吸”。
2.2 网络传输与序列化开销:内网千兆也不够它造
开发者常忽略一个事实:数据库服务器和应用服务器之间的网络,永远比想象中脆弱。即使同机房万兆网,TCP协议栈、序列化反序列化、GC压力,都会让SELECT *的传输成本指数级上升。
以Java Spring Boot应用为例:SELECT * FROM users返回1000行,每行含id(BIGINT)、name(VARCHAR 50)、email(VARCHAR 100)、avatar_url(VARCHAR 255)、bio(TEXT)、settings_json(JSON)六个字段。粗略估算:
bio平均长度800字符,settings_json平均1.5KB → 单行约2.3KB- 1000行 ≈ 2.3MB原始数据
- 经JDBC驱动序列化为
ResultSet对象 → 内存占用翻倍(字符串对象头、字符数组、HashMap结构等) - Spring
@ResponseBody转JSON时,还要再复制一次 → GC压力陡增
我们曾在一个用户中心服务里定位到:一个本该300ms完成的分页接口,实际耗时2.1秒。jstack显示线程大量阻塞在java.io.DataInputStream.readFully——正是JDBC读取大字段时的网络等待。改用明确字段后,接口回归320ms,Full GC频率从每分钟3次降到每小时1次。
注意:PostgreSQL的
pg_stat_statements视图里,total_time和mean_time差距巨大时(比如均值50ms但最大值2s),往往就是SELECT *+大字段导致的长尾延迟。
2.3 执行计划漂移:索引失效的隐形推手
这是最隐蔽也最危险的成本。SELECT *本身不直接导致索引失效,但它会迫使优化器放弃最优路径。原因很简单:当查询需要返回所有字段时,优化器必须确保能从索引中获取全部数据,否则就得回表(Table Lookup)。
假设users表有联合索引(status, created_at),业务想查“最近7天的活跃用户”。写成:
SELECT id, name, email FROM users WHERE status = 'active' AND created_at > NOW() - INTERVAL 7 DAY;优化器发现索引(status, created_at)能覆盖查询所需字段(Covering Index),直接走索引扫描,无需回表。
但若写成:
SELECT * FROM users WHERE status = 'active' AND created_at > NOW() - INTERVAL 7 DAY;优化器一看:索引里没有avatar_url、bio等字段,必须回表读聚簇索引。而回表是随机IO,当结果集稍大(比如5000行),性能断崖下跌。
更糟的是,表结构变更会瞬间引爆这个问题。某次DBA给users表新增last_login_ip字段并重建索引,原本稳定的查询突然变慢5倍——因为新索引未包含这个字段,SELECT *被迫全面回表。而明确指定字段的SQL完全不受影响。
2.4 应用层内存与GC压力:你以为省事,其实喂饱了垃圾回收器
SELECT *在应用层制造的内存压力,常被归咎于“Java太重”。但真相是:它放大了所有语言的内存管理缺陷。
以Python Flask为例:cursor.fetchall()拿到SELECT *结果后,每个字段都生成独立字符串对象。而Python字符串不可变,bio字段的800字符会创建800个字符对象+1个字符串容器。1000行就是80万个小对象。CPython的引用计数机制在此类场景下效率极低,gc.collect()调用频次激增。
我们用memory_profiler对比过:
SELECT id, name, email:峰值内存占用 12MBSELECT *(同条件):峰值内存占用 89MB- GC暂停时间(STW)从平均1.2ms升至18ms
这意味着:在高并发下,应用服务器CPU大量时间花在GC上,而非处理业务逻辑。监控图表上会看到典型的“锯齿状CPU曲线”——那是GC在规律性地暂停所有线程。
2.5 代码耦合与维护风险:一个星号,拖垮整个迭代节奏
技术债最可怕的地方,是它不立刻报错,却让每次修改都如履薄冰。SELECT *正是典型代表。
某SaaS系统有个报表导出功能,前端传参?type=monthly,后端SQL写死SELECT * FROM invoices。三年后,财务部门要求增加“税额”字段,DBA在表里加了tax_amount DECIMAL(10,2)。结果导出Excel时,所有列顺序乱了——因为SELECT *返回字段顺序取决于建表顺序,而新字段插在中间。下游BI工具解析失败,客户投诉。
更严重的是权限失控。SELECT *会无视行级/列级权限设计。某次安全审计发现:一个本该只读user_name和department的HR查询接口,因用了SELECT *,意外暴露了salary和bank_account字段(虽然后端做了脱敏,但原始数据已流出到日志和监控链路)。
2.6 团队协作与可读性损耗:让Code Review失去意义
最后但同样重要:SELECT *让SQL失去语义。Code Review时,同事无法快速判断“这个查询到底需要哪些数据”。他得打开表结构、逐个核对字段用途,再确认是否所有字段都被业务逻辑使用——这本该是SQL编写者的基本责任。
我们推行过一条团队规范:“任何SELECT *必须在注释里写明理由,且需TL审批”。结果三个月内,92%的SELECT *被重构,剩下8%里有3个是历史遗留(已标记tech debt),2个是临时调试(加了-- DEBUG: DO NOT MERGE警告)。当SQL明确写出SELECT user_id, order_count, last_order_date时,Review者一眼就能看出:这是在查用户复购行为,且不需要敏感信息。
3. 实操指南:从“禁用*”到“精准取数”的完整迁移路径
知道问题在哪,不等于知道怎么改。很多团队喊着“不用SELECT *”,结果改成SELECT a,b,c,d,e,f,g,h,i,j...,字段数比原来还多,性能更差。真正的优化,是一套系统性工程。下面是我总结的、已在多个项目验证过的四步法。
3.1 第一步:诊断——用工具揪出所有“星号罪犯”
别靠grep代码库。生产环境里,SELECT *常藏在ORM动态拼接、MyBatis XML的<include>、甚至前端传参构造的SQL里。必须用数据库原生能力精准定位。
MySQL方案:
-- 开启性能分析(需SUPER权限) SET GLOBAL performance_schema = ON; -- 查看所有含SELECT *的慢查询 SELECT DIGEST_TEXT, COUNT_STAR, SUM_TIMER_WAIT FROM performance_schema.events_statements_summary_by_digest WHERE DIGEST_TEXT LIKE '%SELECT * FROM %' AND COUNT_STAR > 10 ORDER BY SUM_TIMER_WAIT DESC LIMIT 20;PostgreSQL方案:
-- 需先启用pg_stat_statements扩展 SELECT query, calls, total_time, mean_time FROM pg_stat_statements WHERE query ~* '\bSELECT\s+\*\s+FROM' AND calls > 5 ORDER BY total_time DESC LIMIT 10;实操心得:别只看
total_time,重点看mean_time和calls的比值。如果mean_time很低(如2ms)但calls极高(如10万次/小时),说明这是高频轻量查询,改起来收益最大——比如用户登录校验、商品详情页基础信息加载。
我们曾用此方法在一个金融系统里发现:SELECT * FROM user_profiles WHERE user_id = ?占据了总SQL调用量的37%,但平均耗时仅1.8ms。将其精简为SELECT nickname, avatar_url, level FROM user_profiles后,数据库CPU使用率下降11%,且因减少了网络传输,API网关的带宽消耗降了23%。
3.2 第二步:重构——不是删字段,而是建“数据契约”
盲目删字段会引发BUG。正确做法是:为每个查询定义最小必要字段集,并固化为接口契约。
以电商订单列表页为例:
- 前端需要:订单号、状态图标、商品缩略图、总价、下单时间、操作按钮(发货/取消)
- 后端SQL应返回:
order_no,status,first_sku_image,total_amount,created_at,can_ship,can_cancel - 注意:
first_sku_image不是表字段,而是通过SUBSTRING_INDEX(sku_images, ',', 1)计算得出;can_ship/can_cancel是根据status和payment_status计算的布尔值
这就是“数据契约”——它明确界定了“前端要什么”和“数据库给什么”,中间的计算逻辑(如图片截取、状态判断)由SQL或应用层完成,但绝不让数据库返回冗余数据。
工具推荐:使用MyBatis的<resultMap>或JPA的@SqlResultSetMapping显式声明字段映射。这样即使表结构变更,只要契约字段存在,SQL就不报错;若新增字段,也不会意外透出。
3.3 第三步:加固——在基础设施层堵住漏洞
靠人盯人不现实。必须在框架层设防。
方案1:SQL审核平台(推荐)
接入Yearning或Archery,在提交SQL前自动检测:
- 是否含
SELECT * - 是否缺少WHERE条件(防全表扫描)
- 是否查询超100万行(阈值可配)
配置规则示例(Yearning):
{ "rule": "no_select_star", "description": "禁止使用SELECT *,请明确指定字段", "regex": "(?i)SELECT\\s+\\*\\s+FROM", "level": "error" }方案2:ORM层拦截(Java Spring Boot)
在DataSourceProxy中重写executeQuery,对SQL做正则匹配:
if (sql.toLowerCase().matches("select\\s+\\*\\s+from\\s+.*")) { log.warn("Detected SELECT * in SQL: {}", sql); throw new RuntimeException("SELECT * is forbidden. Please specify fields explicitly."); }(上线前需白名单放行部分运维脚本)
方案3:数据库权限隔离
为应用账号分配最小权限:
-- 创建只读角色 CREATE ROLE app_reader; GRANT SELECT(id, name, email, status, created_at) ON users TO app_reader; -- 不授予SELECT ANY TABLE权限这样即使代码写了SELECT *,也会因权限不足报错,倒逼开发修正。
3.4 第四步:监控——让优化效果可量化、可持续
改完不是终点。必须建立长效监控,防止“死灰复燃”。
关键指标看板:
| 指标 | 监控方式 | 健康阈值 | 异常含义 |
|---|---|---|---|
SELECT *调用占比 | 从pg_stat_statements或performance_schema聚合 | < 0.5% | 新增未审核SQL或ORM配置错误 |
| 平均单次查询返回字节数 | SUM(pg_total_relation_size('table')) / COUNT(*) | < 2KB(核心表) | 字段膨胀或大字段滥用 |
| Buffer Pool Hit Ratio | SHOW ENGINE INNODB STATUS | > 95% | 缓存污染,可能由SELECT *+大字段引起 |
| 网络IO等待占比 | pt-query-digest --filter '$event->{Bytes} > 10000' | < 15% | 大结果集传输瓶颈 |
我们给某客户部署后,将SELECT *占比从12.7%压到0.18%,同时观察到:数据库连接池平均活跃连接数下降34%,API P95延迟降低41%,运维告警中“数据库慢查询”类工单减少76%。
4. 场景化避坑指南:这些情况,*真的不能用
理论讲完,必须落到具体场景。下面这些案例,都是我在现场踩过的坑,附带真实参数和修复效果。
4.1 分页查询:OFFSET越大,*越致命
问题SQL:
SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 10000;表有500万行,OFFSET 10000意味着MySQL必须扫描前10020行,丢弃前10000行,只取后20行。而SELECT *让每次扫描都加载全字段,IO成本爆炸。
修复方案:
- 改用游标分页(Cursor-based Pagination):
-- 记录上一页最后一条的created_at SELECT id, name, price, image_url FROM products WHERE created_at < '2023-01-01 10:00:00' ORDER BY created_at DESC LIMIT 20; - 或添加复合索引:
INDEX(created_at, id),让排序走索引,避免文件排序(Filesort)
实测效果:某电商平台将商品列表分页从OFFSET改为游标后,OFFSET 50000的查询耗时从8.2秒降至45ms,数据库CPU峰值下降58%。
4.2 关联查询:*会让JOIN变成灾难
问题SQL:
SELECT * FROM orders o JOIN users u ON o.user_id = u.id JOIN addresses a ON u.id = a.user_id;三张表字段合计超60个,其中users.bio、addresses.full_address均为TEXT类型。一次查询可能返回数MB数据。
修复方案:
- 明确业务需求:订单列表页只需要
o.order_no,u.nickname,a.province - 用子查询预过滤:
SELECT o.order_no, u.nickname, a.province FROM orders o JOIN (SELECT id, nickname FROM users) u ON o.user_id = u.id JOIN (SELECT user_id, province FROM addresses) a ON u.id = a.user_id; - 或用
EXISTS替代JOIN(当只需判断存在性时)
注意:MySQL 8.0+支持
LATERAL子查询,可更优雅地实现字段级关联,但需评估版本兼容性。
4.3 JSON字段滥用:*会让半结构化数据彻底失控
问题现象:某内容管理系统,文章表articles有content_json TEXT字段,存全文Markdown。SELECT * FROM articles WHERE id = 123每次返回2MB+数据,但前端只渲染标题和摘要。
修复方案:
- 在SQL层提取必要片段:
-- MySQL 5.7+ JSON函数 SELECT id, title, author, JSON_UNQUOTE(JSON_EXTRACT(content_json, '$.summary')) AS summary, JSON_LENGTH(content_json, '$.tags') AS tag_count FROM articles WHERE id = 123; - 或在应用层用
JSON_EXTRACT预处理,避免传输大文本
我们帮一家媒体客户实施后,文章详情接口平均响应从1.4秒降至220ms,CDN缓存命中率提升至92%(因响应体变小,更容易被边缘节点缓存)。
4.4 ORM陷阱:你以为没写*,其实框架偷偷加了
MyBatis常见坑:
<!-- 错误:resultType="map" 本质是SELECT * --> <select id="getUser" resultType="map"> SELECT * FROM users WHERE id = #{id} </select>Hibernate更隐蔽:
// 错误:findAll() 默认SELECT * List<User> users = userRepository.findAll(); // 正确:用投影查询 List<Object[]> results = entityManager.createQuery( "SELECT u.id, u.name, u.email FROM User u", Object[].class) .getResultList();终极防御:在ORM配置中强制指定字段。MyBatis Plus的QueryWrapper必须用select()方法:
QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.select("id", "name", "email").eq("status", "active");5. 常见问题与实战排查技巧:从报警到根治的完整闭环
再好的方案,落地时也会遇到各种“意料之外”。以下是我在处理客户问题时整理的高频问题清单,附带真实排查路径和解决命令。
5.1 问题速查表:5分钟定位SELECT *相关故障
| 现象 | 可能原因 | 快速验证命令 | 解决方案 |
|---|---|---|---|
| API响应时间突增,P95从200ms→3s | SELECT *查询触发大字段IO | pt-query-digest --since 1h /var/lib/mysql/slow.log | grep -A5 "SELECT \*" | 找出对应SQL,用EXPLAIN分析执行计划,添加覆盖索引 |
| 数据库CPU持续90%+,但慢查询日志为空 | SELECT *导致Buffer Pool频繁刷脏页 | SHOW ENGINE INNODB STATUS\G | grep -A5 "BUFFER POOL" | 检查Pages made young和not young比例,若后者>30%,说明热页被挤出 |
| 应用内存持续增长,频繁Full GC | JDBC加载大结果集未及时关闭 | jmap -histo:live <pid> | head -20 | 检查java.sql.ResultSet和java.lang.String实例数,确认是否未关闭Resultset |
| 同一SQL在测试环境快,生产环境慢 | 生产表有大字段,测试表无 | SELECT column_name, data_type, character_maximum_length FROM information_schema.columns WHERE table_name='xxx' ORDER BY ordinal_position; | 对比两环境表结构,重点看TEXT/BLOB字段是否存在 |
| 修改表结构后,某个接口突然变慢 | SELECT *导致索引失效 | EXPLAIN FORMAT=JSON SELECT * FROM t WHERE ...对比修改前后 | 若key字段为空或rows暴增,说明索引未被使用,需重构SQL |
5.2 深度排查:用EXPLAIN读懂优化器的“心声”
EXPLAIN是解码SELECT *问题的核心钥匙。但多数人只看type和rows,漏掉关键信息。
以这条SQL为例:
EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE status = 'shipped' AND created_at > '2023-01-01';重点关注JSON输出中的:
"key":实际使用的索引。若为NULL,说明没走索引。"key_length":索引使用长度。若远小于索引定义长度(如索引是(status, created_at, user_id),但key_length只显示6,说明只用了前两个字段),可能因SELECT *导致无法覆盖。"filtered":过滤后剩余行数百分比。若<10%,说明WHERE条件选择性差,需优化条件或加索引。"using_index":是否为覆盖索引。若为false,且查询字段多,基本可判定SELECT *是元凶。
实操技巧:对比SELECT *和SELECT id,name的EXPLAIN结果。若后者出现"using_index": true而前者没有,就是铁证。
5.3 权限与安全加固:防止“好心办坏事”
有团队提出:“我们用SELECT *是为了未来扩展方便,万一加字段不用改SQL”。这想法很危险。
真实案例:某支付系统,风控模块用SELECT * FROM transactions。DBA为审计需要,新增raw_request_data LONGTEXT字段存原始报文。结果风控模型训练脚本因内存溢出崩溃——它试图把每笔交易的原始XML(平均800KB)全加载进Python内存。
加固步骤:
- 权限分离:为不同模块创建专用数据库账号:
CREATE USER 'risk_app'@'%' IDENTIFIED BY 'pwd'; GRANT SELECT(id, amount, status, created_at) ON transactions TO 'risk_app'@'%'; - 字段白名单:在数据库代理层(如ProxySQL)配置重写规则,将
SELECT *自动替换为预设字段列表。 - 审计日志:开启MySQL通用日志,用ELK分析
SELECT *来源IP和应用名,精准定位违规服务。
5.4 性能基线测试:用数据说服团队
技术决策需要数据支撑。我推荐一套轻量级基线测试法:
测试环境:用mysqldump --no-create-info --skip-extended-insert导出10万行生产数据,导入测试库。
对比脚本(bash):
# 测试SELECT *性能 time mysql -u test -ptestdb -e "SELECT * FROM orders WHERE status='shipped' LIMIT 1000;" > /dev/null # 测试精准字段性能 time mysql -u test -ptestdb -e "SELECT order_no, amount, created_at FROM orders WHERE status='shipped' LIMIT 1000;" > /dev/null关键指标:
real时间(总耗时)sys时间(内核态耗时,反映IO压力)vmstat 1观察bi(块输入)和si(交换入)数值
我们给某物流客户做的基线测试显示:SELECT *版本bi平均值为12500 KB/s,精准字段版为1800 KB/s——证明IO压力下降近7倍。这份报告直接推动了全公司SQL规范的落地。
6. 我的实践体会:效率低不是技术问题,而是思维惯性
写完这五千多字,我想说点题外话。在多个项目里推动SELECT *治理时,最大的阻力从来不是技术难度,而是思维惯性。很多资深工程师会说:“我写了十年SQL,*用得好好的,凭什么改?”直到某次线上事故,数据库连接池被打满,所有接口超时,运维在凌晨三点重启服务,而根因就是一句被遗忘的SELECT * FROM logs——它本该只查最近1小时日志,却因没加时间条件,扫了半年数据。
后来我总结出一个朴素道理:数据库不是你的私人笔记本,而是团队共享的精密仪器。每一个SELECT *,都是在未经协商的情况下,擅自调高了所有人的使用成本。
所以,我不再把它当作一个“性能优化点”,而是一种职业素养的体现。就像写代码要加注释、接口要写文档一样,明确写出所需字段,是对协作伙伴最基本的尊重。它强迫你思考:“业务真正需要什么?”而不是“数据库里有什么就拿什么”。
最后分享一个小技巧:在IDE里设置Live Template。比如IntelliJ中,输入sstar自动展开为:
-- TODO: Replace * with explicit columns. Business need: [describe] SELECT id, name, status FROM ${TABLE} WHERE ${CONDITION};让每一次敲击,都成为一次思考的开始。
这个习惯,我坚持了八年。它没让我写出更炫酷的算法,但让我负责的系统,连续37个月零数据库相关P1故障。