“增删改查”这四个字,在数据库领域大概是所有人最先接触、也最容易轻视的一组操作。我刚入行时同样觉得,INSERT、SELECT、UPDATE、DELETE不就是几个SQL关键词吗,能往表里塞数据、能把数据取出来,就算会了。直到后来带项目、排查线上故障,才慢慢发现,这几个动作背后牵扯出的是表结构设计、索引策略、事务隔离、锁竞争、日志恢复一整串东西。这篇内容想把这些年做CRUD的经验系统整理一遍,从最基础的语法讲到生产环境里真正能落地的写法。它适合刚学MySQL的读者,也适合写了很久SQL但总被性能问题缠住的开发同学——看完你会发现,增删改查这四个简单动作,想做好远没有想象中那么轻松。
1. 为什么说增删改查是MySQL所有能力的地基
1.1 CRUD不只是四个关键词
先看一组现实情况:大多数业务系统里,SQL请求中八成以上都是增删改查,查询又占了其中很大比例。不管是会员系统、订单后台还是内容管理后台,最终都落在几张表的INSERT、SELECT、UPDATE、DELETE上。所以把CRUD称为地基,一点不夸张。
但地基不等于廉价。同一个功能,不同的人写出来的SQL,执行效率可能相差几十倍。比如查一张百万行的订单表,有人习惯SELECT *全量捞回,在应用层再过滤;有人会先用索引定位主键再去回表,响应时间完全不同。写SQL的差距,本质上是“能不能预判数据库内部的执行过程”的差距。所以CRUD的学习重点不应该只是语法能跑通,而是知道每一步操作在数据库内部到底做了什么。
在这个阶段,我建议你把EXPLAIN当成必修课。每条SELECT都跑一遍EXPLAIN,看访问类型、看索引使用、看Extra列里有没有Using filesort、Using temporary。慢慢的,你会对一轮查询的代价形成直觉,而不是等线上慢查询报警了才回头看。
1.2 常见误区与正确方法论
误区一:CRUD太简单,不需要专门学。这个说法坑过不少人。真正的问题往往发生在最自信的地方:忘记加WHERE导致全表更新、批量插入时没控制数据包大小、时间字段没考虑时区差异。这些坑每一个都够让人折腾半天。
误区二:只看重查询性能,忽略写操作。高并发场景下,插入和更新的锁等待、死锁问题往往比读操作更隐蔽。有时候一条UPDATE卡住,后续所有写请求全部排队,接口超时率直线上升。这类问题的排查难度远高于一般的慢查询。
误区三:把ORM当成万能。ORM确实能简化开发,但复杂查询、大批量数据更新时,它生成的SQL经常不是最优的。而且一旦要排查线上问题,不懂原生SQL的人连日志里报错的语句都读不明白。
正确的学习方法论,我总结成三条。第一,先理解存储引擎和索引原理,再写SQL;第二,读操作看执行计划,写操作看影响行数和事务边界;第三,任何变更类SQL,先在测试环境用接近真实的数据量验证。这三条如果能从第一天就养成习惯,能少踩很多坑。下面我按增删改查四个方向,把每一个动作的关键细节拆开讲。
2. INSERT:把数据放进去之前,必须想清楚的几件事
2.1 基础语法与那些容易忽略的默认行为
INSERT的原始形态很简单:
INSERT INTO user (name, age, email) VALUES ('张三', 25, 'zhangsan@example.com');但它有几个默认行为经常被忽略。第一个是字段顺序。如果省略字段列表,就得按表结构顺序给全所有字段的值,一旦表结构调整,这种INSERT很容易出错。所以我始终建议,生产环境的INSERT必须显式写出字段名,哪怕多打几个字,也要保证可维护性。代码评审里如果看到不带字段列表的INSERT,我基本会直接打回。
第二个是DEFAULT与NULL的差别。表里某一列设置了DEFAULT '未知',而你插入时写了NULL,落库结果是NULL,不是'未知'。只有写INSERT时不带这个字段,才会触发默认值。如果业务上要求区分“空值”和“默认值”,就必须在插入前做好判断。很多线上数据异常,源头都是这里没想清楚。
第三个是自增主键的坑。批量导入数据时,如果人为指定自增ID,很可能消耗掉一部分自增区间,导致后续自然增长出现不可预测的跳号。除非有明确的数据迁移需求,否则不要手工给自增主键赋值。曾经有过一个案例:某开发同学做数据修复时直接指定了一堆大ID回库里,结果后续新插入的数据忽然跳了几千个ID,业务方以为是BUG,查了半天才发现是手工插入把自增序列顶高了。
2.2 批量插入、幂等冲突与字符集时区陷阱
生产环境写库,一条条INSERT效率很低。常见做法是改成批量插入:
INSERT INTO user (name, age, email) VALUES ('张三', 25, 'zhangsan@example.com'), ('李四', 30, 'lisi@example.com'), ('王五', 28, 'wangwu@example.com');批量插入能减少SQL解析和网络往返,明显提升吞吐。但要注意单批次大小,MySQL对单个语句的总包大小有上限,默认配置下如果一次性塞几十万行,可能直接报错。一个比较稳妥的经验值是每批500到1000行,再按行宽适当调整。应用层分批提交,也能避免一个事务锁住表太久。
再讲幂等问题。用户下单、支付回调这类场景最怕重复插入。除了业务层做去重,数据库层也要有兜底手段。比较通用做法是给业务唯一字段加唯一索引,再用INSERT ... ON DUPLICATE KEY UPDATE进行冲突处理:
INSERT INTO order_pay (order_no, amount, status) VALUES ('ORD20240501', 100, 1) ON DUPLICATE KEY UPDATE status = VALUES(status);这里有个版本差异要留意:老版本里VALUES()函数可用,但新版本开始提示弃用,建议用别名写法:
INSERT INTO order_pay (order_no, amount, status) VALUES ('ORD20240501', 100, 1) AS new ON DUPLICATE KEY UPDATE status = new.status;REPLACE INTO也能处理冲突,但它本质上是“删除原行再插入新行”,会改变自增ID,还会触发额外的DELETE操作,有外键约束时甚至可能直接报错。所以多数场景我推荐ON DUPLICATE KEY UPDATE,而不是REPLACE。
插入时还有字符集和时区两个容易踩的坑。建库用了utf8mb4,客户端连接没指定utf8mb4,插入emoji就会变成乱码或直接报错。建议连接串里显式配置character_set_client、character_set_connection和character_set_results。时间字段如果只按服务器本地时区写入,后来跨地域部署就会出偏差。比较稳妥的做法是统一存UTC时间或时间戳,展示层再按用户时区转换。
3. SELECT:百分之九十的性能问题都出在查询上
3.1 WHERE条件是怎么“杀死”索引的
SELECT的能力基本体现在WHERE上。明明有索引,SQL却走了全表扫描,这类问题在开发中非常常见。第一个高频原因是查询条件里对索引列做了函数计算:
SELECT * FROM `order` WHERE DATE(create_time) = '2024-05-01';这段SQL看着很自然,但它对create_time做了DATE()函数,MySQL无法直接使用create_time索引,只能全表扫描后再过滤。改成范围查询就能命中索引:
SELECT * FROM `order` WHERE create_time >= '2024-05-01 00:00:00' AND create_time < '2024-05-02 00:00:00';第二个高频原因是隐式类型转换。字段类型是varchar,查询条件却用数字:
SELECT * FROM user WHERE phone = 13800138000;MySQL会把phone列转成数字比较,这样索引同样会失效。改成带引号的字符串才算正确写法。第三种是LIKE查询前缀带%:
SELECT * FROM user WHERE name LIKE '%张%';这种模糊匹配无法用B+树的普通索引加速。真要做模糊搜索,要么用全文索引,要么引入专门的外部检索组件,而不是在业务库硬扛。
多个条件用OR连接也可能让索引失效。如果OR涉及的多个列中只有其中一列有索引,MySQL大概率会放弃索引走全表扫描。可以改写为UNION ALL,或者在每个条件列上建好索引。还有一个容易被忽略的问题:NULL判断。col IS NULL是可以正常配合索引的,但col IS NOT NULL加上OR等复杂组合后,优化器不一定给好计划。
3.2 JOIN、分组排序与深分页的优化思路
查询一旦出现JOIN,先看EXPLAIN里的驱动表。经验法则是小表驱动大表,用小表结果集去大表里匹配。但驱动表不一定是物理上较小的表,优化器会基于行数估算和索引情况决定。我们实际需要做的,是确保JOIN字段两侧类型一致,并且被驱动表侧的关联字段有索引,否则就会演变成多层嵌套循环扫全表,慢到怀疑人生。
分组排序也有讲究。GROUP BY和ORDER BY如果触发临时表文件排序,数据量一大就会慢。解决办法是让分组字段、排序字段与索引顺序保持一致。比如索引是(user_id, order_time),那么GROUP BY user_id、ORDER BY order_time是很高效的组合;反过来写成ORDER BY order_time, user_id就可能产生额外排序。
深分页是另一类高频痛点。LIMIT 100000, 20看起来只是取第10万页之后的20条,但MySQL要先把前面10万条全部找到再丢弃,代价非常大。常见优化手段是延迟关联,先只查出主键:
SELECT t1.* FROM `order` t1 JOIN (SELECT id FROM `order` ORDER BY id LIMIT 100000, 20) t2 ON t1.id = t2.id;这个思路的核心是用覆盖索引快速定位目标主键,再回表取完整行,减少大量无谓回表。如果业务上允许,更彻底的做法是游标分页,记录上一页最后一条的id,下一页直接WHERE id > 最后id LIMIT 20。这样不管翻多少页,都只扫描目标范围内的数据。
3.3 计数、NULL与空字符串的查询习惯
很多人纠结COUNT()还是COUNT(1),其实现代MySQL里基本没差别。真正有差别的是COUNT(某个字段):如果该字段存在NULL,COUNT(字段)不会统计NULL行,而COUNT()会统计所有行。所以如果只是想拿到表里的总行数,直接COUNT(*)最省心;如果COUNT的是某个可空字段,就一定要清楚自己要的统计口径,再决定用哪个写法。
NULL和空字符串是两个完全不同的概念。判断空字符串用col = '',判断NULL用col IS NULL。新手经常写成col = NULL,这个写法永远不成立,因为NULL和任何值比较的结果都是未知。字段设计时也要想清楚“未填写”到底用NULL还是空串表示,一旦定了,查询和代码里都要统一。最怕的是同一条数据里既有NULL又有空串,后续做统计和去重时会出现各种莫名其妙的结果。
SELECT还有一条容易被忽略的规范:不要在业务查询里无脑SELECT *。字段一多,数据传输量和内存占用都会上来,还很容易覆盖到大字段。写明确字段列表,既是性能习惯,也是代码可读性的要求。这一点在联合索引场景下尤其重要,只查询索引内的字段可以用覆盖索引,连回表都省了。
4. UPDATE与DELETE:改动和删除比新增更考验基本功
4.1 先查后改:一次又一次事故换来的教训
UPDATE和DELETE的风险点出奇一致:WHERE写对了,就是精确更新;WHERE写错了,就是全表级别的灾难。
UPDATE user SET status = 1 WHERE id = 10086;这段SQL本身没问题。但稍不留神,上面这行的WHERE条件可能在复制粘贴时被删掉,变成:
UPDATE user SET status = 1;结果就是整表状态全被改了。我见过不止一次有人线上执行了这种语句,等反应过来,数据已经整表被覆盖。所以一定要养成一个接近铁律的习惯:动手之前先SELECT。
SELECT id, status FROM user WHERE id = 10086; UPDATE user SET status = 1 WHERE id = 10086;先确认要改的行确实是你以为的那些行,再执行UPDATE。在客户端工具里操作时,开启安全模式也能拦住一部分低级错误。MySQL客户端有--safe-updates选项,它要求UPDATE和DELETE语句必须带WHERE或者LIMIT,否则拒绝执行。这个开关不能替代人的判断,但确实能拦住最粗心的操作。
执行完UPDATE或DELETE后,看影响行数也非常关键。MySQL会同时返回Rows matched和Changed。matched表示匹配到的行数,changed表示实际发生变更的行数。如果新值和旧值相同,MySQL默认不会重复更新,changed就是0。这时候别因为changed为0就以为更新失败,先确认匹配行数是不是符合预期。
4.2 DELETE不是唯一删除手段:TRUNCATE与软删除
DELETE删除行,TRUNCATE清空整表,DROP直接删除表,三者使用场景完全不同。
TRUNCATE在清空大表时速度远快于DELETE,因为它不逐行记录行级操作,而是直接释放表空间并重置自增计数。但TRUNCATE不能加WHERE,属于一次性操作,行级无法回滚。测试环境随便用,生产环境执行前一定要反复确认自己在干什么。
DROP TABLE会删除表结构和数据,绝大多数情况下难以恢复。很多公司做过误删表恢复演练,但真到那一刻,能不能在规定时间内恢复,完全取决于备份策略。我的经验是,生产环境少用物理删除,优先考虑软删除:在表里加一个deleted_at字段,需要删除时执行UPDATE:
UPDATE user SET deleted_at = NOW() WHERE id = 10086;查询时统一加上deleted_at IS NULL条件。这样的好处是数据还在库里,出问题随时能捞回来,审计也方便。代价是每个查询都要带这个条件,而且唯一索引要考虑已删除记录对唯一性的影响。通常做法是把deleted_at放进唯一索引,比如user_id + deleted_at的组合唯一索引,防止同一用户下出现多条未删除的重复记录。
如果业务确定要物理删除,也要控制批次和事务大小。一次性DELETE百万行,不仅锁范围大,还会产生大量binlog,主从延迟也可能飙升。比较稳妥的做法是分批删除,每批几百或几千行,循环执行,中间加个小间隔。
4.3 误操作之后:如何争取恢复时间
万一真的误操作了,第一步不是手忙脚乱关库,而是先评估影响面,同时保留现场。如果开启了binlog,且binlog格式是ROW,理论上可以通过解析binlog反向构造恢复操作。恢复思路是找到误操作之前的binlog位置,用mysqlbinlog工具解析出原始记录,再通过反向SQL把数据补回去。整个过程非常考验经验,不建议在没有演练的情况下直接在生产库执行。
最稳妥的恢复依赖还是备份。只要备份策略完整,比如每天全量备份加binlog增量备份,即使误操作发生,也可以把数据恢复到误操作前的时间点,损失的数据量取决于备份频率和操作时间点之间的距离。这也是我一直强调的:不管项目多小,备份都是第一优先级。定时备份看似多花存储成本,真出事的时候就是救命稻草。
5. 事务、锁与日志:CRUD背后的隐形地基
5.1 隔离级别决定你“读”到的数据
CRUD执行的时候,并不是简单把数据从磁盘读进写回。它们运行在事务框架里,事务的隔离级别直接决定了查询能看到什么样的数据。
MySQL默认隔离级别是REPEATABLE READ,即可重复读。这个级别下,同一个事务里多次SELECT结果一致,配合MVCC快照读,能覆盖大多数业务的一致性需求。但要注意,“可重复读”解决的是快照读的一致性问题,一旦走的是SELECT ... FOR UPDATE这种当前读,情况就复杂了。
举个例子:事务A先查询status=1的订单数,事务B插入一条新订单并提交,事务A再查一次可能得到不同结果,这就是所谓的幻读。REPEATABLE READ下普通SELECT不会出现,但加锁读时仍然可能碰到。理解这些不是为了应付面试,而是遇到并发下的锁等待、死锁日志时,能快速判断问题到底出在哪一层。
5.2 行锁、间隙锁对写操作的实际影响
UPDATE和DELETE都会对目标记录加行锁。多个事务同时更新同一行时,后到的事务只能等前面的锁释放,表现为SQL一直卡住,其实是另一个会话还没提交。排查时可以从系统表查到当前活跃事务和锁等待关系。
间隙锁是REPEATABLE READ级别下为阻止幻读引入的锁,它锁的不只是某一行,而是一个索引区间。比如在订单金额区间内做条件更新,如果条件列有索引但不是唯一索引,MySQL可能锁住整个区间,导致其他事务无法向该区间插入新行。高并发下这种锁的杀伤力很大,轻则大量写请求排队,重则直接锁超时。常见改进方向是缩短事务执行时间、让条件走唯一索引、或者在业务允许的情况下降低隔离级别到READ COMMITTED。
写操作为什么一直强调“事务小步快跑”?因为事务越长,占锁时间越长,与别的会话碰撞的概率越高。一个事务里塞几十次UPDATE加几次INSERT,任何一个环节慢一点,整条链路都可能被拖垮。如果线上频繁出现死锁,先检查是不是有人按A表顺序更新后又去更新B表,而另一个事务反过来操作,这种循环等待的环是最典型的死锁成因。
5.3 redo、undo与binlog各司其职
数据库能保证数据不丢、能回滚、能恢复,靠的是三个日志配合。redo log记录物理层的数据变更,在崩溃重启时把已提交但还没落盘的事务重新应用,保证数据不丢。undo log记录逻辑反向操作,事务回滚时用它恢复旧数据,同时它也是MVCC快照读的数据来源。binlog是服务层产生的逻辑日志,记录每条变更,主从复制和恢复都依赖它。
理解日志对日常CRUD的意义有三点。第一,不要轻易关闭双1参数,innodb_flush_log_at_trx_commit=1和sync_binlog=1虽然会增加一点写延迟,但能保证断电不丢已提交事务。第二,批量写数据时,一个事务不要塞太多操作,否则一旦中途回滚,undo log会膨胀,回滚过程会极其漫长。第三,binlog格式优先使用ROW,它对误操作恢复和主从一致性更友好,statement格式在生产环境里很容易因函数、排序差异导致主从数据不一致。
6. 一个贴近实战的完整例子:从建表到日常维护
6.1 初始化:订单表的建表与索引设计
把前面讲的内容串起来,我用一个订单模块的模拟项目来演示完整CRUD流程。假设业务是记录用户订单,先建表:
CREATE TABLE `order` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', `amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消', `deleted_at` DATETIME NULL COMMENT '软删除时间', `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;这张表能体现几个关键设计点:order_no加了唯一索引,防止重复订单号;deleted_at预留软删除;status带默认值0;created_at由数据库自动填充;金额用DECIMAL而不是浮点类型。索引方面,我故意没有给created_at单独建索引,因为日常查询通常是按user_id定位用户后再按时间排序,组合索引更高效:
ALTER TABLE `order` ADD INDEX idx_user_created (`user_id`, `created_at`);组合索引的顺序有讲究。等值条件字段放前面,范围或排序字段放后面。这样查询“某个用户最近订单”时,一个索引既能过滤又能排序。
6.2 日常CRUD的正确姿势与异常处理
下单插入时,先查是否已有相同order_no:
SELECT id FROM `order` WHERE order_no = 'ORD20240501001' LIMIT 1;不存在才执行INSERT,同时在数据库层用唯一索引兜底。这里有一个重要细节:先查后插在高并发下并不能完全防重,两个请求可能同时查到不存在,然后同时插入。所以真正的防重必须靠唯一键约束,插入时捕获唯一键冲突,或者用ON DUPLICATE KEY UPDATE转换成幂等更新。
支付成功后的状态流转,用UPDATE:
UPDATE `order` SET status = 1 WHERE order_no = 'ORD20240501001' AND status = 0;这里用了“乐观锁”思路:WHERE条件里带上当前状态status = 0。如果影响行数是0,说明记录已经被别的事务改过,业务层再决定是提示用户还是重新查询。状态机更新比无条件更新更稳,能避免重复回调覆盖新状态。
查询分页时,用覆盖索引加延迟关联:
SELECT t1.* FROM `order` t1 JOIN (SELECT id FROM `order` WHERE user_id = 10086 ORDER BY created_at DESC LIMIT 10000, 20) t2 ON t1.id = t2.id;统计某个用户的有效订单数:
SELECT COUNT(*) FROM `order` WHERE user_id = 10086 AND deleted_at IS NULL;删除某条订单记录时,走软删除:
UPDATE `order` SET deleted_at = NOW() WHERE user_id = 10086 AND id = 12345;注意WHERE条件里显式带上user_id。虽然id作为主键可以唯一定位记录,但带上业务字段能让查询更符合组合索引的使用习惯,也降低误操作影响面。
6.3 上线之后:慢查询与锁等待的复盘方式
订单表上线一段时间后,如果监控发现某条SQL变慢,不要急着加索引,先用EXPLAIN看执行计划:
EXPLAIN SELECT * FROM `order` WHERE user_id = 10086 ORDER BY created_at DESC LIMIT 10000, 20;重点看type是不是range或ref,key是否用到idx_user_created,Extra里有没有Using filesort。如果出现文件排序,说明索引对排序的支撑不足,再考虑调整组合索引的顺序或增加字段。如果type是ALL,说明还在全表扫描,那就需要确认WHERE条件的写法能否让优化器走索引。
锁等待则用系统表排查:
SELECT * FROM information_schema.innodb_trx; SELECT * FROM information_schema.innodb_lock_waits;通过这两张表能找出阻塞源会话和活跃事务。一般线上锁等待的根因不外乎三类:长事务没提交、多个事务的加锁顺序不一致造成死锁、批量更新锁住的区间过大。定位到具体事务后,由对应负责人确认业务逻辑,再决定是提交、回滚还是拆分批次。
最后说一点带团队这些年的体会。我不会要求每个新人把MySQL高级特性都背下来,但我会要求所有写SQL的人手里有一份自己的checklist:INSERT之前想清楚重复怎么写,SELECT之后用EXPLAIN确认走索引,UPDATE和DELETE之前先跑SELECT确认影响范围,批量操作前先问一句可不可以回滚。这几条写进代码评审规则之后,线上事故率肉眼可见地下降。如果你现在刚开始学增删改查,别急着觉得四个单词背熟就算会了,真正值钱的是你对每一条语句背后数据的敬畏之心。CRUD很简单,简单到人人都会;但做好CRUD很难,难到需要把每一个可能出错的地方提前想一遍。把这件事想透,你的MySQL才算真正入了门。