拿到一个“MySQL ——数据的增删改查”这样的标题,很多刚接触数据库的朋友会觉得浅,甚至觉得这东西有什么好写的?可我在一线干活这么多年,反而越来越觉得,增删改查这四个字才是整个数据库体系里最容易被低估的部分。它不只是一条INSERT、一条SELECT那么简单,每条语句背后都牵扯到索引怎么走、锁怎么加、事务怎么隔离、数据一致性怎么保证。你真把增删改查写明白了,后面再去看性能优化、分库分表、主从复制,都会顺很多。
这篇内容我就按照实际开发中最常用的场景来拆,不空谈理论,重点讲清楚每条SQL怎么写、为什么这么写、写错了会出现什么现象、以及怎么排查。目标读者包括刚学MySQL的学生、从其他数据库转过来的开发者,还有日常要写SQL但没系统梳理过细节的运维和数据分析同学。只要你跟着把环境搭起来,把例子跑一遍,基本就能覆盖工作里八成以上的数据库操作场景。
1. 项目背景与需求拆解
1.1 为什么增删改查是数据库操作的核心
MySQL里的所有业务操作,本质上都能落到增删改查这四类语句上。增对应INSERT,查对应SELECT,改对应UPDATE,删对应DELETE。看起来只有四个动词,但实际工程项目里,任何一张表的业务逻辑都是围绕这四类操作去叠加的,比如注册功能是往用户表插数据,商品列表是查数据,修改订单状态是更新数据,退款或下架是删除或软删除数据。
我见过不少开发者在写SQL时只关注“能不能跑出结果”,不关注“语句写得是否符合数据本身的状态变化逻辑”。举个最简单的例子:一个订单系统里,库存扣减如果用先查库存再UPDATE的方式去做,并发高一点就会出现超卖;而如果用“原子更新”的方式,直接在UPDATE语句里加库存数量条件,就能把并发问题挡在数据库层。这种差别,归根到底是对UPDATE这条语句的理解深度不同。
很多人会把增删改查当成“入门语法”,但真正遇到线上问题、需要做性能分析时,翻来覆去看的还是EXPLAIN执行计划里的索引使用情况、行锁范围、事务隔离级别这些底层的机制。也就是说,增删改查是入口,但想用好它,必须理解数据库在底层如何组织数据、如何加锁、如何保证一致性。这也是我这篇内容会刻意把“操作”和“原理”放在一起讲的原因。
1.2 这个项目标签能解决什么问题
“增删改查”这个标签更适合当作一条完整的学习主线来用。如果你是一个刚开始接触MySQL的人,把这一条主线跑通,你就具备了独立设计简单表结构、写业务接口、排查线上数据异常的基础能力。如果你已经写了好几年SQL但一直是“能跑就行”的状态,那这篇内容里的细节,尤其是那些容易踩坑的地方,可能会帮你补上不少盲区。
我在这里不只讲语法,还会把每类操作最常用的变体列出来。比如插入数据时怎么批量插入、怎么处理重复键,查询时怎么写条件才能走索引、怎么分页才不会越翻越慢,更新时怎么避免误更新全表,删除时DELETE和TRUNCATE到底怎么选。这些内容放到真实的项目里,就是每一次发版、每一次数据订正、每一次报表查询背后要做的事情。
2. 环境准备与最小可用实例
2.1 安装MySQL与客户端工具
实操之前,先准备一个能跑的环境。我自己常用的是MySQL 8.0系列,5.7的语法和8.0整体差别不大,但8.0的字符集默认是utf8mb4、窗口函数更完整,排序规则也做了调整,建议新项目直接上8.0。安装方式按操作系统选,Linux发行版用包管理器装,macOS可以用Homebrew,Windows直接下载安装包,都是下一步下一步的事。
装好之后,一定要确认服务已经启动,然后用命令行客户端或者图形化工具连上去。命令行是基础,任何环境都用得上,我建议至少把mysql命令的常用参数记住:
mysql -h127.0.0.1 -P3306 -uroot -p-h指定主机,-P指定端口,-u指定用户名,-p表示需要密码。连上之后可以用SHOW DATABASES看看当前实例里有哪些库。图形化工具方面,Navicat、DataGrip、DBeaver都行,我个人更推荐DBeaver,开源免费,连接管理、SQL编辑器、执行计划可视化都够用。工具只是辅助,关键还是自己知道每条SQL要干什么。
连接之后,新建一个测试库,后续所有示例都在这个库里面跑:
CREATE DATABASE IF NOT EXISTS shop_demo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE shop_demo;这个建库语句里有个容易被忽略的点:字符集和排序规则。utf8mb4是真正的四字节UTF-8,能存emoji和生僻字,utf8在MySQL里其实最多存三字节,遇到表情符号会报错。排序规则的uni_code_ci是统一码排序,比较时大小写不敏感,适合中英文混合的业务场景。
2.2 建一张能撑起增删改查的业务表
光有库还不够,得有表。这里我用电商项目里最常见的商品表来演示,字段设计尽量贴近真实场景,又不至于复杂到让你看不懂:
CREATE TABLE product ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键', product_name VARCHAR(128) NOT NULL COMMENT '商品名称', category_id INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '分类ID', price DECIMAL(10, 2) NOT NULL DEFAULT 0.00 COMMENT '售价', stock INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '库存', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1上架 0下架', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), KEY idx_category_id (category_id), KEY idx_created_at (created_at) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '商品表';这张表为什么这样设计,有几个点可以展开。INT UNSIGNED是为了扩大正整数的表示范围,商品ID一般不需要负数。DECIMAL(10,2)存金额,不能用FLOAT或DOUBLE,二进制浮点会带来精度问题。stock用INT UNSIGNED避免负数库存。created_at和updated_at直接给默认值,应用层不用手动维护。status用TINYINT而不是字符串枚举,查询效率更高,语义靠注释说明。
索引方面,主键索引是聚簇索引,叶子节点存整行数据。category_id和created_at各建一个普通索引,是因为最常见的查询场景就是按分类筛选、按创建时间排序。索引不是越多越好,每多一个索引,插入和更新时都要额外维护索引结构,这一点在你写完INSERT和UPDATE后会感知得更明显。
3. 数据插入:增
3.1 INSERT语句的标准写法与常见变体
新增数据最简单的方式是单条插入:
INSERT INTO product (product_name, category_id, price, stock) VALUES ('手机支架', 3, 19.90, 300);写INSERT时有个习惯我建议你早点养成:明确列出字段名,不要省略。省略字段名虽然可以写得更短,但只要表结构一调整,这条SQL就很容易出错。尤其在业务代码里,字段顺序一变,数据就会插到错的列上,这种问题非常隐蔽,排查起来很痛苦。
主键id是自增列,不需要写在INSERT里。created_at和updated_at都有默认值,也不用手动写。如果你需要指定某些字段使用默认值,可以写DEFAULT关键字,但一般直接不列字段就行。
有些场景下需要按照查询的结果集去插入数据,典型的就是建一张统计中间表,或者把一张表的部分数据归档到历史表。这时可以用INSERT INTO ... SELECT:
INSERT INTO product_archive (id, product_name, category_id, price, stock, status) SELECT id, product_name, category_id, price, stock, status FROM product WHERE status = 0;这种写法把“查”和“增”结合在了一起,但执行时要特别注意:如果来源表和目标表是同一张表,或者数据量特别大,会造成锁范围和临时表使用上的压力。归档数据前建议先确认目标表结构、索引和存储引擎,最好在业务低峰期执行。
3.2 批量插入到底怎么用才高效
一次插一条在交互式命令行里无所谓,但在应用里要插入成百上千条数据时,批量插入是必须的。MySQL支持一条SQL插入多行:
INSERT INTO product (product_name, category_id, price, stock) VALUES ('数据线', 1, 29.90, 500), ('充电器', 1, 89.00, 200), ('手机壳', 2, 39.90, 150);批量插入比逐条插入快很多,原因在于减少了客户端和服务端之间的网络往返,也减少了SQL解析和日志刷盘的次数。你可能听过max_allowed_packet这个参数,它限制单条SQL的最大长度,批量插入的数据量如果超过这个值会直接报错。MySQL 8.0默认值是64M,一般业务数据很难撞到这个上限,但如果你一次性插入几十万行含大字段的数据,就要留意了。
批量插入还有一个高频场景是初始化种子数据、导入测试数据。我建议把这些SQL写在一个.sql文件里,用source命令执行:
source /path/to/init_data.sql;source命令会把文件里的SQL逐条交给客户端解析,中途报错会显示具体行号,方便定位。导入大批量数据时,还可以考虑临时关闭唯一键检查和外键检查,导入完成后重新开启,能省不少时间,但生产环境千万别这么干,那是拿数据一致性开玩笑。
使用INSERT时还有一个容易忽视的问题:主键冲突。如果插入的数据主键已经存在,MySQL会返回错误。业务上常见的处理方式是使用INSERT ... ON DUPLICATE KEY UPDATE,把“插入”和“更新”合在一条语句里:
INSERT INTO product (id, product_name, price) VALUES (1, '手机支架', 25.90) ON DUPLICATE KEY UPDATE product_name = VALUES(product_name), price = VALUES(price);这条语句的意思是:如果主键不冲突就插入,如果冲突就把指定的字段更新成新值。在需要做数据同步、重复导入的场景里非常实用。不过有一点要注意,VALUES()函数在MySQL 8.0.20里标记为弃用,官方推荐用别名方式改写,新代码可以写成下面这种:
INSERT INTO product (id, product_name, price) VALUES (1, '手机支架', 25.90) AS new ON DUPLICATE KEY UPDATE product_name = new.product_name, price = new.price;我自己在实际项目中,两种写法都用过,老系统里VALUES()还能跑,但新写的代码建议直接用别名方式,省得以后升级数据库还要回头改。
4. 数据查询:查
4.1 SELECT基础:字段、条件与执行顺序
查询是所有操作里变体最多、也最值得花时间研究的。最基础的写法是:
SELECT id, product_name, price FROM product WHERE status = 1 ORDER BY price DESC;这条SQL的语义是:从product表里取status为1的行,按价格从高到低排,输出id、商品名、价格三列。这里我想多说一句SQL的执行顺序:虽然是先写SELECT再写FROM,但数据库实际执行时,顺序是FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT。这个顺序解释了为什么WHERE里不能直接用SELECT里定义的别名,也解释了为什么ORDER BY可以使用SELECT里的字段别名。理解这个执行顺序,对排查SQL报错和性能问题非常关键。
查询的时候永远不要用SELECT *,这是我在代码评审里说过无数遍的话。SELECT *会把所有列都捞出来,当表里有一些TEXT、BLOB大字段时,网络传输和内存开销都会被放大,而且覆盖索引也容易失效。实际业务里,你需要的往往就那么几个字段,明确写出来,消费端代码也会更清晰。
WHERE条件里最常见的错误是隐式类型转换。如果字段类型是字符串,你传了一个数字进去,MySQL会尝试把字符串转成数字再比较,一旦发生函数转换,索引可能就用不上了。比如:
SELECT * FROM product WHERE phone_number = 13800138000;phone_number这列如果是VARCHAR类型,这个条件会导致全表扫描。要让索引生效,查询条件必须写成:
SELECT * FROM product WHERE phone_number = '13800138000';这种细节,往往就是一条SQL在测试环境数据量小时没感觉、上了生产一慢就露馅的原因。
4.2 排序、分页与聚合查询
排序用ORDER BY,默认是ASC升序,DESC是降序。如果排序字段上有索引,数据库可以直接按索引顺序扫描,避免额外排序;如果没有索引,就需要在内存或磁盘上做文件排序,数据量大时慢得明显。所以在经常需要排序的字段上建索引,是很常见的优化手段,比如我建表时给created_at加了索引,就是覆盖这个场景。
分页查询是所有程序员都会写的功能:
SELECT id, product_name, price FROM product ORDER BY id LIMIT 20 OFFSET 0;OFFSET表示跳过的行数。第一页OFFSET 0,第二页OFFSET 20,以此类推。这套写法在数据量小的时候很顺手,但一旦偏移量巨大,比如LIMIT 100000, 20,MySQL依然要先把前100020行都查出来再扔掉前10万行,性能会急剧下降。这个问题的常见优化方案是“延迟关联”:
SELECT p.id, p.product_name, p.price FROM product p INNER JOIN ( SELECT id FROM product ORDER BY id LIMIT 100000, 20 ) t ON p.id = t.id;先用覆盖索引把主键查出来,再回表取完整数据。因为子查询只查了主键列,可以走索引,回表的行数只有20行,性能会好很多。这是面试里常考、实际项目里也真正用得上的优化手段。
聚合查询用来统计数据,核心是和GROUP BY配合。比如统计每个分类下的商品数量:
SELECT category_id, COUNT(*) AS cnt, AVG(price) AS avg_price FROM product WHERE status = 1 GROUP BY category_id HAVING cnt > 10;这里有个常见的认知误区:HAVING和WHERE都能加过滤条件,但WHERE是在分组前过滤行,HAVING是在分组后过滤组。WHERE里不能使用聚合函数,比如COUNT(*) > 10放WHERE里就是非法的。执行顺序上,HAVING是在GROUP BY之后执行的,所以HAVING里可以使用SELECT中定义的别名,而WHERE不行。
聚合查询里COUNT()和COUNT(字段)的差别也值得说一句。COUNT()统计所有行数,不会忽略NULL;COUNT(specific_column)只统计该字段非NULL的行数。如果你想知道一张表有多少行有效记录,用COUNT()就对了。在InnoDB引擎下,COUNT()不像MyISAM那样有现成的计数,数据量大时会全表扫描,这也是为什么大表统计行数经常用近似值或缓存方案。
5. 数据更新:改
5.1 UPDATE语法与防止误更新的安全习惯
更新数据用UPDATE:
UPDATE product SET price = 25.90 WHERE id = 1;意思是把id为1的商品价格改成25.90。UPDATE的WHERE子句和SELECT一样关键,甚至更重要,因为一旦WHERE写错,就会把批量的数据全部改掉。我自己的安全习惯是:在执行UPDATE之前,先用同样的WHERE条件跑一遍SELECT,确认影响的行数范围,再执行更新。
SELECT id, product_name, price FROM product WHERE id = 1; UPDATE product SET price = 25.90 WHERE id = 1;这不是多余步骤,在只有命令行、没有可视化工具的生产环境里,这是最便宜也最有效的防线。UPDATE语句会加上行级排他锁,如果在你执行UPDATE之后、还没提交之前,另一个事务也想更新这一行,它就会阻塞等待。这本来是为了保证并发正确性,但如果你忘记提交或事务长时间不结束,就会拖垮整个业务的并发能力。
所以任何时候开启了事务,都要尽快提交或回滚。MySQL默认的autocommit是开启的,单条UPDATE会自动提交;但如果你显式写了BEGIN或START TRANSACTION,那么后续的修改语句都会在当前事务里,直到你执行COMMIT或ROLLBACK才结束。
再说一个真实踩过的坑:UPDATE语句里字段值也依赖旧值。比如要把所有商品价格上调10%,很多人会这样写:
UPDATE product SET price = price * 1.1 WHERE status = 1;这是没问题的,因为SET表达式右边的price读取的是这一行更新前的值。MySQL里同一行上的“读旧值、写新值”在一条UPDATE里被保证是原子的,不需要先查出来再算,目的是避免并发下覆盖更新。
5.2 多表更新与UPDATE JOIN场景
实际开发里,更新数据经常要参考另一张表。比如要根据订单表里的销量去更新商品表的销量字段,或者根据分类表里的新分类名去批量更新商品表。MySQL支持UPDATE JOIN语法:
UPDATE product p INNER JOIN category c ON p.category_id = c.id SET p.category_name = c.name WHERE c.status = 1;这种写法把“关联查询”和“更新”结合在一起,比逐条SELECT再UPDATE高效很多。但要注意,多表UPDATE时,如果JOIN产生一对多关系,比如一个分类对应多个商品,那么SET里对同一行的更新可能会被多次执行,虽然最终结果还是更新后的值,但如果你在SET里写了一些非确定性表达式,结果就可能超出预期。所以使用UPDATE JOIN前,先确认关联关系是不是唯一的。
还有一种批量更新不同值的场景,比如根据id列表设置不同的价格。常规思路是逐条UPDATE,但数据量大会产生大量SQL和网络往返。MySQL支持CASE WHEN来一次性完成:
UPDATE product SET price = CASE id WHEN 1 THEN 25.90 WHEN 2 THEN 89.00 WHEN 3 THEN 39.90 ELSE price END WHERE id IN (1, 2, 3);这种写法的好处是只发一条SQL,减少网络开销和日志量。缺点是SQL本身会变长,CASE分支过多时,解析和优化成本也会上升。一般几十几百个分支没问题,几千个分支就不建议了,不如分几批或者用临时表关联更新。
更新操作和索引的关联也要说一说。如果UPDATE修改的是索引列,比如把category_id从1改成2,那么MySQL不仅要改数据行,还要移动索引条目。这个过程中,相关的索引页可能被锁定,产生额外的随机IO。所以高频更新索引列,对写性能的影响比想象中更大,设计表结构时就要想清楚哪些列会频繁变动。
6. 数据删除:删
6.1 DELETE的正确姿势与TRUNCATE的本质区别
删除数据用DELETE:
DELETE FROM product WHERE id = 1;WHERE同样是重中之重,不带WHERE的DELETE会把整张表的数据全部删掉。在生产环境执行DELETE之前,最稳妥的做法是先SELECT确认数据量,再做删除,并且建议先用事务包一层,确认无误后COMMIT:
BEGIN; SELECT COUNT(*) FROM product WHERE status = 0; DELETE FROM product WHERE status = 0; -- 仔细观察影响行数,确认无误后提交 COMMIT;DELETE是用来删行的,它走事务日志,是可以回滚的,删除后磁盘空间不会立刻释放,只是把行标记为已删除,后续由清理机制处理。如果业务需求是清空整张表,并且不关心回滚,可以用TRUNCATE TABLE:
TRUNCATE TABLE product;TRUNCATE本质上是把表的结构保留、把数据页重新初始化,速度远快于DELETE,但它不是普通的DML,而是DDL级别的操作,无法按条件删除,也无法通过ROLLBACK恢复。所以使用TRUNCATE前,脑子里必须有一根弦:这不是删几行数据,这是把整张表重置。生产环境里只有确认表数据可以丢、且已备份的情况下才会用。
如果你只是想清空数据但保留自增ID继续增长,那DELETE删除所有行后,AUTO_INCREMENT并不会自动重置;而TRUNCATE会把自增计数器一起重置。这个特性在某些场景是优势,在另一些场景就是坑,比如线上订单表虽然要清空测试数据,但希望下一单ID从10001开始,那就不该用TRUNCATE,而是用DELETE并手动指定AUTO_INCREMENT值。
6.2 软删除为什么更符合业务习惯
真正做业务系统时,“删除”往往不是物理删除,而是逻辑删除,也就是更新一个状态字段,让数据在业务上不可见。电商平台的下架商品、用户注销后的冻结记录、订单系统的取消状态,本质上都是“软删除”。我建product表时特意保留status字段,就是为软删除留的接口:
UPDATE product SET status = 0 WHERE id = 1;软删除的优点是数据仍然在库中,便于审计和恢复,不会因为误操作造成物理数据永久丢失。查询时只要统一带status = 1条件,就能把所有“有效”数据过滤出来。缺点也很明显:表数据量会持续增长,需要定期归档清理,否则会拖慢查询。所以硬删除还是软删除,取决于业务对数据的追溯要求,不能一刀切。
不管是硬删除还是软删除,都要留意外键约束。如果product表被订单表引用了,直接删除被引用的商品行,会因为外键约束而失败,或触发级联删除。级联删除看起来很省事,但生产环境里我基本不用ON DELETE CASCADE,因为一旦误删,会顺带把关联表的数据一起删掉,恢复难度极大。宁可代码里分步删除,也不要把删除操作交给数据库自动级联。
7. 常见问题与排查技巧实录
7.1 高频报错速查表
MySQL的错误信息不算友好,但大部分高频报错就那么几类。我把这些年遇到比较多的情况整理成了表格,方便你快速定位。
| 报错信息 | 常见原因 | 处理思路 |
|---|---|---|
| Duplicate entry '1' for key 'PRIMARY' | 主键或唯一键冲突 | 检查插入数据是否重复,或用ON DUPLICATE KEY UPDATE |
| Data too long for column 'name' | 字段长度不够 | 确认数据长度,扩大VARCHAR长度或改为TEXT |
| Incorrect string value | 字符集问题 | 表和连接字符集都改成utf8mb4 |
| Unknown column 'xxx' in 'where clause' | 字段名拼错或不存在 | 检查表结构,注意大小写和反引号 |
| Lock wait timeout exceeded | 行锁竞争超时 | 检查是否有事务未提交,用SHOW PROCESSLIST定位阻塞源 |
| Out of sort memory | 排序内存不足 | 优化SQL避免大排序,或调大sort_buffer_size |
| You can't specify target table for update in FROM clause | 更新子查询误用同一张表 | 包一层派生表,或改写为UPDATE JOIN |
最后这条错误有意思,它说的是你想根据某张表的查询结果去更新同一张表时,MySQL禁止直接这样写。比如你想把所有价格低于平均价商品的分类改掉,写成下面这样会报错:
UPDATE product SET category_id = 10 WHERE price < (SELECT AVG(price) FROM product);解决办法是包一层派生表,让MySQL以为查的是另一个逻辑结果集:
UPDATE product SET category_id = 10 WHERE price < (SELECT avg_price FROM (SELECT AVG(price) AS avg_price FROM product) t);这种问题看起来像是MySQL的语法限制,其实是为了防止你在一句更新语句里读到“旧状态”和“新状态”混在一起的一致性风险。
7.2 锁等待与事务超时的现场排查
我遇到过不止一次类似这样的场景:白天某个接口突然变慢,数据库连接池被打满,接手的同事第一反应是重启服务,但重启完过半小时又卡了。用SHOW PROCESSLIST一看,总能看到几条处于“Waiting for lock”的会话:
SHOW FULL PROCESSLIST;如果里面都是同一个表上的UPDATE或DELETE,而且有事务一直不提交,问题基本就锁定在锁竞争上。这时候可以用下面这条SQL看一下哪些事务还在运行、持有哪些锁:
SELECT * FROM information_schema.innodb_trx;重点关注trx_state、trx_started、trx_query这几个字段。很多锁等待就是因为一个事务里做了大量的查询和更新,却迟迟没有COMMIT。代码里最常见的失误是把一个事务的范围拉得很大,比如在事务里调用外部接口、执行耗时的循环,这些操作完全没必要放在事务里。
处理锁等待的思路是:先定位到阻塞源头,通过information_schema里的锁信息找到持锁的事务ID,再判断是等待它自然结束,还是KILL掉对应连接。KILL连接是最后的手段,会有很大的副作用,比如事务回滚、应用报错,必须确认无误再操作。更根本的解决办法是在应用层控制好事务粒度,让每个事务短小精悍,减少锁的持有时间。
还有一个容易让人困惑的点:同样一条UPDATE,在Navicat里手动执行没问题,放到应用里就偶发死锁。这是因为应用里多条UPDATE语句的执行顺序不固定,事务A先更新了id=1的商品再更新id=2的商品,事务B先更新id=2再更新id=1,就可能出现死锁。解决方案是让多个事务按照相同的顺序去操作数据,或者预先把需要修改的id排序。这个细节在并发量不高时看不出问题,一旦压测或者大促,直接就是线上故障。
7.3 慢查询与索引失效排查
写增删改查绕不开性能问题,查得慢、更新慢、删除也慢,绝大部分根源都在索引和执行计划上。排查慢查询的第一步是确认慢查询日志有没有打开,MySQL 8.0里可以通过参数控制:
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1;执行完这两句,超过1秒的SQL会被记录到日志文件里。但慢查询日志只是入口,真正的分析工具是EXPLAIN。我每次分析一条问题SQL,都会先执行:
EXPLAIN SELECT ...然后重点看type、key、rows、Extra这几列。type从好到差一般是const -> eq_ref -> ref -> range -> index -> ALL,如果看到ALL,基本就是全表扫描,大概率需要优化索引。key列显示的是实际使用的索引,如果为NULL,说明没走任何索引。Extra里出现Using filesort或Using temporary,通常意味着排序或分组没有利用索引,数据量大时性能不会好。
索引失效的原因有很多。最常见的是在WHERE条件的字段上使用了函数或表达式:
SELECT * FROM product WHERE DATE(created_at) = '2024-01-01';这会让created_at上的索引失效,因为索引存储的是原始值,不是DATE()函数处理后的值。正确写法是条件直接落在字段本身:
SELECT * FROM product WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02';还有一种容易被忽视的情况:OR连接的条件中,如果其中一个字段没有索引,整个查询可能就走不了索引。很多人以为只要WHERE里有索引字段就算走了索引,其实优化器会把整条WHERE当成一个整体来看。所以碰到OR条件,要么给每个字段都建索引,要么改成UNION连接两条独立查询。
8. 从增删改查出发的扩展建议
如果你把增删改查的基本功打牢了,后面有几个方向是最值得继续深入的。第一个方向是事务隔离级别和锁机制。增删改查里,UPDATE和DELETE天然涉及到行锁、间隙锁,理解了锁,你才能解释为什么并发下会出现死锁、为什么某些查询会阻塞写操作。第二个方向是索引优化,尤其是联合索引的最左前缀原则、覆盖索引、索引下推,这些直接决定SELECT的性能上限。第三个方向是备份恢复,mysqldump逻辑备份、binlog增量备份,这些是数据安全的最后一道防线。
我个人的实际体会是,很多人学了增删改查后,会急着去看分库分表、读写分离、中间件,反而忽略了最基础的表设计和SQL写法。但真实线上故障里,有一大半不是架构问题,而是一条UPDATE没走索引、一个事务没提交、一个JOIN写法不严谨引起的。把这些基础中的细节做扎实,比盲目追求高大上的方案有用得多。
最后再分享一个小技巧:在本地环境准备一套和生产尽量一致的测试数据,每次写涉及到更新的SQL,都先在一个事务里执行,观察影响行数和数据变化,确认无误再提交。这个习惯帮我避免了很多次线上事故。增删改查虽然简单,但越简单的东西,越值得用严谨的姿态去对待。