
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、DELETE2.1 INSERT 插入数据的三种姿势插入数据最常见的坑不是语法而是要不要写列名。直接看代码-- 姿势一单行插入推荐写列名 INSERT INTO users (name, email) VALUES (张三, zhangsanexample.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都有idSELECT时必须用表别名.字段名区分否则数据库会报字段不明确。上面的示例里我给表起了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 字符串函数最常用的一组业务数据里字符串占了半壁江山姓名、邮箱、商品名、备注全都要处理。常用的就这么几个-- 拼接把姓名和邮箱拼成张三 zhangsanexample.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的字段包一层COALESCECONCAT(name, , COALESCE(email, 无), )。COALESCE的作用是取参数列表中第一个非NULL的值相当于给NULL设置默认值。另外提醒一下LENGTH和CHAR_LENGTH的区别。很多数据库的LENGTH返回的是字节数一个中文占3个字节所以LENGTH(张三)结果是6而不是2CHAR_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_FORMATPostgreSQL用TO_CHAR日期加减MySQL可以直接用CURRENT_DATE - INTERVAL 30 DAYPostgreSQL也支持类似的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 aliasFROM子句里的子查询没起别名子查询后面加别名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所以所有行都被过滤掉了。解决办法是在子查询里显式排除NULLWHERE user_id IS NOT NULL或者改用NOT EXISTS写法。这些场景靠记忆容易忘靠实践踩一遍就能长记性所有刚接触SQL的人我建议先拿几个NULL做点实验把行为摸透。我在实际项目里处理数据的体感是SQL基础能力决定了下限而对边界情况的敏感度决定了能走多远。把NULL、类型匹配、索引意识这些细节处理好写出来的查询在数据量小和大的时候都稳才算是真正入门了。这篇笔记里的所有示例建议你顺手打开数据库一条条敲一遍尤其是报错的和反例亲手复现一次记忆远比看十遍文字来得深。