📌 本文由 49 篇相关文章智能合并整理而成
Elasticsearch 核心原理——从倒排索引到分布式搜索
Elasticsearch 核心原理——从倒排索引到分布式搜索
一、引言
Elasticsearch 是基于 Apache Lucene 构建的分布式搜索和分析引擎,也是目前最流行的全文检索引擎之一。它以 RESTful API、实时搜索、分布式架构和强大的聚合分析能力著称,被广泛应用于日志分析、全文搜索、安全监控和商业智能等领域。
本文将深入 Elasticsearch 的核心原理,从底层的倒排索引机制、分词与映射,到上层的集群架构、分片机制和写入查询流程,最后通过实战案例展示 Java 集成的最佳实践。
二、倒排索引原理
2.1 什么是倒排索引
传统关系型数据库使用正向索引(正排索引),即文档到词的映射;而倒排索引(Inverted Index)则是词到文档的映射。这种数据结构使得全文搜索极为高效。
graph LR
subgraph Forward[正排索引 Document→Term]
D1["Doc1: Elasticsearch is fast"] --> T1["Elasticsearch, is, fast"]
D2["Doc2: Fast search engine"] --> T2["Fast, search, engine"]
end
subgraph Inverted[倒排索引 Term→Document]
T1x["elasticsearch"] --> D1
T2x["fast"] --> D1
T2x --> D2
T3x["search"] --> D2
T4x["engine"] --> D2
T5x["is"] --> D1
end
2.2 Lucene 倒排索引结构
Lucene 的倒排索引由以下部分组成:
词典(Term Dictionary):存储所有不重复的词项,以有序的方式组织,支持二分查找。
倒排列表(Posting List):记录每个词项出现的文档 ID 列表和词频信息。
倒排表(Posting Entry):每个条目包含:
– 文档 ID(Doc ID)
– 词频(Term Frequency, TF)
– 位置信息(Position)
– 偏移量(Offset)
// Lucene 索引写入的简化表示
public class InvertedIndexExample {
static class TermEntry {
int docId;
int frequency;
List<Integer> positions;
int startOffset;
int endOffset;
}
static Map<String, List<TermEntry>> invertedIndex = new HashMap<>();
public static void addDocument(int docId, String content) {
String[] tokens = content.toLowerCase().split("\\W+");
Map<String, List<Integer>> tempMap = new HashMap<>();
for (int pos = 0; pos < tokens.length; pos++) {
tempMap.computeIfAbsent(tokens[pos], k -> new ArrayList<>()).add(pos);
}
for (Map.Entry<String, List<Integer>> entry : tempMap.entrySet()) {
TermEntry te = new TermEntry();
te.docId = docId;
te.frequency = entry.getValue().size();
te.positions = entry.getValue();
invertedIndex.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).add(te);
}
}
}
2.3 压缩与优化技术
Elasticsearch 使用多种技术优化倒排索引的存储和查询性能:
Frame Of Reference (FOR):将递增的 Doc ID 序列编码为差值(Delta),再使用按位压缩。假设 Doc ID 序列为 [1, 3, 5, 9, 15, 30],差值编码后为 [1, 2, 2, 4, 6, 15]。
Roaring Bitmaps:用于高效存储和计算大量文档集的交集与并集。当文档数较少时使用数组容器(Array Container),超过 4096 个时使用位图容器(Bitmap Container)。
三、分词器与映射
3.1 分词器(Analyzer)
Analyzer 由三部分组成:
– Character Filters:字符过滤(如去除 HTML 标签)
– Tokenizer:分词器(如标准分词、IK 分词)
– Token Filters:词项过滤器(如小写转换、停用词、同义词)
PUT /my_index
{
"settings": {
"analysis": {
"analyzer": {
"ik_smart_analyzer": {
"type": "custom",
"char_filter": ["html_strip"],
"tokenizer": "ik_smart",
"filter": ["lowercase", "asciifolding"]
}
},
"tokenizer": {
"comma_tokenizer": {
"type": "pattern",
"pattern": ","
}
}
}
}
}
常用分词器对比
| 分词器 | 类型 | 适用场景 | 示例:”Elasticsearch是一个搜索引擎” |
|---|---|---|---|
| Standard | 内置 | 英文文本 | elasticsearch, 是, 一, 个, 搜, 索, 引, 擎 |
| IK Smart | 插件 | 中文智能切分 | elasticsearch, 是, 一个, 搜索引擎 |
| IK Max Word | 插件 | 最细粒度切分 | elasticsearch, 是, 一个, 搜索, 引擎 |
| ICU | 插件 | 多语言文本 | 依赖 ICU 分词规则 |
3.2 映射(Mapping)
Mapping 定义索引中的字段类型和索引方式,类似于数据库中的表结构定义:
PUT /my_index/_mapping
{
"properties": {
"title": {
"type": "text",
"analyzer": "ik_smart",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"price": {
"type": "double"
},
"created_at": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
},
"tags": {
"type": "keyword"
},
"description": {
"type": "text",
"analyzer": "standard"
},
"location": {
"type": "geo_point"
}
}
}
关键字段类型:
– text:全文索引,被分词,支持模糊搜索
– keyword:精确值,不分词,用于排序和聚合
– date:日期类型,支持多种格式
– geo_point:地理坐标点
– nested:嵌套对象类型
四、集群架构与分片机制
4.1 集群拓扑
graph TD
subgraph Cluster[ES Cluster - 产品搜索集群]
M1[Node 1<br/>Master + Data]
M2[Node 2<br/>Master + Data]
M3[Node 3<br/>Master + Data]
C1[Node 4<br/>Coordinating Only]
C2[Node 5<br/>Coordinating + Ingest]
end
Client1[Client API] --> C1
Client2[Client API] --> C2
C1 --> M1
C1 --> M2
C2 --> M2
C2 --> M3
M1 --- M2 --- M3
节点类型:
| 节点角色 | 配置 | 职责 |
|---|---|---|
| Master-eligible | node.roles: [master] |
集群元数据管理、索引创建/删除 |
| Data | node.roles: [data] |
数据存储、CRUD、搜索聚合 |
| Coordinating | node.roles: [] |
请求路由、结果聚合 |
| Ingest | node.roles: [ingest] |
文档预处理(Pipeline) |
4.2 分片与副本机制
分片是 ES 数据分布的最小单元,每个索引可拆分为多个分片:
PUT /products
{
"settings": {
"number_of_shards": 5,
"number_of_replicas": 1
}
}
分片路由:
shard = hash(routing_value) % number_of_primary_shards
// 默认 routing_value = _id,可自定义
副本分片:
– 每个主分片可以有零个或多个副本
– 副本不写入相同节点(防止单点故障)
– 副本可处理读请求,提升查询吞吐量
4.3 水平扩展
当数据量增长时,可通过两个方式扩展:
1. 增加分片数(仅索引创建时设定)
2. 增加节点数(自动重平衡)
注意:基于 _id 的路由方式决定了主分片数在索引创建后不可更改。
五、写入与查询流程
5.1 写入流程
sequenceDiagram
participant Client
participant Coord as Coordinating Node
participant Primary as Primary Shard
participant Replica as Replica Shard
participant Translog as Translog
Client->>Coord: Index Request
Coord->>Coord: Route to shard
Coord->>Primary: Forward request
Primary->>Primary: Write to Lucene Index
Primary->>Primary: Write to Translog (fsync)
Primary->>Translog: Persist
Primary-->>Coord: Success
par Replicate
Primary->>Replica: Replication request
Replica->>Replica: Write + Translog
Replica-->>Primary: Success
end
Coord-->>Client: 201 Created
写入关键参数:
– index.refresh_interval:控制 refresh 频率(默认 1s),值越小搜索越实时但开销越大
– index.translog.durability:request(每次请求 fsync)或 async(定期 fsync)
– index.translog.sync_interval:异步 fsync 间隔(默认 5s)
5.2 查询流程
ES 的搜索分为两个阶段:
Query Phase(查询阶段)
1. 协调节点将请求广播到所有相关分片
2. 每个分片本地搜索,返回 Top N 结果的文档 ID 和排序分数
3. 协调节点合并排序,选出最终的 Top N
Fetch Phase(取回阶段)
1. 协调节点向对应分片请求完整文档
2. 分片返回文档的 _source 字段
3. 协调节点构建最终响应
// 搜索查询
GET /products/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "手机" } }
],
"filter": [
{ "range": { "price": { "gte": 1000, "lte": 5000 } } }
]
}
},
"sort": [
{ "_score": "desc" },
{ "created_at": "desc" }
],
"from": 0,
"size": 20,
"aggs": {
"brand_terms": {
"terms": { "field": "brand.keyword" }
},
"price_stats": {
"stats": { "field": "price" }
}
}
}
5.3 深度分页问题
from + size 超过 10000 时会引发性能问题:
// ❌ 不推荐:深度分页
GET /products/_search?from=10000&size=10
// ✅ 推荐:Search After
GET /products/_search
{
"size": 10,
"sort": [
{ "price": "asc" },
{ "_id": "asc" }
],
"search_after": [2999, "product_12345"]
}
// ✅ 推荐:Scroll(批量导出,不维护实时性)
POST /products/_scroll?scroll=5m
{
"size": 1000,
"query": { "match_all": {} }
}
六、Java 集成 Elasticsearch
6.1 客户端配置
co.elastic.clients
elasticsearch-java
8.12.0
com.fasterxml.jackson.core
jackson-databind
2.17.0
@Configuration
public class ElasticsearchConfig {
@Value("${elasticsearch.hosts:localhost:9200}")
private String[] hosts;
@Bean
public ElasticsearchClient esClient() {
RestClient restClient = RestClient.builder(
Arrays.stream(hosts)
.map(h -> {
String[] parts = h.split(":");
return new HttpHost(parts[0], Integer.parseInt(parts[1]), "http");
})
.toArray(HttpHost[]::new)
).build();
ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
return new ElasticsearchClient(transport);
}
}
6.2 CRUD 操作
@Service
public class ProductSearchService {
@Autowired
private ElasticsearchClient esClient;
private static final String INDEX = "products";
// 索引文档
public void indexProduct(Product product) throws IOException {
IndexResponse response = esClient.index(i -> i
.index(INDEX)
.id(product.getId().toString())
.document(product)
);
}
// 批量索引
public void bulkIndex(List<Product> products) throws IOException {
BulkRequest.Builder br = new BulkRequest.Builder();
for (Product p : products) {
br.operations(op -> op
.index(idx -> idx
.index(INDEX)
.id(p.getId().toString())
.document(p)
)
);
}
esClient.bulk(br.build());
}
// 搜索
public SearchResult<Product> search(String keyword, int page, int size) throws IOException {
SearchResponse<Product> response = esClient.search(s -> s
.index(INDEX)
.query(q -> q
.bool(b -> b
.must(m -> m.match(t -> t.field("title").query(keyword)))
.filter(f -> f.term(t -> t.field("status").value("active")))
)
)
.from((page - 1) * size)
.size(size)
.sort(so -> so.field(f -> f.field("price").order(SortOrder.Asc)))
, Product.class);
List<Product> products = response.hits().hits().stream()
.map(Hit::source)
.collect(Collectors.toList());
long total = response.hits().total().value();
return new SearchResult<>(products, total, page, size);
}
// 聚合分析
public Map<String, Long> aggregateByBrand() throws IOException {
SearchResponse<Void> response = esClient.search(s -> s
.index(INDEX)
.size(0)
.aggregations("by_brand", a -> a
.terms(t -> t.field("brand.keyword").size(20))
), Void.class);
return response.aggregations().get("by_brand").sterms().buckets().array()
.stream()
.collect(Collectors.toMap(
b -> b.key().stringValue(),
b -> b.docCount()
));
}
}
6.3 Spring Data Elasticsearch
@Document(indexName = "products")
public class Product {
@Id
private Long id;
@Field(type = FieldType.Text, analyzer = "ik_smart")
private String title;
@Field(type = FieldType.Text)
private String description;
@Field(type = FieldType.Double)
private BigDecimal price;
@Field(type = FieldType.Keyword)
private String brand;
@Field(type = FieldType.Date)
private LocalDateTime createdAt;
}
public interface ProductRepository extends ElasticsearchRepository<Product, Long> {
// 自动派生查询
List<Product> findByTitleContaining(String keyword);
List<Product> findByBrandAndPriceBetween(String brand, Double min, Double max);
// 自定义查询(需要实现)
@Query("{\"match\": {\"title\": {\"query\": \"?0\"}}}")
List<Product> searchByTitle(String keyword);
}
七、性能调优实践
7.1 索引优化
| 优化项 | 操作 | 效果 |
|---|---|---|
| 批量写入 | bulk API,每批 1000-5000 条 |
吞吐量提升 10-50 倍 |
| 增加 Refresh 间隔 | refresh_interval: 30s |
写入性能提升,牺牲实时性 |
| 禁用副本 | 写入前设置 number_of_replicas: 0 |
提升写入速度 30-50% |
| 使用 SSD | 数据盘使用 NVMe SSD | 索引和搜索性能显著提升 |
| 合理分片 | 单分片 10-50GB | 避免过多或过少分片 |
7.2 查询优化
// ✅ 禁用 Scoring(Filter Context)
SearchResponse> response = esClient.search(s -> s
.query(q -> q.bool(b -> b
.filter(f -> f.term(t -> t.field("status.keyword").value("published")))
.filter(f -> f.range(r -> r.field("price").gte(JsonData.of(100))))
))
.trackScores(false) // 禁用评分计算
);
// ✅ 指定字段返回
s.source(src -> src.filter(f -> f.includes("id", "title", "price")));
// ✅ 使用 Profile API 分析慢查询
POST /_search
{
"profile": true,
"query": { ... }
}
八、总结
Elasticsearch 的强大根植于其精妙的底层设计。倒排索引使其全文搜索效率远超传统数据库,分片与副本机制提供了水平扩展能力,而近实时的 Refresh 策略在写入性能与查询实时性之间取得了巧妙的平衡。
核心要点:
1. 倒排索引是搜索的基石,Lucene 的 FOR 和 Roaring Bitmaps 等优化技术使其极致高效
2. 分词器决定了搜索质量,中文场景推荐 IK 分词器
3. 集群架构中 Master、Data、Coordinating 节点的合理规划决定了系统的稳定性
4. 写入流程中 Translog 和 Refresh 机制反映了近实时搜索的权衡设计
5. 查询流程的 Query-Fetch 两阶段设计适用于分布式搜索场景
在实际工程中,性能问题的根源往往不是 ES 本身,而是不合理的索引设计、错误的字段映射和缺乏规划的集群拓扑。理解了底层原理,才能在系统设计时做出正确的取舍。
Elasticsearch 核心原理——从倒排索引到分布式搜索
Elasticsearch 核心原理——从倒排索引到分布式搜索
一、引言
Elasticsearch 是基于 Apache Lucene 构建的分布式搜索和分析引擎,也是目前最流行的全文检索引擎之一。它以 RESTful API、实时搜索、分布式架构和强大的聚合分析能力著称,被广泛应用于日志分析、全文搜索、安全监控和商业智能等领域。
本文将深入 Elasticsearch 的核心原理,从底层的倒排索引机制、分词与映射,到上层的集群架构、分片机制和写入查询流程,最后通过实战案例展示 Java 集成的最佳实践。
二、倒排索引原理
2.1 什么是倒排索引
传统关系型数据库使用正向索引(正排索引),即文档到词的映射;而倒排索引(Inverted Index)则是词到文档的映射。这种数据结构使得全文搜索极为高效。
graph LR
subgraph Forward[正排索引 Document→Term]
D1["Doc1: Elasticsearch is fast"] --> T1["Elasticsearch, is, fast"]
D2["Doc2: Fast search engine"] --> T2["Fast, search, engine"]
end
subgraph Inverted[倒排索引 Term→Document]
T1x["elasticsearch"] --> D1
T2x["fast"] --> D1
T2x --> D2
T3x["search"] --> D2
T4x["engine"] --> D2
T5x["is"] --> D1
end
2.2 Lucene 倒排索引结构
Lucene 的倒排索引由以下部分组成:
词典(Term Dictionary):存储所有不重复的词项,以有序的方式组织,支持二分查找。
倒排列表(Posting List):记录每个词项出现的文档 ID 列表和词频信息。
倒排表(Posting Entry):每个条目包含:
– 文档 ID(Doc ID)
– 词频(Term Frequency, TF)
– 位置信息(Position)
– 偏移量(Offset)
// Lucene 索引写入的简化表示
public class InvertedIndexExample {
static class TermEntry {
int docId;
int frequency;
List<Integer> positions;
int startOffset;
int endOffset;
}
static Map<String, List<TermEntry>> invertedIndex = new HashMap<>();
public static void addDocument(int docId, String content) {
String[] tokens = content.toLowerCase().split("\\W+");
Map<String, List<Integer>> tempMap = new HashMap<>();
for (int pos = 0; pos < tokens.length; pos++) {
tempMap.computeIfAbsent(tokens[pos], k -> new ArrayList<>()).add(pos);
}
for (Map.Entry<String, List<Integer>> entry : tempMap.entrySet()) {
TermEntry te = new TermEntry();
te.docId = docId;
te.frequency = entry.getValue().size();
te.positions = entry.getValue();
invertedIndex.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).add(te);
}
}
}
2.3 压缩与优化技术
Elasticsearch 使用多种技术优化倒排索引的存储和查询性能:
Frame Of Reference (FOR):将递增的 Doc ID 序列编码为差值(Delta),再使用按位压缩。假设 Doc ID 序列为 [1, 3, 5, 9, 15, 30],差值编码后为 [1, 2, 2, 4, 6, 15]。
Roaring Bitmaps:用于高效存储和计算大量文档集的交集与并集。当文档数较少时使用数组容器(Array Container),超过 4096 个时使用位图容器(Bitmap Container)。
三、分词器与映射
3.1 分词器(Analyzer)
Analyzer 由三部分组成:
– Character Filters:字符过滤(如去除 HTML 标签)
– Tokenizer:分词器(如标准分词、IK 分词)
– Token Filters:词项过滤器(如小写转换、停用词、同义词)
PUT /my_index
{
"settings": {
"analysis": {
"analyzer": {
"ik_smart_analyzer": {
"type": "custom",
"char_filter": ["html_strip"],
"tokenizer": "ik_smart",
"filter": ["lowercase", "asciifolding"]
}
},
"tokenizer": {
"comma_tokenizer": {
"type": "pattern",
"pattern": ","
}
}
}
}
}
常用分词器对比
| 分词器 | 类型 | 适用场景 | 示例:”Elasticsearch是一个搜索引擎” |
|---|---|---|---|
| Standard | 内置 | 英文文本 | elasticsearch, 是, 一, 个, 搜, 索, 引, 擎 |
| IK Smart | 插件 | 中文智能切分 | elasticsearch, 是, 一个, 搜索引擎 |
| IK Max Word | 插件 | 最细粒度切分 | elasticsearch, 是, 一个, 搜索, 引擎 |
| ICU | 插件 | 多语言文本 | 依赖 ICU 分词规则 |
3.2 映射(Mapping)
Mapping 定义索引中的字段类型和索引方式,类似于数据库中的表结构定义:
PUT /my_index/_mapping
{
"properties": {
"title": {
"type": "text",
"analyzer": "ik_smart",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"price": {
"type": "double"
},
"created_at": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
},
"tags": {
"type": "keyword"
},
"description": {
"type": "text",
"analyzer": "standard"
},
"location": {
"type": "geo_point"
}
}
}
关键字段类型:
– text:全文索引,被分词,支持模糊搜索
– keyword:精确值,不分词,用于排序和聚合
– date:日期类型,支持多种格式
– geo_point:地理坐标点
– nested:嵌套对象类型
四、集群架构与分片机制
4.1 集群拓扑
graph TD
subgraph Cluster[ES Cluster - 产品搜索集群]
M1[Node 1<br/>Master + Data]
M2[Node 2<br/>Master + Data]
M3[Node 3<br/>Master + Data]
C1[Node 4<br/>Coordinating Only]
C2[Node 5<br/>Coordinating + Ingest]
end
Client1[Client API] --> C1
Client2[Client API] --> C2
C1 --> M1
C1 --> M2
C2 --> M2
C2 --> M3
M1 --- M2 --- M3
节点类型:
| 节点角色 | 配置 | 职责 |
|---|---|---|
| Master-eligible | node.roles: [master] |
集群元数据管理、索引创建/删除 |
| Data | node.roles: [data] |
数据存储、CRUD、搜索聚合 |
| Coordinating | node.roles: [] |
请求路由、结果聚合 |
| Ingest | node.roles: [ingest] |
文档预处理(Pipeline) |
4.2 分片与副本机制
分片是 ES 数据分布的最小单元,每个索引可拆分为多个分片:
PUT /products
{
"settings": {
"number_of_shards": 5,
"number_of_replicas": 1
}
}
分片路由:
shard = hash(routing_value) % number_of_primary_shards
// 默认 routing_value = _id,可自定义
副本分片:
– 每个主分片可以有零个或多个副本
– 副本不写入相同节点(防止单点故障)
– 副本可处理读请求,提升查询吞吐量
4.3 水平扩展
当数据量增长时,可通过两个方式扩展:
1. 增加分片数(仅索引创建时设定)
2. 增加节点数(自动重平衡)
注意:基于 _id 的路由方式决定了主分片数在索引创建后不可更改。
五、写入与查询流程
5.1 写入流程
sequenceDiagram
participant Client
participant Coord as Coordinating Node
participant Primary as Primary Shard
participant Replica as Replica Shard
participant Translog as Translog
Client->>Coord: Index Request
Coord->>Coord: Route to shard
Coord->>Primary: Forward request
Primary->>Primary: Write to Lucene Index
Primary->>Primary: Write to Translog (fsync)
Primary->>Translog: Persist
Primary-->>Coord: Success
par Replicate
Primary->>Replica: Replication request
Replica->>Replica: Write + Translog
Replica-->>Primary: Success
end
Coord-->>Client: 201 Created
写入关键参数:
– index.refresh_interval:控制 refresh 频率(默认 1s),值越小搜索越实时但开销越大
– index.translog.durability:request(每次请求 fsync)或 async(定期 fsync)
– index.translog.sync_interval:异步 fsync 间隔(默认 5s)
5.2 查询流程
ES 的搜索分为两个阶段:
Query Phase(查询阶段)
1. 协调节点将请求广播到所有相关分片
2. 每个分片本地搜索,返回 Top N 结果的文档 ID 和排序分数
3. 协调节点合并排序,选出最终的 Top N
Fetch Phase(取回阶段)
1. 协调节点向对应分片请求完整文档
2. 分片返回文档的 _source 字段
3. 协调节点构建最终响应
// 搜索查询
GET /products/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "手机" } }
],
"filter": [
{ "range": { "price": { "gte": 1000, "lte": 5000 } } }
]
}
},
"sort": [
{ "_score": "desc" },
{ "created_at": "desc" }
],
"from": 0,
"size": 20,
"aggs": {
"brand_terms": {
"terms": { "field": "brand.keyword" }
},
"price_stats": {
"stats": { "field": "price" }
}
}
}
5.3 深度分页问题
from + size 超过 10000 时会引发性能问题:
// ❌ 不推荐:深度分页
GET /products/_search?from=10000&size=10
// ✅ 推荐:Search After
GET /products/_search
{
"size": 10,
"sort": [
{ "price": "asc" },
{ "_id": "asc" }
],
"search_after": [2999, "product_12345"]
}
// ✅ 推荐:Scroll(批量导出,不维护实时性)
POST /products/_scroll?scroll=5m
{
"size": 1000,
"query": { "match_all": {} }
}
六、Java 集成 Elasticsearch
6.1 客户端配置
co.elastic.clients
elasticsearch-java
8.12.0
com.fasterxml.jackson.core
jackson-databind
2.17.0
@Configuration
public class ElasticsearchConfig {
@Value("${elasticsearch.hosts:localhost:9200}")
private String[] hosts;
@Bean
public ElasticsearchClient esClient() {
RestClient restClient = RestClient.builder(
Arrays.stream(hosts)
.map(h -> {
String[] parts = h.split(":");
return new HttpHost(parts[0], Integer.parseInt(parts[1]), "http");
})
.toArray(HttpHost[]::new)
).build();
ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
return new ElasticsearchClient(transport);
}
}
6.2 CRUD 操作
@Service
public class ProductSearchService {
@Autowired
private ElasticsearchClient esClient;
private static final String INDEX = "products";
// 索引文档
public void indexProduct(Product product) throws IOException {
IndexResponse response = esClient.index(i -> i
.index(INDEX)
.id(product.getId().toString())
.document(product)
);
}
// 批量索引
public void bulkIndex(List<Product> products) throws IOException {
BulkRequest.Builder br = new BulkRequest.Builder();
for (Product p : products) {
br.operations(op -> op
.index(idx -> idx
.index(INDEX)
.id(p.getId().toString())
.document(p)
)
);
}
esClient.bulk(br.build());
}
// 搜索
public SearchResult<Product> search(String keyword, int page, int size) throws IOException {
SearchResponse<Product> response = esClient.search(s -> s
.index(INDEX)
.query(q -> q
.bool(b -> b
.must(m -> m.match(t -> t.field("title").query(keyword)))
.filter(f -> f.term(t -> t.field("status").value("active")))
)
)
.from((page - 1) * size)
.size(size)
.sort(so -> so.field(f -> f.field("price").order(SortOrder.Asc)))
, Product.class);
List<Product> products = response.hits().hits().stream()
.map(Hit::source)
.collect(Collectors.toList());
long total = response.hits().total().value();
return new SearchResult<>(products, total, page, size);
}
// 聚合分析
public Map<String, Long> aggregateByBrand() throws IOException {
SearchResponse<Void> response = esClient.search(s -> s
.index(INDEX)
.size(0)
.aggregations("by_brand", a -> a
.terms(t -> t.field("brand.keyword").size(20))
), Void.class);
return response.aggregations().get("by_brand").sterms().buckets().array()
.stream()
.collect(Collectors.toMap(
b -> b.key().stringValue(),
b -> b.docCount()
));
}
}
6.3 Spring Data Elasticsearch
@Document(indexName = "products")
public class Product {
@Id
private Long id;
@Field(type = FieldType.Text, analyzer = "ik_smart")
private String title;
@Field(type = FieldType.Text)
private String description;
@Field(type = FieldType.Double)
private BigDecimal price;
@Field(type = FieldType.Keyword)
private String brand;
@Field(type = FieldType.Date)
private LocalDateTime createdAt;
}
public interface ProductRepository extends ElasticsearchRepository<Product, Long> {
// 自动派生查询
List<Product> findByTitleContaining(String keyword);
List<Product> findByBrandAndPriceBetween(String brand, Double min, Double max);
// 自定义查询(需要实现)
@Query("{\"match\": {\"title\": {\"query\": \"?0\"}}}")
List<Product> searchByTitle(String keyword);
}
七、性能调优实践
7.1 索引优化
| 优化项 | 操作 | 效果 |
|---|---|---|
| 批量写入 | bulk API,每批 1000-5000 条 |
吞吐量提升 10-50 倍 |
| 增加 Refresh 间隔 | refresh_interval: 30s |
写入性能提升,牺牲实时性 |
| 禁用副本 | 写入前设置 number_of_replicas: 0 |
提升写入速度 30-50% |
| 使用 SSD | 数据盘使用 NVMe SSD | 索引和搜索性能显著提升 |
| 合理分片 | 单分片 10-50GB | 避免过多或过少分片 |
7.2 查询优化
// ✅ 禁用 Scoring(Filter Context)
SearchResponse> response = esClient.search(s -> s
.query(q -> q.bool(b -> b
.filter(f -> f.term(t -> t.field("status.keyword").value("published")))
.filter(f -> f.range(r -> r.field("price").gte(JsonData.of(100))))
))
.trackScores(false) // 禁用评分计算
);
// ✅ 指定字段返回
s.source(src -> src.filter(f -> f.includes("id", "title", "price")));
// ✅ 使用 Profile API 分析慢查询
POST /_search
{
"profile": true,
"query": { ... }
}
八、总结
Elasticsearch 的强大根植于其精妙的底层设计。倒排索引使其全文搜索效率远超传统数据库,分片与副本机制提供了水平扩展能力,而近实时的 Refresh 策略在写入性能与查询实时性之间取得了巧妙的平衡。
核心要点:
1. 倒排索引是搜索的基石,Lucene 的 FOR 和 Roaring Bitmaps 等优化技术使其极致高效
2. 分词器决定了搜索质量,中文场景推荐 IK 分词器
3. 集群架构中 Master、Data、Coordinating 节点的合理规划决定了系统的稳定性
4. 写入流程中 Translog 和 Refresh 机制反映了近实时搜索的权衡设计
5. 查询流程的 Query-Fetch 两阶段设计适用于分布式搜索场景
在实际工程中,性能问题的根源往往不是 ES 本身,而是不合理的索引设计、错误的字段映射和缺乏规划的集群拓扑。理解了底层原理,才能在系统设计时做出正确的取舍。
Elasticsearch 搜索引擎核心原理
Elasticsearch 核心原理——从倒排索引到分布式搜索
一、引言
Elasticsearch 是基于 Apache Lucene 构建的分布式搜索和分析引擎,也是目前最流行的全文检索引擎之一。它以 RESTful API、实时搜索、分布式架构和强大的聚合分析能力著称,被广泛应用于日志分析、全文搜索、安全监控和商业智能等领域。
本文将深入 Elasticsearch 的核心原理,从底层的倒排索引机制、分词与映射,到上层的集群架构、分片机制和写入查询流程,最后通过实战案例展示 Java 集成的最佳实践。
二、倒排索引原理
2.1 什么是倒排索引
传统关系型数据库使用正向索引(正排索引),即文档到词的映射;而倒排索引(Inverted Index)则是词到文档的映射。这种数据结构使得全文搜索极为高效。
graph LR
subgraph Forward[正排索引 Document→Term]
D1["Doc1: Elasticsearch is fast"] --> T1["Elasticsearch, is, fast"]
D2["Doc2: Fast search engine"] --> T2["Fast, search, engine"]
end
subgraph Inverted[倒排索引 Term→Document]
T1x["elasticsearch"] --> D1
T2x["fast"] --> D1
T2x --> D2
T3x["search"] --> D2
T4x["engine"] --> D2
T5x["is"] --> D1
end
2.2 Lucene 倒排索引结构
Lucene 的倒排索引由以下部分组成:
词典(Term Dictionary):存储所有不重复的词项,以有序的方式组织,支持二分查找。
倒排列表(Posting List):记录每个词项出现的文档 ID 列表和词频信息。
倒排表(Posting Entry):每个条目包含:
– 文档 ID(Doc ID)
– 词频(Term Frequency, TF)
– 位置信息(Position)
– 偏移量(Offset)
// Lucene 索引写入的简化表示
public class InvertedIndexExample {
static class TermEntry {
int docId;
int frequency;
List<Integer> positions;
int startOffset;
int endOffset;
}
static Map<String, List<TermEntry>> invertedIndex = new HashMap<>();
public static void addDocument(int docId, String content) {
String[] tokens = content.toLowerCase().split("\\W+");
Map<String, List<Integer>> tempMap = new HashMap<>();
for (int pos = 0; pos < tokens.length; pos++) {
tempMap.computeIfAbsent(tokens[pos], k -> new ArrayList<>()).add(pos);
}
for (Map.Entry<String, List<Integer>> entry : tempMap.entrySet()) {
TermEntry te = new TermEntry();
te.docId = docId;
te.frequency = entry.getValue().size();
te.positions = entry.getValue();
invertedIndex.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).add(te);
}
}
}
2.3 压缩与优化技术
Elasticsearch 使用多种技术优化倒排索引的存储和查询性能:
Frame Of Reference (FOR):将递增的 Doc ID 序列编码为差值(Delta),再使用按位压缩。假设 Doc ID 序列为 [1, 3, 5, 9, 15, 30],差值编码后为 [1, 2, 2, 4, 6, 15]。
Roaring Bitmaps:用于高效存储和计算大量文档集的交集与并集。当文档数较少时使用数组容器(Array Container),超过 4096 个时使用位图容器(Bitmap Container)。
三、分词器与映射
3.1 分词器(Analyzer)
Analyzer 由三部分组成:
– Character Filters:字符过滤(如去除 HTML 标签)
– Tokenizer:分词器(如标准分词、IK 分词)
– Token Filters:词项过滤器(如小写转换、停用词、同义词)
PUT /my_index
{
"settings": {
"analysis": {
"analyzer": {
"ik_smart_analyzer": {
"type": "custom",
"char_filter": ["html_strip"],
"tokenizer": "ik_smart",
"filter": ["lowercase", "asciifolding"]
}
},
"tokenizer": {
"comma_tokenizer": {
"type": "pattern",
"pattern": ","
}
}
}
}
}
常用分词器对比
| 分词器 | 类型 | 适用场景 | 示例:”Elasticsearch是一个搜索引擎” |
|---|---|---|---|
| Standard | 内置 | 英文文本 | elasticsearch, 是, 一, 个, 搜, 索, 引, 擎 |
| IK Smart | 插件 | 中文智能切分 | elasticsearch, 是, 一个, 搜索引擎 |
| IK Max Word | 插件 | 最细粒度切分 | elasticsearch, 是, 一个, 搜索, 引擎 |
| ICU | 插件 | 多语言文本 | 依赖 ICU 分词规则 |
3.2 映射(Mapping)
Mapping 定义索引中的字段类型和索引方式,类似于数据库中的表结构定义:
PUT /my_index/_mapping
{
"properties": {
"title": {
"type": "text",
"analyzer": "ik_smart",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"price": {
"type": "double"
},
"created_at": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
},
"tags": {
"type": "keyword"
},
"description": {
"type": "text",
"analyzer": "standard"
},
"location": {
"type": "geo_point"
}
}
}
关键字段类型:
– text:全文索引,被分词,支持模糊搜索
– keyword:精确值,不分词,用于排序和聚合
– date:日期类型,支持多种格式
– geo_point:地理坐标点
– nested:嵌套对象类型
四、集群架构与分片机制
4.1 集群拓扑
graph TD
subgraph Cluster[ES Cluster - 产品搜索集群]
M1[Node 1<br/>Master + Data]
M2[Node 2<br/>Master + Data]
M3[Node 3<br/>Master + Data]
C1[Node 4<br/>Coordinating Only]
C2[Node 5<br/>Coordinating + Ingest]
end
Client1[Client API] --> C1
Client2[Client API] --> C2
C1 --> M1
C1 --> M2
C2 --> M2
C2 --> M3
M1 --- M2 --- M3
节点类型:
| 节点角色 | 配置 | 职责 |
|---|---|---|
| Master-eligible | node.roles: [master] |
集群元数据管理、索引创建/删除 |
| Data | node.roles: [data] |
数据存储、CRUD、搜索聚合 |
| Coordinating | node.roles: [] |
请求路由、结果聚合 |
| Ingest | node.roles: [ingest] |
文档预处理(Pipeline) |
4.2 分片与副本机制
分片是 ES 数据分布的最小单元,每个索引可拆分为多个分片:
PUT /products
{
"settings": {
"number_of_shards": 5,
"number_of_replicas": 1
}
}
分片路由:
shard = hash(routing_value) % number_of_primary_shards
// 默认 routing_value = _id,可自定义
副本分片:
– 每个主分片可以有零个或多个副本
– 副本不写入相同节点(防止单点故障)
– 副本可处理读请求,提升查询吞吐量
4.3 水平扩展
当数据量增长时,可通过两个方式扩展:
1. 增加分片数(仅索引创建时设定)
2. 增加节点数(自动重平衡)
注意:基于 _id 的路由方式决定了主分片数在索引创建后不可更改。
五、写入与查询流程
5.1 写入流程
sequenceDiagram
participant Client
participant Coord as Coordinating Node
participant Primary as Primary Shard
participant Replica as Replica Shard
participant Translog as Translog
Client->>Coord: Index Request
Coord->>Coord: Route to shard
Coord->>Primary: Forward request
Primary->>Primary: Write to Lucene Index
Primary->>Primary: Write to Translog (fsync)
Primary->>Translog: Persist
Primary-->>Coord: Success
par Replicate
Primary->>Replica: Replication request
Replica->>Replica: Write + Translog
Replica-->>Primary: Success
end
Coord-->>Client: 201 Created
写入关键参数:
– index.refresh_interval:控制 refresh 频率(默认 1s),值越小搜索越实时但开销越大
– index.translog.durability:request(每次请求 fsync)或 async(定期 fsync)
– index.translog.sync_interval:异步 fsync 间隔(默认 5s)
5.2 查询流程
ES 的搜索分为两个阶段:
Query Phase(查询阶段)
1. 协调节点将请求广播到所有相关分片
2. 每个分片本地搜索,返回 Top N 结果的文档 ID 和排序分数
3. 协调节点合并排序,选出最终的 Top N
Fetch Phase(取回阶段)
1. 协调节点向对应分片请求完整文档
2. 分片返回文档的 _source 字段
3. 协调节点构建最终响应
// 搜索查询
GET /products/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "手机" } }
],
"filter": [
{ "range": { "price": { "gte": 1000, "lte": 5000 } } }
]
}
},
"sort": [
{ "_score": "desc" },
{ "created_at": "desc" }
],
"from": 0,
"size": 20,
"aggs": {
"brand_terms": {
"terms": { "field": "brand.keyword" }
},
"price_stats": {
"stats": { "field": "price" }
}
}
}
5.3 深度分页问题
from + size 超过 10000 时会引发性能问题:
// ❌ 不推荐:深度分页
GET /products/_search?from=10000&size=10
// ✅ 推荐:Search After
GET /products/_search
{
"size": 10,
"sort": [
{ "price": "asc" },
{ "_id": "asc" }
],
"search_after": [2999, "product_12345"]
}
// ✅ 推荐:Scroll(批量导出,不维护实时性)
POST /products/_scroll?scroll=5m
{
"size": 1000,
"query": { "match_all": {} }
}
六、Java 集成 Elasticsearch
6.1 客户端配置
co.elastic.clients
elasticsearch-java
8.12.0
com.fasterxml.jackson.core
jackson-databind
2.17.0
@Configuration
public class ElasticsearchConfig {
@Value("${elasticsearch.hosts:localhost:9200}")
private String[] hosts;
@Bean
public ElasticsearchClient esClient() {
RestClient restClient = RestClient.builder(
Arrays.stream(hosts)
.map(h -> {
String[] parts = h.split(":");
return new HttpHost(parts[0], Integer.parseInt(parts[1]), "http");
})
.toArray(HttpHost[]::new)
).build();
ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
return new ElasticsearchClient(transport);
}
}
6.2 CRUD 操作
@Service
public class ProductSearchService {
@Autowired
private ElasticsearchClient esClient;
private static final String INDEX = "products";
// 索引文档
public void indexProduct(Product product) throws IOException {
IndexResponse response = esClient.index(i -> i
.index(INDEX)
.id(product.getId().toString())
.document(product)
);
}
// 批量索引
public void bulkIndex(List<Product> products) throws IOException {
BulkRequest.Builder br = new BulkRequest.Builder();
for (Product p : products) {
br.operations(op -> op
.index(idx -> idx
.index(INDEX)
.id(p.getId().toString())
.document(p)
)
);
}
esClient.bulk(br.build());
}
// 搜索
public SearchResult<Product> search(String keyword, int page, int size) throws IOException {
SearchResponse<Product> response = esClient.search(s -> s
.index(INDEX)
.query(q -> q
.bool(b -> b
.must(m -> m.match(t -> t.field("title").query(keyword)))
.filter(f -> f.term(t -> t.field("status").value("active")))
)
)
.from((page - 1) * size)
.size(size)
.sort(so -> so.field(f -> f.field("price").order(SortOrder.Asc)))
, Product.class);
List<Product> products = response.hits().hits().stream()
.map(Hit::source)
.collect(Collectors.toList());
long total = response.hits().total().value();
return new SearchResult<>(products, total, page, size);
}
// 聚合分析
public Map<String, Long> aggregateByBrand() throws IOException {
SearchResponse<Void> response = esClient.search(s -> s
.index(INDEX)
.size(0)
.aggregations("by_brand", a -> a
.terms(t -> t.field("brand.keyword").size(20))
), Void.class);
return response.aggregations().get("by_brand").sterms().buckets().array()
.stream()
.collect(Collectors.toMap(
b -> b.key().stringValue(),
b -> b.docCount()
));
}
}
6.3 Spring Data Elasticsearch
@Document(indexName = "products")
public class Product {
@Id
private Long id;
@Field(type = FieldType.Text, analyzer = "ik_smart")
private String title;
@Field(type = FieldType.Text)
private String description;
@Field(type = FieldType.Double)
private BigDecimal price;
@Field(type = FieldType.Keyword)
private String brand;
@Field(type = FieldType.Date)
private LocalDateTime createdAt;
}
public interface ProductRepository extends ElasticsearchRepository<Product, Long> {
// 自动派生查询
List<Product> findByTitleContaining(String keyword);
List<Product> findByBrandAndPriceBetween(String brand, Double min, Double max);
// 自定义查询(需要实现)
@Query("{\"match\": {\"title\": {\"query\": \"?0\"}}}")
List<Product> searchByTitle(String keyword);
}
七、性能调优实践
7.1 索引优化
| 优化项 | 操作 | 效果 |
|---|---|---|
| 批量写入 | bulk API,每批 1000-5000 条 |
吞吐量提升 10-50 倍 |
| 增加 Refresh 间隔 | refresh_interval: 30s |
写入性能提升,牺牲实时性 |
| 禁用副本 | 写入前设置 number_of_replicas: 0 |
提升写入速度 30-50% |
| 使用 SSD | 数据盘使用 NVMe SSD | 索引和搜索性能显著提升 |
| 合理分片 | 单分片 10-50GB | 避免过多或过少分片 |
7.2 查询优化
// ✅ 禁用 Scoring(Filter Context)
SearchResponse> response = esClient.search(s -> s
.query(q -> q.bool(b -> b
.filter(f -> f.term(t -> t.field("status.keyword").value("published")))
.filter(f -> f.range(r -> r.field("price").gte(JsonData.of(100))))
))
.trackScores(false) // 禁用评分计算
);
// ✅ 指定字段返回
s.source(src -> src.filter(f -> f.includes("id", "title", "price")));
// ✅ 使用 Profile API 分析慢查询
POST /_search
{
"profile": true,
"query": { ... }
}
八、总结
Elasticsearch 的强大根植于其精妙的底层设计。倒排索引使其全文搜索效率远超传统数据库,分片与副本机制提供了水平扩展能力,而近实时的 Refresh 策略在写入性能与查询实时性之间取得了巧妙的平衡。
核心要点:
1. 倒排索引是搜索的基石,Lucene 的 FOR 和 Roaring Bitmaps 等优化技术使其极致高效
2. 分词器决定了搜索质量,中文场景推荐 IK 分词器
3. 集群架构中 Master、Data、Coordinating 节点的合理规划决定了系统的稳定性
4. 写入流程中 Translog 和 Refresh 机制反映了近实时搜索的权衡设计
5. 查询流程的 Query-Fetch 两阶段设计适用于分布式搜索场景
在实际工程中,性能问题的根源往往不是 ES 本身,而是不合理的索引设计、错误的字段映射和缺乏规划的集群拓扑。理解了底层原理,才能在系统设计时做出正确的取舍。
监控 MySQL:QPS、TPS 与慢查询分析
监控 MySQL:QPS、TPS 与慢查询分析
核心监控指标
QPS(Queries Per Second)
每秒查询数,反映数据库的整体查改负载。
-- 计算 QPS(基于当前会话与 5 秒前的差值)
SELECT
(VARIABLE_VALUE - @last_queries) / 5 AS QPS,
@last_queries := VARIABLE_VALUE
FROM information_schema.GLOBAL_STATUS
WHERE VARIABLE_NAME = 'QUESTIONS';
一次完整的循环计算:
SET @last_queries = (SELECT VARIABLE_VALUE FROM information_schema.GLOBAL_STATUS
WHERE VARIABLE_NAME = 'QUESTIONS');
SELECT SLEEP(5);
SELECT (VARIABLE_VALUE - @last_queries) / 5 AS QPS
FROM information_schema.GLOBAL_STATUS
WHERE VARIABLE_NAME = 'QUESTIONS';
TPS(Transactions Per Second)
每秒事务数,反映写入负载。
SET @last_commit = (SELECT VARIABLE_VALUE FROM information_schema.GLOBAL_STATUS
WHERE VARIABLE_NAME = 'COM_COMMIT');
SET @last_rollback = (SELECT VARIABLE_VALUE FROM information_schema.GLOBAL_STATUS
WHERE VARIABLE_NAME = 'COM_ROLLBACK');
SELECT SLEEP(5);
SELECT
((SELECT VARIABLE_VALUE FROM information_schema.GLOBAL_STATUS WHERE VARIABLE_NAME = 'COM_COMMIT') - @last_commit +
(SELECT VARIABLE_VALUE FROM information_schema.GLOBAL_STATUS WHERE VARIABLE_NAME = 'COM_ROLLBACK') - @last_rollback) / 5 AS TPS;
重要状态指标速查
-- 连接相关
Threads_connected -- 当前连接数
Threads_running -- 正在执行 SQL 的线程数
Max_used_connections -- 历史最大连接数
-- 查询相关
Questions -- 总查询数
Queries -- 服务器执行的语句数(不含存储过程内语句)
Slow_queries -- 慢查询总数
-- 临时表
Created_tmp_tables -- 创建的临时表数
Created_tmp_disk_tables -- 磁盘临时表数
-- 排序
Sort_scan -- 要排序的行数
Sort_rows -- 排序后用到的行数
Sort_merge_passes -- 排序合并次数(高说明排序缓冲区小)
-- 表锁(MyISAM)
Table_locks_waited -- 等待表锁的次数
慢查询日志配置
开启慢查询日志
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2 # 超过 2 秒就记录
log_queries_not_using_indexes = 1 # 记录不走索引的查询(8.0.21 之前慎用此参数,因为可能产生大量日志)
log_slow_admin_statements = 1 # 记录慢的管理操作(ALTER TABLE 等)
log_slow_slave_statements = 1 # 从库也记录慢查询
min_examined_row_limit = 100 # 至少扫描 100 行才记录
动态开启:
SET GLOBAL slow_query_log = 1;
SET GLOBAL long_query_time = 2;
SET GLOBAL log_queries_not_using_indexes = 1;
分析慢查询日志
使用 mysqldumpslow 工具对慢查询进行聚合分析:
# 按平均查询时间倒序
mysqldumpslow -s t /var/log/mysql/slow.log
# 按查询次数倒序
mysqldumpslow -s c /var/log/mysql/slow.log
# 按查询总时间倒序
mysqldumpslow -s at /var/log/mysql/slow.log
# 显示前 10 条
mysqldumpslow -t 10 /var/log/mysql/slow.log
# 输出更多细节
mysqldumpslow -a /var/log/mysql/slow.log
使用 pt-query-digest 分析
Percona Toolkit 的 pt-query-digest 是更强大的分析工具:
# 分析慢查询日志文件
pt-query-digest /var/log/mysql/slow.log
# 从 MySQL 进程列表实时分析
pt-query-digest --processlist h=localhost
# 生成报告
pt-query-digest /var/log/mysql/slow.log > slow_report.txt
# 按执行频率排序
pt-query-digest --order-by cnt /var/log/mysql/slow.log
performance_schema 监控查询
MySQL 5.7+ 提供了比慢查询更精细的监控方式:
-- 查看最耗时的 SQL
SELECT DIGEST_TEXT, COUNT_STAR, SUM_TIMER_WAIT/1000000000000 AS total_sec,
AVG_TIMER_WAIT/1000000000000 AS avg_sec
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
-- 查找全表扫描的查询
SELECT DIGEST_TEXT, SUM_ROWS_EXAMINED, SUM_ROWS_SENT,
SUM_ROWS_EXAMINED / SUM_ROWS_SENT AS ratio
FROM performance_schema.events_statements_summary_by_digest
WHERE SUM_ROWS_EXAMINED > SUM_ROWS_SENT * 100
ORDER BY ratio DESC
LIMIT 10;
-- 查看 IO 等待最严重的查询
SELECT DIGEST_TEXT, COUNT_STAR, SUM_NO_INDEX_USED
FROM performance_schema.events_statements_summary_by_digest
WHERE SUM_NO_INDEX_USED > 0
ORDER BY COUNT_STAR DESC
LIMIT 10;
sys schema
MySQL 5.7+ 自带的 sys schema 提供了更易用的视图:
-- 查看最慢的查询
SELECT * FROM sys.statement_analysis
ORDER BY avg_latency DESC
LIMIT 10;
-- 查看全表扫描的语句
SELECT * FROM sys.statements_with_full_table_scans;
-- 查看 IO 热点
SELECT * FROM sys.io_global_by_wait_by_bytes
ORDER BY total_latency DESC
LIMIT 10;
-- 查看未使用索引的查询
SELECT * FROM sys.statements_with_no_index;
-- 查看全表扫描次数
SELECT * FROM sys.schema_tables_with_full_table_scans;
监控自动化
使用自定义脚本
#!/bin/bash
# 采集 MySQL 监控指标
MYSQL="mysql -u monitor -p'password' -h localhost"
# QPS
$MYSQL -e "SHOW GLOBAL STATUS LIKE 'Questions'" | tail -1 | awk '{print $2}'
# 连接数
$MYSQL -e "SHOW GLOBAL STATUS LIKE 'Threads_connected'" | tail -1 | awk '{print $2}'
# 慢查询数
$MYSQL -e "SHOW GLOBAL STATUS LIKE 'Slow_queries'" | tail -1 | awk '{print $2}'
监控工具推荐
| 工具 | 类型 | 特点 |
|---|---|---|
| Prometheus + mysqld_exporter | 开源 | 指标丰富,Grafana 展示 |
| Percona Monitoring and Management (PMM) | 开源 | Percona 出品,MySQL 专属 |
| Zabbix | 开源 | 企业级监控 |
| Alibaba Cloud RDS 监控 | 云厂商 | 托管环境自带 |
| pt-query-digest | 命令行 | 慢查询日志分析 |
MySQL 8.0 的监控增强
-- 查询延迟直方图(Query Response Time)
SELECT * FROM information_schema.QUERY_RESPONSE_TIME;
-- InnoDB 指标表
SELECT * FROM information_schema.INNODB_METRICS;
-- 更详细的性能模式
SELECT * FROM performance_schema.events_waits_current;
SELECT * FROM performance_schema.events_transactions_current;
面试常问题
Q:QPS 和 TPS 的区别?
A:QPS 衡量所有查询(包括 SELECT、INSERT、UPDATE、DELETE 等),TPS 仅衡事务(COMMIT + ROLLBACK)。一般读多写少的场景,QPS 远大于 TPS。
Q:慢查询日志的 long_query_time 设置多少合适?
A:看业务场景。OLTP 建议 1-2 秒,OLAP 可以 5-10 秒。从 0.5 开始逐步调整,先抓所有慢查询再过滤。
Q:为什么 Threads_running 比 Threads_connected 更重要?
A:Threads_connected 只表示连接是否保持,很多连接可能处于 Sleep 状态。Threads_running 表示正在执行 SQL 的线程数,直接反映数据库当前的真实负载。
MySQL 优化器如何选择索引
MySQL 优化器如何选择索引
优化器的决策过程
MySQL 优化器在执行 SQL 前,会评估多种执行计划,选择代价最小的一个。这个过程决定了最终使用哪个索引。
SELECT * FROM order
WHERE status = 1 AND create_time > '2024-01-01'
ORDER BY amount DESC;
优化器面临的选择:
– 使用 idx_status(status) 索引
– 使用 idx_create_time(create_time) 索引
– 使用 idx_status_amount(status, amount) 索引
– 全表扫描
优化器的代价评估模型
核心代价因素
总代价 = IO 代价 + CPU 代价
IO 代价:
- 磁盘读取数据页的次数
- 随机 IO 成本 > 顺序 IO 成本
CPU 代价:
- 比较操作(排序等)
- 临时表处理
- 函数计算
优化器估算的依据
-- 优化器评估索引选择时依赖以下信息
-- 1. 表的数据量
SELECT TABLE_ROWS
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_NAME = 'order';
-- 2. 索引的区分度(Cardinality)
SHOW INDEX FROM order;
-- Cardinality 列表示索引的"不同值"的估算值
-- Cardinality 越高,索引选择性越好
-- 3. 数据分布(直方图,MySQL 8.0+)
ANALYZE TABLE order;
SHOW INDEX FROM order;
-- 直方图帮助优化器了解数据分布,如 "status = 1 有多少行"
优化器选择索引的关键因素
因素 1:索引的选择性(Cardinality)
-- 如果 idx_status 的 Cardinality 很低(status 只有 0/1/2 三种)
-- idx_create_time 的 Cardinality 很高(几乎每条记录不同)
-- 优化器倾向于选择 idx_create_time,因为过滤效果更好
SELECT COUNT(DISTINCT status) FROM order; -- 3
SELECT COUNT(DISTINCT create_time) FROM order; -- 接近行数
因素 2:WHERE 条件的过滤效果
优化器估算每个条件过滤后返回的行数:
WHERE status = 1 -- status = 1 约占总数据 1/3
WHERE create_time > '2024-01-01' -- 2024 年数据约占总 10%
-- 优化器估算:
-- 使用 idx_create_time:扫描 10% 的数据,然后过滤 status
-- 使用 idx_status:扫描 33% 的数据,然后过滤时间
-- 选择:扫描数据少的 idx_create_time
因素 3:排序的影响
如果查询包含 ORDER BY,优化器会考虑索引是否能消除 filesort:
-- 优化器会对比两种执行方案的代价:
-- 方案 A:使用 idx_status 过滤 + filesort
-- 方案 B:使用 idx_amount 排序 + 过滤 WHERE
EXPLAIN SELECT * FROM order
WHERE status = 1
ORDER BY amount DESC
LIMIT 10;
因素 4:LIMIT 的影响
LIMIT 的存在会影响索引选择。如果只需要前几条数据,优化器可能选择排序索引:
-- 场景:需要 status=1 的前 10 条最新记录
SELECT * FROM order
WHERE status = 1
ORDER BY create_time DESC
LIMIT 10;
-- 优化器可能选择:
-- 如果 create_time 有索引,且大多数数据是 status=1
-- 走 create_time 索引 + 过滤(可能只需要扫描少量行就找到 10 条)
-- 如果 status=1 的占比很小(1%)
-- 走 status 索引 + filesort(更能缩小范围)
索引选择错误的场景
场景 1:统计信息过时
-- 数据大量变更后未更新统计信息
-- 优化器用旧的统计信息做决策
-- 解决办法
ANALYZE TABLE order;
-- 重新收集统计信息
场景 2:数据分布不均衡
-- 索引 status 的 Cardinality 是 3
-- 实际上 status=1:99%,status=2:0.5%,status=3:0.5%
-- 优化器以为 status=1 占 1/3,实际占 99%
SELECT * FROM order WHERE status = 2 ORDER BY create_time;
-- QQ 走 idx_status 索引过滤 status=2(实际只有 0.5%)
-- 但因为统计信息不准确,优化器可能选择其他"更差"的路径
如何干预索引选择
方法 1:FORCE INDEX 强制索引
-- 强制使用指定索引
SELECT * FROM order
FORCE INDEX(idx_status_create)
WHERE status = 1
ORDER BY create_time DESC
LIMIT 10;
注意:FORCE INDEX 是最后手段,长期依赖说明索引设计可能有问题。
方法 2:USE INDEX 建议索引
-- 建议优化器使用特定索引(优化器可以拒绝)
SELECT * FROM order
USE INDEX(idx_create_time)
WHERE status = 1
ORDER BY create_time DESC
LIMIT 10;
方法 3:IGNORE INDEX 排除错误索引
-- 排除某个不合适的索引
SELECT * FROM order
IGNORE INDEX(idx_status)
WHERE status = 1
ORDER BY create_time DESC
LIMIT 10;
方法 4:删除不必要的索引
索引过多不仅占用空间,还可能导致优化器选择错误路径。
实践建议
-- 每次创建索引后,用 EXPLAIN 验证查询是否走了预期索引
EXPLAIN SELECT * FROM order WHERE status = 1 ORDER BY create_time DESC;
-- 可以使用 optimizer_trace 查看优化器决策过程
SET optimizer_trace="enabled=on";
SELECT * FROM order WHERE status = 1 ORDER BY create_time DESC LIMIT 10;
SELECT * FROM information_schema.OPTIMIZER_TRACE\G
SET optimizer_trace="enabled=off";
面试要点
- 优化器的核心是”成本估算”,不是”猜”——每个决策都有数学依据
- Cardinality(选择性)是索引选择的最重要指标
- 统计信息过时和未做直方图是索引选择错误的常见原因
- 强制索引(FORCE INDEX)是最后的干预手段
- 能通过 optimizer_trace 查看优化器的完整决策过程
- 理解索引选择有助于设计更合理的索引,而不是事后频繁地 FORCE INDEX
分库分表后的索引设计
分库分表后的索引设计
分库分表后的索引挑战
分库分表后,索引设计从”单表优化”变成了”分布式索引优化”。核心矛盾在于:分片键决定了数据分布,但业务查询不可能总是带着分片键。
场景:订单表按 order_id 分片
经常查询:SELECT * FROM order WHERE user_id = 123;
这是一个"不带分片键"的查询,需要广播到所有分片
索引设计原则
原则一:分片键必须建索引
分片键是路由数据的核心字段。如果分片键上没有索引,即使已经定位到正确的分片,在该分片内也会全表扫描。
-- 在每个分片上,分片键都得有索引
-- 不仅是单个索引,最好是联合索引的"前导列"
CREATE INDEX idx_order_id ON order_0(order_id); -- 必须
原则二:为高频的非分片键查询建立”二级索引”
对于频繁查询但不带分片键的场景,可以在每个分片上建立相同的索引:
-- 按 user_id 查订单很频繁,在每个分片上建立索引
CREATE INDEX idx_user_id ON order_0(user_id);
CREATE INDEX idx_user_id ON order_1(user_id);
CREATE INDEX idx_user_id ON order_2(user_id);
-- ...
这样,虽然 SQL 需要广播到所有分片,但每个分片内可以走索引,而不是全表扫描。
广播查询全表扫描:分片数 × 单表行数(千万级)→ 灾难
广播查询走索引: 分片数 × 索引扫描行数(少量行)→ 可接受
原则三:避免全局唯一索引
分库分表后,数据库的 UNIQUE 约束只能保证在单分片内唯一。跨分片唯一性需要中间件或业务层保证。
-- 订单号在单分片内唯一
-- 但不同分片之间可能出现相同的订单号
CREATE UNIQUE INDEX idx_order_no ON order_0(order_no); -- 仅本分片内有效
解决方案:
– 订单号使用全局唯一 ID(雪花算法)
– 在应用层通过分布式锁保证全局唯一
原则四:充分利用绑定表索引
当使用 ShardingSphere 的绑定表功能时,关联查询可以在同一个分片中完成,索引设计可以沿用单库的”索引覆盖”思路:
-- order 和 order_item 绑定(使用相同的分片键 order_id)
-- 可以在一个分片内 JOIN
SELECT o.order_no, oi.item_name, oi.quantity
FROM order o JOIN order_item oi ON o.id = oi.order_id
WHERE o.user_id = 123;
-- 索引:order(user_id),order_item(order_id)
不同场景的索引策略
场景 1:按 user_id 查询为主 × 按 user_id 分片
理想情况。查询直接路由,不走广播。
-- 分片键 = 查询条件 → 完美
-- 索引:idx_user_create(user_id, create_time) 覆盖排序
SELECT * FROM order WHERE user_id = 123 ORDER BY create_time;
场景 2:按 order_no 查询为主 × 按 user_id 分片
典型的”查询字段 ≠ 分片键”。解决方案:
方案 A:建立全局索引表
-- 创建一个单独的反向索引表(可以用 ES 或独立 MySQL)
CREATE TABLE order_index (
order_no VARCHAR(64) PRIMARY KEY, -- 非分片查询键
user_id BIGINT, -- 分片键
order_id BIGINT -- 主键
);
-- 通过 order_no 先查到 user_id,再路由到正确分片
方案 B:数据同步到 ES
将订单数据同步到 ES,通过 order_no 搜索后获取完整信息。
方案 C:广播表思路(适用于数据量小的场景)
创建一个仅含 order_no 和 route_key 的小表,每个分片都存一份:
-- 广播小表:只有路由信息
order_route(order_no VARCHAR(64), user_id BIGINT)
-- 查询:先查广播表 → 获取 user_id → 路由到分片
场景 3:范围查询 + 分页 × 按 hash(user_id) 分片
-- 查询所有用户的最近一周订单
SELECT * FROM order WHERE create_time > NOW() - INTERVAL 7 DAY;
无法避免广播。优化策略:
– 在每个分片的时间字段上建索引
– 或者改成”逐用户查询”——应用层按用户列表逐个查询后合并
索引设计的常见误区
- 分片键本身不需要索引:❌ 分片键索引用于分片内快速定位
- 每个字段都加索引:❌ 增加更新成本和内存开销
- 全局唯一索引在分片下可用:❌ 只在本分片内唯一
- 全文索引可以直接使用:❌ 分片后 MyISAM 全文索引不可用,InnoDB 全文索引也需谨慎
索引监控
分库分表后需要监控”广播查询”的比例:
-- 在每个分片上启用慢查询日志
-- 慢查询数量突然增加可能意味着有非分片键查询
// 在 SQL 执行时加入分片命中检测
if (sql.contains("broadcast") ||
MetricsMonitor.broadcastCount.get() > threshold) {
// 告警:非分片键查询过多
}
面试要点
- 分库分表后索引设计的关键是”减少广播查询”
- 非分片键的查询需要”二级索引”或”索引表”来辅助路由
- 唯一性约束变成分布式约束问题
- 索引设计要结合分片策略和业务查询模式
- 能在面试中描述”通过索引表解决非分片键查询”的完整方案
慢查询日志的配置、分析与优化实践
慢查询日志的配置、分析与优化实践
概述
慢查询日志(Slow Query Log)是 MySQL 用于记录执行时间超过阈值的 SQL 语句的日志。它是性能优化的第一入口——找到慢查询,分析为什么慢,然后优化它。
配置慢查询日志
基本配置
# 启用慢查询日志
slow_query_log = ON
# 慢查询日志文件路径
slow_query_log_file = /var/log/mysql/slow.log
# 阈值(秒),超过这个时间的 SQL 才记录
long_query_time = 2
# 是否记录不使用索引的查询
log_queries_not_using_indexes = ON
# 是否记录慢管理命令(ALTER TABLE 等)
log_slow_admin_statements = ON
# 每分钟最多的不使用索引的查询记录数(防止刷屏)
log_throttle_queries_not_using_indexes = 10
动态配置(不需要重启)
-- 查看当前配置
SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time%';
SHOW VARIABLES LIKE 'log_queries_not_using_indexes%';
-- 在线开启慢查询日志
SET GLOBAL slow_query_log = ON;
-- 调整阈值(秒,支持小数)
SET GLOBAL long_query_time = 0.5;
-- 记录未使用索引的查询
SET GLOBAL log_queries_not_using_indexes = ON;
慢查询日志的内容
示例
# Time: 2026-05-18T10:30:00.123456+08:00
# User@Host: root[root] @ localhost [] Id: 42
# Query_time: 3.123456 Lock_time: 0.001234 Rows_sent: 10000 Rows_examined: 500000
SET timestamp=1718605800;
SELECT * FROM orders WHERE status = 1 ORDER BY create_time DESC;
| 字段 | 含义 | 本示例值 |
|---|---|---|
| Time | 查询执行时间 | 10:30:00 |
| Query_time | 查询实际执行耗时 | 3.12 秒 ❌ 慢 |
| Lock_time | 表锁等待时间 | 0.001 秒 |
| Rows_sent | 返回了多少行 | 10,000 行 |
| Rows_examined | 扫描了多少行 | 500,000 行 ⚠️ |
| SET timestamp | SQL 执行时的系统时间 | – |
关键指标:Rows_examined >> Rows_sent 说明扫描了大量数据但只返回了少量,通常是索引问题。
分析工具
1. mysqldumpslow(MySQL 自带)
# 按平均查询时间排序,显示前 10 条
mysqldumpslow -t 10 /var/log/mysql/slow.log
# 按查询次数排序
mysqldumpslow -s c /var/log/mysql/slow.log
# 输出示例:
Reading mysql slow query log from /var/log/mysql/slow.log
Count: 123 Time=3.45s (424s) Lock=0.00s (0s) Rows=5000.0 (615000), root[root]
SELECT * FROM orders WHERE status = N ORDER BY create_time DESC
2. pt-query-digest(Percona Toolkit)
# 安装
apt install percona-toolkit # Debian/Ubuntu
# 分析慢查询日志
pt-query-digest /var/log/mysql/slow.log > slow_report.txt
# 分析结果结构:
# - Overall: 总览(总查询数、时间范围等)
# - Profile: 按查询类型分组(最慢的排最前面)
# - Query x of N: 每个慢查询的详细分析
3. performance_schema
-- 查看最近执行时间最长的 SQL
SELECT * FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
慢查询分析三步法
第一步:找到慢查询
tail -f /var/log/mysql/slow.log | grep -E "^# Query_time: [0-9]+\.[0-9]+"
# 实时查看新产生的慢查询
第二步:分析执行计划
-- 对慢查询 SQL 执行 EXPLAIN
EXPLAIN SELECT * FROM orders WHERE status = 1 ORDER BY create_time DESC\G
重点看:
– type:ALL(全表扫描)→ 需要加索引
– rows:扫描行数是否过大
– Extra:Using filesort、Using temporary 都是需要优化的信号
第三步:提出优化方案
-- 原始慢查询
SELECT * FROM orders WHERE status = 1 ORDER BY create_time DESC;
-- 优化方案 1:加联合索引
ALTER TABLE orders ADD INDEX idx_status_create (status, create_time);
-- 优化方案 2:改为只查需要的字段
SELECT id, order_no, amount FROM orders WHERE status = 1 ORDER BY create_time DESC;
-- 优化方案 3:加 LIMIT 限制
SELECT id, order_no, amount FROM orders WHERE status = 1 ORDER BY create_time DESC LIMIT 20;
生产环境最佳实践
# 生产推荐配置
slow_query_log = ON
slow_query_log_file = /data/mysql/slow.log
long_query_time = 1 # 1 秒,不要设太大
log_queries_not_using_indexes = ON
log_throttle_queries_not_using_indexes = 10
min_examined_row_limit = 100 # 扫描少于 100 行的不记录
注意事项
- 不要在生产直接调
long_query_time=0(会记录所有查询,磁盘会被撑爆) - 定期清理慢查询日志(
set global slow_query_log=OFF;重命名文件再 ON) - 配合监控系统(Prometheus + Grafana)收集慢查询指标
面试要点
- 慢查询日志是性能优化的起点——先找到慢的,再分析为什么慢
- 关键指标:
Query_time(耗时)、Rows_examined(扫描行数)、Rows_sent(返回行数) - Rows_examined >> Rows_sent 通常是缺少索引或索引选择不当
- 分析工具:mysqldumpslow(自带)、pt-query-digest(Percona,更强大)
- 优化方向:加索引、减少扫描行数、加 LIMIT、优化 SQL 写法
- 不要在生产开
long_query_time=0,日志量太大
验证新建索引是否生效
验证新建索引是否生效
为什么需要验证索引
建了索引不代表一定能用到。SQL 写法、数据类型、优化器决策都可能导致索引不生效。
-- 创建了索引
CREATE INDEX idx_phone ON user(phone);
-- 但查询时是不是真的用了?
SELECT * FROM user WHERE phone = 13800138000;
-- 不一定!可能因为隐式类型转换而失效
所以建完索引后,必须主动验证。
验证方法一:EXPLAIN
最直接、最常用的方法:
-- 加索引前
EXPLAIN SELECT * FROM user WHERE phone = 13800138000;
+------+------+------+--------+-------------+
| type | key | rows | Extra |
+------+------+------+--------+
| ALL | NULL | 1000 | Using where | ← 全表扫描
-- 创建索引
CREATE INDEX idx_phone ON user(phone);
-- 验证
EXPLAIN SELECT * FROM user WHERE phone = 13800138000;
+------+----------+------+--------+
| type | key | rows | Extra |
+------+----------+------+--------+
| ALL | NULL | 1000 | Using where | ← 还是全表扫描!
为什么? 隐式类型转换导致索引失效。
-- 正确写法
EXPLAIN SELECT * FROM user WHERE phone = '13800138000';
+------+----------+------+--------+
| type | key | rows | Extra |
+------+----------+------+--------+
| ref | idx_phone| 1 | NULL | ← 终于走索引了!
验证方法二:FORMAT=JSON 查看成本
EXPLAIN FORMAT=JSON
SELECT * FROM user WHERE phone = '13800138000'\G
{
"query_block": {
"select_id": 1,
"cost_info": {
"query_cost": "1.01"
},
"table": {
"access_type": "ref",
"possible_keys": ["idx_phone"],
"key": "idx_phone",
"used_key_parts": ["phone"],
"rows_examined_per_scan": 1,
"rows_produced_per_join": 1,
"filtered": "100.00",
"cost_info": {
"read_cost": "1.00",
"eval_cost": "0.10",
"prefix_cost": "1.10",
"data_read_per_join": "1K"
}
}
}
}
关注 access_type: "ref" 和 key: "idx_phone",确认索引生效。
验证方法三:实际执行时间对比
-- 加索引前
SELECT SQL_NO_CACHE * FROM user WHERE phone = '13800138000';
-- 耗时:50 ms
-- 加索引后
SELECT SQL_NO_CACHE * FROM user WHERE phone = '13800138000';
-- 耗时:0.5 ms(快了 100 倍)
注意:用 SQL_NO_CACHE 避免查询缓存影响,或者对大表做对比测试。
验证方法四:SHOW INDEX 确认索引存在
SHOW INDEX FROM user;
+-------+------------+----------+--------------+-------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name |
+-------+------------+----------+--------------+-------------+
| user | 0 | PRIMARY | 1 | id |
| user | 1 | idx_phone| 1 | phone |
+-------+------------+----------+--------------+-------------+
先确认索引确实创建成功了,再用 EXPLAIN 验证。
验证方法五:performance_schema 索引使用统计
-- MySQL 5.6+ 可以用
SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
INDEX_NAME,
COUNT_STAR,
COUNT_READ,
COUNT_INSERT,
COUNT_UPDATE,
COUNT_DELETE
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE OBJECT_SCHEMA = 'your_db'
AND OBJECT_NAME = 'user';
运行一段时间后,查看新索引是否有 COUNT_STAR > 0。如果一直是 0,说明没有查询使用它。
常见索引不生效的排查流程
建索引后 → EXPLAIN 验证
│
├─ key: NULL(没走索引)
│ ├─ 是否是复合索引缺少最左列?
│ ├─ 是否有隐式类型转换?
│ ├─ 是否使用了函数包裹索引列?
│ ├─ 是否是 LIKE '%xxx'?
│ ├─ 是否 OR 中有无索引列?
│ ├─ 优化器认为全表扫描更优?
│ └─ 数据量太少?
│
└─ key: idx_name(走了索引)
├─ type 是什么?
│ - const/eq_ref/ref → 优秀
│ - range → 良好
│ - index → 一般(全索引扫描)
│
├─ key_len 对复合索引足够长?
├─ rows 是否明显减少?
└─ Extra 是否有 Using filesort/temporary?
一句话排查指南
-- 索引不生效的 6 大原因速查
-- 1. 隐式类型转换
-- col 是 VARCHAR,传入数字
WHERE col = 123 → ❌
WHERE col = '123' → ✅
-- 2. 函数包裹
WHERE DATE(col) = ... → ❌
WHERE col >= ... → ✅
-- 3. LIKE 开头通配符
WHERE col LIKE '%abc' → ❌
WHERE col LIKE 'abc%' → ✅
-- 4. OR 含无索引列
WHERE col = 1 OR other = 2 → ❌ 如果 other 没有索引
-- 5. 复合索引缺最左列
-- 索引 idx(a,b,c)
WHERE b = 1 → ❌
-- 6. 优化器放弃
-- 数据太集中(选择性低)→ 全表扫描
-- 数据太少 → 全表扫描
大表建索引后的验证注意事项
-- 大表建索引可能比较慢
CREATE INDEX idx_created ON orders(created_at);
-- 1000 万行:可能需要几分钟到几十分钟
-- 建完后立刻验证
EXPLAIN SELECT * FROM orders WHERE created_at > '2024-01-01';
-- 如果 type: ALL(全表扫描),不一定是索引没建好
-- 可能是查询的数据范围太大,优化器认为全表更优
-- 可以缩小范围再试
EXPLAIN SELECT * FROM orders WHERE created_at > '2024-06-01';
面试要点
- 建索引 ≠ 生效:必须用 EXPLAIN 验证,特别关注 type 和 key 列
- 常见失效原因:隐式转换、函数包裹、LIKE 前缀通配、OR 含无索引列、违反最左前缀
- FORMAT=JSON 比普通 EXPLAIN 信息更全
- performance_schema 可以追踪索引的实际使用频率
- 实际时间验证:用 SQL_NO_CACHE 做加索引前后的耗时对比
一句话总结:建完索引用 EXPLAIN 看一眼,key 不为 NULL 且 type 是 ref/range 才算数——否则白建了。
索引数量与列数权衡
索引数量与列数权衡
索引不是越多越好
-- 一个表如果有无数的索引
CREATE TABLE user (
id BIGINT PRIMARY KEY,
name VARCHAR(50),
age INT,
email VARCHAR(100),
phone VARCHAR(20),
status TINYINT,
gender CHAR(1),
created_at DATETIME,
updated_at DATETIME,
-- 为每个可能的查询建索引
INDEX idx_name(name),
INDEX idx_age(age),
INDEX idx_email(email),
INDEX idx_phone(phone),
INDEX idx_status(status),
INDEX idx_gender(gender),
INDEX idx_created_at(created_at),
INDEX idx_updated_at(updated_at),
INDEX idx_name_age(name, age),
INDEX idx_status_created(status, created_at)
);
问题:每个索引都有维护成本,不是免费的。
索引的维护代价
写入性能损失
每次 INSERT、UPDATE、DELETE 操作,所有索引都要同步更新:
插入一行数据:
1. 写入聚簇索引(主键 B+树)
2. 写入 idx_name B+树
3. 写入 idx_age B+树
4. 写入 idx_email B+树
5. 写入 idx_phone B+树
6. 写入 idx_status B+树
7. 写入 idx_created_at B+树
...
如果表有 10 个索引:
- 插入一行 = 更新 11 个 B+树(1个聚簇 + 10个二级)
- 每个 B+树更新:查找插入位置 + 可能页分裂
- 写性能下降非常明显
存储空间占用
每个索引本质上是一个 B+树,占用磁盘空间。
1000 万行数据,每行 500 字节:
- 表数据:约 5 GB
- 每个单列索引:约 200 MB(取决于列类型)
- 10 个索引:约 2 GB
- 总磁盘占用 ≈ 7 GB
索引多了,磁盘占用可能超过表数据本身。
索引列数的权衡
复合索引列数不宜过多
-- 不推荐:6 列的复合索引
CREATE INDEX idx_mega ON t(a, b, c, d, e, f);
-- 问题:
-- 1. B+树每一层能存储的键值数量减少(每个键更大)
-- 2. 查询只用了前 2 列,后面 4 列浪费
-- 3. 更新时全部 6 列都需要参与计算
复合索引列数建议
一般建议:复合索引不要超过 3~4 列
原因:
1. 超过 3 列,最左前缀几乎很难用满(选择性不够高)
2. B+树节点变小,树高增加
3. 大多数查询最多用到前 2~3 列
索引数量的权衡
单表索引数量建议
OLTP(在线事务)系统:
- 单个表索引一般不超过 5~6 个
- 写入频繁的表不超过 3~4 个
OLAP(分析型)系统:
- 可以更多(读取为主)
- 但也要考虑存储成本
MySQL 官方限制:单表最多 64 个索引
但实际上超过 10 个索引通常意味着设计有问题
如何减少索引数量
策略一:用复合索引替代多个单列索引
-- ❌ 三个单列索引
CREATE INDEX idx_status ON orders(status);
CREATE INDEX idx_created ON orders(created_at);
CREATE INDEX idx_amount ON orders(amount);
-- ✅ 两个复合索引覆盖全部查询
CREATE INDEX idx_status_created ON orders(status, created_at);
CREATE INDEX idx_amount ON orders(amount);
-- idx_status_created 可以覆盖 status 查询和 status+created 查询
-- 节省了一个索引
策略二:用复合索引覆盖多个查询
-- 常见查询
-- WHERE user_id = ? AND status = ?
-- WHERE user_id = ? AND created_at > ?
-- WHERE user_id = ? ORDER BY created_at DESC
-- ❌ 三个索引
CREATE INDEX idx_user_status ON orders(user_id, status);
CREATE INDEX idx_user_created ON orders(user_id, created_at);
-- ✅ 一个索引覆盖全部
CREATE INDEX idx_user_status_created ON orders(user_id, status, created_at);
-- 前两个查询走前缀匹配
-- 第三个查询走 user_id 前缀
策略三:删除很少使用的索引
-- 8.0 特性:用不可见索引测试
ALTER INDEX idx_rare INVISIBLE;
-- 观察一段时间
-- 如果没有查询变慢 → 可以删除
DROP INDEX idx_rare;
策略四:冗余索引清理
-- 冗余索引:与另一个索引前缀相同的索引
-- 冗余:idx_a(a) 是 idx_a_b(a, b) 的前缀
-- idx_a_b(a, b) 已经可以覆盖 idx_a(a) 的用途
CREATE INDEX idx_a ON t(a); → 可以删除
CREATE INDEX idx_a_b ON t(a, b); → 保留
实战:对一个表的索引做”体检”
-- 1. 查看表的所有索引
SHOW INDEX FROM orders;
-- 2. 分析每个索引的使用频率
-- MySQL 5.6+ 可以查看索引使用统计
SELECT * FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE OBJECT_SCHEMA = 'your_db'
AND OBJECT_NAME = 'orders'
ORDER BY COUNT_STAR DESC;
-- 3. 找出从未使用的索引
SELECT *
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE INDEX_NAME IS NOT NULL
AND COUNT_STAR = 0;
面试核心建议
索引的正确姿势:
1. 不要为每个可能的 WHERE 条件建索引
2. 复合索引优先于多单列索引(但不超 4 列)
3. 高频查询优先建,低频查询不建
4. 定期用 performance_schema 检查索引使用率
5. 删除冗余的和从未被使用的索引
6. 评估索引维护成本:写入频繁的表要"吝啬"
7. 宁可少建也不要多建——缺索引可以补,多索引拖垮写入
面试要点
- 索引有代价:写入慢、磁盘占用、维护成本
- 复合索引替代多个单列索引:合理设计复合索引可以减少索引总量
- 列数不宜多:3~4 列是合理上限,超过后收益递减
- 定期清理:用 performance_schema 找出无用索引
- 写入场景谨慎:高写入场景索引越少越好
- MySQL 限制:单表最多 64 个索引,但不是让你建满
一句话总结:索引不是越多越好,每个索引都在牺牲写性能换取读性能——正确的索引策略是用有限的”好索引”覆盖尽可能多的查询,而不是为每个查询单独建索引。
索引基数 Cardinality 统计
索引基数 Cardinality 统计
什么是 Cardinality
Cardinality(基数)表示索引列中不同值的数量,是 MySQL 优化器判断索引选择性的核心指标。
-- 查看索引基数
SHOW INDEX FROM user;
+-------+------------+----------+--------------+-------------+
| Table | Non_unique | Key_name | Seq_in_index | Cardinality |
+-------+------------+----------+--------------+-------------+
| user | 0 | PRIMARY | 1 | 100000 |
| user | 1 | idx_name | 1 | 80000 |
| user | 1 | idx_gender| 1 | 2 |
+-------+------------+----------+--------------+-------------+
Cardinality = 不同值的数量
name 列: 80,000 不同的姓名 → 选择性高 ✓
gender 列: 2 种性别 → 选择性极低 ✗
id 列: 100,000 行 → 唯一值,选择性最高 ✓
Cardinality 的作用
优化器用 Cardinality 来评估走索引的效率:
选择度 (Selectivity) = Cardinality / 总行数
选择度越接近 1,说明该列越"唯一",走索引越有价值。
选择度越接近 0,说明该列重复值越多,走索引可能不如全表扫描。
举例:
gender 列:选择度 = 2/100000 = 0.00002 → 几乎没有选择性
name 列: 选择度 = 80000/100000 = 0.8 → 选择性很高
id 列: 选择度 = 100000/100000 = 1.0 → 完全唯一
-- 优化器的决策
EXPLAIN SELECT * FROM user WHERE gender = 'M';
-- 全表扫描!因为性别选择性太低
EXPLAIN SELECT * FROM user WHERE name = '张三';
-- 走索引!name 选择性高
Cardinality 的统计机制
统计方式
MySQL 通过采样来估算 Cardinality,不是精确统计:
-- InnoDB 的采样算法
-- 1. 随机选取 B+ 树的几个叶子节点
-- 2. 统计选中页面中不同值的数量
-- 3. 用这个平均值 × 总叶子节点数 = 估算 Cardinality
-- 例如:
-- 选了 8 个叶子页
-- 平均每个页有 100 个不同值
-- 总共有 1000 个叶子页
-- 估算 Cardinality = 100 × 1000 ≈ 100000
更新时机
InnoDB 的 Cardinality 统计会在以下时机更新:
-- 1. 表数据变化超过一定比例(默认 1/16)
SHOW VARIABLES LIKE 'innodb_stats_auto_update';
-- 默认 ON,自动更新
-- 2. 手动 ANALYZE TABLE
ANALYZE TABLE user;
-- 3. SHOW INDEX
SHOW INDEX FROM user; -- 也会触发统计更新
-- 4. 重启后首次访问
Cardinality 不准确导致的性能问题
-- 场景:索引选择性其实很高,但优化器不知道
-- 导致选择了错误的执行计划
EXPLAIN SELECT * FROM orders WHERE status = 'pending';
-- 优化器以为 status = 'pending' 有 50% 数据
-- 实际上只有 0.1%
-- 问题:优化器选择了全表扫描(误判)
解决方案
-- 1. 手动更新统计信息
ANALYZE TABLE orders;
-- 2. 调整采样页数(默认 8 页)
SET GLOBAL innodb_stats_persistent_sample_pages = 32;
-- 增大采样页 → 更精确 → 但 ANALYZE 更慢
-- 3. 使用 FORCE INDEX 强制使用索引
SELECT * FROM orders FORCE INDEX (idx_status)
WHERE status = 'pending';
-- 4. 如果无法根治,加一个更好的索引
CREATE INDEX idx_status_created ON orders(status, created_at);
复合索引的 Cardinality
复合索引的 Cardinality 是各列基数乘积的近似值:
-- 索引:idx_name_age(name, age)
-- name 基数:80,000
-- age 基数:50
-- 复合索引期望基数:80,000 × 50 = 4,000,000
SHOW INDEX FROM user;
+----------+----------------+--------------
| Key_name | Seq_in_index | Cardinality
+----------+----------------+--------------
| idx_name_age| 1 | 80000 ← name 基数
| idx_name_age| 2 | 4000000 ← name+age 组合基数
+----------+----------------+--------------
如何查看当前表的索引基数
-- 查看所有索引的基数
SHOW INDEX FROM table_name;
-- 更详细的信息
SELECT
TABLE_NAME,
INDEX_NAME,
SEQ_IN_INDEX,
COLUMN_NAME,
CARDINALITY,
(SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = t.TABLE_SCHEMA
AND TABLE_NAME = t.TABLE_NAME) AS total_rows
FROM information_schema.STATISTICS t
WHERE TABLE_SCHEMA = 'your_db'
AND TABLE_NAME = 'your_table';
选择度判断经验
| Cardinality / 总行数 | 判断 | 索引推荐 |
|---|---|---|
| > 0.7 | 选择性很高 | ✅ 强烈推荐建索引 |
| 0.1 ~ 0.7 | 选择性一般 | ⚠️ 需要根据场景评估 |
| < 0.1 | 选择性很低 | ❌ 索引价值不大 |
-- 实战:判断哪些列值得建索引
-- 高选择性:一定要建
-- 用户表 name、email、phone
-- 中等选择性:评估后建(或建复合索引)
-- 订单表 status(枚举值有限但查询频率高)
-- 低选择性:一般不考虑
-- 性别、是否删除(is_deleted)、类别大类
面试要点
- Cardinality 是估算值:InnoDB 通过采样统计,不是精确值
- 决定索引选择性:基数高的列更适合建独立索引
- 复合索引基数是乘积:复合索引整体基数 = 各列基数乘积的近似值
- 更新方式:自动更新(数据变化 1/16)或手动 ANALYZE TABLE
- 基数和索引失效无关:基数低只是优化器可能放弃索引,不是真正”失效”
- 不一致问题:基数统计不准会导致优化器选择错误执行计划
一句话总结:Cardinality 是索引的”身份证号数量”,数值越高说明索引越能精准定位数据,优化器越倾向于使用它;基数低的列独立索引意义不大。
EXPLAIN 的 type 列详解
EXPLAIN 的 type 列详解
type 列的作用
EXPLAIN 的 type 列描述了 MySQL 如何访问表,是衡量查询效率的核心指标。从好到差排序:
system > const > eq_ref > ref > range > index > ALL
system
表只有一行,是 const 的特例:
-- 系统表,只有一行
EXPLAIN SELECT * FROM mysql.time_zone;
-- 或子查询结果只有一行
EXPLAIN SELECT * FROM (SELECT 1 AS id) t;
+----+-------------+------------+--------+...
| id | select_type | table | type |...
+----+-------------+------------+--------+...
| 1 | SIMPLE | time_zone | system|...
特点:最优级别,实际业务很少遇到。
const
通过主键或唯一索引进行等值匹配,最多返回一行:
-- 主键等值匹配
EXPLAIN SELECT * FROM user WHERE id = 100;
-- 唯一索引等值匹配
EXPLAIN SELECT * FROM user WHERE email = 'test@example.com';
+------+-------+------+---------+...
| type | key | rows | Extra |
+------+-------+------+---------+
| const| PRIMARY| 1 | NULL |
特点:查询速度常快,因为只匹配一行就停止了。
MySQL 如何处理:优化器把 const 表视为常量,表上的数据可能被优化器用常数代替。
eq_ref
出现在多表 JOIN时,驱动表对于被驱动表通过主键或唯一索引进行等值匹配,且被驱动表只返回一行:
-- orders.user_id 是外键,关联 user 表的主键
EXPLAIN SELECT * FROM user u
JOIN orders o ON u.id = o.user_id;
+------+--------+---------+---------+...
| type | table | key | rows |
+------+--------+---------+---------+
| ALL | u | NULL | 1000 | ← 驱动表,全表扫描
| eq_ref| o | PRIMARY | 1 | ← 被驱动表,主键匹配
特点:JOIN 查询中被驱动表的最高效访问方式。
ref
通过非唯一索引或非唯一前缀进行等值匹配,可能返回多行:
-- 普通索引等值匹配
EXPLAIN SELECT * FROM user WHERE name = '张三';
-- 复合索引左前缀匹配
EXPLAIN SELECT * FROM user WHERE name = '张三' AND age = 25;
+------+-------+------+--------+...
| type | key | rows | Extra |
+------+-------+------+--------+
| ref | idx_name| 10 | NULL |
特点:最常见的”好”type 级别之一。
ref_or_null
在 ref 的基础上,额外查找 IS NULL 的记录:
EXPLAIN SELECT * FROM user
WHERE name = '张三' OR name IS NULL;
+-------------+----------+...
| type | key |
+-------------+----------+
| ref_or_null | idx_name |
特点:与 ref 相比多了一步 NULL 值的扫描。
range
索引上的范围扫描:
-- 大于、小于、BETWEEN
EXPLAIN SELECT * FROM user WHERE id > 100;
EXPLAIN SELECT * FROM user WHERE id BETWEEN 100 AND 200;
EXPLAIN SELECT * FROM user WHERE name LIKE '张%'; -- 前缀匹配
EXPLAIN SELECT * FROM user WHERE id IN (1, 2, 3);
+-------+----------+------+...
| type | key | rows |
+-------+----------+------+
| range | PRIMARY | 100 |
特点:比 ref 差一点,但仍是很不错的级别,索引扫描而非全表。
index
全索引扫描,遍历整个索引树:
-- 字段选择性太高,但数据全在索引中
EXPLAIN SELECT name, age FROM user;
-- 如果 name 和 age 在同一个复合索引中,就可能 type = index
-- 或没有 WHERE 条件但只查索引覆盖的列
EXPLAIN SELECT name FROM user;
+-------+----------+----------------------+
| type | key | Extra |
+-------+----------+----------------------+
| index | idx_name | Using index |
特点:比全表扫描好(索引通常比表小),但仍然需要扫描整个索引。
ALL
全表扫描,是最差的访问方式:
-- 没有索引的列
EXPLAIN SELECT * FROM user WHERE status = 1;
-- type = ALL,表示扫描了整个 user 表
-- 或者索引失效
EXPLAIN SELECT * FROM user WHERE YEAR(created_at) = 2024;
-- ALL
+------+------+---------+-------+
| type | key | rows | Extra |
+------+------+---------+-------+
| ALL | NULL | 1000000 | Using where |
特点:需要重点优化的目标,一般因为缺少索引或索引失效导致。
type 级别判断流程图
EXPLAIN type
│
├─ NULL(没有访问表)→ 如 SELECT 1
│
├─ table 只有一行 → system
│
├─ 主键等值查一行 → const
│
├─ JOIN 主键匹配 → eq_ref
│
├─ 非唯一索引等值 → ref
│
├─ 索引范围扫描 → range
│
├─ 全索引扫描 → index
│
└─ 全表扫描 → ALL ❌ 需要优化
实践建议
✅ type 期望值:
- 单表查询:const / ref / range
- JOIN 被驱动表:eq_ref
- 实在不行:index(覆盖索引)
❌ 看到 type = ALL 时:
1. 检查是否该加索引
2. 检查索引是否失效
3. 检查数据量是否过大(考虑分页)
4. 检查是否真的需要全部数据
面试要点
- type 是 EXPLAIN 最关键的字段,直接反映查询性能
- 速度排序:system > const > eq_ref > ref > range > index > ALL
- 业务常见最优:const(主键查询)、ref(索引等值)、range(范围)
- 需要警惕:ALL(全表扫描),index(全索引扫描)偶尔可接受
- NOT NULL 不等于 null:查询优化器的 type 评估不受 nullptr 影响
一句话总结:EXPLAIN 的 type 列是 SQL 性能的”晴雨表”,const/ref/range 表示高效,ALL 就是响警报——该优化了。
EXPLAIN 的 Extra 列关键信息
EXPLAIN 的 Extra 列关键信息
Extra 列的作用
EXPLAIN 的 Extra 列提供关于 MySQL 如何执行查询的额外细节。它揭示了许多重要的性能信息——比如是否使用了临时表、是否在文件排序、索引是否真正覆盖等。
需要重点关注的 Extra 信息
Using index(覆盖索引)
查询的所有列都包含在索引中,不需要回表:
-- 索引:idx_name_age(name, age)
EXPLAIN SELECT name, age FROM user WHERE name = '张三';
Extra: Using index
含义:~~不需要回表~~,性能优秀。这是你希望看到的结果。
Using index condition(索引下推 ICP)
EXPLAIN SELECT * FROM user WHERE name = '张三' AND age > 20;
Extra: Using index condition
含义:MySQL 5.6+ 的索引下推生效,存储引擎在索引层面过滤了部分数据。
与 Using index 的区别:
| Extra | 是否回表 | 场景 |
|---|---|---|
| Using index | 不回表 | 查询的列全在索引中(覆盖索引) |
| Using index condition | 部分回表 | ICP 过滤后仍需回表获取未覆盖的列 |
Using where
-- 没有索引辅助过滤
EXPLAIN SELECT * FROM user WHERE name LIKE '%张三%';
Extra: Using where
含义:MySQL 从引擎读取数据后,在 Server 层通过 WHERE 条件过滤。通常意味着索引没有完全覆盖过滤条件。
Using filesort(文件排序)
EXPLAIN SELECT * FROM user
WHERE name = '张三'
ORDER BY created_at;
-- 如果 name 索引中没有 created_at
Extra: Using filesort
含义:MySQL 需要额外排序,没有用到索引预排序。这是性能杀手之一,数据量大时性能急剧下降。
优化方法:
-- 方案一:在索引中包含排序列
-- 复合索引 idx(name, created_at) → filesort 消失
-- 方案二:减少排序数据量
EXPLAIN SELECT * FROM user
WHERE name = '张三'
ORDER BY created_at LIMIT 10;
-- LIMIT 可以减少排序的临时文件大小,但依然需要 filesort
Using temporary(临时表)
EXPLAIN SELECT name, COUNT(*)
FROM user
GROUP BY name
ORDER BY NULL;
Extra: Using temporary
含义:MySQL 需要使用临时表来完成查询。常见于以下场景:
-- GROUP BY 没有索引
EXPLAIN SELECT status, COUNT(*) FROM orders GROUP BY status;
-- Extra: Using temporary; Using filesort
-- DISTINCT 没有索引
EXPLAIN SELECT DISTINCT name FROM user;
-- UNION 去重
EXPLAIN SELECT name FROM user_a UNION SELECT name FROM user_b;
临时表的代价:
内存临时表 → 大小有限制(tmp_table_size / max_heap_table_size)
超过限制 → 转为磁盘临时表 → 写入磁盘 → 巨慢!
Using join buffer (Block Nested Loop)
EXPLAIN SELECT * FROM user u
LEFT JOIN orders o ON u.name = o.user_name;
-- user_name 在 orders 表没有索引
Extra: Using where; Using join buffer (Block Nested Loop)
含义:JOIN 时被驱动表没有可用索引,MySQL 把驱动表的数据放入 join buffer 然后逐批匹配。
优化方法:给被驱动表的连接列加索引。
Impossible WHERE
EXPLAIN SELECT * FROM user WHERE 1 = 0;
Extra: Impossible WHERE
含义:WHERE 条件永远不成立,MySQL 直接不扫描任何数据。
No tables used
EXPLAIN SELECT 1;
Extra: No tables used
含义:查询不涉及任何表。
Select tables optimized away
-- 查询直接基于索引中的聚合值
EXPLAIN SELECT MAX(id) FROM user;
Extra: Select tables optimized away
含义:MySQL 直接从索引中获取聚合值(MIN/MAX),无需读取任何行。
不重要的 Extra 信息
-- 这些通常不需要关注
-- "Using index for group-by"
-- "Using index for order by"
-- "Range checked for each record (index map: N)"
它们通常出现在 MySQL 已经进行了最优优化时。
警告级别的 Extra
| Extra 信息 | 严重程度 | 含义 |
|---|---|---|
| Using filesort | ⚠️ 高 | 排序未走索引 |
| Using temporary | ⚠️ 高 | 使用了临时表 |
| Using join buffer | ⚠️ 中 | JOIN 未用索引 |
| Using where | ⚠️ 低 | 数据在 Server 层过滤(必要时优化) |
理想状态
EXPLAIN SELECT name, age FROM user WHERE name = '张三';
Extra: Using index
最佳实践:查询尽量达到 Using index(覆盖索引)或只有 NULL(仅索引定位)。
实战排查流程
执行 EXPLAIN → 看 Extra
│
├─ Using index → ✅ 好!
│
├─ Using index condition → ✅ 不错(ICP 优化)
│
├─ NULL → ✅ 正常(索引定位 + 回表)
│
├─ Using where → ⚠️ 检查是否缺少索引
│
├─ Using filesort → ⚠️ 看能否加排序索引
│
└─ Using temporary → ❌ 必须优化(索引缺失或查询需要重写)
面试要点
- Extra 是 EXPLAIN 的第二核心字段,仅次于 type
- 优良:Using index / Using index condition / NULL
- 警告:Using filesort / Using temporary / Using join buffer
- Using filesort ≠ 磁盘文件:filesort 可能发生在内存中(sort_buffer),但仍然是额外的排序开销
- Using temporary 最重:临时表(尤其是磁盘临时表)对性能影响极大
- 覆盖索引是最终目标:
Using index意味着免除回表
一句话总结:EXPLAIN 的 Extra 列是 SQL 性能的”细节放大镜”,”Using index”代表高效,”Using filesort”和”Using temporary”是警告信号,需要重点排查优化。
EXPLAIN 分析执行计划
EXPLAIN 分析执行计划
什么是 EXPLAIN
EXPLAIN 是 MySQL 最常用的性能分析工具,用于查看 SQL 语句的执行计划:
EXPLAIN SELECT * FROM user WHERE name = '张三';
输出包含 12 个左右字段,每个字段揭示了 MySQL 如何执行这条 SQL。
EXPLAIN 输出字段总览
EXPLAIN SELECT * FROM user u
JOIN orders o ON u.id = o.user_id
WHERE u.name = '张三'\G
id: 1
select_type: SIMPLE
table: u
partitions: NULL
type: ref
possible_keys: PRIMARY,idx_name
key: idx_name
key_len: 152
ref: const
rows: 1
filtered: 100.00
Extra: NULL
字段速览:
| 字段 | 核心含义 | 重点关注 |
|---|---|---|
| id | 查询中 SELECT 的编号 | id 越大越先执行,相同则从上到下 |
| select_type | 查询类型 | SIMPLE、PRIMARY、SUBQUERY、DERIVED |
| table | 涉及的表 | 表名(可能有别名) |
| partitions | 涉及的分区 | 有分区表时关注 |
| type | 访问方式 | ⭐ 最重要的字段之一 |
| possible_keys | 可能使用的索引 | 候选索引列表 |
| key | 实际使用的索引 | ⭐ 看索引是否生效 |
| key_len | 使用的索引长度 | ⭐ 判断索引用了多少列 |
| ref | 与索引进行比较的列/常量 | const、列名 |
| rows | 预估扫描行数 | ⭐ 越少越好 |
| filtered | 表条件过滤的百分比 | rows × filtered 是最终行数 |
| Extra | 额外信息 | ⭐ 包含大量关键信息 |
如何使用 EXPLAIN
基本用法
-- 查看查询的执行计划
EXPLAIN SELECT * FROM user WHERE id = 100;
-- 查看 INSERT...SELECT 的执行计划
EXPLAIN INSERT INTO backup SELECT * FROM user WHERE age > 30;
-- 查看 UPDATE 的执行计划
EXPLAIN UPDATE user SET name = '新名' WHERE id = 100;
-- 查看 DELETE 的执行计划
EXPLAIN DELETE FROM user WHERE age > 60;
-- 多表查询
EXPLAIN SELECT *
FROM user u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2024-01-01';
更详细的版本
-- 显示更详细的信息(包括分区、过滤等)
EXPLAIN FORMAT=TRADITIONAL SELECT * FROM user WHERE id = 100;
-- JSON 格式,信息最完整
EXPLAIN FORMAT=JSON SELECT * FROM user WHERE id = 100\G
-- 树形格式显示
EXPLAIN FORMAT=TREE SELECT * FROM user WHERE id = 100\G
-- 实际执行并分析
EXPLAIN ANALYZE SELECT * FROM user WHERE id = 100\G
通过 EXPLAIN 判断常见问题
问题 1:索引是否生效
-- 查看 key 字段
EXPLAIN SELECT * FROM user WHERE phone = 13800138000;
-- key: NULL → 索引失效(隐式类型转换)
-- key: idx_phone → 索引正常使用
问题 2:复合索引用了多少列
-- 索引 idx(name, age, status)
EXPLAIN SELECT * FROM user WHERE name = '张三' AND age > 20;
-- key_len: 计算 → name(152) + age(5) = 157
-- 说明 name 和 age 都走了索引,后面的 status 没用上(范围截断)
问题 3:回表次数
-- 查看 Extra
EXPLAIN SELECT * FROM user WHERE name = '张三';
-- Extra: NULL → 走索引然后回表
EXPLAIN SELECT name, age FROM user WHERE name = '张三';
-- Extra: Using index → 覆盖索引,不需要回表
问题 4:排序是否使用索引
EXPLAIN SELECT * FROM user WHERE name = '张三' ORDER BY age;
-- Extra: NULL → 索引预排序(不需要 filesort)
EXPLAIN SELECT * FROM user WHERE name = '张三' ORDER BY created_at;
-- Extra: Using filesort → 需要额外排序(创建了临时文件)
EXPLAIN ANALYZE(MySQL 8.0.18+)
-- 实际执行 SQL 并返回详细的执行分析
EXPLAIN ANALYZE SELECT * FROM user WHERE name = '张三'\G
输出示例:
-> Index lookup on user using idx_name (name='张三') (cost=1.01 rows=1)
(actual time=0.123..0.125 rows=1 loops=1)
cost=1.01:优化器估算的成本actual time=0.123..0.125:实际执行时间(开始到结束)rows=1:实际返回行数loops=1:该操作循环次数
典型执行计划解读
-- 示例:多表 JOIN
EXPLAIN SELECT u.name, o.order_no, o.amount
FROM user u
JOIN orders o ON u.id = o.user_id
WHERE u.name LIKE '张%'
ORDER BY o.created_at DESC\G
id: 1
select_type: SIMPLE
table: u
type: range
possible_keys: PRIMARY,idx_name
key: idx_name
key_len: 152
rows: 1000
Extra: Using where; Using index
id: 1
select_type: SIMPLE
table: o
type: ref
possible_keys: idx_user_id
key: idx_user_id
key_len: 8
rows: 5
Extra: Using where; Using filesort
解读:
1. 先查 u 表:idx_name 上做范围扫描(LIKE),预估 1000 行
2. 对 u 的每一行,用 idx_user_id 在 o 表上匹配
3. o 表需要 filesort(按 created_at DESC 排序)
EXPLAIN FORMAT=JSON 中的隐藏信息
EXPLAIN FORMAT=JSON SELECT * FROM user WHERE name = '张三'\G
JSON 中除了 EXPLAIN 表字段,还有:
{
"query_block": {
"select_id": 1,
"cost_info": {
"query_cost": "1.01" // 优化器评估的总体成本
},
"table": {
"access_type": "ref",
"possible_keys": ["idx_name"],
"key": "idx_name",
"used_key_parts": ["name"], // 用了索引的哪些部分
"key_length": "152"
}
}
}
使用 EXPLAIN 的黄金流程
1. 发现问题
→ 慢查询日志中收集慢 SQL
2. EXPLAIN 分析
→ type 是不是 ALL?
→ key 是不是 NULL?
→ rows 是不是很大?
→ Extra 有没有 Using filesort?
3. 分析问题原因
→ 索引缺失?索引失效?SQL 写法问题?
4. 优化
→ 加索引、改 SQL、调整表结构
5. 再次 EXPLAIN 验证
→ type 是否改善
→ rows 是否减少
→ Extra 中的警告是否消失
面试要点
- EXPLAIN 是 MySQL 调优的第一步:先看 type、key、rows、Extra
- type 从好到差:system > const > eq_ref > ref > range > index > ALL
- key 是否为 NULL:NULL 表示没有使用索引
- Extra 重点关注:Using filesort、Using temporary、Using index condition
- FORMAT=JSON:获取更完整的执行信息
- EXPLAIN ANALYZE(8.0.18+):实际执行,给出真实耗时
一句话总结:EXPLAIN 是 MySQL 性能分析的”听诊器”,重点看 type、key、rows、Extra 四个字段就能诊断 90% 的性能问题。
复合索引不遵循最左前缀
复合索引不遵循最左前缀
最左前缀原则回顾
最左前缀原则是复合索引的核心规则:使用复合索引时,必须从索引的最左列开始,连续匹配。
-- 复合索引:idx_a_b_c(a, b, c)
-- ✅ 走索引
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
WHERE a = 1 AND c = 3 -- a 走了索引,c 无法匹配最左前缀
-- ❌ 不走索引(缺少 a)
WHERE b = 2
WHERE c = 3
WHERE b = 2 AND c = 3
不理解最左前缀的典型错误
错误一:认为”有索引就能用”
-- 索引:idx_status_created(status, created_at)
-- 开发者以为:created_at 应该在索引了吗?
SELECT * FROM orders WHERE created_at > '2024-01-01';
-- ❌ 没有 status → 全表扫描
纠正:复合索引的每个列不是独立可用的,必须从最左边开始。
错误二:认为”按任意顺序写条件”
-- 索引:idx_a_b_c(a, b, c)
-- 虽然优化器会重排条件
WHERE c = 3 AND b = 2 AND a = 1 -- ✅ 优化器重排后走索引
-- 但这是"包含"不是"跳过"
WHERE c = 3 -- ❌ 没有 a,没有 b,优化器也重排不了
纠正:优化器能重排 AND 条件的顺序,但不能凭空创造不存在的列。
错误三:理解跳跃扫描 = 打破最左前缀
-- 索引:idx_status_created(status, created_at)
-- MySQL 8.0.13 的跳跃扫描
SELECT * FROM orders WHERE created_at BETWEEN '2024-01-01' AND '2024-01-31';
-- 可能走跳跃扫描:Extra: Using index for skip scan
-- 但这不是"打破最左前缀"
-- 本质是:隐式拆分为多个子查询,每个子查询都符合最左前缀
纠正:跳跃扫描是优化技巧,不是最左前缀规则被打破。
不遵循最左前缀时 MySQL 怎么执行
-- 索引:idx_a_b_c(a, b, c)
-- 查询:WHERE b = 2 AND c = 3
-- MySQL 的执行方式:
-- 选项 A:无法使用 idx_a_b_c
-- 因为无法在 a 列进行二分查找
-- 选项 B:检查有没有 b 的单列索引
-- 如果有 idx_b → 用 idx_b
-- 选项 C:全表扫描
-- 如果没有合适的单列索引
-- 但 idx_a_b_c 本身在这个查询中完全用不上
常见复合索引设计错误
错误设计一:把选择性高的列放后面
-- 索引:idx_cat_date(category, created_at)
-- 常见查询:日期范围
SELECT * FROM products WHERE created_at > '2024-01-01';
-- ❌ 全表扫描!category 不在条件中
-- 应该补一个单列索引
CREATE INDEX idx_created_at ON products(created_at);
-- 或考虑调换顺序
-- CREATE INDEX idx_date_cat ON products(created_at, category);
错误设计二:只看有没有用,不看能不能用
-- 索引:idx_name_age(name, age)
-- 以下查询能走索引吗?
SELECT * FROM user WHERE age = 18;
-- ❌ 缺少 name,不能用这个索引
-- 尽管 age 在索引中,但通过 age 无法在 B+ 树中定位
-- B+ 树第一层是按 name 排序的
错误设计三:不考虑 OR 条件
-- 索引:idx_name_age(name, age)
-- OR 条件
SELECT * FROM user WHERE name = '张三' OR age = 18;
-- ❌ 复合索引处理不了 OR
-- OR 需要两个列各自有独立索引(或 index_merge)
如何验证复合索引是否有效
-- 使用 EXPLAIN 检查
-- 情况 1:完全匹配最左前缀
EXPLAIN SELECT * FROM t WHERE a = 1 AND b = 2;
-- type: ref, key: idx_a_b_c → 走索引
-- 情况 2:部分匹配最左前缀
EXPLAIN SELECT * FROM t WHERE a = 1 AND c = 3;
-- type: ref, key: idx_a_b_c
-- key_len: 5(a 列长度)→ 只有 a 走了索引,c 走了 ICP 或回表过滤
-- 情况 3:不遵循最左前缀
EXPLAIN SELECT * FROM t WHERE b = 2 AND c = 3;
-- type: ALL, key: NULL → 全表扫描
面试快速判断
-- 索引:idx(a, b, c)
-- 哪些查询能走索引(至少部分走)?
-- 1. WHERE a = 1 → ✅ 走 a
-- 2. WHERE a = 1 AND b = 2 → ✅ 走 a, b
-- 3. WHERE a = 1 AND c = 3 → ✅ 走 a(仅 a 列,c 在 ICP 或回表后过滤)
-- 4. WHERE a = 1 AND b > 2 → ✅ 走 a, b(b 是范围)
-- 5. WHERE a = 1 AND b > 2 AND c = 3 → ✅ 走 a,b,c 在范围截断后失效
-- 6. WHERE a = 1 ORDER BY b → ✅ 索引预排序
-- 7. WHERE a = 1 ORDER BY c → ❌ filesort(b 被跳过)
-- 8. WHERE b = 2 → ❌ 没有 a
-- 9. WHERE c = 3 → ❌ 没有 a
-- 10. WHERE a = 1 OR b = 2 → ❌ OR 不走复合索引
如何避免违反最左前缀
方法一:根据查询设计索引
-- 先梳理出所有高频查询
-- 查询 1:WHERE status = ? AND created_at > ?
-- 查询 2:WHERE created_at > ? (单独查询)
-- 索引设计:
CREATE INDEX idx_status_created ON orders(status, created_at);
-- 满足查询 1
CREATE INDEX idx_created ON orders(created_at);
-- 满足查询 2(查询 1 也可以用 idx_status_created)
方法二:检查遗漏的单列查询
-- 现有复合索引 idx_a_b_c(a, b, c)
-- 检查是否还有只查 b、只查 c 的查询
-- 如果有:补单列索引
CREATE INDEX idx_b ON t(b);
CREATE INDEX idx_c ON t(c);
方法三:跳跃扫描兜底(8.0.13+)
-- MySQL 8.0.13+ 优化器会尝试跳跃扫描
-- 不需要手动处理,但要求被跳过的列基数低
-- 检查是否触发
EXPLAIN SELECT * FROM orders WHERE created_at > '2024-01-01';
-- Extra: Using index for skip scan → 用了跳跃扫描
面试要点
- 最左前缀不是建议而是规则:缺少最左列,复合索引完全无效
- 优化器只重排顺序不凭空创建列:
WHERE b = 2 AND a = 1可以但WHERE b = 2不行 - 跳跃扫描≠打破最左前缀:跳过的列基数必须很低
- key_len 最直接:EXPLAIN 的 key_len 告诉你索引真正用了哪些列
- 解决不缺列但缺索引:为独立查询补单列索引
一句话总结:复合索引的最左列必须出现在 WHERE 条件中,否则整个索引用不上——这是 B+ 树的数据结构决定的,不是 MySQL 的”脾气”。
LIKE 通配符开头导致索引失效
LIKE 通配符开头导致索引失效
问题复现
-- name 列有索引
CREATE INDEX idx_name ON user(name);
-- ❌ 索引失效
SELECT * FROM user WHERE name LIKE '%张三%';
-- ✅ 索引生效
SELECT * FROM user WHERE name LIKE '张三%';
同样是 LIKE,为什么 % 在前在后差别这么大?
B+ 树索引的查找原理
B+ 树对字符串的查找本质上是在做前缀匹配:
B+ 树中字符串按字典序排列:
'aaa'
'aab' ← 'aab%' 可以在这里定位
'aac'
'baa' ← '%baa' 无法定位!前缀可以是任何值
'bab'
查找 'abc%':
→ 定位到以 'abc' 开头的第一个位置(边界值)
→ 向后扫描直到不以 'abc' 开头
→ 这是范围扫描,B+ 树擅长
查找 '%abc':
→ 任何字符串都可能以 'abc' 结尾
→ B+ 树无法确定搜索起点
→ 必须全索引扫描或全表扫描
三种 LIKE 写法对索引的影响
-- 1. 完全前缀匹配 → 索引效果最好
WHERE name LIKE '张三%'
-- 定位到 "张三" 开头的第一个位置,范围扫描
-- 效率等同于 name >= '张三' AND name < '张四'
-- 2. 左右通配 → 索引失效
WHERE name LIKE '%张三%'
-- 必须全表扫描或全索引扫描
-- 3. 后缀匹配 → 索引失效
WHERE name LIKE '%张三'
-- 同上,无法定位起点
验证实验
-- 建表测试
CREATE TABLE test_like (
id INT PRIMARY KEY,
name VARCHAR(50),
INDEX idx_name(name)
);
INSERT INTO test_like VALUES
(1, '张三丰'), (2, '张三'), (3, '李四'),
(4, '王张三'), (5, '张飞'), (6, '一张三');
-- 验证 1:前缀匹配
EXPLAIN SELECT * FROM test_like WHERE name LIKE '张%';
-- type: range, key: idx_name
-- Extra: Using index condition
-- 验证 2:后缀匹配
EXPLAIN SELECT * FROM test_like WHERE name LIKE '%张三';
-- type: ALL, key: NULL
-- Extra: Using where
-- 验证 3:左右通配
EXPLAIN SELECT * FROM test_like WHERE name LIKE '%张%';
-- type: ALL, key: NULL
-- Extra: Using where
如何优化 LIKE ‘%xxx’ 查询
方案一:覆盖索引(部分场景可用)
-- 如果查询的列全部在索引中,MySQL 可能走全索引扫描
CREATE INDEX idx_name_id ON user(name, id);
-- 这个查询可以走 idx_name_id 的全索引扫描
-- 因为 id 也在索引中,不需要回表
SELECT id, name FROM user WHERE name LIKE '%张三%';
-- Extra: Using where; Using index
-- 注意:这是扫描整个索引,不是二分查找
-- 但索引通常比表小,比全表扫描快
方案二:全文索引(适合文本搜索)
-- 适合搜索文章、大文本内容
ALTER TABLE article ADD FULLTEXT INDEX ft_content(content);
-- 使用全文索引
SELECT * FROM article
WHERE MATCH(content) AGAINST('数据库优化' IN BOOLEAN MODE);
方案三:搜索引擎(最推荐)
-- 如果 LIKE 搜索是核心功能
-- 用 Elasticsearch 替代 MySQL 的模糊匹配
-- MySQL 中只做精确查询和范围查询
-- 模糊搜索交给 ES
SELECT id FROM article WHERE MATCH(content) AGAINST('...'); -- ES
SELECT * FROM article WHERE id IN (1,2,3); -- MySQL 回表
方案四:前缀索引配合业务约束
-- 如果业务允许,约束用户必须输入至少前 N 个字符
-- 前端控制:最少输入 2 个字符才发起查询
-- 然后可以用前缀匹配
WHERE name LIKE '张%'; -- 走索引
WHERE name LIKE '张三%'; -- 走索引,更精确
-- 应用层兜底:不输入前缀则限制结果条数
-- 避免用户输入单个字符就全表扫描
方案五:倒序存储(取巧方案)
-- 如果经常做后缀匹配 '%abc'
-- 可以把字符串倒序存储
-- 原查询:WHERE name LIKE '%abc'
-- 新设计:
-- 增加倒序列
ALTER TABLE user ADD COLUMN name_reverse VARCHAR(50)
GENERATED ALWAYS AS (REVERSE(name)) STORED;
CREATE INDEX idx_name_reverse ON user(name_reverse);
-- 查询改写
SELECT * FROM user WHERE name_reverse LIKE REVERSE('abc') || '%';
-- 等价于 'cba%',可以用前缀匹配!
-- 走索引!
IN 能否替代 LIKE
-- LIKE 前缀匹配转为 IN
WHERE name LIKE '张%'
-- 转为:
WHERE name >= '张' AND name < '龥'
-- 或
WHERE name LIKE '张%' -- 本身就走索引
-- LIKE '%xxx' 无法转为 IN
WHERE name LIKE '%张%'
-- 任何 WHERE name IN (...) 都无法等价替代
-- 因为 '张' 可以在字符串任何位置
MySQL 8.0 函数索引对 LIKE 的帮助
-- 如果经常按后缀匹配,可以用 REVERSE 函数索引
-- 8.0 方案
CREATE INDEX idx_rev_name ON user((REVERSE(name)));
-- 查询
SELECT * FROM user WHERE REVERSE(name) LIKE REVERSE('%abc') || '%';
-- MySQL 会自动跳过尾部通配符,使用扫描
面试要点
- 为什么失效:B+ 树索引依赖前缀有序性,
%在开头破坏前缀匹配能力 - 三种写法:
abc%走索引,%abc和%abc%不走 - 优化手段:覆盖索引(全索引扫描)、全文索引、搜索引擎、倒序存储
- 最本质的替换思路:想办法把
%xxx变成xxx% - 与隐式转换区别:LIKE 开头通配符失效是 B+ 树结构决定的,不是函数问题
一句话总结:LIKE 以通配符开头必然索引失效,因为 B+ 树不知道从哪里开始搜索;能通过覆盖索引缓解但无法根治,高频模糊搜索应该交给搜索引擎。
OR 条件含非索引列问题
OR 条件含非索引列问题
问题描述
-- 有索引
CREATE INDEX idx_age ON user(age);
-- ❌ 即使 age 有索引,这个查询也会全表扫描
SELECT * FROM user WHERE age = 25 OR name = '张三';
-- name 没有索引 → 全表扫描
这就是 OR 条件导致的索引失效:OR 两边有一个条件没有索引,整个查询就降级为全表扫描。
为什么 OR 会导致全表扫描
OR 的语义决定了无法分步执行
查询:WHERE age = 25 OR name = '张三'
语义:只要满足任意一个条件就返回
执行流程(如果只走 idx_age):
① 在 idx_age 中找 age=25 → 得到一批记录(标记这批)
② 这批记录中,如果 name='张三' 也在其中 → 返回
③ 如果 name='张三' 不在其中 → 就不返回了吗?
不对!OR 的意思是 "只要满足一个条件"
① 只找到了 age=25 的,但 name='张三' 且 age≠25 的没有找到
问题:MySQL 不知道 name='张三' 且 age≠25 的数据在哪里
name 没有索引,要找到 name='张三' 必须全表扫描
MySQL 的决策逻辑
优化器思考:
- 选项 A:走 idx_age 查 age=25 + 全表扫描查 name='张三' = 两次扫描
- 选项 B:直接全表扫描,逐行检查 age=25 OR name='张三' = 一次扫描
MySQL 认为:两次扫描不如一次扫描 → 全表扫描
UNION 改写方案
-- ❌ 原查询(全表扫描)
SELECT * FROM user WHERE age = 25 OR name = '张三';
-- ✅ UNION 改写:分两次走索引,再合并
SELECT * FROM user WHERE age = 25
UNION
SELECT * FROM user WHERE name = '张三';
-- 或 UNION ALL(如果没有重复)
SELECT * FROM user WHERE age = 25
UNION ALL
SELECT * FROM user WHERE name = '张三';
UNION 的执行逻辑
查询 1:SELECT * FROM user WHERE age = 25
→ 走 idx_age,快速定位
查询 2:SELECT * FROM user WHERE name = '张三'
→ 走 idx_name(需要先建这个索引)
→ 快速定位
UNION:去重合并(或 UNION ALL 直接合并)
OR 两边都有索引就不失效
-- 给 name 也建索引
CREATE INDEX idx_age ON user(age);
CREATE INDEX idx_name ON user(name);
-- OR 两边都有索引
SELECT * FROM user WHERE age = 25 OR name = '张三';
-- MySQL 8.0 可能做:
-- 走 idx_age 查 age=25 → 结果集 A
-- 走 idx_name 查 name='张三' → 结果集 B
-- A ∪ B 去重
-- 看执行计划
EXPLAIN SELECT * FROM user WHERE age = 25 OR name = '张三';
-- Extra: Using union(idx_age,idx_name); Using where
-- type: index_merge
MySQL 的 index_merge 优化:在 OR 两边都有索引时,可以分别走索引再合并结果。
注意:index_merge 不是全能的,两个索引合并仍然需要回表,且结果集大的时候效率不如全表扫描。
OR 的特殊版本:IN 是好的
-- ❌ OR 写法(可能失效)
SELECT * FROM user WHERE age = 25 OR age = 30;
-- ✅ IN 写法(必然走索引)
SELECT * FROM user WHERE age IN (25, 30);
OR 连接同一列的多个等值时,优化器会自动转为 IN:
-- 这两个是等价的
WHERE age = 25 OR age = 30
-- 优化器转成
WHERE age IN (25, 30)
但 OR 连接不同列时,不会自动转为 IN。
OR 与 AND 的对比
-- AND 连接不同列:复合索引正常工作
CREATE INDEX idx_age_name ON user(age, name);
SELECT * FROM user WHERE age = 25 AND name = '张三';
-- 走 idx_age_name,最左前缀匹配
-- OR 连接不同列:要求两边都有独立索引
SELECT * FROM user WHERE age = 25 OR name = '张三';
-- 需要 idx_age 和 idx_name,不是 idx_age_name
OR 索引失效的”不是陷阱的陷阱”
-- 这不是 OR 陷阱陷阱,因为创建了覆盖索引
CREATE INDEX idx_covering ON user(age, name, id);
-- 或者分别建索引
SELECT id, age FROM user WHERE age = 25 OR name = '张三';
-- 用了覆盖索引 → Extra: Using index
-- 因为所有数据都在索引中
实际案例:电商订单查询
-- 业务场景:根据订单ID 或 手机号 查询订单
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
order_no VARCHAR(32),
user_phone VARCHAR(20),
amount DECIMAL(10,2),
INDEX idx_order_no(order_no),
INDEX idx_phone(user_phone)
);
-- 这个查询会使用 index_merge
SELECT * FROM orders
WHERE order_no = 'ORD20240115001'
OR user_phone = '13800138000';
-- 执行计划
EXPLAIN SELECT * ...
-- type: index_merge
-- Extra: Using union(idx_order_no,idx_phone); Using where
OR 索引失效的判断流程图
OR 条件
│
├─ 所有列都有独立索引 → index_merge(可能走索引合并)
│
└─ 有一个列没有索引 → 全表扫描
│
├─ 方案一:给缺失的列建索引
├─ 方案二:改写为 UNION
├─ 方案三:用覆盖索引
└─ 方案四:确认业务逻辑能否用 AND 替代
面试要点
- OR 核心问题:只要有一个条件列没有索引,整个查询全表扫描
- 两侧都有索引:MySQL 可能用 index_merge(索引合并)优化
- 改写方案:UNION 分拆为多个 SQL 再合并
- IN 优于 OR:
col IN (1,2)比col=1 OR col=2更好 - 复合索引不解决 OR:OR 需要各列自己的索引,不是一个复合索引
- index_merge 的限制:MySQL 5.6 引入,但性能不如预期时可以改写 UNION
一句话总结:OR 连接不同列时,要么给每列独立建索引(触发 index_merge),要么用 UNION 改写,否则必然全表扫描。
索引列使用函数导致失效
索引列使用函数导致失效
问题本质
当 WHERE 条件中对索引列使用函数时,MySQL 无法直接利用该列的 B+ 树索引。
-- created_at 有索引
-- ❌ 失效:DATE() 函数包裹 created_at
SELECT * FROM orders WHERE DATE(created_at) = '2024-01-15';
-- 原因:B+树中存储的是 created_at 的完整值
-- '2024-01-15 10:30:00'
-- '2024-01-15 14:20:00'
-- '2024-01-16 09:00:00'
-- MySQL 无法用 DATE(完整值) 进行二分查找
为什么函数包裹索引列会导致失效
B+树索引的查找机制
B+树索引的搜索依赖原始的字段值:
B+树中存储的键值:
[2024-01-14 08:00:00]
[2024-01-14 12:00:00]
[2024-01-15 10:30:00] ← 实际存储的值
[2024-01-15 14:20:00]
[2024-01-16 09:00:00]
查询:DATE(created_at) = '2024-01-15'
尝试二分查找:
比较 DATE(2024-01-14 08:00:00) = '2024-01-15' ? → no
比较 DATE(2024-01-15 10:30:00) = '2024-01-15' ? → yes
...
问题:B+树的二分查找比较的是原始值
DATE('2024-01-15 10:30:00') 不是 '2024-01-15'
原始值不有序 → 无法用比较搜索
关键点:B+树的有序性建立在原始存储值上。一旦对列施加函数,有序性被破坏,无法二分查找。
常见导致失效的函数
-- 日期/时间函数
WHERE DATE(created_at) = '2024-01-01'
WHERE YEAR(created_at) = 2024
WHERE MONTH(created_at) = 1
WHERE DAY(created_at) = 15
WHERE HOUR(created_at) = 10
WHERE WEEK(created_at) = 3
WHERE UNIX_TIMESTAMP(created_at) > 1700000000
-- 字符串函数
WHERE LEFT(name, 3) = 'abc'
WHERE SUBSTRING(name, 1, 3) = 'abc'
WHERE CONCAT(first_name, ' ', last_name) = '张三'
WHERE LOWER(name) = 'zhangsan'
WHERE UPPER(name) = 'ZHANGSAN'
WHERE TRIM(name) = '张三'
-- 数学/数值函数
WHERE ABS(score) = 100
WHERE ROUND(price) = 100
WHERE CEIL(discount) = 2
WHERE price * 1.1 > 100 -- 列参与运算也算函数
-- 类型转换函数
WHERE CAST(phone AS SIGNED) = 13800138000
-- 其它函数
WHERE IFNULL(age, 0) = 18
WHERE COALESCE(status, 'default') = 'active'
MySQL 8.0 函数索引方案
MySQL 8.0.13 提供了函数索引直接解决此问题:
-- 创建函数索引
CREATE INDEX idx_date_created ON orders((DATE(created_at)));
CREATE INDEX idx_year_created ON orders((YEAR(created_at)));
CREATE INDEX idx_lower_name ON user((LOWER(name)));
CREATE INDEX idx_concat_name ON user((CONCAT(first_name, ' ', last_name)));
-- 现在可以走索引了
SELECT * FROM orders WHERE DATE(created_at) = '2024-01-15';
SELECT * FROM orders WHERE YEAR(created_at) = 2024;
SELECT * FROM user WHERE LOWER(name) = 'zhangsan';
无法使用函数索引时的改写方案
日期函数改写为范围查询
-- ❌ 函数导致索引失效
SELECT * FROM orders WHERE DATE(created_at) = '2024-01-15';
-- ✅ 改写为范围查询
SELECT * FROM orders
WHERE created_at >= '2024-01-15 00:00:00'
AND created_at < '2024-01-16 00:00:00';
-- ❌ 函数导致索引失效
SELECT * FROM orders WHERE YEAR(created_at) = 2024 AND MONTH(created_at) = 1;
-- ✅ 改写为范围查询
SELECT * FROM orders
WHERE created_at >= '2024-01-01 00:00:00'
AND created_at < '2024-02-01 00:00:00';
计算列方案(5.7+)
-- 使用 MySQL 5.7 的生成列
ALTER TABLE user
ADD COLUMN first_char VARCHAR(1)
GENERATED ALWAYS AS (LEFT(name, 1)) STORED;
-- 在生成列上建索引
CREATE INDEX idx_first_char ON user(first_char);
-- 查询走索引
SELECT * FROM user WHERE first_char = '张';
-- 等价于 LEFT(name, 1) = '张'
前缀索引方案(部分特例)
-- 如果函数是 LEFT/SUBSTRING 取固定前 N 位
-- 可以用前缀索引替代
-- 原查询
WHERE LEFT(name, 3) = 'abc';
-- 前缀索引
CREATE INDEX idx_name_prefix ON user(name(3));
-- 注意:走的是 name 的前 3 个字符,不是 LEFT 函数
-- 但效果等价
一些容易忽略的”函数”
-- 这些看起来不像函数,但本质是一样的
-- 类型转换(隐式也行)
WHERE CAST(id AS CHAR) = '100'; -- 显式
WHERE id = '100'; -- 隐式转换,同上
-- 排序中的函数
SELECT * FROM t ORDER BY LEFT(name, 1);
-- GROUP BY 中的函数
SELECT DATE(created_at), COUNT(*) FROM t GROUP BY DATE(created_at);
-- JOIN 条件中的函数
SELECT * FROM a JOIN b ON DATE(a.created_at) = DATE(b.created_at);
面试要点
- 为什么失效:B+ 树基于原始列值构建有序结构,函数破坏了有序性
- 最常见的函数:DATE、YEAR、MONTH、LEFT、CONCAT、LOWER
- 8.0 解决方案:函数索引
CREATE INDEX idx ON t((FUNC(col))) - 5.7 替代方案:生成列 + 索引,或者改写为范围查询
- 改写原则:尽量把函数移到”=”另一边,或者用范围替代
- 千万注意:列参与运算(
col + 1 = 10)同样属于”函数导致索引失效”
一句话总结:对索引列使用任何函数都会破坏 B+ 树的有序性导致索引失效,解决方案是 8.0 的函数索引或用范围查询等价改写。
隐式类型转换导致索引失效
隐式类型转换导致索引失效
什么是隐式类型转换
MySQL 在执行 SQL 时,如果发现比较两边的数据类型不一致,会自动将其中一方转换为另一方的类型,这个过程就是隐式类型转换。
-- name 是 VARCHAR(50) 类型
SELECT * FROM user WHERE name = 123;
-- MySQL 发现:VARCHAR vs INT
-- 隐式转换:CAST(name AS INT) = 123
-- 结果:name 被函数包裹 → 索引失效!
隐式类型转换的规则
MySQL 的类型转换遵循以下规则:
规则 1:字符串 vs 数字 → 字符串转数字
'123' = 123 → 123 = 123 ✅
'123abc' = 123 → 123 = 123 ✅(字符串前缀数字)
'abc123' = 123 → 0 = 123 ❌(无数字前缀)
规则 2:字符串 vs 时间 → 字符串转时间
'2024-01-15' = DATE → 正常比较
规则 3:十六进制 → 二进制字符串
'A' = x'41' → 字符比较
规则 4:其它类型 → 字符串
CAST(... AS CHAR)
最常见的陷阱:数字 vs 字符串
-- 表结构
CREATE TABLE user (
id INT PRIMARY KEY,
phone VARCHAR(20) NOT NULL,
name VARCHAR(50),
INDEX idx_phone(phone),
INDEX idx_name(name)
);
-- 正确的传参
SELECT * FROM user WHERE phone = '13800138000';
-- phone 是 VARCHAR,传入字符串 → 正常走索引
-- 错误的传参(极常见!)
SELECT * FROM user WHERE phone = 13800138000;
-- phone 是 VARCHAR,传入 INT → 隐式转换
-- 等价于 CAST(phone AS INT) = 13800138000 → 索引失效!
显式验证
-- 验证方式 1:EXPLAIN
EXPLAIN SELECT * FROM user WHERE phone = 13800138000;
| type | key | Extra |
|------|-----------|-------------|
| ALL | NULL | Using where | ← 全表扫描
EXPLAIN SELECT * FROM user WHERE phone = '13800138000';
| type | key | Extra |
|------|-----------|-------------|
| ref | idx_phone | Using where | ← 走索引
更隐蔽的隐式转换
-- 1. 应用程序层传入的参数类型不对
-- Java 中:
// ❌ phone 是字符串,但传了 long
preparedStatement.setLong(1, 13800138000L);
// ✅ 应该传字符串
preparedStatement.setString(1, "13800138000");
-- 2. 框架的自动类型映射问题
-- 很多 ORM 框架可能把 INT 类型的列自动映射为 Long
-- 需要检查框架的类型映射
-- 3. 表结构的设计问题
CREATE TABLE t1 (
id VARCHAR(20) PRIMARY KEY, -- ID 用 VARCHAR,但经常传入数字
...
);
-- 业务代码经常传数字 ID → 每次查询都隐式转换
隐式转换回表测试
-- 测试数据
CREATE TABLE test_convert (
id INT PRIMARY KEY,
a VARCHAR(10),
INDEX idx_a(a)
);
INSERT INTO test_convert VALUES
(1, '100'), (2, '200'), (3, '300'), (4, '400'), (5, '500');
-- ❌ 隐式转换——不走索引
SELECT * FROM test_convert WHERE a = 100;
/* 执行过程:
CAST(a AS INT) = 100
→ 无法用 idx_a 定位
→ 全表扫描:逐行 CAST(a) 再比较
→ 每次比较还要 CAST 计算开销
*/
-- ✅ 正确写法——走索引
SELECT * FROM test_convert WHERE a = '100';
/* 执行过程:
在 idx_a 中二分查找 '100'
→ 回表读取完整记录
*/
其它类型的隐式转换
-- 时间类型隐式转换
CREATE TABLE events (
id INT PRIMARY KEY,
event_date DATE,
INDEX idx_date(event_date)
);
-- ❌ 字符串 vs DATE
SELECT * FROM events WHERE event_date = '2024-01-15';
-- event_date 是 DATE,'2024-01-15' 是 VARCHAR
-- MySQL 会将 '2024-01-15' 转为 DATE,不会导致索引失效
-- ❌ 另一种:DATE vs 数字
SELECT * FROM events WHERE event_date = 20240115;
-- event_date 是 DATE,20240115 是 INT
-- CAST(event_date AS INT) = 20240115 → 索引失效!
-- ❌ 字符集隐式转换
-- utf8 列 vs utf8mb4 值(或反之)
-- 会导致索引失效!
SELECT * FROM user WHERE name = _utf8mb4'张三';
-- 如果 name 列是 utf8,比较时会转换 → 索引失效
如何发现隐式转换问题
-- 方式 1:EXPLAIN 查看 Extra 列
EXPLAIN SELECT * FROM user WHERE phone = 13800138000;
-- Extra: Using where; Using index → 可能没问题
-- Extra: Using where → 可能是全表扫描或索引失效
-- 方式 2:SHOW WARNINGS
EXPLAIN EXTENDED SELECT * FROM user WHERE phone = 13800138000;
SHOW WARNINGS;
-- 可以看到优化器重写的 SQL,会显示 CAST
-- 方式 3:慢查询日志分析
-- 开启慢查询日志
SET GLOBAL slow_query_log = ON;
-- 找到慢 SQL
-- 用 EXPLAIN 一个个分析是否索引失效
-- 方式 4:表结构信息确认
DESC user;
-- 确认字段类型,检查代码中的传参类型
如何避免隐式转换
-- 1. 查询时传入正确的类型
-- 始终用单引号包裹字符串
SELECT * FROM user WHERE phone = '13800138000';
-- 2. 使用参数化查询时注意类型
-- Java:preparedStatement.setString(1, phone)
-- Python:cursor.execute("SELECT * FROM user WHERE phone = %s", (phone,))
-- 3. 应用程序中保持类型一致性
-- 有 Phone 类/类型 → 统一用字符串
-- 不要混用 Long/String
-- 4. 表设计合理
-- 手机号 → VARCHAR
-- 自增 ID → BIGINT
-- 状态码 → VARCHAR 或 TINYINT
面试要点
- 最常考的索引失效原因:隐式类型转换是面试高频题
- 转换规则:VARCHAR 列 vs 数字 → 字符串转数字,导致索引失效
- 常见场景:手机号查错、WHERE 条件中忘记引号、框架类型映射问题
- 检测手段:
EXPLAIN+SHOW WARNINGS查看优化器重写后的 SQL - 根本解决:用对类型传入正确的参数,不要让 MySQL 猜
一句话总结:隐式类型转换的本质是 MySQL 在比较时给索引列”套了一层 CAST 函数”,直接导致索引失效,最典型例子就是 VARCHAR 类型的手机号列传入数字参数。
索引跳跃扫描(Skip Scan)
索引跳跃扫描(Skip Scan)
什么是跳跃扫描
MySQL 8.0.13 引入了跳跃扫描(Skip Scan),允许在不满足最左前缀的情况下走索引扫描。
-- 有这个联合索引
CREATE INDEX idx_status_created ON orders(status, created_at);
-- 过去:没有 status 条件,无法使用 idx_status_created
-- 现在(8.0.13+):MySQL 可以"跳过"status,直接用 created_at
SELECT * FROM orders
WHERE created_at BETWEEN '2024-01-01' AND '2024-01-31'
ORDER BY created_at;
最左前缀的”漏洞”
之前学的:复合索引必须包含最左列才能使用。
-- 索引 idx_a_b_c(a, b, c)
-- ✅ 能用:包含 a
WHERE a = 1 AND b = 2
WHERE a = 1 AND c = 3
-- ❌ 不能用:不包含 a
WHERE b = 2
WHERE c = 3
WHERE b = 2 AND c = 3
跳跃扫描打破了这个规则——条件是有限的”跳过”,不是完全跳过。
跳跃扫描的工作原理
跳跃扫描本质上是一种隐式转换为多个等值条件的优化:
-- 索引:idx_status_created(status, created_at)
-- 查询:只有 created_at 条件
-- 假设 status 列只有 3 个值:'pending', 'paid', 'cancelled'
-- MySQL 内部做的是:
WHERE (status = 'pending' AND created_at BETWEEN '2024-01-01' AND '2024-01-31')
OR (status = 'paid' AND created_at BETWEEN '2024-01-01' AND '2024-01-31')
OR (status = 'cancelled' AND created_at BETWEEN '2024-01-01' AND '2024-01-31')
-- 相当于分别扫描了 3 个分片
执行过程示意图:
idx_status_created 索引结构:
status='pending' → [created_at='2024-01-01', ...] ← 扫描这个分片
status='paid' → [created_at='2024-01-01', ...] ← 扫描这个分片
status='cancelled'→ [created_at='2024-01-01', ...] ← 扫描这个分片
↑ ↑ ↑
扫描 pending 区间 扫描 paid 区间 扫描 cancelled 区间
验证跳跃扫描
-- 查看执行计划
EXPLAIN SELECT * FROM orders
WHERE created_at BETWEEN '2024-01-01' AND '2024-01-31'\G
输出:
id: 1
select_type: SIMPLE
table: orders
type: index ← 注意不是 ref,而是 index(全索引扫描)
possible_keys: idx_status_created
key: idx_status_created
key_len: 7
ref: NULL
rows: 1500
Extra: Using where; Using index for skip scan ← 关键标识
关键标识:Extra 列出现 Using index for skip scan。
跳跃扫描的适用条件
适合的场景
-- 1. 索引前缀列只有少量离散值(基数低)
CREATE INDEX idx_type_created ON orders(order_type, created_at);
-- order_type 只有:'normal', 'vip', 'flash'
-- 3 个离散值 → MySQL 只需 3 次子查询
-- 2. 被跳过的列不是必需
SELECT * FROM orders
WHERE created_at >= '2024-01-01'; -- 没有 order_type 条件
-- 3. 查询条件在索引的非前缀列上
SELECT * FROM orders
WHERE created_at BETWEEN '2024-01-01' AND '2024-01-15';
不适合的场景
-- 1. 前缀列基数大
-- 索引 idx_user_created(user_id, created_at)
-- user_id 有 100 万不同值 → 100 万次扫描 = 比全表还慢
-- 2. 前缀列没有重复值(唯一索引的排头)
-- 跳过前缀列的收益很小
-- 3. 不满足单列索引的查询条件
-- 索引 idx_type_created(order_type, created_at)
SELECT * FROM orders WHERE amount > 100;
-- amount 不在索引中,跳跃扫描也帮不了你
-- 4. 前缀列条件不是 AND 连接的等值
SELECT * FROM orders WHERE created_at > '2024-01-01' AND order_type > 'normal';
-- order_type 是范围条件 → 无法转化为多个等值
跳跃扫描 vs 全表扫描
-- 对比实验
CREATE TABLE orders (
id INT PRIMARY KEY,
status ENUM('pending','paid','cancelled'),
created_at DATETIME,
INDEX idx_status_created(status, created_at)
);
-- 插入数据:100 万行,status 均匀分布在 3 个值
-- 查询 1:没有跳跃扫描(5.7 或 8.0 关闭跳跃扫描)
SET optimizer_switch = 'skip_scan=off';
SELECT * FROM orders WHERE created_at BETWEEN '2024-01-01' AND '2024-01-07';
-- 全表扫描:约 800ms
-- 查询 2:有跳跃扫描(默认开启)
SET optimizer_switch = 'skip_scan=on';
SELECT * FROM orders WHERE created_at BETWEEN '2024-01-01' AND '2024-01-07';
-- 索引跳跃扫描:约 50ms
-- 差距 16 倍!
跳跃扫描的代价
跳跃扫描不是免费的:
代价公式:
跳跃扫描成本 = Σ(每个分片范围扫描成本) × 分片数
场景 分片数 跳跃扫描成本 全表扫描成本
低基数列 3~10 低(值得做)
中等基数 100~ 中(需要评估)
高基数列 10000+ 高(比全表还差)
核心判断准则:跳跃扫描的分片数量 = 前缀列的不同值数量。
面试要点
- MySQL 8.0.13 引入:优化器自动执行
- 本质:将查询隐式转化为多个等值条件的 UNION
- 适用前提:前缀列是低基数的(少量不同值)
- 标识:EXPLAIN Extra 列显示
Using index for skip scan - 对比最左前缀:跳跃扫描是例外,不是替代——只适合前缀列基数小的场景
- 限制:WHERE 条件必须只包含索引中的列(或少量回表)
一句话总结:跳跃扫描让复合索引在不满足最左前缀时也能走索引,本质是把范围查询拆成多个等值子查询再合并,适合前缀列值少(如状态、类型)而范围列很大的场景。
索引失效十大常见场景
索引失效十大常见场景
为什么要关注索引失效
精心设计的索引,可能因为 SQL 写法问题而完全派不上用场。索引失效通常意味着全表扫描,对于大表来说就是性能灾难。
以下总结了最常见的十大索引失效场景。
场景一:隐式类型转换
-- name 是 VARCHAR 类型
-- ❌ 失效:传入数字,隐式转为 INT 比较
SELECT * FROM user WHERE name = 123;
-- 等价于 CAST(name AS INT) = 123 → 函数包裹,索引失效
-- ✅ 有效:传入字符串
SELECT * FROM user WHERE name = '123';
场景二:索引列使用函数
-- created_at 有索引
-- ❌ 失效:DATE() 函数包裹索引列
SELECT * FROM orders WHERE DATE(created_at) = '2024-01-15';
-- ✅ 有效:写成范围查询
SELECT * FROM orders
WHERE created_at >= '2024-01-15 00:00:00'
AND created_at < '2024-01-16 00:00:00';
场景三:LIKE 通配符开头
-- name 有索引
-- ❌ 失效:% 在开头
SELECT * FROM user WHERE name LIKE '%张三%';
-- ✅ 有效:% 在结尾
SELECT * FROM user WHERE name LIKE '张三%';
-- ✅ 有效:走全表也用索引可以考虑覆盖索引
SELECT id, name FROM user WHERE name LIKE '%张三%';
-- 如果查询列全部在索引中,可能走全索引扫描
场景四:OR 条件含非索引列
-- 索引:idx_age(age)
-- name 没有索引
-- ❌ 失效:OR 两边的条件只要有一个无索引,就全表扫描
SELECT * FROM user WHERE age = 25 OR name = '张三';
-- ✅ 解决:给 name 也建索引
CREATE INDEX idx_name ON user(name);
-- ✅ 或者改写为 UNION
SELECT * FROM user WHERE age = 25
UNION
SELECT * FROM user WHERE name = '张三';
场景五:复合索引不遵循最左前缀
-- 索引:idx_a_b_c(a, b, c)
-- ✅ 有效:包含最左列 a
WHERE a = 1 AND b = 2
WHERE a = 1 AND c = 3
-- ❌ 失效:没有 a
WHERE b = 2
WHERE b = 2 AND c = 3
WHERE c = 3
场景六:索引列参与运算
-- id 有索引
-- ❌ 失效:列参与了算术运算
SELECT * FROM user WHERE id + 1 = 1000;
-- ✅ 有效:运算移到另一边
SELECT * FROM user WHERE id = 999;
场景七:NOT、!=、<> 操作
-- status 有索引
-- ❌ 失效:不等于操作通常不走索引
SELECT * FROM orders WHERE status != 'cancelled';
SELECT * FROM orders WHERE status <> 'cancelled';
SELECT * FROM orders WHERE NOT status = 'paid';
-- ✅ 可能有效:如果整张表大部分是 cancelled
-- 优化器觉得全表扫描更快,即使走索引也可能评估为全表
-- ✅ 改写(不保证)
SELECT * FROM orders WHERE status IN ('pending', 'paid');
场景八:IS NULL / IS NOT NULL
-- name 有索引
-- ✅ 可能有效
SELECT * FROM user WHERE name IS NULL;
-- ❌ 通常失效
SELECT * FROM user WHERE name IS NOT NULL;
-- 如果大部分记录 name 不为空,优化器认为全表扫描更优
场景九:数据分布不均匀导致优化器放弃
-- status 有索引
-- 表中 99% 是 'normal',1% 是 'abnormal'
-- ❌ 失效(优化器选择):查询 normal 时全表扫描
SELECT * FROM orders WHERE status = 'normal';
-- 优化器:99% 的行都要,索引回表反而慢
-- ✅ 有效(优化器选择):查询 abnormal 走索引
SELECT * FROM orders WHERE status = 'abnormal';
-- 优化器:只有 1%,索引回表更快
场景十:联合索引中 WHERE 条件类型不匹配
-- 索引:idx_created_time(created_at, order_type)
-- ❌ 失效:created_at 条件是 OR(如前所述)
SELECT * FROM orders
WHERE created_at > '2024-01-01' OR order_type = 'vip';
-- ✅ 有效:AND 条件
SELECT * FROM orders
WHERE created_at > '2024-01-01' AND order_type = 'vip';
快速速查表
| 序号 | 场景 | 说明 | 解决方案 |
|---|---|---|---|
| 1 | 隐式类型转换 | 数字 vs 字符串 | 传入正确类型 |
| 2 | 列用函数 | DATE(col) |
范围查询替代 |
| 3 | LIKE ‘%xxx’ | 通配符开头 | LIKE ‘xxx%’ 或覆盖索引 |
| 4 | OR 含非索引列 | 条件中列没索引 | 全加索引或用 UNION |
| 5 | 违反最左前缀 | 复合索引缺首列 | 调整索引或查询 |
| 6 | 列参与运算 | col + 1 = 10 |
移运算到右边 |
| 7 | NOT/!=/<> | 否定查询 | 改写为 IN |
| 8 | IS NOT NULL | 非空判断 | 按数据分布评估 |
| 9 | 数据分布不均 | 选择性差 | 分析后决定 |
| 10 | 条件类型不匹配 | 范围+OR 混乱 | 统一用 AND |
面试要点
- 核心原则:索引列尽量保持”干净”,不要被函数、运算、类型转换”包裹”
- 复合索引:始终问自己”最左列在不在条件中”
- OR 是陷阱:两边都有索引才能用,否则全表
- 优化器也聪明:优化器认为全表扫描更快时也会”放弃”索引
- EXPLAIN 验证:确认索引是否生效的最直接手段
一句话总结:索引失效十有八九是 SQL 写法问题——列上套函数、隐式类型转换、LIKE 前缀通配符是最容易踩的三个坑,用 EXPLAIN 养成验证习惯就能避免。
不可见索引的作用
不可见索引的作用
什么是不可见索引
MySQL 8.0 引入了不可见索引(Invisible Index),允许让索引在优化器层面不可见,但索引本身仍然存在并持续维护。
-- 让一个索引不可见
ALTER TABLE t ALTER INDEX idx_name INVISIBLE;
-- 让一个索引恢复可见
ALTER TABLE t ALTER INDEX idx_name VISIBLE;
-- 创建时指定不可见
CREATE INDEX idx_age ON t(age) INVISIBLE;
为什么需要不可见索引
在 8.0 之前,如果你想验证删除某个索引后系统性能会怎样,只能这样做:
-- 老方法:直接删索引测试
DROP INDEX idx_name ON t;
-- ...测试发现需要这个索引...
CREATE INDEX idx_name ON t(col); -- 重建耗时很长!
问题: 大表建索引很慢(可能几十分钟甚至几小时),DBA 不敢随意删除再重建。
不可见索引的解决方式:
-- ✅ 优雅的方式:设为不可见
ALTER TABLE t ALTER INDEX idx_name INVISIBLE;
-- 优化器不再使用该索引
-- 但索引数据仍然存在,可以随时恢复
不可见索引的核心使用场景
场景一:安全地测试删除索引
-- 步骤 1:设为不可见
ALTER TABLE orders ALTER INDEX idx_status_date INVISIBLE;
-- 步骤 2:监控业务 SQL 性能(比如 1 小时)
-- 检查慢查询日志
-- 检查是否有查询变慢
-- 步骤 3A:如果没问题,确认删除
DROP INDEX idx_status_date ON orders;
-- 步骤 3B:如果有问题,立即恢复
ALTER TABLE orders ALTER INDEX idx_status_date VISIBLE;
场景二:优化器降级测试
-- 某个查询用了错误的索引
-- 但 DBA 不确定删除该索引的后果
-- 先设为不可见
ALTER INDEX idx_wrong INVISIBLE;
-- 观察其他查询是否受影响
-- 观察执行计划是否变好
-- 如果没问题再决定是否删除
场景三:逐步弃用旧索引
-- 系统升级,旧索引不再需要
-- 但因为历史原因,不确定是否有遗留代码在用
-- 步骤 1:设为不可见(遗留查询会走全表扫描或别的索引)
ALTER INDEX idx_legacy INVISIBLE;
-- 步骤 2:监控一段时间(比如一周)
-- 步骤 3:无人投诉 → 删除
DROP INDEX idx_legacy;
不可见索引的行为细节
优化器确实不可见
-- 创建一个表和索引
CREATE TABLE t (
id INT PRIMARY KEY,
name VARCHAR(100),
age INT
);
CREATE INDEX idx_age ON t(age);
-- 正常:走索引
EXPLAIN SELECT * FROM t WHERE age = 25;
-- key: idx_age
-- 设为不可见
ALTER TABLE t ALTER INDEX idx_age INVISIBLE;
-- 优化器不再使用这个索引
EXPLAIN SELECT * FROM t WHERE age = 25;
-- key: NULL (全表扫描)
索引仍被维护
不可见 ≠ 不维护。索引上的增删改操作仍会更新这个索引:
-- 即使索引不可见
INSERT INTO t VALUES (1, '张三', 25); -- 仍然维护 idx_age
UPDATE t SET age = 30 WHERE id = 1; -- 仍然更新 idx_age
DELETE FROM t WHERE id = 1; -- 仍然删除 idx_age 中的条目
为什么还要维护? 为了在恢复可见时,索引立即可用,不需要重建。
主键不能设为不可见
-- ❌ 报错
ALTER TABLE t ALTER INDEX PRIMARY INVISIBLE;
-- ERROR: 主键索引不能设为不可见
-- 唯一索引?
ALTER TABLE t ALTER INDEX uk_name INVISIBLE;
-- 可以设为不可见,但唯一约束仍然生效!
-- 只是优化器不用而已
不可见索引的局限
-- 1. 只是优化器层面不可见
-- FORCE INDEX / USE INDEX 仍然可以使用不可见索引
SELECT * FROM t FORCE INDEX (idx_age) WHERE age = 25; -- 仍然可以
-- 2. 主键不能不可见
-- 原因:InnoDB 表必须有聚簇索引
-- 3. 空间索引不能不可见
-- 原因是空间索引的优化器处理方式不同
-- 4. 对写性能仍有影响
-- 虽然不可见,每次 DML 仍然维护索引数据
对比:不可见索引 vs 删除索引
| 操作 | 不可见索引 | 删除索引 |
|---|---|---|
| 对查询影响 | 优化器不使用 | 无法使用 |
| 对写入影响 | 仍维护索引数据 | 不再维护 |
| 恢复速度 | 秒级(ALTER VISIBLE) |
分钟~小时(重建) |
| 安全性 | 高(可随时回滚) | 低(需重建) |
| 长期使用场景 | 测试/验证 | 确认废弃 |
面试要点
- MySQL 8.0 特性:让索引对优化器不可见,但保留索引数据
- 核心价值:安全测试索引删除的影响,零风险回滚
- 语法:
ALTER TABLE t ALTER INDEX idx INVISIBLE/VISIBLE - 不可见但不删除:DML 操作仍维护索引 → 恢复立即可用
- 适用场景:DBA 安全地做索引清理验证、升级测试
- 不适用:主键索引、空间索引
一句话总结:不可见索引是 DBA 的”安全开关”,让你零风险测试删除索引的影响,发现不对也能秒级恢复,彻底告别大表索引操作的提心吊胆。
倒序索引与降序索引应用场景
倒序索引与降序索引应用场景
什么是降序索引
MySQL 8.0 之前,CREATE INDEX ... DESC 虽然语法支持,但 MySQL 实际上忽略了 ASC/DESC,索引统一按升序存储。
MySQL 8.0 开始,降序索引真正生效:索引可以按降序存储,每种排序方向的叶子节点按相反方向链接。
-- MySQL 8.0 之前的"降序索引"是假的
CREATE INDEX idx_created_desc ON t(created_at DESC);
-- 实际效果 = idx_created_desc (created_at ASC)
-- MySQL 8.0 真正的降序索引
CREATE INDEX idx_created_desc ON t(created_at DESC);
-- 叶子节点按从大到小排列
为什么需要降序索引
看一个常见排序场景:
-- 最常用查询:按时间降序 + 按姓名升序
SELECT * FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, user_name ASC
LIMIT 20;
-- 如果只有升序索引 idx_status_created(status,created_at)
-- MySQL 需要额外做 filesort
EXPLAIN SELECT ...
-- Extra: Using where; Using filesort; Using index
没有降序索引的代价
查询需求:
ORDER BY created_at DESC, user_name ASC
现有索引:
idx_status_created(status ASC, created_at ASC)
执行过程:
① 索引从上到下扫描,created_at 是升序
② 需要 DESC 结果 → 必须 filesort 排序
③ LIMIT 20 也无法提前停止,因为必须全部排完
有降序索引的效果
-- 创建匹配的降序索引
CREATE INDEX idx_status_created_desc ON orders(
status ASC,
created_at DESC,
user_name ASC
);
-- 查询
SELECT * FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, user_name ASC
LIMIT 20;
EXPLAIN SELECT ...
-- Extra: Using where; Using index
-- 没有 filesort!
-- LIMIT 20 也可以提前停止扫描
降序索引的典型场景
场景一:按时间排序的最新列表
-- 社交 Feed:最新动态
CREATE INDEX idx_user_time ON feed(user_id, created_at DESC);
SELECT * FROM feed
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 10;
场景二:价格从高到低 + 评分从高到低
-- 电商:价格降序、评分降序、上架升序
CREATE INDEX idx_price_rate_time ON products(
category_id,
price DESC,
rating DESC,
listed_at ASC
);
SELECT * FROM products
WHERE category_id = 10
ORDER BY price DESC, rating DESC, listed_at ASC
LIMIT 20;
场景三:复合排序 + 分页
-- 排行榜:积分降序 → 时间升序(先达者排前面)
CREATE INDEX idx_score_time ON leaderboard(
score DESC,
reach_time ASC
);
SELECT * FROM leaderboard
ORDER BY score DESC, reach_time ASC
LIMIT 100;
降序索引的底层实现
升序索引 B+树 叶子节点:
[1]->[2]->[3]->[4]->[5]->... (单向链表,升序)
降序索引 B+树 叶子节点:
[9]->[8]->[7]->[6]->[5]->... (单向链表,降序)
混合索引:
idx_a_asc_b_desc:
[(1,9)]->[(1,8)]->[(1,7)]->[(2,5)]->[(2,3)] ← a 升序,b 在 a 内部降序
降序索引解决了”多列排序方向不一致”时的 filesort 问题。
倒序索引 vs 降序索引
容易混淆的两个概念:
降序索引 (Descending Index):
- 存储方向是降序(DESC)
- 叶子节点按从大到小排列
- CREATE INDEX idx ON t(col DESC)
倒序索引 (Reverse Index):
- 索引存储的是字段值的倒序版本
- 用于解决通配符前缀 LIKE %abc 无法走索引的问题
- MySQL 没有原生倒序索引,需用函数索引模拟:
CREATE INDEX idx_rev ON t(REVERSE(col));
WHERE REVERSE(col) LIKE REVERSE('%abc');
性能对比实验
-- 实验:100 万条订单数据,查询最新 20 条
-- ❌ 只有升序索引
CREATE INDEX idx_time ON orders(created_at ASC);
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20;
-- 耗时:120ms(需要 filesort)
-- ✅ 降序索引
CREATE INDEX idx_time_desc ON orders(created_at DESC);
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20;
-- 耗时:1ms(直接索引扫描,无 filesort)
面试要点
- MySQL 8.0 新特性:降序索引真正可用,不仅仅是语法糖
- 核心价值:消除多列排序方向不一致时的 filesort
- 最擅长的场景:
ORDER BY a ASC, b DESC这类混合排序 - 配合 LIMIT:降序索引可以直接从最大值开始扫描,配合 LIMIT 提前停止
- 升级注意:从 5.7 升级到 8.0 后可以考虑优化 ORDER BY DESC 的索引
- 不推荐随意建:只在确实需要混合排序的场景建立,否则升序索引已足够
一句话总结:MySQL 8.0 降序索引让 ORDER BY DESC 不再需要 filesort,对”按时间倒序+其他条件正序”的场景有显著加速效果。
函数索引 MySQL 8.0 新特性
函数索引 MySQL 8.0 新特性
什么是函数索引
MySQL 8.0.13 引入了函数索引(Function Index),允许在索引中使用表达式或函数,解决了一个长期痛点——条件中使用了函数导致索引失效。
-- 在 8.0 之前,这个查询无法走索引
SELECT * FROM user WHERE DATE(created_at) = '2024-01-15';
-- 即使有 created_at 索引,因为套了 DATE() 函数 → 索引失效
-- 8.0 之后,可以建函数索引
CREATE INDEX idx_date_created ON user((DATE(created_at)));
-- 现在可以走索引了
SELECT * FROM user WHERE DATE(created_at) = '2024-01-15';
函数索引解决了什么问题
核心痛点
-- 常见导致索引失效的操作(函数包裹索引列)
SELECT * FROM t WHERE YEAR(create_time) = 2024;
SELECT * FROM t WHERE MONTH(create_time) = 1;
SELECT * FROM t WHERE CONCAT(first_name, ' ', last_name) = '张三';
SELECT * FROM t WHERE id + 1 = 1000; -- 隐式表达式
SELECT * FROM t WHERE LOWER(email) = 'user@example.com';
-- 8.0 之前,以上查询全部全表扫描
-- 8.0 之后,可以为它们建立函数索引
函数索引语法
-- 语法:索引列的括号不能少
CREATE INDEX idx_func ON t((expression));
-- 实际例子
-- 按日期(忽略时间部分)
CREATE INDEX idx_date ON orders((DATE(created_at)));
-- 按年份查询
CREATE INDEX idx_year ON orders((YEAR(created_at)));
-- 大小写不敏感查询
CREATE INDEX idx_lower_email ON user((LOWER(email)));
-- 计算列
CREATE INDEX idx_price_total ON orders((quantity * unit_price));
复合函数索引
-- 函数列 + 普通列 混合索引
CREATE INDEX idx_composite ON orders(
status,
(DATE(created_at)),
amount DESC
);
-- 查询走索引
SELECT * FROM orders
WHERE status = 'paid'
AND DATE(created_at) = '2024-01-15'
ORDER BY amount DESC;
函数索引的实现原理
函数索引本质上是一种虚拟列 + 索引的组合:
虚拟列 索引
│ │
v v
CREATE TABLE t (
name VARCHAR(50),
first_char VARCHAR(1) GENERATED ALWAYS AS (LEFT(name, 1)) VIRTUAL,
INDEX idx_first_char(first_char)
);
函数索引相当于 MySQL 自动帮你做了:
- 创建一个不可见的虚拟生成列
- 在这个虚拟列上建立索引
MySQL 8.0.13+ 的语法糖:
-- MySQL 内部展开等同于:
-- 创建虚拟列 + 索引,但对外表现为"函数索引"
CREATE INDEX idx_name ON t((LEFT(name, 1)));
-- 等价于
-- ALTER TABLE t ADD COLUMN ...
-- LEFT(name, 1) GENERATED ALWAYS AS (LEFT(name, 1)) VIRTUAL;
-- CREATE INDEX idx_name ON t(LEFT(name, 1));
函数索引的适用场景
-- 常用日期截取查询
CREATE INDEX idx_date ON logs((DATE(created_at)));
SELECT * FROM logs WHERE DATE(created_at) = '2024-01-15';
-- 大小写不敏感查询
CREATE INDEX idx_lower ON user((LOWER(email)));
SELECT * FROM user WHERE LOWER(email) = 'alice@example.com';
-- JSON 字段内部值查询
CREATE INDEX idx_json_price ON products((JSON_EXTRACT(price_info, '$.base')));
SELECT * FROM products WHERE JSON_EXTRACT(price_info, '$.base') > 100;
-- 字符串前缀匹配
CREATE INDEX idx_prefix ON t((LEFT(name, 3)));
SELECT * FROM t WHERE LEFT(name, 3) = 'abc';
-- 表达式运算
CREATE INDEX idx_total ON orders((quantity * price));
SELECT * FROM orders WHERE quantity * price > 1000;
函数索引的注意事项
不能再使用函数
-- 建了函数索引后,查询中的函数表达式必须完全匹配
CREATE INDEX idx_year ON orders((YEAR(created_at)));
-- ✅ 走索引
SELECT * FROM orders WHERE YEAR(created_at) = 2024;
-- ❌ 不行!表达式不完全匹配
SELECT * FROM orders WHERE YEAR(created_at) + 0 = 2024;
SELECT * FROM orders WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31';
-- 虽然逻辑等价,但优化器不会识别
性能代价
-- 函数索引需要每次 INSERT/UPDATE 时计算函数值
-- 写性能会下降
CREATE INDEX idx_lower_email ON user((LOWER(email)));
-- 每次写入/更新时,MySQL 都要额外执行 LOWER(email)
-- 并在索引中维护
-- 建议:只在查询频繁的场景建立
-- 如果查询不频繁,全表扫描可能比维护函数索引成本更低
索引大小
-- 函数索引的列值 = 函数结果,通常比原列小
-- 但也有可能更大(如 CONCAT 结果比原列长)
-- 检查函数索引大小
SELECT
TABLE_NAME,
INDEX_NAME,
CARDINALITY,
(INDEX_LENGTH / 1024 / 1024) AS index_size_mb
FROM information_schema.INNODB_INDEXES
WHERE TABLE_NAME LIKE '%orders%';
函数索引 + 覆盖索引
-- 函数索引也可以实现覆盖索引
CREATE INDEX idx_func_covering ON orders(
(DATE(created_at)),
id,
amount
);
-- 查询所有需要的列都在索引中 → 不回表
SELECT DATE(created_at), id, amount
FROM orders
WHERE DATE(created_at) = '2024-01-15';
面试要点
- MySQL 8.0.13 引入:函数索引是解决”索引列被函数包裹”的根本方案
- 本质机制:通过虚拟生成列 + 索引实现
- 语法特点:表达式需要用括号包裹
((expression)) - 完全匹配:查询条件中的表达式必须与索引定义完全一致,逻辑等价不保证走索引
- 适用场景:日期截取、大小写不敏感、JSON 提取、表达式运算
- 权衡:提升读性能,牺牲写性能,只在高频查询场景建立
一句话总结:MySQL 8.0 函数索引让”函数导致索引失效”成为历史,本质是将表达式计算结果预存为虚拟列并建立索引,既保留查询灵活性又保证性能。
自适应哈希索引 AHI
自适应哈希索引 AHI
什么是自适应哈希索引
自适应哈希索引(Adaptive Hash Index,AHI)是 InnoDB 存储引擎的一个自动优化特性。它不需要人工创建,InnoDB 会自动监控索引的访问模式,当发现某些索引页频繁通过等值查询访问时,自动为其建立哈希索引。
B+树 自适应哈希索引
[根节点]
↓ [hash]
[内部节点] → 监控 → {key → page_no}
↓
[叶子节点] 等值查询 O(1)
定位需要多次 IO
AHI 的触发条件
-- AHI 不需要手动创建,系统自动判断
-- 查看 AHI 状态
SHOW ENGINE INNODB STATUS\G
-- 在 Hash 索引相关部分查看
InnoDB 判断是否建立 AHI 的关键指标:
1. 模式:必须满足"等值查询"(= 或 IN 匹配)
- 范围查询(>、<、BETWEEN)不会触发 AHI
2. 频次:同一个索引页被访问足够多次
- 有一个 "连续访问阈值"
- 具体值不可配置,InnoDB 内部自适应
3. 重复性:访问模式稳定、可预测
- 如果是全表扫描或偶尔的随机访问,不会触发
AHI 的存储位置
┌─────────────────────────────────────┐
│ Buffer Pool │
│ ┌───────────────────────────────┐ │
│ │ 数据页(缓存表数据) │ │
│ ├───────────────────────────────┤ │
│ │ 索引页(缓存索引数据) │ │
│ ├───────────────────────────────┤ │
│ │ 自适应哈希索引表(AHI) │ │ ← 在 Buffer Pool 中分配
│ ├───────────────────────────────┤ │
│ │ 锁信息、变更缓冲区等 │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
AHI 是内存结构,不持久化。重启后需要重新建立。
AHI 带来的性能提升
-- 没有 AHI
SELECT * FROM user WHERE id = 1000;
-- 执行流程:
-- ① B+树从根节点到叶子节点:3~4 次 IO
-- ② 找到叶子节点中的记录
-- 有 AHI(如果命中)
SELECT * FROM user WHERE id = 1000;
-- 执行流程:
-- ① 哈希表直接定位:O(1)
-- ② 直接读取目标页
-- AHI 主要优化的是 B+树的前几次 IO 跳转
AHI 的代价
AHI 不是免费的午餐:
1. 内存占用
- AHI 占用 Buffer Pool 的一部分
- 过大的 AHI 会挤占数据缓存空间
- 可通过 innodb_adaptive_hash_index_parts 控制分区数
2. 维护开销
- 每次 B+树变化(插入、删除、分裂)都要同步更新 AHI
- 写密集型场景下,AHI 维护开销可能 > 读收益
什么时候需要关闭 AHI
-- 关闭 AHI
SET GLOBAL innodb_adaptive_hash_index = OFF;
-- 或修改配置文件 my.cnf
-- [mysqld]
-- innodb_adaptive_hash_index = 0
建议关闭 AHI 的场景:
1. 高并发写密集型
- AHI 需要随 B+树变化频繁更新
- 维护 AHI 的锁竞争高
- 典型场景:日志、监控数据写入
2. 热点数据小,完全在内存中
- B+树全部在 Buffer Pool 中
- 根到叶的 IO 已经是内存操作
- AHI 的 O(1) 相比 B+树内存查询优势不大
3. 绝大部分是范围查询
- AHI 只优化等值查询
- 范围查询多的情况 AHI 无用
面试要点
- AHI 是 InnoDB 自管理:不需要 DBA 手动创建哈希索引
- 只优化等值查询:对 =、IN 有效,范围查询无效
- 在内存中:占用 Buffer Pool,不持久化
- 收益区间:读多写少、热点等值查询 → 明显加速
- 高写场景要评估:维护 AHI 的开销可能抵消收益
一句话总结:自适应哈希索引是 InnoDB 的"自动加速器",专精等值查询 O(1) 匹配,但在高写入负载下可能适得其反,需要监控评估是否开启。
索引下推 ICP 原理
索引下推 ICP 原理
什么是索引下推
索引下推(Index Condition Pushdown,ICP)是 MySQL 5.6 引入的优化,核心思想是:将 WHERE 条件下推到存储引擎层过滤,减少回表次数。
没有 ICP 时的执行流程
假设有联合索引 idx_age_name(age, name):
SELECT * FROM user
WHERE age = 25 AND name LIKE '%张%';
没有 ICP 时:
存储引擎层:
① 用 age=25 在索引中定位
② 找到所有 age=25 的记录
③ 回表读取完整行数据
Server 层:
④ 在回表后的行上过滤 name LIKE '%张%'
问题:name 条件本来可以在索引中过滤,但因为没有 ICP,只能回表再判断。
ICP 优化后的流程
存储引擎层:
① 用 age=25 在索引中定位
② 在索引中直接判断 name LIKE '%张%'
③ 只有匹配的回表
Server 层:
④ 处理回表后的行(可能已经很少了)
执行计划对比
-- 关闭 ICP
SET optimizer_switch = 'index_condition_pushdown=off';
EXPLAIN SELECT * FROM user WHERE age = 25 AND name LIKE '%张%';
输出:
| type | key | Extra |
|------|------------|--------------------------------|
| ref | idx_age_name | Using where |
-- 开启 ICP(默认开启)
SET optimizer_switch = 'index_condition_pushdown=on';
EXPLAIN SELECT * FROM user WHERE age = 25 AND name LIKE '%张%';
输出:
| type | key | Extra |
|------|------------|-------------------------------------|
| ref | idx_age_name | Using index condition |
关键标识:Extra 列显示 Using index condition 表示 ICP 生效。
ICP 的适用条件
可以用 ICP 的场景
-- 联合索引中,无法使用索引的列(like %xxx、函数、不等号)
-- ICP 可以下推这部分条件
-- ✓ 联合索引部分列 LIKE %xxx
CREATE INDEX idx_a_b ON t(a, b);
EXPLAIN SELECT * FROM t WHERE a = 1 AND b LIKE '%xxx%';
-- ✓ 联合索引部分列范围查询
EXPLAIN SELECT * FROM t WHERE a = 1 AND b > 10 AND b < 20;
-- b 的范围条件也能下推
-- ✓ 带 IN 条件
EXPLAIN SELECT * FROM t WHERE a = 1 AND b IN (1, 2, 3);
不能用 ICP 的场景
-- ✗ 没有索引可用
EXPLAIN SELECT * FROM t WHERE a > 100 AND b = 5; -- 无索引
-- ✗ 索引完全覆盖查询(Using index)
EXPLAIN SELECT a, b FROM t WHERE a = 1 AND b LIKE '%x%';
-- 如果查询的列都在索引中 → Using where; Using index → 无需 ICP
-- ✗ 聚簇索引(主键)上的条件
-- 主键本身就已经包含全部数据,回表没有损耗概念
ICP 的工作原理深入
索引行结构(idx_age_name):
(age=25, name='张三', pk=100) ← 索引行
(age=25, name='张伟', pk=101) ← 索引行
(age=25, name='李四', pk=102) ← 索引行
(age=25, name='张大拿', pk=103) ← 索引行
没有 ICP:
① 用 age=25 定位到 4 条
↓
② 逐条回表:pk=100, 101, 102, 103 → 4 次回表
↓
③ Server 层过滤 name LIKE '%张%'
有 ICP:
① 用 age=25 定位到 4 条
↓
② 在索引中过滤 name LIKE '%张%':匹配 100, 101, 103
↓
③ 只回表这 3 条 → 3 次回表
ICP 在什么情况下效果最明显?
ICP 效果 = (回表次数减少量) × (单次回表代价)
收益最大的场景:
1. 回表代价高(数据不在 buffer pool,需要磁盘 IO)
2. 索引过滤性强(大量行被 ICP 过滤掉)
3. 联合索引中,非索引前缀列的条件很严格
ICP 覆盖索引的关系
ICP 和覆盖索引互补:
- 覆盖索引:直接在索引中拿到所有数据,不回表 →
Using index - ICP:减少回表次数,但无法完全不回表 →
Using index condition
-- 覆盖索引:索引中有全部查询列
SELECT a, b FROM t WHERE a = 1 AND b LIKE '%x%';
-- Extra: Using where; Using index
-- ICP:需要回表一部分数据
SELECT * FROM t WHERE a = 1 AND b LIKE '%x%';
-- Extra: Using index condition
面试要点
- ICP 本质:把 Server 层的 WHERE 过滤下推到存储引擎层
- 引入版本:MySQL 5.6
- 效果:减少回表次数,降低 IO 开销
- 标识:
EXPLAIN的Extra列显示Using index condition - 适用前提:使用二级索引查询、查询列不在索引中(需要回表)
- 与覆盖索引的区别:覆盖索引完全不回表,ICP 减少但无法消除回表
一句话总结:索引下推将 WHERE 过滤提前到存储引擎层,在索引遍历时直接过滤,避免不必要的回表,是 MySQL 5.6 最重要的性能优化之一。
联合索引与最左前缀匹配原则
联合索引与最左前缀匹配原则
什么是联合索引
联合索引(Composite Index / Multi-Column Index)是指对多个列建立的索引。本质上是把多个列拼接成一个"复合键"来排序。
-- 对 name 和 age 两列建立联合索引
CREATE INDEX idx_name_age ON user(name, age);
联合索引的排序规则
-- 联合索引 idx_name_age 的排序逻辑:
-- 首先按 name 排序,name 相同时按 age 排序
-- 索引中的实际存储(类比):
-- ('张三', 18)
-- ('张三', 20) -- name相同,按age排序
-- ('张三', 25)
-- ('李四', 22) -- name不同,按name排序
-- ('王五', 19)
graph TD
subgraph 索引排序规则
A[按第一列排序] --> B[第一列相同?]
B -->|是| C[按第二列排序]
B -->|否| D[进入下一索引条目]
C --> E[第二列相同?]
E -->|是| F[按第三列排序]
E -->|否| D
end
最左前缀匹配原则
核心定义
联合索引遵循最左前缀匹配原则:查询条件必须从索引的最左列开始,才能使用索引。跳过了最左列,后续列也索引也失效。
-- 索引:idx_name_age(name, age)
-- ✅ 用到索引(最左前缀:name)
SELECT * FROM user WHERE name = '张三';
SELECT * FROM user WHERE name = '张三' AND age = 18;
SELECT * FROM user WHERE name LIKE '张%';
-- ❌ 无法用到索引(没有从最左列开始)
SELECT * FROM user WHERE age = 18;
-- age 是第二列,但跳过了第一列 name
-- ⚠️ 部分使用索引(最左前缀用到了,但后续列不行)
SELECT * FROM user WHERE name = '张三' AND age > 18;
-- name 用到了索引
-- age 也在索引范围查找中用到
可以匹配的模式
-- 索引:idx_a_b_c(a, b, c)
-- ✅ 完整匹配
WHERE a = 1 AND b = 2 AND c = 3 -- 全部命中
-- ✅ 最左前缀(部分匹配)
WHERE a = 1 -- 命中 a
WHERE a = 1 AND b = 2 -- 命中 a, b
WHERE a = 1 AND b = 2 AND c = 3 -- 命中 a, b, c
-- ✅ 最左前缀(范围查询)
WHERE a > 1 -- 命中 a(范围)
WHERE a = 1 AND b > 2 -- 命中 a(等值)+ b(范围)
-- ⚠️ 部分命中(中间跳过)
WHERE a = 1 AND c = 3 -- 命中 a(c 无法使用索引)
-- ❌ 无法命中(没有从最左开始)
WHERE b = 2 -- 无法使用索引
WHERE c = 3 -- 无法使用索引
WHERE b = 2 AND c = 3 -- 无法使用索引
为什么有最左前缀原则
根本原因:B+树联合索引的排序方式决定的。
graph TD
subgraph 索引结构
A[叶子节点存储顺序] --> B[('张三',18) → id=10]
A --> C[('张三',20) → id=15]
A --> D[('张三',25) → id=22]
A --> E[('李四', 22) → id=18]
A --> F[('王五', 19) → id=5]
end
按 (name, age) 排序的结果:
张三18 张三20 张三25 ... 李四19 李四22 ... 王五20
name = '张三':很好找,都在连续区域age = 18:😱 18 分散在各个 name 中,无法利用有序性
因为索引在 age 上的排序依赖于 name,不是全局有序的。
EXPLAIN 验证
-- 验证最左前缀
EXPLAIN SELECT * FROM user WHERE name = '张三';
-- key: idx_name_age ✅
-- ref: const
EXPLAIN SELECT * FROM user WHERE age = 18;
-- key: NULL ❌ 没有使用索引
-- type: ALL 全表扫描
实践建议
1. 把区分度高的列放前面
-- index idx_status_dept(status, dept_id) ❌ status区分度低
-- index idx_dept_status(dept_id, status) ✅ dept_id区分度高
2. 尽量让查询用上最左前缀
-- 索引 idx_abc(a, b, c)
-- ❌ 跳过了 b
WHERE a = 1 AND c = 3 -- a 用到索引,c 用不到
-- ✅ 补上 b
WHERE a = 1 AND b = 2 AND c = 3 -- 全部命中
3. 不要建多余的索引
-- 如果有 idx_a_b(a, b),不需要单列索引 idx_a(a)
-- 因为 idx_a_b 已经覆盖了 a 作为最左前缀的查询
CREATE INDEX idx_a_b ON t(a, b); -- 包含了 idx_a 的功能
-- CREATE INDEX idx_a ON t(a); -- 冗余!
面试要点
- 最左前缀原则:从索引最左列开始匹配,跳过某列后,后面的列无法使用索引
- 根本原因:联合索引按第一列排序,第一列相同时才按第二列排序
- 范围列失效:遇到范围查询(>, <, BETWEEN),后面的列用不到
- 设计建议:区分度高的列放前面,查询频率高的列放前面
一句话总结:最左前缀就像"查字典先查拼音首字母"——跳过前面的规则,后面的规则就无从谈起。
覆盖索引优势
覆盖索引优势
什么是覆盖索引
覆盖索引(Covering Index)是指查询需要的所有字段都包含在一个索引中,查询时只需扫描索引,无需回表。
-- 示例:查询只需要返回 name 和 id
SELECT name, id FROM user WHERE name = '张三';
graph LR
subgraph 无覆盖索引[无覆盖索引
需要回表]
A1[idx_name: 张三 → id=10] --> A2[回表]
A2 --> A3[聚簇索引: id=10 → 全部字段]
A3 --> A4[取name, id]
end
subgraph 有覆盖索引[有覆盖索引
直接返回]
B1[idx_name: 张三 → id=10] --> B2[name和id都在索引里]
B2 --> B3[直接返回 ✅]
end
覆盖索引能带来什么好处
1. 减少 IO 次数
-- 表结构:idx_name_age(name, age)
-- 查询1:需要回表
SELECT * FROM user WHERE name = '张三';
-- IO次数 = 二级索引(3次) + 聚簇索引回表(3次) = 6次
-- 查询2:覆盖索引,无需回表
SELECT name, age FROM user WHERE name = '张三';
-- IO次数 = 二级索引(3次) = 3次 ✅
2. 减少随机 IO
-- 大批量查询时效果更明显
SELECT name, age FROM user WHERE name LIKE '张%';
-- 假设有 1000 条记录
-- 覆盖索引:一次线性扫描索引,3-4次IO
-- 非覆盖索引:回表1000次 = 3000次随机IO 😱
3. 索引比表小,缓存更高效
graph TD
A[InnoDB Buffer Pool] --> B[索引页面<br/>覆盖索引查询只需要加载索引页]
A --> C[数据页面<br/>聚簇索引页包含所有字段<br/>页面大,同样空间能缓存的数据页少]
B --> D[索引页大小: 16KB<br/>全是索引,存储效率高]
C --> E[数据页大小: 16KB<br/>很多字段不需要,浪费空间]
F[覆盖索引只加载索引页<br/>Buffer Pool利用率更高]
如何利用覆盖索引
设计合适的联合索引
-- 业务场景:经常根据状态查询订单的金额和创建时间
-- 查询语句:
SELECT order_id, amount, created_at
FROM orders
WHERE status = 'paid';
-- ❌ 只给 status 加索引
CREATE INDEX idx_status ON orders(status);
-- 需要回表取 amount 和 created_at
-- ✅ 创建覆盖索引
CREATE INDEX idx_status_cover ON orders(status, amount, created_at);
-- status: WHERE 条件
-- amount, created_at: SELECT 字段
-- 一次索引扫描搞定!
检查查询是否用了覆盖索引
-- 使用 EXPLAIN 查看 Extra 字段
EXPLAIN SELECT name, age FROM user WHERE name = '张三';
-- 输出:
+----+-------------+-------+------+---------------+------+---------+-------+------+--------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+-------+------+--------------------------+
| 1 | SIMPLE | user | ref | idx_name_age | idx | 302 | const | 1 | Using index |
+----+-------------+-------+------+---------------+------+---------+-------+------+--------------------------+
关键标识:Extra 字段显示 Using index(不是 Using index condition!)表示使用了覆盖索引。
常见 Extra 含义
| Extra | 含义 |
|---|---|
Using index |
✅ 覆盖索引!不需要回表 |
Using index condition |
索引条件下推,但可能回表 |
Using where; Using index |
覆盖索引 + WHERE 条件过滤 |
| 无 Using index | ❌ 需要回表 |
覆盖索引的限制
1. 不能覆盖所有字段
-- 如果查询需要大量字段,无法全放进索引
SELECT * FROM user WHERE name = '张三';
-- * 包含了所有字段,不可能全放在索引中
-- 除非建索引包含所有列(太浪费)
2. 不能有太长的字段
-- TEXT/BLOB 字段不能放在覆盖索引中
SELECT body FROM article WHERE title = 'MySQL优化';
-- body 是 TEXT 类型,无法放到索引里
-- 即使 (title, body) 建了索引,查 body 还是得回表
3. 更新成本
-- 覆盖索引通常包含多个字段
CREATE INDEX idx_cover ON t(a, b, c, d);
-- 任何对 a/b/c/d 的更新都需要维护这个索引
-- 写入变慢
覆盖索引使用策略
-- 高频查询优化策略
-- 找出最频繁的查询模式,为它建覆盖索引
-- 频繁查询1:按用户查订单的金额和状态
SELECT order_id, amount, status FROM orders WHERE user_id = ?;
-- 建索引:idx_user_cover(user_id, amount, status, order_id)
-- 频繁查询2:按分类查商品名称和价格
SELECT name, price FROM products WHERE category_id = ? ORDER BY price;
-- 建索引:idx_category_cover(category_id, price, name)
面试要点
- 核心价值:减少回表 → 减少 IO → 查询更快
- 判断依据:EXPLAIN 看 Extra 是否包含
Using index - 适用场景:高频查询 + 查询字段有限时
- 设计思路:把 SELECT 的字段也放进索引中
- 代价权衡:覆盖索引让查询更快,但写入更慢
一句话总结:覆盖索引是"查询无需回表"——索引里已经有你想要的一切,不必再去数据行翻找。
二级索引(非聚簇索引)
二级索引(非聚簇索引)
什么是二级索引
二级索引(Secondary Index)也叫非聚簇索引,是主键之外的索引。它的叶子节点不存储完整数据行,而是存储主键值。
graph TD
subgraph 二级索引 idx_name
A1[根节点<br/>name=张 → 指向叶子A] --> B1[叶子节点<br/>name=张三 → PK=10]
A1 --> B2[叶子节点<br/>name=李四 → PK=20]
A1 --> B3[叶子节点<br/>name=王五 → PK=30]
end
subgraph 聚簇索引 PRIMARY
C1[叶子节点<br/>id=10 → 整行数据]
C2[叶子节点<br/>id=20 → 整行数据]
C3[叶子节点<br/>id=30 → 整行数据]
end
B1 --> C1
B2 --> C2
B3 --> C3
创建二级索引
CREATE TABLE user (
id INT PRIMARY KEY, -- 聚簇索引
name VARCHAR(50),
age INT,
email VARCHAR(100),
INDEX idx_name(name), -- 单列二级索引
INDEX idx_age(age), -- 单列二级索引
INDEX idx_name_age(name, age), -- 联合二级索引
UNIQUE INDEX idx_email(email) -- 唯一二级索引
) ENGINE=InnoDB;
二级索引的存储结构
-- 查看索引
SHOW INDEX FROM user;
-- 输出:
+-------+------------+----------+--------------+
| Table | Non_unique | Key_name | Column_name |
+-------+------------+----------+--------------+
| user | 0 | PRIMARY | id |
| user | 1 | idx_name | name | -- 二级索引
| user | 1 | idx_age | age | -- 二级索引
+-------+------------+----------+--------------+
二级索引的 B+树
idx_name 的 B+树:
非叶子节点(只存 name + 指针):
[name='a' ~ name='z']
叶子节点(存 name + id):
[name='张三', id=10] → [name='李四', id=20] → [name='王五', id=30]
↓ ↓ ↓
聚簇索引 聚簇索引 聚簇索引
关键点:每个二级索引都是一棵独立的 B+树,占用额外的磁盘空间。
二级索引的查询过程
等值查询
SELECT * FROM user WHERE name = '张三';
sequenceDiagram
participant E as 执行器
participant S as 二级索引 idx_name
participant C as 聚簇索引 PRIMARY
E->>S: 查找 name='张三'
S-->>E: 找到主键 id=10
Note over S,E: 第一步:二级索引定位
E->>C: 通过 id=10 查找完整行
C-->>E: 返回整行数据
Note over C,E: 第二步:回表查询
完整步骤:
1. 在 idx_name 的 B+树中找到 name='张三' 的叶子节点
2. 叶子节点中拿到主键值 id=10
3. 通过 id=10 回聚簇索引查找完整行数据
覆盖索引(无需回表)
-- 只查 name 和 id,都在二级索引中
SELECT name, id FROM user WHERE name = '张三';
sequenceDiagram
participant E as 执行器
participant S as 二级索引 idx_name
E->>S: 查找 name='张三'
S-->>E: 返回 name='张三', id=10
Note over S,E: ✅ 二级索引已包含所需字段<br/>无需回表!
二级索引维护对性能的影响
-- 插入一条记录
INSERT INTO user VALUES (100, '赵六', 35, 'zhao@example.com');
-- InnoDB 要做:
-- 1. 在聚簇索引中插入数据
-- 2. 在 idx_name 中插入 [赵六, 100]
-- 3. 在 idx_age 中插入 [35, 100]
-- 索引越多,写入越慢!
DELETE FROM user WHERE id = 100;
-- 同样需要维护所有索引
索引维护成本
graph TD
A[写入一条数据] --> B[写聚簇索引<br/>1次IO]
A --> C[写 idx_name<br/>1次IO]
A --> D[写 idx_age<br/>1次IO]
A --> E[写 idx_email<br/>1次IO]
F[索引数量 = N] --> G[写入成本 = 1 + N 次索引维护]
查看索引占用的空间
-- 查看每个索引的大小
SELECT
TABLE_NAME AS `Table`,
INDEX_NAME AS `Index`,
ROUND(STAT_VALUE * @@innodb_page_size / 1024 / 1024, 2) AS `Size(MB)`
FROM performance_schema.table_io_waits_summary_by_index_usage
JOIN mysql.innodb_index_stats
USING (TABLE_NAME, INDEX_NAME)
WHERE STAT_NAME = 'size'
AND TABLE_SCHEMA = 'test';
选择哪些列建二级索引
-- 好的选择:区分度高、查询频繁的列
CREATE INDEX idx_order_id ON orders(order_id); -- ✅ 频繁查询
CREATE INDEX idx_status ON orders(status); -- ❌ 区分度低(就几种状态)
-- 好的选择:WHERE 条件常用列
CREATE INDEX idx_city ON user(city); -- ✅ WHERE 经常用
-- 注意长度:字符串索引可以前缀
CREATE INDEX idx_content ON article(body(10)); -- 只对前10字符建索引
面试要点
- 结构:二级索引 = 独立的 B+树,叶子节点存主键值
- 回表:通过二级索引查数据需要两次 B+树搜索
- 覆盖索引:如果查询只需要索引中的字段,不需要回表,更高效
- 维护成本:每个索引写入都需要额外 IO,索引越多写入越慢
- 空间占用:每个二级索引都是单独的数据结构
一句话总结:二级索引是"加引路牌"——它告诉你数据在哪(主键),但你要自己去取(回表)。
聚簇索引与非聚簇索引区别
聚簇索引与非聚簇索引区别
一句话区别
| 聚簇索引 | 非聚簇索引(二级索引) | |
|---|---|---|
| 数据在哪 | 叶子节点存整行数据 | 叶子节点存主键值 |
| 需要回表 | 不需要 | 需要(除非覆盖索引) |
| 一个表有几个 | 一定有且仅有 1 个 | 可以有 0-N 个 |
| InnoDB 默认 | 主键 = 聚簇索引 | 手动创建 |
结构对比
graph TD
subgraph 聚簇索引[聚簇索引 PRIMARY KEY]
A1[非叶子节点<br/>10 , 20 , 30 ...] --> B1[叶子节点<br/>id=10 → 整行<br/>name, age, email...]
A1 --> B2[叶子节点<br/>id=20 → 整行<br/>name, age, email...]
A1 --> B3[叶子节点<br/>id=30 → 整行<br/>name, age, email...]
B1 <--> B2 <--> B3
end
subgraph 非聚簇索引[非聚簇索引 idx_name]
A2[非叶子节点<br/>张 , 李 , 王 ...] --> B4[叶子节点<br/>name=张三 → id=10]
A2 --> B5[叶子节点<br/>name=李四 → id=20]
A2 --> B6[叶子节点<br/>name=王五 → id=30]
B4 <--> B5 <--> B6
B4 -.-> B1
B5 -.-> B2
B6 -.-> B3
end
详细对比
| 维度 | 聚簇索引 | 非聚簇索引 |
|---|---|---|
| 排序 | 数据物理有序 | 逻辑有序 |
| 查询速度(等值) | ⭐ 更快(一次定位) | 较快(两次定位) |
| 查询速度(范围) | ⭐ 更快(相邻数据物理相邻) | 较快(随机IO回表) |
| 插入速度(顺序) | ⭐ 快(追加) | 快 |
| 插入速度(随机) | 慢(页分裂) | 慢 |
| 空间占用 | 数据本身 | 额外空间 |
| 删除 | 删除整行 | 删除索引条目 |
一个表 vs 多个索引
CREATE TABLE employee (
id INT PRIMARY KEY, -- 聚簇索引(数据存储在这里)
emp_no VARCHAR(20),
name VARCHAR(50),
dept_id INT,
salary DECIMAL(10,2),
INDEX idx_emp_no(emp_no), -- 非聚簇索引1
INDEX idx_dept(dept_id), -- 非聚簇索引2
INDEX idx_salary(salary) -- 非聚簇索引3
) ENGINE=InnoDB;
graph LR
subgraph 磁盘上的物理结构
T[.ibd文件] --> A[聚簇索引 B+树<br/>总数据量 ≈ 100MB]
T --> B[二级索引 idx_emp_no<br/>≈ 15MB]
T --> C[二级索引 idx_dept<br/>≈ 10MB]
T --> D[二级索引 idx_salary<br/>≈ 10MB]
end
- 聚簇索引 ≈ 表数据大小
- 每个非聚簇索引 ≈ 索引列大小 + 主键大小
查询路径对比
-- 场景1:通过主键查询(聚簇索引直接命中)
SELECT * FROM employee WHERE id = 100;
-- 路径:聚簇索引 → 直接返回数据
-- IO次数:树高=3, 只需3次
-- 场景2:通过工号查询(二级索引,需回表)
SELECT * FROM employee WHERE emp_no = 'E00100';
-- 路径:idx_emp_no → 主键=100 → 聚簇索引 → 数据
-- IO次数:二级索引3次 + 聚簇索引3次 = 6次
-- 场景3:通过工号查询但只查索引字段(覆盖索引)
SELECT emp_no FROM employee WHERE emp_no = 'E00100';
-- 路径:idx_emp_no → 直接返回
-- IO次数:只查二级索引3次 ✅
MyISAM 的非聚簇索引
MyISAM 中没有聚簇索引,数据和索引完全分离:
graph LR
subgraph MyISAM
A[.MYI 索引文件] --> B[索引叶子节点<br/>key+数据页指针]
B --> C[.MYD 数据文件<br/>随机存储的行数据]
end
-- MyISAM 中所有索引都是非聚簇的
CREATE TABLE t (
id INT PRIMARY KEY, -- 主键索引也是非聚簇的
name VARCHAR(50),
INDEX idx_name(name) -- 普通索引同
) ENGINE=MyISAM;
-- MyISAM 不管用什么索引,都要去 .MYD 文件中找数据
SELECT * FROM t WHERE id = 1; -- idx_primary 找到指针 → .MYD 取数据
SELECT * FROM t WHERE name = '张三'; -- idx_name 找到指针 → .MYD 取数据
| 特性 | InnoDB 聚簇 | MyISAM 非聚簇 |
|---|---|---|
| 主键索引 | 叶子存整行 | 叶子存指针 |
| 二级索引 | 叶子存主键 | 叶子存指针 |
| 回表方式 | 根据主键回聚簇索引 | 根据指针回 .MYD 文件 |
| 数据文件 | 索引=数据(.ibd) | 索引(.MYI) ≠ 数据(.MYD) |
实践建议
-- 1. 主键越小越好(影响所有二级索引的大小)
-- ❌ 大主键
id CHAR(36) PRIMARY KEY -- 每个二级索引都多了36字节
-- ✅ 小主键
id INT PRIMARY KEY -- 每个二级索引只多4字节
-- 2. 尽量利用聚簇索引查询
SELECT * FROM t WHERE id = 100; -- 直接命中聚簇索引,不回表
-- 3. 需要回表时,批量查比逐条查好
-- ❌ 逐条回表(N次随机IO)
SELECT * FROM t WHERE name IN ('a', 'b', 'c', ..., 'z');
-- ✅ 利用聚簇索引的批处理
面试要点
- 根本区别:聚簇索引存数据,非聚簇索引存主键
- 数量区别:1 个聚簇 vs N 个非聚簇
- 查询区别:聚簇查询更快(不回表),非聚簇可能回表
- MyISAM 区别:所有索引都是非聚簇的
- 大小关系:主键大小直接影响非聚簇索引大小
一句话总结:聚簇索引是"带着行李住酒店"(什么都有),非聚簇索引是"告诉你房号"(自己去前台拿)。
聚簇索引 Clustered Index
聚簇索引 Clustered Index
什么是聚簇索引
聚簇索引(Clustered Index)是指数据行和索引存储在一起的索引结构。InnoDB 中,表数据本身就是按照聚簇索引组织的。
graph TD
subgraph InnoDB聚簇索引
A[根节点<br/>非叶子页<br/>只存主键+指针] --> B[中间页]
A --> C[中间页]
B --> D[叶子页<br/>key=10 → 整行数据<br/>id=10, name=张三, age=25]
B --> E[叶子页<br/>key=20 → 整行数据<br/>id=20, name=李四, age=30]
C --> F[叶子页<br/>key=30 → 整行数据<br/>id=30, name=王五, age=28]
end
如何创建聚簇索引
CREATE TABLE user (
id INT PRIMARY KEY, -- 主键 = 聚簇索引
name VARCHAR(50),
age INT
) ENGINE=InnoDB;
-- 或
CREATE TABLE user (
id INT NOT NULL,
name VARCHAR(50),
PRIMARY KEY (id) -- 显式定义主键
) ENGINE=InnoDB;
聚簇索引的创建规则:
flowchart TD
A[创建InnoDB表] --> B{定义了主键?}
B -->|是| C[主键 = 聚簇索引]
B -->|否| D{有非空唯一键?}
D -->|是| E[第一个非空唯一键 = 聚簇索引]
D -->|否| F[InnoDB自动生成<br/>6字节ROWID作为隐藏主键]
聚簇索引的存储结构
-- InnoDB 表的数据文件
-- 每个 .ibd 文件就是一个大的B+树
物理布局:
graph LR
subgraph IBD文件
D[数据页1<br/>行数据] --> E[数据页2<br/>行数据]
D --> F[数据页3<br/>行数据]
G[索引页1] --> H[索引页2]
G --> I[索引页3]
end
- 聚簇索引的数据页同时也是索引的叶子节点
- 数据和索引是同一个文件
- 按主键顺序物理存储
聚簇索引的优点
1. 主键查询极快
-- 通过主键查询,一次索引定位就拿到全部数据
SELECT * FROM user WHERE id = 100;
-- B+树 3层 → 3次IO → 直接拿到整行数据
-- 不需要额外"回表"
2. 范围查询高效
-- 按主键范围查询,数据已经有序
SELECT * FROM user WHERE id BETWEEN 100 AND 200;
-- 找到id=100后,顺着叶子链表遍历
-- 相邻页物理连续,磁盘IO友好
3. 排序无需额外成本
SELECT * FROM user ORDER BY id;
-- 直接遍历聚簇索引的叶子节点链表
-- 已经是排好序的,不需要 filesort
聚簇索引的缺点
1. 插入速度受顺序影响
-- ✅ 自增主键:追加插入,尾部写入
CREATE TABLE t (id INT AUTO_INCREMENT PRIMARY KEY);
-- ❌ UUID主键:随机插入,频繁页分裂
CREATE TABLE t (id CHAR(36) PRIMARY KEY);
UUID主键导致的问题:
graph LR
A[插入UUID=xxx] --> B[页已满]
B --> C[页分裂<br/>→ 新开一个页]
C --> D[数据移动<br/>→ 性能开销]
C --> E[页碎片<br/>→ 空间浪费]
D --> F[频繁分裂<br/>→ 插入速度慢]
2. 数据移动
- 按主键顺序插,B+树只在末尾增长
- 不按顺序插,B+树需要做页分裂
- 页分裂导致性能下降和空间浪费
验证聚簇索引
-- 查看表的索引类型
SHOW INDEX FROM user;
-- 输出:
+-------+------------+----------+--------------+
| Table | Non_unique | Key_name | Column_name |
+-------+------------+----------+--------------+
| user | 0 | PRIMARY | id | -- 聚簇索引
| user | 1 | idx_name | name | -- 二级索引
+-------+------------+----------+--------------+
面试要点
- 定义:数据行和索引存储在一起的索引就是聚簇索引
- InnoDB 必有:每个 InnoDB 表都有一个聚簇索引(即使没定义主键)
- 主键建议:自增整型主键 → 避免页分裂,提升插入性能
- 查询优势:主键查询一次 IO 就到数据,不需要回表
- 数据组织:表数据 = 聚簇索引,删掉索引就等于删掉表
一句话总结:聚簇索引就是"索引即数据,数据即索引"——找到了索引就直接拿到了整行数据。
B+树与哈希索引对比
B+树与哈希索引对比
概述
MySQL 中 B+树是默认索引结构,但 InnoDB 也支持自适应哈希索引(Adaptive Hash Index)。理解两者区别,知道什么场景用什么索引。
结构对比
graph LR
subgraph B+树
A[根节点<br/>有序排列] --> B[中间节点]
A --> C[中间节点]
B --> D[叶子节点1<br/>key=1 → data]
B --> E[叶子节点2<br/>key=5 → data]
C --> F[叶子节点3<br/>key=10 → data]
C --> G[叶子节点4<br/>key=20 → data]
D <--> E <--> F <--> G
end
subgraph 哈希索引
H[哈希函数] --> I[数组]
I --> J[槽位1]
I --> K[槽位2]
I --> L[...]
end
核心区别对比
| 特性 | B+树 | 哈希索引 |
|---|---|---|
| 单点查询 | O(log N) | O(1) ⭐ |
| 范围查询 | ✅ 支持 ⭐ | ❌ 不支持 |
| 排序 | ✅ 天然有序 | ❌ 无序 |
| 模糊匹配 | ✅ LIKE 'abc%' | ❌ LIKE '%abc' |
| 组合索引 | ✅ 最左前缀匹配 | ❌ 必须全字段 |
| 冲突处理 | 无冲突 | 哈希冲突 |
| 存储空间 | 较大 | 较小 |
| 适用场景 | 通用 | 等值查询特化 |
性能对比
-- 哈希索引优势场景:等值查询
SELECT * FROM user WHERE id = 100; -- 哈希 O(1) > B+树 O(log N)
| 数据量 | B+树查询 | 哈希查询 |
|---|---|---|
| 100万 | ~3次IO | 1次计算+1次IO |
| 1亿 | ~4次IO | 1次计算+1次IO |
哈希索引的劣势场景
-- B+树支持,哈希索引不支持
-- 1. 范围查询
SELECT * FROM user WHERE id > 100 AND id < 200;
-- B+树:找到100,顺着叶子链表走 ✅
-- 哈希:无法范围扫描 ❌
-- 2. 排序
SELECT * FROM user ORDER BY id;
-- B+树:叶子节点顺序遍历 ✅
-- 哈希:全量加载再排序 ❌
-- 3. 模糊匹配
SELECT * FROM user WHERE name LIKE '张%';
-- B+树:利用有序性,"张"开头的范围扫描 ✅
-- 哈希:无法做到 ❌
-- 4. GROUP BY
SELECT COUNT(*), age FROM user GROUP BY age;
-- B+树:索引有序,分组高效 ✅
-- 哈希:无序 ❌
InnoDB 自适应哈希索引(AHI)
InnoDB 内部有一个"智能"的哈希索引机制,不需要手动创建。
flowchart TD
A[InnoDB运行中] --> B[监控索引访问模式]
B --> C{某个B+树索引<br/>频繁等值查询?}
C -->|是| D[自动在内存中
建立哈希索引]
C -->|否| E[继续监控]
D --> F[查询命中哈希
速度更快]
特点
- 自动:无需手动创建或维护
- 内存:只存在 Buffer Pool 中,不持久化
- 筛选条件:某个索引页被反复等值访问时自动建立
- 限制:只对等值查询有效
-- 查看自适应哈希索引状态
SHOW VARIABLES LIKE 'innodb_adaptive_hash_index';
-- 默认开启
-- 监控AHI使用情况
SHOW ENGINE INNODB STATUS\G
-- 查看 Hash searches / Non-hash searches 比例
Memory 引擎的哈希索引
Memory 引擎支持显式创建哈希索引:
CREATE TABLE t (
id INT PRIMARY KEY,
name VARCHAR(50),
INDEX USING HASH (name) -- 显式哈希索引
) ENGINE=MEMORY;
注意:
- 仅 Memory 引擎支持显式 USING HASH
- InnoDB 不支持手动指定哈希索引
- Memory 引擎的哈希索引也是仅等值查询
实际选型建议
flowchart TD
A[查询类型] --> B{等值查询为主?}
B -->|是| C{是否频繁范围/排序?}
C -->|否| D[可以考虑哈希索引<br/>但自带的AHI就行]
C -->|是| E[B+树]
B -->|否| E
面试要点
- 哈希优势:等值查询 O(1),比 B+树的 O(log N) 快
- B+树优势:范围查询、排序、模糊匹配、组合索引
- InnoDB 实际方案:B+树 + 自适应哈希 双剑合璧
- 结论:B+树是"全能型选手",哈希是"专项选手"
- 生产建议:一般不需要手动建哈希索引,AHI 够用了
一句话总结:B+树是"六边形战士"什么都能做,哈希是"百米飞人"等值查询无敌但其他不行。
B+树树高和存储能力
B+树树高和存储能力
面试高频题
B+树为什么能支撑千万甚至亿级数据?关键看树高和每个节点的存储能力。
核心概念:扇出(Fan-out)
扇出 = 每个节点包含的子节点数量。B+树的扇出远大于二叉树,这是它"矮胖"的根本原因。
graph TD
subgraph 二叉树[二叉树 扇出=2]
A1[1] --> B1[2]
A1 --> B2[3]
end
subgraph B+树[B+树 扇出≈1170]
A2[根节点<br/>1170个key] --> B3[子节点1]
A2 --> B4[子节点2]
A2 --> B5[...]
A2 --> B6[子节点1170]
end
数据页大小
-- InnoDB 默认数据页大小
SHOW VARIABLES LIKE 'innodb_page_size';
-- 默认 16384 = 16KB
计算过程
非叶子节点能存多少个 key
graph LR
A[16KB页] --> B[1页 = 16384字节]
B --> C[假设:<br/>key=8字节 INT<br/>指针=6字节]
C --> D[每个索引项<br/>= 8 + 6 = 14字节]
D --> E[每页索引数<br/>= 16384 / 14 ≈ 1170个]
树高与存储行数的关系
-- 假设:
-- 主键大小:8 字节(BIGINT)
-- 每页:16KB
-- 行大小:1KB
-- 每页数据行数:16KB / 1KB = 16 行
-- 非叶子节点扇出:~1170
graph TD
subgraph 树高=2[树高=2<br/>1个根页存储1170个key]
H2_R[根页<br/>1170个key] --> H2_L1[叶子节点1<br/>最多16行]
H2_R --> H2_L2[叶子节点2<br/>最多16行]
H2_R --> H2_L3[...]
H2_R --> H2_L1170[叶子节点1170<br/>最多16行]
end
subgraph 存储量2[存储量<br/>1170 × 16 = 18,720行]
end
graph TD
subgraph 树高=3[树高=3<br/>千万级存储]
H3_R[根页<br/>1170个key] --> H3_M1[中间页<br/>1170个key]
H3_R --> H3_M2[中间页<br/>1170个key]
H3_R --> H3_M3[...]
H3_R --> H3_M1170[中间页<br/>1170个key]
H3_M1 --> H3_L1[叶子<br/>16行]
H3_M1 --> H3_L2[叶子<br/>16行]
H3_M1 --> H3_L1170[叶子<br/>16行]
end
subgraph 存储量3[存储量<br/>1170 × 1170 × 16 ≈ 2190万行]
end
完整计算表
| 树高 | 存储行数 | 查询需要IO次数 |
|---|---|---|
| 1 | ≤ 16 行 | 1 次 |
| 2 | 约 1.8 万行 | 2 次 |
| 3 | 约 2190 万行 | 3 次 |
| 4 | 约 256 亿行 | 4 次 |
实际场景验证
-- 创建一个千万级数据的表
CREATE TABLE t (
id BIGINT PRIMARY KEY,
name VARCHAR(50)
) ENGINE=InnoDB;
-- 插入 2000 万行数据
-- 查看 B+树高度
SELECT
b.name,
index_levels -- InnoDB 索引层数
FROM information_schema.INNODB_TABLESPACES t
JOIN information_schema.INNODB_TABLES b ON t.space = b.space;
实际中 index_levels 的值通常为 2(表示树高为 3,根页 level=2)。
影响树高的因素
-- 1. 主键越大,扇出越小
-- 如果主键是 UUID(36字节):
-- 每个索引项 = 36 + 6 = 42字节
-- 每页索引数 = 16384 / 42 ≈ 390
-- 树高=3时存储量: 390 × 390 × 16 ≈ 240万行
-- 比 BIGINT 主键少了近 10 倍!
-- 2. 行越大,每页行数越少
-- 行大小 2KB → 每页 8 行
为什么主键建议用整型自增
- 整型小(4/8字节)→ 扇出高 → 树矮
- 自增 → 插入在末尾 → 减少页分裂
- UUID → 32字节 → 扇出低 → 树高 → 性能差
-- ❌ 不推荐:UUID 做主键
CREATE TABLE bad (
id CHAR(36) PRIMARY KEY DEFAULT UUID(),
...
);
-- ✅ 推荐:BIGINT 自增
CREATE TABLE good (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
...
);
面试要点
- 树高逻辑:B+树通常 3-4 层就能支撑千万到亿级数据
- 扇出关键:非叶子节点只存 key+指针,扇出 ≈ 1170(BIGINT)
- IO 次数:一次查询需要 3-4 次磁盘 IO(树高=3或4)
- 影响因素:主键大小、行大小直接决定存储能力
- 实践结论:自增 BIGINT 主键是 B+树的最佳拍档
一句话总结:B+树 3 层就能存 2000 万行数据,每次查询只需要 3 次磁盘 IO——这就是它"矮胖"的威力。
索引本质是什么
索引本质是什么
索引的直观理解
索引的本质是一种数据结构,用于加速数据的检索。类比书本的目录:
graph LR
subgraph 书[书本]
A[目录页<br/>页码索引] --> B[第1章 P1]
A --> C[第2章 P20]
A --> D[第3章 P50]
end
subgraph DB[数据库]
E[索引<br/>B+树结构] --> F[数据行1]
E --> G[数据行1000]
E --> H[数据行50000]
end
没有目录时,你要一页一页翻(全表扫描);有目录时直接翻到目标页(索引定位)。
MySQL 中索引的本质
-- 创建索引
CREATE INDEX idx_name ON user(name);
-- 有索引时,查找过程:
SELECT * FROM user WHERE name = '张三';
-- ① 不走索引:遍历整张表,逐行匹配 → 扫描 N 行
-- ② 走索引:B+树查找,O(log N) 复杂度 → 扫描 ~4 行
索引是"排好序的数据结构"
MySQL 默认使用 B+树 作为索引结构。B+树的核心特点是:
- 有序性:所有数据按关键字排序存储
- 多路平衡:每个节点可以有多个子节点
- 叶子节点存储数据:非叶子节点只存索引值
索引的数据结构演变
graph LR
A[线性查找<br/>全表扫描 O(N)] --> B[二分查找<br/>O(log N)<br/>但要求数据有序]
B --> C[二叉树<br/>可能退化为链表<br/>最差O(N)]
C --> D[平衡二叉树AVL<br/>严格O(log N)<br/>树高随数据量上升]
D --> E[B+树<br/>多路平衡<br/>扇出大 树高低<br/>MySQL索引]
索引的空间和时间
-- 空间换时间
CREATE TABLE user (
id INT PRIMARY KEY, -- 主键索引 ≈ 占空间
name VARCHAR(50),
email VARCHAR(100),
INDEX idx_name(name) -- 额外索引 ≈ 额外空间
);
索引需要的额外空间
-- 查看索引大小
SELECT
TABLE_NAME,
INDEX_LENGTH / 1024 / 1024 AS index_size_mb
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'test' AND TABLE_NAME = 'user';
- 每个索引都是一个独立的数据结构(B+树)
- 索引也要占用磁盘空间
- 索引也要维护(INSERT/UPDATE/DELETE 时需要更新索引)
索引的存储方式
graph TD
subgraph InnoDB索引存储
A[聚簇索引<br/>Clustered Index] --> B[叶子节点存整行数据]
C[二级索引<br/>Secondary Index] --> D[叶子节点存主键值]
end
索引的重要性
-- 没有索引的查询
EXPLAIN SELECT * FROM user WHERE name = '张三';
-- type: ALL(全表扫描)
-- rows: 1000000
-- 有索引的查询
EXPLAIN SELECT * FROM user WHERE name = '张三';
-- type: ref(非唯一索引扫描)
-- rows: 1
| 数据量 | 无索引 | 有B+树索引 |
|---|---|---|
| 1000行 | 1000次比较 | ~3次IO |
| 100万行 | 100万次比较 | ~4次IO |
| 10亿行 | 10亿次比较 | ~5次IO |
这就是为什么索引能让查询从"秒级"降到"毫秒级"。
索引的缺点
-- 索引不是万能的
-- 1. 额外空间
-- 2. 写入变慢(需要同时维护索引)
INSERT INTO user VALUES (1, '张三', ...); -- 写数据 + 更新索引
-- 3. 选择性差的列不适合建索引
CREATE INDEX idx_gender ON user(gender); -- 只有"男/女",没啥用
-- 4. 频繁修改的列不适合建索引
CREATE INDEX idx_status ON order(status); -- status 经常变,索引频繁维护
面试要点
- 本质:索引是一种排好序的数据结构,用于加速数据检索
- 核心思想:空间换时间
- 默认实现:B+树(MySQL InnoDB)
- 效率:O(log N) 查询复杂度,百万行数据只需 3-4 次 IO
- 代价:占用空间 + 降低写入速度
一句话总结:索引的本质是"排好序的数据结构",用空间换时间,让数据检索从 O(N) 降到 O(log N)。
优化器如何选择索引
优化器如何选择索引
MySQL 优化器的工作流程
MySQL 优化器是 SQL 执行的"大脑"。它负责在多个可能的执行计划中选择"成本最低"的那个。对于索引选择,优化器的核心任务是:走索引还是全表扫描?走哪个索引?
成本模型(Cost Model)
MySQL 优化器估算每条 SQL 的执行成本,选择成本最低的方案。
成本组成
总成本 = IO 成本 + CPU 成本
IO 成本:从磁盘读取数据页的成本
CPU 成本:内存中处理数据的成本(比较、排序等)
成本估算依据
优化器基于以下信息估算:
1. 表的行数(rows)
2. 索引的基数(cardinality)—— 索引列的唯一值数量
3. 索引的选择性(selectivity)—— cardinality / table_rows
4. WHERE 条件的过滤效率
5. 是否需要回表(是否走覆盖索引)
-- 查看表的统计信息
SHOW INDEX FROM orders;
-- 输出中包含 Cardinality 列
-- 查看表的行数估算
SHOW TABLE STATUS LIKE 'orders';
索引选择的决策过程
SQL 解析 → 语法树 → 优化器评估候选索引
对于每个候选索引:
索引扫描成本 = 索引页读取成本 + 回表读取成本 + 条件过滤成本
全表扫描成本 = 数据页读取成本 + 条件过滤成本
选择总成本最低的执行计划
具体计算示例
假设 orders 表有 100 万行,每页 100 行,共 10000 页。
status 列有 5 个不同值(PAID, PENDING, SHIPPED, CANCELLED, REFUNDED)。
-- 查询:SELECT * FROM orders WHERE status = 'PAID' ORDER BY create_time;
-- 候选索引1:idx_status
-- 扫描行数估算:1000000 / 5 = 200000 行
-- 回表行数:200000 行(需要读全量字段)
-- 回表成本 ≈ 200000 次随机 I/O → 成本极高
-- 全表扫描
-- 扫描行数:1000000 行
-- 成本 = 10000 页 × 1.0(顺序I/O)+ 1000000行 × 0.2(CPU)
-- 如果 status=PAID 占比 20%,优化器会选择哪个?
-- 通常:当选择性低于 20%-30% 时,优化器倾向于全表扫描
常见的选择错误场景
场景一:回表成本被低估
-- 索引 (status)
SELECT * FROM orders WHERE status = 'PAID';
- 索引扫描很快(只需遍历索引页)
- 但回表 20 万行是随机 I/O,成本极高
- 优化器有时会低估回表成本,导致选择了不合适的索引
场景二:过期的统计信息
统计信息不是实时更新的,当数据大量变更后:
- 实际有 100 万行,统计信息显示只有 10 万行
- 优化器基于错误的信息选择执行计划
-- 更新统计信息
ANALYZE TABLE orders;
场景三:索引没覆盖 ORDER BY
-- 需要按 order_time 排序
SELECT * FROM orders WHERE status = 'PAID' ORDER BY create_time;
-- 有索引 idx_status(status) + 单列索引 idx_time(create_time)
-- 优化器可能在两个索引之间"摇摆"
-- 结果:走 status 索引 + filesort(因为 status 过滤效率高)
-- 或走 create_time 索引 + 全表扫描过滤(如果数据量小)
如何影响优化器的选择
方式一:FORCE INDEX(强制索引)
-- 强制走 idx_status 索引
SELECT * FROM orders FORCE INDEX (idx_status) WHERE status = 'PAID' AND create_time > '2024-01-01';
谨慎使用:强制索引后,如果数据分布变化,可能适得其反。
方式二:USE INDEX(建议索引)
-- 建议优化器使用这个索引,但最终由优化器决定
SELECT * FROM orders USE INDEX (idx_status_time) WHERE status = 'PAID' ORDER BY create_time;
方式三:IGNORE INDEX(忽略索引)
-- 告诉优化器不要用某个索引
SELECT * FROM orders IGNORE INDEX (idx_status) WHERE status = 'PAID';
方式四:改写 SQL
最简单粗暴的"控制"方式:
-- 原 SQL:可能被优化器走全表扫描
SELECT * FROM orders WHERE status = 'PAID' AND amount > 100;
-- 改写:缩小扫描范围,让优化器更愿意走索引
SELECT * FROM orders WHERE status = 'PAID' AND amount > 100 LIMIT 1000;
方式五:创建覆盖索引消除回表
-- 创建覆盖所有查询字段的索引
CREATE INDEX idx_covering ON orders(status, create_time, id, amount, user_id);
-- 查询所有字段都在索引中 → 无需回表
SELECT id, user_id, amount, create_time FROM orders
WHERE status = 'PAID' ORDER BY create_time;
-- Extra: Using where; Using index
关键参数
-- 查看优化器是否使用索引合并
SHOW VARIABLES LIKE 'optimizer_switch';
-- 输出中包含 index_merge=on/off 等信息
验证优化器的选择
-- 查看优化器选择的执行计划
EXPLAIN SELECT * FROM orders WHERE status = 'PAID';
-- 查看优化器在所有候选索引上的成本
EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE status = 'PAID';
FORMAT=JSON 输出会包含详细的成本信息:
{
"query_block": {
"table": {
"access_type": "ref",
"possible_keys": ["idx_status", "idx_status_time"],
"key": "idx_status",
"used_key_parts": ["status"],
"cost_info": {
"read_cost": "1234.56",
"eval_cost": "567.89",
"prefix_cost": "1802.45",
"data_read_per_join": "1M"
}
}
}
}
总结
优化器选择索引的核心原则:选择成本最低的执行计划。但成本估算依赖于统计信息,而统计信息可能不准确。
作为开发者需要做的是:
1. 保持统计信息更新:定期执行 ANALYZE TABLE
2. 创建覆盖索引:消除回表,降低走索引的成本
3. 关注选择性:选择性高的列走索引更有效(如 status 只有 5 个值时选择性很低)
4. 用 EXPLAIN 验证:永远不要猜测执行计划
5. 加 LIMIT:数据量少时优化器更愿意走索引
最后记住:优化器也是"人",也会犯错。当发现优化器选错了索引时,用 FORCE INDEX 或 IGNORE INDEX 校正它,但不是永久依赖——同时要排查为什么优化器决策失误。
分库分表后索引设计
分库分表后索引设计
分片键的天然索引
分库分表后,分片键本身就是天然的最佳索引。因为按分片键查询可以直接路由到正确的分片,只需查询一张表。
-- user_id 是分片键,可以直接路由到单个分片
SELECT * FROM orders WHERE user_id = 12345;
索引设计挑战
分片后索引设计面临的新问题主要是:
1. 非分片键查询需要跨全部分片,每条查询都要扫描全部分片
2. 全局唯一索引很难实现,跨分片的唯一约束需要分布式协调
3. 索引数据的存储分散,原有的单表索引策略不再适用
非分片键查询的索引策略
策略一:建立"索引表"
创建一张小表专门存储非分片键到分片键的映射关系,相当于一个二级索引。
-- 原订单表按 user_id 分片
-- 需要按 order_no(非分片键)快速查询
-- 创建索引表
CREATE TABLE order_no_index (
order_no VARCHAR(32) PRIMARY KEY, -- 唯一约束
user_id BIGINT NOT NULL, -- 通过 user_id 找到分片
INDEX idx_user_id (user_id)
) ENGINE=InnoDB;
-- 查询流程
SELECT user_id FROM order_no_index WHERE order_no = 'ORD202401010001';
SELECT * FROM orders_${shard} WHERE order_no = 'ORD202401010001';
优点:索引表很小,查询极快
缺点:写入时需要维护索引表,有额外的分布式事务或最终一致性成本
策略二:冗余存储(广播查询)
在每个分片中都对非分片键建立普通索引,查询时并发请求所有分片。
-- 在每个 orders_x 分表中,都对 order_no 建立本地索引
CREATE INDEX idx_order_no ON orders_0(order_no);
CREATE INDEX idx_order_no ON orders_1(order_no);
-- ...
-- 查询时遍历所有分片
SELECT * FROM orders_0 WHERE order_no = 'ORD202401010001'
UNION ALL
SELECT * FROM orders_1 WHERE order_no = 'ORD202401010001'
-- ...
优点:实现简单,不需要额外的表或组件
缺点:查询需要扫描所有分片,分片越多性能越差
适用:分片较少(不超过 16 个)且此类查询低频的场景
策略三:数据同步到 ES
将数据通过 binlog 同步到 Elasticsearch,在 ES 中建立所有需要的索引。
MySQL orders_x → Canal → ES
↓
查询 ES 获取 user_id
↓
路由到 MySQL 获取全量数据
优点:ES 支持任意字段的全文搜索和排序,查询能力远超 MySQL
缺点:引入 ES 增加运维复杂度,数据有秒级延迟
全局唯一索引的实现
单库中用 UNIQUE KEY 就能实现的约束,分库分表后需要在全局维度保证。
方式一:用分片键保证唯一
让唯一约束的表本身就是分片键。如 order_no 作为分片键或分片键的一部分。
方式二:使用全局唯一 ID 生成器
用雪花算法等生成全局唯一的 ID,不依赖数据库产生——"预防"而非"检查"重复。
方式三:Redis 或数据库锁实现
通过 Redis SETNX 或分布式锁来保证唯一性:
// 尝试插入前,检查唯一性
if (redis.setnx("unique:order_no:" + orderNo, "1")) {
// 执行插入
orderDao.insert(order);
} else {
throw new DuplicateException("订单号已存在");
}
复合索引设计技巧
先按分片键建索引
复合索引设计时,将分片键放在索引的最左列:
-- 推荐:分片键在最左
CREATE INDEX idx_user_time ON orders(user_id, create_time);
按分片维度的"本地方案"
每个分片的表内索引与单表索引设计原则相同,但需要额外考虑分片场景:
- 索引数量少的话可以考虑加"查全部分片"标记
- 为频繁的非分片键查询预置走索引表方案
总结
分库分表后的索引设计核心原则:
- 按分片键查询永远是最优的——设计分片键要覆盖主要查询
- 非分片键查询需要辅助手段:索引表、广播查询或 ES
- 全局唯一索引很难实现,尽量通过 ID 生成器"预防"重复
- 复合索引让分片键在最左列
索引本身的价值不会因为分库分表而消失,反而因为数据分布在多个分片上而变得更加重要——每条 SQL 都要精准命中索引,否则遍历分片的成本会被所有分片叠加放大。
联合索引与最左前缀匹配原则
联合索引与最左前缀匹配原则
什么是联合索引
联合索引(Composite Index / Multi-Column Index)是指对多个列建立的索引。本质上是把多个列拼接成一个"复合键"来排序。
-- 对 name 和 age 两列建立联合索引
CREATE INDEX idx_name_age ON user(name, age);
联合索引的排序规则
-- 联合索引 idx_name_age 的排序逻辑:
-- 首先按 name 排序,name 相同时按 age 排序
-- 索引中的实际存储(类比):
-- ('张三', 18)
-- ('张三', 20) -- name相同,按age排序
-- ('张三', 25)
-- ('李四', 22) -- name不同,按name排序
-- ('王五', 19)
graph TD
subgraph 索引排序规则
A[按第一列排序] --> B[第一列相同?]
B -->|是| C[按第二列排序]
B -->|否| D[进入下一索引条目]
C --> E[第二列相同?]
E -->|是| F[按第三列排序]
E -->|否| D
end
最左前缀匹配原则
核心定义
联合索引遵循最左前缀匹配原则:查询条件必须从索引的最左列开始,才能使用索引。跳过了最左列,后续列也索引也失效。
-- 索引:idx_name_age(name, age)
-- ✅ 用到索引(最左前缀:name)
SELECT * FROM user WHERE name = '张三';
SELECT * FROM user WHERE name = '张三' AND age = 18;
SELECT * FROM user WHERE name LIKE '张%';
-- ❌ 无法用到索引(没有从最左列开始)
SELECT * FROM user WHERE age = 18;
-- age 是第二列,但跳过了第一列 name
-- ⚠️ 部分使用索引(最左前缀用到了,但后续列不行)
SELECT * FROM user WHERE name = '张三' AND age > 18;
-- name 用到了索引
-- age 也在索引范围查找中用到
可以匹配的模式
-- 索引:idx_a_b_c(a, b, c)
-- ✅ 完整匹配
WHERE a = 1 AND b = 2 AND c = 3 -- 全部命中
-- ✅ 最左前缀(部分匹配)
WHERE a = 1 -- 命中 a
WHERE a = 1 AND b = 2 -- 命中 a, b
WHERE a = 1 AND b = 2 AND c = 3 -- 命中 a, b, c
-- ✅ 最左前缀(范围查询)
WHERE a > 1 -- 命中 a(范围)
WHERE a = 1 AND b > 2 -- 命中 a(等值)+ b(范围)
-- ⚠️ 部分命中(中间跳过)
WHERE a = 1 AND c = 3 -- 命中 a(c 无法使用索引)
-- ❌ 无法命中(没有从最左开始)
WHERE b = 2 -- 无法使用索引
WHERE c = 3 -- 无法使用索引
WHERE b = 2 AND c = 3 -- 无法使用索引
为什么有最左前缀原则
根本原因:B+树联合索引的排序方式决定的。
graph TD
subgraph 索引结构
A[叶子节点存储顺序] --> B[('张三',18) → id=10]
A --> C[('张三',20) → id=15]
A --> D[('张三',25) → id=22]
A --> E[('李四', 22) → id=18]
A --> F[('王五', 19) → id=5]
end
按 (name, age) 排序的结果:
张三18 张三20 张三25 ... 李四19 李四22 ... 王五20
name = '张三':很好找,都在连续区域age = 18:😱 18 分散在各个 name 中,无法利用有序性
因为索引在 age 上的排序依赖于 name,不是全局有序的。
EXPLAIN 验证
-- 验证最左前缀
EXPLAIN SELECT * FROM user WHERE name = '张三';
-- key: idx_name_age ✅
-- ref: const
EXPLAIN SELECT * FROM user WHERE age = 18;
-- key: NULL ❌ 没有使用索引
-- type: ALL 全表扫描
实践建议
1. 把区分度高的列放前面
-- index idx_status_dept(status, dept_id) ❌ status区分度低
-- index idx_dept_status(dept_id, status) ✅ dept_id区分度高
2. 尽量让查询用上最左前缀
-- 索引 idx_abc(a, b, c)
-- ❌ 跳过了 b
WHERE a = 1 AND c = 3 -- a 用到索引,c 用不到
-- ✅ 补上 b
WHERE a = 1 AND b = 2 AND c = 3 -- 全部命中
3. 不要建多余的索引
-- 如果有 idx_a_b(a, b),不需要单列索引 idx_a(a)
-- 因为 idx_a_b 已经覆盖了 a 作为最左前缀的查询
CREATE INDEX idx_a_b ON t(a, b); -- 包含了 idx_a 的功能
-- CREATE INDEX idx_a ON t(a); -- 冗余!
面试要点
- 最左前缀原则:从索引最左列开始匹配,跳过某列后,后面的列无法使用索引
- 根本原因:联合索引按第一列排序,第一列相同时才按第二列排序
- 范围列失效:遇到范围查询(>, <, BETWEEN),后面的列用不到
- 设计建议:区分度高的列放前面,查询频率高的列放前面
一句话总结:最左前缀就像"查字典先查拼音首字母"——跳过前面的规则,后面的规则就无从谈起。
覆盖索引优势
覆盖索引优势
什么是覆盖索引
覆盖索引(Covering Index)是指查询需要的所有字段都包含在一个索引中,查询时只需扫描索引,无需回表。
-- 示例:查询只需要返回 name 和 id
SELECT name, id FROM user WHERE name = '张三';
graph LR
subgraph 无覆盖索引[无覆盖索引
需要回表]
A1[idx_name: 张三 → id=10] --> A2[回表]
A2 --> A3[聚簇索引: id=10 → 全部字段]
A3 --> A4[取name, id]
end
subgraph 有覆盖索引[有覆盖索引
直接返回]
B1[idx_name: 张三 → id=10] --> B2[name和id都在索引里]
B2 --> B3[直接返回 ✅]
end
覆盖索引能带来什么好处
1. 减少 IO 次数
-- 表结构:idx_name_age(name, age)
-- 查询1:需要回表
SELECT * FROM user WHERE name = '张三';
-- IO次数 = 二级索引(3次) + 聚簇索引回表(3次) = 6次
-- 查询2:覆盖索引,无需回表
SELECT name, age FROM user WHERE name = '张三';
-- IO次数 = 二级索引(3次) = 3次 ✅
2. 减少随机 IO
-- 大批量查询时效果更明显
SELECT name, age FROM user WHERE name LIKE '张%';
-- 假设有 1000 条记录
-- 覆盖索引:一次线性扫描索引,3-4次IO
-- 非覆盖索引:回表1000次 = 3000次随机IO 😱
3. 索引比表小,缓存更高效
graph TD
A[InnoDB Buffer Pool] --> B[索引页面<br/>覆盖索引查询只需要加载索引页]
A --> C[数据页面<br/>聚簇索引页包含所有字段<br/>页面大,同样空间能缓存的数据页少]
B --> D[索引页大小: 16KB<br/>全是索引,存储效率高]
C --> E[数据页大小: 16KB<br/>很多字段不需要,浪费空间]
F[覆盖索引只加载索引页<br/>Buffer Pool利用率更高]
如何利用覆盖索引
设计合适的联合索引
-- 业务场景:经常根据状态查询订单的金额和创建时间
-- 查询语句:
SELECT order_id, amount, created_at
FROM orders
WHERE status = 'paid';
-- ❌ 只给 status 加索引
CREATE INDEX idx_status ON orders(status);
-- 需要回表取 amount 和 created_at
-- ✅ 创建覆盖索引
CREATE INDEX idx_status_cover ON orders(status, amount, created_at);
-- status: WHERE 条件
-- amount, created_at: SELECT 字段
-- 一次索引扫描搞定!
检查查询是否用了覆盖索引
-- 使用 EXPLAIN 查看 Extra 字段
EXPLAIN SELECT name, age FROM user WHERE name = '张三';
-- 输出:
+----+-------------+-------+------+---------------+------+---------+-------+------+--------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+-------+------+--------------------------+
| 1 | SIMPLE | user | ref | idx_name_age | idx | 302 | const | 1 | Using index |
+----+-------------+-------+------+---------------+------+---------+-------+------+--------------------------+
关键标识:Extra 字段显示 Using index(不是 Using index condition!)表示使用了覆盖索引。
常见 Extra 含义
| Extra | 含义 |
|---|---|
Using index |
✅ 覆盖索引!不需要回表 |
Using index condition |
索引条件下推,但可能回表 |
Using where; Using index |
覆盖索引 + WHERE 条件过滤 |
| 无 Using index | ❌ 需要回表 |
覆盖索引的限制
1. 不能覆盖所有字段
-- 如果查询需要大量字段,无法全放进索引
SELECT * FROM user WHERE name = '张三';
-- * 包含了所有字段,不可能全放在索引中
-- 除非建索引包含所有列(太浪费)
2. 不能有太长的字段
-- TEXT/BLOB 字段不能放在覆盖索引中
SELECT body FROM article WHERE title = 'MySQL优化';
-- body 是 TEXT 类型,无法放到索引里
-- 即使 (title, body) 建了索引,查 body 还是得回表
3. 更新成本
-- 覆盖索引通常包含多个字段
CREATE INDEX idx_cover ON t(a, b, c, d);
-- 任何对 a/b/c/d 的更新都需要维护这个索引
-- 写入变慢
覆盖索引使用策略
-- 高频查询优化策略
-- 找出最频繁的查询模式,为它建覆盖索引
-- 频繁查询1:按用户查订单的金额和状态
SELECT order_id, amount, status FROM orders WHERE user_id = ?;
-- 建索引:idx_user_cover(user_id, amount, status, order_id)
-- 频繁查询2:按分类查商品名称和价格
SELECT name, price FROM products WHERE category_id = ? ORDER BY price;
-- 建索引:idx_category_cover(category_id, price, name)
面试要点
- 核心价值:减少回表 → 减少 IO → 查询更快
- 判断依据:EXPLAIN 看 Extra 是否包含
Using index - 适用场景:高频查询 + 查询字段有限时
- 设计思路:把 SELECT 的字段也放进索引中
- 代价权衡:覆盖索引让查询更快,但写入更慢
一句话总结:覆盖索引是"查询无需回表"——索引里已经有你想要的一切,不必再去数据行翻找。
聚簇索引与非聚簇索引区别
聚簇索引与非聚簇索引区别
一句话区别
| 聚簇索引 | 非聚簇索引(二级索引) | |
|---|---|---|
| 数据在哪 | 叶子节点存整行数据 | 叶子节点存主键值 |
| 需要回表 | 不需要 | 需要(除非覆盖索引) |
| 一个表有几个 | 一定有且仅有 1 个 | 可以有 0-N 个 |
| InnoDB 默认 | 主键 = 聚簇索引 | 手动创建 |
结构对比
graph TD
subgraph 聚簇索引[聚簇索引 PRIMARY KEY]
A1[非叶子节点<br/>10 , 20 , 30 ...] --> B1[叶子节点<br/>id=10 → 整行<br/>name, age, email...]
A1 --> B2[叶子节点<br/>id=20 → 整行<br/>name, age, email...]
A1 --> B3[叶子节点<br/>id=30 → 整行<br/>name, age, email...]
B1 <--> B2 <--> B3
end
subgraph 非聚簇索引[非聚簇索引 idx_name]
A2[非叶子节点<br/>张 , 李 , 王 ...] --> B4[叶子节点<br/>name=张三 → id=10]
A2 --> B5[叶子节点<br/>name=李四 → id=20]
A2 --> B6[叶子节点<br/>name=王五 → id=30]
B4 <--> B5 <--> B6
B4 -.-> B1
B5 -.-> B2
B6 -.-> B3
end
详细对比
| 维度 | 聚簇索引 | 非聚簇索引 |
|---|---|---|
| 排序 | 数据物理有序 | 逻辑有序 |
| 查询速度(等值) | ⭐ 更快(一次定位) | 较快(两次定位) |
| 查询速度(范围) | ⭐ 更快(相邻数据物理相邻) | 较快(随机IO回表) |
| 插入速度(顺序) | ⭐ 快(追加) | 快 |
| 插入速度(随机) | 慢(页分裂) | 慢 |
| 空间占用 | 数据本身 | 额外空间 |
| 删除 | 删除整行 | 删除索引条目 |
一个表 vs 多个索引
CREATE TABLE employee (
id INT PRIMARY KEY, -- 聚簇索引(数据存储在这里)
emp_no VARCHAR(20),
name VARCHAR(50),
dept_id INT,
salary DECIMAL(10,2),
INDEX idx_emp_no(emp_no), -- 非聚簇索引1
INDEX idx_dept(dept_id), -- 非聚簇索引2
INDEX idx_salary(salary) -- 非聚簇索引3
) ENGINE=InnoDB;
graph LR
subgraph 磁盘上的物理结构
T[.ibd文件] --> A[聚簇索引 B+树<br/>总数据量 ≈ 100MB]
T --> B[二级索引 idx_emp_no<br/>≈ 15MB]
T --> C[二级索引 idx_dept<br/>≈ 10MB]
T --> D[二级索引 idx_salary<br/>≈ 10MB]
end
- 聚簇索引 ≈ 表数据大小
- 每个非聚簇索引 ≈ 索引列大小 + 主键大小
查询路径对比
-- 场景1:通过主键查询(聚簇索引直接命中)
SELECT * FROM employee WHERE id = 100;
-- 路径:聚簇索引 → 直接返回数据
-- IO次数:树高=3, 只需3次
-- 场景2:通过工号查询(二级索引,需回表)
SELECT * FROM employee WHERE emp_no = 'E00100';
-- 路径:idx_emp_no → 主键=100 → 聚簇索引 → 数据
-- IO次数:二级索引3次 + 聚簇索引3次 = 6次
-- 场景3:通过工号查询但只查索引字段(覆盖索引)
SELECT emp_no FROM employee WHERE emp_no = 'E00100';
-- 路径:idx_emp_no → 直接返回
-- IO次数:只查二级索引3次 ✅
MyISAM 的非聚簇索引
MyISAM 中没有聚簇索引,数据和索引完全分离:
graph LR
subgraph MyISAM
A[.MYI 索引文件] --> B[索引叶子节点<br/>key+数据页指针]
B --> C[.MYD 数据文件<br/>随机存储的行数据]
end
-- MyISAM 中所有索引都是非聚簇的
CREATE TABLE t (
id INT PRIMARY KEY, -- 主键索引也是非聚簇的
name VARCHAR(50),
INDEX idx_name(name) -- 普通索引同
) ENGINE=MyISAM;
-- MyISAM 不管用什么索引,都要去 .MYD 文件中找数据
SELECT * FROM t WHERE id = 1; -- idx_primary 找到指针 → .MYD 取数据
SELECT * FROM t WHERE name = '张三'; -- idx_name 找到指针 → .MYD 取数据
| 特性 | InnoDB 聚簇 | MyISAM 非聚簇 |
|---|---|---|
| 主键索引 | 叶子存整行 | 叶子存指针 |
| 二级索引 | 叶子存主键 | 叶子存指针 |
| 回表方式 | 根据主键回聚簇索引 | 根据指针回 .MYD 文件 |
| 数据文件 | 索引=数据(.ibd) | 索引(.MYI) ≠ 数据(.MYD) |
实践建议
-- 1. 主键越小越好(影响所有二级索引的大小)
-- ❌ 大主键
id CHAR(36) PRIMARY KEY -- 每个二级索引都多了36字节
-- ✅ 小主键
id INT PRIMARY KEY -- 每个二级索引只多4字节
-- 2. 尽量利用聚簇索引查询
SELECT * FROM t WHERE id = 100; -- 直接命中聚簇索引,不回表
-- 3. 需要回表时,批量查比逐条查好
-- ❌ 逐条回表(N次随机IO)
SELECT * FROM t WHERE name IN ('a', 'b', 'c', ..., 'z');
-- ✅ 利用聚簇索引的批处理
面试要点
- 根本区别:聚簇索引存数据,非聚簇索引存主键
- 数量区别:1 个聚簇 vs N 个非聚簇
- 查询区别:聚簇查询更快(不回表),非聚簇可能回表
- MyISAM 区别:所有索引都是非聚簇的
- 大小关系:主键大小直接影响非聚簇索引大小
一句话总结:聚簇索引是"带着行李住酒店"(什么都有),非聚簇索引是"告诉你房号"(自己去前台拿)。
二级索引(非聚簇索引)
二级索引(非聚簇索引)
什么是二级索引
二级索引(Secondary Index)也叫非聚簇索引,是主键之外的索引。它的叶子节点不存储完整数据行,而是存储主键值。
graph TD
subgraph 二级索引 idx_name
A1[根节点<br/>name=张 → 指向叶子A] --> B1[叶子节点<br/>name=张三 → PK=10]
A1 --> B2[叶子节点<br/>name=李四 → PK=20]
A1 --> B3[叶子节点<br/>name=王五 → PK=30]
end
subgraph 聚簇索引 PRIMARY
C1[叶子节点<br/>id=10 → 整行数据]
C2[叶子节点<br/>id=20 → 整行数据]
C3[叶子节点<br/>id=30 → 整行数据]
end
B1 --> C1
B2 --> C2
B3 --> C3
创建二级索引
CREATE TABLE user (
id INT PRIMARY KEY, -- 聚簇索引
name VARCHAR(50),
age INT,
email VARCHAR(100),
INDEX idx_name(name), -- 单列二级索引
INDEX idx_age(age), -- 单列二级索引
INDEX idx_name_age(name, age), -- 联合二级索引
UNIQUE INDEX idx_email(email) -- 唯一二级索引
) ENGINE=InnoDB;
二级索引的存储结构
-- 查看索引
SHOW INDEX FROM user;
-- 输出:
+-------+------------+----------+--------------+
| Table | Non_unique | Key_name | Column_name |
+-------+------------+----------+--------------+
| user | 0 | PRIMARY | id |
| user | 1 | idx_name | name | -- 二级索引
| user | 1 | idx_age | age | -- 二级索引
+-------+------------+----------+--------------+
二级索引的 B+树
idx_name 的 B+树:
非叶子节点(只存 name + 指针):
[name='a' ~ name='z']
叶子节点(存 name + id):
[name='张三', id=10] → [name='李四', id=20] → [name='王五', id=30]
↓ ↓ ↓
聚簇索引 聚簇索引 聚簇索引
关键点:每个二级索引都是一棵独立的 B+树,占用额外的磁盘空间。
二级索引的查询过程
等值查询
SELECT * FROM user WHERE name = '张三';
sequenceDiagram
participant E as 执行器
participant S as 二级索引 idx_name
participant C as 聚簇索引 PRIMARY
E->>S: 查找 name='张三'
S-->>E: 找到主键 id=10
Note over S,E: 第一步:二级索引定位
E->>C: 通过 id=10 查找完整行
C-->>E: 返回整行数据
Note over C,E: 第二步:回表查询
完整步骤:
1. 在 idx_name 的 B+树中找到 name='张三' 的叶子节点
2. 叶子节点中拿到主键值 id=10
3. 通过 id=10 回聚簇索引查找完整行数据
覆盖索引(无需回表)
-- 只查 name 和 id,都在二级索引中
SELECT name, id FROM user WHERE name = '张三';
sequenceDiagram
participant E as 执行器
participant S as 二级索引 idx_name
E->>S: 查找 name='张三'
S-->>E: 返回 name='张三', id=10
Note over S,E: ✅ 二级索引已包含所需字段<br/>无需回表!
二级索引维护对性能的影响
-- 插入一条记录
INSERT INTO user VALUES (100, '赵六', 35, 'zhao@example.com');
-- InnoDB 要做:
-- 1. 在聚簇索引中插入数据
-- 2. 在 idx_name 中插入 [赵六, 100]
-- 3. 在 idx_age 中插入 [35, 100]
-- 索引越多,写入越慢!
DELETE FROM user WHERE id = 100;
-- 同样需要维护所有索引
索引维护成本
graph TD
A[写入一条数据] --> B[写聚簇索引<br/>1次IO]
A --> C[写 idx_name<br/>1次IO]
A --> D[写 idx_age<br/>1次IO]
A --> E[写 idx_email<br/>1次IO]
F[索引数量 = N] --> G[写入成本 = 1 + N 次索引维护]
查看索引占用的空间
-- 查看每个索引的大小
SELECT
TABLE_NAME AS `Table`,
INDEX_NAME AS `Index`,
ROUND(STAT_VALUE * @@innodb_page_size / 1024 / 1024, 2) AS `Size(MB)`
FROM performance_schema.table_io_waits_summary_by_index_usage
JOIN mysql.innodb_index_stats
USING (TABLE_NAME, INDEX_NAME)
WHERE STAT_NAME = 'size'
AND TABLE_SCHEMA = 'test';
选择哪些列建二级索引
-- 好的选择:区分度高、查询频繁的列
CREATE INDEX idx_order_id ON orders(order_id); -- ✅ 频繁查询
CREATE INDEX idx_status ON orders(status); -- ❌ 区分度低(就几种状态)
-- 好的选择:WHERE 条件常用列
CREATE INDEX idx_city ON user(city); -- ✅ WHERE 经常用
-- 注意长度:字符串索引可以前缀
CREATE INDEX idx_content ON article(body(10)); -- 只对前10字符建索引
面试要点
- 结构:二级索引 = 独立的 B+树,叶子节点存主键值
- 回表:通过二级索引查数据需要两次 B+树搜索
- 覆盖索引:如果查询只需要索引中的字段,不需要回表,更高效
- 维护成本:每个索引写入都需要额外 IO,索引越多写入越慢
- 空间占用:每个二级索引都是单独的数据结构
一句话总结:二级索引是"加引路牌"——它告诉你数据在哪(主键),但你要自己去取(回表)。
B+树树高和存储能力
B+树树高和存储能力
面试高频题
B+树为什么能支撑千万甚至亿级数据?关键看树高和每个节点的存储能力。
核心概念:扇出(Fan-out)
扇出 = 每个节点包含的子节点数量。B+树的扇出远大于二叉树,这是它"矮胖"的根本原因。
graph TD
subgraph 二叉树[二叉树 扇出=2]
A1[1] --> B1[2]
A1 --> B2[3]
end
subgraph B+树[B+树 扇出≈1170]
A2[根节点<br/>1170个key] --> B3[子节点1]
A2 --> B4[子节点2]
A2 --> B5[...]
A2 --> B6[子节点1170]
end
数据页大小
-- InnoDB 默认数据页大小
SHOW VARIABLES LIKE 'innodb_page_size';
-- 默认 16384 = 16KB
计算过程
非叶子节点能存多少个 key
graph LR
A[16KB页] --> B[1页 = 16384字节]
B --> C[假设:<br/>key=8字节 INT<br/>指针=6字节]
C --> D[每个索引项<br/>= 8 + 6 = 14字节]
D --> E[每页索引数<br/>= 16384 / 14 ≈ 1170个]
树高与存储行数的关系
-- 假设:
-- 主键大小:8 字节(BIGINT)
-- 每页:16KB
-- 行大小:1KB
-- 每页数据行数:16KB / 1KB = 16 行
-- 非叶子节点扇出:~1170
graph TD
subgraph 树高=2[树高=2<br/>1个根页存储1170个key]
H2_R[根页<br/>1170个key] --> H2_L1[叶子节点1<br/>最多16行]
H2_R --> H2_L2[叶子节点2<br/>最多16行]
H2_R --> H2_L3[...]
H2_R --> H2_L1170[叶子节点1170<br/>最多16行]
end
subgraph 存储量2[存储量<br/>1170 × 16 = 18,720行]
end
graph TD
subgraph 树高=3[树高=3<br/>千万级存储]
H3_R[根页<br/>1170个key] --> H3_M1[中间页<br/>1170个key]
H3_R --> H3_M2[中间页<br/>1170个key]
H3_R --> H3_M3[...]
H3_R --> H3_M1170[中间页<br/>1170个key]
H3_M1 --> H3_L1[叶子<br/>16行]
H3_M1 --> H3_L2[叶子<br/>16行]
H3_M1 --> H3_L1170[叶子<br/>16行]
end
subgraph 存储量3[存储量<br/>1170 × 1170 × 16 ≈ 2190万行]
end
完整计算表
| 树高 | 存储行数 | 查询需要IO次数 |
|---|---|---|
| 1 | ≤ 16 行 | 1 次 |
| 2 | 约 1.8 万行 | 2 次 |
| 3 | 约 2190 万行 | 3 次 |
| 4 | 约 256 亿行 | 4 次 |
实际场景验证
-- 创建一个千万级数据的表
CREATE TABLE t (
id BIGINT PRIMARY KEY,
name VARCHAR(50)
) ENGINE=InnoDB;
-- 插入 2000 万行数据
-- 查看 B+树高度
SELECT
b.name,
index_levels -- InnoDB 索引层数
FROM information_schema.INNODB_TABLESPACES t
JOIN information_schema.INNODB_TABLES b ON t.space = b.space;
实际中 index_levels 的值通常为 2(表示树高为 3,根页 level=2)。
影响树高的因素
-- 1. 主键越大,扇出越小
-- 如果主键是 UUID(36字节):
-- 每个索引项 = 36 + 6 = 42字节
-- 每页索引数 = 16384 / 42 ≈ 390
-- 树高=3时存储量: 390 × 390 × 16 ≈ 240万行
-- 比 BIGINT 主键少了近 10 倍!
-- 2. 行越大,每页行数越少
-- 行大小 2KB → 每页 8 行
为什么主键建议用整型自增
- 整型小(4/8字节)→ 扇出高 → 树矮
- 自增 → 插入在末尾 → 减少页分裂
- UUID → 32字节 → 扇出低 → 树高 → 性能差
-- ❌ 不推荐:UUID 做主键
CREATE TABLE bad (
id CHAR(36) PRIMARY KEY DEFAULT UUID(),
...
);
-- ✅ 推荐:BIGINT 自增
CREATE TABLE good (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
...
);
面试要点
- 树高逻辑:B+树通常 3-4 层就能支撑千万到亿级数据
- 扇出关键:非叶子节点只存 key+指针,扇出 ≈ 1170(BIGINT)
- IO 次数:一次查询需要 3-4 次磁盘 IO(树高=3或4)
- 影响因素:主键大小、行大小直接决定存储能力
- 实践结论:自增 BIGINT 主键是 B+树的最佳拍档
一句话总结:B+树 3 层就能存 2000 万行数据,每次查询只需要 3 次磁盘 IO——这就是它"矮胖"的威力。
聚簇索引 Clustered Index
聚簇索引 Clustered Index
什么是聚簇索引
聚簇索引(Clustered Index)是指数据行和索引存储在一起的索引结构。InnoDB 中,表数据本身就是按照聚簇索引组织的。
graph TD
subgraph InnoDB聚簇索引
A[根节点<br/>非叶子页<br/>只存主键+指针] --> B[中间页]
A --> C[中间页]
B --> D[叶子页<br/>key=10 → 整行数据<br/>id=10, name=张三, age=25]
B --> E[叶子页<br/>key=20 → 整行数据<br/>id=20, name=李四, age=30]
C --> F[叶子页<br/>key=30 → 整行数据<br/>id=30, name=王五, age=28]
end
如何创建聚簇索引
CREATE TABLE user (
id INT PRIMARY KEY, -- 主键 = 聚簇索引
name VARCHAR(50),
age INT
) ENGINE=InnoDB;
-- 或
CREATE TABLE user (
id INT NOT NULL,
name VARCHAR(50),
PRIMARY KEY (id) -- 显式定义主键
) ENGINE=InnoDB;
聚簇索引的创建规则:
flowchart TD
A[创建InnoDB表] --> B{定义了主键?}
B -->|是| C[主键 = 聚簇索引]
B -->|否| D{有非空唯一键?}
D -->|是| E[第一个非空唯一键 = 聚簇索引]
D -->|否| F[InnoDB自动生成<br/>6字节ROWID作为隐藏主键]
聚簇索引的存储结构
-- InnoDB 表的数据文件
-- 每个 .ibd 文件就是一个大的B+树
物理布局:
graph LR
subgraph IBD文件
D[数据页1<br/>行数据] --> E[数据页2<br/>行数据]
D --> F[数据页3<br/>行数据]
G[索引页1] --> H[索引页2]
G --> I[索引页3]
end
- 聚簇索引的数据页同时也是索引的叶子节点
- 数据和索引是同一个文件
- 按主键顺序物理存储
聚簇索引的优点
1. 主键查询极快
-- 通过主键查询,一次索引定位就拿到全部数据
SELECT * FROM user WHERE id = 100;
-- B+树 3层 → 3次IO → 直接拿到整行数据
-- 不需要额外"回表"
2. 范围查询高效
-- 按主键范围查询,数据已经有序
SELECT * FROM user WHERE id BETWEEN 100 AND 200;
-- 找到id=100后,顺着叶子链表遍历
-- 相邻页物理连续,磁盘IO友好
3. 排序无需额外成本
SELECT * FROM user ORDER BY id;
-- 直接遍历聚簇索引的叶子节点链表
-- 已经是排好序的,不需要 filesort
聚簇索引的缺点
1. 插入速度受顺序影响
-- ✅ 自增主键:追加插入,尾部写入
CREATE TABLE t (id INT AUTO_INCREMENT PRIMARY KEY);
-- ❌ UUID主键:随机插入,频繁页分裂
CREATE TABLE t (id CHAR(36) PRIMARY KEY);
UUID主键导致的问题:
graph LR
A[插入UUID=xxx] --> B[页已满]
B --> C[页分裂<br/>→ 新开一个页]
C --> D[数据移动<br/>→ 性能开销]
C --> E[页碎片<br/>→ 空间浪费]
D --> F[频繁分裂<br/>→ 插入速度慢]
2. 数据移动
- 按主键顺序插,B+树只在末尾增长
- 不按顺序插,B+树需要做页分裂
- 页分裂导致性能下降和空间浪费
验证聚簇索引
-- 查看表的索引类型
SHOW INDEX FROM user;
-- 输出:
+-------+------------+----------+--------------+
| Table | Non_unique | Key_name | Column_name |
+-------+------------+----------+--------------+
| user | 0 | PRIMARY | id | -- 聚簇索引
| user | 1 | idx_name | name | -- 二级索引
+-------+------------+----------+--------------+
面试要点
- 定义:数据行和索引存储在一起的索引就是聚簇索引
- InnoDB 必有:每个 InnoDB 表都有一个聚簇索引(即使没定义主键)
- 主键建议:自增整型主键 → 避免页分裂,提升插入性能
- 查询优势:主键查询一次 IO 就到数据,不需要回表
- 数据组织:表数据 = 聚簇索引,删掉索引就等于删掉表
一句话总结:聚簇索引就是"索引即数据,数据即索引"——找到了索引就直接拿到了整行数据。
B+树与哈希索引对比
B+树与哈希索引对比
概述
MySQL 中 B+树是默认索引结构,但 InnoDB 也支持自适应哈希索引(Adaptive Hash Index)。理解两者区别,知道什么场景用什么索引。
结构对比
graph LR
subgraph B+树
A[根节点<br/>有序排列] --> B[中间节点]
A --> C[中间节点]
B --> D[叶子节点1<br/>key=1 → data]
B --> E[叶子节点2<br/>key=5 → data]
C --> F[叶子节点3<br/>key=10 → data]
C --> G[叶子节点4<br/>key=20 → data]
D <--> E <--> F <--> G
end
subgraph 哈希索引
H[哈希函数] --> I[数组]
I --> J[槽位1]
I --> K[槽位2]
I --> L[...]
end
核心区别对比
| 特性 | B+树 | 哈希索引 |
|---|---|---|
| 单点查询 | O(log N) | O(1) ⭐ |
| 范围查询 | ✅ 支持 ⭐ | ❌ 不支持 |
| 排序 | ✅ 天然有序 | ❌ 无序 |
| 模糊匹配 | ✅ LIKE 'abc%' | ❌ LIKE '%abc' |
| 组合索引 | ✅ 最左前缀匹配 | ❌ 必须全字段 |
| 冲突处理 | 无冲突 | 哈希冲突 |
| 存储空间 | 较大 | 较小 |
| 适用场景 | 通用 | 等值查询特化 |
性能对比
-- 哈希索引优势场景:等值查询
SELECT * FROM user WHERE id = 100; -- 哈希 O(1) > B+树 O(log N)
| 数据量 | B+树查询 | 哈希查询 |
|---|---|---|
| 100万 | ~3次IO | 1次计算+1次IO |
| 1亿 | ~4次IO | 1次计算+1次IO |
哈希索引的劣势场景
-- B+树支持,哈希索引不支持
-- 1. 范围查询
SELECT * FROM user WHERE id > 100 AND id < 200;
-- B+树:找到100,顺着叶子链表走 ✅
-- 哈希:无法范围扫描 ❌
-- 2. 排序
SELECT * FROM user ORDER BY id;
-- B+树:叶子节点顺序遍历 ✅
-- 哈希:全量加载再排序 ❌
-- 3. 模糊匹配
SELECT * FROM user WHERE name LIKE '张%';
-- B+树:利用有序性,"张"开头的范围扫描 ✅
-- 哈希:无法做到 ❌
-- 4. GROUP BY
SELECT COUNT(*), age FROM user GROUP BY age;
-- B+树:索引有序,分组高效 ✅
-- 哈希:无序 ❌
InnoDB 自适应哈希索引(AHI)
InnoDB 内部有一个"智能"的哈希索引机制,不需要手动创建。
flowchart TD
A[InnoDB运行中] --> B[监控索引访问模式]
B --> C{某个B+树索引<br/>频繁等值查询?}
C -->|是| D[自动在内存中
建立哈希索引]
C -->|否| E[继续监控]
D --> F[查询命中哈希
速度更快]
特点
- 自动:无需手动创建或维护
- 内存:只存在 Buffer Pool 中,不持久化
- 筛选条件:某个索引页被反复等值访问时自动建立
- 限制:只对等值查询有效
-- 查看自适应哈希索引状态
SHOW VARIABLES LIKE 'innodb_adaptive_hash_index';
-- 默认开启
-- 监控AHI使用情况
SHOW ENGINE INNODB STATUS\G
-- 查看 Hash searches / Non-hash searches 比例
Memory 引擎的哈希索引
Memory 引擎支持显式创建哈希索引:
CREATE TABLE t (
id INT PRIMARY KEY,
name VARCHAR(50),
INDEX USING HASH (name) -- 显式哈希索引
) ENGINE=MEMORY;
注意:
- 仅 Memory 引擎支持显式 USING HASH
- InnoDB 不支持手动指定哈希索引
- Memory 引擎的哈希索引也是仅等值查询
实际选型建议
flowchart TD
A[查询类型] --> B{等值查询为主?}
B -->|是| C{是否频繁范围/排序?}
C -->|否| D[可以考虑哈希索引<br/>但自带的AHI就行]
C -->|是| E[B+树]
B -->|否| E
面试要点
- 哈希优势:等值查询 O(1),比 B+树的 O(log N) 快
- B+树优势:范围查询、排序、模糊匹配、组合索引
- InnoDB 实际方案:B+树 + 自适应哈希 双剑合璧
- 结论:B+树是"全能型选手",哈希是"专项选手"
- 生产建议:一般不需要手动建哈希索引,AHI 够用了
一句话总结:B+树是"六边形战士"什么都能做,哈希是"百米飞人"等值查询无敌但其他不行。
索引本质是什么
索引本质是什么
索引的直观理解
索引的本质是一种数据结构,用于加速数据的检索。类比书本的目录:
graph LR
subgraph 书[书本]
A[目录页<br/>页码索引] --> B[第1章 P1]
A --> C[第2章 P20]
A --> D[第3章 P50]
end
subgraph DB[数据库]
E[索引<br/>B+树结构] --> F[数据行1]
E --> G[数据行1000]
E --> H[数据行50000]
end
没有目录时,你要一页一页翻(全表扫描);有目录时直接翻到目标页(索引定位)。
MySQL 中索引的本质
-- 创建索引
CREATE INDEX idx_name ON user(name);
-- 有索引时,查找过程:
SELECT * FROM user WHERE name = '张三';
-- ① 不走索引:遍历整张表,逐行匹配 → 扫描 N 行
-- ② 走索引:B+树查找,O(log N) 复杂度 → 扫描 ~4 行
索引是"排好序的数据结构"
MySQL 默认使用 B+树 作为索引结构。B+树的核心特点是:
- 有序性:所有数据按关键字排序存储
- 多路平衡:每个节点可以有多个子节点
- 叶子节点存储数据:非叶子节点只存索引值
索引的数据结构演变
graph LR
A[线性查找<br/>全表扫描 O(N)] --> B[二分查找<br/>O(log N)<br/>但要求数据有序]
B --> C[二叉树<br/>可能退化为链表<br/>最差O(N)]
C --> D[平衡二叉树AVL<br/>严格O(log N)<br/>树高随数据量上升]
D --> E[B+树<br/>多路平衡<br/>扇出大 树高低<br/>MySQL索引]
索引的空间和时间
-- 空间换时间
CREATE TABLE user (
id INT PRIMARY KEY, -- 主键索引 ≈ 占空间
name VARCHAR(50),
email VARCHAR(100),
INDEX idx_name(name) -- 额外索引 ≈ 额外空间
);
索引需要的额外空间
-- 查看索引大小
SELECT
TABLE_NAME,
INDEX_LENGTH / 1024 / 1024 AS index_size_mb
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'test' AND TABLE_NAME = 'user';
- 每个索引都是一个独立的数据结构(B+树)
- 索引也要占用磁盘空间
- 索引也要维护(INSERT/UPDATE/DELETE 时需要更新索引)
索引的存储方式
graph TD
subgraph InnoDB索引存储
A[聚簇索引<br/>Clustered Index] --> B[叶子节点存整行数据]
C[二级索引<br/>Secondary Index] --> D[叶子节点存主键值]
end
索引的重要性
-- 没有索引的查询
EXPLAIN SELECT * FROM user WHERE name = '张三';
-- type: ALL(全表扫描)
-- rows: 1000000
-- 有索引的查询
EXPLAIN SELECT * FROM user WHERE name = '张三';
-- type: ref(非唯一索引扫描)
-- rows: 1
| 数据量 | 无索引 | 有B+树索引 |
|---|---|---|
| 1000行 | 1000次比较 | ~3次IO |
| 100万行 | 100万次比较 | ~4次IO |
| 10亿行 | 10亿次比较 | ~5次IO |
这就是为什么索引能让查询从"秒级"降到"毫秒级"。
索引的缺点
-- 索引不是万能的
-- 1. 额外空间
-- 2. 写入变慢(需要同时维护索引)
INSERT INTO user VALUES (1, '张三', ...); -- 写数据 + 更新索引
-- 3. 选择性差的列不适合建索引
CREATE INDEX idx_gender ON user(gender); -- 只有"男/女",没啥用
-- 4. 频繁修改的列不适合建索引
CREATE INDEX idx_status ON order(status); -- status 经常变,索引频繁维护
面试要点
- 本质:索引是一种排好序的数据结构,用于加速数据检索
- 核心思想:空间换时间
- 默认实现:B+树(MySQL InnoDB)
- 效率:O(log N) 查询复杂度,百万行数据只需 3-4 次 IO
- 代价:占用空间 + 降低写入速度
一句话总结:索引的本质是"排好序的数据结构",用空间换时间,让数据检索从 O(N) 降到 O(log N)。
慢查询日志 SLOWLOG:找出 Redis 中的"慢司机"
慢查询日志 SLOWLOG:找出 Redis 中的"慢司机"
什么是 SLOWLOG
SLOWLOG 是 Redis 内置的慢查询日志系统,用于记录执行时间超过指定阈值的命令。它是排查 Redis 性能问题的第一道防线。
SLOWLOG 的配置
两个关键配置项
# 慢查询阈值(微秒)
slowlog-log-slower-than 10000 # 记录执行超过 10 毫秒的命令
# 慢查询日志最大保留条数
slowlog-max-len 1000 # 最多保留 1000 条
参数说明:
- slowlog-log-slower-than:单位是微秒(1 毫秒 = 1000 微秒)
- 设为 -1 表示不记录任何慢查询
- 设为 0 表示记录所有命令
- slowlog-max-len:当日志超过此数量时,最早的日志会被丢弃
运行时修改
# 不需要重启即可修改
CONFIG SET slowlog-log-slower-than 5000
CONFIG SET slowlog-max-len 2000
# 持久化到配置文件
CONFIG REWRITE
配置文件修改
# redis.conf
slowlog-log-slower-than 10000
slowlog-max-len 1000
SLOWLOG 命令使用
查看慢查询
# 获取最近 10 条慢查询
SLOWLOG GET 10
# 输出示例
1) 1) (integer) 14 -- 唯一 ID
2) (integer) 1650000000 -- Unix 时间戳
3) (integer) 15023 -- 执行耗时(微秒)
4) 1) "KEYS" -- 命令
2) "*" -- 参数
5) "127.0.0.1:6379" -- 客户端地址
6) "user-agent=redis-cli" -- 客户端名称
2) 1) (integer) 13
2) (integer) 1650000000
3) (integer) 12045
4) 1) "SORT"
2) "biglist"
3) "LIMIT"
4) "0"
5) "1000"
5) "127.0.0.1:6379"
6) ""
其他 SLOWLOG 命令
# 获取慢查询总数
SLOWLOG LEN
# (integer) 14
# 清空慢查询日志
SLOWLOG RESET
慢查询日志的结构
每条日志包含 6 个字段:
| 字段 | 含义 | 示例 |
|---|---|---|
| ID | 慢查询的唯一标识 | 14 |
| 时间戳 | 命令执行时的 Unix 时间戳 | 1650000000 |
| 耗时 | 执行耗时(微秒) | 15023 |
| 命令 | 命令及参数数组 | ["KEYS", "*"] |
| 客户端地址 | 来源 IP 和端口 | "127.0.0.1:6379" |
| 客户端名称 | 客户端标识 | "user-agent=redis-cli" |
如何分析慢查询
1. 按频率排序
import redis
r = redis.Redis()
logs = r.slowlog_get(100)
# 统计各命令出现频率
from collections import Counter
commands = Counter()
for log in logs:
cmd = log['command'].decode() if isinstance(log['command'], bytes) else log['command']
commands[cmd.split()[0]] += 1
print("慢查询命令频率 TOP 5:")
for cmd, count in commands.most_common(5):
print(f" {cmd}: {count} 次")
2. 按耗时排序
# 找出最慢的命令
sorted_logs = sorted(logs, key=lambda x: x['duration'], reverse=True)
print("最慢的 5 个命令:")
for log in sorted_logs[:5]:
print(f" {log['command']} - {log['duration']}μs ({log['duration']/1000:.2f}ms)")
生产环境最佳实践
合理的阈值设置
# 不同场景推荐值
slowlog-log-slower-than 10000 # 一般业务:10ms
slowlog-log-slower-than 5000 # 对延迟敏感:5ms
slowlog-log-slower-than 1000 # 高延迟敏感业务:1ms
日志保留策略
# 慢调用量较大的服务建议增大容量
slowlog-max-len 5000 # 保留更多日志用于分析
监控告警
def check_slowlog(redis, threshold_ms=50):
"""监控慢查询并告警"""
logs = redis.slowlog_get(10)
for log in logs:
duration = log['duration'] / 1000 # 转为毫秒
if duration > threshold_ms:
alert(
level="WARNING",
message=f"Redis 慢查询: {log['command']}",
duration=f"{duration:.0f}ms"
)
常见慢查询案例
| 命令 | 耗时 | 原因 | 解决方案 |
|---|---|---|---|
| KEYS * | 500ms | 遍历全量 key | 改用 SCAN |
| SMEMBERS big_set | 200ms | 集合百万成员 | 改用 SSCAN |
| DEL big_set | 300ms | 删除大集合 | 改用 UNLINK |
| SORT big_list | 400ms | 排序大量元素 | 使用 ZSET 或限制数据量 |
| LREM big_list | 150ms | 移除大量元素 | 改用其他数据结构 |
面试要点
slowlog-log-slower-than单位是微秒(默认 10000μs = 10ms)- SLOWLOG 记录的是命令执行时间,不包括排队等待时间
- SLOWLOG 存储在内存中,重启不会丢失(但重启后清空)
- 默认保留 128 条,建议调整为 1000+
- 配合监控系统定期分析慢查询日志是 Redis 运维的重要工作
索引失效场景全解析:10 种常见原因及 EXPLAIN 排查方法
索引失效场景全解析:10 种常见原因及 EXPLAIN 排查方法
一、定义
索引失效是指 SQL 查询虽然使用了索引列作为条件,但查询优化器没有选择使用索引,或者使用了部分索引但效果不佳,导致全表扫描或扫描无效。理解索引失效场景对 SQL 性能优化至关重要。
二、10 种常见索引失效场景
1. 违背最左前缀原则
复合索引 (a, b, c) 的查询必须从最左列开始:
-- 索引 idx_a_b_c (a, b, c)
-- ✅ 能用到索引
SELECT * FROM t WHERE a = 1;
SELECT * FROM t WHERE a = 1 AND b = 2;
SELECT * FROM t WHERE a = 1 AND b = 2 AND c = 3;
SELECT * FROM t WHERE a = 1 AND c = 3; -- a 用到索引,c 无法用到
-- ❌ 索引失效(跳过了最左列)
SELECT * FROM t WHERE b = 2;
SELECT * FROM t WHERE c = 3;
2. 对索引列进行函数操作
-- ❌ 索引失效
SELECT * FROM users WHERE LOWER(name) = 'alice';
SELECT * FROM users WHERE DATE(create_time) = '2024-01-01';
-- ✅ 改写为范围查询,索引生效
SELECT * FROM users WHERE name = 'Alice';
SELECT * FROM users WHERE create_time >= '2024-01-01'
AND create_time < '2024-01-02';
3. 隐式类型转换
-- 假设 phone VARCHAR 类型,有索引
-- ❌ 索引失效(phone 是字符串,传入数字导致类型转换)
SELECT * FROM users WHERE phone = 13800138000;
-- ✅ 正确写法
SELECT * FROM users WHERE phone = '13800138000';
4. 使用 LIKE 以通配符开头
-- ❌ 索引失效(前缀模糊查询)
SELECT * FROM users WHERE name LIKE '%张%';
SELECT * FROM users WHERE name LIKE '%三';
-- ✅ 索引可用(后缀模糊查询)
SELECT * FROM users WHERE name LIKE '张%';
5. OR 条件中有非索引列
-- 假设 name 有索引,status 没有索引
-- ❌ 索引失效(OR 需要对两边都建索引或都全表扫描)
SELECT * FROM users WHERE name = 'Alice' OR status = 1;
-- ✅ 改写为 UNION,分别使用索引
SELECT * FROM users WHERE name = 'Alice'
UNION
SELECT * FROM users WHERE status = 1;
6. 使用 != 或 <> 或 NOT IN
-- ❌ 索引失效(不等于通常走全表扫描)
SELECT * FROM users WHERE name != 'Alice';
SELECT * FROM users WHERE status <> 1;
7. IS NULL 和 IS NOT NULL
-- IS NOT NULL 通常索引失效
SELECT * FROM users WHERE name IS NOT NULL;
-- IS NULL 是否走索引取决于优化器判断和 NULL 值的比例
8. 在索引列上进行算术运算
-- ❌ 索引失效
SELECT * FROM orders WHERE price * 2 > 100;
-- ✅ 将计算移到等号另一边
SELECT * FROM orders WHERE price > 100 / 2;
9. 使用 NOT IN、NOT EXISTS
-- ❌ NOT IN/EXISTS 通常不走索引
SELECT * FROM users WHERE id NOT IN (1, 2, 3);
-- ✅ 考虑使用 LEFT JOIN + IS NULL 改写(有时可走索引)
SELECT u.* FROM users u
LEFT JOIN blacklist b ON u.id = b.user_id
WHERE b.user_id IS NULL;
10. 数据分布特殊(优化器认为全表扫描更快)
- 表中大部分数据满足查询条件(如性别的选择度太低)
- 优化器估算全表扫描比使用索引 + 回表更高效
-- 如果 gender 只有 'M'/'F',各占 50%,索引失效
EXPLAIN SELECT * FROM users WHERE gender = 'M';
三、使用 EXPLAIN 排查索引失效
EXPLAIN 关键字段解读
| 字段 | 含义 | 索引失效表现 |
|---|---|---|
| type | 访问类型 | ALL(全表扫描)= 索引失效 |
| possible_keys | 可能用到的索引 | 为 NULL 表示无可用索引 |
| key | 实际使用的索引 | 为 NULL 表示未使用索引 |
| rows | 扫描行数 | 很大说明未有效使用索引 |
| Extra | 额外信息 | Using where 可能未用索引 |
EXPLAIN SELECT * FROM users WHERE name LIKE '%三';
-- type=ALL, key=NULL, rows=100000, Extra=Using where
-- 全表扫描,索引失效
EXPLAIN SELECT * FROM users WHERE name LIKE '张%';
-- type=range, key=idx_name, rows=100, Extra=Using index condition
-- 范围扫描,使用了索引
四、索引失效总结表
| 场景 | 是否索引失效 | 解决方法 |
|---|---|---|
| 违背最左前缀 | ✅ | 调整查询或索引顺序 |
| 函数操作 | ✅ | 将函数移到右侧或改用范围查询 |
| 隐式类型转换 | ✅ | 保证类型一致 |
| LIKE '%xxx' | ✅ | 改为 LIKE 'xxx%' 或全文索引 |
| OR 含非索引列 | ✅ | 建联合索引或改为 UNION |
| != / <> / NOT IN | 可能 | 改写成其他形式 |
| NULL 查询 | 视情况 | 用默认值替代 NULL |
| 算术运算 | ✅ | 表达式移到等号右边 |
五、面试常见问题
Q1:最左前缀原则中,跳过的列后面的索引列还能用吗?
A:不能。复合索引 (a, b, c) 查询 WHERE a=1 AND c=3,a 用了索引,但 c 没用到(b 列跳过导致 c 无法匹配索引顺序),c 的过滤是在回表后做的。
Q2:如何在多种索引失效场景下快速定位问题?
A:使用 EXPLAIN 分析 SQL 执行计划,关注 type、key、rows、Extra 字段。type 为 ALL 且 rows 很大时说明索引失效或没有索引。
Q3:为什么 MySQL 优化器有时候会放弃使用索引?
A:当优化器估算到使用索引的代价(回表 I/O + 索引扫描)大于全表扫描时(通常是指定列选择度低、数据量小、回表次数多时),会主动放弃索引。可以通过 FORCE INDEX 强制使用索引。
相关文章:MySQL 索引类型详解 | InnoDB 索引原理 B+树 | MySQL 锁机制
InnoDB 索引原理:B+树结构与聚簇索引/二级索引详解
InnoDB 索引原理:B+树结构与聚簇索引/二级索引详解
一、定义
B+树是一种平衡多路搜索树,是 InnoDB 存储引擎的默认索引结构。它通过将数据存储在叶子节点、非叶子节点只存储键值的方式,实现了高效的磁盘 I/O 访问,特别适合范围查询和排序操作。
二、B+树结构详解
1. 基本结构
flowchart TD
subgraph 根节点
R1[50, 80]
end
subgraph 内部节点
I1[10, 30]
I2[60, 70]
I3[90, 100]
end
subgraph 叶子节点
L1[数据页1: 1,2,3,4,5]
L2[数据页2: 6,7,8...]
L3[数据页3: ...]
L4[数据页4: ...]
end
R1 --> I1
R1 --> I2
R1 --> I3
I1 --> L1
I2 --> L2
I3 --> L3
I3 --> L4
L1 -.->|链表| L2
L2 -.->|链表| L3
L3 -.->|链表| L4
2. 关键特性
| 特性 | 说明 |
|---|---|
| 多路平衡 | 每个节点可以存储多个键值,树的高度低 |
| 叶子节点有序 | 叶子节点按键值排序,并形成双向链表 |
| 非叶子节点纯索引 | 只存储键值,不存数据,扇出大 |
| 自平衡 | 插入、删除时自动分裂或合并,保持平衡 |
3. B+树 vs B树
| 对比项 | B+树 | B树 |
|---|---|---|
| 数据存储位置 | 叶子节点 | 所有节点 |
| 非叶子节点 | 仅索引 | 存数据和索引 |
| 叶子节点结构 | 链表相连 | 无链表 |
| 范围查询 | 高效(链表遍历) | 低效(中序遍历) |
| 单点查询 | O(log n) | O(log n) |
三、InnoDB 索引实现
聚簇索引(主键索引)
叶子节点存储完整数据行,数据物理顺序与索引顺序一致。
聚簇索引叶子节点结构:
[主键1, trx_id, roll_ptr, col1, col2, ...] → [主键2, ...] → [主键3, ...]
二级索引(辅助索引)
叶子节点存储主键值,需要回表查询才能获取完整数据。
二级索引叶子节点:
[name:'Alice', 主键1] → [name:'Bob', 主键3] → [name:'Tom', 主键5]
回表查询过程
sequenceDiagram
participant Client as SQL: SELECT * FROM users WHERE name='Alice'
participant Index as 二级索引 idx_name
participant PK as 聚簇索引 (主键)
Client->>Index: 1. 在idx_name中查找name='Alice'
Index->>Client: 2. 返回主键值 id=1
Client->>PK: 3. 通过主键id=1回表查询
PK->>Client: 4. 返回完整数据行
-- 覆盖索引优化:不需要回表
-- 如果查询的列都在二级索引中
SELECT name FROM users WHERE name = 'Alice'; -- Extra: Using index
-- 不需要回表,直接在二级索引叶子节点获取数据
四、B+树高度与性能
高度计算
- InnoDB 一个数据页大小默认为 16KB
- 假设主键为 BIGINT(8字节),指针为 6字节,每个内部节点可存储约 1170 个键值
- 高度为 3 时即可存储约 1170 × 1170 × 16 ≈ 2197 万条记录
- 即 3 次磁盘 I/O 即可查到目标数据
磁盘 I/O 优势
| 数据量 | B+树高度 | 查询 I/O 次数 |
|---|---|---|
| 1万 | 2 | 2次 |
| 1000万 | 3 | 3次 |
| 10亿 | 4 | 4次 |
五、顺序插入 vs 随机插入
flowchart LR
subgraph 顺序插入
A1[新数据] --> A2[追加到最后一页]
A2 --> A3[页分裂次数少]
end
subgraph 随机插入
B1[新数据] --> B2[插入随机位置]
B2 --> B3[频繁页分裂]
end
六、索引优化策略
| 优化策略 | 说明 |
|---|---|
| 覆盖索引 | 索引包含查询所需所有列,避免回表 |
| 索引下推(ICP) | MySQL 5.6+,在索引遍历时先过滤减少回表 |
| 合理设计复合索引 | 遵循最左前缀,将高选择性列放前面 |
| 避免过多索引 | 降低写入开销 |
七、面试常见问题
Q1:为什么 MySQL InnoDB 选择 B+树而不是红黑树或哈希表?
A:红黑树是二叉树,高度太高(百万级数据高度约 20),磁盘 I/O 次数多;哈希表不支持范围查询和排序。B+树结合了树状查找 O(log n) 和链表范围遍历的优势。
Q2:B+树的高度一般是多少?如何计算?
A:一般 2~4 层。以 BIGINT 主键为例,每个节点约 16KB/14B ≈ 1170 个键值指针,百万数据高度为 2~3,千万数据高度为 3~4。
Q3:覆盖索引如何减少回表?
A:当索引包含了查询所需的所有列时,MySQL 直接从二级索引的叶子节点获取数据,无需回表查询聚簇索引。EXPLAIN 的 Extra 字段显示 Using index。
Q4:什么是索引下推(ICP)?
A:MySQL 5.6 引入的优化。在二级索引遍历过程中,对索引包含的列先做条件过滤,减少回表次数。EXPLAIN 中 Extra 显示 Using index condition。
相关文章:MySQL 索引类型详解 | 索引失效场景 | MySQL 锁机制
MySQL 索引类型详解:主键索引 / 普通索引 / 唯一索引 / 复合索引 / 聚簇索引
MySQL 索引类型详解:主键索引 / 普通索引 / 唯一索引 / 复合索引 / 聚簇索引
一、定义
索引是数据库管理系统中一种用于加速数据检索的辅助数据结构。它类似于书籍的目录,通过减少查询时需要扫描的数据量来提高查询性能。MySQL 支持多种索引类型,每种类型适用于不同的场景。
二、索引类型详解
1. 主键索引(PRIMARY KEY)
- 定义:建立在主键列上的唯一索引,一张表只能有一个主键索引
- 特点:自动创建,不允许 NULL 值;InnoDB 中物理上决定了数据存储顺序
- 查询性能最高,因为主键索引就是聚簇索引,叶子节点直接存储完整数据行
CREATE TABLE users (
id INT PRIMARY KEY, -- 自动创建主键索引
name VARCHAR(50)
);
2. 普通索引(INDEX / KEY)
- 定义:最基本的索引类型,没有唯一性约束
- 特点:允许重复值和 NULL,一张表可以有多个普通索引
CREATE INDEX idx_name ON users(name);
-- 或
ALTER TABLE users ADD INDEX idx_name(name);
3. 唯一索引(UNIQUE)
- 定义:要求索引列的所有值唯一,但允许存在 NULL 值(NULL 可以重复)
- 特点:兼具唯一约束和索引功能,可用于防止数据重复
CREATE UNIQUE INDEX idx_email ON users(email);
4. 复合索引(联合索引)
- 定义:在一张表的多个列上建立的索引
- 特点:最左前缀原则——查询条件必须从索引最左列开始匹配才能生效
-- 创建复合索引 (a, b, c)
CREATE INDEX idx_a_b_c ON users(a, b, c);
-- ✅ 能用到的查询:
-- WHERE a=1
-- WHERE a=1 AND b=2
-- WHERE a=1 AND b=2 AND c=3
-- WHERE a=1 AND c=3 -- a 用到索引,c 无法用到索引顺序
-- ❌ 用不到的查询:
-- WHERE b=2
-- WHERE c=3
-- WHERE b=2 AND c=3
5. 聚簇索引(Clustered Index)
InnoDB 中,主键索引即为聚簇索引。叶子节点直接存储完整的数据行,表中数据的物理顺序与索引顺序一致。一张表只能有一个聚簇索引。
flowchart TD
subgraph 聚簇索引
A[根节点: 主键范围] --> B[内部节点: 键+指针]
B --> C1[叶子: 完整数据行]
B --> C2[叶子: 完整数据行]
B --> C3[叶子: 完整数据行]
end
subgraph 二级索引
D[根节点] --> E[内部节点]
E --> F1[叶子: 主键值]
E --> F2[叶子: 主键值]
end
F1 -.->|回表查询| C1
F2 -.->|回表查询| C2
聚簇索引 vs 二级索引的区别:
- 聚簇索引叶子节点存储完整数据行
- 二级索引(非聚簇索引)叶子节点存储的是主键值
- 通过二级索引查询时,需先找到主键值,再回表查询完整数据
三、索引数据结构对比
| 索引类型 | 默认存储引擎 | 数据结构 |
|---|---|---|
| BTREE 索引 | InnoDB/MyISAM | B+树 |
| HASH 索引 | Memory | 哈希表 |
| FULLTEXT 索引 | MyISAM/InnoDB | 倒排索引 |
四、索引设计原则
| 原则 | 说明 |
|---|---|
| 高选择性 | 选择区分度高的列作为索引(如身份证号 > 性别) |
| 小表不用索引 | 全表扫描比索引查找更快时不应建索引 |
| 避免过多索引 | 索引会降低写入性能 |
| 覆盖索引 | 尽量让索引覆盖查询所需的所有列,避免回表 |
| 自增主键 | 顺序插入减少页分裂 |
五、面试常见问题
Q1:MySQL 的 InnoDB 为什么用 B+树而不是 B树做索引?
B+树只有叶子节点存储数据,非叶子节点只存索引,因此 B+树更矮宽(扇出更大),高度更低,I/O 次数更少;且叶子节点通过链表连接,支持范围查询和排序,而 B树需要中序遍历。
Q2:主键索引和普通索引的查询过程有什么区别?
主键索引(聚簇索引)直接找到数据行;普通索引(二级索引)先找到主键值,再回表查询完整数据。如果普通索引本身就是覆盖索引,则无需回表。
Q3:为什么建议使用自增主键?
自增主键是顺序插入,B+树叶子节点按顺序增长,减少页分裂和碎片,提高写入性能。使用 UUID 作为主键会导致插入随机分布,频繁引起页分裂。
Q4:最左前缀原则具体是什么?
复合索引 (a, b, c) 生效的条件:查询条件必须从最左列开始且不跳过中间列。能命中:a / a,b / a,b,c / a,c(但只用到 a 列做索引过滤)。不能命中:b / c / b,c。
相关文章:InnoDB 索引原理 B+树 | 索引失效场景 | MySQL 锁机制
MySQL 索引与 SQL 优化底层解析——从 B+ 树到执行计划
MySQL 索引与 SQL 优化底层解析——从 B+ 树到执行计划
摘要
MySQL InnoDB 存储引擎以 B+ 树作为索引核心结构,深刻理解 B+ 树的设计原理是做好 SQL 优化的前提。本文从 B+ 树底层结构出发,详细解析聚簇索引与二级索引的区别、覆盖索引与索引下推优化机制,结合 EXPLAIN 执行计划分析给出慢查询优化实战案例。全文配套大量表结构、Mermaid 图解与实战 SQL,助你构建系统的索引优化方法论。
一、InnoDB B+ 树索引结构
1.1 从二叉搜索树到 B+ 树
索引数据结构经历了多个阶段的演进:
| 数据结构 | 查询复杂度 | 磁盘 I/O 次数 | 适用场景 |
|---|---|---|---|
| 二叉搜索树 | O(log n) | 树高次 I/O | 内存数据 |
| 红黑树 | O(log n) | 树高次 I/O | 内存数据 |
| B-Tree | O(log n) | 树高次 I/O | 磁盘数据 |
| B+ Tree | O(log n) | 树高次 I/O | 磁盘数据,范围查询优 |
| Hash | O(1) | 1次 | 等值查询 |
B+ 树相对于 B-Tree 的核心改进:
- 非叶子节点不存数据——只存索引 key 和指针,一个节点可以存储更多 key,降低树高
- 叶子节点形成有序链表——范围查询只需找到起始叶子节点然后顺序遍历
- 所有数据都在叶子节点——查询次数稳定(等于树高)
InnoDB 的 B+ 树高度通常为 2~4 层。以主键为 bigint(8 字节)为例,每个非叶子节点可存储约(16KB / (8B + 6B 指针) ≈ 1170 个 key,3 层 B+ 树可存储 1170 × 1170 × 16KB ≈ 2200 万 行记录。
graph TD
subgraph 非叶子节点[非叶子节点 - 仅存储索引键和指针]
N1[1170 个键值对]
end
subgraph 中间层
N2[每个子节点也是 1170 个键值对]
end
subgraph 叶子节点[叶子节点 - 存储完整行数据]
L1[Row1 Row2 ... RowN]
L2[RowN+1 ...]
L3[...]
end
N1 --> N2
N2 --> L1
N2 --> L2
N2 --> L3
L1 -.->|"双向链表"| L2 -.-> L3
1.2 数据页的结构
InnoDB 以 页(Page) 为最小 I/O 单位,默认 16KB:
+---------------------------+
| File Header (38B) | ← 页类型、前后指针、校验和
+---------------------------+
| Page Header (56B) | ← 页内元信息
+---------------------------+
| Infimum + Supremum (26B) | ← 虚拟记录,界定边界
+---------------------------+
| User Records | ← 实际的行记录(单向链表)
| ↓ |
| Row1 |
| Row2 |
| ... |
+---------------------------+
| Free Space |
+---------------------------+
| Page Directory | ← 槽(Slot)组织,二分查找定位
+---------------------------+
| File Trailer (8B) | ← 校验和,保证完整性
+---------------------------+
Page Directory 的作用: 页内记录用单向链表连接,但线性遍历太慢。Page Directory 将记录分组,每组最后一个记录的偏移量写入槽数组(slot array),查找时先对 slot 执行二分查找确定组,再在组内线性遍历。
二、聚簇索引与二级索引
2.1 聚簇索引(Clustered Index)
InnoDB 表必定有一个聚簇索引:
- 定义了主键 → 主键作为聚簇索引
- 未定义主键 → 选用第一个 NOT NULL UNIQUE 索引
- 都没有 → 隐式生成
ROW_ID(6 字节,全局自增)
聚簇索引的特征:
- 叶子节点存储整行数据——按主键顺序物理排列
- 数据物理排序——近似按主键顺序存储(逻辑上连续,物理上通过页链表连接)
- 主键查询只需一次索引查找——直接从叶子节点获取数据
主键设计最佳实践:建议使用自增 BIGINT 而非 UUID。 UUID 作为主键时,插入的新记录可能插入到已有页的中间位置,导致页分裂(Page Split)和磁盘碎片。
-- 推荐:自增主键
CREATE TABLE `user` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(50),
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
-- 不推荐:UUID 主键
CREATE TABLE `user_uuid` (
`id` CHAR(36) NOT NULL, -- 随机插入导致频繁页分裂
`name` VARCHAR(50),
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
2.2 二级索引(Secondary Index / 辅助索引)
二级索引的叶子节点存储的是 主键值 而非整行数据:
graph LR
subgraph 二级索引B+树
A[非叶子节点<br/>name 值 + 指针]
B[叶子节点<br/>name='张三' → id=1]
C[叶子节点<br/>name='李四' → id=2]
D[叶子节点<br/>name='王五' → id=3]
end
subgraph 聚簇索引B+树
E[叶子节点<br/>id=1 → 整行数据]
F[叶子节点<br/>id=2 → 整行数据]
G[叶子节点<br/>id=3 → 整行数据]
end
B --> E
C --> F
D --> G
二级索引查询过程(两次索引扫描):
1. 通过二级索引找到主键值
2. 回表(回聚簇索引)找到完整行数据
2.3 回表(Bookmark Lookup)
-- 表结构
CREATE TABLE `user` (
`id` BIGINT PRIMARY KEY,
`name` VARCHAR(50),
`age` INT,
`email` VARCHAR(100),
INDEX `idx_name` (`name`)
);
-- 查询
SELECT * FROM `user` WHERE `name` = '张三';
执行过程:
1. 在 idx_name 索引中找到 name='张三' 的记录,得到主键 id=1
2. 回聚簇索引,通过 id=1 找到完整行
回表的代价: 若二级索引命中了 100 行,就需要 100 次随机 I/O 回表。当回表次数过多时,MySQL 优化器可能选择全表扫描(因为顺序 I/O 比随机 I/O 快)。
三、覆盖索引与索引下推
3.1 覆盖索引(Covering Index)
当查询所需的所有列都包含在二级索引中时,无需回表,索引"覆盖"了查询需求。
-- idx_name_age 覆盖了以下查询
CREATE INDEX `idx_name_age` ON `user` (`name`, `age`);
-- 不需要回表,因为 name 和 age 都在索引中
EXPLAIN SELECT `name`, `age` FROM `user` WHERE `name` = '张三';
-- 需要回表,因为 email 不在索引中
EXPLAIN SELECT `name`, `age`, `email` FROM `user` WHERE `name` = '张三';
Extra 字段中看到 Using index 即表示使用了覆盖索引:
+----+-------------+-------+------+---------------+----------+-----------------+
| id | select_type | table | type | possible_keys | key | Extra |
+----+-------------+-------+------+---------------+----------+-----------------+
| 1 | SIMPLE | user | ref | idx_name_age | idx_name | Using index |
+----+-------------+-------+------+---------------+----------+-----------------+
3.2 索引下推(Index Condition Pushdown, ICP)
MySQL 5.6 引入的优化,将查询条件从服务层"下推"到存储引擎层的索引遍历过程:
-- 联合索引 (name, age)
-- 查询
SELECT * FROM `user` WHERE `name` LIKE '张%' AND `age` = 25;
无 ICP 时:
1. 存储引擎通过索引找到所有 name LIKE '张%' 的记录(如 1000 条)
2. 逐一回表获得完整的行
3. 服务层过滤 age = 25
4. 最终可能只剩 10 条
有 ICP 时:
1. 存储引擎遍历索引时,直接在索引记录上判断 age = 25
2. 只有满足条件的记录才回表(如 10 条)
Extra 中显示 Using index condition 即启用了索引下推:
+----+-------------+-------+-------+----------+------------------+
| id | select_type | table | type | key | Extra |
+----+-------------+-------+-------+----------+------------------+
| 1 | SIMPLE | user | range | idx_name | Using index cond |
+----+-------------+-------+-------+----------+------------------+
3.3 索引优化三项原则
| 原则 | 说明 | 反例 |
|---|---|---|
| 最左前缀 | 联合索引按定义顺序,从最左列开始匹配 | WHERE age=25 无法使用 idx(name,age) |
| 索引列少参与运算 | 对索引列使用函数/运算会导致索引失效 | WHERE LEFT(name,1)='张' |
| 覆盖索引优先 | 尽量用索引覆盖查询列 | SELECT * 经常需要回表 |
四、EXPLAIN 执行计划深度分析
4.1 EXPLAIN 输出详解
EXPLAIN SELECT `u`.`name`, `o`.`amount`
FROM `user` `u`
JOIN `order` `o` ON `u`.`id` = `o`.`user_id`
WHERE `u`.`age` > 18 AND `o`.`status` = 1\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: u
partitions: NULL
type: ALL
possible_keys: PRIMARY
key: NULL
key_len: NULL
ref: NULL
rows: 50000
filtered: 33.33
Extra: Using where
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: o
type: ref
possible_keys: idx_user_id,idx_status
key: idx_user_id
key_len: 8
ref: db.u.id
rows: 10
filtered: 10.00
Extra: Using where
各字段含义:
| 字段 | 值 | 含义 |
|---|---|---|
| type | ALL | 全表扫描,最差 |
| type | index | 全索引扫描 |
| type | range | 索引范围扫描 |
| type | ref | 非唯一索引等值匹配 |
| type | eq_ref | 唯一索引等值匹配(JOIN) |
| type | const/system | 主键/唯一键常量查询,最快 |
| possible_keys | idx_name | 可能用到的索引 |
| key | idx_name | 实际使用的索引 |
| rows | 50000 | 预估扫描行数 |
| Extra | Using where | 需要回表过滤 |
| Extra | Using index | 覆盖索引,无需回表 |
| Extra | Using index condition | 使用了索引下推 |
| Extra | Using filesort | 需要额外排序(需优化) |
| Extra | Using temporary | 需要临时表(需优化) |
4.2 type 字段详解
性能排序:const > eq_ref > ref > range > index > ALL
-- const:主键或唯一索引等值查询
EXPLAIN SELECT * FROM `user` WHERE `id` = 1;
-- type: const
-- ref:普通索引等值查询
EXPLAIN SELECT * FROM `user` WHERE `name` = '张三';
-- type: ref(name 有普通索引)
-- range:索引范围查询
EXPLAIN SELECT * FROM `user` WHERE `age` BETWEEN 18 AND 25;
-- type: range(age 有索引)
-- index:全索引扫描
EXPLAIN SELECT `name` FROM `user`;
-- type: index(扫描整个索引树,比全表略好)
-- ALL:全表扫描
EXPLAIN SELECT * FROM `user` WHERE `email` = 'test@test.com';
-- type: ALL(email 无索引)
4.3 联合索引选择性与列顺序
选择性的定义: COUNT(DISTINCT column) / COUNT(*),值越接近 1 表示区分度越高。
联合索引列顺序原则:高选择性在前。
-- 用户表有 10 万行
-- gender 选择性 ≈ 0.00005(只有男女)
-- email 选择性 ≈ 1(几乎唯一)
-- 不推荐
CREATE INDEX `idx_gender_email` ON `user` (`gender`, `email`);
-- gender 过滤后仍有 5 万行,索引效率低
-- 推荐
CREATE INDEX `idx_email_gender` ON `user` (`email`, `gender`);
-- email 直接定位到 1 行,gender 几乎不增加过滤效果
五、慢查询优化实战案例
5.1 案例一:分页查询深度偏移
-- 慢查询:offset 越大越慢
SELECT * FROM `order` ORDER BY `id` LIMIT 100000, 20;
-- 耗时:2.3s
问题分析: LIMIT 100000, 20 实际上需要 MySQL 读取 100020 行,丢弃前 100000 行。
优化方案——游标分页(延迟关联 + 覆盖索引):
-- 优化后:先通过覆盖索引找到主键,再回表
SELECT `o`.*
FROM `order` `o`
INNER JOIN (
SELECT `id`
FROM `order`
WHERE `id` > 100000
ORDER BY `id`
LIMIT 20
) `tmp` ON `o`.`id` = `tmp`.`id`;
-- 耗时:0.03s(提升 76 倍)
5.2 案例二:隐式类型转换导致索引失效
-- 慢查询
SELECT * FROM `user` WHERE `phone` = 13800138000;
-- 耗时:1.8s(phone 是 VARCHAR 类型但有索引)
问题分析: phone 是 VARCHAR,传入的是整型。MySQL 会将索引列转换为整型做比较,导致索引失效。
-- 优化后:显式字符串匹配
SELECT * FROM `user` WHERE `phone` = '13800138000';
-- 耗时:0.01s
索引列类型不匹配时的转换规则:
- WHERE VARCHAR_col = 123 → 索引列隐式转为 INT → 索引失效
- WHERE INT_col = '123' → 字符串 '123' 转为 INT → 索引可用
5.3 案例三:函数操作导致索引失效
-- 慢查询
SELECT * FROM `order` WHERE DATE(`create_time`) = '2025-01-01';
-- 耗时:3.1s(create_time 有索引)
-- 优化后:范围查询代替函数
SELECT * FROM `order`
WHERE `create_time` >= '2025-01-01 00:00:00'
AND `create_time` < '2025-01-02 00:00:00';
-- 耗时:0.02s
5.4 案例四:NOT IN vs NOT EXISTS
-- 慢查询:NOT IN (子查询可能包含 NULL,导致全表扫描)
SELECT * FROM `user` WHERE `id` NOT IN (
SELECT `user_id` FROM `order` WHERE `status` = 1
);
-- 耗时:8.5s
-- 优化后:NOT EXISTS + 关联子查询
SELECT `u`.* FROM `user` `u`
WHERE NOT EXISTS (
SELECT 1 FROM `order` `o`
WHERE `o`.`user_id` = `u`.`id` AND `o`.`status` = 1
);
-- 耗时:0.8s
5.5 案例五:多表 JOIN 查询优化
-- 慢查询
SELECT `u`.`name`, `o`.`amount`, `p`.`product_name`
FROM `user` `u`
JOIN `order` `o` ON `u`.`id` = `o`.`user_id`
JOIN `order_item` `oi` ON `o`.`id` = `oi`.`order_id`
JOIN `product` `p` ON `oi`.`product_id` = `p`.`id`
WHERE `u`.`age` > 18 AND `o`.`status` = 1;
-- 耗时:6.2s
优化方案:
-- 1. 执行顺序优化(小表驱动大表)
-- user 50万行,order 200万行,order_item 500万行
-- 2. 确保 JOIN 列有索引
ALTER TABLE `order` ADD INDEX `idx_user_id` (`user_id`);
ALTER TABLE `order` ADD INDEX `idx_status_user` (`status`, `user_id`);
ALTER TABLE `order_item` ADD INDEX `idx_order_id` (`order_id`);
ALTER TABLE `product` ADD INDEX `idx_id` (`id`);
-- 3. 先缩小数据范围
SELECT `u`.`name`, `o`.`amount`, `p`.`product_name`
FROM (
SELECT `id` FROM `user` WHERE `age` > 18
) `sub_u`
JOIN `order` `o` ON `sub_u`.`id` = `o`.`user_id` AND `o`.`status` = 1
JOIN `order_item` `oi` ON `o`.`id` = `oi`.`order_id`
JOIN `product` `p` ON `oi`.`product_id` = `p`.`id`;
-- 耗时:0.15s
六、索引维护与监控
6.1 索引碎片查看
-- 查看表空间碎片率
SELECT
TABLE_SCHEMA,
TABLE_NAME,
ROUND(DATA_LENGTH / 1024 / 1024, 2) AS data_mb,
ROUND(INDEX_LENGTH / 1024 / 1024, 2) AS index_mb,
ROUND(DATA_FREE / 1024 / 1024, 2) AS free_mb,
ROUND(DATA_FREE / DATA_LENGTH * 100, 2) AS frag_pct
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'your_db'
ORDER BY frag_pct DESC;
6.2 重建索引
-- 重建表(消除碎片)
ALTER TABLE `user` ENGINE = InnoDB;
-- 或
OPTIMIZE TABLE `user`;
6.3 监控慢查询
-- 开启慢查询日志
SET GLOBAL slow_query_log = 1;
SET GLOBAL long_query_time = 1; -- 单位:秒
SET GLOBAL log_queries_not_using_indexes = 1;
-- 查看慢查询位置
SHOW VARIABLES LIKE 'slow_query_log_file';
七、总结
InnoDB B+ 树索引是 MySQL 高性能的基石,理解其设计与工作机制直接决定了 SQL 优化的天花板:
- B+ 树设计——通过低树高(2~4 层)、叶子节点链表和页内二分查找,实现高效的范围查询与稳定的查询性能
- 索引类型——聚簇索引存储整行数据,二级索引存储主键值后回表,合理设计覆盖索引可以消除回表
- 索引下推——将过滤条件提前到索引遍历阶段,减少回表次数,是 5.6 版本最重要的优化之一
- EXPLAIN 分析——type 字段决定查询效率层级(const→eq_ref→ref→range→index→ALL),Extra 字段揭示覆盖索引和下推优化状态
- 实战优化——游标分页处理深度偏移、避免隐式类型转换和函数操作、NOT EXISTS 替代 NOT IN、小表驱动大表 JOIN
最后,索引不是越多越好——每个索引都会增加写入开销和存储空间。理想的优化思路是:分析慢查询 → EXPLAIN 诊断 → 针对性创建索引 → 验证效果 → 监控长期运行。
Elasticsearch 核心原理——从倒排索引到分布式搜索
Elasticsearch 核心原理——从倒排索引到分布式搜索
一、引言
Elasticsearch 是基于 Apache Lucene 构建的分布式搜索和分析引擎,也是目前最流行的全文检索引擎之一。它以 RESTful API、实时搜索、分布式架构和强大的聚合分析能力著称,被广泛应用于日志分析、全文搜索、安全监控和商业智能等领域。
本文将深入 Elasticsearch 的核心原理,从底层的倒排索引机制、分词与映射,到上层的集群架构、分片机制和写入查询流程,最后通过实战案例展示 Java 集成的最佳实践。
二、倒排索引原理
2.1 什么是倒排索引
传统关系型数据库使用正向索引(正排索引),即文档到词的映射;而倒排索引(Inverted Index)则是词到文档的映射。这种数据结构使得全文搜索极为高效。
graph LR
subgraph Forward[正排索引 Document→Term]
D1["Doc1: Elasticsearch is fast"] --> T1["Elasticsearch, is, fast"]
D2["Doc2: Fast search engine"] --> T2["Fast, search, engine"]
end
subgraph Inverted[倒排索引 Term→Document]
T1x["elasticsearch"] --> D1
T2x["fast"] --> D1
T2x --> D2
T3x["search"] --> D2
T4x["engine"] --> D2
T5x["is"] --> D1
end
2.2 Lucene 倒排索引结构
Lucene 的倒排索引由以下部分组成:
词典(Term Dictionary):存储所有不重复的词项,以有序的方式组织,支持二分查找。
倒排列表(Posting List):记录每个词项出现的文档 ID 列表和词频信息。
倒排表(Posting Entry):每个条目包含:
- 文档 ID(Doc ID)
- 词频(Term Frequency, TF)
- 位置信息(Position)
- 偏移量(Offset)
// Lucene 索引写入的简化表示
public class InvertedIndexExample {
static class TermEntry {
int docId;
int frequency;
List<Integer> positions;
int startOffset;
int endOffset;
}
static Map<String, List<TermEntry>> invertedIndex = new HashMap<>();
public static void addDocument(int docId, String content) {
String[] tokens = content.toLowerCase().split("\\W+");
Map<String, List<Integer>> tempMap = new HashMap<>();
for (int pos = 0; pos < tokens.length; pos++) {
tempMap.computeIfAbsent(tokens[pos], k -> new ArrayList<>()).add(pos);
}
for (Map.Entry<String, List<Integer>> entry : tempMap.entrySet()) {
TermEntry te = new TermEntry();
te.docId = docId;
te.frequency = entry.getValue().size();
te.positions = entry.getValue();
invertedIndex.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).add(te);
}
}
}
2.3 压缩与优化技术
Elasticsearch 使用多种技术优化倒排索引的存储和查询性能:
Frame Of Reference (FOR):将递增的 Doc ID 序列编码为差值(Delta),再使用按位压缩。假设 Doc ID 序列为 [1, 3, 5, 9, 15, 30],差值编码后为 [1, 2, 2, 4, 6, 15]。
Roaring Bitmaps:用于高效存储和计算大量文档集的交集与并集。当文档数较少时使用数组容器(Array Container),超过 4096 个时使用位图容器(Bitmap Container)。
三、分词器与映射
3.1 分词器(Analyzer)
Analyzer 由三部分组成:
- Character Filters:字符过滤(如去除 HTML 标签)
- Tokenizer:分词器(如标准分词、IK 分词)
- Token Filters:词项过滤器(如小写转换、停用词、同义词)
PUT /my_index
{
"settings": {
"analysis": {
"analyzer": {
"ik_smart_analyzer": {
"type": "custom",
"char_filter": ["html_strip"],
"tokenizer": "ik_smart",
"filter": ["lowercase", "asciifolding"]
}
},
"tokenizer": {
"comma_tokenizer": {
"type": "pattern",
"pattern": ","
}
}
}
}
}
常用分词器对比
| 分词器 | 类型 | 适用场景 | 示例:"Elasticsearch是一个搜索引擎" |
|---|---|---|---|
| Standard | 内置 | 英文文本 | elasticsearch, 是, 一, 个, 搜, 索, 引, 擎 |
| IK Smart | 插件 | 中文智能切分 | elasticsearch, 是, 一个, 搜索引擎 |
| IK Max Word | 插件 | 最细粒度切分 | elasticsearch, 是, 一个, 搜索, 引擎 |
| ICU | 插件 | 多语言文本 | 依赖 ICU 分词规则 |
3.2 映射(Mapping)
Mapping 定义索引中的字段类型和索引方式,类似于数据库中的表结构定义:
PUT /my_index/_mapping
{
"properties": {
"title": {
"type": "text",
"analyzer": "ik_smart",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"price": {
"type": "double"
},
"created_at": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
},
"tags": {
"type": "keyword"
},
"description": {
"type": "text",
"analyzer": "standard"
},
"location": {
"type": "geo_point"
}
}
}
关键字段类型:
- text:全文索引,被分词,支持模糊搜索
- keyword:精确值,不分词,用于排序和聚合
- date:日期类型,支持多种格式
- geo_point:地理坐标点
- nested:嵌套对象类型
四、集群架构与分片机制
4.1 集群拓扑
graph TD
subgraph Cluster[ES Cluster - 产品搜索集群]
M1[Node 1<br/>Master + Data]
M2[Node 2<br/>Master + Data]
M3[Node 3<br/>Master + Data]
C1[Node 4<br/>Coordinating Only]
C2[Node 5<br/>Coordinating + Ingest]
end
Client1[Client API] --> C1
Client2[Client API] --> C2
C1 --> M1
C1 --> M2
C2 --> M2
C2 --> M3
M1 --- M2 --- M3
节点类型:
| 节点角色 | 配置 | 职责 |
|---|---|---|
| Master-eligible | node.roles: [master] |
集群元数据管理、索引创建/删除 |
| Data | node.roles: [data] |
数据存储、CRUD、搜索聚合 |
| Coordinating | node.roles: [] |
请求路由、结果聚合 |
| Ingest | node.roles: [ingest] |
文档预处理(Pipeline) |
4.2 分片与副本机制
分片是 ES 数据分布的最小单元,每个索引可拆分为多个分片:
PUT /products
{
"settings": {
"number_of_shards": 5,
"number_of_replicas": 1
}
}
分片路由:
shard = hash(routing_value) % number_of_primary_shards
// 默认 routing_value = _id,可自定义
副本分片:
- 每个主分片可以有零个或多个副本
- 副本不写入相同节点(防止单点故障)
- 副本可处理读请求,提升查询吞吐量
4.3 水平扩展
当数据量增长时,可通过两个方式扩展:
1. 增加分片数(仅索引创建时设定)
2. 增加节点数(自动重平衡)
注意:基于 _id 的路由方式决定了主分片数在索引创建后不可更改。
五、写入与查询流程
5.1 写入流程
sequenceDiagram
participant Client
participant Coord as Coordinating Node
participant Primary as Primary Shard
participant Replica as Replica Shard
participant Translog as Translog
Client->>Coord: Index Request
Coord->>Coord: Route to shard
Coord->>Primary: Forward request
Primary->>Primary: Write to Lucene Index
Primary->>Primary: Write to Translog (fsync)
Primary->>Translog: Persist
Primary-->>Coord: Success
par Replicate
Primary->>Replica: Replication request
Replica->>Replica: Write + Translog
Replica-->>Primary: Success
end
Coord-->>Client: 201 Created
写入关键参数:
- index.refresh_interval:控制 refresh 频率(默认 1s),值越小搜索越实时但开销越大
- index.translog.durability:request(每次请求 fsync)或 async(定期 fsync)
- index.translog.sync_interval:异步 fsync 间隔(默认 5s)
5.2 查询流程
ES 的搜索分为两个阶段:
Query Phase(查询阶段)
1. 协调节点将请求广播到所有相关分片
2. 每个分片本地搜索,返回 Top N 结果的文档 ID 和排序分数
3. 协调节点合并排序,选出最终的 Top N
Fetch Phase(取回阶段)
1. 协调节点向对应分片请求完整文档
2. 分片返回文档的 _source 字段
3. 协调节点构建最终响应
// 搜索查询
GET /products/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "手机" } }
],
"filter": [
{ "range": { "price": { "gte": 1000, "lte": 5000 } } }
]
}
},
"sort": [
{ "_score": "desc" },
{ "created_at": "desc" }
],
"from": 0,
"size": 20,
"aggs": {
"brand_terms": {
"terms": { "field": "brand.keyword" }
},
"price_stats": {
"stats": { "field": "price" }
}
}
}
5.3 深度分页问题
from + size 超过 10000 时会引发性能问题:
// ❌ 不推荐:深度分页
GET /products/_search?from=10000&size=10
// ✅ 推荐:Search After
GET /products/_search
{
"size": 10,
"sort": [
{ "price": "asc" },
{ "_id": "asc" }
],
"search_after": [2999, "product_12345"]
}
// ✅ 推荐:Scroll(批量导出,不维护实时性)
POST /products/_scroll?scroll=5m
{
"size": 1000,
"query": { "match_all": {} }
}
六、Java 集成 Elasticsearch
6.1 客户端配置
co.elastic.clients
elasticsearch-java
8.12.0
com.fasterxml.jackson.core
jackson-databind
2.17.0
@Configuration
public class ElasticsearchConfig {
@Value("${elasticsearch.hosts:localhost:9200}")
private String[] hosts;
@Bean
public ElasticsearchClient esClient() {
RestClient restClient = RestClient.builder(
Arrays.stream(hosts)
.map(h -> {
String[] parts = h.split(":");
return new HttpHost(parts[0], Integer.parseInt(parts[1]), "http");
})
.toArray(HttpHost[]::new)
).build();
ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
return new ElasticsearchClient(transport);
}
}
6.2 CRUD 操作
@Service
public class ProductSearchService {
@Autowired
private ElasticsearchClient esClient;
private static final String INDEX = "products";
// 索引文档
public void indexProduct(Product product) throws IOException {
IndexResponse response = esClient.index(i -> i
.index(INDEX)
.id(product.getId().toString())
.document(product)
);
}
// 批量索引
public void bulkIndex(List<Product> products) throws IOException {
BulkRequest.Builder br = new BulkRequest.Builder();
for (Product p : products) {
br.operations(op -> op
.index(idx -> idx
.index(INDEX)
.id(p.getId().toString())
.document(p)
)
);
}
esClient.bulk(br.build());
}
// 搜索
public SearchResult<Product> search(String keyword, int page, int size) throws IOException {
SearchResponse<Product> response = esClient.search(s -> s
.index(INDEX)
.query(q -> q
.bool(b -> b
.must(m -> m.match(t -> t.field("title").query(keyword)))
.filter(f -> f.term(t -> t.field("status").value("active")))
)
)
.from((page - 1) * size)
.size(size)
.sort(so -> so.field(f -> f.field("price").order(SortOrder.Asc)))
, Product.class);
List<Product> products = response.hits().hits().stream()
.map(Hit::source)
.collect(Collectors.toList());
long total = response.hits().total().value();
return new SearchResult<>(products, total, page, size);
}
// 聚合分析
public Map<String, Long> aggregateByBrand() throws IOException {
SearchResponse<Void> response = esClient.search(s -> s
.index(INDEX)
.size(0)
.aggregations("by_brand", a -> a
.terms(t -> t.field("brand.keyword").size(20))
), Void.class);
return response.aggregations().get("by_brand").sterms().buckets().array()
.stream()
.collect(Collectors.toMap(
b -> b.key().stringValue(),
b -> b.docCount()
));
}
}
6.3 Spring Data Elasticsearch
@Document(indexName = "products")
public class Product {
@Id
private Long id;
@Field(type = FieldType.Text, analyzer = "ik_smart")
private String title;
@Field(type = FieldType.Text)
private String description;
@Field(type = FieldType.Double)
private BigDecimal price;
@Field(type = FieldType.Keyword)
private String brand;
@Field(type = FieldType.Date)
private LocalDateTime createdAt;
}
public interface ProductRepository extends ElasticsearchRepository<Product, Long> {
// 自动派生查询
List<Product> findByTitleContaining(String keyword);
List<Product> findByBrandAndPriceBetween(String brand, Double min, Double max);
// 自定义查询(需要实现)
@Query("{\"match\": {\"title\": {\"query\": \"?0\"}}}")
List<Product> searchByTitle(String keyword);
}
七、性能调优实践
7.1 索引优化
| 优化项 | 操作 | 效果 |
|---|---|---|
| 批量写入 | bulk API,每批 1000-5000 条 |
吞吐量提升 10-50 倍 |
| 增加 Refresh 间隔 | refresh_interval: 30s |
写入性能提升,牺牲实时性 |
| 禁用副本 | 写入前设置 number_of_replicas: 0 |
提升写入速度 30-50% |
| 使用 SSD | 数据盘使用 NVMe SSD | 索引和搜索性能显著提升 |
| 合理分片 | 单分片 10-50GB | 避免过多或过少分片 |
7.2 查询优化
// ✅ 禁用 Scoring(Filter Context)
SearchResponse> response = esClient.search(s -> s
.query(q -> q.bool(b -> b
.filter(f -> f.term(t -> t.field("status.keyword").value("published")))
.filter(f -> f.range(r -> r.field("price").gte(JsonData.of(100))))
))
.trackScores(false) // 禁用评分计算
);
// ✅ 指定字段返回
s.source(src -> src.filter(f -> f.includes("id", "title", "price")));
// ✅ 使用 Profile API 分析慢查询
POST /_search
{
"profile": true,
"query": { ... }
}
八、总结
Elasticsearch 的强大根植于其精妙的底层设计。倒排索引使其全文搜索效率远超传统数据库,分片与副本机制提供了水平扩展能力,而近实时的 Refresh 策略在写入性能与查询实时性之间取得了巧妙的平衡。
核心要点:
1. 倒排索引是搜索的基石,Lucene 的 FOR 和 Roaring Bitmaps 等优化技术使其极致高效
2. 分词器决定了搜索质量,中文场景推荐 IK 分词器
3. 集群架构中 Master、Data、Coordinating 节点的合理规划决定了系统的稳定性
4. 写入流程中 Translog 和 Refresh 机制反映了近实时搜索的权衡设计
5. 查询流程的 Query-Fetch 两阶段设计适用于分布式搜索场景
在实际工程中,性能问题的根源往往不是 ES 本身,而是不合理的索引设计、错误的字段映射和缺乏规划的集群拓扑。理解了底层原理,才能在系统设计时做出正确的取舍。


暂无评论内容