SQL,结构化查询语言,这个缩写几乎贯穿了所有和数据打交道的日常工作。我见过不少刚入行的同事,靠搜索引擎临时拼SQL,遇到报错就抓瞎,更别说看懂别人写的一百行关联查询了。其实SQL基础这块,核心就这么几类操作:建表、增删改、查询、聚合、关联、函数。把它们吃透,工作中八成以上的取数需求都能自己搞定。这篇文章就把这些基础语句按照我日常使用的顺序重新整理一遍,用一个模拟的订单系统做贯穿示例,每段都配上可运行的最小示例和踩坑说明。无论你是准备面试的新人,还是想系统补齐短板的开发者,按这个顺序过一遍,能省不少走弯路的成本。
1. 环境准备与建表基础
1.1 练习环境选哪个
想练SQL,第一步是搞一个能跑的环境。很多人卡在这一步,其实完全没必要纠结。我自己最开始用的是某轻量级嵌入式数据库,一条命令就能启动,连安装都省了,适合纯粹练语法。后来工作切到某主流开源数据库,才发现两者的语法差别很小,绝大多数SELECT、INSERT、UPDATE、DELETE都能直接跑通。真正要留意的是函数命名,比如日期格式化,一边叫DATE_FORMAT,另一边叫TO_CHAR,含义相同但写法不同。文章里的示例我尽量选两边都认识的写法,个别差异处会单独标注。
如果你已经有固定的数据库环境,那更好,直接用就行。练习SQL不需要追求复杂环境,关键是能跑、能试错、能随时重置数据。我自己是这么建议新人的:建一个专门用来练习的库,随便造数据、随便删,就算把表搞坏了也不心疼。生产数据库千万别拿来练手,这是底线。
1.2 先建四张核心表:模拟订单系统
贯穿全文的示例是一个简化版的订单系统,一共四张表:用户表、商品表、订单表。为什么是订单系统?因为它的数据关系足够典型,有主键、外键、数字、日期、字符串,几乎覆盖了基础SQL要处理的所有数据类型。
CREATE TABLE users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, email VARCHAR(100) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE products ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, category VARCHAR(50), price DECIMAL(10, 2) NOT NULL, stock INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE orders ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, product_id INT UNSIGNED NOT NULL, quantity INT NOT NULL DEFAULT 1, amount DECIMAL(10, 2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (product_id) REFERENCES products(id) );这三条CREATE TABLE语句看起来简单,但每个约束都不是随便写的。PRIMARY KEY是主键,保证每一行都能被唯一标识;AUTO_INCREMENT让主键自动递增,不用手工维护;NOT NULL确保关键字段必须有值,避免"幽灵数据";FOREIGN KEY是外键,它告诉数据库:这个表的user_id必须在users表里存在,从源头防止脏数据。
这里我想多说一句外键的事。很多开发为了性能会刻意不用物理外键,只靠应用层校验。但在练习阶段,我强烈建议你把外键建上,因为外键约束能在你写DELETE和关联查询时,帮你直观理解"表与表之间是怎么相互牵制的",这种体感是看文档学不来的。等以后进公司,再根据团队规范决定要不要保留物理外键。
1.3 字段类型别乱选,先看业务再说
字段类型这块,新手最容易犯的错就是凭感觉选。我整理了一个常用对照表,基本覆盖日常业务:
| 类型 | 用途 | 推荐场景 | 避坑提示 |
|---|---|---|---|
| INT / BIGINT | 整数 | 主键、数量、年龄 | 订单量大的表建议BIGINT,避免将来溢出 |
| DECIMAL(p, s) | 定点小数 | 金额、单价、费率 | 千万别用FLOAT存钱,浮点误差会让你对不上账 |
| VARCHAR(n) | 变长字符串 | 姓名、邮箱、商品名 | n别拍脑袋,按业务最大长度上限再留余量 |
| CHAR(n) | 定长字符串 | 状态码、固定编号 | 长度基本固定的短串用CHAR反而省空间 |
| DATETIME | 日期时间 | 创建时间、更新时间 | 不要用字符串存时间,排序和计算都会很痛苦 |
| TEXT | 长文本 | 备注、描述 | 尽量不要当普通字段查,大字段会拖慢查询 |
特别强调金额精度问题。FLOAT和DOUBLE在计算机里是二进制浮点,存89.9这类十进制小数时会有误差,多条记录加起来差异会累积。DECIMAL是定点数,按十进制精确存储,钱相关的一律用它。我在实际项目中接手过一个遗留系统,订单表金额用的是DOUBLE,月底对账总是差几毛钱,排查了一天才定位到是浮点精度问题,从那以后我对金额字段格外敏感。
时间字段的坑也提一下。有人习惯用VARCHAR存"2025-06-01 10:30:00",觉得看着直观。问题是你要查"最近30天"时,必须先做字符串转换,既麻烦又无法走索引。老老实实用DATETIME,查询时再格式化成你想看的样式,这才是正路。
2. 增删改:INSERT、UPDATE、DELETE
2.1 INSERT 插入数据的三种姿势
插入数据最常见的坑不是语法,而是"要不要写列名"。直接看代码:
-- 姿势一:单行插入(推荐写列名) INSERT INTO users (name, email) VALUES ('张三', 'zhangsan@example.com'); -- 姿势二:多行插入,一次搞定 INSERT INTO products (name, category, price, stock) VALUES ('无线鼠标', '外设', 89.00, 120), ('机械键盘', '外设', 299.00, 45), ('27寸显示器', '显示设备', 1299.00, 30); -- 姿势三:从已有表导入 INSERT INTO products (name, category, price, stock) SELECT name, category, price, stock FROM backup_products;写列名有那么重要吗?真的很重要。第一,可读性强,别人一眼看出每个值对应哪个字段;第二,如果以后表结构调整,比如新增了一列,不写列名的INSERT会立刻报错,而写了列名的语句依然能正确执行。第三,某些字段有默认值或自增属性,你只想插入部分字段时,必须写列名才能跳过它们。
多行INSERT是我用得最多的写法。一次插几百上千行,比一条条循环快得多,也方便维护。姿势三的INSERT ... SELECT在做数据迁移、生成测试数据时非常好用,它本质上是把一条查询的结果灌进另一张表。注意SELECT出来的列数要和INSERT的列数严格对齐,多了少了都会报错。
2.2 UPDATE 修改数据前先想一想
UPDATE的基本写法很简单:
-- 把订单102的价格改成299 UPDATE orders SET amount = 299.00 WHERE id = 102; -- 批量改:给外设类商品涨价10% UPDATE products SET price = price * 1.1 WHERE category = '外设';SET子句负责指定"改成什么",WHERE子句负责框定"改哪些行"。后者才是UPDATE的灵魂。如果漏掉WHERE,后果是整张表全部被改。这个错误我见过不止一次,有人在测试库没带WHERE执行了一条UPDATE,结果全表状态统一被刷没了,只能从备份恢复。
我的经验是两条:第一,执行UPDATE之前,先用同样的WHERE条件跑一条SELECT,看看到底会命中哪些行。命中范围符合预期了,再改成UPDATE执行。第二,容易误操作的系统,写UPDATE时养成"先SELECT确认,再UPDATE执行"的习惯,这几乎是零成本的安全措施。第三,如果你不确定条件写对没有,最稳妥的办法是先把操作放到事务里:
BEGIN; UPDATE orders SET status = 2 WHERE user_id = 1001; -- 先不提交,执行一条 SELECT 看看效果,不对劲就 ROLLBACK; COMMIT;事务配上SELECT自查,基本能把误操作的概率降到很低。
2.3 DELETE 删除数据的三种境遇
DELETE是增删改里最需要敬畏的一句,因为删掉往往就找不回来了:
-- 删除指定用户的订单 DELETE FROM orders WHERE user_id = 1001; -- 清空整张表,保留结构 TRUNCATE TABLE orders; -- 连表结构一起删 DROP TABLE orders;DELETE FROM ... WHERE是精确删除,可以带条件,走事务,可以回滚。TRUNCATE是快速清空整张表,它不逐行删除而是直接释放数据页,速度极快,但一般不可回滚,而且会重置自增ID。DROP则直接把表整个扔掉,连结构都不剩,是最彻底的操作。
我在实际工作中有一条铁律:生产环境执行DELETE前,必须先跑等价的SELECT确认命中行数。比如要删一批历史订单,先执行SELECT COUNT(*) FROM orders WHERE created_at < '2024-01-01',确认数量符合预期,再执行DELETE。另外,大批量删除建议分批做,一次删几万行可能锁表很久,影响线上业务。按主键范围分批次删除,每批几百到一两千行,中间稍微停顿一下,对数据库的压力会小很多。
3. SELECT 查询基本功
3.1 一张查询的完整骨架
SELECT是SQL里戏份最重的部分,几乎所有的数据分析工作到最后都是围绕它展开。一条完整查询的骨架是这样的:
SELECT 要显示的列 FROM 数据来源 WHERE 行的过滤条件 GROUP BY 分组依据 HAVING 分组的过滤条件 ORDER BY 排序依据 LIMIT 返回行数;每个子句都有自己的任务:WHERE负责在分组前筛掉不需要的行,GROUP BY负责把相同值的行归成一组,HAVING负责在分组后筛掉不需要的组,ORDER BY决定最后结果的显示顺序,LIMIT控制返回多少行。这个执行顺序和书写顺序不完全一样,理解它很重要——你写的WHERE条件永远先于GROUP BY执行,所以在WHERE里不能直接用聚合函数。
来看一个最基础的查询:
-- 查询所有商品的名称和价格 SELECT name, price FROM products; -- 给列起别名,让结果更易读 SELECT name AS 商品名, price AS 价格 FROM products; -- 去重:看看订单里出现过哪些商品 SELECT DISTINCT product_id FROM orders;这里有两个习惯想强调。一是别名用得上就用,中文别名在报表场景里很常见,但注意别名只是查询结果的显示名,不会改变底层字段。二是绝大多数情况下不要用SELECT *。星号虽然省事,但会把表里所有列都拉回来,不光网络传输浪费,后面如果表里加了个TEXT大字段,查询性能会明显变差。除非是临时排查数据、确认表结构这种场景,否则老老实实列出你需要的列。
3.2 WHERE 条件过滤里最容易错的两个点
WHERE是查询的"安检门",它的写法花样很多,但核心就几类:
-- 比较运算 SELECT * FROM products WHERE price > 100; SELECT * FROM products WHERE price >= 100 AND price <= 500; -- IN:匹配一组值 SELECT * FROM products WHERE category IN ('外设', '配件'); -- BETWEEN:闭区间范围 SELECT * FROM orders WHERE amount BETWEEN 50 AND 500; -- LIKE:模糊匹配 SELECT * FROM products WHERE name LIKE '%键盘%'; -- 空值判断 SELECT * FROM users WHERE email IS NULL;第一个最容易错的是NULL判断。数据库中NULL表示"未定义、不知道",它不等于0,也不等于空字符串,更不等于它自己。用WHERE email = NULL去查,永远查不到任何行,必须写成WHERE email IS NULL。这个坑我见过太多次了,包括我自己刚学SQL时也犯过,原因是直觉上觉得"等于空"就是NULL。实际上NULL不能用等号比较,只能用IS NULL或IS NOT NULL判断。
第二个容易错的是AND和OR混用时忘记加括号。AND的优先级高于OR,所以WHERE a = 1 OR b = 2 AND c = 3实际执行的是WHERE a = 1 OR (b = 2 AND c = 3),跟很多人想的不一样。一旦逻辑复杂,就无脑加括号,既保证正确,也让别人读代码时不用猜。
3.3 排序与分页:让结果按你的预期出现
数据查询出来往往不是有序的,数据库返回的顺序是由存储引擎决定的,不会因为你插入的先后而固定。想要可控的顺序,必须显式指定ORDER BY:
-- 按价格从高到低排序 SELECT name, price FROM products ORDER BY price DESC; -- 多列排序:先按分类排,同类里再按价格降序 SELECT name, category, price FROM products ORDER BY category ASC, price DESC; -- 只拿最贵的三个商品 SELECT name, price FROM products ORDER BY price DESC LIMIT 3;多列排序时,ORDER BY后面的列是有先后权重的。上面那句的意思是:整个结果集先按category升序排,category相同的行之间再按price降序排。理解这一点,写报表时才能控制好每一层的排列逻辑。
分页是列表页最常见的需求,LIMIT后面接两个参数时,第一个是偏移量,第二个是返回条数:
-- 第一页:每页10条 SELECT * FROM orders ORDER BY id LIMIT 0, 10; -- 第二页 SELECT * FROM orders ORDER BY id LIMIT 10, 10; -- 第三页 SELECT * FROM orders ORDER BY id LIMIT 20, 10;偏移量的计算公式是(页码 - 1) * 每页条数。第3页就是偏移20条。这个公式不用背,推一遍就记住了:第一页从头开始,偏移0;第二页跳过第一页的10条,偏移10;第三页再跳过第二页的10条,偏移20。注意LIMIT和大偏移量有个性能问题,越往后翻页越慢,因为数据库要扫描并丢弃前面的行。深分页在生产环境是个常见痛点,但那是进阶话题,基础阶段先把语法搞对。
4. 分组聚合:从明细数据到汇总数字
4.1 五个聚合函数逐个说透
聚合函数的作用是把多行数据汇总成一个值。最常见的五个是COUNT、SUM、AVG、MAX、MIN,每一个都有使用细节:
-- 用户总数 SELECT COUNT(*) FROM users; -- 有邮箱的用户数 SELECT COUNT(email) FROM users; -- 出现过多少个不同商品 SELECT COUNT(DISTINCT product_id) FROM orders; -- 订单总金额 SELECT SUM(amount) FROM orders; -- 平均每单金额,保留两位小数 SELECT ROUND(AVG(amount), 2) FROM orders; -- 最贵和最便宜的商品价格 SELECT MAX(price), MIN(price) FROM products;COUNT()和COUNT(列)的区别很微妙但很重要。COUNT()统计的是行数,包含那些其他字段为NULL的行;COUNT(列名)只统计该列非NULL的行数。如果一列里有NULL,两个结果会不一样。所以统计"订单总数"用COUNT(*),统计"填写了邮箱的用户数"用COUNT(email),不要搞混。
另外要注意SUM和AVG在计算时会自动忽略NULL值,不会把NULL当成0加起来。这通常是有利的,但如果你希望"没有值就算0",需要配合COALESCE函数处理,后面函数章节会讲到。
4.2 GROUP BY 分组与 HAVING 过滤
分组是SQL里第一个真正的分水岭。把一堆明细数据按某个维度揉成一个个小组,再对每个组做聚合计算:
-- 每个分类下的商品数量和平均价格 SELECT category, COUNT(*) AS 商品数, AVG(price) AS 平均价 FROM products GROUP BY category; -- 统计每个用户下了多少单、共花了多少钱 SELECT user_id, COUNT(*) AS 订单数, SUM(amount) AS 总金额 FROM orders GROUP BY user_id;GROUP BY会把相同category的行合并成一组,然后你SELECT里出现的非聚合列,就必须出现在GROUP BY里。这是很多新手报错的原因之一:SELECT了某个普通列,GROUP BY里却没有它,数据库不知道该从组合里取哪一行,只能报错。
HAVING的作用则是"分组之后的过滤"。WHERE在分组前执行,HAVING在分组后执行。比如想看哪些分类的商品数量超过5个:
SELECT category, COUNT(*) AS 商品数 FROM products GROUP BY category HAVING COUNT(*) > 5;有人会问:为什么不能直接写WHERE COUNT(*) > 5?因为WHERE执行的时候GROUP BY还没跑,分组结果根本不存在,聚合函数当然不能用在WHERE里。记住一句话:过滤单行用WHERE,过滤分组结果用HAVING。
4.3 用一个SQL统计多种状态的条件聚合
报表场景里有个特别实用的技巧:用CASE WHEN配合SUM,一行SQL同时统计出多个状态的数量。比如订单状态有"待支付、已支付、已发货、已完成"四种,想一次性看出各状态订单数:
SELECT SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) AS 待支付, SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS 已支付, SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) AS 已发货, SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) AS 已完成 FROM orders;这个写法的精妙之处在于:每个CASE WHEN会逐行检查状态,满足条件输出1,不满足输出0,再被SUM加总,结果就是该状态的行数。一条查询返回一行汇总,前端报表直接拿来用,非常直观。类似地,还可以用MAX(CASE WHEN ...)做行转列,不过那属于进阶玩法,这里先留个引子。
5. 多表关联与子查询
5.1 JOIN的三兄弟:INNER、LEFT、RIGHT
单表查询解决不了所有问题。订单表里只存了user_id和product_id,你想看订单主人的名字、商品的名字,就必须把三张表关联起来。JOIN就是干这个的:
-- 内连接:只返回两表都匹配的行 SELECT o.id, u.name, p.name AS product_name, o.amount FROM orders o INNER JOIN users u ON o.user_id = u.id INNER JOIN products p ON o.product_id = p.id;INNER JOIN是最常见也最安全的连接,它只返回能对上号的行。订单表的user_id能在users表里找到名字,才出现在结果里;找不到的订单会被丢弃。ON后面写的是关联条件,本质上是在告诉数据库"两张表通过哪两个字段对上"。
LEFT JOIN则不同,它把左表的行全部保留,右表匹配不上就填空值:
-- 列出所有商品以及它们被下单的次数(没被下单的商品也要显示) SELECT p.name, COUNT(o.id) AS 下单次数 FROM products p LEFT JOIN orders o ON p.product_id = o.id GROUP BY p.name;这里LEFT JOIN的意义在于:商品可能没有被任何订单引用,但如果用INNER JOIN,这些没销量的商品就凭空消失了。业务上要"列出所有商品"时,必须用LEFT JOIN保住左表的完整性。RIGHT JOIN和LEFT JOIN对称,只是换边保留,实际工作中用得少,知道含义就行。
关联表时还有个细节:如果多张表有同名字段,比如users和orders都有id,SELECT时必须用表别名.字段名区分,否则数据库会报"字段不明确"。上面的示例里我给表起了o、u、p三个别名,写起来短,结果列也不容易混淆。
5.2 子查询的三个核心场景
子查询就是"嵌套在另一个查询里的查询",相当于先算出一个中间结果,再拿它继续查询。最常见的有三种:
-- 场景一:WHERE里用子查询,找出下过单的用户 SELECT name FROM users WHERE id IN (SELECT DISTINCT user_id FROM orders); -- 场景二:FROM里用子查询,先统计再关联 SELECT u.name, t.订单数 FROM users u INNER JOIN ( SELECT user_id, COUNT(*) AS 订单数 FROM orders GROUP BY user_id ) t ON u.id = t.user_id; -- 场景三:EXISTS判断存在性,找出有订单的用户 SELECT name FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id );场景一里,子查询先找出所有下过单的user_id,外层用户表只要id在这些ID里就返回。场景二更典型:内层子查询把订单表按用户分组统计出订单数,形成一个派生表t,外层再和用户表关联,这样既能显示用户名又能带出统计数字。注意FROM子句里的子查询必须起别名,比如这里的t,不然会直接报错。
场景三的EXISTS写法在数据量大时性能通常优于IN,因为它找到第一条匹配就会停下,不需要把子查询结果全部算出来。基础阶段建议先把IN写法掌握了,遇到慢查询时再考虑把IN改成EXISTS来优化。
5.3 关联查询要建立的三个性能意识
多表查询写起来爽,跑起来慢的例子我也见过不少。建表时就要给关联字段建索引,这是JOIN查询性能的基石。比如orders表的user_id和product_id都有外键,多数数据库会自动建索引;如果外键没建,JOIN时就得一张表一张表地做全表扫描,数据量一大立刻卡死。
第二,ON条件里的关联字段类型必须一致。一个是INT一个是VARCHAR,数据库会做隐式转换,索引可能就失效了。第三,能用一条JOIN解决的事情,别拆成三五条查询在代码里拼接,减少数据库往返的次数,很多时候一次复杂查询比多次简单查询快得多。
6. 常用函数与条件表达式
6.1 字符串函数:最常用的一组
业务数据里字符串占了半壁江山,姓名、邮箱、商品名、备注,全都要处理。常用的就这么几个:
-- 拼接:把姓名和邮箱拼成"张三 <zhangsan@example.com>" SELECT CONCAT(name, ' <', email, '>') FROM users; -- 截取:取邮箱的前5个字符 SELECT SUBSTRING(email, 1, 5) FROM users; -- 大小写转换 SELECT UPPER(name), LOWER(email) FROM users; -- 去掉首尾空格 SELECT TRIM(name) FROM users; -- 替换:把商品名里的"USB"替换成"Type-C" SELECT REPLACE(name, 'USB', 'Type-C') FROM products;CONCAT有一个经典的坑:只要其中任何一个参数是NULL,整个结果就是NULL。比如name不是NULL但email是NULL,拼接结果就成了NULL,这往往不是你想要的效果。稳妥做法是给可能为NULL的字段包一层COALESCE:CONCAT(name, ' <', COALESCE(email, '无'), '>')。COALESCE的作用是取参数列表中第一个非NULL的值,相当于给NULL设置默认值。
另外提醒一下LENGTH和CHAR_LENGTH的区别。很多数据库的LENGTH返回的是字节数,一个中文占3个字节,所以LENGTH('张三')结果是6而不是2;CHAR_LENGTH返回的是字符数,结果是2。要做显示长度判断时记得用CHAR_LENGTH,否则中文场景会算错。
6.2 日期函数:时间数据的日常操作
时间字段的处理是报表需求里的大头,几乎每个统计都和"最近N天""本月""去年"打交道:
-- 今天的日期 SELECT CURRENT_DATE; -- 提取年份,查2025年的订单 SELECT COUNT(*) FROM orders WHERE YEAR(created_at) = 2025; -- 最近30天的订单 SELECT COUNT(*) FROM orders WHERE created_at >= CURRENT_DATE - INTERVAL 30 DAY; -- 格式化为"2025-06-01"形式的日期 SELECT DATE_FORMAT(created_at, '%Y-%m-%d') FROM orders;不同数据库的函数名差异主要集中在这一块:MySQL用DATE_FORMAT,PostgreSQL用TO_CHAR;日期加减MySQL可以直接用CURRENT_DATE - INTERVAL 30 DAY,PostgreSQL也支持类似的INTERVAL语法。这里给出的是兼容性相对好的写法,如果你固定用一种数据库,建议去查一下官方手册里的日期函数清单,值得花半小时扫一遍。
日期查询有个性能相关的点:在created_at上套YEAR()函数会导致索引失效。要查2025年的数据,更好的写法是WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01',让数据库直接在索引上做范围比较。这个习惯趁早养成,等到线上慢查询出现再改就晚了。
6.3 CASE WHEN:给数据分门别类
CASE WHEN本质是SQL里实现"如果...那么..."的表达式,它不改变行数,而是根据条件生成一个新列:
-- 把订单金额分成三档 SELECT id, amount, CASE WHEN amount < 100 THEN '小额订单' WHEN amount < 500 THEN '中额订单' ELSE '大额订单' END AS amount_level FROM orders;这个搜索CASE的写法会从上往下逐条判断,命中第一个满足条件的就返回对应结果,后面的不再看。所以条件的顺序很关键:把范围更窄的条件放前面。如果上面的例子先写WHEN amount < 500,那所有金额小于500的订单都会落入"中额订单","小额订单"永远不会被命中。这类逻辑错误跑出来的报表数据看起来没错,实际逻辑已经歪了,排查起来还不容易发现,写的时候就要小心。
CASE WHEN不只用在SELECT里,也可以用在WHERE、ORDER BY里。比如想让"待支付"状态的订单排在前面,可以用ORDER BY CASE WHEN status = 0 THEN 0 ELSE 1 END。灵活用好这个表达式,很多原本要在程序代码里写的判断逻辑,都能下沉到SQL里一次搞定。
7. 常见报错与避坑经验
7.1 高频报错速查表
把这些年见过最多的报错整理成一张速查表,遇到问题先对着看:
| 报错现象 | 常见原因 | 快速解法 |
|---|---|---|
| Unknown column 'xxx' in 'field list' | 字段名拼错或表里根本没这列 | 用DESCRIBE表名看字段清单 |
| You have an error in your SQL syntax | 漏了逗号、引号或写错关键字 | 从报错位置往上看最近的语句 |
| Duplicate column name | 多表JOIN时SELECT里有重名字段 | 给字段加表别名或重命名 |
| Every derived table must have its own alias | FROM子句里的子查询没起别名 | 子查询后面加别名 |
| Foreign key constraint fails | 删了被引用表的行,或插入外键值不存在 | 检查关联数据,先处理从表 |
| Unknown table xxx | 表名写错或没加库名前缀 | 确认当前所在的数据库 |
这些报错基本覆盖了新手前三个月的出错量。其实SQL的报错信息都很直白,关键是别慌,把报错当线索往回找。在语句中间多加几个空格、拆开执行,定位错误的速度会快很多。
7.2 写SQL的几个坏习惯,趁早改掉
有些写法当时很爽,到线上就是事故苗子。第一个是SELECT *,前面讲过,既拖性能又让代码语义不清。第二个是在索引列上套函数,像WHERE YEAR(created_at) = 2025、WHERE LEFT(name, 2) = 'USB',函数一包装,索引就没法用了,查询被迫全表扫描。第三是模糊匹配用LIKE '%关键字%',前导通配符同样让索引失效。不是说不可以这么用,但要意识到代价,数据量小的时候无所谓,数据上千万之后就卡给你看。
第四是隐式类型转换。str字段存的是字符串,条件里却写成WHERE str = 123,数据库会把字符串转成数字再比较,结果可能出乎意料,索引也经常失效。第五是JOIN时忘了确认关联字段有没有索引。多表查询慢,九成是关联字段裸奔导致的。新手不需要懂特别深的优化理论,但这五个习惯从现在就开始纠正,能少走很多弯路。
7.3 别被NULL骗了:五个经典场景
NULL是SQL里最容易阴人的设定,我把踩过的集中表演一次:
-- 1. 用 = NULL 查空值,永远查不到 SELECT * FROM users WHERE email = NULL; -- 错误,必须用 IS NULL -- 2. COUNT(列名) 会自动跳过NULL,统计结果少于行数 SELECT COUNT(email) FROM users; -- 3. NULL参与CONCAT拼接,整个结果变NULL SELECT CONCAT(name, ' - ', email) FROM users; -- 4. NOT IN 的子查询结果里有NULL,结果集直接为空 SELECT name FROM users WHERE id NOT IN (SELECT user_id FROM orders); -- 若orders里有NULL,整句失效 -- 5. NULL参与加法或比较,结果也都是NULL SELECT amount + 100 FROM orders;第4个坑尤其隐蔽。当子查询返回的user_id列表里混着NULL时,id NOT IN (NULL, 1, 2)在逻辑上等于"id不等于NULL",而NULL和任何值比较结果都是NULL,不是TRUE,所以所有行都被过滤掉了。解决办法是在子查询里显式排除NULL:WHERE user_id IS NOT NULL,或者改用NOT EXISTS写法。这些场景靠记忆容易忘,靠实践踩一遍就能长记性,所有刚接触SQL的人,我建议先拿几个NULL做点实验,把行为摸透。
我在实际项目里处理数据的体感是:SQL基础能力决定了下限,而对边界情况的敏感度决定了能走多远。把NULL、类型匹配、索引意识这些细节处理好,写出来的查询在数据量小和大的时候都稳,才算是真正入门了。这篇笔记里的所有示例,建议你顺手打开数据库一条条敲一遍,尤其是报错的和反例,亲手复现一次,记忆远比看十遍文字来得深。