做了这么多年后端开发和数据库运维,跟SQL打交道是每天的家常便饭。在增删改查这四个字里,如果说SELECT是查,那INSERT、UPDATE、DELETE这三兄弟就是DML(Data Manipulation Language,数据操作语言)的核心主力。很多初学者刚接触SQL时总觉得“不就是个INSERT INTO嘛,有什么好讲的”,可一旦到了生产环境,一条慢UPDATE能把整张表锁死,一句DELETE误操作能让业务直接停摆——DML这东西,看着简单,背后全是门道。
这篇文章我想从DML的整体设计思路、核心语法、实操细节到常见故障排查,一次讲透。不管你是刚入行的新人,还是写过几年SQL但没系统梳理过的老手,这篇文章都能帮你把DML这块补扎实。我会用实际业务场景来拆解,也会穿插一些我踩过的坑和验证过的方法论,保证你读完能直接用。
1. 内容整体设计与思路拆解
1.1 DML的核心定位:业务操作落地的唯一通道
数据库里有DDL(数据定义语言,建表、改表结构)、DCL(数据控制语言,权限管理),还有DML。DML负责的是数据内容的变更——往表里塞数据、改数据、删数据。任何业务系统的每一次用户点击、每一笔订单生成、每一条日志记录,最终都要通过DML语句落到数据库里。
你可以把表想象成一个仓库,DDL是建仓库、DCL是配钥匙,而DML就是往仓库里放货、搬货、扔货的动作。没有DML,表结构定义得再完美也只是个空壳。这个定位决定了DML一定是所有SQL操作里使用频率最高、对性能和稳定性影响最直接的部分。
我见过不少开发者喜欢把所有逻辑都写到应用层,数据库只当个存储工具,结果遇到批量数据要处理时,一条一条发SQL,性能惨不忍睹。DML不是简单的“能改数据就行”,它要解决的问题包括:怎么写才能不锁表、怎么更新才能命中索引、怎么删除才能不影响线上在读请求。这些才是DML真正值得深挖的地方。
1.2 为什么选择“先理解再实操”的拆解方式
我在带团队和写培训资料时有一条经验:直接给语法清单是没用的,三天之后准忘。DML这类高频操作,真正应该掌握的是它背后的逻辑。比如UPDATE的执行过程是先定位数据、再加锁、再修改,理解了这条链路,你就知道为什么WHERE条件不走索引会导致全表扫描加行锁升级为表锁。
所以本文不打算干巴巴罗列语法,而是把每个操作放到真实业务场景里来讲——订单表更新库存、用户表批量改状态、日志表清理过期数据。每个场景都配SQL示例和注意事项,你照着场景练一遍,比背十遍语法都管用。
另外,不同数据库在DML语法上是有差异的。MySQL、PostgreSQL、SQL Server、Oracle各有各的方言特性,比如MySQL有ON DUPLICATE KEY UPDATE,PostgreSQL有ON CONFLICT,SQL Server有MERGE。本文以MySQL为主,因为这些思想是通用的,遇到其他数据库时我会标注差异点,避免你换库抓瞎。
2. INSERT:别只会写单行插入
2.1 INSERT基础语法与执行细节
INSERT负责往表里添加新记录,标准语法是:
INSERT INTO table_name (column1, column2, column3, ...) VALUES (value1, value2, value3, ...);这里有个关键点值得展开:显式指定列名比不指定列名更好。如果你的表以后加了字段,不带列名的INSERT INTO table_name VALUES (...)会直接报错或者错位插入——这一条在生产环境我踩过不止一次。显式列名的写法虽然长了点,但表结构变化时影响可控,字段对应关系一目了然。
另一个容易被忽略的细节是默认值。如果表的某些列设了DEFAULT(比如create_time DEFAULT CURRENT_TIMESTAMP),INSERT时不写这一列,它会自动用默认值填充。利用这个特性可以少写不少冗余字段,而且还能避免代码里手动传时间戳带来的时区不一致问题。
2.2 批量插入:别一条一条发SQL
新手最常见的错误就是循环单条INSERT。比如要插入5000条用户数据,代码里for循环执行5000次INSERT,每次都有一次网络往返、一次SQL解析、一次事务提交。生产环境下这能让数据库CPU直接飙升,响应时间慢得没法看。
正确做法是用批量插入:
INSERT INTO user_info (name, age, email) VALUES ('张三', 25, 'zhangsan@example.com'), ('李四', 30, 'lisi@example.com'), ('王五', 28, 'wangwu@example.com');批量插入的原理是一次SQL语句携带多行VALUES,数据库只需要解析一次、执行一次,事务也只需要提交一次。我在压测中实测过:同样插入1万条记录,单条循环需要约8秒,批量插入(每批500条)只需要约0.4秒,性能差距在20倍左右。
批量插入的批次大小也有讲究。不是批越大越好,我试过一批插5000条,虽然次数少了,但单次生成的SQL文本太大,数据库要分配的buffer更多,反而出现内存抖动。实践下来,每批500到1000条是个比较稳的区间,数据量大的时候分批提交,既能控制事务大小,也不会因为一条失败就全部回滚。
2.3 插入冲突处理:UPSERT的几种实现
业务中常遇到“有就更新,没有就插入”的需求,这就是UPSERT。MySQL支持ON DUPLICATE KEY UPDATE:
INSERT INTO user_counter (user_id, login_count) VALUES (1001, 1) ON DUPLICATE KEY UPDATE login_count = login_count + 1;这种写法的前提是表上有唯一索引或主键,数据库才能判断“重复”是什么。它比“先SELECT再判断再INSERT/UPDATE”要高效得多,因为省去了多次查询和条件分支,同时也能避免并发下两个请求同时判定“不存在”而插入两条重复数据的问题。
PostgreSQL的写法是ON CONFLICT (user_id) DO UPDATE SET ...,SQL Server和Oracle则可以用MERGE INTO实现。这几个语法思路一致,核心都是“让数据库自己判断冲突”,而不是靠应用层判断。但凡涉及并发写入,应用层判断大概率会出问题,这就是我在代码评审里永远会提醒的一点:用数据库原生的UPSERT能力,别在应用层拼操作。
3. UPDATE:别让整表数据陪你冒险
3.1 UPDATE的执行逻辑与WHERE的生死线
UPDATE基本语法:
UPDATE table_name SET column1 = value1, column2 = value2 WHERE condition;这段语法看着简单,但它的执行逻辑你如果不清楚,迟早出事。UPDATE执行时,数据库会先定位到满足WHERE条件的行,然后对这些行加锁,接着才修改数据。问题就出在这个“定位”上:如果WHERE条件能走索引,数据库快速定位到少量行,锁的范围就小;如果WHERE条件不走索引(比如对无索引列做函数运算、LIKE '%xxx'),数据库只能全表扫描,把所有行都扫一遍才开始加锁更新——这时候锁的是整张表。
生产环境里有一种典型事故:凌晨跑数据订正任务,UPDATE语句的WHERE条件因为字段类型不匹配(比如字符串列没加引号)导致索引失效,一张几千万行的表被全表扫描加锁,第二天业务高峰所有读写全部阻塞。这种问题我在排查时见过太多次。
所以UPDATE语句有一条红线:WHERE条件必须命中索引。写UPDATE之前先EXPLAIN一下,确认走了索引,再上生产。
3.2 多表关联更新的写法与使用场景
当更新的数据来自另一张表时,需要用到关联更新。MySQL的语法是:
UPDATE orders o JOIN temp_orders t ON o.order_id = t.order_id SET o.status = t.status;这个场景很常见:外部系统导入了订单状态变更,需要把变更结果同步到主订单表。JOIN UPDATE的优点是把“查临时表得到新值”和“更新主表”合成了一条SQL,避免了在应用层逐条处理。
但关联更新有个明显的性能隐患:JOIN的驱动表选择。MySQL优化器一般会选择小表作为驱动表,但如果关联条件的索引缺失,临时表几万行数据和主表几百万行做关联,代价非常高。我的建议是:关联字段两边都要有索引,UPDATE前先单独跑一下SELECT JOIN确认执行计划和数据量,再改为UPDATE执行。
3.3 UPDATE的性能优化:分批更新和限流思想
全表更新或大范围更新是另一个高频事故点。比如把某状态字段都改成“已过期”,一次UPDATE影响几百万行,锁表时间按分钟算,业务在读请求全部被阻塞。
解决方案是分批更新:
UPDATE table_name SET status = 'expired' WHERE status = 'active' ORDER BY id LIMIT 500;然后循环执行这条SQL,直到影响行数为0。每次只更新500行,事务很快提交,锁很快释放,其他请求就能在间隙继续执行。配合SELECT SLEEP(0.1)让每次更新之间停顿100毫秒,对在线业务的影响几乎可以忽略。
这个方法看起来简单,但我见过不少团队在数据订正时一次UPDATE到底,把线上业务卡死。经验总结就一句话:大范围更新必须拆批,拆批的大小根据表的行宽和索引情况调整。行宽大的表(字段多、TEXT类型多)每批就少一点,行宽小的表可以多点。
4. DELETE与TRUNCATE:删除不只是删数据
4.1 DELETE的语法与锁影响
DELETE的标准语法:
DELETE FROM table_name WHERE condition;DELETE和UPDATE一样,第一步也是定位数据,第二步加锁,第三步删除。所以前面讲的WHERE条件走索引、控制影响行数,这些原则在这里同样适用。
有个常见的细节是:DELETE语句如果不加WHERE,那就会清空整张表。这个操作的危险程度不用多说,在MySQL里可以通过sql_safe_updates参数(默认关闭)来保护:开启后,不带WHERE的UPDATE/ DELETE会被拒绝执行。我建议所有人都在开发环境和测试环境开启这个参数,养成好习惯。
另外和UPDATE不同的一点是,DELETE会产生大量的undo日志。在InnoDB存储引擎下,如果一个事务删除了大量数据但不提交,undo日志会不断膨胀,塞满undo表空间会导致数据库不可写。这也是为什么大范围删除需要分批提交,不能让一个事务长时间持有太多锁和日志。
4.2 DELETE与TRUNCATE:清空表数据时选谁
如果需要清空一张表的所有数据,可以用TRUNCATE TABLE。它的底层实现和DELETE完全不一样:TRUNCATE是直接重建表,而不是逐行删除数据,所以速度极快,而且不产生逐行的undo日志。
两者的核心区别我列个表:
| 对比项 | DELETE | TRUNCATE |
|---|---|---|
| 条件删除 | 支持WHERE | 不支持,只能全清 |
| 速度 | 逐行删,慢 | 重建表,极快 |
| 事务回滚 | 可以回滚 | 隐式提交后不可回滚 |
| 自增ID | 保留当前值 | 重置为初始值 |
| 锁范围 | 行锁(条件合理时) | 表锁 |
选择依据很简单:只要确定整张表的数据都没用了(比如临时表、日志表的归档前清理),就选TRUNCATE;只要还有一点可能“想反悔”,就老老实实用DELETE。TRUNCATE在MySQL中会隐式提交事务,一旦执行无法回滚,比DELETE危险得多。
4.3 大批量删除的黄金实践
生产上清历史数据是个经典难题。一张日志表累计了上亿条记录,需要只保留最近30天,一次DELETE一亿行数据显然不现实。我的做法是分两步:
第一步,把要保留的数据INSERT到新表:
CREATE TABLE log_table_new LIKE log_table; INSERT INTO log_table_new SELECT * FROM log_table WHERE create_time >= NOW() - INTERVAL 30 DAY;第二步,用TRUNCATE或RENAME的方式快速切换表:
RENAME TABLE log_table TO log_table_old, log_table_new TO log_table; DROP TABLE log_table_old;RENAME TABLE是原子操作,切换瞬间完成,业务几乎无感知。这个方案比DELETE几千万行再VACUUM要快几个数量级,我在处理数据清理时基本都用这套思路——能用建新表+切换解决的,绝不在原表上硬删。
5. 实战中那些DML的坑,我替你踩过了
5.1 误更新/误删除的恢复策略
DML最怕什么?最怕的是WHERE条件写错导致大量数据被误更新或误删除。我要说的是:数据库的恢复手段是有限的,靠binlog恢复只能到“删除前一刻”,前提是binlog开着且保留完整。
MySQL有binlog并开启row模式时,可以通过mysqlbinlog解析出误操作前的数据,再逆向生成INSERT语句恢复。但这个过程非常耗时,而且要求从事故发生到发现之间没有其他写入,否则很难精确还原。
真正负责的做法是在执行高风险DML之前做好备份(mysqldump或物理备份),并且把SQL先放到测试环境验证影响行数,再拿到生产在事务里执行,先查后改:
SELECT COUNT(*) FROM orders WHERE status = 'abc'; -- 先看数量 UPDATE orders SET status = 'xyz' WHERE status = 'abc'; -- 确认后再改如果数据库支持,也可以先开一个事务执行UPDATE,检查影响行数无误后COMMIT,有问题直接ROLLBACK。我自己在操作生产数据时的习惯是:必须两条SQL,一条SELECT确认,一条UPDATE执行,绝不把条件手打成只有自己能看懂的样子。
5.2 慢SQL与DML优化:索引失效的典型场景
DML语句慢,绝大多数情况是WHERE条件的索引没有生效。常见失效场景包括:
- 对索引列使用函数:
WHERE YEAR(create_time) = 2024,改成WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01'。 - 隐式类型转换:字符串列和数字比较,索引失效。
- 前导通配符:
LIKE '%keyword'无法走索引,LIKE 'keyword%'可以。 - OR条件涉及非索引列:整个条件无法走索引,拆成UNION或者改用IN。
慢SQL的排查工具优先用EXPLAIN看执行计划里的type字段:const和ref是好的,ALL就是全表扫描,必须优化。线上慢日志(slow query log)也要开起来,定时扫一遍,很多潜在问题都是这么提前发现的。
5.3 并发环境下的DML注意点:死锁与锁等待
两个事务互相持有对方需要的锁时会发生死锁,其中一条会被数据库回滚,应用层抛出死锁异常。典型场景:事务A先UPDATE表1再UPDATE表2,事务B先UPDATE表2再UPDATE表1,两条并发执行时各执一把锁,谁也走不下去。
解决办法的核心是让所有事务按相同的顺序访问表和行。比如规定所有代码里UPDATE的顺序必须一致:先用户表,再订单表,再日志表。顺序统一之后,死锁概率大幅下降。另外,事务尽量短——不要在事务里做远程调用、等待用户输入,事务越长,锁持有时间越久,锁等待和死锁概率就越高。
排查死锁用SHOW ENGINE INNODB STATUS的LATEST DETECTED DEADLOCK段落,能看到互相等待的具体SQL,定位很快。
5.4 利用DML做数据清洗:去重与空值处理的实用写法
热搜里就有“sql语句去重”“sql去除空值”,说明这是日常高频需求。去重不一定要用SELECT DISTINCT,DML里经常需要的是删除重复数据,只保留一条。MySQL的写法:
DELETE FROM user_info WHERE id NOT IN ( SELECT MIN(id) FROM user_info GROUP BY email );注意MySQL有个小坑:DELETE的子查询不能直接引用同一张表(You can't specify target table for update in FROM clause),需要包一层临时表:
DELETE FROM user_info WHERE id NOT IN ( SELECT * FROM ( SELECT MIN(id) FROM user_info GROUP BY email ) AS tmp );空值处理上,把空字符串转成NULL再统一处理是常见清洗逻辑:
UPDATE user_info SET phone = NULL WHERE phone = '';5.5 DML中随手可用的实用小技巧
最后分享几个我日常写DML时的小技巧,都是验证过很高效的:
- INSERT ... SELECT:从一张表直接取数插入另一张表,比应用层读出来再插入高效得多,常用于数据归档。
- UPDATE ... ORDER BY ... LIMIT:配合游标或循环做分批更新,前面已经讲过,这里再次强调是真的好用。
- GET_LOCK()做分布式锁:MySQL的
SELECT GET_LOCK('key', 10)可以拿到命名锁,防止多个任务重复处理同一条DML数据。 - 先EXPLAIN再执行:无论UPDATE还是DELETE,凡是要上生产的,先EXPLAIN看执行计划,这是职业素养。
数据库DML这块,看起来是最基础的技能,但真正拉开水平差距的地方恰恰就在这些最基础的细节里。我刚开始写SQL时也觉得DML没什么好学的,直到有一次在大表上执行UPDATE忘了条件,把整个线上服务拖死,才明白什么叫“会写”和“写得好”是两回事。之后每次写DML心里都会多问一句:这条语句走了索引吗?影响多少行?事务多久能提交?能不能分批?
这套思维方式不是一朝一夕练出来的,但一旦养成,数据库相关的故障会少掉一大半。希望这篇文章能帮你建立这样一个下意识检查的习惯,从今往后写DML时心里有底。