1. 为什么要系统过一遍SQL基础,即使你说你"会一点"
作为和数据打交道的日常,我一直有个感受:很多人说"SQL我会一点",真到了写报表、查数据、排查线上问题的时候,却总是卡壳。不是不会写,而是不知道这条语句怎么写才是对的,不知道为什么同样的写法换一种场景就报错,更不知道那些看起来差不多的关键字背后到底有什么差别。基础不牢,后面每一步都在还债。
这篇我把日常工作中最常用、最高频的SQL基础语句完整梳理了一遍,涵盖建表、增删改、查询、分组聚合、连表、函数、事务以及新手最容易踩的坑。不追求讲多么高深的调优和存储过程,而是保证你拿到手就能用、能看懂、能照着写,而且知道每条语句背后的逻辑。适合刚入门的数据分析师、后端开发、运维同学,也适合写过一些SQL但总觉得自己是"野路子"的读者,用它把碎片知识串成体系。
先说明一个前提:本文示例以MySQL的语法为主,但我在关键地方会标注SQL Server、PostgreSQL、Oracle的差异,因为实际工作中经常要在不同数据库之间切换。你最好在本地装一个MySQL练手库,或者哪怕用在线SQL练习平台,边读边敲,效果比单纯看强十倍。
2. 建库建表与字段约束:DDL是后面一切的地基
很多人一上来就写SELECT,问题是连表和字段都没有,查什么呢?DDL(Data Definition Language)虽然看起来只是建库建表,但它决定了你后面所有查询能不能优雅地写出来。我见过太多表结构设计得乱七八糟的项目,光是为字段类型和约束擦屁股就花了大量时间。
2.1 数据库的创建与删除
-- 创建数据库 CREATE DATABASE IF NOT EXISTS demo_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- 查看数据库 SHOW DATABASES; -- 删除数据库(慎用!) DROP DATABASE IF EXISTS demo_db;这里有两个关键点值得展开。第一是字符集,我强烈建议直接用utf8mb4而不是utf8。原因很简单:utf8在MySQL里最多只能存3个字节的字符,像emoji这种4字节字符会直接报错,而utf8mb4是完整兼容的,现在新项目基本默认就是它。虽然网上总有文章吐槽UTF-8的各种历史问题,但实践里你只需要记住——MySQL选utf8mb4,基本不会踩字符集坑。
第二是IF NOT EXISTS和IF EXISTS,这两个修饰符性价极高。脚本要重复执行、自动化部署,没有它在第二次运行时会直接报错中断。往后写的每一个DDL,我都建议默认加上。
2.2 CREATE TABLE:字段类型与约束设计
建表是最能体现一个开发者基本功的地方。举一个实际开发中经常出现的用户表例子:
CREATE TABLE IF NOT EXISTS users ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', username VARCHAR(50) NOT NULL COMMENT '用户名', email VARCHAR(100) NOT NULL COMMENT '邮箱', age TINYINT UNSIGNED DEFAULT 0 COMMENT '年龄', balance DECIMAL(10, 2) NOT NULL DEFAULT 0.00 COMMENT '账户余额', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 0禁用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_email (email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';每个字段类型的选择,都是有讲究的:
INT UNSIGNED:主键一般用整型,UNSIGNED让它只能存非负数,等于把容量翻倍,从21亿变成42亿。VARCHAR(50):长度别拍脑袋,要按实际业务估。存用户名50足够,存邮箱100够用,如果存很长的文本就该用TEXT,并评估是否要把太长的内容拆到单独的表或者用对象存储。DECIMAL(10, 2):凡是涉及钱的字段,绝对不要用FLOAT或DOUBLE。浮点数是近似值,0.1 + 0.2的二进制表示会带来精度丢失。DECIMAL是字符串存储的定点数,精确可靠,做金额计算必须用它。TINYINT:像状态、年龄这种取值范围很小的整数,用TINYINT(-128到127)就够了。它可以配合业务语义当作枚举用,注意TINYINT(1)和TINYINT(4)的括号并不影响存储范围,只是显示宽度。DATETIME+DEFAULT CURRENT_TIMESTAMP:这个组合非常好用。插入数据时自动写入当前时间,ON UPDATE CURRENT_TIMESTAMP会在记录更新时自动刷新,省掉在业务代码里手动维护updated_at的麻烦。
2.3 主键、唯一键、外键与常用约束
约束的目的是让数据库帮我们守住数据底线,而不是完全依赖应用层判断。常用的五种约束必须烂熟于心:
| 约束类型 | 语法示例 | 作用 |
|---|---|---|
| 主键约束 | PRIMARY KEY (id) | 唯一标识一行,一张表只能有一个主键 |
| 非空约束 | NOT NULL | 字段不允许为空 |
| 唯一约束 | UNIQUE KEY uk_email (email) | 字段值不能重复,允许NULL但可有多个 |
| 默认值约束 | DEFAULT 0 | 不传值时自动填默认值 |
| 外键约束 | FOREIGN KEY (dept_id) REFERENCES dept(id) | 保证引用完整性 |
关于外键,我的观点是:互联网业务里,高并发场景下外键约束往往会造成性能瓶颈,因为每次插入和更新都要去检查关联表,还会带来锁竞争。所以很多团队实际开发中禁用外键,改用应用层保证引用关系。但学习阶段,外键仍然需要理解它背后的"引用完整性"思想:你设计表时,一定要想清楚哪张表是主表、哪张表是子表、关联字段有没有索引。
提示: 线上环境改表结构(ALTER TABLE)一定要谨慎,在大表上加字段或索引可能锁表导致业务阻塞。如果是重要表,选业务低峰期操作,或者用迁移工具在线执行。
2.4 ALTER TABLE与DROP:改表和删表的正确姿势
建表之后不可能一成不变,ALTER TABLE是我日常里用得非常频繁的语句:
-- 加一列 ALTER TABLE users ADD COLUMN avatar_url VARCHAR(255) DEFAULT '' COMMENT '头像' AFTER email; -- 修改字段类型 ALTER TABLE users MODIFY COLUMN username VARCHAR(100) NOT NULL COMMENT '用户名'; -- 重命名字段 ALTER TABLE users CHANGE COLUMN email user_email VARCHAR(100) NOT NULL COMMENT '登录邮箱'; -- 删除字段 ALTER TABLE users DROP COLUMN age; -- 添加索引 ALTER TABLE users ADD INDEX idx_username (username);几个细节要注意。AFTER email是指定新列位置,这在维护中很实用,避免新列加到末尾后看数据时觉得别扭。MODIFY用于改字段定义,CHANGE可以同时改字段名和定义,两者别搞混。加索引是为了加速查询,但索引不是越多越好,尤其SELECT *时无关索引只会增加写放大。
3. 数据增删改:INSERT、UPDATE、DELETE与事务的基本功
表结构创造了容器,接下来就是往里面装数据和修改数据。DML语句看起来简单,却是生产事故高发区。每一次数据变更都直接影响业务现状,所以这一章不光讲语法,还要讲安全意识和事务保护。
3.1 INSERT的三种常见写法
最基础的单条插入:
INSERT INTO users (username, email, age, balance) VALUES ('zhangsan', 'zhangsan@example.com', 28, 100.50);批量插入,这是插入大量数据时效率最高的方式:
INSERT INTO users (username, email, age, balance) VALUES ('lisi', 'lisi@example.com', 30, 99.00), ('wangwu', 'wangwu@example.com', 25, 0), ('zhaoliu', 'zhaoliu@example.com', 32, 50.20);还有一种常见场景是从另一张表复制数据:
INSERT INTO users_backup (username, email, age, balance) SELECT username, email, age, balance FROM users WHERE status = 1;批量插入的好处显而易见:一次网络往返,多条记录落库。在数据量几百到几千时性能远超循环单插。不过要注意,一条INSERT的SQL语句不要无限长,数据量很大时建议分片,比如每批500到1000条,免得超过数据库的max_allowed_packet限制。
3.2 UPDATE与DELETE:永远先想WHERE
-- 更新单列 UPDATE users SET status = 0 WHERE id = 1001; -- 更新多列 UPDATE users SET age = age + 1, updated_at = CURRENT_TIMESTAMP WHERE username = 'zhangsan'; -- 删除指定记录 DELETE FROM users WHERE id = 1001;这两条语句我只想强调一个点:WHERE条件必须慎重。UPDATE不带WHERE会更新整表,DELETE不带WHERE会清空整表,这是生产环境最经典的翻车操作。我见过不止一个新手(其实偶尔也有老手犯迷糊)连了测试库当生产库执行,一条语句下去数据全没了,得靠备份恢复。
所以我的习惯是:先在SELECT里把WHERE条件跑一遍,看看影响的行数是否符合预期,确认没问题再把它改成UPDATE或DELETE。另外,UPDATE时加上updated_at = CURRENT_TIMESTAMP能自动维护修改时间,对问题回溯很有帮助。
3.3 DELETE与TRUNCATE的区别到底在哪
很多人分不清DELETE FROM和TRUNCATE TABLE,面试也常被问。它们根本不是一个层面的东西:
| 对比项 | DELETE FROM | TRUNCATE TABLE |
|---|---|---|
| 类型 | DML(可以加WHERE) | DDL(不能加WHERE) |
| 删除范围 | 可以删部分或全部行 | 只能整表清空 |
| 事务回滚 | 支持,可回滚 | 隐式提交,一般不可回滚 |
| 性能 | 逐行删除,较慢 | 直接释放数据页,非常快 |
| 自增ID | 不清零 | 会清零,重新从1开始 |
结论很直接:只是清空一张临时表、日志表、测试表,用TRUNCATE;要精准删除业务数据、可能误删得回滚,用DELETE。还有千万记住,TRUNCATE之后MySQL的InnoDB自增列会从头开始,如果主键有业务语义(不太推荐,但见过),容易造成主键重复。
3.4 事务:多条语句要么全成,要么全败
INSERT、UPDATE、DELETE这些语句天生就和事务绑在一起。事务的四个特性ACID——原子性、一致性、隔离性、持久性,看起来是教科书概念,实际场景中非常真实。
举一个最经典的转账例子:
START TRANSACTION; UPDATE accounts SET balance = balance - 500 WHERE account_id = 1; UPDATE accounts SET balance = balance + 500 WHERE account_id = 2; -- 如果一切正常、影响行数符合预期: COMMIT; -- 如果中间哪一步报错,或者检查发现余额不足: ROLLBACK;这里的关键是原子性:两步扣款和加款必须同时成功或同时失败。如果没有事务,第一步执行完、第二步执行前数据库突然崩溃,钱就凭空消失了。
MySQL默认的InnoDB引擎每条语句是自动提交的(autocommit=1),但多条语句之间要形成整体保护,就必须手动开启事务并用COMMIT或ROLLBACK结束。另外,事务里涉及的行会被加锁,事务内长时间不提交会阻塞其他事务,所以事务要短平快地完成,绝不建议在事务里写循环慢查询或者等待外部网络请求。
4. 查询语句的完整拆解:从一个最简单的SELECT到分组统计
如果说DDL和DML是基本功,那SELECT就是每个写SQL的人真正天天打交道的部分。这一章我会把SELECT的每个子句讲透。记住一个核心观念:书写顺序不等于执行顺序,理解执行顺序才是写对SQL的关键。
4.1 SELECT完整的子句顺序与执行顺序
先看一条相对完整的查询:
SELECT dept_id, COUNT(*) AS cnt, MAX(salary) AS max_salary FROM employees WHERE status = 1 GROUP BY dept_id HAVING COUNT(*) > 5 ORDER BY cnt DESC LIMIT 10;书写顺序就是上面这样,但数据库引擎真正执行的逻辑顺序是:
FROM:确定从哪张表取数据(如果有多表,先生成笛卡尔积或连接结果)WHERE:对源数据逐行过滤,把不满足条件的行扔掉GROUP BY:对过滤后的数据分组HAVING:对分组后的结果进行过滤SELECT:计算并选出需要的列、聚合函数、表达式、别名ORDER BY:对结果排序LIMIT:限制返回行数
理解这个顺序最大的实际收益,是你不会再犯"WHERE里能不能直接用SELECT别名"这种错误——别名在SELECT阶段才产生,WHERE阶段还不存在,所以SQL Server里报"列名无效",MySQL里虽然某些情况下允许用别名(它重写了语法树),但也不建议写这种依赖方言的代码。
4.2 基础查询与去重:SELECT、DISTINCT、AS别名
-- 查询特定列 SELECT username, email FROM users; -- 查询所有列(仅限小表/临时排查) SELECT * FROM users WHERE id = 1001; -- 去重查询 SELECT DISTINCT dept_id FROM employees; -- 列别名与表达式 SELECT username AS 用户名, age + 1 AS next_age FROM users;三条实战守则:
第一,业务代码里尽量少用SELECT *。原因不只是浪费IO,更关键的是你的应用代码如果依赖了列顺序,一旦表结构调整,代码可能静默出错。第二,DISTINCT对多列是"组合去重",不是单独对某一列去重,想知道一张表有多少个不同的部门,就SELECT DISTINCT dept_id FROM employees。第三,别名字符串里的中文要用引号括起来,这在导出Excel报表时很实用。
4.3 WHERE过滤:比较、逻辑、范围、模糊与空值
WHERE是查询里最常用的过滤器,它支持各种条件组合:
-- 比较运算 SELECT * FROM products WHERE price >= 100 AND stock < 20; -- IN范围 SELECT * FROM orders WHERE status IN ('paid', 'shipped'); -- BETWEEN区间 SELECT * FROM orders WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31'; -- LIKE模糊匹配 SELECT * FROM users WHERE username LIKE 'zhang%'; -- NULL空值判断:不能用=,必须用IS NULL SELECT * FROM users WHERE email IS NULL;每个写法都对应一个高频坑:
IN里面数据量大的时候,建议换EXISTS或JOIN,上万级别的IN可能会让性能变得很糟。BETWEEN是边界包含的,即包含左右两个端点。时间范围查询建议用>=和<组合,避免日期时间类型在上边界出问题。LIKE的%在中间或开头的写法(如%keyword%)无法利用普通索引,但如果用全文索引或更后面的"模糊搜索方案"是另一回事,基础阶段只要知道它慢就好。- NULL不是空字符串,它代表"未知"。用
= NULL永远查不到数据,必须IS NULL或IS NOT NULL。这个坑非常普遍。
4.4 ORDER BY排序与LIMIT分页
-- 单列排序 SELECT * FROM products ORDER BY created_at DESC; -- 多列排序:先按价格降序,再按销量降序 SELECT * FROM products ORDER BY price DESC, sales_num DESC; -- 分页:第2页,每页20条 SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 20;排序要关注的是多列之间的优先级:先满足第一个排序键,再在相同值内排第二个键。这个逻辑简单,但漏写第二个键时,看似稳定的数据顺序其实是不确定的。分页时,LIMIT 20 OFFSET 20意思是跳过20条取20条,页面深了OFFSET会越来越大,性能随之变差。另一种写法是借助主键定位(WHERE id > 上一页最大id ORDER BY id LIMIT 20),适合大数据量场景,面试也常考。
MySQL还有一个特有语法LIMIT 20, 20,效果和LIMIT 20 OFFSET 20相同,但可读性差。SQL Server用OFFSET ... FETCH NEXT,Oracle用ROWNUM或者12c之后的OFFSET ... FETCH,博客里不以MySQL为主也可以,但你要是接触别的数据库,至少要能认出这些差异。
4.5 GROUP BY分组与HAVING过滤
分组统计是数据分析的核心操作。GROUP BY把相同值的行聚成一个组,配合聚合函数得到每组的总量、均值、极值:
-- 按部门统计人数和平均薪资 SELECT dept_id, COUNT(*) AS cnt, AVG(salary) AS avg_salary FROM employees GROUP BY dept_id; -- 只保留人数大于5的部门 SELECT dept_id, COUNT(*) AS cnt FROM employees GROUP BY dept_id HAVING COUNT(*) > 5;选出的非聚合列,标准SQL里必须出现在GROUP BY中。MySQL有一个叫做sql_mode设置(ONLY_FULL_GROUP_BY),默认开启后会报错;关了就能容忍一些不完全的写法,但这不代表好习惯。分组之后想要的条件都用HAVING,不要想当然地把聚合条件塞到WHERE——WHERE要的是"行级"条件,WHERE在分组前执行,聚合结果在分组后才有,所以WHERE里不能写聚合函数。这个"先后顺序"就是4.1执行顺序的直接应用。
5. 聚合、字符串、日期与条件函数:常用的内建函数怎么选
SQL函数是让查询从"能跑"到"好用"的润滑剂。但有一个贯穿全文的主题:函数的使用一定要考虑NULL的行为,否则看起来正常的SQL会得出让你半夜挠头的结果。
5.1 聚合函数:COUNT、SUM、AVG、MAX、MIN
-- 统计行数 SELECT COUNT(*) FROM users; SELECT COUNT(id) FROM users; -- 求和、均值、最大最小 SELECT SUM(balance), AVG(balance), MAX(balance), MIN(balance) FROM users;COUNT(*)和COUNT(列)是差别最大的两个。COUNT(*)数的是行数,包括NULL的行;COUNT(列)数的是该列非NULL的行数。SUM、AVG会自动忽略NULL,但如果某列全是NULL,SUM返回NULL而不是0,AVG也返回NULL,这个写业务报表时必须注意,否则前端图表上会莫名奇妙地空白。
AVG的另一个坑是"AVG(a+b)"这种表达式也会被NULL污染,任何值与NULL运算结果都是NULL。所以聚合逻辑里有加减乘除,一定要先想清楚NULL怎么处理,必要时用IFNULL或COALESCE兜底。
还有一种分组后把多个行的值拼成字符串的需求,在统计报表里很常见。MySQL是GROUP_CONCAT,PostgreSQL和SQL Server是STRING_AGG,Oracle是LISTAGG:
-- MySQL SELECT dept_id, GROUP_CONCAT(username ORDER BY id SEPARATOR ',') AS members FROM employees GROUP BY dept_id;5.2 字符串函数:拼接、截取、替换、大小写
SELECT CONCAT(first_name, ' ', last_name) AS full_name, UPPER(email) AS upper_email, LOWER(username) AS lower_username, LENGTH(username) AS len_bytes, CHAR_LENGTH(username) AS len_chars, SUBSTRING(email, 1, 5) AS email_prefix, TRIM(' hello ') AS trimmed, REPLACE('hello world', 'world', 'sql') AS replaced;这里只提醒两个高频失误。一个是LENGTH和CHAR_LENGTH:LENGTH返回的是字节数,在utf8mb4下中文一个字符占3个字节,英文占1个;CHAR_LENGTH才是字符数。想截取按人感知的字符数,用CHAR_LENGTH和SUBSTRING配合,SUBSTRING按字符裁,不会把中文字符劈成两半。另一个是字符串拼接在不同数据库里的写法差异很大:MySQL和PostgreSQL用CONCAT,SQL Server得用+(或较新版本的CONCAT),Oracle用||。多平台开发时,尽量统一封装或注释标明。
5.3 日期函数:格式化、加减、提取
日期时间的操作是真正的重灾区,因为每个数据库都有一套自己的日期函数。这里以MySQL为主:
-- 当前时间 SELECT NOW(), CURDATE(), CURTIME(); -- 日期格式化 SELECT DATE_FORMAT(NOW(), '%Y-%m-%d') AS today, DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s') AS now_str; -- 日期加减 SELECT DATE_ADD('2024-01-01', INTERVAL 1 DAY); SELECT DATE_SUB(NOW(), INTERVAL 7 DAY); -- 提取年月日 SELECT YEAR(created_at), MONTH(created_at), DAY(created_at) FROM orders;日期操作最常见的Bug就是"时间范围查询少了边界"。比如"查2024年1月的订单",新手会写WHERE created_at BETWEEN '2024-01-01' AND '2024-01-31',看起来对,但实际上如果某条记录的created_at是2024-01-31 23:59:59就没事,要是刚好是2024-02-01 00:00:00的极微秒也会在边界外。稳妥写法是:
WHERE created_at >= '2024-01-01' AND created_at < '2024-02-01'这种左闭右开的写法把所有情况都考虑进去了,能避免各种边界问题。
5.4 条件逻辑:CASE WHEN的完整用法
CASE WHEN是SQL里的if-else,它在统计报表中出镜率极高:
SELECT username, CASE WHEN age < 18 THEN '未成年人' WHEN age BETWEEN 18 AND 60 THEN '成年人' ELSE '老年人' END AS age_group FROM users;CASE WHEN还能用来做"条件聚合",把多行记录转换成列状统计。比如统计不同状态的订单数量:
SELECT COUNT(CASE WHEN status = 'paid' THEN 1 END) AS paid_cnt, COUNT(CASE WHEN status = 'shipped' THEN 1 END) AS shipped_cnt, COUNT(CASE WHEN status = 'cancelled' THEN 1 END) AS cancelled_cnt FROM orders;这里巧妙利用了COUNT(表达式)只数非NULL值的特性:条件不满足时返回NULL,不计入总数。这种写法避免了多次扫描表,一条SQL就搞定一张宽表统计。我第一次看到这个写法是在某个项目的报表脚本里,当时觉得又巧妙又简洁,现在它已经成了我写统计SQL的常规武器。
5.5 处理NULL的三个函数:IFNULL、NULLIF、COALESCE
-- IFNULL:MySQL专有 SELECT IFNULL(phone, '无手机号') FROM users; -- NULLIF:相等时返回NULL SELECT NULLIF(a, b) FROM temp; -- COALESCE:返回第一个非NULL值,所有数据库通用 SELECT COALESCE(phone, email, '无联系方式') FROM users;COALESCE是标准SQL,可以接受多个参数,返回第一个非NULL的值。它在多字段兜底时非常好用,比如联系方式有手机号用手机号、没手机号用邮箱、再没有就显示占位文案。写报表时我基本都会用它把所有可能为NULL的展示字段兜一遍底。
6. 连表查询与子查询:多表场景下最实用的写法
现实中几乎没有业务数据是单独放在一张表里的。订单表关联用户表、明细表关联商品表,这些是常态。连表查询(JOIN)是SQL基础里最考验逻辑能力的部分,理解它,你就跨过了"只会查一张表"的阶段。
6.1 为什么需要连表,连表的基本逻辑
先说一个容易混淆的概念:JOIN不是"把两张表拼起来",而是"根据匹配条件把两张表的行组合起来"。这个组合以笛卡尔积为基础——两张表所有行两两组合,然后通过ON条件过滤出我们需要的配对。
如果不写ON条件,SELECT * FROM users, orders产生的就是笛卡尔积,N条用户和M条订单组合成N×M条结果,几乎一定是错的。我刚学SQL时也就是这么把两张小表搞成巨大结果集的,幸好当时数据是模拟的,不然系统得卡死。
JOIN的核心在于:明确"我以哪张表为主",以及"主表和从表之间怎么匹配"。主表决定最终结果的行数,匹配不上的从表列会用NULL填。
6.2 INNER JOIN、LEFT JOIN、RIGHT JOIN、FULL JOIN的区别
最常用的JOIN类型,一张表看清:
| JOIN类型 | 含义 | 结果特征 |
|---|---|---|
| INNER JOIN | 只保留能匹配上的行 | 两边都有才算 |
| LEFT JOIN | 以左表为主,右表没有就用NULL | 左表所有行都保留 |
| RIGHT JOIN | 以右表为主,左表没有就用NULL | 右表所有行都保留 |
| FULL JOIN | 两侧都保留,匹配不上用NULL | 两边所有行都保留(MySQL不直接支持) |
举一个经典场景:用户表和订单表。
-- INNER JOIN:只查下过单的用户 SELECT DISTINCT u.id, u.username FROM users u INNER JOIN orders o ON u.id = o.user_id; -- LEFT JOIN:查所有用户及其最近订单信息,没下单的用户也要显示 SELECT u.id, u.username, o.order_no, o.amount FROM users u LEFT JOIN orders o ON u.id = o.user_id;LEFT JOIN是我日常用得最多的JOIN类型。比如从主表出所有用户,再关联订单信息补充展示,即使这个用户从未下过单也必须出现在列表里。它的关键是:主表行数不会被右表"撑多"。如果一个用户下了三笔订单,LEFT JOIN后这个用户会对应三行,这是正常现象,想再合并可以使用后面提到的聚合手段。
RIGHT JOIN虽然存在,但我实际项目中几乎没见过正经使用的人。原因是大家习惯"主表放左边",RIGHT JOIN无非是换了个方向写,可读性不如LEFT JOIN,还会让排查逻辑的人多绕一圈。FULL JOIN在MySQL里没有原生支持,需要用UNION把LEFT和RIGHT拼起来,我很少用到。
6.3 连表时的ON条件与WHERE条件的误用
这是JOIN的超级高频坑:LEFT JOIN里把右表的过滤条件写在WHERE,结果左表主表的行还是丢了。
-- 错误示范:似乎查所有用户和已支付订单 SELECT u.id, u.username, o.order_no FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.status = 'paid'; -- 正确示范:先限定右表范围再连接 SELECT u.id, u.username, o.order_no FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid';差别就在于WHERE什么时候执行。WHERE是在JOIN之后执行的普通行过滤,o.status = 'paid'会把status为NULL的行(也就是没有订单的用户那行)一并过滤掉,LEFT JOIN瞬间变成了INNER JOIN。而把条件放到ON里,是先让订单表按status过滤,再去做LEFT JOIN,无订单用户依然保留NULL。
这个Bug很隐蔽,因为数据里只要每个用户都有订单,结果就看起来是对的;一旦出现"无订单用户",错得一塌糊涂。排查这种问题没法全靠肉眼,你得心里时刻想着"执行顺序"这张图。
6.4 子查询:IN、EXISTS与FROM子查询
子查询是嵌套在另一个查询里的SELECT,三种位置对应三种用途:
-- WHERE子查询:查出下单金额大于100的用户 SELECT username FROM users WHERE id IN ( SELECT user_id FROM orders WHERE amount > 100 ); -- EXISTS子查询:存在即返回,适合大数据量的存在性判断 SELECT username FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 100 ); -- FROM子查询:把子查询当成临时表继续聚合 SELECT dept_id, AVG(cnt) FROM ( SELECT dept_id, COUNT(*) AS cnt FROM employees GROUP BY dept_id ) t GROUP BY dept_id;子查询的出现让SQL有了组合爆炸般的能力,但也要注意性能。IN在数据量大时有时走全表扫,EXISTS经常会比IN快,但具体还是得看执行计划,不同数据库和版本差异很大。另外FROM子查询必须取别名(上面的t),这是SQL语法硬性规定,漏掉会直接报错。子查询的结果集如果很大,要评估是不是拆成临时表或者用CREATE TABLE AS SELECT落库会更划算。
7. 那些年我踩过的SQL基础坑:错误与纠正清单
最后这一章不是凑字数,而是我实打实踩过、以及周围同事踩过、几乎每周都会在答疑里见到的坑。把这些记牢,能省下你后面很多排查时间。
7.1 没有备份就执行UPDATE/DELETE,或者忘了带WHERE
这是生产事故第一杀手。总觉得"我就改一条数据"、"我就清几行测试数据",结果不带WHERE或者WHERE条件写错,影响范围直接拉到全表。
我的铁律是:操作前先SELECT同款WHERE看行数,操作前确认当前连接的库是哪个,重要表操作前最好有备份或至少确认有可用的binlog。如果公司没有DBA拉数据的能力,你自己更要小心。真出事了也别慌:看binlog、看备份、查慢日志,但要记住,这些手段都是事后的,事前小心才是根本。
7.2 NULL参与运算、COUNT(列)不包括NULL、GROUP BY分组里的NULL
前面反复提过NULL,这里汇总成三句话:
NULL + 1结果是NULL,NULL = NULL结果是NULL(既不真也不假),NULL AND TRUE结果是NULL/未知。COUNT(列)不会统计NULL值,COUNT(*)会。想做"有手机号的人有多少",用COUNT(phone);想要"所有人的总数",用COUNT(*)。- GROUP BY会把所有NULL值分到同一组。如果某个字段存在大量NULL,分组统计会把它们堆到一行里,看到结果别懵。
7.3 WHERE和HAVING的顺序感永远是执行顺序问题
WHERE是"行级过滤器",HAVING是"组级过滤器"。查询里不能用聚合函数条件的地方,就要用HAVING。这个顺序一旦心里有印象,写SQL就自然了,因为你会先想"我该在哪一步扣掉哪些行"。
7.4 字符集与中文乱码
建库立表时用了DEFAULT CHARSET=utf8mb4,连接层也要确认SET NAMES utf8mb4,否则前端传进来的中文可能在连接时被转码,落库就是乱码。现代框架一般会在连接串里配置charset=utf8mb4,但也经常有人从数据库客户端工具直接执行脚本,工具的连接字符集没设置对,照样乱码。排查乱码时先看当前连接的字符集:MySQL里执行SHOW VARIABLES LIKE 'character_set%'能快速定位。
7.5 时间范围查询的边界陷阱
统计"今天"的数据,用WHERE created_at = CURDATE()经常查不出几条,因为created_at是DATETIME,而CURDATE()返回的是DATE,精确到天了。正确写法是:
SELECT * FROM orders WHERE created_at >= CURDATE() AND created_at < CURDATE() + INTERVAL 1 DAY;月初、月初加一天这种左闭右开手法在前面的日期函数部分讲过,再补充一个:不同数据库的日期加减写法不同,PostgreSQL可以直接CURRENT_DATE + 1,SQL Server要用DATEADD(day, 1, CURRENT_TIMESTAMP),别把MySQL的INTERVAL写法硬搬到别的库里。
7.6 表字段太多的时候,SELECT * 真的是偷懒的代价
我知道在临时排查时会用SELECT *,这没问题。但写进存储过程、视图、定时任务,甚至Java/Python代码里的查询,我都会避开它。原因很简单:数据库表加了一列,SELECT *会把新列带出来,但应用层代码不一定准备好了;如果两张表SELECT *做UNION,列数不一致直接报错。列多的时候,SELECT *的IO开销也客观存在,尤其是在宽表场景下。
我的习惯是:凡是结果要被程序读取的查询,永远显式列出字段名。这多花不了几秒钟,却能让代码的健壮性上一个台阶。
8. 最后说一点我的实操体会
SQL基础之所以值得系统地过一遍,是因为它所有"规则"几乎都指向同一个底层逻辑:数据是怎么存储、过滤、组合、聚合的。你只要在脑子里建立起"行、列、执行顺序、NULL语义"这四个概念,绝大多数写法都是水到渠成的。
我个人经验里,最有效练基础的方式不是刷题,而是拿一份真实的业务数据(脱敏后的订单、用户、商品表都行),自己给自己提需求:查上个月每个区域的销售额、找出连续三个月未下单的用户、统计各品类的复购率……需求越贴近真实,写出来的语句越有手感。写完之后再用EXPLAIN看执行计划,甚至故意把条件改错、把JOIN写反,观察结果怎么变,这种"故意踩坑"的做法比背任何语法清单都管用。
如果读这篇的你是学生或刚转行,建议把文中的例子全部亲手敲一遍,再把每个函数的参数换着试几次。SQL这东西,看一眼和做一遍的差距非常大。希望这些内容在你需要时能当做一个随时翻阅的基础手册,少走一点我当年走过的弯路。