☰
MySQL增删改查:从SQL语法到索引、事务与性能优化的实战指南
2026/10/13 3:09:05 网站建设 项目流程

拿到一个“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,都先在一个事务里执行,观察影响行数和数据变化,确认无误再提交。这个习惯帮我避免了很多次线上事故。增删改查虽然简单,但越简单的东西,越值得用严谨的姿态去对待。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询