从增删改查到性能优化:一份能直接落地的 SQL 完整学习笔记
SQL 这东西,说起来简单,就是“对数据库说话”,但真正能把话说利索的人其实不多。我在带新人和做技术评审时见过太多情况:CRUD 写得飞起,一碰到多表关联、去重统计、条件优先级就开始翻车;或者同样的查询需求,别人 0.1 秒出结果,你的 SQL 跑 10 秒还顺带把数据库锁了半分钟。这篇文章的目标很直接——用一篇文章的篇幅,把 SQL 里的数据定义、数据操作和数据查询三大块从原理到实操讲透,顺手把 sql 去重、between and 边界、where 中 and 和 or 的优先级、with as 临时结果集、窗口函数这些高频踩坑点一并解决。适合零基础入门,也适合干了两三年但始终靠百度拼 SQL 的同学做一次系统梳理。
先说清楚:这里的 SQL 指的是结构化查询语言本身,不是某个具体数据库产品的方言。文中示例在 SQLite、MySQL、PostgreSQL、SQL Server 上基本都能跑通,个别语法差异我会单独标注。
1. 先搞懂 SQL 的三大门派:DDL、DML、DQL
1.1 为什么必须区分这三种语句?
很多初学者学 SQL 就是背单词,select 是查、insert 是插、create 是建表,背完就上手。这种学法最大的问题是,你根本不知道数据库背后在干什么,遇到报错只能瞎试。
其实 SQL 语句按功能分,就是三大类:
- 数据定义语言(DDL,Data Definition Language):负责“建房子”——建库、建表、改表结构、删表。关键词是 create、alter、drop。
- 数据操作语言(DML,Data Manipulation Language):负责“搬家具”——往表里插数据、改数据、删数据。关键词是 insert、update、delete。
- 数据查询语言(DQL,Data Query Language):负责“看房子”——按照各种条件查数据。关键词核心就是 select。
为什么要分这么清?因为不同类型的语句,数据库对它们的处理方式完全不同。DDL 语句执行时通常会隐式提交事务,意思是它一旦执行成功就立刻生效,你没法回滚(在 MySQL 里尤其需要注意)。DML 语句则可以配合事务控制,做到“后悔了还能撤销”。DQL 则只读不改,理论上无论怎么执行都不会破坏数据。理解这一点,你至少不会在生产环境随手 drop 一张表还指望能找回来。
1.2 一本书学 SQL 的最高效路径
我见过太多人学 SQL 的方式,是买一本几百页的大部头,从第一章数据库原理开始读,结果读到第三章就放弃了。说实话,SQL 是门实操语言,正确姿势应该是“带着问题查文档 + 动手建表跑数据”。建议的学习顺序是:
- 第一周:把 DDL 过一遍,自己动手建三张有关联的表(比如用户表、订单表、商品表),反复改字段结构。
- 第二周:主攻 DQL 的单表查询,把 where、group by、order by、limit 用熟。
- 第三到四周:啃多表 join、子查询、窗口函数,配合实际业务问题去写。
- 随时补充:遇到 DML、事务、索引优化的实际问题就专项突破。
我自己带人的时候总是建议他们用 SQLite 起步,因为它零配置、不需要安装数据库服务端,一个文件就是整个库,特别适合练手。等你把 SQL 逻辑练通了,再去摸 SQL Server 或者 MySQL 的安装配置,会发现语言本身根本没变,变的只是环境细节。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据定义(DDL):把表的骨架搭对,后面才不遭罪
2.1 建表语句的正确打开方式
建表是数据库设计的落地环节。语法本身不复杂:
sql复制create table if not exists user_info (
id integer primary key autoincrement,
username varchar(32) not null unique,
email varchar(64) not null,
age integer default 18,
created_at timestamp default current_timestamp
);
这段代码的意思是:创建一张用户信息表,包含自增主键 id、不允许重复的用户名、必填的邮箱、默认年龄 18,以及自动记录创建时间。每一行定义里,除了字段名和类型,后面的 not null、unique、default 这些都属于约束条件。
在实际工作中,我建议即使是做临时表,也要养成写约束的习惯。很多初级工程师觉得约束麻烦,数据先进去再说,结果就是表里一堆重复的脏数据,清洗的时候欲哭无泪。
另一个重要的点是字段类型的选择。以数字为例,MySQL 里有 tinyint、int、bigint 之分,范围不同,占用存储空间也不同。年龄用 tinyint 就够(0 到 255),订单金额要看单位——如果是分,int 能存 21 亿,大概 2000 多万人民币,够用;如果直接存元且涉及大额流水,就得考虑 decimal 或者 bigint。选字段类型的原则是:够用就好,别动不动就 bigint 铺满全表,那是在给数据库加无谓的负担。
2.2 改表和删表:动手之前先问自己三句话
表结构不是一成不变的,业务需求一变,就要用 alter table 来调整:
sql复制-- 新增字段
alter table user_info add column phone varchar(20);
-- 修改字段类型(MySQL 语法)
alter table user_info modify column age smallint default 18;
-- 删除字段
alter table user_info drop column phone;
-- 给字段加索引(查询加速的关键)
create index idx_user_email on user_info(email);
-- 删除整张表(慎用!)
drop table if exists user_info;
改表结构这件事,开发环境里随便折腾,生产环境里就是高危操作。在改任何一张线上表之前,我都要问自己三句话:第一,这个改动会不会锁表?锁表多久?会不会影响正在运行的业务?第二,有没有备份?能不能回滚?第三,有没有通知到所有使用这张表的同事?
关于锁表多说一句:MySQL 5.6 之前,alter table 会锁住整张表,期间所有的读写都卡住。现在大部分版本支持在线 DDL,但也不是完全不锁,特别是大数据量下加索引,依然可能造成主从延迟。如果是核心业务表,动结构之前尽量挑低峰期,或者用专门的在线变更工具去做。
2.3 主键、外键和索引:关系型数据库的灵魂
主键(primary key)是每一行数据的唯一身份证。它有两种玩法:一种是自增整数 id,简单粗暴;另一种是业务主键,比如订单号、身份证号。我的建议是能自增就自增,纯数字的自增 id 在索引效率和关联查询上都更友好。业务主键常有变动可能,改主键的代价是灾难级的。
外键(foreign key)用来保证两张表之间的数据完整性。比如订单表里的 user_id 必须存在于用户表中,否则这条订单就是“无主数据”。很多人为了性能干脆不用外键,由应用层面控制逻辑,这确实是一种取舍。但新手学习阶段,我强烈建议把外键建上,它能逼你理解表之间的关系,等真懂了再考虑要不要去掉。
索引则是最容易被忽视的加速器。它的原理类似书的目录——没有目录就只能一页页翻(全表扫描),有目录才能直接翻到对应章节。索引不是越多越好,每个索引在写入时都要额外维护,所以只给查询频繁的字段建索引就够了。常见的坑是:在 where 条件里对字段做函数运算,比如 where year(created_at) = 2024,这种情况即使建了索引也用不上,因为数据库要先对每个值做一次函数计算才比较。正确写法是范围查询:where created_at >= '2024-01-01' and created_at < '2025-01-01'。
3. 数据操作(DML):增删改背后的逻辑与风险
3.1 insert:插入数据的三种姿势
数据操作的第一件事就是往表里填数据。基础语法是:
sql复制-- 按顺序插入所有字段
insert into user_info values (1, 'zhangsan', 'zhangsan@example.com', 25);
-- 指定字段插入
insert into user_info (username, email) values ('lisi', 'lisi@example.com');
-- 批量插入
insert into user_info (username, email) values
('wangwu', 'wangwu@example.com'),
('zhaoliu', 'zhaoliu@example.com'),
('sunqi', 'sunqi@example.com');
第三种批量插入在生产环境中非常实用。如果你需要一次性写入一万条数据,分一万次 insert 执行,每次都要经过网络传输、SQL解析、事务提交等环节,慢得让人怀疑人生;而一条多 values 的批量 insert,可能只需要毫秒级的时间就搞定了。
在实际项目里还有一种常见的用法是“插入或更新”,在 MySQL 里叫 insert ... on duplicate key update,在 PostgreSQL 里是 insert ... on conflict do update,SQLite 从 3.24.0 开始也支持类似语法。比如统计用户登录次数,今天用户登录了,如果表里没有记录就插入一条计数为 1 的,如果有就把计数加 1,这种场景用一条语句就能原子地完成,避免先查再改造成的并发问题。
3.2 update 和 delete:两大高危操作,务必带上 where
开发环境更新数据是家常便饭,但生产环境里 update 和 delete 没带 where 条件的后果,等同于拿汽油灭火。举一个真实的教训:
sql复制-- 危险操作:不带 where,全表更新
update user_info set age = 30;
-- 正确姿势:精确锁定目标行
update user_info set age = 30 where username = 'zhangsan';
第一句执行完,整张表所有人的年龄都变成 30 了。如果是在生产环境,可能影响几十万用户的数据,而且大部分时候你根本不知道是哪个环节触发的。
delete 同理,更安全的方式是“先查再删”:先用 select 把所有计划删除的数据查出来看一遍,确认数量准确之后,再把 select 换成 delete 执行。还有一个实践建议是在执行影响行数较大的 delete 之前,先备份要删除的数据——老手都会给你推荐这种“留退路”的做法。用 SQL 保存一份备份:
sql复制-- 删数据前先把将被删除的记录存到备份表
create table user_info_deleted as select * from user_info where created_at < '2020-01-01';
-- 确认备份无误后再删除
delete from user_info where created_at < '2020-01-01';
3.3 事务:让数据操作拥有“后悔药”
事务是关系型数据库最重要的特性之一。想象一下网购付款的场景:从你的账户扣钱和给商家账户加钱,这是两个独立的 update 操作。如果第一步成功而第二步失败,钱凭空消失了,这是绝对不能接受的。事务就是把多个操作包成一个整体,要么全部成功,要么全部失败。
sql复制begin transaction;
update account set balance = balance - 100 where user_id = 1;
update account set balance = balance + 100 where user_id = 2;
commit;
上面这段代码执行后,系统崩溃了、网络断了,都没关系——只要没有执行 commit,所有的修改都会被回滚,数据仍然是两台账户余额都保持不变的状态。只有显示执行了 commit,修改才最终落盘。
对于业务开发来说,事务是保命符。但要注意,事务也不是越长越好。事务期间数据库会持有行锁,如果你在一个事务里执行了几个耗时的查询再更新,其他需要更新同一行数据的请求就只能排队等待,严重时会造成大量连接堆积。基本原则是:事务范围要小,能一条 update 完成就别拆三条,事务里坚决不写耗时不定的外部接口调用。
4. 数据查询(DQL):SQL 能力的试金石
4.1 select 基础:不只是“select * from 表名”
几乎所有 SQL 教程都会从 select * from 表 开始,但实际开发中,我几乎不写星号。最直接的原因是效率:如果一张表有 30 个字段,而业务只需要其中 3 个,每次查询把 30 个字段全部读出来,既浪费网络带宽又增加数据库负载。更隐蔽的问题是,如果表结构变了,比如新增了一个超大字段 text 类型,那么之前所有 select * 的接口都会白无故多返回一堆无用数据,拖慢整个接口。
显式列出需要查询的字段还有一个好处:帮自己理清楚到底要什么数据。人脑在思考“我要从这张表拿哪些字段”和“我先把所有字段拉回来再说”这两种模式下,分析问题的深度完全不同。
基础查询的几个核心子句,按逻辑顺序理解是这样的:
- from:告诉我从哪张表查;
- where:先过滤掉不满足条件的行;
- group by:把剩余的行分组做聚合;
- having:对聚合后的分组再做过滤;
- order by:对最终结果排序;
- limit:只取前 N 行。
sql复制-- 一条完整查询各子句的示例
select
department_id,
count(*) as emp_count,
avg(salary) as avg_salary
from employee
where status = 'active'
group by department_id
having count(*) > 5
order by avg_salary desc
limit 10;
这个查询的意思是:找出状态为在职的员工,按部门分组,统计每个部门的人数和平均工资,只保留人数超过 5 人的部门,按平均工资降序排列,最后只返回前 10 条。很多初学者分不清 where 和 having 的区别,一句话总结:where 是在分组之前过滤原始行,having 是在分组之后过滤聚合结果。
4.2 where 条件里的关键语法:and、or、between、like
where 条件字段的写法五花八门,组合起来经常出事。先说很多人踩过的坑:and 和 or 混用时的优先级问题。在 SQL 里,and 的优先级高于 or。看下面这段:
sql复制-- 意图是筛选公司 A 的员工和公司 B 的经理,实际呢?
select * from employee
where company = 'A' or company = 'B' and title = '经理';
因为 and 优先执行,这条 SQL 实际等价于 where company = 'A' or (company = 'B' and title = '经理')。也就是说,它会返回公司 A 的所有员工,加上公司 B 的经理。如果确实想同时满足“公司 A 或公司 B”且“职位是经理”,必须加括号:
sql复制select * from employee
where (company = 'A' or company = 'B') and title = '经理';
这个知识点在 sql 面试里出现频率极高,每次都能筛掉一批人。我的习惯是,只要是 and 和 or 混用的条件,一律显式加括号,既避免记错优先级,也方便同事review代码。
between and 的用法看起来简单,但边界值容易搞错。where age between 18 and 30 等价于 age >= 18 and age <= 30,也就是包含两端。但是注意,between 对日期类型的处理在不同数据库里有差异。在 SQL Server 里,如果你存的是 datetime 类型,between '2024-01-01' and '2024-01-31' 实际上不包含 2024-01-31 00:00:00 之后的时间——这一天的 23:59:59 的记录会漏掉。常规做法是用 >= '2024-01-01' and < '2024-02-01' 这种半开区间。
like 语句最常用在模糊搜索:where username like '张%' 是查所有姓张的用户,百分号表示任意长度的任意字符。where username like '%张%' 是查所有名字里带张的用户。下划线 _ 表示恰好一个字符,比如 like '张_' 只会匹配“张三”,不会匹配“张三丰”。如果业务里要匹配的字符串本身就包含百分号或下划线,记得用转义符 escape 来声明,否则会得到意料之外的结果。
4.3 去重:distinct 和 group by 的取舍
去重查询也是热词里反复出现的需求。SQL 里有两种去重思路:一种是用 distinct,直接把查询结果中重复的行合并且只保留一份;另一种是用 group by,按照某个字段分组,分组天然就把重复值合并了。
sql复制-- 查看表里有多少种不同的部门
select distinct department from employee;
-- 同样效果
select department from employee group by department;
两者单列去重的结果一样,但在需要同时查出聚合信息时场景就不同了。select department, count(*) from employee group by department 能得到每个部门的人数统计,这是 distinct 做不到的。
去重的坑主要在“部分字段去重”。比如用户表里每个人有多个订单,你想知道有订单的用户数,容易写出:
sql复制-- 错误示例:因为是 select distinct *, 只要订单号不同,整行就算不重复
select distinct user_id, order_id from orders;
这时候正确的方法是 select distinct user_id from orders,或者用 group by:select user_id from orders group by user_id。更复杂的去重需求,比如取每个用户最新的一条订单,则要用到窗口函数,后面会专门讲。
4.4 多表查询:join 关联是分水岭
单表查询练熟之后,SQL 水平要往上走,必须跨过多表查询这道坎。表与表之间通过关联字段连接,inner join 是内连接,只返回两边都匹配上的行;left join 是左连接,返回左表的全部行,右表没有匹配的在结果里显示 null。理解 join 的关键在于脑子里要有一张“临时拼接大表”的画面。
sql复制select
u.username,
o.order_id,
o.amount
from user_info u
left join orders o on o.user_id = u.id
where u.status = 'active';
这种写法的意思是以用户为主表,不管用户有没有订单都会被查出来,有订单的会显示对应订单信息,没订单的订单字段就是 null。如果你用 inner join,那么没下过单的用户根本不会出现在结果里。到底是选 inner join 还是 left join,取决于你是“以谁为主”的业务视角。
在这类场景里有一个常见的性能误区和脏数据问题:当你 join 的两张表关联字段或条件有重复时,结果集会呈现笛卡尔积式的膨胀。比如用户表和部门表的关系不是一对多,而是一对多对多,join 后产生重复行会造成统计翻倍。遇到这种数据翻倍的诡异问题,第一步是查清楚——关联字段是不是一张表的唯一键,如果两边都不是唯一键,结果就会膨胀,需要先聚合子查询再去 join。
4.5 子查询与 with as:SQL 的结构化思维
子查询就是“查询里套查询”。写法上有两种位置:放在 from 后面当作临时表,或者放在 where 后面配合 in、exists 做条件判断。
sql复制-- from 子查询:先找到在职员工分布最多的部门,再回头查这些部门的人员明细
select * from employee
where department_id in (
select department_id
from employee
where status = 'active'
group by department_id
having count(*) > 50
);
这种嵌套一多,SQL 就会变得又长又难读。这时候就该用 with as 了。with as 的正式名称是公共表表达式(Common Table Expression,CTE),作用是把查询中反复用到的子查询抽出来,先起个名字,再在后面的主体查询中直接引用它。它最大的意义不是性能提升,而是让你的 SQL 从“一条长面条”变成“一块块积木”,可读性显著提高。
sql复制with active_emp as (
select * from employee where status = 'active'
),
dept_stats as (
select department_id, count(*) as cnt
from active_emp
group by department_id
having count(*) > 50
)
select
e.username,
e.department_id
from active_emp e
inner join dept_stats d on d.department_id = e.department_id;
这里我把“在职员工”和“人数大于 50 的部门”分别定义成了两个 CTE,主查询就是把它们连起来。逻辑清晰到看你代码的人哪怕没有需求文档,也知道这段 SQL 在干什么。我在实际工作中,凡是被迫写三层以上的嵌套子查询,就会本能地重构成 with as,几乎无一例外,能节省大量排查问题的时间。
递归 CTE 是另一个进阶场景,适合处理层级数据,比如组织架构的上下级关系、分类树等。SQL Server、PostgreSQL 都支持 with recursive,MySQL 8.0 也加入了这项能力。如果你经常处理树形结构,非常值得花时间学。
4.6 排序、分页和 Top N:每个业务都跑不掉
order by 是结果展示环节的标配。一个容易忽略的点是排序时的空值处理。在大多数数据库里,order by 默认把 null 排在最前面(升序时),MySQL 也是这样。如果希望 null 排最后,可以写成 order by age is null, age asc。
分页查询是互联网应用的高频需求。在 MySQL 和 PostgreSQL 里用 limit 和 offset:
sql复制select * from orders
order by create_time desc
limit 20 offset 40;
这段 SQL 表示每页 20 条,跳过前面 40 条,也就是取第三页数据。问题出在页数很大的时候,offset 需要扫过前面所有行才能定位到目标位置,所以 offset 1000000 会非常慢。业界对深分页的常见优化,是把“先偏移再取数”改成“基于上一页最后一条记录的位置继续找”:
sql复制select * from orders
where create_time < '2024-06-01 00:00:00'
order by create_time desc
limit 20;
应用层记住上一页最后一条的时间作为入参,每次只往前查一页。这就是所谓“键集分页”,数据量再大,每页的速度都是稳定的。
在 SQL Server 里没有 limit 语法,老版本通常用 select top 20 来实现取前 N 条,从 2012 版本开始也支持了标准的 offset ... fetch next ... rows only 分页写法。不同数据库语法有差异,遇到具体环境时查一下对应版本的文档就好。
4.7 窗口函数:进阶必学的数据分析神器
窗口函数是近年使用频率暴涨的功能。它的核心特点是在不改变行数的情况下,对每一行做聚合计算。普通 group by 会把多行合并成一行,窗口函数则会在结果的每一行上额外展示一个聚合值,可以直观地对比每一行和整体之间的关系。
举一个非常经典的例子:给每个部门按工资从高到低排名,取每个部门的前三名。
sql复制select
department_id,
username,
salary,
row_number() over (partition by department_id order by salary desc) as rank_in_dept
from employee;
这里的 partition by 是“分组窗口”,order by 是组内排序。执行完,你会发现每一行边上都多了一列 rank_in_dept,同一个部门里的员工按照工资高低有了 1、2、3 的序号。再往外套一层查询,where rank_in_dept <= 3 就能精确拿到每个部门工资倒序排行的前三名。这在窗口函数流行之前,往往要靠复杂的三层嵌套子查询或者临时表才能实现。
窗口函数中常用的几类角色:row_number() 给每行一个不重复的序号;rank() 遇到相同排序值会并列并留空位;dense_rank() 并列但不断号。sum() over (partition by ... order by ...) 这种组合可以实现“累计求和”,比如统计每个用户每月的消费累计额。窗口函数的执行顺序发生在 where 和 group by 之后,因此在窗口函数里做不了 where 过滤,只能靠外层查询再包一层。这个语法细节比较绕,建议初学者拿着真实数据,把每一层执行后的结果打印出来看,理解会深刻得多。
5. 实用技能锦囊:从 SQL 注入防御到慢查询排查
5.1 SQL 注入:躲不开的安全必修课
讨论 SQL 注入不是为了教人攻击,而是为了让你真正理解为什么代码规范里总是强调“使用参数化查询”。SQL 注入的原理说穿了很简单:应用程序把用户输入的内容直接拼接到 SQL 语句中,没有做任何校验和转义,导致用户输入“变成了 SQL 代码的一部分”。
以登录场景为例。一条校验用户密码的 SQL 可能是:
sql复制select * from user_info
where username = 'admin' and password = '任意口令';
如果开发者写代码时用字符串拼接的方式组装 SQL:
python复制sql = "select * from user_info where username = '" + username + "' and password = '" + password + "'"
用户把用户名填成 admin' --,那么拼接出来的 SQL 就变成了:
sql复制select * from user_info
where username = 'admin' --' and password = 'xxx'
在 SQL 里,两个连字符是注释符号,后面所有的条件在数据库眼里全部变成了注释。攻击者甚至不需要知道密码,只要知道一个合法的用户名就能直接登录系统。如果这个攻击者再狠一点,在参数里追加一句 union select ...,他甚至能把你数据库里的用户数据全部脱出来。
防御手段优先级排序,我给出的建议是:
- 使用参数化查询或预编译语句,让用户输入永远作为数据,而不是可执行的 SQL 片段。这是最根本的措施。
- 对用户的特殊字符进行过滤或转义,如果因为历史原因没使用参数化,这层是保底。
- 数据库账号权限按最小化原则分配,应用程序里不要用管理员账号连接数据库。
- 不要把数据库的错误信息原样展示给用户,攻击者能根据报错内容一步步推断出表结构。
5.2 常见报错与排查速查表
动手实践过程中,每个人都会遇到几个经典的报错,我把高频问题整理成一张速查表,可以直接对照排查:
| 报错或现象 | 常见原因 | 解决思路 |
|---|---|---|
syntax error near ... |
SQL 语法拼写错误 | 检查关键词是否拼对、逗号分号是否齐全、字符串是否漏了引号 |
unknown column xxx |
字段名写错或该字段不在当前表中 | 先 desc 表名看真实字段,再检查是否被反引号/双引号包错了 |
| 查询结果行数翻倍 | join 的两张表关联字段在某一侧有重复 | 检查关联条件是否一对多,必要时先用子查询去重 |
| 分组统计结果不准 | 用了 group by 但 select 的普通字段不在分组里 | MySQL 的 only_full_group_by 模式下会直接报错,其他低版本则不报但结果随机 |
| 更新巨慢、卡死 | 事务未提交导致行锁/表锁 | 查一下当前连接是否有未结束事务,执行 commit 或 rollback |
division by zero |
计算过程分母为零 | 用 nullif 或 case when 保护除法 |
| 中文乱码 | 客户端和数据库字符集不一致 | 连接串指定 utf8mb4,确认建表时字符集也一致 |
5.3 慢 SQL 优化的一个最小排查流程
工作中经常被叫去“看一条 SQL 为什么跑得慢”。我不喜欢上来就乱加索引,而是遵循下面这个固定流程:
第一步,先看执行计划。MySQL 里在 SQL 前面加 explain,SQL Server 里按 Ctrl+L 显示估计执行计划。重点看 type 列是否出现 all(全表扫描),看 key 列实际命中了哪个索引,看 rows 列估算扫描了大约多少行。
第二步,检查 where 条件中的字段能否利用上索引。最容易出问题的是对索引字段做了隐式类型转换。比如表里 user_id 是字符串类型,但你查询时传的是数字,数据库必须把每一行的字符串转成数字再比较,索引就失效了。
第三步,确认是否真的需要返回那么多行。一次慢查询,大部分时候问题不出在查询本身,而在于它干了太多多余的活——查了根本用不到的字段,关联了根本不需要的表,排序了个巨大的结果集。先做减法,再做优化。
第四步,看数据量级和索引设计。如果表里有几千万数据,却没有任何索引,那肯定是谁来查都慢。这时候根据查询条件里的字段组合,设计一个联合索引,可能直接把扫描行数从千万降低到几十。
给新人的一个建议是:不要试图把每条 SQL 都优化到极致。你的时间应该花在最频繁执行、数据量最大的那部分 SQL 上。一个每天执行一万次且每次耗时 0.2 秒的查询,优化到 0.02 秒的意义,远大于一个每月只跑一次、耗时 10 秒的报表。优化性能之前先量化影响面,才是工程化的做法。
6. 写在最后:关于 SQL 学习的一些实用建议
我从写第一行 select 语句到现在,踩过的坑比看过的教程多得多。最后分享几个经验之谈,不一定系统,但都是实打实的体感。
第一,SQL 是一门练出来的手艺,不是看出来的学问。你收藏一百篇教程,不如亲手建三张表、插一百行数据、写二十条查询。本地装一个 SQLite 或者 Docker 拉一个 MySQL,半小时就能具备全部学习环境。动手过程中遇到的问题,比教程里刻意设计的例子有价值十倍。
第二,写 SQL 时始终问自己两个问题:如果数据量扩大一千倍,这条 SQL 还扛得住吗?如果半年后的自己来看这条 SQL,能一眼看明白吗?前者逼你养成索引意识和避免全表扫描的习惯,后者逼你用 with as、加注释、统一格式来提升可读性。做数据分析也好,做业务开发也好,易读和易维护的 SQL 永远比炫技式的一行流更受欢迎。
第三,任何时候都不要绕过安全底线。参数化查询不是可有可无的最佳实践,而是必须刻在骨子里的基本素养。无论项目多急、代码多烂,SQL 拼接用户输入这条红线一次都不能碰。
SQL 的全貌当然不止这六千多字,索引原理、事务隔离级别、查询优化器、分区表、存储过程这些都还有巨大的挖掘空间。但如果你能把文章里这些基础打得足够扎实,后续深入的学习曲线会平缓得多。数据库是所有软件系统的地基,而 SQL 是你和地基对话的唯一语言——无论你未来做后端、做数据分析还是转行搞架构,这份投入都不会白费。
