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 BY 和 GROUP 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 表,完成以下练习:
-
创建联合索引
(category, brand, price),然后测试以下查询是否走索引:-
WHERE category = '手机配件' AND brand = 'Apple' AND price > 100 -
WHERE category = '手机配件' AND price > 100 -
WHERE brand = 'Apple' AND price > 100
-
-
使用
EXPLAIN查看执行计划 ,观察type、key、rows列的变化。 -
尝试使用
FORCE INDEX强制使用某个索引,对比和优化器自动选择的差异。 -
思考 :如果经常需要按
created_date和category联合查询,索引顺序应该是(created_date, category)还是(category, created_date)?为什么?
参考提示 :第 2 题可以用 EXPLAIN SELECT ... 直接在 Python 中执行并打印结果。
9. 总结
今天我们真正走进了索引的世界:
-
B+Tree 原理:矮胖树、叶子节点链表、二分查找
-
索引类型:普通索引、唯一索引、前缀索引
-
最左前缀原则:联合索引必须从第一列开始,不能跳列
-
代价:索引加速查询但拖慢写入,占用空间
索引是数据库优化的第一利器,但绝不是"给每一列都建索引"这么简单。下一篇我们将学习事务与锁入门,理解 InnoDB 如何保证并发下的数据一致性。下次见!
想了解更多还可以去各个平台搜索「IT策士」,一起升级 IT 思维 !