“MySQL学习路径”这个话题,这些年被问过的次数我自己都记不清了。有意思的是,问的人背景差异极大:有刚转行、连命令行都不太熟的新人,也有写了几年业务代码、天天跟 ORM 打交道但一遇到慢查询就发怵的后端。他们的问题表面看是“我该先学什么后学什么”,往深里挖其实是同一个毛病——把数据库当成一门“要背下来的课”,而不是一套“要拿来解决问题的工具”。我见过不止一个人,花了两个月把语法手册从头翻到尾,笔记做得工工整整,结果给他一张三千万行的订单表,让他查一下上个月的退款明细,他第一反应还是SELECT * FROM orders WHERE ...,然后就盯着屏幕等。这不是学习路径的问题,是路径的起点选错了。下面这份路径是我自己带人、也自己踩坑之后沉淀下来的,适合零基础但目标明确的人,也适合有几年经验但知识碎片化、想系统补一遍的人。我会把每个阶段“为什么这么排”“哪一步最容易卡住”“卡住了怎么绕过去”都写清楚,你照着走,至少不会在错误的方向上耗掉半年。
1. 先把路径的底层逻辑想明白,再决定学什么
1.1 从“会用”倒推“要学”,而不是从目录正着推
绝大多数人学 MySQL 的方式,是找一份教程目录,从“数据库概述”开始,一章一章往下啃。这种做法在纯理论学科里没问题,但数据库是个强反馈的技能型东西,你学一个概念,如果没有对应的场景去验证,它在脑子里就是浮的。我当时带过一个刚毕业的同学,他上来就问我要不要先看存储引擎的对比、要不要先搞清楚 InnoDB 和 MyISAM 的五点区别。我说你先别急,你手上有数据吗?他说没有。我说那你先把数据造出来,造完你自然就知道该关心什么了。
所以这条路径的设计逻辑是倒推的:先确定你三个月后要能独立完成什么事,再把这个目标拆成能力项,最后才落到知识点。我给自己的目标定得很朴素——三个月后,拿到一个陌生的业务库,我能自己建表、能写出不拖垮服务的查询、能在出问题的时候定位到原因、能把数据安全地备份出来。这四个“能”,对应四个能力块,剩下的都是填充物。
这么做的另一个好处是,能天然过滤掉大量“学了也用不上”的内容。比如复制架构的细节、分区表的各种策略,这些东西在入门阶段投入产出比极低,等你真的遇到单表过亿、读写分离需求的时候再补,两个小时就能捡起来,因为那时候你有具体问题做锚点。
1.2 三个阶段的分界线在哪里
我把整个路径切成三段,分界线不是“知识点数量”,而是你能不能判断自己写的东西是好是坏。
第一段的终点是:你能独立建出一张结构合理的表,并且能写出结果正确的增删改查。注意“结果正确”这四个字,很多人写 SQL 是“看起来对”,跑出来有数据就以为对了,实际上 JOIN 写错、NULL 处理错、聚合口径错,都是隐性 bug。
第二段的终点是:你能看懂一条 SQL 为什么慢,并且知道该加什么索引、或者该怎么改写。这一段是从“会写”到“写得动”的分水岭,也是绝大多数人卡住的地方。
第三段的终点是:数据不出事。包括并发场景下数据不能被写乱、机器挂了数据能回来、误删了数据能救回来。这一段平时没感觉,出事的时候就是生死线。
三个阶段的时间分配,我的建议是 3:5:2。第一阶段三周足够,第二阶段是重头戏,至少五到六周,第三阶段两周打底,剩下的时间靠实战慢慢补。
注意:这三段是能力分界,不是学习顺序上的硬隔离。你在第二阶段学索引的时候,必然会回头看第一阶段的表结构设计,因为索引是建在表上的,表设计歪了索引怎么调都别扭。这种“回头”是正常的,不是白学了。
1.3 版本选择这件小事,其实很影响体验
现在主流的版本是 8.0 系列,我的建议是直接用 8.0,别为了“资料多”去装 5.7。理由很实在:8.0 默认字符集是 utf8mb4,你新建库不用再操心中文乱码;窗口函数、CTE 这些写分析型查询很顺手;而且现在的教程和文档基本都是围绕 8.0 写的。唯一的坑是老项目迁移的时候会碰到一些默认行为差异,但那是后面的事。
小版本号尽量选带 LTS 性质的稳定版,不要追最新的实验特性。我就吃过一次亏,图新鲜装了个刚发布的小版本,结果某个连接池组件跟它配合有兼容问题,排查了大半天才发现是版本本身的 bug,白白浪费一个周末。
2. 第一阶段:把库建起来,把表查明白
2.1 环境搭建,能省事的就别折腾
环境这一步,我见过太多人在这里翻车然后失去兴趣。正经的建议是:如果你只是学,别在物理机上裸装。装个容器,一条命令起一个实例,用完删掉,干净。
docker run -d --name mysql-learn \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=Learn@123456 \ -e MYSQL_DATABASE=shop_demo \ -v /data/mysql-learn:/var/lib/mysql \ mysql:8.0这里几个参数说清楚为什么这么写。-v挂载数据目录是必须的,不然容器一删数据全没,你辛苦造的三百万行测试数据就白造了。-e MYSQL_DATABASE让容器启动时就把库建好,省得你手动CREATE DATABASE。端口映射到 3306 是最省事的,但如果本机已经有实例在跑,就改成 3307,客户端连接时指定端口即可。
至于图形化工具,我的态度是入门阶段必须用,但不能依赖。图形工具帮你把表结构可视化、把结果集排版,对理解关系型数据很有帮助。但你要强制自己有一半的操作在命令行里完成,因为线上环境不会给你图形界面,而且命令行能让你对“我到底执行了什么”有更清晰的感知。
提示:容器方式起实例,适合学习和本地开发。生产环境的部署涉及更多考量,包括磁盘 IO 特性、内存分配、内核参数等,不在入门阶段的讨论范围内。
2.2 别急着写 SELECT,先把表设计对
我发现一个很普遍的现象:新手拿到任务,第一反应是“怎么写查询”,而不是“数据该怎么存”。这个顺序反了。表结构设计错了,后面所有的查询都是在给错误买单。
举个最常见的例子。假设要做个简单的商品订单场景,很多人会这么建:
-- 反例:把多个值塞进一个字段 CREATE TABLE orders_bad ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT, product_ids VARCHAR(500), -- 存成 "1001,1002,1003" amounts VARCHAR(200), -- 存成 "99.00,199.00" created_at DATETIME );这么设计的动机很好理解:简单,一张表搞定。但它带来的问题会随着数据量放大——你要查“哪些订单包含商品 1002”,只能LIKE '%1002%',索引直接报废;你要统计某个商品的总销售额,得先把字符串拆开再求和,SQL 写起来极其痛苦;更麻烦的是数据一致性,商品被删了,这里的字符串还留着脏数据。
正确的做法是拆成订单主表和订单明细表,明细表一行一个商品。这就是所谓的第一范式——字段不可再分。听起来像教科书里的东西,但你只要记住一句话就够了:一个字段里出现逗号分隔的多个值,几乎一定是设计错误。
再补充两个建表时的实用原则。第一,每张表都加id主键,用自增的BIGINT,别用业务字段当主键,业务字段会变,主键不该变。第二,所有涉及金额的字段用DECIMAL而不是FLOAT或DOUBLE,浮点数做精确计算会出现0.1 + 0.2 != 0.3这种问题,我见过真实项目里因为用 DOUBLE 存金额,月底对账差几分钱,查了两天。
2.3 增删改查,重点练“边界”而不是“语法”
语法这部分,说实话,两天就能过完。INSERTUPDATEDELETESELECT的写法,任何一份速查表都比我能讲得全。真正需要花时间的是边界情况,这些是教程里通常一笔带过、但实际工作天天遇到的:
第一类是 NULL。NULL不等于任何值,包括它自己。WHERE col = NULL永远返回空,必须写WHERE col IS NULL。更隐蔽的是聚合函数,COUNT(col)会跳过 NULL,COUNT(*)不会。我见过统计报表数字对不上,最后发现是某个字段有大量 NULL,用COUNT(col)少算了一批。还有一个坑是NOT IN,如果子查询结果里含 NULL,整个NOT IN会返回空集,这个行为非常反直觉。
第二类是 JOIN 的方向。内连接和左连接的区别,语法上一目了然,但实际写的时候经常搞混。判断方法很简单:问自己“如果右表没有匹配的记录,左表这行要不要保留”。要保留就是左连接,不要就是内连接。另外多表 JOIN 的时候,注意连接条件写对,我见过把ON a.id = b.a_id写成ON a.id = b.id的,跑出来数据量暴涨十倍还没人发现,因为“看起来有结果”。
第三类是隐式类型转换。这条留到第二阶段讲,因为它直接影响索引,但你在第一阶段就要养成习惯:字段是什么类型,传参就用什么类型,别用字符串去比数字。
-- 练习:把这几种情况的区别跑一遍,用结果说话 SELECT COUNT(*), COUNT(phone) FROM users; -- 两者可能不同 SELECT * FROM users WHERE phone = NULL; -- 永远是空 SELECT * FROM users WHERE phone IS NULL; -- 这才是你要的2.4 数据类型的选择,比想象中重要
很多人建表时字段类型随手选,INT还是BIGINT、VARCHAR(255)还是VARCHAR(64),觉得无所谓。这个“无所谓”会在数据量上来之后变成实打实的成本和性能问题。
选择原则我总结成三条。能用小的不用大的,整数类型TINYINT1 字节、INT4 字节、BIGINT8 字节,一个存“状态”的字段用TINYINT就够了,用BIGINT是浪费,而且索引也更大。能用定长不用变长,像性别、状态这种取值固定的,理论上可以用CHAR或者TINYINT,VARCHAR虽然灵活但有额外的长度开销。时间字段想清楚用哪种,DATETIME和TIMESTAMP的区别不只是范围,TIMESTAMP会随时区变化,跨时区业务容易出问题,我的习惯是统一用DATETIME存业务时间,需要时区转换在应用层做。
有一个计算可以帮你建立直觉:假设一张表有 1000 万行,某个字段本来用INT就够但你用了BIGINT,每行多 4 字节,光这一列就多占 40MB,如果这个字段还建了索引,索引文件同样要膨胀。单看一个字段不多,但一张表几十个字段,累积起来就是几百 MB 的差距,直接影响缓存命中率。
3. 第二阶段:看懂执行计划,学会和索引打交道
3.1 索引为什么能加速,B+ 树到底解决了什么
这一段我建议你务必搞懂原理,不要停留在“加索引就快了”的层面。因为一旦你理解了结构,后面所有的“索引失效”场景你都能自己推出来,不需要背。
InnoDB 的索引底层是 B+ 树。你可以把它想象成一本字典的目录体系:最上层是很粗的分类,往下越来越细,最底层是具体的页码。关键是这个“目录”的每一层都在磁盘上按顺序存放,一次磁盘读取能捞出一大批相邻的数据,这就是它比二叉树高效的原因——二叉树每个节点只有两个分叉,树高太大,读一次走一层,磁盘 IO 次数压不下来;B+ 树的节点可以放几百上千个分叉,三到四层就能覆盖上千万行,也就是说查一条记录最多三四次磁盘 IO。
还有一个容易忽略的点:InnoDB 的主键索引是聚簇索引,叶子节点直接存整行数据,所以按主键查最快,一次就拿到全部。而你自己建的其他索引叫二级索引,叶子节点存的是索引列的值加主键值,查完索引还要拿主键回主表再查一次,这叫回表。理解了回表,你就明白为什么SELECT *有时候比SELECT id, name慢——后者如果索引里已经包含了这两列,就不需要回表了,这就是所谓的覆盖索引。
我在实际调优里用得最多的手段之一就是覆盖索引。某个列表页要查id、status、created_at三列,按status过滤按时间排序,那就建一个(status, created_at, id)的联合索引,查询完全走索引,一次回表都不用。实测在千万级表上,从 800ms 降到 30ms 很常见。
3.2 EXPLAIN 只看四个字段就够用
EXPLAIN输出有十几列,新手容易被吓住。我的经验是,入门阶段只盯四个字段,其余的先放着。
| 字段 | 看什么 | 危险信号 |
|---|---|---|
type | 访问类型 | 出现ALL(全表扫描)要警惕,index也不理想 |
key | 实际用了哪个索引 | 为NULL说明没走索引 |
rows | 预估扫描行数 | 数字接近表总行数,说明索引没起作用 |
Extra | 补充信息 | 出现Using filesort、Using temporary要留意 |
type的取值有个大致的好坏顺序:system>const>eq_ref>ref>range>index>ALL。日常业务查询做到ref或range就算合格,偶尔出现index说明在扫整个索引树,数据量大就有问题,ALL基本就是需要处理的信号。
Extra里的Using filesort不一定是文件排序,内存里排也叫这个名字,但它的意思是你 ORDER BY 的字段没走索引顺序,需要额外排一次。Using temporary更值得关注,通常出现在 GROUP BY 或者 DISTINCT 场景,MySQL 需要建临时表,数据量大时非常慢。
-- 标准动作:任何要上线的查询,先 EXPLAIN 一遍 EXPLAIN SELECT id, order_no, amount FROM orders WHERE user_id = 10086 AND status = 2 ORDER BY created_at DESC LIMIT 20;如果这条语句的type是ALL,rows是几百万,Extra里有Using filesort,那基本可以确定要建(user_id, status, created_at)这个联合索引。建完之后再 EXPLAIN,type应该变成ref,rows降到几十,Extra里的 filesort 消失,因为索引本身已经按created_at有序了。
3.3 索引失效的六种情况,我按踩坑频率排了序
这部分是实操里最有价值的内容,我按自己遇到的频率从高到低排:
第一位,联合索引没走最左前缀。建了(a, b, c),查询条件是WHERE b = 1 AND c = 2,索引直接跳过。原因是 B+ 树是先按 a 排序、a 相同再按 b 排,你不给 a 的值,就没法在这棵树里定位。这里有个细节:如果WHERE a = 1 AND c = 2,能用到 a,但 c 用不上,只能算部分使用。
第二位,字段上做了运算或函数。WHERE DATE(created_at) = '2024-01-01'这种写法,索引直接失效。改成范围查询WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02'就能走索引。这个改写我在实际项目里做过无数次,几乎每次都有明显提升。
第三位,隐式类型转换。字段是VARCHAR但你传了个数字,或者字段是BIGINT但你传了个字符串,MySQL 会做类型转换,转换之后索引就用不上了。这个坑特别隐蔽,因为查询能出结果,只是慢。我排查过一个接口,原因就是前端传的userId是字符串,后端拼进 SQL 没做处理,一千万行的表全表扫描。
第四位,前导通配符。LIKE '%关键词'没法走索引,因为索引是按前缀排序的,你不知道开头是什么就没法定位。LIKE '关键词%'可以走。如果业务真的需要前导模糊匹配,那要考虑全文索引或者搜索引擎方案,不要在 B+ 树上硬扛。
第五位,OR 连接了不同字段。WHERE a = 1 OR b = 2,如果 a 和 b 上都有索引,MySQL 可能会走索引合并,但效率通常不如预期;如果只有一个字段有索引,另一个没索引,那就退化成全表扫描。这种情况我一般建议拆成两个查询用 UNION 合并,或者在业务层分开查。
第六位,索引选择性太低。比如“性别”字段只有两三个值,建了索引也没意义,优化器算一下发现走索引要回表几十万次,还不如直接全表扫。索引要建在区分度高的字段上,这是基本原则。
提示:判断一个字段适不适合建索引,可以算一下选择性:
SELECT COUNT(DISTINCT col) / COUNT(*) FROM table_name。结果越接近 1 越好,低于 0.1 基本不值得单独建索引。
3.4 慢查询日志:让问题自己浮出来
靠用户投诉发现慢查询,是最被动的做法。正确姿势是打开慢查询日志,让它自动记录。
-- 查看当前配置 SHOW VARIABLES LIKE 'slow_query_log%'; SHOW VARIABLES LIKE 'long_query_time'; -- 开启(会话级,重启失效;生产环境建议写进配置文件) SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; SET GLOBAL log_queries_not_using_indexes = ON;long_query_time设多少合适?我的经验是业务接口按 1 秒起步,因为一般接口的超时阈值在 3 到 5 秒,1 秒的记录能给你留出处理时间。等优化到一定程度,再往下调到 0.5 秒甚至 0.1 秒。别一上来设 0.01,日志会爆炸,根本看不过来。
log_queries_not_using_indexes这个开关很有用,它会把所有没走索引的语句也记下来。但注意,小表全表扫描很快,也会被记进去,所以日志里会有一堆噪音,需要你自己过滤。
抓到慢 SQL 之后,标准动作是三步:先EXPLAIN看执行计划,再判断是索引问题还是写法问题,最后改完再测一遍。千万不要一上来就加索引,有些慢查询根本原因是写法(比如一次性捞全表数据在应用层过滤),加再多索引也没用。
4. 第三阶段:让数据不出事——事务、锁与备份
4.1 事务隔离级别,别背表格,要能讲出场景
四个隔离级别、三种并发问题,这个表格谁都会背。但真正需要你掌握的是:在什么业务场景下,你会主动选择哪个级别,为什么。
先澄清一个常见误解:MySQL 的默认隔离级别是可重复读,不是读已提交。这个默认值跟很多其他数据库不一样,是个高频考点,也是实际踩坑点。可重复读意味着同一个事务里多次读同一行,结果一致,靠的是 MVCC(多版本并发控制)——每行数据保留多个版本,事务按自己的快照读。
那什么时候该改成读已提交?我的经验是,当你的业务里存在大量“读了马上要写”的场景,且不希望因为间隙锁导致死锁时,可以考虑。可重复读为了防幻读会加间隙锁,间隙锁之间不冲突,但和插入意向锁会冲突,在高并发写入场景下死锁概率明显更高。我处理过一个批量导入的场景,并发开到 20 就频繁死锁,改成读已提交之后稳定了。
至于串行化,实际业务里基本不用,除非是极低频的强一致对账类操作。读未提交更不用考虑,它带来的脏读问题没有任何业务能接受。
还有一个常被忽略的点:事务要短。我见过有人在事务里调第三方接口、发消息、做文件上传,一个事务持锁几秒钟,并发一上来直接堵死。原则很简单,事务里只放数据库操作,且尽快提交。
4.2 死锁不是玄学,有明确的排查路径
死锁这件事,没遇到过的觉得神秘,遇到过的知道它有迹可循。MySQL 检测到死锁会自动回滚其中一个事务,所以业务上通常表现为“偶尔有个请求报错,重试就好了”。但频繁死锁必须查。
排查的第一步是看日志:
SHOW ENGINE INNODB STATUS;输出里找LATEST DETECTED DEADLOCK这一段,它会告诉你两个事务分别持有什么锁、在等什么锁、执行的最后一条 SQL 是什么。我第一次看这个输出的时候一头雾水,其实只要抓住关键:找到两个事务的 SQL,看它们的加锁顺序是不是相反。
死锁的经典成因就三种。第一种是加锁顺序不一致,事务 A 先锁行 1 再锁行 2,事务 B 先锁行 2 再锁行 1,必然死锁。解决办法是统一加锁顺序,比如批量更新时按主键排序后再执行。第二种是间隙锁冲突,可重复读下的范围更新容易触发。第三种是索引缺失导致的锁范围扩大,本该锁一行,因为没走索引变成了锁一批,冲突概率自然上升。
这里有个很实用的经验:大部分死锁的根因是 SQL 没走索引。因为 InnoDB 加锁是加在索引上的,如果查询没走索引,就会锁住扫描过的所有行,甚至锁住间隙。所以优化死锁,先看 SQL 的执行计划,往往比分析锁本身更有效。
4.3 备份这件事,只有恢复过才算学会
备份的坑在于,很多人做了备份,但从来没验证过能不能恢复。等到真出事,发现备份文件是空的、或者恢复出来数据对不上,那就晚了。
入门阶段必须掌握的两种方式:逻辑备份和物理备份。逻辑备份用mysqldump,导出的是 SQL 语句,优点是跨版本、可读、能只导某张表,缺点是慢,数据量大时恢复要很久。物理备份是直接拷贝数据文件,快,但跟版本和平台绑定。
# 逻辑备份:整库导出,带一致性快照 mysqldump -h 127.0.0.1 -u root -p \ --single-transaction \ --routines --triggers \ --databases shop_demo > /backup/shop_demo_$(date +%F).sql--single-transaction这个参数是关键,它让备份在一个一致性快照里完成,不会锁表,也不会因为备份过程中有写入导致数据不一致。不加这个参数,备份出来的数据可能是“半截”的。--routines --triggers把存储过程和触发器也带上,不然恢复之后业务逻辑就缺块了。
恢复的时候要注意,mysqldump导出的文件默认包含DROP TABLE IF EXISTS,直接导入会覆盖现有表。所以在生产环境操作前,务必确认目标库是对的,这个错误我犯过一次,还好是测试环境。
除了备份,还得练误删恢复。思路是:定期全量备份 + binlog 增量。全量恢复到某个时间点,然后用 binlog 重放到误删之前的那一刻。这个流程建议你在本地完整走一遍,不要只在脑子里过。
-- 查看 binlog 状态和文件列表 SHOW VARIABLES LIKE 'log_bin'; SHOW BINARY LOGS; -- 用 mysqlbinlog 解析指定时间段(命令行执行) -- mysqlbinlog --start-datetime="2024-01-01 10:00:00" \ -- --stop-datetime="2024-01-01 10:05:00" \ -- mysql-bin.000003 > /tmp/recover.sql注意:binlog 的保留时长默认可能只有几天,如果业务对恢复时间点有要求,要提前调整保留策略,并确认磁盘空间够。这个参数改之前一定要评估磁盘增长,我见过因为保留太久把磁盘写满导致实例不可用的。
5. 学习节奏怎么排,练习素材从哪来
5.1 一份能落地的六周计划
光有路径没有节奏,很容易三天打鱼。我把自己带人用过的一版计划整理出来,你可以按自己的时间缩放。
| 周次 | 主线任务 | 产出物 | 常见卡点 |
|---|---|---|---|
| 第 1 周 | 环境搭建 + 建表练习 | 一个完整的电商库结构(6 到 8 张表) | 表关系理不清,外键要不要用纠结半天 |
| 第 2 周 | 查询练习 + NULL/聚合边界 | 20 条覆盖各种场景的查询语句 | JOIN 写错、聚合口径错 |
| 第 3 周 | 造数据 + 索引原理 | 单表 500 万行测试数据 | 造数太慢,不知道怎么写批量插入 |
| 第 4 周 | EXPLAIN + 索引优化 | 5 组优化前后的执行计划对比 | 不知道优化到什么程度算好 |
| 第 5 周 | 事务与锁 | 复现一次死锁并解决 | 死锁不好复现,看不出日志 |
| 第 6 周 | 备份恢复 + 综合演练 | 完整走一遍备份到恢复的流程 | 恢复时字符集、权限出问题 |
这张表里我特别想强调第 3 周。没有真实数据量的练习是没有意义的。你在几百行的表上加索引、看执行计划,优化器可能直接选择全表扫描,因为那样更快,你会误以为索引没用。只有在百万级数据上,索引的价值和失效的代价才看得出来。
5.2 造数据这件事,自己写脚本比找现成的强
网上的测试数据集很多,但我不建议直接用。原因是你自己造数据的过程,本身就是一次绝佳的建模练习——你得想清楚字段之间的关联、取值范围、时间分布,这个过程比看别人设计的表收获大得多。
批量插入有个技巧,别一条一条 INSERT,用多值插入:
-- 正确姿势:一次插入多行,比循环单条快一个数量级 INSERT INTO orders (user_id, amount, status, created_at) VALUES (1001, 99.00, 1, '2024-01-01 10:00:00'), (1002, 199.50, 2, '2024-01-01 10:05:00'), (1003, 59.90, 1, '2024-01-01 10:08:00');一次插 500 到 1000 行是比较合适的批次大小,太多会超过max_allowed_packet限制,太少又慢。造 500 万行大概需要几分钟,耐心等一下。
造数据的时候,注意让数据分布贴近真实。比如状态字段不要平均分配,真实业务里大部分订单是已完成状态,退款是少数;时间字段要分散在一段时间内,不要全挤在同一天,否则你测试范围查询的时候看不出效果。这种“人为制造不均衡”,恰恰是让索引效果显现的关键。
5.3 找一个真实的小项目练手,比刷题有用
刷题能练语法,但练不出工程判断。我的建议是找一个你能说清楚业务逻辑的小场景,从头到尾做一遍。比如记账、藏书管理、健身记录,随便什么,关键是你要真的用它。
用起来之后,各种真实问题就来了:数据越积越多查询变慢、两个人同时改一条记录出现覆盖、误操作删错了数据要恢复。这些问题在刷题里永远遇不到,但正是它们构成了数据库能力的核心。我当年就是给自己的一个记账工具做优化,才发现原来按月份查账单那么慢,然后一步步摸到了索引和日期范围查询的门道。
6. 常见问题速查与踩坑实录
6.1 高频问题速查表
这张表是我这些年被问得最多的问题汇总,遇到卡壳可以先来这里对号。
| 现象 | 最可能的原因 | 处理方向 |
|---|---|---|
| 中文显示成问号或乱码 | 连接字符集不是 utf8mb4 | 检查建库、建表、连接三处字符集 |
| 查询突然变慢,昨天还好好的 | 数据量增长导致执行计划改变 | EXPLAIN 对比,补索引或改写 |
| 加了索引反而更慢 | 索引选择性低或回表次数太多 | 算选择性,考虑覆盖索引 |
| 并发更新出现数据丢失 | 应用层“读-改-写”没有加锁 | 用行锁或原子更新语句 |
| 频繁死锁 | 加锁顺序不一致或 SQL 未走索引 | 统一顺序,先看执行计划 |
| 备份恢复后数据少一截 | 备份时没有一致性快照 | 加事务快照参数,或停机备份 |
| 磁盘空间莫名增长 | binlog 或慢日志没清理 | 检查日志保留策略和过期时间 |
| 连接数打满报错 | 连接池配置不当或连接未释放 | 检查连接池上限和泄漏 |
6.2 几个我实实在在踩过的坑
第一个坑,用SELECT *图省事。早期写代码,图方便全用SELECT *,后来一次接口优化,把返回字段收敛成三列之后,响应时间从 400ms 掉到 60ms,主要原因是走了覆盖索引,省掉了大量回表。更隐蔽的问题是,SELECT *让程序和数据表结构强耦合,加个字段就可能影响下游解析。
第二个坑,把LIMIT当成万能的分页方案。LIMIT 1000000, 20这种深分页,MySQL 要先扫过前一百万行再丢掉,越翻越慢。我处理过一次列表页第五十页打开要八秒的问题,改法是游标分页——记住上一页最后一条的 id,下一页从那个 id 之后取:
-- 深分页的替代写法,按主键游标翻页 SELECT id, order_no, amount FROM orders WHERE id > 1000000 ORDER BY id LIMIT 20;这个改法要求按主键或者有序的唯一键翻页,业务上要稍微调整一下交互,但性能提升是数量级的。如果业务必须支持跳页,那就得另外想办法,比如限制最大可翻页数,或者用覆盖索引先查出主键再回表。
第三个坑,认为“事务里报错了会自动回滚”。默认情况下,事务里某条语句失败,事务本身不会自动回滚,除非你显式 ROLLBACK 或者遇到特定类型的错误。我见过一次批量操作,中间一条失败被 catch 掉了,后面继续执行并提交,结果数据只写了一半。正确的做法是在应用层捕获异常后明确回滚。
第四个坑,测试环境和生产环境的执行计划不一样。测试库数据量小,优化器可能选择全表扫描;上线之后数据量上来,执行计划变了,索引反而成了负担。所以涉及索引调整,一定要在接近真实数据量的环境里验证。
第五个坑,忘记UPDATE加WHERE。这个错误每个人都可能犯一次,代价是全表数据被改。我的习惯是写UPDATE和DELETE之前先写WHERE再回去补前面的部分,另外执行前先用同样的WHERE跑一次SELECT COUNT(*),确认影响行数在预期内。这个动作看起来啰嗦,但能救命。
6.3 关于学习资源的一个个人建议
网上的资料很多,但质量参差。我自己的筛选方法是:看它有没有讲“为什么”。只告诉你“这样写更快”的资料,价值有限;能说清楚“因为索引结构决定了这样写能定位、那样写只能扫描”的,才值得花时间。另外,官方文档永远是最该读的,虽然读起来枯燥,但它是唯一不会给你错误信息的来源,尤其是参数说明和默认值那部分,遇到拿不准的先去查官方文档。
还有一点,别陷入“学完再做”的循环。数据库这门手艺,很多认知必须从真实问题里长出来。你可以在只学了第一阶段的水平就开始用,遇到慢的再回头补第二阶段,遇到并发问题再补第三阶段,这个顺序比按部就班更扎实。
我自己现在的习惯依然没变,每隔一段时间会回头翻一遍自己以前的 SQL,经常能发现当时觉得没问题、现在看很糟糕的写法。这种“回头看能看出问题”的状态,其实就是水平在长。你可以把第一阶段写的那些查询存到一个文件里,两个月后再看一遍,如果一条都挑不出毛病,说明你没进步;如果挑出一堆,恭喜你,路走对了。