SQL 这门语言,说它是数据行业的“普通话”一点不夸张。无论你是后端开发、数据分析师、运维,还是刚转行做测试,只要跟数据库打交道,SQL 就是绕不开的第一道门槛。很多人一开始被各种概念吓住,其实剥开来看,SQL 的核心就三件事:定义数据、操作数据、查询数据。把这套逻辑理清楚,后面再学窗口函数、慢查询优化、ORM 映射这些进阶内容,都是水到渠成的事。
这篇文章我就按这条主线来拆,先从整体学习路径说起,然后一个模块一个模块地过一遍 DDL、DML、DQL 的常用写法,穿插一些我在实际项目中踩过的坑和总结出来的经验。内容尽量贴近真实的开发场景,不搞那种纯理论的说教。适合刚学 SQL 的新手,也适合那些写过一段时间 SQL 但总觉得基础不牢、想系统梳理一遍的同学。
1. 学 SQL 前,先搞清楚它到底能干什么
很多人学 SQL 容易陷入一个误区,就是上来就背语法、刷题,结果背了一堆关键字,到了真实业务场景还是不会用。要解决这个问题,得先建立一个整体认知:SQL 在数据体系里面到底处在什么位置。
1.1 SQL 不是数据库本身,而是和数据库沟通的语言
我先打个比方。你把数据库想成一个大型仓库,里面摆满了货架(表),货架上放着各种分类好的货物(数据)。SQL 就是你跟仓库管理员对话用的那套指令。你不需要自己钻进仓库搬货,你只需要告诉管理员“把 3 号货架第二层所有红色的货物清点一遍”,管理员就会按你的指令去执行。
这个比喻背后对应着一个很重要的概念:SQL 是结构化查询语言(Structured Query Language),它是一种声明式语言。声明式的意思是,你只需要告诉系统“我要什么”,而不需要告诉它“怎么去拿”。数据库引擎会自己决定是走索引、做全表扫描还是调整连接顺序。这一点跟 Java、Python 这种命令式语言有本质区别,初学的时候一定要在脑子里把这个弯转过来。
另外要注意,SQL 本身有标准(比如 SQL:2016),但不同数据库产品在实现上会有方言差异。MySQL、SQL Server、Oracle、PostgreSQL,它们的核心语法基本一致,可一旦涉及分页、字符串拼接、自增主键这些细节,写法就各不相同了。所以学习的时候要有意识地记一下“通用写法”和“产品特有写法”之间的边界,后面换数据库的时候会少踩很多坑。
1.2 三条主线:DDL、DML、DQL
整个 SQL 体系可以粗分为四大类,前三个是这篇文章的重点:
| 分类 | 英文全称 | 作用 | 典型关键字 |
|---|---|---|---|
| 数据定义 | Data Definition Language | 定义数据库对象的结构 | CREATE、ALTER、DROP |
| 数据操作 | Data Manipulation Language | 对表里的数据做增删改 | INSERT、UPDATE、DELETE |
| 数据查询 | Data Query Language | 查询、统计、分析数据 | SELECT、JOIN、GROUP BY |
| 数据控制 | Data Control Language | 权限与事务管理 | GRANT、REVOKE、COMMIT |
你会发现,真正写 SQL 的时间分配上,DQL 能占到八成以上,这也是面试和工作中考察最重的部分。但如果没有 DDL 把表建好,没有 DML 把数据灌进去,查询就成了无源之水。所以我的建议是学习路径按照 DDL → DML → DQL 的顺序走,每一步都建立在前面已经实际操作过的基础上。硬要跳级的话,后面查数据卡住了都不知道问题出在表结构上还是查询语句上,排查起来非常痛苦。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据定义(DDL):会建表才算入门
数据定义这层解决的问题是:数据以什么样的结构存放。就好比你要开一家超市,得先决定货架怎么摆、每个货架放什么类型的商品。放到数据库里,这就是建表、改表、删表的过程。
2.1 CREATE TABLE:一张表的诞生
先看一个最典型的建表语句,我们模拟一个简化版的电商用户表:
sql复制CREATE TABLE users (
id INT NOT NULL AUTO_INCREMENT COMMENT '用户ID,自增主键',
username VARCHAR(50) NOT NULL COMMENT '用户名',
email VARCHAR(100) COMMENT '邮箱',
age INT COMMENT '年龄',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_username (username)
) COMMENT='用户表';
这里有几个关键点需要展开说。第一是数据类型的选择。INT 存整数,VARCHAR 存变长字符串,DATETIME 存时间。很多新手容易犯的错误是用户名用 TEXT,年龄用 VARCHAR,或者把所有字段都定义成 VARCHAR(255)。这种设计短期内看不出问题,但数据量一上来,空间浪费、索引失效、排序错乱全来了。原则很简单:能用数值类型就不用字符串,字符串要按实际长度控制范围。
第二是约束条件的理解。NOT NULL 表示字段不能为空,AUTO_INCREMENT 表示自增(MySQL 语法),PRIMARY KEY 是主键约束,UNIQUE KEY 是唯一约束。这里我多说一句,主键和唯一键都能保证唯一性,但主键更多是逻辑上的行标识,一张表只能有一个主键,可以有多个唯一键。选择谁的邮箱作为唯一键、谁的手机号作为唯一键,要结合业务来定。
第三是 COMMENT 注释。写注释这个习惯非常重要。很多项目里表结构动辄几十上百张,每张表十几个字段,时间一长,写 SQL 的人自己都忘了某个字段的含义。有了字段级注释和表级注释,后面接手的人会感激你。
2.2 ALTER TABLE 与 DROP TABLE:后天的调整和告别
建表很难一次到位,业务一变,表结构就得跟着调整。ALTER TABLE 就是用来做这件事的。常见操作包括:
sql复制-- 增加字段
ALTER TABLE users ADD COLUMN phone VARCHAR(20) COMMENT '手机号';
-- 修改字段类型
ALTER TABLE users MODIFY COLUMN age TINYINT COMMENT '年龄';
-- 修改字段名
ALTER TABLE users CHANGE COLUMN phone mobile VARCHAR(20) COMMENT '手机号';
-- 删除字段
ALTER TABLE users DROP COLUMN mobile;
-- 修改表名
ALTER TABLE users RENAME TO app_users;
这里要特别提醒一个经验:在 MySQL 这类数据库里,对大表执行 ALTER TABLE 是一个非常重的操作。比如一张上千万行的表,你只是加一个字段,就可能锁表很久,导致线上业务阻塞。生产环境的常见做法是借助 pt-online-schema-change 这类工具,在低峰期平滑变更,或者新建一张新表,把数据迁移过去再切换。这个知识点属于运维侧的进阶内容,但学习 DDL 的时候就要有这个意识:结构变更不是随便点一下执行就完事的。
删除表用的是 DROP TABLE,真实项目里要极其谨慎,因为一旦执行,表结构和数据会一起消失。如果你只是想清空数据但保留表结构,应该用 DELETE FROM table(逐行删除)或者 TRUNCATE TABLE table(直接清空)。TRUNCATE 速度远快于 DELETE,而且会重置自增 ID,但它不能像 DELETE 那样加 WHERE 条件,使用时要权衡。
3. 数据操作(DML):增删改是基本功
表建好了,就得往里装数据。DML 管的就是数据行的增加、修改和删除。这部分语法本身不难,难的是在操作中保持数据的一致性和准确性。我见过不少新手写 UPDATE 不带 WHERE,把整张表的数据都改了,这种事故在面试里也经常被当作反面案例来问。
3.1 INSERT:把数据放进去的三种姿势
最基础的插入是指定列名,这也是我最推荐大家使用的写法:
sql复制INSERT INTO users (username, email, age)
VALUES ('zhangsan', 'zhangsan@example.com', 25);
为什么不推荐省略列名的简写 INSERT INTO users VALUES (...)?因为这种写法要求 VALUES 的顺序跟表结构定义完全一致,字段一多,或者表结构调整过,很容易插错。指定列名的写法更清晰,而且可以只插入部分字段。
当你要批量插入多条数据时,可以一条语句拼多个 VALUES:
sql复制INSERT INTO users (username, email, age) VALUES
('lisi', 'lisi@example.com', 30),
('wangwu', 'wangwu@example.com', NULL);
批量插入能显著减少数据库连接的往返次数,写入性能会好很多。另外,现在很多业务会在表里做一个逻辑删除字段 is_deleted,默认值为 0,这样的话同一业务主键可以先查再决定是 UPDATE 还是 INSERT。MySQL 里也有更高级的 INSERT ... ON DUPLICATE KEY UPDATE 语法,遇到唯一键冲突时自动执行更新,这个在数据同步场景非常实用,建议学完基础语法后重点看一下。
3.2 UPDATE 与 DELETE:永远先想 WHERE
更新操作的核心是把“改哪些行”限定清楚:
sql复制UPDATE users
SET age = 26
WHERE username = 'zhangsan';
如果没有 WHERE 条件,就是把全表所有用户的 age 都改成 26。有时候这确实是业务需求(比如全量重置某字段),但大多数情况下是失误。我的实操习惯是,写 UPDATE 之前先写一条同条件 SELECT 确认范围:
sql复制SELECT id FROM users WHERE username = 'zhangsan';
查出来是预期的那几行,再把 SELECT 改成 UPDATE,同时补上 SET。这个习惯虽然看起来多了一步,但能避免太多线上事故。
DELETE 的逻辑类似:
sql复制DELETE FROM users WHERE id = 10086;
同样地,没有 WHERE 就是清空全表。如果真的是清空整张表,用 TRUNCATE 通常效率更高。删除操作还有一个容易忽略的点是外键约束:如果别的表有记录通过外键引用着这张表的某行,直接 DELETE 会报错或者触发级联删除。所以在设计表结构时,外键的 ON DELETE CASCADE 还是 ON DELETE RESTRICT 一定要根据业务想清楚,否则很容易把不该删的数据连带删掉。
4. 数据查询(DQL):SQL 的核心战场
查询是 SQL 里花样最多、也最见功力的部分。从最简单的 SELECT * FROM table 到多表 JOIN、聚合统计、子查询,每一步都对应着不同的业务诉求。这个章节我会从单表查询开始,逐层递进到多表关联,最后收在几个高频的进阶技巧上。
4.1 SELECT 基础:过滤、排序、去重三板斧
先从最简单的查询开始:
sql复制-- 查询所有列
SELECT * FROM users;
-- 查询指定列
SELECT id, username, email FROM users;
SELECT * 在练习和探索数据时很方便,但到了生产环境要尽量避免。原因有两个:一是网络传输不需要的字段会白白占用带宽,二是如果表结构后续加了字段,SELECT * 的行为会变得不可控。规范的写法永远是显式列出你需要的字段名。
过滤条件用的是 WHERE,配合比较运算符、逻辑运算符和范围运算符:
sql复制SELECT id, username, age
FROM users
WHERE age >= 18 AND age < 30;
SELECT id, username
FROM users
WHERE email IS NOT NULL;
这里有几个细节值得新手注意。第一,判断空值要用 IS NULL 或 IS NOT NULL,而不是 = NULL。任何与 NULL 的比较结果都是 UNKNOWN,所以你用 WHERE email = NULL 永远查不出数据。这是一个非常经典的新手坑。第二,AND 的优先级高于 OR,SQL 里同时出现这两个操作符时,如果不确定运算顺序,最好的办法是加括号。比如查询年龄小于 20 岁或者年龄大于 30 岁的用户,写成 WHERE age < 20 OR age > 30 没问题,但如果混着条件就很容易因为忘记括号而出逻辑错误,加括号能让意图一目了然。第三,字符串匹配用 LIKE,其中 % 代表任意多个字符,_ 代表单个字符。WHERE username LIKE 'zhang%' 能匹配到 zhangsan、zhangwei 等。注意 LIKE 在左模糊('%zhang')或全模糊('%zhang%')时无法走索引,数据量大时查询会很慢,能用右模糊就别用左模糊。
ORDER BY 用于排序,默认是升序 ASC,想要降序要显式写 DESC:
sql复制SELECT id, username, age
FROM users
ORDER BY age DESC, id ASC;
排序跟在 WHERE 后面,先过滤再排序,语义别整混了。多字段排序时,从前往后优先级递减。
去重用 DISTINCT:
sql复制SELECT DISTINCT age FROM users;
DISTINCT 的作用范围是后面跟着的所有列的组合。你写 SELECT DISTINCT age, username,去重的是 (age, username) 这个组合,而不是单独对 age 去重。如果只是想去掉某一列的重复值,更合理的做法是后面学 GROUP BY 时按那列分组。顺便说一句,很多人面试时会背“DISTINCT 和 GROUP BY 的去重效果一样”,但它们在执行引擎里可能走不同的路径,性能在不同数据库里表现也不同,还是得结合实际情况验证。
4.2 聚合与分组:从明细数据到统计结果
只会查明细还不够,日常更多的需求是“统计一下”“算个数”。这就要用到聚合函数。常用聚合函数包括 COUNT(计数)、SUM(求和)、AVG(平均值)、MAX(最大值)、MIN(最小值)。
sql复制SELECT
COUNT(*) AS total_count,
AVG(age) AS avg_age,
MAX(age) AS max_age
FROM users;
COUNT() 和 COUNT(column) 的区别值得多说一句。COUNT() 统计的是行数,不管某列是不是 NULL。COUNT(column) 统计的是该列非 NULL 的值的数量。所以如果你要统计用户总量,用 COUNT(*);要统计填写了邮箱的用户数,那才需要 COUNT(email)。
GROUP BY 是配合聚合函数使用的核心语法。它的意思是按某个字段分组,每组算一个聚合结果:
sql复制SELECT age, COUNT(*) AS user_count
FROM users
GROUP BY age;
这条 SQL 的含义是“按年龄分组,统计每个年龄的用户数量”。使用 GROUP BY 时有一个铁律:SELECT 后面的非聚合字段必须出现在 GROUP BY 中。比如你写 SELECT age, username, COUNT(*) FROM users GROUP BY age,在大多数数据库里会直接报错或者结果不可预期,因为一个年龄组里有不止一个 username,数据库不知道该取哪一个。这个规则背后的逻辑是:一旦分组,每个组就只能输出一行,组内每个非聚合字段的值无法唯一确定。
分组之后还要过滤,就要用 HAVING。它的作用和 WHERE 类似,但 WHERE 是在分组之前过滤原始行,HAVING 是在分组之后过滤聚合结果:
sql复制SELECT age, COUNT(*) AS user_count
FROM users
WHERE age > 10
GROUP BY age
HAVING COUNT(*) >= 2;
这条语句先剔除 10 岁以下的用户,再按年龄分组,最后只保留用户数量超过 2 人的年龄组。
4.3 JOIN 多表关联:数据放一起才能回答复杂问题
实际业务很少只查一张表。订单表存了 user_id,但你想展示下单人的用户名,就得去用户表里查。JOIN 就是把多张表按关联条件编织成一张大宽表的技术。
最常见的三种 JOIN:
sql复制-- 内连接:只返回能匹配上的行
SELECT o.order_id, u.username
FROM orders o
INNER JOIN users u ON o.user_id = u.id;
-- 左连接:返回左表全部行,右表没有匹配时填 NULL
SELECT o.order_id, u.username
FROM orders o
LEFT JOIN users u ON o.user_id = u.id;
-- 右连接:返回右表全部行,左表没有匹配时填 NULL
SELECT o.order_id, u.username
FROM users u
RIGHT JOIN orders o ON o.user_id = u.id;
实际开发里,LEFT JOIN 用得最多,RIGHT JOIN 相对少见,因为写 LEFT JOIN 更符合中文的阅读习惯。INNER JOIN 则用来找交集。
有一个非常常见的统计需求是“找出没有下过单的用户”。这就要用到 LEFT JOIN 加空值判断:
sql复制SELECT u.id, u.username
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.user_id IS NULL;
核心原理是:如果用户没下过单,JOIN 之后右表那一侧就是 NULL,所以用 WHERE o.user_id IS NULL 就能筛出来。这个 SQL 在面试中出现的频率很高,一定要自己亲手跑一遍理解透。
JOIN 的绊脚石之一是数据重复。如果用户表的一行在订单表里匹配到了 3 单,那么 JOIN 之后就会产生 3 行。很多新手统计订单总额时先 JOIN 用户表,搞得总额翻了好几倍,就是因为忽略了 JOIN 之前一对多带来的行膨胀问题。我的建议是:在做多表关联之前,心里要先清楚两张表的粒度关系,是一对一、一对多还是多对多,这决定了 JOIN 之后的结果会不会重复。
4.4 子查询与常用技巧:SQL 进阶的分水岭
子查询就是一个查询嵌套在另一个查询里。它有几种常见形态:
sql复制-- 标量子查询:返回单个值,可用于 SELECT 或 WHERE
SELECT username,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = users.id) AS order_count
FROM users;
-- IN 子查询:返回一列值
SELECT * FROM users
WHERE id IN (SELECT DISTINCT user_id FROM orders);
子查询不是任何时候都比 JOIN 好。早期 MySQL 对 IN 子查询的优化不理想,很多情况下改写为 JOIN 之后执行效率更高。现代版本(5.6 之后)做了大量优化,但依然不能掉以轻心。经验法则是:能用 JOIN 表达的关联需求优先用 JOIN;子查询更适合表达“不存在”这种逻辑(通常配合 NOT EXISTS)。这里多提一句 EXISTS 与 IN 的区别:小表驱动大表时它们性能差异明显,但在现代优化器下已经越来越接近了。面试时如果被问到,可以提一句“逻辑上等价,性能取决于数据分布和数据库版本”。
窗口函数是 SQL 学习中后期的一个重要进阶方向,比如 ROW_NUMBER()、RANK()、SUM() OVER (PARTITION BY ...) 这类写法,在处理“分组 TopN”“累计求和”“同比环比”时非常好用。文章开头提到的热词里能看到 SQL 窗口函数是一个高频搜索词,说明市场需求很大。但窗口函数依赖的排序和分组理解又是建立在今天讲的 GROUP BY、聚合函数基础上的,所以别看它高级,先把 4.2 的内容吃透,再上手窗口函数会轻松得多。
4.5 执行顺序:写 SQL 前脑子里要有一张表
写 SQL 这么多年,我最大的体会是:真正常出错的不是单个关键字的用法,而是对 SQL 执行顺序的误解。SQL 的书写顺序是 SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY,但逻辑执行顺序不同:
- FROM:从哪张表取数
- WHERE:对原始行做初步过滤
- GROUP BY:按分组键分组
- HAVING:过滤分组结果
- SELECT:计算和投影需要的列
- ORDER BY:对最终结果排序
- LIMIT:截取一定数量的行
这个顺序能解释很多怪问题。比如你写 SELECT age AS a FROM users WHERE a > 10,某些数据库会报错,因为 WHERE 在 SELECT 之前执行,此时 SELECT 里的别名还没生效。如果你是给用户做培训,我强烈建议把这张执行顺序表放到学习资料的第一页,它能省下一半的调试时间。
5. 从会写 SQL 到写得好:综合案例与常见问题排查
前面几章已经把三类语句过了一遍,但把它们组合起来解决一个真实业务问题时,很多人还是会手忙脚乱。这一章我给出一个贴近实际业务的综合题目,并把平时容易踩的坑按问题清单的方式整理出来,方便你排查和复习。
5.1 一个贴近业务的综合实战
假设现在数据库里有三张表:用户表 users、订单表 orders、订单明细表 order_items。
业务需求是:筛选出 2024 年 1 月之后注册、且至少下过 2 单的用户,计算每个用户的订单总金额,按注册时间倒序展示前 10 名。
先看表结构设计假设:
sql复制CREATE TABLE users (
id INT PRIMARY KEY,
username VARCHAR(50),
reg_time DATETIME
);
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT,
order_time DATETIME,
status VARCHAR(20)
);
CREATE TABLE order_items (
id INT PRIMARY KEY,
order_id INT,
product_name VARCHAR(100),
price DECIMAL(10,2),
quantity INT
);
然后分步拆解。第一步,先定位“每个用户的订单数和订单总金额”,这个子问题需要先把订单表与订单明细表 JOIN 起来,因为订单总金额要累计明细行的 price * quantity:
sql复制SELECT o.user_id,
COUNT(DISTINCT o.id) AS order_cnt,
SUM(oi.price * oi.quantity) AS total_amount
FROM orders o
LEFT JOIN order_items oi ON o.id = oi.order_id
GROUP BY o.user_id;
注意这里的 COUNT(DISTINCT o.id),因为订单表跟明细表 JOIN 后,一个订单会展开成多行,直接 COUNT(*) 会把订单数算多。这是 JOIN 之后最容易踩的坑,用 DISTINCT 包一下主键就能修复。
第二步,把这个子查询作为临时结果,再与用户表关联,加上注册时间筛选和订单数大于等于 2 的限制,最后排序取前 10 名:
sql复制SELECT u.id, u.username, u.reg_time, t.order_cnt, t.total_amount
FROM (
SELECT o.user_id,
COUNT(DISTINCT o.id) AS order_cnt,
SUM(oi.price * oi.quantity) AS total_amount
FROM orders o
LEFT JOIN order_items oi ON o.id = oi.order_id
GROUP BY o.user_id
) t
INNER JOIN users u ON t.user_id = u.id
WHERE u.reg_time >= '2024-01-01 00:00:00'
AND t.order_cnt >= 2
ORDER BY u.reg_time DESC
LIMIT 10;
实际业务中,订单金额要考虑折扣、退款、运费等,情况更复杂,但这个语句已经把“分组聚合 + 子查询 + 多表 JOIN + 过滤 + 排序 + 分页”这些核心内容都串起来了。如果能不看答案独立写出这个 SQL 并解释每一步的意图,那么 SQL 基础关基本算是过了。
5.2 高频问题排查速查表
我把平时带新人和自己运维过程中经常遇到的问题整理成了表格,方便你遇到故障时快速对号入座。
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 查询结果有大量重复行 | JOIN 使一对多数据膨胀 | 用 DISTINCT 主键或先聚合再 JOIN |
| UPDATE/DELETE 影响行数超出预期 | WHERE 条件写错或漏写 | 先 SELECT 确认范围,再执行修改 |
条件判断 = NULL 查不到数据 |
对空值理解有误 | 改用 IS NULL / IS NOT NULL |
| GROUP BY 后结果中非聚合字段值很怪 | SELECT 了非分组字段 | 确保 SELECT 字段都在 GROUP BY 中,或用 ANY_VALUE |
| 查询很慢,数据量大时明显卡顿 | 缺少索引,或 LIKE 左模糊无法走索引 | 用慢查询日志定位,增加合适索引 |
| 多表 JOIN 总额翻倍 | JOIN 后行数膨胀导致 SUM 重复计算 | 先聚合明细再 JOIN,或 COUNT(DISTINCT 主键) |
| 大批量修改导致数据库锁等待 | 单条 UPDATE 波及行数过大 | 分批修改,配合 LIMIT 或按主键范围切片 |
每种问题背后都有对应的知识点,如果你能把表格里的每个“解决方向”对应的 SQL 都亲手写一遍,基础会扎实很多。
5.3 写在最后的一点经验
我自己用 SQL 这么多年,最大的感受是:SQL 靠看是学不会的,必须靠练。练也不是漫无目的地刷题,而是结合业务问题去写。比如你自己手头没有现成的练习环境,可以用数据库自带的示例库,或者随便建一张表格模拟订单、用户、商品这些数据场景,每天给自己出一个小问题:统计每个月的订单量、找出连续两次未购买的用户、计算库存不足的商品清单。这些问题看着简单,但每解决一个,你对 GROUP BY、JOIN、子查询的理解就会深一层。
另外还要保留一个良好的习惯:任何写在生产环境执行的 SQL,尤其是 DELETE、UPDATE、DROP,都先备份,再把语句放到事务里执行,确认无误后再提交。很多线上事故说白了不是不会写 SQL,而是太相信自己写的 SQL。给所有修改操作都预留一个回滚的余地,这句话我每次带新人都要说一遍,因为它真的是用教训换来的。
SQL 的学习曲线并不陡峭,关键是把基础打瓷实,然后把常见模式内化成自己的思维习惯。等你能不看资料写出前面那个实战题时,你已经超过大部分只会在表里点筛选的“取数选手”了。接下来再往窗口函数、执行计划、索引优化这些方向走,会发现一切都顺理成章。
