很多人觉得 MySQL 的增删改查没什么好讲的,无非是 INSERT、SELECT、UPDATE、DELETE 四条语句,背个语法就能上手。但实际在业务里跑几年就会发现,真正让你在深夜被电话叫醒的,往往不是那些复杂的 JOIN 和存储过程,而是一条没带 WHERE 的 UPDATE、一个没走索引的查询条件、一次大批量 DELETE 引发的锁等待。所以这篇东西我不想只给你罗列语法,而是把增删改查从“怎么写”讲到“为什么这么写”,再讲到“出了问题怎么排查”,让刚入门的朋友能建立完整的知识框架,也让写了几年代码但没系统整理过的朋友查漏补缺。
我尽量用实际工作的视角来讲,所有示例都基于一份简单的用户表和订单表,你可以直接复制到本地 MySQL 里跑。版本以 MySQL 8.0 为主,但大部分内容在 5.7 上也同样适用。
1. 一次增删改查背后到底发生了什么
先建立一个大视角。增删改查在 MySQL 里的全流程,本质上是应用系统里数据流动的最小闭环:客户端发起连接,把 SQL 文本交给服务端,经过连接器校验身份、解析器做词法和语法分析、优化器根据统计信息和索引情况生成执行计划,最后由执行器调用 InnoDB 存储引擎接口去读写数据页。每一条你以为“很简单”的 SQL,背后都牵扯到行锁、事务日志、索引维护、MVCC 多版本控制这些机制。
这意味着什么?意味着如果你把 SQL 当成“字符串拼接完能跑就行”,那后面所有性能问题、死锁问题、数据一致性问题都会找上你。我见过太多这样的例子:一个同事写了个查询,本意是取出最近 30 天的数据,但因为日期字段没建索引,每次请求都全表扫描,数据库 CPU 直接被打满;另一个同学在测试环境执行 UPDATE 时忘了写 WHERE,整个表的数据被改成了同一个值。这些事故看起来五花八门,根子全在增删改查的基础上。
1.1 为什么“最简单的SQL”反而最容易出问题
先举几个我在实际工作中遇到的场景。某次线上事故是统计任务里一条 SELECT 没走索引,全表扫描把磁盘 IO 打满,所有业务查询全部排队;还有一次是多个事务同时对同一行做更新,其中一个事务迟迟不提交,其他事务全部报 Lock wait timeout exceeded,看起来像“数据库死机”,实际上就是锁等待超时;再有就是某个同学用 REPLACE INTO 同步数据,结果自增主键疯狂跳号,一张不大的表 id 涨到了几亿,就是因为 REPLACE 每冲突一次就执行一次“删除+插入”,自增值消耗速度远高于预期。
所以我对增删改查的学习路径有个建议。第一步,必须能熟练正确地写,覆盖绝大多数业务场景;第二步,要理解每条语句在 MySQL 中是怎么被执行的,为什么有的快有的慢,为什么有的会锁表有的只锁行;第三步,遇到问题时知道查哪些系统表、看哪些状态变量,能够自己定位根因。这篇文章主要围绕前两步,第三步会在后面的排查章节详细展开。
1.2 学习增删改查前先把环境和表结构准备好
要动手验证后面的示例,你得先有一个能跑的 MySQL。版本上我建议直接用 8.0 系列,因为 5.7 已经进入生命周期末期,新装的机器优先选 8.0 或更新的 LTS 版本。下载安装这块简单说两句:Windows 下用安装包或者 zip 解压都行,zip 包需要手动初始化 data 目录、配置 my.ini;Linux 下用发行版自带的包管理器安装最省心,但要注意默认仓库里的版本可能偏旧,想装特定版本可以下载官方 tar 包手动部署。装完之后用命令行客户端连接,再用图形化工具如 DBeaver、Navicat 辅助看表结构,两者结合就足够日常使用了。
后面的示例统一用shop这个数据库,包含user用户表和orders订单表。设计成两张表,是为了能演示主键、唯一键、外键、聚合查询和连接查询,比单表示例务实得多。下面从建库建表开始,把增删改查每个操作完整过一遍。
2. 增删改查的语法细节与必须避开的坑
这一节是全文的核心,我按 INSERT、SELECT、UPDATE、DELETE 四个方向逐个拆解,每个方向都附上常见误区和实战心得。你读完以后,可以试着整理一份属于自己的“增删改查避坑清单”。
2.1 插入:批量插入、重复键处理与常见误区
插入数据的标准语法很简单:
INSERT INTO user (name, age, email) VALUES ('张三', 28, 'zhangsan@example.com');这里第一条建议是:显式写字段列表,不要省略。省略字段列表的INSERT INTO user VALUES (...)写法完全依赖表结构的列顺序,一旦后来有人执行 ALTER TABLE 调整了列的位置,老脚本可能悄悄写错列而不报错。这在团队协作里是很让人头疼的问题。
接着讲批量插入的价值。同样插入 1000 行数据,一条多值 INSERT 比 1000 条单行 INSERT 快得多,原因有两个。一是减少了大量网络往返和 SQL 解析开销,二是 InnoDB 在批量写入时可以更高效地维护二级索引和日志缓冲。我本地实测过,插入 10 万行数据,分批次每次 500 行,总耗时几秒钟;一条一条插可能要一两分钟,差距非常明显。所以业务代码里如果需要一次性写入较多数据,优先考虑批量方式。
INSERT INTO user (name, age, email) VALUES ('李四', 30, 'lisi@example.com'), ('王五', 25, 'wangwu@example.com'), ('赵六', 35, 'zhaoliu@example.com');再说重复键处理。如果user.email字段有唯一约束,再插入相同邮箱就会报 ERROR 1062。常见的三种应对方案是 INSERT IGNORE、ON DUPLICATE KEY UPDATE 和 REPLACE INTO,它们的差异非常关键。INSERT IGNORE 遇到冲突直接忽略这一行,适合导入场景里“不希望因为重复数据中断任务”;ON DUPLICATE KEY UPDATE 是冲突时更新指定列,比如累加次数、更新时间戳,这是最常用的方案;REPLACE INTO 的逻辑则是“先删旧行、再插新行”,副作用最大,不仅会额外消耗自增主键,还可能触发 DELETE 相关触发器,非特殊需求我不建议用。
补充一个 8.0 的细节:从 MySQL 8.0.20 开始,ON DUPLICATE KEY UPDATE后面的VALUES()函数已经被标记为废弃,推荐用别名语法替代。例如:
INSERT INTO user (name, age, email) VALUES ('张三', 29, 'zhangsan@example.com') AS new ON DUPLICATE KEY UPDATE age = new.age;这样写意图更清晰:冲突发生时直接用new.age拿到准备插入的新值。初看可能觉得别扭,但习惯以后会觉得很自然,而且提前适配新语法也能避免以后升级 MySQL 时报错。
2.2 查询:执行顺序、WHERE与HAVING、排序与分页
SELECT 是整个增删改查里最灵活也最容易写出低效语句的操作。很多人写查询靠“试”而不是靠“懂”,最典型的误区是分不清 SQL 的书写顺序和执行顺序。书写顺序是SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT,但 MySQL 真正执行时的逻辑顺序是FROM → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT。这个顺序解释了为什么 WHERE 里不能用 SELECT 中定义的别名,而 HAVING 里可以;也解释了为什么普通字段条件应该放在 WHERE 而不是 HAVING。
我用一个统计例子说明:统计订单金额总和大于 100 的用户及其总金额。
SELECT user_id, SUM(amount) AS total_amount FROM orders WHERE status = 1 GROUP BY user_id HAVING total_amount > 100;这里status = 1是行级过滤条件,在分组前执行,放 WHERE;total_amount > 100是聚合后的组级过滤条件,放 HAVING。如果反过来把status条件塞进 HAVING,MySQL 就需要先对全表分组,再过滤结果集,浪费的时间和资源完全没必要。
排序分页也是查询里的重灾区。ORDER BY 默认升序,指定DESC降序。排序如果走索引会非常快,如果对未索引的大结果集排序,MySQL 就会用到 filesort,数据量大时会把临时结果写到磁盘,对性能影响严重。分页最常见的坑是深分页:
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;这条语句要把前 100 万行都扫出来再丢掉,只保留最后 20 行,代价极高。常见的优化手段是延迟关联:先用覆盖索引拿分页后的主键集合,再回表取完整行。对大多数业务系统来说,数据量一旦超过几十万行,分页查询就必须认真对待,否则接口迟早会越来越慢。
DISTINCT 去重也要注意。SELECT DISTINCT user_id FROM orders会基于整行数据比较去重,如果后面只取一列,问题不大;但如果你希望按某个字段去重、同时返回其他字段,DISTINCT 就容易误伤,这时候通常应该用GROUP BY配合聚合函数来达到目的。
2.3 更新:条件过滤、锁范围与最危险的误操作
UPDATE 的语法本身不复杂,但它是增删改查里破坏力最大的语句。执行 UPDATE 时,InnoDB 会对命中的行加排他锁,同时生成 undo 日志用于回滚;如果 WHERE 条件没有走索引,锁的范围可能会无限扩大,甚至从行锁升级成锁住大量记录。线上那些“一个无索引条件的 UPDATE 把所有请求卡住”的事故,本质都是锁范围失控。
所以我自己写 UPDATE 时,有三条铁律:第一,执行前先用同样的 WHERE 条件跑一次 SELECT,确认影响行数和目标数据;第二,把 UPDATE 放进事务,COMMIT 之前仔细看一眼影响行数;第三,生产环境的 DML 脚本尽量带上主键或唯一键条件,哪怕业务上稍微绕一点。
一个非常典型的安全更新示例:
START TRANSACTION; SELECT * FROM user WHERE id = 100; UPDATE user SET age = age + 1 WHERE id = 100; COMMIT;如果你在客户端工具里执行,开启事务后可以先不加 COMMIT,直接 ROLLBACK 回滚,验证没问题再提交。这个习惯能救你很多次。
多表 UPDATE 也是业务里常见的写法,比如根据订单金额回写用户等级:
UPDATE user u JOIN orders o ON u.id = o.user_id SET u.level = u.level + 1 WHERE o.amount > 5000 AND o.status = 1;这种写法要小心 JOIN 的匹配行数。如果 JOIN 出来的结果里同一个用户匹配多个订单,这个用户会被更新多次。想做到一个用户只加一次等级,就得先对订单表做分组去重,再用UPDATE ... JOIN (派生表)的方式完成。
2.4 删除:DELETE与TRUNCATE的区别及碎片处理
DELETE 是另一种高危 DML,语法和 UPDATE 类似,但有几个关键点必须讲清楚。首先是 DELETE 和 TRUNCATE 的本质差异。DELETE 是 DML,可以加 WHERE 条件,逐行删除,事务内可以回滚;TRUNCATE 是 DDL,直接重建表,不能加 WHERE,执行过程中会隐式提交事务,无法回滚,同时会重置自增计数器,速度比 DELETE 快得多。我整理了一张对比表,建议直接收藏:
| 操作 | 类型 | 可加 WHERE | 可回滚 | 是否重置自增 | 执行速度 | 适用场景 |
|---|---|---|---|---|---|---|
| DELETE | DML | 是 | 是(事务内) | 否 | 慢 | 删除部分数据、逻辑删除 |
| TRUNCATE | DDL | 否 | 否 | 是 | 快 | 清空临时表、重置测试数据 |
| DROP | DDL | 否 | 否 | 是 | 快 | 删除整个表结构 |
接着说一个 DELETE 的隐形副作用:它不会自动回收磁盘空间。InnoDB 删除的行只是被标记为“可复用”,表空间文件的大小不会立刻下降。频繁、大面积地 DELETE 会产生碎片,导致后续插入和扫描性能下滑。应对方案是分批删除,并在低峰期执行OPTIMIZE TABLE重建表、回收碎片。
大批量删除的推荐写法是循环分批,而不是一条 DELETE 删几百万行。例如每次删除 1000 条:
DELETE FROM orders WHERE created_at < '2020-01-01' ORDER BY id LIMIT 1000;重复执行直到影响行数为 0。这样能把单条事务的锁持有时间控制在很短的范围内,避免长时间锁表拖垮主库,也避免 undo 日志膨胀太快。需要归档老数据时,我的流程是先把数据 INSERT 到归档表,再按上面的方式分批清理原表。
3. 手把手走一遍完整的增删改查实操流程
纸上谈兵到此为止,下面我们用一套真实的表结构,把增删改查完整走一遍。你可以把这段 SQL 复制到本地执行,体验一次“从零到查询”的完整链路。
3.1 建库建表:从需求到字段类型和索引选择
创建数据库时第一个要决定的就是字符集。我建议统一用utf8mb4,因为它支持完整的 Unicode,包括 emoji 和一些生僻字,utf8只能存基本多语言平面,遇到特殊字符就会报错或者乱码。
CREATE DATABASE IF NOT EXISTS shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE shop; CREATE TABLE user ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, age TINYINT UNSIGNED DEFAULT 0, email VARCHAR(100) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_email (email) ) ENGINE=InnoDB;这里几个字段设计值得解释一下。年龄用TINYINT UNSIGNED而不是INT,因为年龄最大也就一百多岁,一个字节就能表达,省空间;email 加唯一键,既保证数据不重复,也为后面的ON DUPLICATE KEY UPDATE提供冲突依据;created_at用DATETIME,默认值设置为当前时间,这样插入时就不用手动传时间字段了。
再建订单表,让它和用户表产生关联。
CREATE TABLE orders ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_id (user_id), KEY idx_created_at (created_at), CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user(id) ) ENGINE=InnoDB;amount用DECIMAL(10,2)而不是FLOAT,是因为金额需要精确存储,浮点数存在二进制无法精确表示的问题,算账时容易产生分毫误差。user_id和created_at各建一个普通索引,为后面的查询优化做准备。外键在业务系统里到底建不建,一直有争论,但如果是从学习角度出发,我建议建上,因为外键能帮你理解数据完整性和 DELETE 相关的约束行为。
3.2 插入与查询:多方式写入及多种查询场景演练
先插入基础数据:
INSERT INTO user (name, age, email) VALUES ('张三', 28, 'zhangsan@example.com'), ('李四', 30, 'lisi@example.com'), ('王五', 25, 'wangwu@example.com'), ('赵六', 35, 'zhaoliu@example.com');再插入几笔订单:
INSERT INTO orders (user_id, amount, status) VALUES (1, 199.00, 1), (1, 599.00, 1), (2, 89.00, 0), (3, 1299.00, 1), (3, 399.00, 2);然后验证刚刚讲的查询技巧。先看普通条件查询,再看聚合统计,最后看连接查询。
-- 查询已支付订单中金额大于 100 的订单 SELECT id, user_id, amount, created_at FROM orders WHERE status = 1 AND amount > 100 ORDER BY amount DESC;-- 统计每位用户的订单总额,并且只保留总额大于 200 的用户 SELECT user_id, SUM(amount) AS total_amount, COUNT(*) AS order_cnt FROM orders GROUP BY user_id HAVING total_amount > 200 ORDER BY total_amount DESC;-- 连接查询:显示每个订单对应的用户名和邮箱 SELECT o.id, u.name, u.email, o.amount, o.status FROM orders o JOIN user u ON o.user_id = u.id ORDER BY o.id;注意第三条语句里我给表起了别名o和u,在字段多的查询中,别名能显著提升可读性和书写效率。分组统计结果里也可以直接体验 WHERE 与 HAVING 的差异:如果把status = 1放进 WHERE 就在聚合前过滤,放进 HAVING 就在聚合后过滤,执行计划差别很大。
3.3 更新与删除:结合事务演示一次安全的DML流程
下面演示一次完整的安全更新流程。假设要给用户“张三”的订单金额统一加 10 块钱“服务费”,并且只处理已支付订单。
第一步,先查询确认将影响哪些记录:
SELECT id, user_id, amount FROM orders WHERE user_id = 1 AND status = 1;第二步,开启事务执行更新:
START TRANSACTION; UPDATE orders SET amount = amount + 10 WHERE user_id = 1 AND status = 1; -- 再次查询确认结果 SELECT id, user_id, amount FROM orders WHERE user_id = 1 AND status = 1; COMMIT;如果第二步的查询结果不对劲,直接执行ROLLBACK;,一切回到更新前状态。这套“先查、再改、后确认”的流程,是我在团队里要求新人必须遵守的底线,比任何权限管控都更直接有效。
删除的演示同样走这个流程。假如要删除订单总额为零且状态为无效(status=2)的订单,先查再删:
SELECT id, user_id, amount, status FROM orders WHERE amount = 0 AND status = 2; DELETE FROM orders WHERE amount = 0 AND status = 2;如果表中存在外键关联,删除父表记录时往往会碰到约束问题。比如直接删除用户 id=4:
DELETE FROM user WHERE id = 4;如果该用户已经有子表订单,会报 ERROR 1451 外键约束失败。遇到这种情况,需要先处理子表数据,再删父表记录。这也是为什么我建议学习阶段保留外键约束的原因,它能让你真实体会到“数据库帮你守规矩”的感觉。
4. 增删改查常见报错与排查技巧实录
实际操作里,增删改查报错和异常的频率远超你想象。我把这些年最常遇到的几个问题整理成速查表,再单独拆解一个线上锁等待的完整排查过程。
4.1 高频报错速查表
| 报错信息 | 原因 | 解决方案 |
|---|---|---|
| ERROR 1062:Duplicate entry | 插入数据违反了主键或唯一键约束 | 改用 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE |
| ERROR 1175:You are using safe update mode | 客户端开启了安全更新模式,UPDATE/DELETE 没带主键条件 | 加上主键或唯一键条件;或临时关闭安全模式 |
| ERROR 1205:Lock wait timeout exceeded | 目标行被其他事务长时间锁住 | 查看information_schema.innodb_trx,杀掉持锁事务 |
| ERROR 1451:foreign key constraint fails | 外键约束阻止删除或更新父表记录 | 先处理子表数据,或检查外键策略 |
| ERROR 1093:You can't specify target table for update in FROM clause | 在同一语句中 UPDATE 目标表又被 SELECT 子查询引用 | 把子查询包一层派生表,或改用 JOIN 语法 |
| ERROR 1366:Incorrect string value | 插入内容与字段字符集不兼容 | 统一使用 utf8mb4,检查表和连接字符集 |
| ERROR 1264:Out of range value | 数值超出字段类型范围 | 修改字段类型,或校验业务数据范围 |
这里特别说一下 1093 这个错误。MySQL 不允许在同一条 UPDATE 语句里既修改目标表,又从目标表做子查询。典型场景是“把某表中金额最高的几条数据加标记”,直接写会报 1093,需要用派生表包一层:
UPDATE orders SET status = 9 WHERE user_id IN ( SELECT user_id FROM ( SELECT user_id FROM orders WHERE status = 1 GROUP BY user_id ORDER BY SUM(amount) DESC LIMIT 3 ) t );最内层先查出目标 user_id,外层再更新,就能避开 MySQL 的限制。这类报错在面试里也经常被问,值得记一下。
4.2 实战排查:更新阻塞与锁等待的处理思路
有一次同事跟我说某个更新订单状态的接口突然变慢,几十秒才返回,后续请求直接超时。我第一反应不是查应用代码,而是去数据库里看当时有哪些事务在运行:
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id FROM information_schema.innodb_trx;结果发现有一个事务已经跑了快十分钟,状态是 RUNNING,一直没提交,而它正好持有一批订单行的锁。再结合SHOW PROCESSLIST;看线程信息,定位到是一个后台任务里的事务忘记 COMMIT,导致后续所有更新相同行的请求全部排队,最终报锁等待超时。
处理方式也很直接,找到那个持锁线程,应用侧无法快速结束任务时,先通知负责人确认是否可以回滚,然后执行:
KILL 线程ID;持有锁的事务被强制回滚之后,排队的更新请求立刻恢复。这个案例说明两件事:第一,锁等待不一定是死锁,很多时候只是一方事务太长没提交;第二,上线前做好 SQL 审核和超时设置,能省掉绝大多数半夜被叫醒的麻烦。另外,我习惯在监控里定期查innodb_trx这个视图,把事务持续时间超过 60 秒的都记下来,提前发现隐患。
5. 让增删改查“变快”的几个进阶思路
最后聊几个进阶方向,都是围绕增删改查性能展开的。你不需要一口气全部掌握,但知道这些关键词和思路,遇到问题会更有方向。
5.1 索引是如何改变查询路径的
数据库里的索引相当于书的目录。没有索引,MySQL 只能从第一页翻到最后一页,也就是全表扫描;有索引,它能定位到叶子节点,直接找到目标行。InnoDB 的索引默认是 B+ 树,主键索引的叶子节点存了整行数据,二级索引的叶子节点存的是主键值。所以查询时如果只在二级索引上就能拿到全部需要字段,就免去了回到主键索引取整行的过程,这叫覆盖索引,是减少回表开销的常见优化手段。
但索引不是越多越好,每次 INSERT、UPDATE、DELETE 都要同步维护索引结构,索引多了写入自然变慢。所以判断一个索引建不建的依据,不是“这个字段经常出现”,而是“查询条件和排序分组是否真的依赖它”。比如前面订单表的idx_user_id,就是为高频的WHERE user_id = ?查询准备的;如果还存在WHERE status = ? AND created_at >= ?这类查询,就要考虑联合索引(status, created_at),并且注意最左前缀原则。
5.2 批量DML、undo日志与碎片对性能的影响
写入性能的优化重点在“小事务,批量化”。小事务意味着每条 SQL 的锁持有时间短,并发冲突概率低;批量化意味着减少事务提交次数和日志刷盘次数。一个很典型的选择题:插入 1 万行数据,是分成 1 万次单条 INSERT 提交,还是分成 10 次每次 1000 行的批量提交?后者通常快出一个数量级以上,因为每次 COMMIT 都涉及磁盘同步,而这个同步成本是固定的。
DELETE 和 UPDATE 的性能问题往往和多版本并发控制有关。InnoDB 为了支持回滚,执行 UPDATE 或者 DELETE 时会把旧值写入 undo 日志,如果一次操作影响的行数特别多,undo 会快速膨胀,还会导致 purge 线程清理不及时,形成“长事务”连带问题。这也是我反复强调“分批删除”的根本原因:不是为了让系统看起来更谨慎,而是让 undo、锁、binlog 的压力都被控制在小范围内。更新大表字段时也可以考虑先把目标主键捞出来,再按主键分批更新,效果同样显著。
5.3 事务隔离级别与并发读写冲突
MySQL InnoDB 默认的事务隔离级别是 REPEATABLE READ,也就是可重复读。在这个级别下,普通 SELECT 看到的是快照读,不会阻塞其他事务的写入,因此“读写不冲突”是 InnoDB 并发能力强的重要基础。真正容易出问题的是两个事务同时更新同一行,这时会触发锁等待甚至死锁。减少死锁的常规手段包括:固定 DML 的访问顺序,比如都按主键从小到大处理;每条事务尽量短小快速;批量更新时避免多张表交叉访问。
如果你的业务是偏统计分析的宽松场景,可以按需调整为 READ COMMITTED,减少 gap lock 带来的锁竞争。不过这个操作建议由 DBA 或者资深开发评估后再执行,别在没完全理解隔离级别差异前贸然修改。
关于存储过程和预处理语句,这里也简单提一句。存储过程本质上是把一段增删改查逻辑封装在数据库端,减少应用和数据库之间的网络往返;预处理语句则能把 SQL 模板与参数分离,既提升重复执行效率,也能规避常见的 SQL 注入风险。两者都是增删改查的合理延伸,但不是银弹,过度使用反而会让维护成本上升,合理评估再决定用不用。
说回个人体会。我在带新人的时候,最常强调的其实不是语法记得多熟,而是每一次写增删改查前,先问自己三个问题:这条 SQL 会影响多少行?会不会锁很多记录?如果执行错了能不能快速回滚?把这三个问题想清楚,很多线上事故根本不会发生。数据库没有玄学,所有的“慢”和“卡”,本质上都是某个细节没有处理好。希望这一篇能帮你把这些细节串起来,少踩几个坑。