1. 这一天的内容定位:为什么是数据库索引,而不是Python语法
学Python学到第43天,按常规路线早该撸Flask、写爬虫、做数据分析玩了。但我那套“100天计划”把Day 43专门留给MySQL索引,原因很简单:我见过太多用Python写业务的人,CRUD溜得飞起,一看数据库慢查询日志直接傻眼。你写的代码再优雅,一条不带索引的SQL能让接口从20毫秒变成20秒,到时候Python的任何优化技巧都救不了你。
这一天不是让你背索引语法,而是要你在脑子里建立起“数据在磁盘上到底怎么存储、MySQL怎么找到你想要的那条记录”这一整套逻辑。只有搞懂索引的底层组织方式和失效条件,你才能避免瞎建索引——尤其是那种在一个表上堆了十几个单列索引、结果写入慢、查询也没变快的经典翻车操作。
这篇内容适合三类人:第一类是Python基础已经学完、开始写真实业务接口的开发者;第二类是负责维护老项目、经常被慢SQL折磨的运维或后端开发者;第三类是纯粹想搞懂“为什么加个索引查询就快了”的求知型选手。不需要你有DBA背景,只要你写过SELECT、JOIN、WHERE这三种语句,这天的内容就完全能跟上。
我更想强调的是,这里讨论的不是一个孤立的技术点。索引优化的思路——用空间换时间、用预处理换查询效率、用合理的结构减少无效计算——这种思维方式在你以后设计缓存策略、拆分服务、优化算法时完全通用。所以别只把Day 43当成一个“索引语法速查”,它其实是你从“写代码”转变成“设计系统”的一道分水岭。
2. 从“全表扫描”说起:索引到底解决了什么问题
2.1 没有索引时,MySQL是怎么找数据的
先想个最朴素的问题:一张表里存了一万条用户记录,你要找到手机号是138xxxx8888的那个人,MySQL在没有索引的情况下是怎么干的?答案是:从头开始,一条一条地读,比对手机号字段,直到找出所有匹配的记录。这个操作在MySQL里叫全表扫描(Full Table Scan)。
全表扫描本身没毛病,关键看表有多大。一万条还能忍,一百万条呢?一千万条呢?每次查询都要把整张表的全部数据页从磁盘捞出来过一遍,哪怕你只需要一条记录。我做过一次不算严谨的测试:在一张600万行数据的表上做无索引等值查询,冷启动状态下耗时接近4秒。同样的查询,加了合适的索引之后,耗时降到30毫秒以内。这个数量级的差距,完全不是Python层面你能通过什么代码技巧弥补回来的。所以问题的本质是:MySQL需要一个类似“目录”的机制,不用翻完整本书就能定位到目标记录所在的页码。这个机制就是索引。
这里涉及一个关键认知:索引不是MySQL执行查询时的可选项,而是数据读取路径的底层加速器。每一条SQL执行前,优化器都会根据统计信息和索引情况决定“走哪条路”去取数据。没有索引,就只有全表扫描一条路;有索引,优化器才可能有别的选择。
2.2 索引的底层组织方式:B+树到底比顺序扫描强在哪
MySQL InnoDB引擎里,主键索引和辅助索引最常用的底层数据结构是B+树。很多人一听B+树就觉得复杂,其实你可以把它想象成一个“多层目录”:最顶层是根节点,往下是内部节点,最底层是叶子节点。关键特性有两个:叶子节点之间通过链表串联,且叶子节点直接存储整行数据(聚簇索引)或者索引字段加主键值(辅助索引)。
B+树的查找过程是典型的二分查找逐层收窄。以查找id=500000为例,假设树的高度是3层,MySQL先从根节点读入一大块索引条目,判断目标范围落在哪一层,再往下走一层,最后抵达叶子节点,在链表里定位到具体位置。整个过程读入磁盘的数据页数量是常数级别的——通常3到4次I/O就能定位任意一行数据,而不是从头扫描几百万条。
这里有个特别重要的设计点:B+树的叶子节点是有序的,而且它们之间是双向链表。这意味着范围查询(比如WHERE age BETWEEN 20 AND 30)只需要找到起点叶节点,然后顺着链表往后读就行了。相比随机访问,顺序读取在磁盘和内存层面都更友好。我在Day 43会专门强调这一点,因为很多人的范围查询慢,往往不是索引没建,而是索引设计让范围扫描没法充分利用B+树的链表特性。
另外,还有一点容易被忽略:B+树为了保持平衡,插入、删除时可能会触发页分裂和页合并。换句话说,索引不是建完就一劳永逸的,频繁写入会让索引页不断变化,碎片增多,查询性能会逐渐退化。这也是为什么即使有索引,也要定期做优化维护——后面我会专门讲。
2.3 聚簇索引和辅助索引:为什么说“回表”是性能杀手
InnoDB里每张表都有一个聚簇索引,也就是主键索引。聚簇索引的叶子节点直接存这一行的完整数据。也就是说,当你用主键查询时,MySQL在B+树里找到叶子节点就等于找到了整行数据,不需要再去别的地方取。这是最高效的查询路径。
当你给某个非主键字段(比如user_name)创建索引时,这个索引属于辅助索引(也叫二级索引)。辅助索引的叶子节点存储的是“索引字段的值 + 主键值”。也就是说,如果你用user_name索引查询,MySQL先在辅助索引的B+树里找到匹配的叶子节点,拿到主键值,然后再拿着主键去聚簇索引的B+树里再查一遍,才能拿到完整行数据。这个过程就叫回表。
回表不是必然的坏事,一共就多一两次磁盘I/O。但如果你需要查出大量数据,比如一个辅助索引匹配了10万行,那就要回表10万次,性能急剧下降。这就是我经常说的“辅助索引不是万能的,要查全字段时得想想怎么避免回表”。最常见的解决方案是覆盖索引:让辅助索引的叶子节点直接包含你要查询的所有字段。比如用户表里有id、user_name、phone三个字段,你建立了(user_name, phone)联合索引,那么SELECT phone FROM table WHERE user_name = 'xxx'根本不需要回表,因为索引叶子节点里已经有你要的phone了。这个技巧,MySQL术语叫Using index,是优化查询的一个非常高效的方案。
回表这个问题,Python开发者经常会忽略,因为ORM框架帮你生成的SQL往往都是SELECT *。我在后面第4小节会说,不要盲目相信ORM默认行为,必要时要手动指定返回列,不仅是为了省流量,更关键的是帮MySQL创造覆盖索引的机会。
3. 索引设计实操:创建索引前要回答的四个关键问题
3.1 第一个问题:你到底要优化哪一条SQL
很多人一上来就是“给这个字段加个索引吧”,完全没搞清这条SQL长什么样、执行路径是怎样的。这是典型的“没看病情先开药”。我在Day 43里反复强调一个原则:索引不是给表建的,是给SQL建的。同样的表,WHERE条件不同、排序方式不同、关联字段不同,需要索引的字段组合完全不同。
举个例子。一张订单表orders,字段有id, user_id, status, created_at, amount。如果你有一个高频查询是“查某个用户最近30天的已完成订单”,那么SQL大概是:
SELECT * FROM orders WHERE user_id = 1001 AND status = 'completed' AND created_at >= '2024-01-01' ORDER BY created_at DESC;这时候直接在user_id上建单列索引,效果有限——因为MySQL还得去过滤status和created_at。更好的方案是建立一个联合索引(user_id, status, created_at),这样B+树先按user_id定位用户,再按status过滤,最后直接利用索引的有序性完成排序,连filesort都省了。这就是所谓“索引匹配最左前缀 + 索引排序”。
所以我的建议是:动手创建索引之前,先把项目里最慢的20条SQL翻出来,逐个分析它们的WHERE条件、ORDER BY字段、JOIN用的关联字段。然后根据高频SQL的模式来设计联合索引,而不是凭空猜。没有慢SQL日志的话,可以从MySQL的performance_schema或慢查询日志开启记录,这方面的配置我第5节会提到。
提示:把慢查询日志打开,
long_query_time先设为1秒,跑个业务高峰期,再回头分析。这是所有索引优化工作的起点,“数据驱动”不仅适用于产品决策,也适用于索引设计。
3.2 第二个问题:联合索引的字段顺序怎么排
联合索引的字段顺序是整个索引设计中最容易翻车的点。联合索引(a, b, c)本质上是先按a排序,a相同再按b排序,b相同再按c排序。这决定了它有两个重要特性:最左前缀原则和索引排序能力。
最左前缀原则的意思是,如果查询条件里不包含最左边的字段,那么这个联合索引基本不会被高效使用。例如(user_id, status, created_at)这个联合索引,可以高效支持WHERE user_id = ?、WHERE user_id = ? AND status = ?、WHERE user_id = ? AND status = ? AND created_at >= ?,但如果你直接写WHERE status = 'completed',MySQL优化器极大概率会放弃这个联合索引,或者只能扫索引但效率很低。
字段顺序怎么排?我的经验是三句话:等值条件的字段放前面,范围条件的字段放后面;选择性高的字段放前面;最常用于排序的字段放在最后或者紧随最后一个等值条件之后。以user_id = 1001 AND status = 'completed' AND created_at >= '2024-01-01' ORDER BY created_at DESC来说,user_id和status是等值条件,放前面,created_at既有范围又有排序,放最后。这样的排列既满足了过滤条件,又让索引天然支持排序。
需要特别说明的是:选择性(区分度)也很重要。如果一个字段只有“男/女”两种值,区分度极低,放在联合索引第一位意义不大——因为它无法快速收窄范围。假如是is_deleted这种标志字段,更是直接别考虑放索引首位。当然,如果查询全是等值条件,区分度低的字段只要放在前面不会影响后续字段的过滤,但这需要衡量确实无法绕开。
3.3 第三个问题:唯一索引、普通索引、前缀索引怎么选
这一节我想集中把三类常见索引的特点说透,因为它们各自的适用场景完全不同。
唯一索引有两个作用:一是约束——保证字段值不重复,防止脏数据;二是加速——它天然是等值查询的利器。如果你的业务本身要求某个字段必须唯一,比如手机号、订单号,那直接建唯一索引,不要先建普通索引再加应用层校验。因为唯一索引是数据库层面的兜底,靠Python代码校验总有漏网之鱼。
普通索引就是没有任何约束的辅助索引,适合不需要纯唯一性的场景。它的查询性能跟唯一索引差别不大,但允许重复值,因此插入时不强制检查唯一性,写入成本略低一点。很多开发者说“这个字段业务上不会重复啊”,但我通常建议不要依赖这种“自定义假设”,该建唯一索引就建,否则后面数据从多个接口汇入时,很容易出现意想不到的重复。
前缀索引则是个容易被忽视的利器。如果字段是长文本,比如用户邮箱user@example.com、或者日志消息,你不可能把整列都塞进索引里——浪费空间、降低I/O效率。这时可以只对前几个字符建索引,比如ALTER TABLE users ADD INDEX idx_email_prefix (email(10))。但要注意,前缀索引也是有代价的:它无法做覆盖索引扫描,因为在索引里存不了完整字段值,查询时仍然需要回表拿真实数据。另外,定前缀长度时要测试区分度,选取一个能覆盖绝大多数数据的前缀长度,否则优化效果会大打折扣。
3.4 第四个问题:一个表建多少索引才合理
说一个我踩过的大坑:早期维护某个后台管理系统的用户表,陆陆续续为每个字段都建了索引,加索引的时候感觉自己是“性能工程师”,结果等业务起来后,用户表写入直接变慢,单条INSERT从1毫秒变成了十几毫秒,并发一高,更是明显变慢。原因很简单:每个索引都是B+树,每次INSERT、UPDATE、DELETE不仅要写数据本身,还要同步维护所有索引树。索引越多,写放大越严重。
那么一个表建多少索引合理?这个没有绝对标准,但我的经验是:单表索引数量控制在5到6个以内,联合索引尽量覆盖高频查询组合;OLTP(在线事务处理)系统的表要精打细算,能不建的索引坚决不建;OLAP(分析型)系统则相反,可以适当多建,因为写入频率低、查询模式复杂。另外,MySQL 8.0中索引是支持不可见索引(invisible index)的,你可以把一个索引设置为不可见,观察查询计划是否变化,用来安全地评估删掉它会不会影响业务。这个特性我强烈推荐用起来,比直接删索引安全得多。
还有一个实用技巧:用SHOW INDEX FROM table_name查看某个表已经存在的索引信息;用SELECT * FROM sys.schema_unused_indexes直接列出从未被使用过的索引。我每次接手新项目都会先跑这条SQL,一堆冗余索引立刻暴露。删掉这些吃灰索引,写入性能往往会有明显提升。
4. Python视角的索引优化:从ORM到原生SQL的实操闭环
4.1 你写的ORM代码,可能正在悄悄坑你
用Python操作MySQL,多数人走的是SQLAlchemy或Django ORM。ORM的好处不用多说,但它最大的问题在于:你很难直观看到生成的SQL到底长什么样,更谈不上针对索引特点去调整。我见过太多人写filter(user_id=1001),觉得这就够了,结果发现ORM生成的SQL里SELECT了所有字段,或者加了一些MySQL无法用索引的写法。
举个例子。Django ORM里如果你做这样的链式调用:
users = User.objects.filter(user_name__startswith="张").order_by("-created_at")ORM生成的SQL可能是WHERE user_name LIKE '张%' ORDER BY created_at DESC。如果user_name上有前缀索引,碰巧覆盖了这个左前缀条件,那查询能用上索引;但如果ORDER BY created_at不在索引里,MySQL还得做一次额外的排序操作。这就是为什么即使是写ORM代码,你也必须能预判底层SQL的形态。
我的建议是:开发阶段打开SQL日志,让框架打印出每一条执行的SQL。SQLAlchemy可以通过echo=True开启日志,Django可以通过配置LOGGING打印django.db.backends的SQL语句。看到真实SQL后,用EXPLAIN分析执行计划,再判断索引是否需要调整。整个过程完全不玄学,就是“看SQL,查计划,调索引”的循环。
4.2 用EXPLAIN读懂MySQL的执行计划
这是整个Day 43里最值钱的技能。拿到一条SQL,不要急着猜,直接在前面加EXPLAIN关键字跑一遍,MySQL会告诉你它的执行计划。重点看以下几列:
type:访问类型。从好到差大致是const、eq_ref、ref、range、index、ALL。看到ALL基本等于全表扫描,必须警惕。key:实际用到的索引名。如果为NULL,说明这条SQL没用索引。rows:预估扫描的行数。这个数字越小越好。Extra:各种附加信息。如果出现Using filesort或Using temporary,说明排序或分组没用到索引,是优化重点;如果出现Using index,说明索引覆盖了查询,不再回表,这是理想状态。
我在教学过程中最喜欢拿一个例子来说事。假设有这样一个查询:
EXPLAIN SELECT user_name, phone FROM users WHERE user_name = '张三' AND status = 1;假设表上有idx_user_name(user_name)和idx_status(status)两个单列索引。MySQL优化器只会选择一个索引来用,另一个就被浪费了。你会发现key只显示idx_user_name,rows可能是一个比较大的值,因为还得靠status = 1逐条过滤。这种情况下,正确的做法是建联合索引(user_name, status),让B+树直接同时过滤两个条件,rows会大幅下降。
需要留意的是,EXPLAIN显示的rows是估算值,不一定精确到每一行,但它足以给你一个相对可靠的判断方向。
注意:EXPLAIN不执行查询,只输出执行计划。真正线上排查时,如果SQL本身很重,请先EXPLAIN,确认没有问题后再去执行真实查询,避免一次慢查询拖垮数据库。
4.3 覆盖索引实战:把回表次数降为零
前面我已经两次提到覆盖索引,这里专门给一个Python场景的实操闭环。
假设你有一个文章表articles(id, author_id, category, title, created_at, content),前端列表页最常见的SQL是:
SELECT id, author_id, title, created_at FROM articles WHERE category = 'python' ORDER BY created_at DESC LIMIT 20 OFFSET 0;如果直接在category上建单列索引,MySQL靠索引定位到category='python'的所有主键,然后每条都要回表取title、created_at,最后再排序。如果这个分类下有几千篇文章,回表几千次,性能堪忧。
更优的方案是建联合索引(category, created_at, id, author_id, title)——注意,不只是过滤字段,连SELECT的字段也一起放进去。这样MySQL可以在索引叶子节点直接取到全部所需字段,不需要任何回表操作。EXPLAIN的Extra会显示Using index,效率极高。
当然,这种“把SELECT字段全部塞进索引”的做法不适合所有查询。比如列表页要展示正文content这个超大字段,你把整个content塞进索引里,索引体积会膨胀到难以接受,还不如老老实实回表。所以我的建议是:覆盖索引用于高频列表页、统计页这类查询字段固定的场景,不要为了“全覆盖”而把核心大字段塞进索引。
4.4 Python脚本批量验证索引效果的完整流程
光说不练假把式。我在Day 43的实操部分会让学生做一个完整的测试闭环,用Python脚本对比加索引前后的查询耗时。这个方法也推荐你在自己的项目里做,整个流程分四步:
第一步,准备数据。用Python脚本往测试表里灌入至少50万到100万行数据,模拟真实业务量级。建议使用INSERT ... ON DUPLICATE KEY UPDATE或者批量插入,否则插入本身就会耗费很久。
第二步,关闭查询缓存并使用SQL_NO_CACHE关键字执行查询,确保每次查询都是真实走磁盘/InnoDB缓冲池,而不是缓存命中。Python代码里用time.perf_counter()计时,重复执行10次取平均值,降低偶然误差。
第三步,不加索引先跑一遍,记录基准时间;然后创建索引,再跑一遍同样的查询,记录优化时间。同时用EXPLAIN对比两次的执行计划,把type、rows、Extra的变化记录下来。
第四步,也是容易被忽略的一步:随机改变查询条件,多测几组不同值,验证索引在全范围内的稳定性。因为如果索引选择性差,可能某些值快、某些值慢,这不利于上线。
我贴一段简化版的测试脚本片段,用的是PyMySQL,思路可以直接迁移到任何ORM:
import time import pymysql conn = pymysql.connect(host="127.0.0.1", user="root", password="***", database="test_db") cursor = conn.cursor() sql = "SELECT SQL_NO_CACHE id, user_name, phone FROM users WHERE user_name = 'user_100'" # 不加索引时先执行一次(预热) cursor.execute(sql) start = time.perf_counter() for _ in range(10): cursor.execute(sql) cursor.fetchall() print("无索引平均耗时:", (time.perf_counter() - start) / 10) # 创建索引 cursor.execute("ALTER TABLE users ADD INDEX idx_user_name (user_name)") conn.commit() # 再加索引后再执行一次 cursor.execute(sql) start = time.perf_counter() for _ in range(10): cursor.execute(sql) cursor.fetchall() print("有索引平均耗时:", (time.perf_counter() - start) / 10)这里有一个容易踩的坑:MySQL有查询缓存时,第二次查询直接命中缓存,耗时记录为0,很多新手看到0就误以为索引无敌。所以一定要用SQL_NO_CACHE,或者把MySQL的query_cache_type配置确认清楚。MySQL 8.0已经彻底移除了查询缓存,反而省了这份操心。
以上内容基于实测经验总结,加索引前后耗时发生数量级变化时,你会真正从心底信任这个机制。
5. 索引失效的隐藏雷区与慢查询排查技巧实录
5.1 那些“加了索引却不生效”的经典操作
索引失效的高频原因,我总结下来基本逃不开这几类,每一条都是我在真实项目里反复碰过或带新人时常提的问题。
第一类:在索引字段上做计算或函数操作。比如WHERE YEAR(created_at) = 2024,哪怕created_at上有索引,MySQL也无法直接利用,因为它需要对每一行的created_at先执行函数计算再比较。正确写法是WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'。还有一种常见写法是WHERE DATE_FORMAT(created_at, '%Y-%m-%d') = '2024-06-01',同样会让索引失效。函数操作是索引失效最普遍的原因之一。
第二类:隐式类型转换。字段是字符串类型,但查询传了整型,或者反过来。比如user_phone是VARCHAR,你写WHERE user_phone = 13812345678,MySQL会先把字段转成数字类型做比较,索引就废了。正确的做法是传字符串'13812345678'。这个问题在Python代码里尤其容易发生,因为Pandas处理数据时数字类型非常容易被当成整型传进去。
第三类:LIKE左模糊查询。WHERE user_name LIKE '%张三%',由于最左前缀原则,MySQL没法从前缀开始定位,B+树优势无从发挥。如果业务确实需要这种模糊搜索,建议引入全文索引(MySQL FULLTEXT),或者用专门的搜索引擎组件,而不是指望普通索引。
第四类:联合索引违反最左前缀。前面已详细说过,这里只补充一个常见场景:ORM生成的查询条件顺序往往跟你的联合索引顺序不一致。但注意,SQL优化器会调整WHERE条件的顺序,所以跟SQL书写顺序无关,关键是“查询里有没有包含联合索引最左边的字段”。只要包含,优化器基本能自适应。
第五类,也是容易被忽略的:对索引列进行了隐式字符集转换。当两个表JOIN时,字段类型相同但字符集不同,MySQL会隐式转换导致索引失效。比如一个表用utf8mb4,另一个表用latin1,关联字段varchar类型时就会出现这种问题。建表时统一字符集,是很多老项目翻车后不得不回头补的功课。
5.2 慢查询日志与性能诊断的联动用法
我很推荐把慢查询日志当作日常工具,而不是出了事故才打开。配置也很简单,在MySQL配置文件里设置:
slow_query_log = ON slow_query_log_file = /var/log/mysql/slow.log long_query_time = 1 log_queries_not_using_indexes = ONlong_query_time = 1表示超过1秒的SQL会被记录。log_queries_not_using_indexes这个参数尤其有用:哪怕SQL执行很快,只要没使用索引,也会被记录到慢日志里。这样我就可以定期扫一眼,找出“没走索引但暂时还不慢”的潜在隐患,在数据量变大之前提前优化。
拿到慢日志文件之后,可以用mysqldumpslow命令做汇总分析,它会帮你把相似的SQL聚合在一起,显示执行次数、平均耗时、总耗时。这样你就能快速定位“哪个SQL模板最值得优化”,而不是盯着几十条散乱的日志看半天。
另外,在Python生态里,你还可以把慢查询日志接入到日志采集系统,按小时或按天统计TOP N慢SQL,配合告警规则。我见过不少团队将这套数据对接到内部监控看板,用趋势图判断索引优化是否有效。不过这一步作为Day 43的延伸就足够了,核心还是先把单条SQL的执行计划读懂。
5.3 常见问题速查表
我把这天的内容浓缩成一张问题速查表,方便你在实际工作中对照排查,更直观一些:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 查询很慢,EXPLAIN显示type=ALL | 没有可用索引或索引被函数/类型转换破坏 | 检查WHERE条件字段是否有索引,重写SQL避免函数操作 |
| 走了索引但rows很大 | 索引选择性差,比如低区分度字段放前面 | 调整联合索引顺序,优先放等值高区分度字段 |
| 出现Using filesort | 排序字段不在索引范围内 | 尝试把ORDER BY字段加入联合索引,或调整联合索引字段顺序 |
| 出现Using temporary | GROUP BY或DISTINCT未能走索引 | 检查分组字段是否满足联合索引最左前缀 |
| 插入和更新变慢 | 冗余索引太多导致写放大 | 用sys.schema_unused_indexes找没用的索引并删除 |
| SQL里LIKE '%xx%' 走不了索引 | 左模糊导致B+树无法定位 | 改用全文索引或外部搜索引擎 |
这张表看起来简单,但我每次接手新项目都会把它打印出来贴显示器旁边。排查慢SQL时先对号入座,效率远高于靠感觉瞎改。
5.4 一个真实场景的排查过程回放
这里分享一个曾经排查过的问题,细节略做脱敏处理,但整体思路完全来自实际经历。
当时某个后台管理的列表页一到月底就卡顿,所有接口都变慢。翻慢日志发现基本都是同一条SQL模板:对订单表按商户编号和创建时间过滤后做分页排序。EXPLAIN一眼看下来,type=ALL,全表扫描,几百秒。表上明明有“商户编号”的单列索引,但优化器就是没用。
原因很微妙:商户编号的选择性太差——头部几个大商户的订单量占到全表的八成。优化器经过统计信息评估后发现,利用这个单列索引扫描还需要回表大量数据,成本甚至比全表扫描还高,于是干脆选择放弃索引。后来我们把联合索引调整为(merchant_id, created_at, status),让大商户的查询能直接在索引里完成排序和过滤,不再大量回表。执行计划从ALL变成range,接口响应时间从原来的秒级降到百毫秒级。
这个例子想说明的很重要的一点:索引是否生效并不只看“有没有建”,而是优化器评估“用这个索引是否划算”。因此统计数据过期时,ANALYZE TABLE刷新一下统计信息,有时能带来立竿见影的效果。我见过有人加索引没用时第一反应是删了重建,其实可能只是统计信息没更新。
MySQL还有一个贴心的能力:8.0引入了EXPLAIN ANALYZE,可以真实执行查询并输出每个步骤的实际耗时和行数。相比普通EXPLAIN的估算值,这个工具在排查“优化器判断有误”的场景时更有说服力。
6. 索引优化的边界:什么时候该放弃索引,引入别的方案
6.1 分页深了和排序多了之后,索引也扛不住
索引不是银弹。特别是分页查询,当页码不断加深时,MySQL的常见实现是LIMIT 100000, 20——它会扫描前100020条数据,然后丢掉前100000条,只返回最后20条。就算有索引,这个扫描量也会让性能直线下降。我曾见过一条本来30毫秒的分页查询,翻到第5000页时直接变成4秒。
处理深分页有几个常规方案:
一是延迟关联。先用覆盖索引快速查到目标行的主键,再通过主键关联原表取完整数据:
SELECT t.* FROM orders t INNER JOIN ( SELECT id FROM orders WHERE status = 'completed' ORDER BY created_at DESC LIMIT 100000, 20 ) tmp ON t.id = tmp.id;子查询里只要id和排序字段,索引基本能覆盖,扫描量比直接取全字段小得多。
二是游标分页(keyset pagination)。不用OFFSET,而是记住上一页最后一条记录的排序字段值,下一页查询直接用WHERE created_at < 上次的值定位。这种方式完全跳过了“扫描并丢弃”的开销,数据量再大也稳定。Python后端做API时非常推荐这种方案,只是前端需要配合改造一下分页交互逻辑。
三是走物化视图或定时汇总表。如果列表页只是需要统计总数或者汇总数据,不要直接查大表,而是把中间结果定时同步到一张小表里,应用层查小表。这就是拿空间换时间,用一个小代价换取稳定的查询性能。
6.2 当索引空间膨胀到难以接受时,考虑什么
索引的本质是冗余存储。如果一个表的某些索引体积已经接近甚至超过数据本身,那就要警惕了。出现这种情况,通常是因为索引字段过长或冗余索引太多。除了删除无用索引、改前缀索引之外,还可以考虑压缩存储,或者把超长文本字段挪到单独的扩展表,只在主表保留短ID关联。
MySQL 8.0支持索引页压缩,但压缩和解压会消耗CPU,不一定适合所有场景。我更推荐从源头控制索引体积:能用数字主键关联就不要用UUID字符串;能用小整型枚举字段就不要用VARCHAR存状态;能缩短前缀长度就缩短。索引越小,单次I/O能加载的索引条目越多,整体查询效率越高。这个道理和“缓存友好性”是一样的。
6.3 从索引优化延伸到缓存层:Python开发者的进阶思路
当你发现索引优化已经到了瓶颈,比如单表数据量实在太大、索引膨胀又太厉害,这时候就该考虑引入缓存层了。Python生态里最常见的方案是Redis缓存热点数据。比如用户信息的查询频率极高,完全可以在第一次从MySQL查出后写入Redis并设置过期时间,后续请求直接打Redis,给MySQL减负。
缓存层用得好的前提是“知道哪些数据是真正的高频热点”,这又回到第一节说的数据驱动思路。不要凭空猜哪个接口热点高,去翻监控、看访问量排行。我常常看到有人一上来就把所有接口全加缓存,结果缓存命中率不到10%,反而引入了数据一致性的复杂度。正确的姿势是:先用索引优化把SQL本身做好,再用缓存把顶级热点的数据库读压力降下来。顺序不能反——如果SQL本身是全表扫描,缓存也只是把问题往后推,而且缓存击穿时数据库照样会被打穿。
这里顺带提一句,在Python中操作Redis、设计缓存过期策略这些内容,我在后面的Day里会系统展开。但索引这部分,至少你应该建立起“MySQL负责持久化与一致性,Redis负责扛读压力”的分工认知。
注意:缓存不是用来掩盖糟糕SQL设计的。先把自己能控制的数据库查询做到最优,再谈缓存,这是性能优化里最基本的职业素养。
7. 这一天的学习路径怎么落实:给自己的项目做一个索引优化小实验
7.1 三步设计实验,亲眼看索引的效果
学到这里,如果你只是读了没动手,那再过两周基本只剩一个模糊印象。我建议你当天就做一个实验闭环,整个流程控制在两小时以内。
第一步,选一张你项目里真实的业务大表,或者创建一个至少50万行的测试表。如果能在真实业务表上做测试,效果更直观——但要记得在测试库操作,不要动生产环境。
第二步,列出这张表最高频的三条查询SQL,分别用EXPLAIN分析执行计划,记录type、rows、Extra。如果条件允许,用Python脚本记录每条SQL的平均执行时间。
第三步,根据SQL模式设计联合索引,建好后重新跑EXPLAIN和计时脚本,对比优化前后的数据。做完之后,把整张表所有索引梳理一遍,尝试用sys.schema_unused_indexes找出无用索引并删除,再对比一次写入性能的差异。
这个实验做完,你对索引的理解会从“死记硬背”变成“刻在骨子里”。我见过太多人问我“联合索引到底有什么用”,只要带他做一遍这个实验,他自己就能回答。
7.2 常用管理SQL汇总
为了方便,我把Day 43涉及的常用SQL整理一下,都是运维排查和索引管理中的高频操作。建议保存到本地笔记里随查随用:
-- 查看某张表的索引信息 SHOW INDEX FROM users; -- 查看数据库中从未被使用过的索引 SELECT * FROM sys.schema_unused_indexes; -- 分析索引效率,刷新统计信息 ANALYZE TABLE users; -- 分析一条SQL的执行计划 EXPLAIN SELECT * FROM users WHERE user_name = '张三'; -- 真实执行并分析每个步骤的实际耗时(MySQL 8.0+) EXPLAIN ANALYZE SELECT * FROM users WHERE user_name = '张三'; -- 创建联合索引 ALTER TABLE users ADD INDEX idx_user_name_status (user_name, status); -- 删除冗余索引 ALTER TABLE users DROP INDEX idx_user_name;有些版本的MySQL默认没开启performance_schema或者sys库,如果sys.schema_unused_indexes查不到,可能需要在配置文件中开启performance_schema = ON。不过大部分MySQL 5.7以上版本默认就已经开了,直接查询基本没问题。
7.3 常见误区:索引建了终身有效
最后想纠正一个非常普遍的思维误区:索引不是建完就完事了。随着数据量增长、查询模式变化、字段值分布变化,原有索引效果会逐渐衰减。我见过一个系统上线时加了索引,跑了两年后查询又开始变慢,结果一查,新业务的SQL模式完全变了,原来的联合索引已经无法覆盖现在的查询条件。这说明运维层面也应有定期索引健康巡检的机制。
最简单的巡检方式:每个月利用慢查询日志和sys.unused_indexes两个来源,清理掉不再使用的索引,同时为新增的慢SQL设计新索引。这个过程不需要DBA,后端开发者完全能自己搞定。只要把它沉淀为固定动作,你管理的数据库会一直保持健康,而不是等报警了才救火。
另外,方案评审阶段也要留个心眼:每次新上功能,必须先确认核心SQL的执行计划,再决定要不要建索引。如果功能还没上线时SQL就出现全表扫描,别指望上线后用户量大了会自动变快,那只会更糟。
写到最后:聊聊我对索引这件事的一点体会
学Python到第43天,把时间花在MySQL索引上,乍看像是一门“偏科”的课。但说实话,我后来写业务接口时感受最深的一点是:性能问题的瓶颈从来不在Python语法层面,而在数据存储和查询路径层面。你把列表推导用出花来,也不如给数据库加一条合适的索引带来的提升明显。
我最初学索引时,犯过的最大错误就是“这个字段总是出现在WHERE里,那就加个索引吧”,结果索引越建越多,反而拖慢了写入。后来才慢慢理解,索引的优雅之处不在“多”,而在“准”——每一棵B+树都应该有它明确服务的SQL模式,多余的结构本身就是负担。这个道理放到系统设计里同样成立,任何冗余的加速手段都该有清晰的收益目标和退出机制。
另外,我建议你把今天学到的这些手段沉淀成一套自己的排查清单:拿到慢SQL先EXPLAIN,看type和rows,再查联合索引匹配情况和回表成本,最后才动手加索引或改SQL。这个过程做多了会变成肌肉记忆,遇到任何数据库性能问题都不慌。Day 43的索引只是整个性能优化大厦的地基,往后的缓存、分区、读写分离,全都建立在这套底层认知之上。地基打牢了,后面的东西自然顺理成章。