兄弟,今天聊点实在的。不管你是刚入行的菜鸟,还是写过几年业务的老手,MySQL这套增删改查(CRUD)肯定天天都在碰。但说实话,大部分人写查询就是“能跑就行”,根本没琢磨过这些操作背后的门道。遇到让人抓狂的慢查询、死锁、数据错乱问题,又从头翻文档翻到头晕。我这些年踩过的坑不少,总结下来一句话:增删改查看着简单,真要把性能、安全、逻辑都照顾到,全是细节。
这篇文章我不打算念手册,就想用实际干活的方式,把MySQL里增、删、改、查这些事的原理、坑点和完整实操流程拆开揉碎讲清楚。适合正在学数据库的初学者,也适合那些写了好几年业务但还是没搞清楚索引和事务的开发者。看完你至少能少走三个月弯路。敢说这话,是因为下面每一条都是我在真实环境里试过、翻过车的经验。
1. 整体设计与思路拆解
1.1 增删改查看似简单,真正的坑在哪
很多人一上来就背语法,INSERT INTO、SELECT、UPDATE、DELETE,背得比谁都顺,可一到真正干活就露馅。原因很简单,CRUD从来不是“把语句写对”就完事,而是一个完整的数据生命周期管理过程。
我习惯把整个体系拆成四个层面去看:
- 数据层:表结构怎么设计、字段类型怎么选、约束怎么加。这一步错了,后面写什么SQL都是补丁打法。
- 逻辑层:你的CRUD语句是否考虑了事务、并发、边界条件。比如同时有大量用户下单,库存字段怎么安全扣减。
- 执行层:MySQL真实执行你的SQL时,走没走索引、扫描了多少行、有没有产生临时表和文件排序。
- 运维层:数据量大了之后,慢查询怎么发现、表怎么优化、数据归档怎么做。
这个思路特别重要。因为你会发现,80%的线上事故都不是语法错误,而是后面三层出了问题。我见过一个项目,订单表才几万条数据,一个分页查询把主库CPU打满,最后查出来是因为用了SELECT *加OFFSET深分页,每次都要扫全表。这种情况,你语法背得再熟也没用。
1.2 一套能长期复用的执行思路
基于上面的拆解,我给自己规定了一套CRUD的固定动作:
- 先确认数据特征:这个表的数据是读多写少、写多读少,还是读写均衡?比如用户登录日志是明显写多读少,这种表就要控制索引数量,避免写放大。
- 评估数据量级:一万行和千万行,完全是两种写法。小表随便查,大表每一种查询都必须先看执行计划。
- 确认并发压力:有没有多个进程同时改同一行?这决定你要不要用事务、锁或者乐观锁。
- 写完SQL立刻看执行计划:用
EXPLAIN确认有没有走索引,扫描行数是否可控。 - 关注影响行数:UPDATE和DELETE操作,返回的影响行数符不符合预期?不是0就是有问题。
这五步看似啰嗦,但能让你的SQL稳定很多。我到现在写任何一条稍微复杂一点的语句,都习惯先走一遍这个流程,省掉无数的返工。
2. 核心细节解析与实操要点
2.1 插入数据:批量插入和唯一键冲突才是关键
INSERT是最基础的操作,但很多人一上来就写单条插入,性能稀烂。举个我实际优化过的例子:某业务每天要往日志表写几百万条数据,原来一条条INSERT,写了一个晚上都写不完。改成批量INSERT后,把500条拼接成一条语句,总耗时直接从几个小时降到了十几分钟。
批量插入的核心逻辑是减少网络往返次数和日志刷盘次数。每一条INSERT单独执行,MySQL都要经历一次SQL解析、权限检查、引擎写入、Binlog记录;而一条语句插入多行,这些开销被分摊了,效率自然高。
但是批量插入也有讲究:
- 一次别贪多:我试验下来,500到1000行一批是比较合适的。超过太多,比如一次性塞5万行,容易把内存和Binlog撑爆,锁持有时间太长,阻塞其他会话。
- 注意
max_allowed_packet限制:这个参数决定单次SQL报文的最大字节数。如果批量插入的行数太多、字段值太长,直接报Packet too large,需要调大这个参数或者减小批量。 - 唯一键冲突是个大坑:如果批量插入的数据里存在重复的唯一键,整条语句会直接报错回滚,前面插进去的也都白插了。这时候要用
INSERT IGNORE忽略冲突,或者INSERT ... ON DUPLICATE KEY UPDATE做覆盖更新。
再说一个实际场景,导入初始化数据。很多人直接用INSERT一条一条写,或者用可视化工具一条条导。正确做法是把数据导出成格式化的SQL文件或CSV,然后用LOAD DATA INFILE批量导入。我自己实测过,同样的数据量,LOAD DATA比逐条INSERT快几十倍。
要点:插入之前,先看表结构,确认哪些字段有默认值、哪些是自增主键。如果你在自增主键列强行指定了值,后面插入的序号可能会出现意想不到的断层,这本身不是问题,但如果你业务上依赖自增ID的连续性,就会出问题。
2.2 查询数据:WHERE、JOIN和分组是三大分水岭
SELECT是CRUD里最常用也最容易翻车的。新手写查询,脑子里只有“我要什么数据”,老手写查询,脑子里还会想“MySQL怎么找到这些数据”。这两者的差距,在数据量上来之后会体现得极其明显。
先说过滤条件。WHERE的写法直接决定了查询走不走索引。最常见的坑是把索引列放在函数或运算里,比如WHERE DATE(create_time) = '2024-01-01'。这种写法会导致索引失效,全表扫描。正确写法是WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02'。
再比如WHERE name LIKE '%张'这种模糊匹配,以百分号开头也是无法走索引的。非要模糊搜索,可以考虑全文索引,或者干脆用专门的搜索引擎,别硬在MySQL里拖。
接着说JOIN。我一直强调一个原则:能不用JOIN尽量不用,能用小表驱动大表最好。JOIN的底层逻辑就是嵌套循环匹配,MySQL的优化器会根据表的行数和索引情况,自动决定哪张表作为驱动表。如果你能通过设计避免掉JOIN,比如提前冗余字段,查询速度提升是立竿见影的。
在实际业务中经常有这样的需求:联查三张表,查出结果集再去过滤排序。这种SQL在数据量小的时候跑得飞快,但一旦某张表数据量过千万,性能直接断崖式下跌。我的建议是:优先缩小结果集。比如先按条件把主表数据过滤到几百条,再通过这些ID去关联其他表,比一开始就大范围JOIN高效得多。
分组统计是另一个重灾区。GROUP BY看着简单,但很多人没注意到它背后的隐形成本:MySQL通常需要创建临时表来做分组计算。如果你的分组字段没索引、结果集又大,临时表可能直接写到磁盘,那速度就惨不忍睹了。
分组统计的正确姿势:
- 尽量让分组字段走索引。
- 先用WHERE过滤掉不需要的数据,再分组,减少临时表的大小。
- 用
EXPLAIN看一下有没有Using temporary和Using filesort,如果都有,就要考虑改SQL或者加索引。
还有一点容易被忽略的是LIMIT分页。数据量大了以后,LIMIT 100000, 20这种写法会扫描前10万条数据再丢弃,极其浪费。我常用的优化手段有两种:一是利用主键做条件分页,WHERE id > 上一页最大ID ORDER BY id LIMIT 20,这种写法是走索引的,性能极好;二是如果你非要基于其他字段排序,先查出符合条件的ID列表,再用JOIN或子查询关联回原表。
2.3 更新数据:影响行数和事务边界必须盯死
UPDATE看起来人畜无害,实际坑比想象中的多。最大的坑就是更新条件没写好,导致全表被更新。我见过不止一次,有人在生产环境执行UPDATE时忘了加WHERE条件,或者WHERE条件写错,把整张表的值全部改掉。这种事故一旦发生,如果没有备份,那就是灾难。
所以更新操作我一直坚持几个习惯:
- 先SELECT确认范围。执行UPDATE之前,先用同样的WHERE条件跑一遍SELECT,看看会命中哪些行,确认无误后再执行更新。
- 看影响行数。执行完之后,如果影响行数是0,可能是数据不存在,但也可能是类型不一致或者编码问题导致匹配不上,一定要排查。
- 控制事务边界。UPDATE不是执行完就万事大吉,你需要考虑这个更新是不是应该在同一个事务里和别的操作一起提交或回滚。比如转账操作,扣款和加钱就必须放在一个事务里,要么都成功,要么都失败。
再深入一点,并发更新的问题。两个会话同时对同一行做UPDATE,后一个会阻塞等待前一个提交或回滚。如果你的事务里不止一条更新,锁的持有时间会更长,这时就容易引发死锁。MySQL的死锁检测机制会自动回滚其中一个事务,但业务上你需要捕获这种异常并重试。
我在实际项目里处理并发更新的方案通常是乐观锁。在表里加一个version字段,更新时携带上次查询的版本号:
UPDATE inventory SET stock = stock - 5, version = version + 1 WHERE product_id = 123 AND version = 3;如果影响行数为0,说明这行数据在读取之后已经被别人修改过,需要重新查询再执行。这种方式比直接把整个事务串行化要高效得多。
2.4 删除数据:物理删除和逻辑删除的本质区别
DELETE是CRUD里最需要敬畏的操作。一次误删除,可能让某个功能的线上数据瞬间空白。我的建议是:生产环境的业务表,能不物理删除就不要物理删除。
物理删除就是真正把数据从表里删掉,释放存储空间。逻辑删则是给表加一个is_deleted字段,默认0,删除时改为1,查询时统一过滤掉。逻辑删的好处很明显,数据还在,随时能恢复,也方便审计追踪。代价是查询语句要处处带条件,稍微麻烦一点;如果忘记过滤,已删除的数据就全冒出来了。
如果确实需要物理删除,以下几点务必注意:
- DELETE会逐行删除,并且记录Binlog,数据量大时,一条DELETE语句可能执行很久。对于大表的清理,我通常分批删除,比如每次删除5000行,然后
SLEEP(1),循环执行,避免长时间锁表影响业务。 - TRUNCATE和DELETE要分清楚。TRUNCATE是清空表,重置自增ID,且不记录逐行删除日志,速度极快,但无法通过事务回滚。DELETE可以加WHERE条件,也更容易控制。生产环境我几乎不用TRUNCATE,除非目标是彻底清空一张临时表。
- 删除前必须备份。哪怕是在测试环境,也建议先把要删除的数据导出备份。这种习惯养成了之后,线上翻车的概率会小很多。
还有一个很多人不知道的坑:DELETE表数据之后,表空间并不会自动缩小。这是因为InnoDB的表空间是复用机制,删除数据后页会标记为空闲,但文件不会缩小。如果你删除大量数据后想真正释放磁盘空间,需要执行OPTIMIZE TABLE或者重建表。但这个操作会锁表较长的时间,建议在低峰期操作。
3. 实操过程与核心环节实现
3.1 一个完整案例:从建表到查询优化
我直接用一个实际做过的模拟项目来完整过一遍流程。这个模拟项目的需求很简单:做一个简单的订单管理系统,需要记录订单基本信息、订单商品明细,还要能按用户、时间、状态去查询和统计订单。
第一步,设计表结构。这是整个项目的地基。订单主表我这样建:
CREATE TABLE `orders` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '订单ID', `order_no` varchar(32) NOT NULL COMMENT '订单编号', `user_id` bigint NOT NULL COMMENT '用户ID', `status` tinyint NOT NULL DEFAULT '0' COMMENT '订单状态:0待支付,1已支付,2已发货,3已完成,4已取消', `total_amount` decimal(10,2) NOT NULL COMMENT '订单总额', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_create` (`user_id`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';这里我说一下为什么这样设计。order_no用了唯一索引,保证订单编号不重复。(user_id, create_time)建了联合索引,为后面的“查某个用户的订单列表”铺路。为什么是联合索引而不是两个独立索引?因为单独查user_id时,联合索引也能用上最左前缀原则,而单独查create_time的场景很少,单独建索引反而是浪费。
第二步,写入测试数据。用存储过程批量插入模拟数据,这一步一定要做,没有足够的数据量,你永远发现不了SQL性能问题。插入十万条订单数据,再插入对应的订单明细数据,保证后面测试能真实反映执行计划。
第三步,写业务查询并优化。模拟一个最常见的需求:查询某个用户最近三个月的订单列表。初始版本很多人这么写:
SELECT * FROM orders WHERE user_id = 1001 AND create_time BETWEEN '2024-01-01' AND '2024-04-01' ORDER BY create_time DESC LIMIT 20;这条语句在数据量小的时候没问题,但有个隐患:SELECT *会返回所有字段,包括一些大字段(比如备注信息),造成不必要的IO。优化后的版本:
SELECT id, order_no, status, total_amount, create_time FROM orders WHERE user_id = 1001 AND create_time BETWEEN '2024-01-01' AND '2024-04-01' ORDER BY create_time DESC LIMIT 20;只取需要的字段,减少回表次数和网络传输量。这个习惯在真实项目中非常实用。
第四步,验证执行计划。用EXPLAIN查看:
EXPLAIN SELECT id, order_no, status, total_amount, create_time FROM orders WHERE user_id = 1001 AND create_time BETWEEN '2024-01-01' AND '2024-04-01' ORDER BY create_time DESC LIMIT 20;结果里type应该显示ref,key显示idx_user_create,说明已经用上联合索引;Extra里如果没有Using filesort,说明排序也直接用了索引,是最理想的情况。如果你看到type=ALL,说明全表扫描,问题很大。
3.2 UPDATE与事务实操演示
还是基于这个订单系统,模拟一个“订单支付成功”的操作。这个操作涉及几个数据变更:
- 订单主表的状态从“待支付”改成“已支付”。
- 商品库存减掉对应数量。
- 记录一条支付流水。
这多个操作必须放在同一个事务里。我用伪代码展示一下MySQL里的事务控制:
START TRANSACTION; UPDATE orders SET status = 1 WHERE order_no = '202401010001' AND status = 0; -- 检查影响行数是否为1,如果为0说明订单状态不是待支付,直接回滚 UPDATE inventory SET stock = stock - 2 WHERE product_id = 999 AND stock >= 2; -- 检查影响行数,为0说明库存不足,回滚 INSERT INTO payment_log(order_no, pay_amount, pay_time) VALUES ('202401010001', 199.00, NOW()); COMMIT;这里每一步都有“影响行数检查”的逻辑。很多人写代码时忽略了这一步,导致订单状态改了、库存也扣了,最后插入流水时失败,事务回滚时才没出大问题。但如果你每一步都没有检查影响行数,很容易在程序代码层面把状态搞错。
事务的隔离级别也值得说一句。MySQL默认的隔离级别是REPEATABLE READ,在这个级别下,同一事务内两次查询的结果是一致的,能避免不可重复读的问题。但代价是并发性能有所下降。如果你的业务场景读多写少,而且对数据一致性要求没那么高,可以适当调整为READ COMMITTED,这个级别下很多场景性能更好。当然,调整之前一定要对业务做充分评估。
3.3 DELETE的实操:避免误删的策略
这里说一个很实际的场景:清理一年前的订单归档数据。直接执行:
DELETE FROM orders WHERE create_time < '2023-01-01';数据量可能很大,执行时间长不说,还会把整个表的写操作都阻塞住,生产环境肯定不能这么干。我的做法是写一个循环删除的存储过程:
DELIMITER $$ CREATE PROCEDURE `clean_old_orders`(IN batch_size INT, IN max_loop INT) BEGIN DECLARE affected_rows INT DEFAULT batch_size; DECLARE loop_count INT DEFAULT 0; WHILE affected_rows >= batch_size AND loop_count < max_loop DO DELETE FROM orders WHERE create_time < '2023-01-01' LIMIT batch_size; SET affected_rows = ROW_COUNT(); SET loop_count = loop_count + 1; -- 每次删除后休眠0.5秒,让其他事务有时间执行,避免长时间锁表 DO SLEEP(0.5); END WHILE; END$$ DELIMITER ;这个思路的核心是“小批量、多次执行”。每次只删除5000行,执行完马上释放锁,其他会话可以穿插执行,不至于把线上业务堵死。如果你用的不是存储过程,也可以在程序代码里循环执行同一条带LIMIT的DELETE语句,效果一样。
另外,删除前一定要先做备份。我通常在清理任务前把要删除的数据导出成文件:
SELECT * FROM orders WHERE create_time < '2023-01-01' INTO OUTFILE '/tmp/orders_archive.csv';这个操作确认无误后,再进行删除。万一出现误删,还有一份完整的备份可以恢复。
4. 常见问题与排查技巧实录
4.1 慢查询定位:无脑加索引是饮鸩止渴
最让人头疼的问题就是“SQL怎么越跑越慢”。先说最常见的排查工具:慢查询日志。打开慢查询日志,找到慢SQL的详细信息。我一般会先看Rows_examined,这个值表示这条SQL扫描了多少行。如果扫描行数几十万,返回结果才几百行,那基本可以断定没有有效利用索引。
用EXPLAIN进一步分析,重点关注几个字段:
type:从好到差依次是system、const、eq_ref、ref、range、index、ALL。看到ALL就要警惕。key:实际使用的索引名。显示NULL说明没用索引。rows:估算的扫描行数。Extra:出现了Using temporary或Using filesort,说明查询中有关联临时表或额外排序,往往是性能瓶颈所在。
但我要泼一盆冷水:无脑加索引是饮鸩止渴。索引不是越多越好。每一个索引都要占用磁盘空间,每次INSERT/UPDATE/DELETE都要维护所有索引,索引过多会让写入变慢,甚至可能导致优化器选错索引。
我见过一个表,被加了一堆“感觉可能用得上”的索引,结果关键的查询还是慢,因为MySQL优化器在一个查询里只能用到一个索引(复合索引内部除外)。加索引的正确姿势是先看慢SQL的WHERE条件,再结合排序字段,设计一个最匹配的复合索引,删除掉所有冗余索引。
4.2 死锁问题:经典的并发修改同一行
另一个高频问题是死锁。模拟一个场景:
- 事务A先UPDATE订单1,再UPDATE订单2。
- 事务B先UPDATE订单2,再UPDATE订单1。
两个事务同时执行,就形成了循环等待。MySQL的死锁检测机制会立刻发现并回滚其中一个事务,报错信息类似Deadlock found when trying to get lock; try restarting transaction。
排查死锁的办法是查看SHOW ENGINE INNODB STATUS,里面会列出最近一次死锁的详细信息,包括涉及的表、锁类型和SQL语句。但我更想说的是,死锁本质上是一种代码层面的设计问题,不该寄希望于死锁检测。预防方案也很明确:
- 多个事务更新多行数据时,尽量按相同的顺序更新。比如都按主键从小到大排序,这样就不容易出现循环等待。
- 事务尽量短小,减少持锁时间。
- 合理利用索引。更新条件没有走索引时,MySQL会锁住更多的行甚至表,死锁概率大增。
4.3 数据不一致:事务隔离级别和字符集两个隐蔽杀手
你可能会遇到“明明查出来数据是对的,但插入后变成问号了”这种诡异问题。多数情况下是字符集不一致。MySQL里字符集分为服务器级别、数据库级别、表级别、连接级别。连接时客户端用的字符集和表字符集不一致,就会产生乱码。检查连接字符集:
SHOW SESSION VARIABLES LIKE 'character_set_connection';确保写入和读取都是utf8mb4,这是最稳妥的选择。utf8mb4不仅能存中文,还能存表情符号。用旧的utf8会有隐患,因为它最多只支持3字节,一些特殊字符存不进去。
还有一种数据不一致的来源是自动提交的陷阱。很多人以为每执行一条语句就是一个完整操作,忘了显式开启事务。当系统有多个操作需要原子性时,会自动提交。比如上文提到的“扣库存 + 更新状态 + 写流水”,如果没显式开启事务,三个操作之间一旦中途失败,前面已经成功的操作不会回滚,数据就乱了。养成显式开启事务的习惯,并且在外层代码里捕获异常做回滚,这个习惯能帮你省很多麻烦。
4.4 常见问题速查表
| 问题现象 | 可能的根因 | 排查办法 |
|---|---|---|
| 查询很慢,CPU飙升 | 没有走索引、大量数据扫描 | 用EXPLAIN查看type和rows,优化WHERE条件 |
| 批量插入报错 | 单条语句太大或唯一键冲突 | 减小批量大小,使用INSERT IGNORE |
| UPDATE影响了不该影响的行 | WHERE条件写错或没加WHERE | 执行前先用SELECT确认命中范围 |
| 死锁报错 | 多事务以不同顺序更新行 | 统一更新顺序,缩短事务时间 |
| 中文乱码 | 字符集不一致 | 统一数据库/表/连接字符集为utf8mb4 |
| 表数据删了很多,文件却没变小 | InnoDB表空间复用 | 使用OPTIMIZE TABLE重建表,低峰期操作 |
| 分页越翻越慢 | 大偏移量导致大量行扫描 | 改为基于主键的分页或延迟关联 |
5. 性能与安全的扩展思考
5.1 表结构设计如何降低CRUD的难度
很多CRUD问题,根源都在表结构。一张字段设计混乱的表,写多少条SQL都是事倍功半。我每次设计表结构时都会过一遍下面的检查清单:
- 字段类型尽量精简:能用
INT不用BIGINT,能用VARCHAR(50)不用TEXT。字段越宽,每一行占用的存储和IO越多,扫描大量数据时的差距非常明显。 - 避免过多的NULL值:
NULL在索引中的处理比较特殊,统计和查询时容易产生意想不到的坑。能设置默认值就设置默认值,比如数字字段默认0,字符串默认空串。 - 时间字段用
DATETIME还是TIMESTAMP:TIMESTAMP能存的范围更小,但有时区自动转换的能力。如果你需要支持多时区用户,建议用TIMESTAMP;如果时间范围比较大,用DATETIME。两种都能满足大多业务。 - 大字段独立出来:如果某一个字段是很长的文本(如备注、长描述),而且查询时大多数场景根本不需要它,建议拆到单独的附属表。因为InnoDB存储大字段时会使用溢出页,查询非大字段时也可能受到额外IO影响。
5.2 权限与安全:增删改查的最后一道防线
生产环境的MySQL账号,权限管理一定要慎之又慎。我见过有的团队为了图省事,所有应用都用一个拥有ALL PRIVILEGES的账号连接数据库。一旦代码里存在SQL注入漏洞,攻击者可以直接DROP整个库,那场面想想就后背发凉。
正确做法是按需分配最小权限:
- 只读账号,只给
SELECT权限。 - 业务写账号,只给
INSERT、UPDATE、DELETE权限。 - DDL操作(建表、删表、索引变更)单独用一个管理员账号,而且应该通过审核流程执行,不应该长在业务代码的配置里。
再提醒一个细节:DELETE和UPDATE操作同样存在注入风险。不要拼接字符串构造SQL,一定要用参数化查询。用参数化查询之后,哪怕用户输入再刁钻的内容,也只会被当成值,不会变成SQL指令。这一点无论你用什么语言写代码,都必须坚持。
另外,定期做数据备份也是CRUD安全的一部分。备份不只是备份数据,还要演练恢复流程。我见过有些团队备份脚本一直在跑,但从未真正验证过备份文件能不能恢复。等到出事故时才发现异机恢复失败,那样的教训太惨痛了。
6. 一个扩展方向
CRUD这层功夫练好之后,再往深走,就是MySQL的引擎机制、索引的数据结构(B+树)、事务的底层实现(redo log和undo log)。我个人不建议一上来就去啃源码,先把增删改查的每一个细节、每一个坑都吃透,你会在日常开发中省下大量时间。
有人问我,既然有ORM框架,SQL写不好是不是无所谓?我的观点始终是:ORM只是帮你生成SQL,它不帮你决定业务逻辑,更不帮你优化执行计划。把CRUD本身吃透,你才能一眼看穿ORM生成的SQL哪里有问题,才能写出既能被ORM正确映射、又能充分利用索引的逻辑。
我在实际项目里最深的体会是,扎实的基础操作能力,比花哨的技巧更能救你的命。很多时候一个大事故的最后解法,就是一条写正确的UPDATE或者DELETE,加上一个恰到好处的WHERE条件。把功夫下在平时,比什么高级别的“大数据方案”都管用。
最后分享一个我自己的习惯:每次写完一批CRUD操作,我一定会检查三件事——影响行数是否符合预期、是否开启了显式事务、有没有执行计划验证。这三件事看起来简单,扛住了不少线上事故。希望这篇文章能帮你把MySQL的基石打牢。