我前阵子被一个转岗过来的同事问:DELETE删完数据自增ID却断了一截,TRUNCATE清空表之后又没法回滚,为什么?我说,你先把MySQL里的SQL分类搞明白,就全通了。这个分类不是考试时候拿来背的,而是你面对一个数据库问题时用来定位“危险等级”的底层地图。SQL分类可以大致拆成DDL、DML、DQL、DCL四类,有人会把事务控制TCL也单独拎出来,本质上还是围绕数据操纵展开。这篇文章我就用实际开发、review别人SQL、处理线上故障的视角,把这几类讲透。适合刚学MySQL想系统梳理一遍的同学,也适合已经写了几年SQL但对“执行顺序”“能不能回滚”“为什么锁表”这些事还模糊的开发者。
1. SQL分类的底层逻辑:为什么很多故障都是“我以为是A其实是B”
1.1 四类语句的分工比你以为的更清晰
很多数据库事故,锅不在操作人粗心,而是操作人没意识到自己用的语句属于哪个类别。比如DELETE和DROP看起来都是“删数据”,实际一个属于DML、一个属于DDL,性质完全不一样。前者改的是数据行,后者动的是表结构;前者在事务里可能回滚,后者执行完基本没有回头路。
我常用一个类比:一个数据库实例就像一套房子。DDL是砸墙、改格局、装楼梯,动的是建筑设计;DML是搬家具、扔杂物,动的是屋内摆设;DQL是你站在屋里打开手机拍照记录;DCL是给谁配钥匙、允许谁进哪个房间。买房子的人不会天天砸承重墙,但很多人写数据库操作时,却随手执行TRUNCATE和DROP,根本没意识到自己在砸墙。
下面这张表是我给团队新人培训时用的,直接建一个简单印象:
| 分类 | 核心动词 | 作用对象 | 典型风险 |
|---|---|---|---|
| DDL | CREATE、ALTER、DROP、TRUNCATE、RENAME | 库、表、索引、视图等结构 | 隐式提交、可能锁表、不可回滚 |
| DML | INSERT、UPDATE、DELETE | 表中的数据行 | 条件写错会全表更新/删除 |
| DQL | SELECT | 查询数据 | 本身不改数据,但慢查询会拖垮数据库 |
| DCL | GRANT、REVOKE | 用户权限 | 授权不当等于裸奔 |
| TCL | COMMIT、ROLLBACK、SAVEPOINT | 事务边界 | 不会用就丢数据 |
单看这张表还比较抽象,下面每类我都配合真实场景展开。
1.2 分类的本质是给“错误代价”分级
我review过上千条SQL,发现很多人写SQL时最大的问题是:不管什么语句,拿到就执行。想清空表数据就TRUNCATE,想删几行垃圾数据就DELETE,想删表就DROP。等出了事才发现DELETE删不掉是因为语句写错条件漏了,TRUNCATE一执行整表数据没了还回滚不了,DROP更不用说,整个表结构直接消失。
如果脑子里有分类意识,动手前就会多问一句:这条语句是改结构、改数据,还是查数据?改操作不可怕,可怕的是在错误分类下用了错误的回滚策略。DML可以用事务包住,出问题ROLLBACK;DDL不行,MySQL里的DDL会隐式提交当前事务,执行完再想反悔就晚了。
另一个常见分类混淆是TRUNCATE。很多人以为TRUNCATE是DELETE的升级版,其实MySQL把TRUNCATE归为DDL,它采用 drop table + recreate table 的方式清空数据,过程中还会隐式提交。这意味着你没法用事务包住TRUNCATE然后回滚。在我们实际项目里,清空一张表之前必须确认这张表是不是需要保留结构、是不是有外键引用、是不是生产库,任何一个环节没确认都可能酿成事故。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. DDL:结构定义类语句的“高危”真相与线上避坑方法
2.1 一个完整的建表语句应该注意什么
DDL的第一步不是写CREATE,而是想清楚表结构。我见过太多表里全是varchar(255)、int字段甚至用float存金额。这里我给一个比较标准的订单表建表语句,也是后文反复会用到的表:
sql复制CREATE TABLE `t_order` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`order_no` varchar(64) NOT NULL COMMENT '订单号',
`user_id` bigint NOT NULL COMMENT '用户ID',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '订单金额',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '订单状态:0待支付,1已支付,2已取消',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
字段类型的选择有几个容易被忽略的点。
主键用bigint而不是int,尤其业务量稍微大一点,int的21亿上限没有想象中那么远。订单号用varchar(64)并加唯一索引,因为订单号是业务唯一键,不允许重复。金额必须用decimal而不是float或double,二进制浮点数没法精确表示0.1这样的十进制小数,算订单金额、账务余额时会出现奇怪误差,这在金融和电商场景是致命的。
状态字段用tinyint而不是varchar或int,存枚举值够用且节省空间。created_at用datetime并给默认值CURRENT_TIMESTAMP,省得每次INSERT都要手动塞时间。字符集直接指定utf8mb4,而不是utf8,因为utf8在MySQL里最大只支持3字节,一些生僻字和emoji存进去就报错或变问号,utf8mb4才是真正的完整UTF-8。
int(5)这个问题很多人困惑,热搜里也常常有人搜“mysql中int+5”。简单说,int(5)不代表“最多只能存5位数”,它只是显示宽度,配合ZEROFILL使用才有意义。INT类型在MySQL里固定占4字节,无符号范围是0到4294967295,有符号范围是-2147483648到2147483647。哪怕你写int(5),存123456789也OK,因为int(5)并没有限制存储范围。
索引设计也不能贪多。很多新人喜欢把所有字段都加上索引,结果写入性能暴跌、索引文件膨胀。经验是:查询频繁where的条件、join的关联字段、order by排序字段适合加索引;区分度低的字段比如status状态列,一般不加普通索引,除非这种状态的查询结果本身就是要从大表里筛出少量数据。高频唯一字段才适合唯一索引。
2.2 DDL隐式提交:误以为事务能回滚是最大的坑
DDL真正危险的地方在于隐式提交。很多人以为只要把SQL包在事务里,任何一步出错都能回滚,看到CREATE、ALTER这些命令也照写不误。看这个例子:
sql复制START TRANSACTION;
DELETE FROM t_order WHERE status = 0;
CREATE INDEX idx_status ON t_order(status);
ROLLBACK;
你觉得ROLLBACK能撤销DELETE吗?不能。因为在MySQL里执行CREATE INDEX时,它会把当前事务隐式提交掉,前面的DELETE已经永久生效。这不是MySQL设计缺陷,而是DDL本身需要修改数据字典,很多结构变更无法在事务型存储引擎中做成原子回滚。
所以实际开发中有一条铁律:不要把DDL和DML混在同一个事务流程里。如果一个业务脚本里既要改数据又要建索引,宁可分成两段执行,先让DML显式COMMIT,再单独执行DDL。否则脚本执行到一半,你根本不知道哪些数据已经默默提交了。
另一个同样危险的操作是ALTER TABLE。在MySQL 8.0里,ADD COLUMN这类操作有部分支持INSTANT算法,可以秒级完成,但ADD INDEX这类操作仍然要扫描原表数据并重建索引。5.7及更早版本更是如此,大表ALTER会以拷表方式执行,在执行期间产生MDL锁,阻塞其他会话的读写。
2.3 大表改结构的教训:线上加索引差点拖垮从库
讲一个真实案例。某团队给一张近两千万行的订单表加索引,选择白天14点高峰时段执行了下面这条SQL:
sql复制ALTER TABLE t_order ADD INDEX idx_user_created (user_id, created_at);
MySQL 5.7环境下,ADD INDEX虽然走INPLACE算法,但仍需要扫描全表数据并构建二级索引,期间会产生大量redo日志和IO压力。执行到一半,主库CPU明显升高,从库复制延迟一路飙到三千秒以上。所有走从库的报表查询全部超时,慢SQL数量从每分钟几十条冲到了上千条。最后还是KILL掉ALTER任务才恢复了正常。
这个事故后我总结了大表DDL的几个必做动作。
第一步,评估对象。先确认表有多少行、多大:
sql复制SELECT
COUNT(*) AS row_cnt,
ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) AS size_mb
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'mydb' AND TABLE_NAME = 't_order';
第二步,选方案。几十万行的小表直接执行问题不大;超过千万行或者数据量很大的表,强烈建议用pt-online-schema-change或gh-ost这类在线变更工具,它们通过建临时表、拷贝数据、Binlog回放的机制减少锁表时间。即使线下不方便装工具,也必须在业务低峰期执行,并做好回滚预案。
第三步,盯监控。执行ALTER期间重点看threads_running、mdl锁等待、主从延迟时间。如果发现慢查询堆积或从库延迟超过阈值,果断KILL掉,不要抱着“再等等就好”的侥幸心理。
DDL是SQL分类里最需要敬畏的一类。一个优秀的开发不是不会写CREATE和ALTER,而是知道什么时候该写、什么时候不该直接写。
3. DML:条件没写全会怎样?谈谈数据操纵的边界习惯
3.1 UPDATE和DELETE之前,先做SELECT和COUNT
DML是所有SQL分类里使用频率最高的,也是出事概率最大的。原因很简单:INSERT可能因为约束失败但不会误伤已有数据;而UPDATE和DELETE一旦WHERE条件写错,影响的是整个表的数据,而且MySQL默认不会弹窗提示“你将影响多少行”。
我给团队定的规矩是:生产库执行UPDATE、DELETE之前,先用相同WHERE写一条SELECT确认影响范围。
sql复制-- 先查数量,确认范围
SELECT COUNT(*) FROM t_order WHERE created_at < '2020-01-01';
-- 再执行删除或更新
DELETE FROM t_order WHERE created_at < '2020-01-01';
这个习惯看着简单,实际能拦住绝大多数DML事故。很多线上数据被误清,问题根本不是SQL语法复杂,而是执行人一看表名匹配就直接敲了回车。
如果删除的数据量比较大,不要一条DELETE删几十万行,否则会产生大事务、多行锁、主从延迟,甚至把undo表空间撑爆。正确的做法是分批删除:
sql复制DELETE FROM t_order
WHERE created_at < '2020-01-01'
ORDER BY id
LIMIT 1000;
循环执行上述语句,每次观察主从延迟和锁情况,确认稳定后再删下一批。批量删除也可以放到事务里,每批结束就提交一次,避免事务过大。
3.2 更新操作的另外一个隐患:不确定的WHERE
除了漏写条件,UPDATE最常见的翻车场景是WHERE条件没能精确圈定目标行。比如下面这类写法:
sql复制UPDATE t_order SET status = 1 WHERE status = 0;
如果业务只需要把某一个用户的待支付订单改成已支付,却漏了user_id条件,这条SQL会把全表所有待支付订单全部更新,直接导致业务数据混乱。这种故障比删除更麻烦,因为数据还在,但已经错了,事后回滚几乎不可能。
所以团队可以开启MySQL的安全更新模式,在mysql命令行或客户端连接初始化时设置:
bash复制mysql --safe-updates -u root -p
SQL_SAFE_UPDATES开启后,不带WHERE条件的UPDATE和DELETE会被拒绝执行,并要求必须带LIMIT或主键条件。这适合本地开发环境,可以大幅减少误操作。生产环境更常用的做法是在管理平台或工单系统层面做防护,但思路一样:给危险DML加一道闸门。
3.3 死锁是怎么产生的,现场怎么查
死锁这个词在MySQL面试里出现频率很高,但很多人在生产环境遇到死锁还是一脸懵。死锁本质上就是两个或多个事务互相持有对方需要的锁,又不肯释放。
比如两个会话执行以下操作:
事务A:
sql复制START TRANSACTION;
UPDATE t_order SET amount = amount + 1 WHERE id = 100;
UPDATE t_order SET amount = amount + 1 WHERE id = 200;
COMMIT;
事务B:
sql复制START TRANSACTION;
UPDATE t_order SET amount = amount + 1 WHERE id = 200;
UPDATE t_order SET amount = amount + 1 WHERE id = 100;
COMMIT;
如果事务A先更新了id=100的行,事务B先更新了id=200的行,然后A再去更新id=200时会等B的锁,B再去更新id=100时会等A的锁,两边互不相让,死锁就出现了。
InnoDB检测到死锁会自动回滚其中一个事务,另一个事务继续执行。客户端报错一般是Deadlock found when trying to get lock; try restarting transaction。
排查死锁最直接的办法是执行:
sql复制SHOW ENGINE INNODB STATUS\G
在输出里找到LATEST DETECTED DEADLOCK部分,会看到两个事务分别持有和等待哪些行锁。但日常开发中更有价值的是预防,我的经验有三条:一是多行UPDATE按固定顺序执行,比如都按id升序更新;二是事务尽快提交,减少持锁时间;三是更新条件尽量走主键索引,避免扫描时锁住大量行。
3.4 DELETE、TRUNCATE、DROP到底差在哪
这是SQL分类知识最高频的面试题,我直接给一张对比表:
| 操作 | 分类 | 删除内容 | 能否回滚 | 自增ID | 表空间 | 速度 |
|---|---|---|---|---|---|---|
| DELETE | DML | 按条件删除数据行 | 事务内可回滚 | 不重置 | 不释放 | 逐行删,较慢 |
| TRUNCATE | DDL | 清空所有数据行 | 不可回滚 | 重置 | 释放 | 极快 |
| DROP | DDL | 删除表结构和数据 | 不可回滚 | 表没了 | 释放 | 极快 |
理解了分类,这张表就不需要背。DELETE属于DML,它逐行删除,可以通过事务回滚,但不会重置AUTO_INCREMENT;TRUNCATE会先DROP表再重建,所以速度极快、自增会重置、隐式提交导致不可回滚;DROP更进一步,连表结构也没了。
4. DQL性能之源:书写顺序、执行顺序和慢SQL排查链路
4.1 执行顺序为什么能帮你理解90%的SQL问题
DQL虽然只有SELECT一个核心动词,但它占据了日常开发SQL工作量的八成以上。我看到的绝大多数SQL性能问题都出在DQL,而不是DML。
先记住核心结论:书写顺序不等于执行顺序。SQL的书写顺序通常是:
text复制SELECT -> FROM -> WHERE -> GROUP BY -> HAVING -> ORDER BY -> LIMIT
但数据库引擎实际执行顺序是:
text复制FROM -> JOIN -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT
为什么这个顺序重要?因为它决定了哪些条件能在这个阶段使用。我举一个最常见的错误:在WHERE里对聚合结果做过滤。
sql复制SELECT
user_id,
COUNT(*) AS order_cnt,
AVG(amount) AS avg_amount
FROM t_order
WHERE status = 1
GROUP BY user_id
HAVING order_cnt > 5
ORDER BY avg_amount DESC
LIMIT 10;
上面这段SQL的执行脉络是:
- FROM先拿到t_order全表
- WHERE过滤status = 1的行
- GROUP BY按user_id分组
- 对每组计算COUNT和AVG
- HAVING把组内订单数大于5的组筛出来
- SELECT最终返回需要的列
- ORDER BY按平均金额排序
- LIMIT取前10
可以看到HAVING是在分组之后执行,所以能对COUNT、AVG这些聚合结果过滤;WHERE是在分组之前执行,不能引用聚合函数。很多人写成WHERE order_cnt > 5马上报错,就是没理解这个顺序。同样,SELECT别名在WHERE里也基本不能用,因为WHERE执行时SELECT别名还没生成。
4.2 一个慢SQL的完整排查链路
热搜词里“慢sql优化”一直居高不下,说明这是很多开发的实际痛点。处理慢SQL时,分类意识会帮上大忙:慢查询问题,90%是DQL语句写得不合理,但解决手段往往要靠DDL来加索引。定位链路一般分四步。
第一步,开启慢日志。配置文件里设置:
ini复制slow_query_log = ON
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
超过1秒的SQL会被记录到慢日志。第二步,从慢日志摘出有问题的SQL,用EXPLAIN看执行计划:
sql复制EXPLAIN SELECT * FROM t_order
WHERE user_id = 12345
ORDER BY created_at DESC
LIMIT 10;
EXPLAIN结果需要重点关注四列:type、possible_keys、key、rows。type如果出现ALL,说明是全表扫描;key是NULL说明没走上索引;rows的值很大说明扫描行数非常多,可能是索引没建好或建错了。
第三步,分析为什么没走索引。常见原因有成堆,列几个最常见的。
对索引列做函数或运算会让索引失效:
sql复制SELECT * FROM t_order WHERE DATE(created_at) = '2024-01-01';
这种写法在created_at上用了DATE函数,索引无法生效。改成范围查询更好。
隐式类型转换也会让索引失效。比如order_no是varchar类型,但如果这样查:
sql复制SELECT * FROM t_order WHERE order_no = 10086;
MySQL会把字符串和整数比较时对字段做隐式转换,导致索引失效。正确写法是给字符串加引号:
sql复制SELECT * FROM t_order WHERE order_no = '10086';
还有不符合最左前缀的复合索引查询、LIKE '%关键词'的开头模糊查询,都会把索引废掉。
第四步,根据业务SQL设计更合理的索引。很多查询排序和过滤条件不是同一个字段,像上面那条user_id + created_at排序的SQL,最佳方案是建一个复合索引:
sql复制ALTER TABLE t_order ADD INDEX idx_user_created (user_id, created_at);
这样WHERE可以走user_id字段过滤,ORDER BY可以直接用created_at索引顺序完成排序,不需要额外filesort。
4.3 深分页和延迟关联:一个经典优化案例
LIMIT分页是常规操作,但数据量大以后会越来越慢,尤其是深分页:
sql复制SELECT * FROM t_order
ORDER BY created_at
LIMIT 100000, 20;
LIMIT 100000, 20意味着MySQL要先查出来100020行,再扔掉前100000行,只返回最后20行。前面这些被扫描掉的数据全是无用功,所以越往后翻页越慢。
优化思路有几种。如果业务场景允许,可以用“基于ID的迭代查询”,记录上一页最后一条ID,下一页只查ID大于它的前N条:
sql复制SELECT * FROM t_order
WHERE id > 100000
ORDER BY id
LIMIT 20;
这种写法每次都能直接走主键索引,性能极稳,缺点是只适合按顺序翻页且不能随意跳页的场景。
如果必须保留深分页跳页能力,可以改用延迟关联:先只查出主键ID,再用主键ID反查完整行:
sql复制SELECT t.*
FROM t_order t
INNER JOIN (
SELECT id
FROM t_order
ORDER BY created_at
LIMIT 100000, 20
) tmp ON t.id = tmp.id;
子查询里覆盖索引只取id,扫描代价小很多。外层再按id回表查完整数据。实测在千万级表上,这种写法通常能比直接LIMIT快数倍甚至一个数量级。
5. DCL与TCL:账号权限和事务,日常维护中被忽视的闸门
5.1 不要所有环境都用root连库
DCL平时在单机开发中容易被忽略,因为本机MySQL直接root一把梭。到了团队协作或生产环境,权限问题就会冒出来。
最典型的场景:新来的开发要连测试库,直接找DBA要root密码,然后所有操作都用root执行。一旦写错DELETE条件或DROP了表,责任和损失都没有边界。更合理的做法是给应用或个人创建最小权限账号,只授予他完成工作所需的那几类SQL权限。
比如只允许应用账号对某个库执行增删改查,不授予DDL权限:
sql复制CREATE USER 'app_user'@'10.20.%' IDENTIFIED BY 'StrongPass123';
GRANT SELECT, INSERT, UPDATE, DELETE ON mall.* TO 'app_user'@'10.20.%';
FLUSH PRIVILEGES;
这里host限定了10.20.%网段,避免任何地方都能连上来。查看一个账号当前拥有什么权限:
sql复制SHOW GRANTS FOR 'app_user'@'10.20.%';
如果发现授权过多,可以用REVOKE回收:
sql复制REVOKE DELETE ON mall.* FROM 'app_user'@'10.20.%';
应用账号能用SELECT、INSERT、UPDATE、DELETE完成业务读写就够了,为什么不能给ALTER、DROP、CREATE?因为这些DDL权限一旦暴露给应用,代码里任何SQL注入漏洞都可能被用来删库或改表。就算没有恶意,程序bug误执行了结构变更SQL,影响面也远大于数据错误。
5.2 TCL事务控制语句的正确姿势
TCL虽然经常被归到DML讨论里,但熟练掌握COMMIT、ROLLBACK、SAVEPOINT的人其实不多。
先看一个标准的转账事务需求。要把100元从A账户转到B账户,必须保证两条UPDATE要么都成功,要么都失败:
sql复制START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE user_id = 1;
UPDATE account SET balance = balance + 100 WHERE user_id = 2;
COMMIT;
如果第二条UPDATE因为余额不足或账户不存在报错,应用层可以主动执行ROLLBACK,两条更新一起撤销,数据保持一致。
SAVEPOINT是事务里的“标记点”,适合长事务中部分回滚的场景。比如一个事务里前两步已经完成,第三步出错但不想回滚前两步:
sql复制START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE user_id = 1;
SAVEPOINT sp_deduct;
UPDATE account SET balance = balance + 100 WHERE user_id = 2;
-- 假设这里发现user_id=2账户异常,需要撤销第二步,但不影响第一步
ROLLBACK TO sp_deduct;
COMMIT;
这个例子中,ROLLBACK TO sp_deduct只回滚到SAVEPOINT点之后的操作,第一步扣款仍然保留,最终事务继续COMMIT。
5.3 隔离级别和事务纪律
MySQL的默认隔离级别是REPEATABLE READ,与常见的Oracle默认READ COMMITTED不同。很多人在并发测试时发现数据和预期不一样,就是没搞清楚隔离级别对可见性的影响。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不会 | 可能 | 可能 |
| REPEATABLE READ | 不会 | 不会 | 可能(InnoDB通过间隙锁部分避免) |
| SERIALIZABLE | 不会 | 不会 | 不会 |
开发中我比较强调三条纪律。第一,事务尽量短,尤其不要在事务里做Redis调用、HTTP请求、复杂业务计算,长时间持锁会放大锁冲突。第二,多个事务要按相同顺序访问资源,降低死锁概率。第三,注意autocommit状态,别让连接一直挂着未提交事务,否则行锁和undo信息会不断累积。
6. 几个高频SQL细节和自查清单:去重、between、注入与准备环境
6.1 DISTINCT、GROUP BY和窗口函数去重怎么选
去重查询也是经常被搜的问题。DISTINCT和GROUP BY在单字段去重时结果基本一样,比如:
sql复制SELECT DISTINCT user_id FROM t_order;
等价于:
sql复制SELECT user_id FROM t_order GROUP BY user_id;
但两者侧重点不同。DISTINCT是“直接消除重复行”,只适合简单去重展示;GROUP BY更偏重分组聚合,后面可以接COUNT、SUM、AVG等聚合函数。实际业务里经常需要取“
