MySQL 系列:第13篇 索引,不止是目录

IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我会在各个平台持续发布最新文章,助你少走弯路。


我们已经能熟练地进行增删改查,但当数据量到达百万级后,一条简单的 SELECT 也可能耗时数秒。解决这个问题的核心武器就是索引(Index)。它像书的目录一样,让 MySQL 不用翻遍整本书就能快速定位内容。今天我们用 Python 制造海量数据,亲眼见证索引的神奇加速效果,并掌握最左前缀等核心原则。

1. 准备海量测试数据

先创建一张商品表,然后通过 Python 插入 100 万条数据,为后续的性能对比做铺垫。

bash 复制代码
import mysql.connector
import random
import time

conn = mysql.connector.connect(
    host="127.0.0.1", port=3306,
    user="root", password="MyNewPass123!",
    database="shop"
)
cursor = conn.cursor()

# 创建一张宽表,包含各种类型列
cursor.execute("DROP TABLE IF EXISTS big_products")
cursor.execute("""
CREATE TABLE big_products (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    stock INT DEFAULT 0,
    category VARCHAR(30),
    brand VARCHAR(50),
    created_date DATE,
    INDEX idx_category (category)   -- 先建一个普通索引供后面对比
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")

# 批量插入100万行(实际演示时可先插入10万行以节省时间,这里写100万逻辑但建议读者根据需要调整)
print("⏳ 正在生成 10 万条测试数据(约需30秒)...")
batch_size = 10000
categories = ["电脑外设", "音频设备", "手机配件", "办公用品", "智能家居"]
brands = ["Logi", "Sony", "Apple", "Dell", "HP", "Lenovo", "Samsung", "Xiaomi"]
total = 100000

for i in range(0, total, batch_size):
    data = []
    for j in range(batch_size):
        idx = i + j + 1
        title = f"product_{idx}"
        price = round(random.uniform(10, 5000), 2)
        stock = random.randint(0, 1000)
        category = random.choice(categories)
        brand = random.choice(brands)
        created_date = f"202{random.randint(0,5)}-{random.randint(1,12):02d}-{random.randint(1,28):02d}"
        data.append((title, price, stock, category, brand, created_date))
    cursor.executemany(
        "INSERT INTO big_products (title, price, stock, category, brand, created_date) VALUES (%s,%s,%s,%s,%s,%s)",
        data
    )
    conn.commit()
    print(f"  已插入 {min(i+batch_size, total)}/{total} 条", end="\r")
print("\n✅ 数据插入完毕")

预期输出(时间因机器而异):

bash 复制代码
⏳ 正在生成 10 万条测试数据(约需30秒)...
  已插入 10000/100000 条
  ...
✅ 数据插入完毕

2. 没有索引的查询有多慢

先来体验一下全表扫描的痛苦。我们查询一条具体的商品标题(注意 title 列目前除了主键外没有单独索引)。

bash 复制代码
# 确保查询不使用缓存,每次都是真实磁盘读取
cursor.execute("SET SESSION query_cache_type = OFF")
cursor.execute("SELECT COUNT(*) FROM big_products")
count = cursor.fetchone()[0]
print(f"当前表行数: {count}")

# 无索引查询 title
start = time.time()
cursor.execute("SELECT * FROM big_products WHERE title = 'product_88888'")
result = cursor.fetchone()
elapsed = time.time() - start
print(f"🔍 无索引查询 title='product_88888' 耗时: {elapsed:.4f} 秒")
if result:
    print(f"  结果: {result[1]} 价格 {result[2]}")

预期输出

bash 复制代码
当前表行数: 100000
🔍 无索引查询 title='product_88888' 耗时: 0.1250 秒
  结果: product_88888 价格 2345.67

0.12 秒在 10 万行下似乎还能接受,但如果是千万行,会变成几十秒,这是灾难性的。这背后的原因是全表扫描 :MySQL 一行行地读取所有数据来匹配 title

3. B+Tree 索引原理预览

在创建索引之前,我们先了解 InnoDB 默认的索引结构------B+Tree

可以把 B+Tree 想象成一本《新华字典》的拼音检字表:

  • 非叶子节点只存导航信息(键值和子节点指针)

  • 所有真实数据都存储在叶子节点,且叶子节点之间用双向链表连接,支持范围查询

  • 每个节点通常是一个 16KB 的磁盘页

一张简化流程图:

bash 复制代码
         [50  100]
         /    |    \
   [10,30] [60,80] [120,150]   ← 非叶子节点(索引页)
    / | \    / | \    / | \
   [数据] [数据] [数据] ...    ← 叶子节点(数据页,双向链表)
  • 查找单个值:从根节点开始,逐层二分查找,3-4 次 I/O 即可定位(树高通常 ≤3)

  • 范围查找:找到起始叶子节点后,沿链表顺序扫描,效率极高

为什么不用二叉树? 二叉树在顺序插入时会退化成链表,而 B+Tree 会自动分裂和平衡,保持树的矮胖形态。

4. 创建索引:让查询飞起来

4.1 普通索引(Normal Index)

bash 复制代码
# 创建普通索引
cursor.execute("CREATE INDEX idx_title ON big_products(title)")
print("✅ 普通索引 idx_title 创建成功")

# 再次查询
start = time.time()
cursor.execute("SELECT * FROM big_products WHERE title = 'product_88888'")
result = cursor.fetchone()
elapsed = time.time() - start
print(f"🚀 有索引查询 title='product_88888' 耗时: {elapsed:.4f} 秒")

预期输出

bash 复制代码
✅ 普通索引 idx_title 创建成功
🚀 有索引查询 title='product_88888' 耗时: 0.0012 秒

从 0.12 秒降到 0.001 秒,快了 100 倍!而且数据量越大,差距越悬殊。

4.2 唯一索引(Unique Index)

唯一索引除了加速查询,还强制列值唯一。主键本身就是一种特殊的唯一索引。

bash 复制代码
# 创建唯一索引(假设 title 应该唯一)
cursor.execute("DROP INDEX idx_title ON big_products")
cursor.execute("CREATE UNIQUE INDEX uk_title ON big_products(title)")
print("✅ 唯一索引 uk_title 创建成功")

# 尝试插入重复值
try:
    cursor.execute("INSERT INTO big_products (title, price, category) VALUES ('product_88888', 99, 'test')")
    conn.commit()
except mysql.connector.IntegrityError as e:
    print(f"❌ 违反唯一约束: {e}")

4.3 前缀索引(Prefix Index)

对于很长的字符串列(如 TEXT、长 VARCHAR),为整个字符串建索引会浪费大量空间。前缀索引只取前 N 个字符建索引,大大节省空间,但会降低区分度。

bash 复制代码
# 先看字符串长度的分布
cursor.execute("SELECT MAX(CHAR_LENGTH(title)), AVG(CHAR_LENGTH(title)) FROM big_products")
max_len, avg_len = cursor.fetchone()
print(f"title 最大长度: {max_len}, 平均长度: {avg_len}")

# 测试不同前缀长度的区分度
for prefix_len in [5, 10, 15, 20]:
    cursor.execute(f"""
    SELECT COUNT(DISTINCT LEFT(title, {prefix_len})) / COUNT(*) 
    FROM big_products
    """)
    selectivity = cursor.fetchone()[0]
    print(f"  前缀 {prefix_len} 区分度: {selectivity:.4f}")

预期输出

bash 复制代码
title 最大长度: 15, 平均长度: 11.0000
  前缀 5 区分度: 0.9999
  前缀 10 区分度: 1.0000
  前缀 15 区分度: 1.0000
  前缀 20 区分度: 1.0000

这里因为我们的 title 是 product_数字,前缀 10 已经基本唯一。实际业务中需要在区分度和空间之间权衡,通常区分度 > 0.9 即可。

bash 复制代码
# 创建前缀索引
cursor.execute("CREATE INDEX idx_title_prefix ON big_products(title(10))")
print("✅ 前缀索引 idx_title_prefix(10) 创建成功")

使用限制 :前缀索引无法用于 ORDER BYGROUP BY,也无法作为覆盖索引(后续会讲)。

5. 最左前缀原则:联合索引的核心法则

实际查询中,我们经常需要按多个条件筛选,例如:WHERE category = '手机配件' AND brand = 'Apple'。这时就需要联合索引

5.1 创建联合索引

bash 复制代码
cursor.execute("CREATE INDEX idx_cat_brand ON big_products(category, brand)")
print("✅ 联合索引 idx_cat_brand(category, brand) 创建成功")

5.2 最左前缀原则

联合索引的 B+Tree 先按第一列排序,第一列相同再按第二列排序,以此类推。这决定了它只能从最左列开始匹配,不能跳过前面的列直接用后面的列

用 Python 验证哪些查询能用上这个索引:

bash 复制代码
def explain_query(sql):
    cursor.execute(f"EXPLAIN {sql}")
    result = cursor.fetchall()
    # 简化输出:表名、type、key
    for row in result:
        print(f"  表: {row[0]}, type: {row[1]}, key: {row[5]}, Extra: {row[9]}")

print("🔍 条件 category='手机配件' AND brand='Apple' (全匹配):")
explain_query("SELECT * FROM big_products WHERE category='手机配件' AND brand='Apple'")

print("\n🔍 条件 category='手机配件' (只匹配最左列):")
explain_query("SELECT * FROM big_products WHERE category='手机配件'")

print("\n🔍 条件 brand='Apple' (跳过最左列):")
explain_query("SELECT * FROM big_products WHERE brand='Apple'")

print("\n🔍 条件 category LIKE '手机%' AND brand='Apple' (最左列范围):")
explain_query("SELECT * FROM big_products WHERE category LIKE '手机%' AND brand='Apple'")

print("\n🔍 条件 category='手机配件' ORDER BY brand (排序):")
explain_query("SELECT * FROM big_products WHERE category='手机配件' ORDER BY brand")

预期输出(关键看 key 列):

bash 复制代码
🔍 条件 category='手机配件' AND brand='Apple' (全匹配):
  表: big_products, type: ref, key: idx_cat_brand, Extra: Using index condition

🔍 条件 category='手机配件' (只匹配最左列):
  表: big_products, type: ref, key: idx_cat_brand, Extra: Using index condition

🔍 条件 brand='Apple' (跳过最左列):
  表: big_products, type: ALL, key: None, Extra: Using where    ← 全表扫描!

🔍 条件 category LIKE '手机%' AND brand='Apple' (最左列范围):
  表: big_products, type: range, key: idx_cat_brand, Extra: Using index condition

🔍 条件 category='手机配件' ORDER BY brand (排序):
  表: big_products, type: ref, key: idx_cat_brand, Extra: Using index condition

结论

  • category = ? AND brand = ? ✅ 全匹配,走索引

  • category = ? ✅ 走索引的最左列

  • brand = ? ❌ 不走索引(跳过了 category)

  • category LIKE '手机%' AND brand = ? ✅ 范围后的等值也走索引

  • category = ? ORDER BY brand ✅ 索引本身已按 brand 排序,无需额外排序

这就是最左前缀原则------联合索引像多层目录,必须先定位到第一层,才能继续深入。

6. 索引的代价:写入变慢

索引不是免费的午餐。每个索引都会占用额外的磁盘空间,并且在 INSERT/UPDATE/DELETE 时需要同步维护索引结构。

bash 复制代码
# 比较有多个索引时的插入速度
cursor.execute("DROP INDEX idx_cat_brand ON big_products")
cursor.execute("DROP INDEX idx_title_prefix ON big_products")

# 无额外索引时插入 1 万条
start = time.time()
data = [("perf_test", 99, 10, "test", "test", "2025-01-01") for _ in range(10000)]
cursor.executemany("INSERT INTO big_products (title, price, stock, category, brand, created_date) VALUES (%s,%s,%s,%s,%s,%s)", data)
conn.commit()
print(f"无额外索引插入 1 万条耗时: {time.time() - start:.3f} 秒")

# 重建索引后再插入
cursor.execute("CREATE INDEX idx_cat_brand ON big_products(category, brand)")
cursor.execute("CREATE INDEX idx_title_prefix ON big_products(title(10))")

start = time.time()
data = [("perf_test2", 99, 10, "test", "test", "2025-01-01") for _ in range(10000)]
cursor.executemany("INSERT INTO big_products (title, price, stock, category, brand, created_date) VALUES (%s,%s,%s,%s,%s,%s)", data)
conn.commit()
print(f"有额外索引插入 1 万条耗时: {time.time() - start:.3f} 秒")

预期输出

bash 复制代码
无额外索引插入 1 万条耗时: 1.234 秒
有额外索引插入 1 万条耗时: 2.567 秒

索引越多,写入越慢。因此要按需创建,避免冗余索引。

7. Python 封装:自动分析索引使用情况

bash 复制代码
class IndexAnalyzer:
    def __init__(self, conn):
        self.conn = conn
        self.cursor = conn.cursor()
    
    def unused_indexes(self, db='shop'):
        """找出从未使用的索引"""
        sql = f"""
        SELECT object_schema, object_name, index_name 
        FROM performance_schema.table_io_waits_summary_by_index_usage
        WHERE index_name IS NOT NULL
          AND count_star = 0
          AND object_schema = '{db}'
        ORDER BY object_name, index_name
        """
        self.cursor.execute(sql)
        return self.cursor.fetchall()

# 使用
analyzer = IndexAnalyzer(conn)
unused = analyzer.unused_indexes()
if unused:
    print("⚠️ 从未使用的索引:")
    for row in unused:
        print(f"  {row[0]}.{row[1]} -> {row[2]}")
else:
    print("✅ 所有索引都有被使用")

8. 动手试试:优化你的查询

基于 big_products 表,完成以下练习:

  1. 创建联合索引 (category, brand, price),然后测试以下查询是否走索引:

    • WHERE category = '手机配件' AND brand = 'Apple' AND price > 100

    • WHERE category = '手机配件' AND price > 100

    • WHERE brand = 'Apple' AND price > 100

  2. 使用 EXPLAIN 查看执行计划 ,观察 typekeyrows 列的变化。

  3. 尝试使用 FORCE INDEX 强制使用某个索引,对比和优化器自动选择的差异。

  4. 思考 :如果经常需要按 created_datecategory 联合查询,索引顺序应该是 (created_date, category) 还是 (category, created_date)?为什么?

参考提示 :第 2 题可以用 EXPLAIN SELECT ... 直接在 Python 中执行并打印结果。

9. 总结

今天我们真正走进了索引的世界:

  • B+Tree 原理:矮胖树、叶子节点链表、二分查找

  • 索引类型:普通索引、唯一索引、前缀索引

  • 最左前缀原则:联合索引必须从第一列开始,不能跳列

  • 代价:索引加速查询但拖慢写入,占用空间

索引是数据库优化的第一利器,但绝不是"给每一列都建索引"这么简单。下一篇我们将学习事务与锁入门,理解 InnoDB 如何保证并发下的数据一致性。下次见!

想了解更多还可以去各个平台搜索「IT策士」,一起升级 IT 思维 !

相关推荐
长栎1 小时前
Lombok @Builder 越用越爽,直到生产上构造函数的参数顺序全乱了
后端
长栎1 小时前
Spring 的 prototype scope 你用对了吗?原型模式的三个正确打开方式
后端
云技纵横1 小时前
Gap Lock 死锁实战:5 秒在本地复现 MySQL 间隙锁死锁
后端·mysql
XovH1 小时前
MySQL 系列:第12篇 用户、权限与安全基础
后端
张居邪1 小时前
GitHub Actions + 阿里云 OSS:OIDC 免密同步构建产物
后端·github
砍材农夫2 小时前
python环境|conda安装和使用(2)
后端·python
MacroZheng2 小时前
Claude Code官方桌面端正式发布,夯爆了!
java·人工智能·后端
IT_陈寒2 小时前
React的useEffect依赖数组把我坑惨了,真相其实很简单
前端·人工智能·后端