1. 查询这件事,先想清楚它到底在做什么
这几天在整理项目的查询模块,顺带把手头常用的 SQL、框架层查询方案过了一遍。有个很深的感触:多数人把“查询”当成最简单的活,实际上它才是后端系统里最容易翻车、也最能体现基本功的部分。
所谓“基础查询示例”,不光是教你写几条 SELECT 语句,而是要把查询这件事的来龙去脉理清楚。你写的一条 SELECT * FROM user WHERE age > 18,从发起到结果返回,数据库内部至少要经历:语法解析、语义检查、权限校验、执行计划生成、存储引擎扫描、结果集回传这几个阶段。但凡你对这些阶段没有基本概念,后面遇到慢查询、查不到数据、索引失效的时候,就只能靠猜。
我在工作里见过太多新人,遇到一个“查不准”的需求,第一反应是上网搜“SQL 查询语句汇总”“查询数据库语句大全”,贴一堆代码跑一遍,跑不通就换一版,最后碰运气跑出个结果,也不知道对不对。这种“试错式查询”在数据量小的时候没什么感觉,等上了生产环境、表里有几十万行数据、查询条件交叉几个维度,问题立刻爆炸。
所以这篇内容,我想围绕“查询”这条线,把基础 SQL 的正确写法、框架层查询(MyBatis Plus 为代表)、慢查询分析、常见报错排查这几个核心环节串起来。适合刚入行的后端开发、写业务代码但数据库基础薄弱的同学,也适合那些想系统性梳理查询知识的进阶者。
提示:不管你是用 MySQL、Oracle、SQL Server 还是其他数据库,本文讲的查询思路和排查方法基本通用。只是个别函数的写法有差异,我会在关键处点明。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础查询到底在查什么——理解数据存储与 SQL 执行顺序
2.1 别把表当成 Excel,要先理解行和页的存储逻辑
很多人在写查询时有个思维误区:把数据库表当成一个大的 Excel 表格,以为查询就是“整表从上往下扫一遍,找到匹配的行再返回”。这个理解在数据量小的学习阶段没问题,但真实场景里,表的存储远比想象中复杂。
MySQL 的 InnoDB 引擎中,表数据是按“页”(Page)为单位存储的,默认每页 16KB,行数据落在页里面。页和页之间通过指针形成双向链表,同一页内的行通过单向链表串联。当你执行一条不带任何条件的 SELECT * FROM order_info 时,数据库其实是沿着页链表一页页读出来,扫描全部数据。这个过程叫全表扫描,数据量大时非常慢,而且占用的 IO 资源很高。
理解这个背景有什么用?它能帮你建立“查询成本”的概念:每条查询背后都有实际的资源开销,查询的目标就是用最小成本拿到你想要的数据。索引的本质,就是为数据建立一种更高效的查找路径,避免每次从第一页扫到最后一页。这也是为什么后面讲慢查询优化时,第一件事就是看走没走索引。
2.2 SQL 的“逻辑执行顺序”和你想的不一样
有一个非常重要的基础知识,是 SQL 的编写顺序和逻辑执行顺序不一致:
- 编写顺序:
SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT - 逻辑执行顺序:
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
区别在哪呢?最典型的就是 WHERE 子句中不能使用 SELECT 里定义的别名,因为 SQL 引擎先执行 FROM 和 WHERE,过滤完数据才轮到 SELECT 计算列名。比如下面这条写法在大部分数据库里都会报错:
sql复制SELECT order_amount * 0.9 AS discounted_amount
FROM order_info
WHERE discounted_amount > 100;
正确写法应该把条件写到 HAVING 或子查询里:
sql复制SELECT *
FROM (
SELECT order_amount * 0.9 AS discounted_amount
FROM order_info
) t
WHERE t.discounted_amount > 100;
这种细节看起来很小,但在业务查询里很容易踩。尤其是做报表汇总、多条件动态查询的时候,搞不清执行顺序,写出来的 SQL 常常在不知不觉中把数据过滤错了,而且不报错——这才是最危险的。
2.3 查询需求的第一步:先梳理清楚筛选维度
我在接到一个查询需求时,并不会马上开写 SQL,而是先问业务方三件事:查哪些表、用什么条件过滤、结果集按什么维度展示。这三件事对应到 SQL 里,就是 FROM、WHERE、SELECT 和 GROUP BY 的核心逻辑。
举个例子。假设需求是“查询近 30 天每个品类的订单总量和总金额”。按我的习惯,先拆解:
- 数据来源:订单表
order_info,包含category_id、order_amount、create_time等字段 - 过滤条件:
create_time >= 当前日期 - 30 天 - 聚合维度:按
category_id分组 - 展示内容:品类、订单数量
COUNT(*)、总金额SUM(order_amount)
拆完之后 SQL 就是水到渠成的事。你会发现,基础查询的核心不是背诵语法,而是把模糊的业务需求翻译成结构化的查询条件。这一步做不清楚,后面怎么写都是错。
3. 核心查询操作的实用写法与易错点
3.1 条件查询:WHERE 的正确打开方式
条件查询是使用频率最高的查询类型,常见的方式有等值匹配、范围匹配、模糊匹配、集合匹配。这里我不打算列举所有函数,而是挑几个最容易踩坑的点。
**NULL 值判断必须用 IS NULL,不能用 = NULL。**在 SQL 中,NULL 代表未知值,任何与 NULL 的比较(包括 =、<>、!=)结果都是 UNKNOWN,不会被 WHERE 条件命中。很多人拿 WHERE phone = NULL 去查空手机号的用户,结果一条都查不出来,到处找原因,其实就是这个基础问题。
**字段与数字之间的隐式转换要留意。**假设 user_id 是 VARCHAR 类型,里面存的是字符串数字。你写 WHERE user_id = 1001 时,数据库有可能做隐式类型转换。在 MySQL 里,这种写法常常导致索引失效,查询走全表扫描。安全起见,字符类型的字段就用字符串写法:WHERE user_id = '1001'。
**模糊匹配 LIKE 的前置通配符会让索引失效。**比如 WHERE user_name LIKE '%张%',由于 % 在最前面,数据库无法利用普通索引来加速查找,只能全表扫描。如果业务上确实需要这种模糊查询,数据量大时要考虑全文索引或搜索引擎方案;数据量小的话,硬查也不是不行,但要心里有数。
3.2 去重查询:DISTINCT 与 GROUP BY 的取舍
去重查询也是热搜词里出现频率非常高的需求。通常用 SELECT DISTINCT category_id FROM product_info 就能拿到所有不重复的品类 ID。但这里有一个很多人忽略的点:DISTINCT 是对整行去重,不是对某一列去重。
什么意思呢?如果你写 SELECT DISTINCT category_id, status FROM product_info,数据库的去重维度是 (category_id, status) 这个组合,而不是只看 category_id。结果里可能出现两行 category_id 相同但 status 不同的记录。如果业务上要“只看有哪些品类”,这个写法就是错的。
正确的做法是用 GROUP BY:
sql复制SELECT category_id
FROM product_info
GROUP BY category_id;
当需要同时统计每个品类的数量时,GROUP BY 更顺手:
sql复制SELECT category_id, COUNT(*) AS cnt
FROM product_info
GROUP BY category_id
ORDER BY cnt DESC;
注意:DISTINCT 和 GROUP BY 的执行方式有差异,在大数据集上性能也不同。我的实践体会是,需要带聚合统计时统一用 GROUP BY,逻辑更清晰;纯去重场景用 DISTINCT 代码更简洁。具体选哪种,看可读性和执行计划来定。
3.3 排序与分页:LIMIT 大偏移量的性能陷阱
排序分页是列表页最常见的查询需求。常规写法是 LIMIT offset, size,比如每页 20 条,查第 100 页就是 LIMIT 1980, 20。
这里有个隐藏很深的问题:MySQL 在实现 LIMIT 1980, 20 时,实际会读取前 2000 行,然后丢掉前 1980 行,只返回最后 20 行。当页数越来越多,偏移量越来越大,查询就拿越来越慢。数据量只有几千条时无感,到几十万条以后,第 1000 页的查询可能直接让你接口超时。
优化思路有几个方向:
基于索引覆盖的延迟关联,先查主键再关联回原表:
sql复制SELECT *
FROM order_info t
INNER JOIN (
SELECT id
FROM order_info
ORDER BY id
LIMIT 1980, 20
) t2 ON t.id = t2.id;
基于游标的分页,用 WHERE id > 上一页最后一条ID ORDER BY id LIMIT 20,这种方式不依赖偏移量,数据量大时依然很快,适合滚动加载场景。
我实际项目里,大部分列表页都改造成了游标分页,配合前端“加载更多”的交互模式,体验和性能都好很多。
4. 进阶查询实操:JOIN、子查询与视图
4.1 JOIN 的三种连接方式,何时用哪种
联表查询是业务开发绕不开的环节。最常见的三种 JOIN:
| 连接类型 | 语义 | 返回结果 |
|---|---|---|
| INNER JOIN | 只返回两表匹配成功的行 | 交集 |
| LEFT JOIN | 返回左表全部行,右表没有匹配的用 NULL 填充 | 左表全集 |
| RIGHT JOIN | 返回右表全部行,左表没有匹配的用 NULL 填充 | 右表全集 |
用生活场景来类比:订单表和用户表关联查询,INNER JOIN 相当于“只给有下单记录的用户发优惠券”;LEFT JOIN 相当于“给所有注册用户发优惠券,没下单的也能领,只是下单信息为空”。
实际开发中的教训是:LEFT JOIN 看起来简单,但条件放错位置会导致结果集变大。比如下面这例子:
sql复制SELECT u.user_name, o.order_amount
FROM user_info u
LEFT JOIN order_info o ON u.user_id = o.user_id
WHERE o.order_amount > 100;
这个写法的问题是,WHERE 条件把“没有订单”的用户过滤掉了,LEFT JOIN 实际上退化成 INNER JOIN。如果你想让“没有订单的用户也保留”,条件应该写在 ON 后面:
sql复制SELECT u.user_name, o.order_amount
FROM user_info u
LEFT JOIN order_info o ON u.user_id = o.user_id
AND o.order_amount > 100;
这种细节一旦写错,页面上的数据显示就有偏差。尤其是做报表统计时,左表数据量一大,结果集多一行少一行都很难发现。
4.2 子查询什么时候该改成 JOIN
子查询用起来很直观,特别是 IN 和 EXISTS 的子查询。但性能上有很多坑。举个真实的例子,热搜词里有一条“in查询语句报错”,很可能是子查询结果集太大。早期版本的 MySQL 对 IN (SELECT ...) 做了临时表保存结果,如果子查询返回的数据量达到几十万甚至上百万,临时表可能把内存占满,报 45007 OOM 之类的错误,或者直接卡死。
从优化角度,很多子查询可以改写为 JOIN。比如:
sql复制-- 子查询写法
SELECT *
FROM user_info
WHERE user_id IN (
SELECT user_id
FROM order_info
WHERE status = 'PAID'
);
-- JOIN 改写
SELECT DISTINCT u.*
FROM user_info u
INNER JOIN order_info o ON u.user_id = o.user_id
WHERE o.status = 'PAID';
改写之后,优化器可以更好地利用索引和连接算法。但要注意,JOIN 改写可能导致结果重复(因为 order_info 里一个用户可能有多条支付订单),所以需要加上 DISTINCT 或先对子查询去重。这里没有放之四海而皆准的答案,核心思路是通过 EXPLAIN 对比执行计划,看哪种写法成本更低。
4.3 视图到底能不能加快查询速度
热搜词里有“视图可以加快查询速度吗”,这是个非常经典的问题。很多人以为视图和物化视图是一回事。事实上,普通视图的本质是一条“预编译的 SQL 语句”,它不存储数据,查询视图时数据库会把它展开成背后的 SELECT 语句再执行。所以:
- 视图可以简化复杂查询的编写,把多表关联、复杂计算逻辑封装成一个虚拟表,业务代码里只查视图
- 视图可以起到权限控制的作用,只暴露需要给上层使用的字段
- 但普通视图不会自动加速查询,性能取决于背后 SQL 和表结构有没有走索引
真正能加速的是“物化视图”(Materialized View),它会把查询结果落地成物理表,后续查询直接读结果。但 MySQL 原生不直接支持物化视图,Oracle 和 PostgreSQL 有。如果你的项目在 MySQL 上遇到复杂查询性能问题,可以考虑用一张汇总表配合定时任务来实现类似“手动物化视图”的效果,业务层面按查询维度去做预聚合。
5. 框架层查询的实际操作:以 MyBatis Plus 为例
5.1 动态条件查询的正确姿势
在 Java 后端项目里,MyBatis Plus 是现在非常主流的 ORM 框架。它的 QueryWrapper 和 LambdaQueryWrapper 提供了很方便的动态条件查询能力。
先说一个常见的需求:前端传筛选条件,可能传了用户名,也可能没传;可能传了时间范围,也可能没传。如果用传统 MyBatis XML 拼 SQL,要写一堆 <if test="..."> 判断。而 MyBatis Plus 的封装让代码简洁很多:
java复制LambdaQueryWrapper<OrderInfo> wrapper = Wrappers.lambdaQuery();
wrapper.eq(StringUtils.hasText(userName), OrderInfo::getUserName, userName)
.ge(startTime != null, OrderInfo::getCreateTime, startTime)
.le(endTime != null, OrderInfo::getCreateTime, endTime)
.orderByDesc(OrderInfo::getCreateTime);
List<OrderInfo> list = orderInfoMapper.selectList(wrapper);
注意一个细节:条件为 false 时,eq 方法不会拼接这个条件。比如 userName 为空,StringUtils.hasText(userName) 返回 false,SQL 里就没有 user_name 这个过滤条件。这种写法既安全又清晰,不用再去手动判断字符串是否为空。
但有个坑是:不要把所有业务规则都塞进 QueryWrapper。我在代码评审时见过很多 QueryWrapper 里堆了几十个条件,方法长达几百行,根本无法维护。建议复杂查询抽取成专门的查询条件对象(Query 对象),把条件构建逻辑内聚到独立的查询类或 Service 层方法里。
5.2 逻辑删除的坑:明明查不出数据,为什么还是能看到
热搜词里有一条“mybatis plus 查询 禁用逻辑删除”,这个是很多人会遇到的问题。MyBatis Plus 默认支持逻辑删除,你配置了 @TableLogic 注解后,框架在执行查询时会自动追加 AND deleted = 0 条件。具体场景是:
我在一个项目里看到,运营后台要查看“已删除的用户列表”,但查询接口返回的结果始终为空。排查到最后发现是逻辑删除字段起了作用,框架自动过滤掉了 deleted = 1 的数据。如果业务上确实需要查询包含已删除的数据,可以绕过框架的自动拼接,用自定义 SQL 或 @InterceptorIgnore 注解禁用逻辑删除。
但这里我要提醒一句:“能查出来”和“应不应该查出来”是两件事。逻辑删除的设计初衷就是让业务层默认看不到已删除数据。如果你频繁需要绕过逻辑删除,可能说明数据模型或需求设计有问题,先和业务方确认清楚再动手,别让“绕过”成为习惯。
另一种情况是逻辑删除字段导致唯一索引失效。比如用户表用 user_name 建了唯一索引,用户删除后 deleted 字段设为 1,但 user_name 还是原值。此时再插入一个同名用户,就会和已删除的记录冲突,唯一索引不生效。合理做法是给逻辑删除字段参与的唯一索引设计做调整,比如删除时把 user_name 加上时间戳后缀,或者用生成列来辅助唯一性控制。
5.3 定时任务里执行查询报空指针,问题往往不在 SQL
热搜词里有“timer执行查询是报空指针”,这个我一看就很有共鸣。定时任务里查询报空指针,很多人第一反应是 SQL 写错了,其实多半不是。
我遇到过的典型场景是:定时任务里调用了某个查询服务,但任务启动时 Spring 容器中的依赖没有注入成功,service 对象是 null。又或者是查询结果本身为 null,后续代码直接对结果调方法。
排查建议按以下顺序:
- 先看报错堆栈,定位空指针发生在哪一行代码
- 如果是 service 对象为 null,检查定时任务类有没有被 Spring 管理,
@Autowired是否生效 - 如果是查询结果为空导致的,检查查询条件是否拼错、表数据是否未同步
很多“查询报错”的问题,本质是编码问题,不是查询问题。基础查询练得再多,也要学会先分清错误归属哪一层。
6. 查询性能排查:从慢查询日志到 EXPLAIN
6.1 慢查询日志怎么开,开了之后怎么读
“慢查询日志”是 MySQL 排查性能问题的第一站。开启后,MySQL 会把执行时间超过阈值的 SQL 记录到日志文件里。通过分析这些日志,你能快速定位到哪些查询是性能瓶颈。
在 MySQL 中开启慢查询日志,可以在 my.cnf 配置:
ini复制slow_query_log = ON
slow_query_log_file = /var/log/mysql/slow-query.log
long_query_time = 1
long_query_time 单位是秒,设置为 1 表示查询时间超过 1 秒就记录下来。生产环境一般建议先设为 2 或者 3 秒,避免日志量过大,等优化完再逐步收紧。
日志里每个条目会包含实际执行的 SQL、查询时间、扫描行数、返回行数等信息。我拿到一条慢 SQL,会重点看两个数字:扫描行数和返回行数。如果扫描了 50 万行只返回 10 条,说明索引利用效率很低,大概率是查询条件没有走索引,或者索引区分度不够。
排查时有一条非常实用的流程:
- 第一步:用
EXPLAIN分析执行计划,确认查询是否使用索引 - 第二步:检查 WHERE 条件的字段类型与索引字段是否匹配
- 第三步:结合业务场景,评估是否可以减少扫描行数,比如增加查询条件、调整索引、改写 SQL 结构
6.2 EXPLAIN 的常用列,至少要看懂这几项
EXPLAIN SELECT ... 是 MySQL 提供的执行计划分析工具。很多同学第一眼看到结果表就懵了,其实日常排查只需要把关键的几列看懂:
| 列名 | 含义 | 重点关注 |
|---|---|---|
| type | 访问类型 | 从好到坏依次是 system、const、eq_ref、ref、range、index、ALL |
| key | 实际使用的索引 | 尽量不为 NULL 或 PRIMARY |
| rows | 估算扫描行数 | 越少越好 |
| Extra | 额外信息 | 出现 Using filesort、Using temporary 时要注意 |
type 列是最直观的判断依据。如果看到 ALL,说明是全表扫描,查询大概率需要优化;能看到 range 或 ref,说明条件用上了索引。Using filesort 表示排序没有走索引,需要额外排序操作;Using temporary 表示用了临时表,常见于 GROUP BY 或去重场景,这两类情况在数据量大时都要重点优化。
我之前查一个分页接口,响应时间到了 5 秒。用 EXPLAIN 一看,type 是 ALL,rows 显示 80 多万行。原因很简单:ORDER BY create_time 字段没有索引,数据库被迫把所有数据读出来排序再分页。给 create_time 加上索引后,查询时间降到 200 毫秒左右。很多慢查询不是 SQL 语法问题,而是缺索引的问题。
6.3 分页查询慢,用 Redis 到底怎么优化
热搜词里有一条“分页查询慢怎么用 redis 优化”,这是一个很实际的场景。先说结论:Redis 不能包治百病,分页查询慢的问题要先定位瓶颈,再决定是不是用 Redis。
分页慢的原因常见有两类:
- 大偏移量分页:扫描了太多用不到的行,这是最普遍的瓶颈
- 复杂查询本身计算量大:比如多个表 JOIN 后做 GROUP BY + 排序
对于第一类,可以按前面讲的游标分页方式解决,不一定需要 Redis。Redis 在这里能帮忙的场景,更多是“热点数据缓存”:
比如首页榜单、热门文章列表这类访问量极高的查询,可以把计算好的前 N 页结果直接缓存到 Redis。用 Redis 的 List 结构存分页数据:
bash复制# 缓存第一页数据(假设 key 为 hot_order_list_page:1)
LPUSH hot_order_list_page:1 orderId1 orderId2 orderId3 ...
查询时先查缓存,缓存不存在再查数据库并回填。这种方案的核心逻辑是“用多级缓存扛住高频读,DB 只负责兜底”。
但要注意,缓存数据会面临一致性问题。如果业务允许短期数据不一致(比如热点榜 5 分钟内可以不那么实时),可以放心缓存;如果要求实时强一致,Redis 缓存方案就要谨慎设计,最好结合消息队列同步更新缓存。
7. 常见查询问题排查速查表
结合这几年在生产环境排过的问题,整理了一份速查表。遇到类似情况,先按表里思路排查,比盲目搜答案要高效得多。
| 报错/现象 | 常见原因 | 排查建议 |
|---|---|---|
| 查询结果为空 | WHERE 条件不匹配、NULL 判断写错、逻辑删除过滤 | 先用 SELECT 不带条件确认表数据,再逐步加条件定位 |
| 查询报 OOM(如 45007) | 子查询结果集过大、未分页全量查询 | 查看代码是否一次加载全表,改成 LIMIT 分批查询 |
IN 子查询报错 |
子查询返回结果太大、字段类型不匹配 | 尝试改成 JOIN,或用 EXISTS 改写并对比执行计划 |
| 查询很慢但看不出问题 | 缺索引、索引失效、扫描行数大 | 用 EXPLAIN 看 type 和 rows,确认是否走索引 |
| 定时任务查询报空指针 | Spring 注入失败或查询结果为 null | 先看堆栈,确认是 service 为 null 还是 result 为 null |
| 分页越翻越慢 | LIMIT 大偏移量导致扫描行数增长 | 改成游标分页,或延迟关联优化 |
再补充一个“查询字段是否加索引”的通用判断思路:查询频率高、数据量大、是 WHERE 或 ORDER BY 条件,就应该考虑加索引;频繁更新或写入的字段要谨慎加索引,因为索引本身会拖慢写入速度。索引不是越多越好,而是越精准越好。
8. 实战案例:从分析需求到完成查询优化
为了让这一整套思路更连贯,我拿一个实际需求的完整过程来复盘。
需求背景:一个运营后台需要查询“最近 7 天每个渠道的下单用户数和订单总金额”,展示成报表列表。数据分布在两张表:order_info(订单表)和user_info(用户表),渠道信息存在 user_info.channel_id 字段。
我按前面讲的方法,先把需求拆成三步:
第一步,确定数据来源。
订单表和用户表通过 user_id 关联。渠道维度的分组字段在用户表,金额和数量在订单表。所以查询必须 JOIN,不能只在单表操作。
第二步,确定过滤条件和聚合逻辑。
过滤条件是 create_time >= NOW() - INTERVAL 7 DAY。聚合维度是 channel_id。需要计算的指标是用户数(对 user_id 去重计数)和订单总金额(SUM(order_amount))。
第三步,写出第一版 SQL。
sql复制SELECT u.channel_id,
COUNT(DISTINCT o.user_id) AS user_cnt,
SUM(o.order_amount) AS total_amount
FROM order_info o
INNER JOIN user_info u ON o.user_id = u.user_id
WHERE o.create_time >= NOW() - INTERVAL 7 DAY
GROUP BY u.channel_id
ORDER BY total_amount DESC;
上线前我习惯先看执行计划。EXPLAIN 之后发现 order_info 走了全表扫描,因为 create_time 和 user_id 都没有合适索引。orders 表当时有 300 多万行数据,查询耗时接近 8 秒,根本没法交接给运营。
于是做索引优化:
sql复制ALTER TABLE order_info ADD INDEX idx_create_time_user_id (create_time, user_id);
加完索引后,查询时间从 8 秒降到 300 毫秒左右。这个例子非常典型——SQL 结构本身没问题,缺的只是索引。但如果没有 EXPLAIN 的习惯,很多人会在“SQL 写法”上反复折腾,白白浪费时间。
等到数据量进一步增长,这个实时查询很可能还是会慢。此时可以考虑把 7 天报表做成定时任务预聚合,把结果写入报表表,运营直接查报表表。这就是性能和实时性之间的典型取舍,一般我会先问业务方:能不能接受 5 分钟甚至 1 小时的延迟?能接受,就有很大的优化空间。
9. 最后分享几点我踩过的坑
基础查询写多了,真正影响效率的往往不是语法,而是排查思路。我有几个切身的体会,写在最后。
一是拿到查询需求先确认“业务口径”。什么叫“下单用户数”?同一个用户下两单,算一个人还是两个人?这个口径不确认清楚,COUNT DISTINCT 和 COUNT(*) 写出来的结果天差地别。我做报表时吃过亏,以为业务方说的“用户数”就是去重用户数,结果他们想要的是“下单人次”。后来所有查询需求都养成习惯:先把口径用一句话复述一遍,跟对方确认后再动手。
二是写完 SQL 一定要复盘执行计划。哪怕只是开发环境的小表,也要 EXPLAIN 扫一眼。养成看 type、key、rows 的习惯,实际上比背任何 SQL 优化技巧都管用。只有执行计划拿在手里,你才知道数据库到底怎么执行你的 SQL。
三是不要盲目“优化”SQL。有时候一条 SQL 写法不太优雅,但执行计划很健康,扫描行数少、走了索引、临时表不大,这时候没必要为了“看起来很厉害”去改写成复杂的 JOIN 或子查询结构。改写的风险可能比收益大,特别是涉及线上数据时,宁可少动也不乱动。
四是框架给了你便利,但别丢了 SQL 的底子。用 MyBatis Plus 写条件查询很爽,但框架生成的 SQL 可能和你预期不一致,特别是动态条件组合复杂时。养成一个习惯:把框架生成的 SQL 打印出来看一遍,确认和业务预期完全一致。很多线上 bug 就是条件被某个参数影响,动态拼接出了意料之外的 SQL,肉眼对一遍往往就能发现。
查询这件事,说基础是真基础,说深也是真深。从一条 SELECT 语句开始,背后是数据库原理、索引设计、框架封装、性能调优整个体系的串联。希望这篇内容能帮你在写查询时多想一层,少踩一些我踩过的坑。
