☰
从增删改查到高效查询:MySQL性能优化实战指南
2026/10/19 13:47:21 网站建设 项目流程

做了这么多年数据相关工作,我最大的感受是:MySQL 入门最典型的瓶颈真的不是语法记不住,而是“每条 SQL 单独拎出来都认识,组合到一起、数据量一上来,就卡成幻灯片”。这篇实战指南,就是想顺着从“增删改查”到“高效查询”这条主线,把“从写对到写快”的完整过程捋一遍。适合刚学会连库建表、准备真正上手做项目的开发者,也适合那些已经写了几个月 SQL、但从来不知道为什么慢的初级工程师。我会尽量用实际能跑的例子说话,把原理和操作都放在同一个场景里讲,不做教科书式的罗列。

1. 先搞清楚:增删改查为什么写不对比写不出来更常见

1.1 入门时的认知错觉:会写不等于写对

MySQL 入门最迷惑人的地方在于:前三天特别有成就感。建库、建表、插入几条数据、查出来,一气呵成,感觉数据库不过如此。等真到了业务系统里,问题就全冒出来了——不是你不会写,而是你写的每条 SQL 都在用最笨的方式执行,只是数据量小的时候看不出来。

举个例子。某个演示用的订单表,里面只有几千行数据,你执行:

SELECT * FROM order_info WHERE user_phone = '13812345678';

不管表里有没有索引,这条语句都能在几十毫秒内返回。于是你觉得没问题。等到表里累积到几百万行,同一句话变成好几秒,你还是觉得没问题,只是“服务器变慢了”。这种归因方式,是入门阶段最大的坑。

真正负责任的入门路线,不是先学“怎么把 SQL 写出来”,而是先建立两个底层认知:

  1. 每条 SQL 都有执行成本,成本主要集中在“扫了多少行数据”上。
  2. 表结构设计决定了 SQL 的天花板,很多慢查询从建表那一刻就注定了。

我先说结论:一个字段乱七八糟的表演示demo,不管你怎么优化 SQL,都很难快起来;反过来,表设计合理,SQL 写得稍微糙一点,性能也不会太难看。所以入门阶段一定要把“建表”当成和“写查询”同等重要的事。

1.2 表结构设计是先手棋:主键、字符集、字段类型

所有增删改查都建立在表结构上,而表结构最容易被忽视的是三个地方:主键、字符集、字段类型。

主键这一块,我强烈建议用自增整数主键或者雪花算法生成的分布式ID,不要用业务字段做主键。很多人喜欢拿身份证号、手机号这种“天然唯一”的字段当主键,当时觉得方便,后面 join 和索引维护都会别扭。主键最好是单调递增的,这样 InnoDB 在组织数据页的时候可以顺序写入,避免频繁的页分裂。

字符集选择上,现在主流是utf8mb4,别再用utf8了。utf8在 MySQL 里其实是阉割版,最多存 3 个字节,像 emoji 和一些生僻字根本存不进去。我踩过一次雷:用户昵称里带了一个 emoji,插入直接报错,排查半天发现是建表时用了utf8,改成utf8mb4之后一切正常。这个问题在入门阶段碰到属于运气好,在线上碰到就是事故。

字段类型的基本原则是“够用就好,不要贪大”。整数类型从小到大是TINYINT、SMALLINT、MEDIUMINT、INT、BIGINT,能存下业务范围就行,没必要一上来全都BIGINT。变长字符串用VARCHAR而且不要随意给超大长度,比如VARCHAR(500)就算是 500 个字符实际存储也可能占用很大空间,索引长度也会被撑爆。日期类型能用DATETIME就不要用字符串存时间,字符串比较时间大小是利用字典序,但可读性差、也占空间、还容易格式不统一。

我自己带过一个入门项目,表结构长这样:

CREATE TABLE `order_info` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(64) NOT NULL COMMENT '订单号', `user_id` BIGINT NOT NULL COMMENT '下单用户ID', `total_amount` DECIMAL(10,2) NOT NULL COMMENT '总金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

这张表可能是你见过的最普通的订单表,但它恰恰是后面所有增删改查和优化实验的基础。建表时多花十分钟想清楚,后面写业务代码时能少加一星期的班。

2. 增删改查逐句拆解:每一条SQL背后都藏着全表扫描

增删改查四个操作,表面上都是“一句话”,实际执行逻辑天差地别。这一章我不讲语法,只讲“每一类操作真正在底层干了什么”。

2.1 INSERT:别一条条插,事务和批量是两回事

很多人入门时写插入都是在一个循环里执行,比如用编程语言循环 1000 次,每次执行一条:

INSERT INTO order_info (order_no, user_id, total_amount, status) VALUES ('NO001', 1, 99.00, 0);

这种方式在数据量少的时候没毛病,但放到真实业务里就是灾难。每一条 INSERT 都是独立的 SQL,都要经历解析、权限校验、事务提交、刷日志等完整流程。1000 条数据就相当于把成本重复 1000 次。正确做法是拼接成一条批量插入:

INSERT INTO order_info (order_no, user_id, total_amount, status) VALUES ('NO001', 1, 99.00, 0), ('NO002', 2, 129.00, 0), ('NO003', 3, 39.90, 0);

一次插入 500 到 1000 行是比较常见的经验值,并不是越多越好。超过某个阈值后,单条 SQL 过大,网络传输和 undo 日志的开销也会明显上升,反而得不偿失。

还有一个入门级误区:INSERT 也要注意事务边界。在存储过程或业务代码里,如果手动开启了事务,就要明确提交。我见过有同学在事务里 insert 了一万条数据,忘记 commit,直接把连接池打满,数据库锁等待飙升。这不是 SQL 写法问题,而是对事务机制的理解问题。

2.2 UPDATE/DELETE:忘带 WHERE 只是最轻的错

UPDATE 和 DELETE 最经典的错误当然是忘带 WHERE,这个老师都会讲,我就不重复了。我要说的是另一个隐蔽问题:即使带了 WHERE,条件如果没走索引,也会锁住大量行。

InnoDB 默认行级锁,听起来很高大上,但如果你 UPDATE 的 WHERE 条件是一个无法使用索引的字段,MySQL 就只能做全表扫描来定位目标行。扫描过程中涉及的每一行都可能被加上锁,在高并发场景下,一个小小的事故就能拖垮整个库。

先说一个人门时最容易踩的坑:用字符串条件去匹配一个非字符串字段,或者反过来。举个例子,如果user_id是BIGINT,你写:

UPDATE order_info SET status = 1 WHERE user_id = 'abc';

这样 MySQL 需要把每一行的user_id都做一次隐式转换来比较,索引自然就失效了。同样的情况还有字段类型是VARCHAR但你在查询时忘了加引号:

SELECT * FROM order_info WHERE order_no = 123456;

order_no是字符串,查询条件是数字,MySQL 会把字段转成数字再比较,这个操作足以让索引失效。不要小看这个细节,它是新手查询慢的重灾区。

再一个容易忽略的点:UPDATE 和 DELETE 之前先说 SELECT。不是让你额外查一次浪费时间,而是让你确认 WHERE 条件筛选出的数据范围是否符合预期。先查一遍,再看影响行数,能避免大多数误操作。尤其在测试环境刚写完数据清理任务时,先用SELECT COUNT(*)验证目标行数,再执行 DELETE,是成本最低的保险方案。

2.3 SELECT:SELECT * 和隐式转换的坑

SELECT 是入门阶段写得最多、也最容易写得“没毛病但很笨”的语句。最常见的两个坏习惯:SELECT *和不加LIMIT。

SELECT *的问题不只是“多返回了几列数据”这么简单。它意味着 MySQL 必须把整行数据的所有字段都读出来,哪怕你只需要其中一个字段。更重要的是,如果查询走的是二级索引,SELECT *往往需要回表查主键索引,才能拿到完整行数据;而你只要某一个字段的话,很多场景完全可以只在索引树上完成,这个差异在数据量大时是数量级的。

不加LIMIT也很危险。比如只想确认某类订单是否存在,有人写:

SELECT * FROM order_info WHERE status = 3;

如果这个状态下有十万条数据,网络传输的负担和内存消耗都很大。正确做法是:

SELECT id FROM order_info WHERE status = 3 LIMIT 1;

或者用EXISTS语义去写。实际业务中,除非是导出数据,否则查询必须要想清楚“我到底要多少行”。

另外,SELECT 里也经常出现日期范围判断的问题。比如要查某天的订单,别写:

SELECT * FROM order_info WHERE create_time = '2024-06-01';

这样可能查不到当天所有数据,因为create_time是DATETIME,包含了时分秒。正确写法是:

SELECT * FROM order_info WHERE create_time >= '2024-06-01 00:00:00' AND create_time < '2024-06-02 00:00:00';

这个半开区间写法也更容易让 MySQL 在create_time上走索引范围扫描。

增删改查这四类操作里,我只强调了几个点,但每一个都是真实项目中反复出现的高频问题。把它们练成本能反应,再往后走“高效查询”才有意义。

3. 高效查询的地基:索引到底让MySQL做了什么

如果你已经能熟练完成增删改查,下一步就是回答一个问题:为什么加了索引就快了?不搞清楚这个问题,后面用 EXPLAIN 也好、优化慢 SQL 也好,都是在背答案。

3.1 索引的底层逻辑:从翻字典变成查目录

可以把 MySQL 的索引想象成一本字典的“目录”和“正文”。没有目录的时候,你想找一个字,只能从第一页翻到最后一页,这叫全表扫描。有目录之后,先查偏旁笔画定位页码,再翻到对应页,就叫索引查找。

InnoDB 存储引擎用的是B+ 树结构。为什么不是二叉树、不是哈希表?简单说:二叉树的层级太深,数据量大时树高度太高,磁盘 IO 次数太多;哈希表适合等值查询,但做不了范围查找和排序,而业务里到处都是BETWEEN、>、<、ORDER BY这类范围型操作。B+ 树把数据都放在叶子节点,并且叶子节点之间用双向指针串联,既保证了等值查找高效,又能高效地做范围扫描和排序。这算是数据库索引设计里最经典的一个权衡。

入门阶段不需要手写 B+ 树,但必须建立两个直觉:

  1. 索引是额外的数据结构,它的存在空间换时间。
  2. 索引不是越多越好,每一次写入都要同步维护索引树,索引多了,写入就慢了。

3.2 索引的类型和选择:主键、唯一、普通、联合

MySQL 的索引从功能上可以分为主键索引、唯一索引、普通索引、全文索引,从结构上最常用的是 B+ 树索引。入门阶段真正要理解的是联合索引,因为它最容易被用错。

联合索引,也就是多个字段一起建索引,比如:

ALTER TABLE order_info ADD INDEX idx_user_status (user_id, status);

这个索引看起来是两个条件一起查就能用,但它有一个重要的原则:最左前缀原则。也就是说,查询条件里必须包含联合索引最左边的字段,索引才会生效。上面的idx_user_status,只有在查询条件包含user_id时才能走索引;你只查status,用不上这个索引;你查status+user_id(条件顺序颠倒也没关系,MySQL 优化器会调整),能用上部分索引。

为什么会有这个原则?因为联合索引的排序规则是先按第一个字段排,再按第二个字段排,相当于字典先按姓氏排、再按名字排。只拿名字去查,目录自然无从用起。理解这一点,比死记硬背“最左前缀”四个字有用得多。

单独字段建索引的判断标准,主要看两点:查询频率和区分度。比如订单状态字段的值通常只有 0、1、2、3 几种,区分度很低,单独建索引往往收益有限;而order_no这种唯一性很强的字段,建索引就非常有价值。

3.3 索引失效的 5 个常见场景

这里我总结一下入门阶段最常遇到的索引失效场景,每一个都是可以直接对照排查的:

  1. 在索引列上做了函数运算或表达式计算。比如WHERE DATE(create_time) = '2024-06-01',对索引字段做了函数操作后,索引就废了。改写方案是前面说过的区间条件。
  2. 隐式类型转换。字符串字段和数字比较、数字字段和字符串比较,都会让索引失效。
  3. 模糊查询的前缀使用%。LIKE '%abc%'因为无法确定字符串开头,B+ 树索引无法利用;但LIKE 'abc%'是可以走索引的。
  4. 联合索引不满足最左前缀。前面已经解释过了。
  5. OR两侧的条件只有一个能走索引。比如WHERE id = 1 OR status = 0,如果status上没有索引,整个查询可能退化成全表扫描。改写思路是用UNION ALL把两个条件拆开,或者给两侧字段都加上合适的索引。

这些场景说白了都是同一个核心问题:MySQL 要能高效定位数据,就必须让查询条件和索引树上的排序规则匹配。任何会让“大小比较”失真的操作,本质上都是对索引的破坏。

4. 一条慢SQL的完整体检:EXPLAIN执行计划实战

光知道索引原理还不够,真正定位问题要靠工具。MySQL 里最常用的就是EXPLAIN,它能告诉你一条 SQL 会怎么执行、扫多少行、用哪个索引。这一章我带着你完整看一遍执行计划。

4.1 type 列的“鄙视链”:从 const 到 ALL

EXPLAIN 输出里有一列叫type,它表示 MySQL 访问数据的方式。你可以把它理解成一个性能从好到差的排序:

  • const:最多只有一行匹配,常见于按主键或者唯一索引等值查询,这是最快的访问方式。
  • ref:使用非唯一索引等值匹配,可能匹配多行,但通常很快。
  • range:索引范围扫描,比如>、<、BETWEEN、IN,性能也不错。
  • index:扫描整棵索引树,虽然用了索引,但本质是遍历,通常不理想。
  • ALL:全表扫描,性能最差,是绝大多数慢查询的根源。

入门阶段的目标就是:尽量让核心查询的type在ref及以上,避免ALL。看到ALL的第一反应应该是“这里缺索引”,而不是“服务器性能差”。

比如针对前面的订单表执行:

EXPLAIN SELECT * FROM order_info WHERE user_id = 1001 AND status = 0;

如果执行计划显示type = ALL和rows = 1000000,说明它扫描了全表一百万行。加上idx_user_status之后,type变成ref,rows大幅下降,这就是索引带来的直观改变。

4.2 key、rows、Extra:这三个字段信息量最大

除了type,EXPLAIN 里我最常看的是另外三个字段。

key表示实际用到的索引。很多时候你以为会走某个索引,但 MySQL 优化器综合判断后发现另一个索引更合适,或者干脆放弃索引走全表扫描。这时候不要跟优化器硬刚,先看它选了哪个索引,再结合数据分布想它为什么这么选。

rows是预估扫描行数,不是精确值,但数量级很有参考意义。优化 SQL 前后对比rows的变化,能很快判断改动是否有效。

Extra里包含的信息量非常大,几个关键值:

  • Using index:表示查询用到了覆盖索引,不需要回表,这是非常好的信号。
  • Using where:表示先读数据再过滤,通常意味着索引只帮了一部分忙,或者根本没走索引。
  • Using filesort:表示需要额外的排序操作,通常发生在ORDER BY字段和查询使用的索引顺序不一致时,代价不低。
  • Using temporary:表示使用了临时表,常见于GROUP BY、DISTINCT操作,数据量大时也很伤。

看到Using filesort时不要慌,先检查ORDER BY的字段是否跟查询走的索引顺序一致。比如索引是idx_user_status (user_id, status),而查询是WHERE user_id = 1 ORDER BY create_time,create_time不在索引里,MySQL 就得先把数据取出来排序,自然出现filesort。如果排序字段能进索引,就能省掉这一步。

4.3 完整案例:一条秒级查询是怎么变毫秒级的

我用一个实际案例把整个过程串起来。假设业务方反馈“订单列表加载很慢”,对应 SQL 简化之后是:

SELECT order_no, user_id, total_amount, create_time FROM order_info WHERE user_id = 10086 ORDER BY create_time DESC LIMIT 20;

第一次 EXPLAIN 的结果是:

type: ALL key: NULL rows: 1500000 Extra: Using where; Using filesort

全表扫一百五十万行,还要额外排序,不慢才怪。这里的user_id上其实有单列索引,但为什么没走?继续看才发现,这条 SQL 是嵌套在业务代码里自动拼接的,实际 WHERE 条件还可能带了一个status的等值条件,而旧索引是idx_user_id(user_id),status过滤不进去。优化器估算后觉得回表成本高,干脆扫全表。

处理方案是调整索引为联合索引,让查询条件走索引、排序字段也能放到索引里:

ALTER TABLE order_info ADD INDEX idx_user_status_create (user_id, status, create_time);

再 EXPLAIN:

type: ref key: idx_user_status_create rows: 856 Extra: Using index condition

rows从一百五十万降到八百多,Using filesort消失了——因为ORDER BY create_time DESC刚好和联合索引的排序方向匹配,数据读出来就是有序的。这条 SQL 从秒级降到了毫秒级。整个过程没有改任何业务逻辑,纯粹是索引和查询条件的匹配问题。

5. 从慢到快的三条路:改写SQL、调整索引、重构表结构

发现慢查询之后,手段其实就三类:改 SQL、调索引、改表结构。这三者的成本排序是先 SQL、再索引、最后表结构,因为改表结构往往涉及业务停写或者数据迁移,风险最大。

5.1 覆盖索引:让查询只在一棵索引树上完成

覆盖索引是指查询需要的字段都能从索引树里直接拿到,不需要回表。比如表里有一个联合索引idx_user_status (user_id, status),那你执行:

SELECT user_id, status FROM order_info WHERE user_id = 10086;

需要返回的两个字段都包含在索引里,MySQL 在索引树上就能完成判断和取值,Extra会显示Using index。这个小细节在数据量大时非常有用,因为回到主键索引读完整行数据的随机 IO 开销比索引树上的顺序访问高一个量级。

所以实操中有个实用习惯:为高频查询设计联合索引时,把需要查询的字段也带进去,让索引“覆盖”更多需求。但要注意尺度,不能把几十个字段都塞进一个索引,那样索引本身占用的空间和写放大都不可接受,一般覆盖两三个核心字段就够了。

5.2 分页查询优化:深分页的 LIMIT 陷阱

另一个非常典型的慢查询是深分页。很多列表接口会写:

SELECT * FROM order_info ORDER BY id LIMIT 100000, 20;

前一万条翻起来轻轻松松,翻到十万条之后越来越慢。原因在于:LIMIT 100000, 20并不是“只取最后 20 条”,而是 MySQL 实打实地读取了前十万条数据,再把它们丢掉,只返回最后 20 条。翻得越深,丢掉的数据越多,查询自然越慢。

优化思路有两个。第一个是延迟关联,先只查主键,拿到主键后再回表取完整行:

SELECT o.* FROM order_info o INNER JOIN ( SELECT id FROM order_info ORDER BY id LIMIT 100000, 20 ) t ON o.id = t.id;

里面的子查询只查id,如果主键能走覆盖索引,前十万条数据的扫描成本会低很多;外层再用主键精确回表,读取的行数被限制在 20。

第二个思路是基于游标的翻页:前端传上一页最后一条数据的id,下次查询直接WHERE id > 上一页最大id ORDER BY id LIMIT 20。这种方式完全避免了深偏移,是最推荐的方案,但需要业务端配合改造参数传递方式。

5.3 group by、order by、join 的优化思路

GROUP BY最容易踩的坑是“分组字段和索引不匹配”。比如:

SELECT status, COUNT(*) FROM order_info GROUP BY status;

如果status没有索引,或者联合索引不以status开头,MySQL 就要用临时表做分组聚合,Extra显示Using temporary。优化思路要么给分组字段建合适的索引,要么在业务侧减少分组规模;如果只是统计状态分布,用缓存做离线统计也未尝不可,没必要让在线数据库扛这种聚合压力。

JOIN优化的核心原则是:小表驱动大表,连接字段一定要有索引。比如订单表和用户表关联查询用户昵称,就要保证user_id在用户表上有索引。如果连接字段没有索引,每连接一行都可能触发一次全表扫描,性能会灾难性恶化。另外,不到万不得已不要对三个以上的表做关联,很多复杂 JOIN 可以拆成多次简单查询,在业务代码里组装数据,对数据库压力反而更小。

改写 SQL 和调整索引经常是组合拳。改 SQL 的目的往往是让条件能对上已有索引;调整索引的目的则是让新 SQL 能走更优路径。两者都要用 EXPLAIN 验证,不要凭感觉下结论。

6. 慢查询日志:把自己写的SQL放到显微镜下

很多时候,你并不知道哪条 SQL 慢。慢查询日志是帮你发现问题的第一道工具,建议入门期就把它打开。

6.1 慢查询日志的开启与解读

在测试环境或者开发环境,可以临时打开慢查询日志:

SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;

long_query_time单位是秒,设置成 1 表示超过 1 秒的 SQL 都会被记录。线上环境可以根据情况调整阈值,比如 2 秒或者 5 秒,避免日志刷得太猛影响磁盘 IO。

日志内容会记录执行时间、锁等待时间、返回行数、扫描行数、SQL 文本等信息。看到扫描行数巨大但返回行数很少的语句,基本可以直接判定缺索引。比如:

# Query_time: 2.300519 Lock_time: 0.000176 Rows_sent: 10 Rows_examined: 780000

10 行返回结果,却扫了 78 万行,这几乎是指数级差距,下一步就是拿这条 SQL 去 EXPLAIN,然后补索引或者改写法。

6.2 日常巡检的习惯

慢查询日志不是打开就完事,更重要的是形成翻阅习惯。我自己的做法是每周花一点时间看一遍慢查询汇总,重点关注三类:

  1. 扫描行数和返回行数差距特别大的查询,优先处理;
  2. 同一个业务接口反复出现慢查询,说明索引设计有系统性问题,不是个例;
  3. 后半夜批量任务导致的慢查询,考虑是不是批量 SQL 写得过于粗暴,比如一次 UPDATE 好几百万行。

把慢查询日志当成“数据库的体检报告”,你才能慢慢建立对线上库的敏感度。很多资深工程师看一条 SQL 就能猜出问题在哪,不是因为记忆力好,而是因为他们长年累月在看这些日志和 EXPLAIN 结果。

从“会写增删改查”到“能写出高效查询”,中间隔的不是更多语法技巧,而是三个习惯的转变:建表前想清楚结构、写 SQL 时想清楚成本、上线后盯着慢查询日志反馈。这三个习惯养成之后,MySQL 对于你就不再是一堆能跑的句子,而是一个能控制的系统。这条路上没有捷径,但每一步都有迹可循。

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

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

立即咨询