我见过太多人栽在这三个最简单的SQL语句上,尤其是刚入行的朋友。在学校里、在培训视频里,WHERE、UPDATE、DELETE都是"第一章第四节"的内容,老师两页PPT就讲完了。结果一到真实业务环境,要么一条UPDATE把整张表的工资都改了,要么DELETE删完才发现忘了加条件,要么WHERE条件写了一个NULL判断查了半天查不出来,最后只能从binlog里翻数据。
这篇文章我不打算按照"数据库系统概论"的讲法来写,什么关系代数、元组演算一概不谈。就讲在真实开发、运维、面试中用得上的WHERE、UPDATE、DELETE,包含我在项目中踩过的坑、总结的技巧,以及那些教科书上不会写但是非常重要的小细节。
适合谁看?刚学MySQL的新手、写了两三年SQL但没系统梳理过的开发、以及准备面试需要把基础打扎实的求职者。文章里的示例语句,我建议你打开终端或者Navicat,建一张测试表跟着敲一遍——SQL这东西,看十遍不如跑一遍。
1. WHERE条件过滤:入门最容易、坑也最多的部分
1.1 比较运算符和逻辑运算符的基础用法
WHERE子句的任务只有一个:从表里筛出满足条件的行。语法结构非常简单,就是 WHERE 表达式,表达式为真(TRUE)的行会被返回,为假或NULL的行会被过滤掉。
最基本的用法是各种比较运算符:
sql复制-- 等值比较
SELECT * FROM employees WHERE department = '研发部';
-- 范围比较
SELECT * FROM employees WHERE salary >= 8000 AND salary <= 15000;
SELECT * FROM employees WHERE salary BETWEEN 8000 AND 15000;
-- 集合匹配
SELECT * FROM employees WHERE department IN ('研发部', '产品部', '设计部');
-- 模糊匹配
SELECT * FROM employees WHERE name LIKE '张%';
这几个看起来平平无奇,但实际使用中有一个非常经典的问题:BETWEEN AND的边界到底含不含?答案是包含的。BETWEEN 8000 AND 15000 等价于 salary >= 8000 AND salary <= 15000,包含8000和15000这两个边界值。很多人在排查数据时想当然地以为BETWEEN是"大于等于左边、小于右边",结果漏了一条边界数据,排查半天。这是真实发生过的坑。
再说说字符集和排序规则的问题。MySQL里字符串比较默认跟排序规则有关,大多数情况下用的是 utf8mb4_general_ci 或 utf8mb4_unicode_ci,后缀的 ci 是 case insensitive 的缩写,意思是不区分大小写。所以:
sql复制SELECT * FROM employees WHERE name = 'zhangsan';
-- 这个查询会把 'ZhangSan'、'ZHANGSAN' 等大小写变体也查出来
如果业务上需要区分大小写,可以用 BINARY 关键字强制二进制比较:
sql复制SELECT * FROM employees WHERE BINARY name = 'zhangsan';
这在实际开发中非常容易被忽视。比如你有个用户的账号是 Admin,用户输错了输入成 admin,如果不区分大小写就能蒙混过关,但如果账号体系设计上要求大小写敏感,就必须用 BINARY,否则会出现重复账号。这个细节我在之前的项目里真实遇到过,排查了整整一个下午,最后发现就是排序规则的问题。
1.2 LIKE模糊匹配:通配符的准确含义
LIKE是模糊查询的主力,配合两个通配符使用:
%:匹配任意数量的字符,包括零个字符_:匹配恰好一个字符
sql复制-- 名字以"张"开头
SELECT * FROM employees WHERE name LIKE '张%';
-- 名字以"张"结尾
SELECT * FROM employees WHERE name LIKE '%张';
-- 名字中任意位置包含"张"
SELECT * FROM employees WHERE name LIKE '%张%';
-- 名字恰好是三个字,且最后一个字是"明"
SELECT * FROM employees WHERE name LIKE '__明';
这里有个性能问题不得不提。LIKE '%张%' 这种写法,因为前导通配符的存在,MySQL无法使用B+树索引的快速查找特性,即使 name 字段上有索引,也会执行全表扫描(除非用了全文索引或者搜索引擎)。数据量小的时候无感,一旦表里有个几百万行,一条 LIKE '%张%' 就能把数据库拖慢。
真实业务里如果需要做包含匹配,而且数据量很大,我会优先考虑几种方案。一是用全文索引(MySQL 5.7+的ngram全文解析器支持中文分词);二是数据同步到Elasticsearch这类搜索引擎;三是后台任务做数据冗余,把可能需要搜索的字段预先拆好。这些都比在生产环境直接执行 LIKE '%关键字%' 靠谱得多。当然,如果表只有几千行几万行,那无所谓,索引都用不上,直接全表扫也很快。
再补充一个很多人不知道的转义技巧。如果搜索的内容本身就包含 % 或 _,需要用 ESCAPE 指定转义字符:
sql复制-- 查找包含 "100%" 的记录
SELECT * FROM products WHERE detail LIKE '%100\%%';
-- 默认反斜杠就是转义符,也可以显式指定
SELECT * FROM products WHERE detail LIKE '%100!%%' ESCAPE '!';
1.3 NULL的判断:为什么= NULL永远查不出数据
这是新手问得最多的问题,也是面试官最爱考的题。直接看结论:
sql复制-- 这种写法永远是错的,查不出任何结果
SELECT * FROM employees WHERE manager_id = NULL;
-- 应该用 IS NULL 或 IS NOT NULL
SELECT * FROM employees WHERE manager_id IS NULL;
SELECT * FROM employees WHERE manager_id IS NOT NULL;
为什么 = NULL 不行?因为SQL里的NULL代表"未知",不是"空字符串",不是0,更不是某个确定的值。在SQL的三值逻辑体系(TRUE、FALSE、UNKNOWN)下,用 = 去比较任何值与NULL,结果都是UNKNOWN,而WHERE只会保留结果为TRUE的行,UNKNOWN的行全部被过滤掉。所以 manager_id = NULL 这个条件的判断结果永远是UNKNOWN,自然就什么都查不出来。
更隐蔽的坑是NULL和函数、运算符混用时结果被"污染"。举个例子:
sql复制-- 如果 bonus 字段有 NULL 值,那么 salary + bonus 的结果就是 NULL
SELECT employee_id, salary + bonus AS total_salary FROM employees;
一个为NULL的 bonus,会让 salary + bonus 的整体结果为NULL。在报表统计里,这会导致合计数据显示空白,而不是0。解决办法是用 COALESCE 或 IFNULL:
sql复制SELECT employee_id, salary + COALESCE(bonus, 0) AS total_salary FROM employees;
-- 或者
SELECT employee_id, salary + IFNULL(bonus, 0) AS total_salary FROM employees;
注意,这里是"把NULL变成0参与运算",而不是"把NULL改成0存到表里"。这两者在业务语义上是完全不同的。
再补充一个统计函数和NULL的纠缠。COUNT(*) 统计的是行数,而 COUNT(column) 统计的是该列非NULL值的个数。比如:
sql复制-- 如果 manager_id 有10行是NULL,那么下面两条返回结果不一样
SELECT COUNT(*) FROM employees; -- 返回所有行数
SELECT COUNT(manager_id) FROM employees; -- 返回非NULL的manager_id行数
这在做报表统计时非常容易算错。曾经的同事统计"有多少员工有上级主管",用 COUNT(*) 去数,结果把没有主管的人也数进去了,多出了好几百人。
1.4 AND、OR、IN的组合优先级
先看一个经典面试题:
sql复制-- 查出的是所有研发部的人,以及所有部门里薪资超过15000的人?
-- 还是所有研发部里薪资超过15000的人?
SELECT * FROM employees WHERE department = '研发部' OR department = '产品部' AND salary > 15000;
答案是:AND 的优先级高于 OR,所以上面等价于:
sql复制SELECT * FROM employees WHERE department = '研发部' OR (department = '产品部' AND salary > 15000);
也就是说,查询结果包含"所有研发部员工"和"产品部但薪资大于15000的员工"。如果本意是"研发部或产品部里薪资大于15000的员工",必须加括号:
sql复制SELECT * FROM employees WHERE (department = '研发部' OR department = '产品部') AND salary > 15000;
这个优先级问题在真实业务中很容易被忽视,因为很多时候SQL是拼接出来的,尤其是历史项目里的动态SQL,后面加一个条件忘了加括号,整个查询语义就变了。如果你在维护老项目,遇到查询结果和预期不符,优先检查OR和AND混用时的括号。
再说IN和OR的关系。department IN ('研发部', '产品部') 在大多数情况下等价于 department = '研发部' OR department = '产品部',但IN的写法更简洁、可读性更好,而且MySQL对IN列表的执行计划优化通常也比一长串OR更好。另外IN还有一个好处,列表里可以传子查询:
sql复制SELECT * FROM employees WHERE department_id IN (
SELECT id FROM departments WHERE company = '集团总部'
);
需要注意的一点,如果IN后面的列表里有NULL,结果不会匹配NULL,但也不会报错。比如 department IN ('研发部', NULL),如果department本身是NULL,这个条件依然不会返回该行。这一点很容易让人困惑,但底层逻辑还是那句话:NULL = NULL 的结果不是TRUE,而是UNKNOWN,所以WHERE不会保留该行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. UPDATE更新数据:从基础语法到最常见的惨痛教训
2.1 UPDATE基础语法和WHERE的关系
UPDATE的语法结构:
sql复制UPDATE 表名
SET 列1 = 值1, 列2 = 值2
WHERE 条件;
SET 后面指定要修改的列和新值,WHERE 指定哪些行会被修改。如果没有WHERE,整张表的所有行都会被更新。 这一条必须刻在脑子里。
为什么会这样?因为UPDATE的语义就是"对满足条件的行执行修改",条件为空就意味着所有行都满足。这就像你给全班同学发了一条"所有人下周穿校服"的通知和给"座位号是3号的同学"发了一条"下周穿校服"的通知,前者显然会影响所有人。
真实案例:我见过一次生产事故,一个运营后台的"修改用户备注"功能,前端拼接SQL时少传了一个用户ID,WHERE 条件直接没拼上去,一条 UPDATE users SET remark = '测试备注' 把全站几十万用户的备注全改成了"测试备注"。最后的处理方式是从备份恢复那张表,恢复期间该表只读,业务停摆了将近两个小时。所以与其事后救火,不如在应用层加一道保护:代码里凡是UPDATE、DELETE语句,必须经过一个检查方法,检测到没有WHERE就拒绝执行。现在很多ORM框架也支持这种强制条件,比如MyBatis-Plus的 updateById 方法天然带主键条件,就是防止这种全表更新的惨剧。
更新单个字段:
sql复制UPDATE employees SET salary = 10000 WHERE employee_id = 1001;
更新多个字段,用逗号分隔:
sql复制UPDATE employees
SET salary = 12000, department = '技术部'
WHERE employee_id = 1001;
2.2 利用字段之间的运算更新数据
UPDATE不仅能设置常量,还可以基于原值做运算。这在真实业务中非常常见,比如年度调薪、库存扣减、计数器累加。
sql复制-- 所有研发部员工薪资上涨10%
UPDATE employees
SET salary = salary * 1.1
WHERE department = '研发部';
-- 库存扣减
UPDATE products
SET stock = stock - 5
WHERE product_id = 999;
这里有一个非常深的坑,值得单独拿出来说:字段运算更新不是无条件的,要警惕负数产生。比如上面库存扣减的例子,如果当前库存只有3件,执行扣减5件后stock变成-2,产生了负库存。这在真实业务里是不允许的。所以库存扣减的SQL通常会带上库存充足的条件:
sql复制UPDATE products
SET stock = stock - 5
WHERE product_id = 999 AND stock >= 5;
这样如果库存不足,这条UPDATE的影响行数(affected rows)就是0,应用层可以根据影响行数判断是否需要提示"库存不足"。但要注意,在高并发场景下,这种"先检查再更新"的模式会有并发问题。假设两个请求同时读到库存为10,同时执行扣减5件,都通过了 stock >= 5 的检查,最后库存变成0而不是预想中的10-5-5=0(恰好也是0,这是凑巧,如果扣减数量不同就会出问题)。更稳妥的做法是使用原子操作,让扣减和条件检查在一条SQL里完成,或者使用 SELECT ... FOR UPDATE 加锁。MySQL里InnoDB引擎默认行锁,UPDATE 本身就是原子操作,所以:
sql复制-- 这个是安全的:把扣减动作和库存充足条件放在一条UPDATE里
UPDATE products
SET stock = stock - 5
WHERE product_id = 999 AND stock >= 5;
如果影响行数为1,说明扣减成功;为0,说明库存不足。不需要额外的SELECT来"检查一下再更新",这种"检查再更新"的写法不仅多了一次往返,还引入了并发窗口期。我在项目规范里经常强调:能做原子操作就不要拆成多条语句。
MySQL中INT类型和其他数值运算也经常出现在热词里,比如 mysql中int+5。这里提一句:MySQL的INT是定点整数,直接相加减就行,但如果字段是字符串类型存的是数字,做加法时MySQL会隐式转换,这有时会导致索引失效,尤其是 WHERE string_int_field + 5 > 10 这种写法。解决办法是避免对索引列做运算,改成 WHERE string_int_field > 5(前提是你能控制数据类型)。最根本的做法是设计表时把该字段定为数值类型。
2.3 用CASE WHEN做条件更新
有时候一次更新需要根据不同的条件设置不同的值,用多条UPDATE语句容易产生中间状态,而且可能被事务隔离级别影响。更好的做法是用 CASE WHEN 在一条语句里搞定:
sql复制UPDATE employees
SET salary = CASE
WHEN department = '研发部' THEN salary * 1.2
WHEN department = '销售部' THEN salary * 1.1
ELSE salary
END
WHERE department IN ('研发部', '销售部');
这条SQL的意思非常直白:研发部涨20%,销售部涨10%,其他部门不涨。用一条UPDATE完成,避免分成两条语句时,在两条语句之间其他会话看到的数据可能是旧的上涨结果,造成业务上的不一致。
CASE WHEN在真实业务还有一种用法,就是处理"用新值替换旧值"的字典映射。比如原来字段里存的是状态码 1、2、3,现在要改成 'pending'、'approved'、'rejected':
sql复制UPDATE orders
SET status = CASE status
WHEN 1 THEN 'pending'
WHEN 2 THEN 'approved'
WHEN 3 THEN 'rejected'
END
WHERE status IN (1, 2, 3);
这种写法比三条UPDATE更高效,也更容易保证一致性。如果分三条执行,中间任何一条失败,状态就有一部分改了、一部分没改,除非你用事务包起来,但事务包三条语句和一条CASE语句本质上都能保证原子性,CASE更简洁。
2.4 UPDATE多表关联更新(UPDATE JOIN)和子查询更新
MySQL支持在UPDATE中使用关联别的表来更新目标表。这在做数据订正、批量同步时非常有用。
最常用的关联更新写法:
sql复制-- 用 departments 表的 company 字段更新 employees 表的 company_name
UPDATE employees e
JOIN departments d ON e.department_id = d.id
SET e.company_name = d.company
WHERE d.company = '集团总部';
这里 JOIN 的作用就是让目标表的行和关联表的行匹配起来,匹配上的才会被更新。如果你只想更新关联表匹配不上的行,比如把"没有任何部门关联的员工"的部门名改成"未分配",可以使用 LEFT JOIN 加 IS NULL 判断:
sql复制UPDATE employees e
LEFT JOIN departments d ON e.department_id = d.id
SET e.department_name = '未分配'
WHERE d.id IS NULL;
注意,这条SQL里 LEFT JOIN 之后,WHERE d.id IS NULL 筛选出的就是那些在 departments 表里没有匹配记录的行,这也是JOIN和WHERE配合使用的一个经典模式。
子查询更新也是常用手段:
sql复制UPDATE employees
SET department = (
SELECT new_department
FROM department_mapping
WHERE department_mapping.old_department = employees.department
)
WHERE department IN (
SELECT old_department FROM department_mapping
);
这里要特别提醒一个MySQL的限制:在UPDATE或DELETE语句的子查询中,不能直接引用当前正在更新的目标表。比如下面的写法会报错:
sql复制-- MySQL会报错:You can't specify target table 'employees' for update in FROM clause
UPDATE employees
SET salary = salary * 1.1
WHERE employee_id IN (
SELECT employee_id FROM employees WHERE department = '研发部'
);
这是因为MySQL不允许在更新某张表的同时,从同一张表里做子查询。解决办法是套一层派生表(用 AS temp 包一层):
sql复制UPDATE employees
SET salary = salary * 1.1
WHERE employee_id IN (
SELECT id FROM (
SELECT employee_id AS id FROM employees WHERE department = '研发部'
) AS temp
);
这个"套一层"的技巧非常重要,很多老开发也容易在这个地方卡住。核心原因是MySQL物化子查询的时候,派生表(Derived Table)已经被物化成临时表,不再是直接引用原表,所以避开了"更新目标表又在子查询里读同一张表"的限制。
2.5 用LIMIT限制UPDATE的影响行数
MySQL的UPDATE语法支持 LIMIT,这个很少人知道,但在处理分批更新时非常实用:
sql复制-- 每次只更新100行,适用于在线表的大批量数据修改
UPDATE employees
SET status = '已处理'
WHERE status = '待处理'
LIMIT 100;
为什么这样做?因为如果一次性更新几十万行,会产生大量的行锁,长时间锁表,影响线上读写,还可能导致主从延迟飙升。分批更新可以把一次大事务拆成多个小事务,每批100条,执行完一批提交一批,对线上影响小很多。
配合 ORDER BY 可以让分批更新的顺序可控:
sql复制UPDATE employees
SET status = '已处理'
WHERE status = '待处理'
ORDER BY employee_id
LIMIT 100;
这个技巧在实际清理数据、修复脏数据的场景中非常实用。比如某个历史bug产生了一批脏数据,你要把几百万行数据按照某个规则修正,直接一条UPDATE锁全表,主库上业务直接卡死;分批LIMIT更新,每批之间加 SLEEP 函数或者应用层sleep几毫秒,数据库压力立刻降下来了。具体操作里,可以写一个存储过程循环执行,或者在Java/Python代码里循环执行UPDATE语句。
3. DELETE删除数据:两种删除方式的本质区别
3.1 DELETE基础语法和影响行数
DELETE的基本语法:
sql复制DELETE FROM 表名 WHERE 条件;
和UPDATE一样,不带WHERE的DELETE会清空整张表。这个比UPDATE不带WHERE还危险,因为删了之后如果没有备份,数据可能彻底找不回来。
一条DELETE语句执行后返回的影响行数(affected rows)表示删了多少行。如果影响行数是0,说明没有匹配到任何行,不代表SQL执行出错。
这里有必要把 "DELETE删除" 和 "DROP TABLE" 区分开。DELETE删除的是表中的行,表结构还在;DROP是连表结构带数据一起删掉。业务上绝大多数时候用的是DELETE和TRUNCATE,DROP通常只出现在重建表结构时。
DELETE和TRUNCATE在"删除全部数据"时的区别是一个经典面试题,它们的主要差异我整理成一个对比表:
| 对比项 | DELETE | TRUNCATE |
|---|---|---|
| 删除范围 | 可通过WHERE指定行,也可删除所有行 | 只能删除整个表的数据 |
| 速度 | 逐行删除,慢(数据量大时明显) | 直接释放表空间,快得多 |
| 事务支持 | 支持事务回滚(InnoDB下) | 隐式提交,无法回滚 |
| 自增ID | 继续延续之前的值,不重置 | 重置为初始值 |
| 锁 | 行锁或表锁,视条件而定 | 表锁 |
| 触发器 | 会触发DELETE触发器 | 不会触发 |
| 空间释放 | 不释放存储空间(可后续用OPTIMIZE回收) | 立刻释放存储空间 |
这些差异里,最常被忽视的是"删除后空间是否释放"和"自增ID是否重置"。很多人在线上清空一张表,用了 DELETE FROM table(没带WHERE),发现磁盘空间没有减少,然后过来问为什么。答案是DELETE是逐行标记删除,数据页只是被打上删除标记,存储空间并不会立即释放,除非表引擎的purge线程在后台回收,但释放空间是渐进的,而且自增ID依然延续。如果真想立刻释放空间重新开始,用 TRUNCATE TABLE table 才合适。但要注意,TRUNCATE属于DDL操作,操作时会隐式提交,执行后不能回滚,线上执行前一定要确认清楚。
3.2 多表删除(DELETE JOIN):一次删多张表的关联数据
MySQL的DELETE支持JOIN,可以一次性从多张表中删除关联的行。这在管理有外键关系的数据时特别方便(尤其是在没建外键约束的老项目里)。
语法一:删除关联表中匹配的行,用于清理孤儿数据。
sql复制-- 删除没有有效订单的用户
DELETE u FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;
这里的意思是,把 users 表里在 orders 表中没有匹配记录的用户删掉。DELETE u FROM users u 指明要删的是users表的行,JOIN出来的其他表行不会被删。
语法二:同时从多张表删除匹配的行。
sql复制-- 同时删除用户和该用户的所有订单(这里就是两张表都删)
DELETE u, o
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.id = 1001;
这条SQL会同时删除 users 表中 id=1001 的用户,以及 orders 表中所有 user_id=1001 的订单,相当于一次"级联删除"。如果只写 DELETE FROM users ... JOIN orders ...,MySQL的语法是 DELETE 表别名 FROM ...,要非常注意哪个表名写在DELETE后面,决定了哪些表的行会被删。用错了会删错表。
还有一个类似的场景,清理订单时只删订单里的明细,不动订单主表:
sql复制DELETE oi
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE o.status = '已取消';
3.3 子查询删除和派生表限制
和UPDATE一样,DELETE也可以结合子查询。但同样受"不能从正在删除的同一张表里做子查询"的限制:
sql复制-- 还是会报错
DELETE FROM employees
WHERE department_id IN (
SELECT department_id FROM employees WHERE salary < 5000
);
解决方案同样是套一层派生表:
sql复制DELETE FROM employees
WHERE department_id IN (
SELECT id FROM (
SELECT department_id AS id FROM employees WHERE salary < 5000
) AS temp
);
这个限制的本质原因,我在前面UPDATE部分已经解释过:MySQL要避免"一边修改一边读取同一张表"导致的不确定性。遇到类似报错,记住 AS temp 这个套路就够了。
3.4 删除数据前,如何安全确认要删的是哪几行
DELETE比UPDATE更需要提前验证。我的习惯是"先查再删",三步走:
第一步,用相同的WHERE条件先执行SELECT,确认影响范围:
sql复制-- 先查
SELECT * FROM employees WHERE department = '临时部门' AND status = '离职';
第二步,统计数量,确认量级在预期范围内:
sql复制-- 再数
SELECT COUNT(*) FROM employees WHERE department = '临时部门' AND status = '离职';
第三步,如果确认无误,再执行DELETE:
sql复制-- 最后删
DELETE FROM employees WHERE department = '临时部门' AND status = '离职';
这里有个更稳妥的做法:先在事务里执行DELETE,然后SELECT验证删除结果,确认无误再COMMIT,发现不对就ROLLBACK。MySQL的InnoDB引擎默认支持事务,所以:
sql复制START TRANSACTION;
DELETE FROM employees WHERE department = '临时部门' AND status = '离职';
-- 验证:查看是否还有残留数据
SELECT COUNT(*) FROM employees WHERE department = '临时部门' AND status = '离职';
-- 确认无误后提交
COMMIT;
-- 如果发现问题,不要执行COMMIT,执行ROLLBACK
-- ROLLBACK;
这样做的好处是,即使DELETE执行了,只要还没COMMIT,随时可以通过ROLLBACK把数据恢复回来。这是我在生产环境操作数据时最常用的安全手段。尤其是在线业务表,哪怕SELECT确认过了,实际跑DELETE时也可能因为并发写入导致影响行数超预期,事务 + 影响行数检查 + 验证查询三板斧才是真的稳。
需要特别提醒的是,MySQL的DDL语句(如TRUNCATE、DROP、ALTER)会隐式提交当前事务,一旦执行就无法通过ROLLBACK恢复。所以如果你想用上面的方法安全地"清空表",不要用TRUNCATE,应该用DELETE带不带WHERE配合事务操作。TRUNCATE不支持事务回滚,这在前面表格里已经提到了。
4. 真实排查案例:一条"没问题"的UPDATE为什么影响了1万行
4.1 问题现象
之前接手过一个CRM系统,运营反馈说"批量改归属人"功能出问题了,改了100个客户的归属人,结果有1万多个客户的归属人全被改了。我第一反应是"WHERE条件丢了",但看代码发现WHERE条件明明写着 WHERE id IN (:ids),不应该有问题。
4.2 排查过程
我定位到应用的日志,发现打印出来的SQL是这样的:
sql复制UPDATE customers SET owner_id = 123 WHERE id IN (1001, 1002, 1003, ...);
从日志看条件在,但"影响行数"却是10432。这时候我意识到不是WHERE丢了,而是 id IN (...) 里的 id 列表出问题了。继续追查请求参数,发现前端传过来的ID列表,第一个参数竟然是空字符串。在ORM框架里空字符串被拼进了SQL,变成了 id IN ('', 1001, ...),MySQL隐式把空字符串转成了0,而客户表里恰好有大量 id = 0 的历史脏数据,这一下全被匹配上了。
问题根源是两层:一是前端没有做参数校验,把空串传了进来;二是业务表里存在 id = 0 这种不合理的脏数据。修复方案是,应用层增加参数校验,过滤掉空值和非数字串;同时把表里 id = 0 的垃圾数据清理掉。再往后,我在代码规范里加了一条:所有按ID批量操作的接口,必须对ID列表做合法性校验,一旦出现非正整数立刻拒绝请求。
4.3 从这个案例能学到什么
这个案例是我亲身经历的真实事故,它比教科书里的"WHERE条件丢了"更隐蔽。它告诉我们,你不光要确认WHERE存在,还要确认WHERE里的参数值在语义上是合法的。特别是那些从外部传入、经过字符串拼接、再进入SQL的参数,一定要警惕隐式类型转换带来的意外匹配。
也因为这个事故,我后来养成了一个习惯:线上执行UPDATE、DELETE之前,先在测试环境用相同的数据跑一遍,然后再到生产环境启动一个事务,执行完立刻检查影响行数,确认在预期范围才提交。这不是稳妥不稳妥的问题,这是保命的基本操作。
5. 三个SQL高频面试点:语义、优先级、事务
5.1 三值逻辑和NULL处理
面试官喜欢问基础,往往问的就是这些最容易被忽略的细节。比如:WHERE name = NULL 和 WHERE name IS NULL 的区别是什么?如果你能答出"三值逻辑"这四个字,面试官就知道你不是背题的。
SQL的布尔逻辑不是二值的(TRUE/FALSE),而是三值的(TRUE/FALSE/UNKNOWN)。NULL参与任何比较运算,结果都是UNKNOWN。UNKNOWN在WHERE里不会保留行,在ON里也不会匹配行。唯一的例外是在 IS NULL、IS NOT NULL、IFNULL/COALESCE、COUNT(column) 这些专门处理NULL的语法里,NULL才会被正确处理。
一个相关的进阶问题是:WHERE NOT (salary > 10000) 能不能查出 salary IS NULL 的行?答案是查不出来,因为 salary > 10000 的结果是UNKNOWN,NOT UNKNOWN 仍然是UNKNOWN,不是TRUE。所以如果你想把"薪资不大于10000,包括薪资未知"的行都查出来,得写成:
sql复制SELECT * FROM employees WHERE NOT (salary > 10000) OR salary IS NULL;
5.2 运算符优先级和隐式类型转换
面试题里经常会有这种题:SELECT * FROM t WHERE a = 1 OR a = 2 AND b = 3,问你查的是什么。前面已经说过,AND优先于OR,所以这是 a = 1 OR (a = 2 AND b = 3)。这种题看似简单,但真实业务中出现频率很高的原因是拼接SQL时括号位置非常容易出错。
另一个高频点是MySQL的隐式类型转换。比如字段是字符串类型的 varchar,你拿数字去比较,MySQL会把字符串转成数字再比较;字段是数字类型的,你拿字符串去比较,MySQL会把字符串转成数字。这种转换大多数时候"能用",但可能带来两个后果:一是索引失效,查询变慢;二是转换规则不符合直觉,匹配出意外数据。
比如:
sql复制-- phone 字段是 varchar
SELECT * FROM users WHERE phone = 13800138000;
这条SQL里,MySQL会把 phone 列的所有值都转成数字再和 13800138000 比较。如果 phone 里存了 '13800138000abc' 这种带非数字字符的值,转换时MySQL会尽量转成数字,可能就 13800138000 匹配上了;而且因为要对列本身做转换,phone 字段上的索引大概率用不上,导致全表扫描。这个问题的解法就是用引号:
sql复制SELECT * FROM users WHERE phone = '13800138000';
5.3 UPDATE、DELETE与事务的配合
面试还会问一个实用问题:如果UPDATE执行到一半失败了,数据会变成什么样?这取决于你是否开启了事务、SQL是否在自动提交模式下执行。
MySQL默认是自动提交模式(autocommit=1),一条UPDATE如果没有显式放在事务里,执行成功就立即提交,失败就自动回滚。如果放在事务里:
sql复制START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
-- 如果第二条执行失败,整个事务可以回滚
ROLLBACK;
事务的ACID特性里,原子性(Atomicity)和一致性(Consistency)是核心,上面这个转账例子就是最常见的解释方式。两条UPDATE要么都成功,要么都失败,不会出现"扣了钱但没到账"的情况。在真实项目里,凡是涉及多表写入、多个步骤更新,都必须考虑事务的边界:要么全做,要么全不做。而DML语句(INSERT、UPDATE、DELETE)是支持事务回滚的,DDL语句(CREATE、ALTER、TRUNCATE、DROP)不支持,这一点我在上面多次强调过,因为太重要了。
还有一个和事务紧密相关的锁机制值得提一下。InnoDB在 UPDATE 和 DELETE 执行时会锁定匹配到的行(默认行锁),直到事务提交或回滚。如果一条UPDATE的WHERE条件写得很宽,比如 WHERE status = '待处理' AND create_time < '2024-01-01',命中了大量行,这些行的事务锁在提交前会一直持有。此时其他事务想更新这些行就会被阻塞,产生锁等待。生产环境出现"数据库突然变慢"的时候,一个常见原因就是有人在事务里执行了一个宽泛的UPDATE,没有COMMIT,线上其他写操作全被卡住了。排查锁等待的常用命令是:
sql复制SHOW PROCESSLIST;
-- 或者查性能库
SELECT * FROM information_schema.innodb_trx;
能看到哪些事务在运行、锁了哪些行。我在多个项目里靠这个命令快速定位过"卡死"的元凶,比如某个后台任务跑了一半程序崩了,事务没提交也没回滚,锁一直没释放,导致后续所有操作都在等锁。
6. 一些越早知道越好的SQL习惯
6.1 三个"先"的原则
基于多年实战踩坑的教训,我总结出三个"先"的原则,适用于所有涉及UPDATE、DELETE的操作:
先查后改。任何UPDATE和DELETE前,先跑一遍相同WHERE条件的SELECT,确认影响行数。这个动作成本极低,收益极高。
先备份后动。对重要表做批量修改前,先建备份表:
sql复制CREATE TABLE employees_backup_20250101 AS SELECT * FROM employees;
这样即使操作失误,也能从备份表恢复。备份表加日期,方便追溯。
先小后大。不要一次性UPDATE/DELETE大量数据。用LIMIT分批或者用范围分批。比如按主键范围分批:
sql复制-- 第一批
UPDATE employees SET status = '已处理' WHERE id BETWEEN 1 AND 1000;
-- 第二批
UPDATE employees SET status = '已处理' WHERE id BETWEEN 1001 AND 2000;
6.2 索引和WHERE的关系
WHERE条件能不能用上索引,直接影响SQL执行速度。虽然是基础,但值得反复说:
- 对索引列使用函数或运算,索引失效。比如
WHERE YEAR(create_time) = 2024,如果create_time有索引也走不了,应该改为WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01'。 - 前导模糊匹配索引失效。
LIKE '%abc'走不了索引,LIKE 'abc%'能走。 - 隐式类型转换可能导致索引失效。
WHERE phone = 13800138000(phone是varchar)就会失效,要写成字符串。 - OR连接多个条件时,如果其中一个条件没索引,整个查询可能都不走索引。可以用
UNION ALL拆分,或者用优化器提示INDEX。
这些经验是在真实的慢查询优化项目中反复验证过的。遇到线上SQL慢,第一时间看执行计划:
sql复制EXPLAIN SELECT * FROM employees WHERE department = '研发部';
看 possible_keys、key 列,确认是否走了索引,看 rows 列评估扫描行数。MySQL 5.7+ 还可以用 EXPLAIN ANALYZE(8.0版本)看实际执行耗时和扫描行数。
6.3 避免SQL注入,杜绝字符串拼接
最后但肯定不是最不重要的,是SQL注入。WHERE、UPDATE、DELETE都涉及到条件拼接,如果条件里的值直接拼接到SQL字符串里,就存在被注入的风险。举例来说,一个登录逻辑如果写成:
java复制String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";
如果用户在 username 里输入 ' OR '1'='1,SQL就变成了:
sql复制SELECT * FROM users WHERE username = '' OR '1'='1' AND password = '';
这就能绕过密码验证直接登录。这个虽然是老生常谈,但我确实见过有老项目这样写。正确做法是使用参数化查询(PreparedStatement)或者ORM框架(MyBatis、Hibernate)的 #{} 占位符。MySQL的 prepare 语句也是同样的道理:
sql复制PREPARE stmt FROM 'SELECT * FROM users WHERE username = ? AND password = ?';
EXECUTE stmt USING @username, @password;
使用参数化查询不仅安全,而且MySQL可以复用执行计划,性能上也更好。
6.4 善用SHOW WARNINGS查看警告
执行UPDATE、DELETE之后,MySQL可能会产生警告。默认情况下,很多客户端不会显示警告,但警告里往往藏着重要信息。比如:
sql复制UPDATE employees SET salary = salary * 1.1 WHERE department = '测试部';
SHOW WARNINGS;
如果发生了隐式类型转换、数据截断、除法除零等问题,SHOW WARNINGS 会明确告诉你。我在写存储过程时经常用这一招排查数据不一致的问题。补充一点,如果UPDATE/DELETE影响了大量行,也可以在客户端看到"Query OK, 10432 rows affected",此时就要警惕影响行数是否与预期匹配——这比执行完不管要强得多。
6.5 数据修改的几个保命小技巧
写到这里,我再分享几个平时积累的小技巧,都是真实项目里用上的。
一个是怎么快速构造一条"安全"的SQL。我习惯把所有线上执行的UPDATE/DELETE都先包一层事务,并且用影响行数来兜底。比如我要更新一张千万级大表,会写成:
sql复制START TRANSACTION;
UPDATE user_orders
SET order_status = 'cancelled', cancel_time = NOW()
WHERE order_id = 123456789;
-- 手动检查影响行数,如果为1,说明符合预期
-- 如果影响行数超过预期值,立刻 ROLLBACK
COMMIT;
再一个是"操作已经完成,如何确认数据正确"。更新完表后,我会习惯性地跑一条验证SQL:
sql复制SELECT COUNT(*) AS matched_rows FROM user_orders WHERE order_status = 'cancelled' AND cancel_time IS NOT NULL;
如果COUNT值符合预期,确认没问题;不符合预期,说明更新逻辑哪里有问题,需要继续排查。
还有个很实用的习惯,是给所有线上批量修改脚本都加上 -- 是否开启自动提交 的开关。比如在MySQL客户端操作时,我先执行 SET autocommit = 0;,这样即使我忘了写BEGIN,MySQL也不会自动提交,必须手动COMMIT。这个习惯在操作线上数据库时能多一道安全闸门。当然,这个设置只对当前会话有效,不会影响其他会话。
7. 关于MySQL 8.0的一些新变化(简短补充)
虽然这篇文章讲的是基础知识,但如果你用的是MySQL 8.0,有几个和WHERE、UPDATE、DELETE相关的点值得了解。
第一个是窗口函数。虽然窗口函数不是用来替代WHERE的,但它和WHERE配合起来能做很多复杂查询。比如"查询每个部门薪资最高的员工":
sql复制SELECT employee_id, department, salary, rank_num
FROM (
SELECT employee_id, department, salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_num
FROM employees
) t
WHERE rank_num = 1;
这条SQL里的 rank_num 是在子查询里通过窗口函数算出来的,外层WHERE筛选排名第一的行。8.0以后这种查询变得非常顺手,不用再写复杂的自连接了。
第二个是CTE(公共表表达式)。CTE可以让子查询更清晰,也避免了多次重复相同的子查询:
sql复制WITH recent_orders AS (
SELECT user_id, SUM(amount) AS total_amount
FROM orders
WHERE create_time >= '2024-01-01'
GROUP BY user_id
)
UPDATE users u
JOIN recent_orders ro ON u.id = ro.user_id
SET u.last_year_spending = ro.total_amount;
8.0的CTE和UPDATE/ DELETE配合,让复杂更新的可读性好了很多。
第三个是对DELETE语法的一些改进。8.0支持同样的DELETE JOIN语法,和之前版本一致。大部分DML行为和5.7是兼容的,升级数据库基本不用担心基础语法不通用。
不过,基础知识讲到最后,我还是想说一点:不管你用了多新的数据库版本,解决了多少高级特性,WHERE、UPDATE、DELETE这三个基础语句永远是数据库操作的地基。地基打不牢,楼盖得再高也是危楼。这些语法看起来简单,但真实业务里最复杂的故障,往往就是从最简单的语句里漏掉的某个细节开始的。希望这篇整理能帮你把这三个基础语句里里外外吃透——不只是知道语法怎么写,更要知道为什么这么写、踩过什么坑、怎么避免踩坑。按照文中的示例亲手建一张表跑一遍,比只看不练有用得多。
