1. DML的边界:为什么说增删改查是SQL的核心
1.1 SQL四大分类与DML的位置
DML全称Data Manipulation Language,数据操纵语言。学MySQL的人迟早都要过这一关——把增删改查这四类操作彻底吃透。很多初学者一听到"操纵"这个词就紧张,觉得像什么黑客操作,其实它说的就是最日常的增删改查。你写的每一条SELECT、INSERT、UPDATE、DELETE,都属于DML的范畴。
先理清SQL的整体分类,这样后面学什么都不会乱。SQL通常被分成四个大类:
- DDL(Data Definition Language):数据定义语言,主要负责表、库、索引这类结构性的东西,常见的有CREATE、ALTER、DROP、TRUNCATE。
- DML(Data Manipulation Language):数据操纵语言,负责对表里的数据做操作,主要是SELECT、INSERT、UPDATE、DELETE。MySQL官方文档里还把REPLACE、LOAD DATA这些也归到DML章节。
- DCL(Data Control Language):数据控制语言,负责权限管理,GRANT、REVOKE就是这类。
- TCL(Transaction Control Language):事务控制语言,COMMIT、ROLLBACK、SAVEPOINT,负责把DML操作包裹成事务。
有一个老生常谈但又特别容易在面试里被问到的点:SELECT到底算不算DML?国内不少教材把SELECT单独拎出来叫DQL(Data Query Language),理由是"查询"和"操纵"不是一回事。但打开MySQL官方文档,SELECT被归在DML章节下。我自己在实际面试时一般这样应对:先说明"DML广义上包含增删改查四种操作",再补充"有些教材把查询单独归为DQL,但在MySQL官方文档里SELECT属于DML的一部分"。两边都给到,既显得知识扎实,又不至于跟面试官抬杠。
这套分类的意义不只是应付面试。你在写代码、做SQL审查、排查问题的时候,脑子里要先有一个分类框架:表结构出了问题找DDL,数据出了问题找DML,权限出了问题找DCL,事务提交出了岔子找TCL。分类清楚了,定位问题的速度能快一倍。
1.2 DML的执行层差异:存储引擎决定下限
这个点放在前面讲,是因为后面所有DML操作的"坑"都跟它有关。MySQL在5.5版本之后默认存储引擎是InnoDB,InnoDB支持事务、支持外键、支持行级锁,所以你的INSERT、UPDATE、DELETE默认都是"可回滚"的。但如果你在建表时指定了ENGINE=MyISAM,那这个表的所有DML操作都不支持事务,DELETE删了就是删了,没有后悔的余地。
我在实际项目里见过一个经典事故:某个老系统为了让一张日志表查询更快,把引擎改成了MyISAM,结果运营误删了一批数据,想ROLLBACK发现根本不存在事务。所以记住一句话:生产环境里,除非你非常明确地知道自己在干什么,否则一律用InnoDB。MyISAM在MySQL 8.0里已经被移除了,这也侧面印证了InnoDB才是现在的绝对主流。
另一个容易被忽略的点是字符集。DML操作数据时,字符集不一致会导致查询条件匹配不上。比如表是utf8mb4,连接串里指定的是latin1,你按中文条件去UPDATE或者SELECT,可能出现"查得到但改不动"或者"改了但多改了几行"的诡异现象。这种问题排查起来非常痛苦,而且一旦发现,通常线上已经受影响了一段时间。所以建库的时候就把字符集统一成utf8mb4,连接参数里也显式指定,是最省心的做法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. INSERT写入:从单行插入到批量灌数,每一步都有讲究
2.1 INSERT基础语法和几个容易被忽略的细节
最常见的INSERT写法:
sql复制INSERT INTO user (name, age, email) VALUES ('张三', 28, 'zhangsan@example.com');
有几个细节值得展开。
第一,字段列表可以不写,直接给值,比如INSERT INTO user VALUES ('张三', 28, 'zhangsan@example.com')。但我不建议你这么写,因为你必须知道表结构的所有字段顺序,一旦表结构加了字段、调整了顺序,这条SQL就会把数据插入到错误的列。实际开发中表结构变更是很常见的事,宁可多敲几个字段名,也不要偷这个懒。写字段列表还有一个好处:代码评审的时候,别人一眼就能看出你插入的是哪些列,对不对得上,风险一目了然。
第二,字符串和日期类型的值要加引号。数字类型可以不加,但为了统一风格,有些团队习惯给所有值加引号。这里我要多说一句:数字列真不建议加引号,因为MySQL在涉及隐式类型转换时可能无法使用索引,后面做性能调优的时候这是个重点。字符串列引号必须加,不加直接语法报错。
第三,INSERT执行成功后,如果你的表有自增主键,那SQL会返回一个"受影响的行数",同时你可以在代码里拿到生成的主键ID。很多ORM框架(比如MyBatis的useGeneratedKeys)就是靠这个机制把新生成的ID回填到对象里的。如果你用JDBC,可以调用Statement对象的getGeneratedKeys()方法拿到这批自增ID。
2.2 批量插入的性能差异和实操建议
如果一次要插入大量数据,最忌讳的就是在代码里写for循环,一条一条INSERT。每条INSERT都有网络往返、SQL解析、事务日志写入,5000条数据就要来回5000次,性能惨不忍睹。我见过一个实际案例:一个导入功能原本用循环逐条插入,处理10万行数据耗时将近十分钟;改成批量插入之后,耗时降到几十秒,差别是数量级的。
正确做法是用一条语句插入多行:
sql复制INSERT INTO user (name, age, email) VALUES
('张三', 28, 'zhangsan@example.com'),
('李四', 30, 'lisi@example.com'),
('王五', 25, 'wangwu@example.com');
一次插入多少条合适?这个没有绝对标准,我的经验是500到1000条一批比较稳。太少了(比如几十条)体现不出批量优势,太多了(比如几万条)会占用过多内存、拉长单条SQL的执行时间,一旦某一批失败,回滚成本也高。另外要注意max_allowed_packet这个参数,默认一般是64MB,如果单条INSERT语句太大超过这个限制,MySQL会直接报错。大数据量导入时,脚本里分批循环执行是最稳妥的。
还有一个非常实用的场景是INSERT INTO ... SELECT,把一张表的数据查询后直接插入另一张表:
sql复制INSERT INTO user_backup (name, age, email)
SELECT name, age, email FROM user WHERE create_time < '2024-01-01';
这种写法在做数据归档、建临时表、数据迁移的时候非常好用,比先查出来再程序里循环插入高效太多。我自己做数据清洗时就经常这么干,一条SQL就能把几百万行数据搬过去。唯一的注意事项是,INSERT和SELECT的字段数量、字段类型要对齐,否则MySQL会报错或者做隐式类型转换,后者可能带来精度损失。
2.3 主键冲突处理:INSERT的边界情况
插入数据时经常遇到主键冲突。最典型的场景是:你有一批数据要同步,其中一些已经存在。如果直接INSERT,会报Duplicate entry错误。这时候有几个处理思路。
第一种是INSERT IGNORE,遇到主键或唯一索引冲突就忽略这条数据,不报错,继续插入后面的:
sql复制INSERT IGNORE INTO user (id, name) VALUES (1, '张三');
第二种是ON DUPLICATE KEY UPDATE,冲突时改为更新指定字段:
sql复制INSERT INTO user (id, name, age) VALUES (1, '张三', 28)
ON DUPLICATE KEY UPDATE age = VALUES(age);
VALUES(age)这种写法在MySQL 8.0.20之后有官方推荐的新写法,用别名的方式更清晰:
sql复制INSERT INTO user (id, name, age) VALUES (1, '张三', 28) AS new
ON DUPLICATE KEY UPDATE age = new.age;
第三种是REPLACE INTO,这个要格外谨慎。它做的事情是:如果主键或唯一索引冲突,先删掉旧行,再插入新行。这意味着如果表里有外键引用这个主键,REPLACE可能因为删行失败而报错;同时它会产生DELETE加INSERT两条操作,比ON DUPLICATE KEY UPDATE多一步删除,效率更低,并且自增ID会变化。所以REPLACE INTO我只有在"完全不管旧数据、丢了就丢了"的场景下才会用,绝大多数情况下宁可用ON DUPLICATE KEY UPDATE。
3. UPDATE修改:一条语句能影响多少行,取决于你的WHERE
3.1 UPDATE的执行逻辑
UPDATE的基本语法:
sql复制UPDATE user SET age = 29 WHERE name = '张三';
MySQL执行UPDATE时,InnoDB引擎的逻辑是"先找行,再改行"。它会根据WHERE条件定位到需要修改的行,然后对定位到的行加锁,修改后再记录UNDO日志,以便事务回滚时能恢复原值。所以UPDATE的WHERE条件如果能走索引,定位行就快;如果走不了索引,InnoDB只能全表扫描,一行一行判断,性能和锁范围都会变差。
有一个新手必须理解的返回值概念:UPDATE执行后会告诉你"受影响的行数"。这里有个坑——如果UPDATE把某行的值改成了和原来一样的值,"受影响的行数"可能是0,也可能是实际匹配的行数,这取决于MySQL的版本和连接参数。MySQL默认情况下,如果新值与旧值完全相同,它不会真的去执行写入,所以影响行数可能是0。这个细节在代码里判断"更新是否成功"时特别容易踩,我见过不少同事用受影响行数来判断数据是否存在,结果因为值没变化返回0而误判。正确做法是,先更新,再根据受影响行数判断,但如果业务上需要区分"数据不存在"和"数据存在但值没变",就要另外用SELECT去确认。
3.2 UPDATE忘加WHERE的全表事故
这是DML领域最经典的翻车场景:UPDATE user SET age = 30;没有WHERE条件,跑完之后全表所有用户的年龄都变成30了。类似的还有DELETE FROM user;直接把表清空。这种事故听起来很傻,但几乎每个团队都经历过一次。
怎么防范?第一,习惯性检查WHERE条件,写UPDATE之前先用SELECT查一遍匹配行数,确认范围没问题再执行。第二,开启MySQL的safe-update模式(也叫sql_safe_updates),这个模式在MySQL客户端和JDBC连接里都有对应配置,开启之后,UPDATE和DELETE如果没有WHERE条件,或者WHERE条件里没有用到索引列,MySQL会拒绝执行。我自己的习惯是开发和测试环境一直开着这个开关,虽然在生产环境它可能限制一些合法的全表操作,但为了防手滑,值得牺牲一点便利。
另外说一个执行顺序的细节:UPDATE语句里SET的赋值顺序是有讲究的。MySQL 8.0的SQL标准允许SET后面按从左到右的顺序赋值,但不同版本行为可能不一致。比如UPDATE user SET age = age + 1, num = age;这条语句,num到底是取原来的age还是加1之后的age,在不同版本里可能有不同结果。为了避免歧义,我建议一个UPDATE里的多个赋值字段不要互相依赖,或者用子查询把值算清楚再赋进去,不要依赖赋值顺序这种不可控的行为。
3.3 UPDATE与行锁的恩怨
InnoDB的UPDATE默认走行级锁。这意味着你更新某一行时,其他事务如果也想更新同一行,会被阻塞,直到你提交事务或回滚。
我遇到过一个真实的死锁案例:两个事务分别更新两条记录,但是更新顺序不一致。事务A先UPDATE id=1再UPDATE id=2,事务B先UPDATE id=2再UPDATE id=1,两个事务互相等对方释放锁,就死锁了。MySQL检测到死锁后,会自动回滚其中代价较小的事务,并抛出Deadlock found错误。但代价是那个事务的所有操作都白做了,业务程序如果没做重试,用户就会看到一次偶发失败。解决办法是约定好更新顺序,让所有事务都按照主键从小到大或者某种统一顺序来更新,就能避免交叉等待。
还有一个非常常见的问题是"长事务"导致的锁等待。有人执行了一个UPDATE,忘记COMMIT,事务一直开着,其他线程对这行的UPDATE就全部卡住了。这种问题在排查时往往表现为"SQL执行超时",你查show processlist能看到一堆连接卡在同一个表上。所以写代码时,事务要短、要快,COMMIT要及时。如果一个事务里需要做很多事,尽量把耗时操作(比如调外部接口)放到事务外面。
提示:在MySQL 8.0里,默认隔离级别是REPEATABLE READ(可重复读),这个级别下普通SELECT走的是MVCC一致性非锁定读,不会阻塞其他事务的UPDATE;但如果是SELECT ... FOR UPDATE或者SELECT ... LOCK IN SHARE MODE这种显式加锁的读,就会和其他写事务互相阻塞。
4. DELETE删除:TRUNCATE、DROP、DELETE到底选谁
4.1 DELETE基础语法和"删了不等于空间释放"
DELETE的基本用法:
sql复制DELETE FROM user WHERE id = 1;
删除操作本身看起来简单,但有几个关键点要清楚。
第一,DELETE只是把数据行标记为删除,并不会立刻释放磁盘空间。在InnoDB里,删除后的空间会被数据库内部回收利用,但物理文件不会马上变小。如果你对一个几百万行的表反复做INSERT和DELETE,表文件会越涨越大,碎片越来越多。解决方法是定期用OPTIMIZE TABLE或者ALTER TABLE ... ENGINE=InnoDB来重建表、整理碎片。不过这两个操作会锁表,所以要安排在业务低峰期执行,否则容易把请求堵在表上。
第二,DELETE也走事务。也就是说,在没COMMIT之前,你有机会ROLLBACK。我在执行大范围DELETE时有一个固定习惯:先开启事务,用SELECT核对要删的数据行数,确认无误后再DELETE,然后再COMMIT。如果中间发现不对,直接ROLLBACK。这套流程看着多几步,但能救命的场景太多了。
第三,删除操作的WHERE条件,必须能唯一确定目标行。这是原则。很多人DELETE时随手写几个条件,结果匹配范围比预期大,多删了行。条件越精确,风险越小。如果是按主键删,那是最安全的;如果是按普通字段删,先想清楚这个字段是否唯一。
4.2 DELETE、TRUNCATE、DROP三兄弟的对比
很多人把这三个搞混,我直接用一张对比表说明。
| 操作 | 类型 | 是否支持事务回滚 | 是否释放空间 | 是否删除表结构 | 是否触发DELETE触发器 | 速度 |
|---|---|---|---|---|---|---|
| DELETE | DML | 支持(InnoDB) | 不释放,空间可复用 | 不删除 | 触发 | 慢 |
| TRUNCATE | DDL | 不支持 | 释放 | 不删除 | 不触发 | 快 |
| DROP | DDL | 不支持 | 释放 | 删除 | 不触发 | 最快 |
TRUNCATE是清空表数据、保留表结构的操作,它不会逐行删除,而是直接把表的数据页整体标记为可复用,所以速度极快。但TRUNCATE属于DDL,不是DML,它不记录UNDO日志,不可回滚,也不触发DELETE触发器。如果表里有自增主键,TRUNCATE会重置自增值从1开始;DELETE不会重置。
如何选择?我的原则是:确认要清空一张表、且不需要恢复,用TRUNCATE;只要还有一丝一毫"可能删错了"的担心,就用DELETE放在事务里操作。DROP则用于直接干掉整张表,连结构都不要了。顺便提醒一句,TRUNCATE和DROP都会隐式提交当前事务,也就是说,如果你在一个未提交的事务里先UPDATE了几行,然后执行TRUNCATE,前面UPDATE的事务会被强制提交,回滚机会直接消失。
4.3 误删数据的补救思路
误删数据是每一个开发者职业生涯里的大概率事件。如果是DELETE误删,最简单的思路是立即执行ROLLBACK——前提是事务还没提交。但很多人是执行完就顺手提交了,根本没意识到问题。
提交之后的恢复思路,我按可靠程度排个序:
- 如果有备份,用备份恢复。这是最稳妥的。
- 如果开启了binlog,可以通过binlog的timestamp或position找到删除前的数据,逆向恢复。binlog里会记录每个事务的SQL,你可以解析出删除操作之前的数据快照。
- 如果既没备份又没开binlog,那基本只能靠应用层兜底了,比如操作日志、ES、Redis里的冗余数据,能捞多少捞多少。
所以这里我要强烈建议:任何正式环境,binlog必须开启(MySQL 8.0默认开启),并且建议设置成ROW格式。因为ROW格式的binlog记录的是逐行的实际数据变更,恢复时能够精确还原到某一行;而STATEMENT格式只记录SQL语句,恢复了也不一定能精确复现当时的数据。做运维的同学还可以每天做全量备份加binlog增量备份,真要出大事了,也能恢复到任意时间点。
另外,针对"删除"场景,现在很多业务系统不用物理删除,而是做"软删除",也就是在表里加一个is_deleted字段(0正常,1已删除),删除操作变成一个UPDATE。这样既保留了数据,又能查询历史,规避误删风险。代价是每次查询都要记得加上is_deleted=0的条件,否则会把已删除的数据也查出来。这个模式在用户中心、订单系统这类对数据保留敏感的模块里非常常见。我自己做新表设计时,如果业务上对历史数据有留存要求,一般默认就加软删除字段。
5. SELECT查询:DML里真正拉开水平的部分
5.1 SELECT基础语法和几个够用的优化技巧
SELECT基础语法:
sql复制SELECT name, age FROM user WHERE age > 20 ORDER BY age DESC LIMIT 10;
新手刚接触时觉得SELECT很简单,直到他们遇到一张几百万行的表,一条没走索引的SELECT能把数据库拖到CPU飙满。所以SELECT这一节,我不打算只是罗列语法,重点讲怎么把查询写得更高效。
第一个原则:SELECT里只写你需要的字段,不要无脑SELECT *。虽然SELECT *在数据量小的表上没太大问题,但一旦表有很多列、有些列还是TEXT或BLOB类型,SELECT *会把大量无用数据从磁盘读出来,网络传输和内存开销都翻倍。ORM框架里写实体映射时也是如此,尽量映射到具体字段,避免每次都把整行数据捞出来。
第二个原则:WHERE条件里的写法会直接影响索引是否生效。最常见的例子是对索引列进行函数运算,比如WHERE YEAR(create_time) = 2024,MySQL没法直接用create_time上的索引,因为索引存储的是原始值,不是YEAR()函数的结果。正确写法是WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01',这样就能走索引范围扫描了。类似的还有对索引列做算术运算、在索引列前面加%做模糊匹配等,都会让索引失效。
第三个原则:避免隐式类型转换。比如user_id是字符串类型,你写WHERE user_id = 100,MySQL会把字符串列转成数字去比较,导致索引失效;正确写法是WHERE user_id = '100'。类似的还有字符串列和数字列直接比较、字符集不一致导致索引失效等,这类问题在SQL审查时是重点排查对象。判断一条SQL有没有走索引,最直接的办法是EXPLAIN,看type列和key列。type从好到差大致是const、eq_ref、ref、range、index、ALL,看到ALL基本就是全表扫描。
5.2 WHERE、ORDER BY、GROUP BY、HAVING的执行顺序
这一小节是很多面试题的考点,也是写复杂查询时最容易搞混的地方。一条多子句SELECT的逻辑执行顺序大致是:
- FROM:确定数据源
- WHERE:第一次过滤,行级过滤
- GROUP BY:分组
- HAVING:分组后的过滤
- SELECT:选择列,可能包含聚合函数计算
- ORDER BY:排序
- LIMIT:分页截取
为什么WHERE不能使用聚合函数、而HAVING可以?就是因为WHERE发生在GROUP BY之前,此时聚合函数还没算出来。比如你想找"订单数超过10个的用户",就必须用HAVING COUNT() > 10,而不能写WHERE COUNT() > 10。
GROUP BY本身也有一些容易踩的点。最典型的是ONLY_FULL_GROUP_BY模式:MySQL 5.7之后默认开启了这个SQL模式,它要求SELECT的列要么出现在GROUP BY子句里,要么被聚合函数包裹,否则直接报错。比如SELECT name, age FROM user GROUP BY age,在ONLY_FULL_GROUP_BY下会报错,因为name没有对应到确定的值。这种限制其实是在帮我们避免"结果不确定"的脏查询。我见过不少人遇到这个报错时,第一反应是关掉这个模式,我建议不要去关,按规范改写SQL更健康。
ORDER BY的注意点也很多。最经典的是别在ORDER BY里用函数运算,比如ORDER BY RAND()在大表上是灾难,它会对每一行都调用RAND()并排序,性能极差。我在生产环境见过一句ORDER BY RAND()把整个查询拖到几十秒的。需要随机取几条数据时,应该换个思路,比如先COUNT(*)拿到总行数,再随机生成几条偏移量去LIMIT取,或者用JOIN关联一个随机数表,都比ORDER BY RAND()靠谱得多。
5.3 LIMIT分页的深坑:深分页
LIMIT分页是每个后端都会写的功能,但很多人写过LIMIT 10000, 20这种深分页SQL之后,就再也不想写了。原因是MySQL执行LIMIT m, n时,实际上是先把前m+n行全部读出来,然后丢弃前m行,只返回最后n行。当m很大时(比如1000000),这个操作会读取1000020行,性能自然惨不忍睹。
深分页的几种优化思路:
- 用主键ID做偏移:WHERE id > 上次查询的最大id ORDER BY id LIMIT 20。这种方案利用主键索引,走的是索引范围扫描而不是全量扫描,速度飞快。适合翻页顺序固定、不跳页的场景,比如信息流、时间线。
- 用子查询先定位起始ID:SELECT * FROM user WHERE id >= (SELECT id FROM user ORDER BY id LIMIT 1000000, 1) ORDER BY id LIMIT 20。子查询里只查索引字段,比直接全表扫描快很多。
- 用覆盖索引(covering index):让查询的字段都包含在索引里,这样查询只扫描索引页,不需要回表,性能会有明显提升。
分页这个场景,经验之谈是:如果产品允许"加载更多"而不是"页码跳转",用第一种方案最舒服;如果必须支持任意页码跳转,且数据量很大,那就要考虑缓存、搜索引擎或者游标分页了,纯靠SQL硬撑不是长久之计。我在实际项目里还见过一种做法:在查询接口里加限制,单次查询最多返回1000条,超过就必须携带上一页的游标。这样既保护了数据库,也倒逼前端改成"加载更多"的交互。
6. 事务与DML:并发写入时的数据安全底线
6.1 为什么DML必须搭配事务理解
DML四大操作里,INSERT、UPDATE、DELETE都是写操作,写操作在并发环境下就会有"互相干扰"的问题。事务正是解决这个问题的官方方案。
事务的ACID四个特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。用一句话概括:事务保证一个批次的DML操作,要么全部成功,要么全部失败,不会出现"改了一半"的尴尬状态。
最经典的例子就是转账:A账户扣1000,B账户加1000。这两步必须放在同一个事务里。如果扣完A账户1000之后程序崩了,B账户没加上,这时候事务回滚,A账户的1000也会还回来,账目才会平衡。如果没有事务,要么A的钱丢了,要么B的钱凭空多了,账永远对不上。
6.2 COMMIT和ROLLBACK的使用习惯
MySQL默认是自动提交(autocommit=1),也就是说每条DML语句执行完,MySQL会自动帮你提交。这在交互式操作时很方便,但在编程世界里,你需要手动控制事务边界,把多条DML包起来:
sql复制START TRANSACTION;
UPDATE account SET balance = balance - 1000 WHERE user_id = 1;
UPDATE account SET balance = balance + 1000 WHERE user_id = 2;
COMMIT;
在编程语言里的写法是类似的,Java里配合Spring的@Transactional注解,Python里用pymysql的connection.commit()。核心思维是:把"一组逻辑上不可分割的操作"看成一个整体。事务边界要小、要快,不要在一个事务里做太多无关操作,比如调用外部API、sleep等待、长时间计算,因为那会长时间占着数据库连接和行锁。我见过一个案例:一个事务里调了一次短信服务,结果短信服务超时,整个事务卡了30秒,数据库连接池被占满,线上直接雪崩。
还有一点,某些语句会导致隐式提交,比如DDL语句(CREATE TABLE、ALTER TABLE)、以及TRUNCATE TABLE。也就是说,如果你在一个事务里执行了UPDATE,然后又执行了TRUNCATE TABLE,事务会被强制提交,之前UPDATE的回滚机会直接消失。这个细节在写数据初始化脚本时尤其要小心。
6.3 并发写入的锁机制和死锁预防
InnoDB的锁机制是行级锁,但行锁也分好几类。我捡最关键的说:
- 共享锁(S锁):SELECT ... LOCK IN SHARE MODE,允许其他事务继续加S锁读,但不允许加X锁写。
- 排他锁(X锁):SELECT ... FOR UPDATE,以及INSERT、UPDATE、DELETE自带的行锁,加锁之后其他事务不能加任何锁,只能等。
实际开发中最常见的锁问题有三个。
第一,大事务导致锁等待。解决方案是把事务拆小、把慢查询优化掉、及时COMMIT。排查时用show processlist看State列,很多卡住的连接都会显示Waiting for table metadata lock或者Waiting for row lock。
第二,间隙锁(Gap Lock)导致"明明只改一行,却把一堆行锁住"。在REPEATABLE READ隔离级别下,InnoDB的索引扫描不仅会锁命中的行,还会锁相邻的间隙,防止幻读。如果WHERE条件用的是普通索引且不是唯一索引,间隙锁的范围可能比想象的大得多。解决办法是尽量用主键或唯一索引作为WHERE条件;或者把事务隔离级别改成READ COMMITTED,但要评估业务是否依赖可重复读的语义。
第三,死锁。两个事务都持有一部分锁、又互相等待对方释放另一部分锁,就死锁了。MySQL检测到死锁后,会自动回滚代价较小的事务,并抛出Deadlock found错误。业务代码里必须捕获这个异常并做重试,这是最实在的建议。我在写订单接口时,Deadlock found的异常处理是标配,捕获到就重试两三次,大多数情况下都能成功。
写DML的最终建议是:凡是涉及并发写入的地方,先想清楚三个问题——我的WHERE条件能精确命中索引吗?事务会持锁多久?可能出现死锁吗?这三个问题想明白了,再去动手写SQL。这种习惯一旦养成,线上事故能少一大半。
