做后端开发这些年,我见过太多人把增删改查当成"入门门槛"——背完SELECT、INSERT、UPDATE、DELETE四个关键字,跑通一个带表单的 Demo,就觉得自己会数据库了。真正上了生产环境才发现,同样是这四类语句,有人写出来跑三年不出事,有人写出来上线第二天就收到告警。差别不在语法,而在于对MySQL 增删改查背后的行为边界有没有清晰认知:一条UPDATE忘了写WHERE会怎样、LIMIT深分页为什么越翻越慢、INSERT IGNORE到底忽略了多少东西、DELETE和TRUNCATE在事务里的表现为什么完全不同。这篇内容围绕 MySQL 数据库最核心的增删改查操作展开,从建表定字段的地基讲起,逐层拆到写入冲突、查询优化、更新风控、删除分级、事务锁与慢查询排查。适合刚接触 MySQL 想打牢基础的新手,也适合已经能写业务代码、但对自己写的 SQL 心里没底的同学。我会尽量把每一步"为什么这么写"讲透,而不是甩一堆语法让你自己悟。
1. 增删改查的"简单"是假象:先把操作心智模型立住
1.1 语法过关和写对业务之间隔着一条河
新手最容易产生的错觉是:UPDATE users SET name = 'x' WHERE id = 1这么简单,能有什么坑?坑就在于它太简单了,简单到你会不假思索地写,然后某天在拼接条件的时候少了个WHERE,整张表的名字全变成同一个值。我见过真实的事故复盘,一位开发在测试库上验证一个批量修正脚本,语句写成了UPDATE t_order SET status = 2,本来想改某个时间段的订单,条件在复制粘贴时丢了。所幸那次是测试库,但同一个人如果在生产上敲下回车,后果就不是重跑一遍能解决的了。
所以我建议在脑子里先建立一个模型:增删改查不是四个孤立的命令,而是四个作用在"一张有结构、有约束、有索引、有并发"的表上的动作。你写的每一行 SQL,MySQL 都要去做解析、选索引、加锁、写日志、改数据页。理解了这个链路,你才会明白为什么有些写法快、有些写法慢、有些写法危险。
这里有一组容易被忽略的对照关系,值得先记住:
| 操作 | 主要风险 | 关键防线 |
|---|---|---|
| INSERT | 主键冲突、批量过大、重复数据 | 唯一索引 + 冲突处理策略 |
| SELECT | 索引失效、深分页、结果集过大 | EXPLAIN + 覆盖索引 + 游标分页 |
| UPDATE | 漏写条件、并发覆盖、锁范围过大 | 先 SELECT 后 UPDATE + 版本号 |
| DELETE | 误删、锁表、无法回滚 | 逻辑删除 + 分批 + 备份 |
这张表不是让你背,而是让你意识到:四类操作的风险点完全不同,防守手段也完全不同。把这张表放在心里,后面每一节都会对应到其中一行。
1.2 建表定字段:CRUD 的地基,一步错步步错
很多人学增删改查是从INSERT开始的,我觉得顺序反了。你应该先学会建一张不容易出事的表,再去往里写数据。因为字段类型一旦定错,后面所有的增删改查都是带着病跑的。我按实际踩过的顺序说几个点。
主键类型。自增主键用INT UNSIGNED还是BIGINT UNSIGNED?INT UNSIGNED的上限是 42 亿左右,听起来够用,但如果你有一张流水表、日志表,按每天几十万条算,几年就到顶了。到顶之后AUTO_INCREMENT会用尽,插入直接报错,而改主键类型的代价在千万级数据上几乎是灾难性的。我的默认选择是无脑BIGINT UNSIGNED,多占 4 个字节,换的是彻底不用操心。
CREATE TABLE t_order ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '业务单号', user_id BIGINT UNSIGNED NOT NULL, amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 0, remark VARCHAR(255) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_created (user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;金额字段用DECIMAL而不是FLOAT/DOUBLE,这一点没有商量余地。浮点数在二进制里存不下精确的 0.1,做累加和比对时会出现0.1 + 0.2 != 0.3这种事,涉及钱的场景一次都不能错。DECIMAL(12,2)表示总共 12 位、小数 2 位,整数部分最多 10 位,对绝大多数业务足够。
时间字段用DATETIME还是TIMESTAMP?TIMESTAMP只占 4 字节,但它的取值范围卡在 2038 年,而且会随服务器时区做转换。如果你的系统要跨时区部署,或者不想在 2038 年之前被叫起来改表,就用DATETIME。另外created_at和updated_at用默认值自动维护,能省掉大量在业务代码里手写时间的麻烦,也避免了"某些代码路径忘了更新时间"的脏数据。
字符集统一utf8mb4。老项目里经常看到utf8,那是 MySQL 里的utf8mb3,只支持最多 3 字节的字符,存不了 emoji 和部分生僻字。用户昵称里带一个 emoji 就插入报错,这种问题排查起来特别费时间,因为报错信息不一定直观。
还有一条经验:VARCHAR的长度不要随手写 255。长度给大了不会占用实际存储空间(VARCHAR是变长的),但会影响到内存里的临时表行为和索引长度计算。更实用的是把业务约束定准,比如手机号VARCHAR(20)、单号VARCHAR(32),让字段本身成为一层校验。
2. INSERT:写入环节最容易被想当然的四件事
2.1 自增主键和业务主键,别把两件事混成一件事
新手常见写法是拿业务单号或者 UUID 直接当主键:
-- 不推荐 CREATE TABLE t_log ( id VARCHAR(36) NOT NULL, content TEXT, PRIMARY KEY (id) );这样写功能上没问题,性能上有问题。InnoDB 的表数据是按主键顺序组织在 B+ 树里的,主键是自增整数时,每次插入都追加到最右边,页分裂少、写入快、索引紧凑。而 UUID 是随机分布的,每次插入都可能落在树的任意位置,导致频繁的页分裂和随机磁盘写,同时二级索引里存的也是这个 36 字节的字符串,索引体积会膨胀好几倍。
如果业务上确实需要 UUID 作为外部标识,正确做法是主键仍然用自增BIGINT,UUID 作为普通字段加唯一索引;或者使用有序的 UUID 变体(比如把时间戳放在前面的版本),减少随机性。我个人的习惯是:表的物理主键永远是自增整数,业务唯一性交给UNIQUE KEY去保证。这样既满足了唯一性约束,又不牺牲写入性能。
2.2 循环单条插入和批量插入,差距不是一点半点
我见过一段代码,导入一万条数据用了将近 40 秒,原因是写了个for循环,每条数据执行一次INSERT。每次INSERT都是一次网络往返、一次 SQL 解析、一次事务提交(autocommit开启时),这些开销跟数据本身的大小关系不大,纯粹是固定成本叠加。
改成批量插入后,同样的数据量降到了一秒以内:
INSERT INTO t_order (order_no, user_id, amount, status) VALUES ('NO20240101001', 1001, 99.00, 0), ('NO20240101002', 1002, 199.50, 0), ('NO20240101003', 1003, 39.90, 0);批量插入也不是越大越好。单条 SQL 的总大小受max_allowed_packet限制,默认通常是 64MB,超过会报错。另外一条超大 SQL 在主从复制时也可能带来延迟。我的经验值是每批 500 到 1000 条,配合一个循环分批提交。如果导入量特别大,还可以考虑LOAD DATA直接从文件导入,它绕过了 SQL 解析层,速度比批量INSERT更快。
回看那次 40 秒到 1 秒的优化,本质不是"用了批量插入这个技巧",而是搞清楚了时间花在哪里:大部分时间不在写磁盘,而在网络和事务提交的固定开销上。理解了这个,你自然知道为什么合并语句有效、为什么分批提交比一次性大事务更稳妥。
2.3 主键冲突的三种处理方式,副作用各不相同
插入数据时撞上唯一索引,是高频场景,比如并发下的用户注册、重复的订单回调。MySQL 提供了三种写法,很多人只知道第一种,用错了会埋雷。
-- 写法一:忽略错误 INSERT IGNORE INTO t_user (phone, name) VALUES ('13800000000', 'zhang'); -- 写法二:替换 REPLACE INTO t_user (phone, name) VALUES ('13800000000', 'zhang'); -- 写法三:冲突时更新 INSERT INTO t_user (phone, name) VALUES ('13800000000', 'zhang') ON DUPLICATE KEY UPDATE name = VALUES(name), updated_at = NOW();INSERT IGNORE的坑在于它忽略的不只是重复键错误。字段超长会被截断后插入、类型转换失败会变成默认值、NOT NULL字段给NULL会被静默替换成默认值——这些本该报错的问题全被吞掉了,数据静默出错,事后极难追查。我在做数据质量排查时,最怕看到的就是这种"插进去了但内容不对"的情况。
REPLACE INTO的语义是"先删后插",删除旧行会触发该行上的DELETE触发器、影响外键级联,还会让自增主键跳号。如果你有从表依赖这个主键,行为会非常危险。它看起来像更新,实际是删除加插入,这个认知差是很多事故的源头。
ON DUPLICATE KEY UPDATE是三者里语义最清晰的:它真的做更新,不删行、不改主键。唯一要注意的是,在多唯一索引的表上,它可能命中其中任意一个索引,所以最好只在一个唯一索引上使用它。另外VALUES()函数在新版本中有被标记为过时的趋势,写的时候留意下版本兼容性,不行就用显式的别名写法。
3. SELECT:从"能查出结果"到"查得准、查得快"
3.1 WHERE 条件的写法直接决定索引走不走
查询慢的第一嫌疑人永远是索引失效。我排查过的慢查询里,超过一半是条件写法把索引挡住了。下面这些情况是最常见的:
| 写法 | 是否走索引 | 原因 |
|---|---|---|
WHERE user_id = 1001 | 是 | 直接匹配索引列 |
WHERE DATE(created_at) = '2024-01-01' | 否 | 列被函数包裹,索引失效 |
WHERE phone = 13800000000 | 否 | 字符串列传数字,隐式转换 |
WHERE name LIKE '张%' | 是 | 前缀匹配可用索引 |
WHERE name LIKE '%张%' | 否 | 前置通配符无法定位起点 |
WHERE a = 1 OR b = 2 | 视情况 | 两侧都有索引才可能合并 |
WHERE status != 1 | 通常否 | 不等于的区分度太低,优化器放弃 |
第一条和第二条的对比特别值得说。DATE(created_at) = '2024-01-01'看起来非常自然,但created_at外面套了函数,MySQL 无法用索引定位,只能全表扫描后逐行计算。改成范围查询就能用上索引:
-- 慢:全表扫描 SELECT * FROM t_order WHERE DATE(created_at) = '2024-01-01'; -- 快:走索引范围扫描 SELECT * FROM t_order WHERE created_at >= '2024-01-01 00:00:00' AND created_at < '2024-01-02 00:00:00';隐式类型转换也是一样的道理。如果phone定义为VARCHAR,你写WHERE phone = 13800000000(不带引号),MySQL 会把列值转成数字再比较,索引直接失效。这种错误在代码里非常隐蔽,因为 SQL 能正常执行、结果也对,只是慢,不会报错。
还有一个新手常犯的:SELECT *。它除了传输多余列,更容易导致无法使用覆盖索引。本来SELECT id FROM t WHERE user_id = 1只查索引就能返回结果,走SELECT *就必须回表读整行。在数据量大、并发高的场景下,这个差异会被放大很多倍。
3.2 深分页:为什么越翻到后面越慢
LIMIT offset, size的分页写法在业务里太常见了,但它在翻到后面时会越来越慢。原因是 MySQL 必须先读出前offset + size行,再丢弃前offset行。翻到第 10000 页时,它其实已经扫过了十万行数据然后扔掉。
-- 翻到后面会越来越慢 SELECT * FROM t_order ORDER BY id LIMIT 1000000, 20;有两种改法。第一种是延迟关联,先用覆盖索引查出主键,再用主键回表:
SELECT o.* FROM t_order o INNER JOIN ( SELECT id FROM t_order ORDER BY id LIMIT 1000000, 20 ) t ON o.id = t.id;这样子查询只扫索引、不回表,扫描量虽然没变,但每一行的代价小得多。
第二种是游标分页,也就是记住上一页的最大 id,下一页从它之后开始:
SELECT * FROM t_order WHERE id > 1000000 ORDER BY id LIMIT 20;这种方式每页的代价基本恒定,是真正意义上的优化。代价是不能再跳到任意页码,只能一页页往下翻。所以它适合无限滚动的列表,不适合带页码导航的后台管理页面。选哪种,取决于产品形态,不是纯技术问题。
3.3 JOIN 的连接顺序,优化器不一定替你选对
JOIN写出来能跑,但驱动表选错了可能慢十倍。简单说,MySQL 的嵌套循环连接是拿驱动表的每一行,去被驱动表里找匹配。所以应该让小表做驱动表,标准的说法是小表驱动大表。但"小"指的是过滤后的结果集小,不是表的行数少。
还有一个陷阱是LEFT JOIN的条件写错位置:
-- 条件写在 ON 里:左表全保留,右表不匹配时补 NULL SELECT * FROM t_user u LEFT JOIN t_login l ON u.id = l.user_id AND l.type = 1; -- 条件写在 WHERE 里:会把左连接退化成内连接 SELECT * FROM t_user u LEFT JOIN t_login l ON u.id = l.user_id WHERE l.type = 1;第二种写法里,l.type = 1会把l.type为NULL(即左表有而右表无匹配)的行过滤掉,结果等同于INNER JOIN。这个行为很多人不知道,写了LEFT JOIN却得到少一截的数据,还以为是数据不全。
连接字段的类型也要一致。如果一边是VARCHAR、一边是INT,同样会触发隐式转换导致索引失效。我在评审 SQL 时,看到JOIN就一定会核对两侧字段类型,这是最容易埋雷的地方之一。
4. UPDATE:一条忘写 WHERE 的语句能造成多大损失
4.1 把 UPDATE 当成 DELETE 来防
这是我在团队里反复强调的一条纪律:执行任何 UPDATE 或 DELETE 之前,先把 WHERE 条件拿到 SELECT 里跑一遍,确认行数和样例数据。
-- 第一步:先看影响范围 SELECT COUNT(*), MIN(id), MAX(id) FROM t_order WHERE status = 0 AND created_at < '2024-01-01'; -- 第二步:确认无误后再改 UPDATE t_order SET status = 9 WHERE status = 0 AND created_at < '2024-01-01';这个习惯的价值在于,SELECT的结果会反直觉地救你。你以为只影响几百行,结果查出几十万行,这时候你就知道条件写漏了。如果是UPDATE直接执行,你连后悔的机会都没有,因为语句提交后数据已经改了。
MySQL 提供了一个原生的保护开关:
SET SESSION sql_safe_updates = 1;打开后,UPDATE/DELETE如果没带WHERE或者WHERE里没用索引列,语句会被直接拒绝。这个开关在客户端连接里可以默认配置,我强烈建议所有人本地环境和测试环境都开着。生产上有些老系统不方便开,那就靠流程来兜底——写变更脚本必须双人复核,并且先在小范围数据上验证。
4.2 影响行数不是你以为的那个数
schema层面还有一个容易忽略的细节:UPDATE返回的"影响行数"有两种语义。默认情况下,MySQL 返回的是实际被修改的行数,如果新值和旧值完全一样,这一行不算被修改,计数里不包含它。
假设表里有 5 行status = 0,你执行UPDATE t SET status = 0 WHERE status = 0,返回的影响行数是 0,不是 5。如果你的业务代码依赖这个返回值做判断(比如"影响行数大于 0 说明更新成功"),就会误判成失败。
要拿到"匹配行数",可以在连接参数里加useAffectedRows=false(不同客户端驱动叫法不同),或者干脆在业务上另想办法,比如用乐观锁。
4.3 并发覆盖:两个人同时改同一条数据
更新最隐蔽的问题是并发覆盖。A 和 B 同时读到一条记录balance = 100,A 改成 80 写回,B 基于自己读到的 100 改成 90 写回,最后余额是 90,A 的扣减凭空消失了。这就是典型的丢失更新。
解决方法之一是乐观锁,加一个版本号字段:
UPDATE t_account SET balance = balance - 20, version = version + 1 WHERE id = 1 AND version = 7;执行后检查影响行数,如果是 0,说明版本号已经被别人改过,需要重新读取再试。这种写法把"检查版本"和"更新数据"合并成一条原子语句,避免了中间被插队的可能。
另一种是悲观锁,用SELECT ... FOR UPDATE先把行锁住再改。它更直观,但锁持有时间长,并发高时容易排队,还容易和其他事务交叉形成死锁。两种方案没有绝对优劣,写多读少、冲突概率高的场景用悲观锁更稳,读多写少用乐观锁更轻。我一般默认乐观锁,除非业务明确要求强串行。
5. DELETE、TRUNCATE、DROP:三种"删"的分级差异
5.1 三种删除方式的本质区别
这三个词新手经常混用,实际行为差别巨大:
| 方式 | 删除对象 | 能否回滚 | 是否走事务 | 自增计数 | 速度 |
|---|---|---|---|---|---|
DELETE FROM t WHERE ... | 符合条件的行 | 可以 | 是 | 保留 | 慢,逐行删 |
DELETE FROM t | 全部行 | 可以 | 是 | 保留 | 仍逐行删 |
TRUNCATE TABLE t | 全部行 | 不可以 | 否(隐式提交) | 重置为 1 | 很快 |
DROP TABLE t | 表结构加数据 | 不可以 | 否 | 无 | 最快 |
最关键的一条是:TRUNCATE会隐式提交当前事务,也就是说你在事务里执行它,之前未提交的改动也会一并落盘。这意味着你以为可以ROLLBACK救回来,实际并不能。DELETE则写 undo 日志,在事务内可以回滚。
所以判断标准很清楚:需要条件删除、需要回滚、需要触发器联动,就用DELETE;要清空整表且不打算撤销,才用TRUNCATE。清空生产表之前,先确认有没有备份,这不是废话,是真有人栽过。
还有一个实际问题是DELETE大范围数据时锁范围过大。一条删一百万行的DELETE会长时间持有大量行锁,阻塞其他写入。正确做法是分批删除:
DELETE FROM t_log WHERE created_at < '2023-01-01' ORDER BY id LIMIT 1000;在循环里执行,每批之间稍作停顿,把一次长事务拆成多次短事务。虽然总耗时可能更长,但不会拖垮线上业务。
5.2 逻辑删除的取舍:该不该真的删
业务上"删除"通常分两种含义:物理上从表里消失,还是对用户不可见。绝大多数场景应该是后者,也就是逻辑删除,加一个is_deleted字段或者在deleted_at里记时间。
逻辑删除的好处是数据可追溯、可恢复,出了问题还能查。但它会带来两个连续的问题需要提前想清楚。
第一是唯一索引冲突。如果phone上有唯一索引,用户注销后逻辑删除,过段时间同一手机号重新注册,插入会撞唯一键。解决办法是把唯一索引改成(phone, is_deleted)组合,但这样同一手机号只能有一条删除记录。更彻底的做法是用deleted_at时间戳参与唯一索引,删除时间不同就不会冲突。
第二是索引选择性下降。数据只增不删,表会越来越大,而查询永远带着is_deleted = 0,这个条件区分度低,帮不上索引什么忙。长期方案是定期归档历史数据到冷表,或者用分区表按时间切分。这件事不做,两三年后查询性能会明显退化。
我的经验是:核心业务表用逻辑删除,日志类、临时类数据用物理删除加定期清理。别一上来所有表都加is_deleted,那样只是把问题往后推。
6. 事务与锁:多条语句之间的一致性保障
6.1 事务的边界划在哪里
单条INSERT、UPDATE本身是原子的,但业务往往是多步组合:扣库存、生成订单、写流水。这三步要么全成,要么全不成,就需要事务包起来。
START TRANSACTION; UPDATE t_stock SET qty = qty - 1 WHERE sku_id = 100 AND qty >= 1; INSERT INTO t_order (order_no, sku_id, amount) VALUES ('NO001', 100, 99.00); INSERT INTO t_flow (order_no, type, amount) VALUES ('NO001', 'pay', 99.00); COMMIT;边界的划分原则是:事务里只放必须一起成功的写操作,不要放网络请求、文件读写、耗时计算。我见过在事务里调用第三方接口的代码,接口一慢,事务就挂几十秒,锁一直不放,后面所有涉及这些行的请求全部排队。长事务还会导致 undo 日志膨胀,影响整个实例的性能。
还有一条容易忽略的:MySQL 默认autocommit = 1,也就是每条语句自己就是一个事务。你可能没写BEGIN,但代码里连续两次更新之间如果不是同一个事务,中间出错就会留下半截数据。用框架的时候要确认事务注解真的生效了——方法内部自调用、异常被吞掉、多数据源切换,都会让事务静默失效。
6.2 死锁不是偶发故障,是顺序问题
死锁的经典场景是两个事务以相反顺序更新同一批行:
-- 事务 A UPDATE t_account SET balance = balance - 10 WHERE id = 1; UPDATE t_account SET balance = balance + 10 WHERE id = 2; -- 事务 B UPDATE t_account SET balance = balance - 10 WHERE id = 2; UPDATE t_account SET balance = balance + 10 WHERE id = 1;A 拿到 id=1 的锁等 id=2,B 拿到 id=2 的锁等 id=1,互相等待,MySQL 检测到后会自动回滚其中一个,报Deadlock found。
根本的解决办法是统一加锁顺序。所有涉及这两个账户的操作,都按 id 从小到大加锁。这样 A 和 B 的请求会自然排队,不会交叉。其次是缩小事务范围、缩短持锁时间,以及在应用层对死锁错误做重试——死锁被回滚是正常现象,捕获到错误后重试一次通常就成功了,不要把它当成系统故障。
排查死锁时,SHOW ENGINE INNODB STATUS里的LATEST DETECTED DEADLOCK段落会给出两个事务的具体 SQL 和等待的锁,这是最直接的信息来源。看到那段输出,基本能立刻判断是哪个顺序反了。
7. 慢查询的排查链路:从发现到定位到解决
7.1 EXPLAIN 里真正要看的几个字段
EXPLAIN输出字段一大堆,新手容易看晕。我实际排查时只盯四个。
type:连接类型,从好到坏大致是system > const > eq_ref > ref > range > index > ALL。看到ALL就是全表扫描,看到index是全索引扫描,这两个都要警惕。ref和range是正常业务查询的常见状态。
key:实际用到的索引。如果是NULL,说明没走索引,结合possible_keys看是没建索引还是建了没用上(后者通常是写法问题,比如函数包裹)。
rows:预估扫描行数。这个数字乘上实际执行的次数,就是大致的工作量。看到一个查询预估扫几十万行,基本可以判定有问题。
Extra:这里的信息量最大。Using filesort表示排序没走索引,需要额外排序;Using temporary表示用了临时表,常见于GROUP BY;Using index是好消息,说明用了覆盖索引不用回表;Using where表示在存储引擎返回后还要过滤。
7.2 一个真实的排查过程
前段时间有个列表接口偶尔超时,日志里能看到执行时间从 20ms 跳到 3s。我按这个链路走了一遍。
第一步,开慢查询日志,把阈值调到 1s,抓到了具体 SQL。是一条带多条件筛选、ORDER BY created_at DESC加LIMIT的查询。
第二步,EXPLAIN看执行计划。type是ALL,key是NULL,rows约 40 万,Extra是Using where; Using filesort。全表扫描加额外排序,问题很明确。
第三步,看WHERE里的条件。有三个筛选字段,其中两个是高区分度的(user_id、status),一个是范围条件(时间)。所以建了一个联合索引(user_id, status, created_at)。
第四步,再跑EXPLAIN。type变成range,key用上了新索引,但Extra里还有Using filesort。原因是排序字段created_at在索引的第三位,而user_id和status都是等值匹配,理论上应该能用到索引顺序。查了才发现原来的语句里status用的是IN (0, 1),多个值导致后续字段无法保证有序,优化器只好再排一次。
第五步,把排序需求拆开:先按status单独查再合并,或者接受这次排序但把LIMIT变小。最终选的是调整索引顺序为(user_id, created_at, status),让排序字段紧跟等值列,status放到最后做过滤。调整后Using filesort消失,接口耗时稳定在 30ms 以内。
这次排查里最值得记住的是第四步:索引建对了不等于用对了,IN、范围条件、函数都会打断索引的有序性。光看有没有走索引不够,还要看走索引之后是不是还在做额外排序。
还有一个工具层面的建议:慢查询日志不要只在出问题时才开,平时就开着,用pt-query-digest这类工具定期分析,找出出现频次高、总耗时大的语句。单次慢不是最可怕的,高频的"略慢"累积起来对数据库的压力往往更大。
8. 我在实际项目里沉淀的几条操作纪律
写到这儿,把前面散落各节的实践原则收拢一下,都是吃过亏之后固定下来的习惯,可以直接照着执行。
任何写操作上线前,先在测试环境用生产数据量的子集跑一遍,记录耗时和影响行数。这条听起来麻烦,但它能挡住绝大多数"本地好好的、线上一执行就锁表"的问题。特别是加索引、改字段类型这类 DDL,在小表上瞬间完成,在大表上可能锁几分钟。
所有涉及批量修改的脚本,都写成"可重入"的。也就是重复执行结果一致,中途失败可以接着跑。实现方式通常是带上状态条件,比如WHERE status = 0,改完变成 1,重跑时不会重复处理。这一点在故障恢复时价值极高。
给每张核心表配上created_at和updated_at,并且用数据库默认值自动维护。排查数据异常时,这两个字段往往是最先提供线索的。有一次定位一个数据错乱问题,就是靠updated_at的时间分布,发现某个时间点之后写入的数据全都不对,从而锁定了一次发版。
查询接口尽量明确列名,不用SELECT *。这个习惯短期看只是多打几个字,长期看是在给自己留优化空间——当你需要加覆盖索引时,明确的列名让这件事变得可行。
最后说一个心态上的体会。增删改查这四个操作,语法半小时就能学完,但真正写对、写稳,靠的是对"数据会怎样变化、锁会锁住谁、索引会怎么走"的持续判断。我到现在写一条要跑在生产上的UPDATE,还是会先把条件拿去SELECT一遍。这不是不自信,是把风险控制在可承受范围内的成本最低的方式。