MySQL 索引与SQL优化

📌 本文由 49 篇相关文章智能合并整理而成

Elasticsearch 核心原理——从倒排索引到分布式搜索

Elasticsearch 核心原理——从倒排索引到分布式搜索

一、引言

Elasticsearch 是基于 Apache Lucene 构建的分布式搜索和分析引擎,也是目前最流行的全文检索引擎之一。它以 RESTful API、实时搜索、分布式架构和强大的聚合分析能力著称,被广泛应用于日志分析、全文搜索、安全监控和商业智能等领域。

本文将深入 Elasticsearch 的核心原理,从底层的倒排索引机制、分词与映射,到上层的集群架构、分片机制和写入查询流程,最后通过实战案例展示 Java 集成的最佳实践。

二、倒排索引原理

2.1 什么是倒排索引

传统关系型数据库使用正向索引(正排索引),即文档到词的映射;而倒排索引(Inverted Index)则是词到文档的映射。这种数据结构使得全文搜索极为高效。

graph LR
    subgraph Forward[正排索引 DocumentTerm]
        D1["Doc1: Elasticsearch is fast"] --> T1["Elasticsearch, is, fast"]
        D2["Doc2: Fast search engine"] --> T2["Fast, search, engine"]
    end

    subgraph Inverted[倒排索引 TermDocument]
        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.durabilityrequest(每次请求 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[正排索引 DocumentTerm]
        D1["Doc1: Elasticsearch is fast"] --> T1["Elasticsearch, is, fast"]
        D2["Doc2: Fast search engine"] --> T2["Fast, search, engine"]
    end

    subgraph Inverted[倒排索引 TermDocument]
        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.durabilityrequest(每次请求 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[正排索引 DocumentTerm]
        D1["Doc1: Elasticsearch is fast"] --> T1["Elasticsearch, is, fast"]
        D2["Doc2: Fast search engine"] --> T2["Fast, search, engine"]
    end

    subgraph Inverted[倒排索引 TermDocument]
        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.durabilityrequest(每次请求 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;

无法避免广播。优化策略:
– 在每个分片的时间字段上建索引
– 或者改成”逐用户查询”——应用层按用户列表逐个查询后合并

索引设计的常见误区

  1. 分片键本身不需要索引:❌ 分片键索引用于分片内快速定位
  2. 每个字段都加索引:❌ 增加更新成本和内存开销
  3. 全局唯一索引在分片下可用:❌ 只在本分片内唯一
  4. 全文索引可以直接使用:❌ 分片后 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)收集慢查询指标

面试要点

  1. 慢查询日志是性能优化的起点——先找到慢的,再分析为什么慢
  2. 关键指标Query_time(耗时)、Rows_examined(扫描行数)、Rows_sent(返回行数)
  3. Rows_examined >> Rows_sent 通常是缺少索引或索引选择不当
  4. 分析工具:mysqldumpslow(自带)、pt-query-digest(Percona,更强大)
  5. 优化方向:加索引、减少扫描行数、加 LIMIT、优化 SQL 写法
  6. 不要在生产开 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)
);

问题:每个索引都有维护成本,不是免费的。

索引的维护代价

写入性能损失

每次 INSERTUPDATEDELETE 操作,所有索引都要同步更新:

插入一行数据:
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 列的作用

EXPLAINtype 列描述了 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 列的作用

EXPLAINExtra 列提供关于 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_ido 表上匹配
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 优于 ORcol 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 开销
  • 标识EXPLAINExtra 列显示 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/>1IO]
    A --> C[ idx_name<br/>1IO]
    A --> D[ idx_age<br/>1IO]
    A --> E[ idx_email<br/>1IO]
    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/>1170key] --> 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个根页存储1170key]
        H2_R[根页<br/>1170key] --> 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/>1170key] --> H3_M1[中间页<br/>1170key]
        H3_R --> H3_M2[中间页<br/>1170key]
        H3_R --> H3_M3[...]
        H3_R --> H3_M1170[中间页<br/>1170key]

        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 行

为什么主键建议用整型自增

  1. 整型小(4/8字节)→ 扇出高 → 树矮
  2. 自增 → 插入在末尾 → 减少页分裂
  3. 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+树的核心特点是:

  1. 有序性:所有数据按关键字排序存储
  2. 多路平衡:每个节点可以有多个子节点
  3. 叶子节点存储数据:非叶子节点只存索引值

索引的数据结构演变

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 INDEXIGNORE 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);

按分片维度的"本地方案"

每个分片的表内索引与单表索引设计原则相同,但需要额外考虑分片场景:
- 索引数量少的话可以考虑加"查全部分片"标记
- 为频繁的非分片键查询预置走索引表方案

总结

分库分表后的索引设计核心原则:

  1. 按分片键查询永远是最优的——设计分片键要覆盖主要查询
  2. 非分片键查询需要辅助手段:索引表、广播查询或 ES
  3. 全局唯一索引很难实现,尽量通过 ID 生成器"预防"重复
  4. 复合索引让分片键在最左列

索引本身的价值不会因为分库分表而消失,反而因为数据分布在多个分片上而变得更加重要——每条 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/>1IO]
    A --> C[ idx_name<br/>1IO]
    A --> D[ idx_age<br/>1IO]
    A --> E[ idx_email<br/>1IO]
    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/>1170key] --> 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个根页存储1170key]
        H2_R[根页<br/>1170key] --> 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/>1170key] --> H3_M1[中间页<br/>1170key]
        H3_R --> H3_M2[中间页<br/>1170key]
        H3_R --> H3_M3[...]
        H3_R --> H3_M1170[中间页<br/>1170key]

        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 行

为什么主键建议用整型自增

  1. 整型小(4/8字节)→ 扇出高 → 树矮
  2. 自增 → 插入在末尾 → 减少页分裂
  3. 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+树的核心特点是:

  1. 有序性:所有数据按关键字排序存储
  2. 多路平衡:每个节点可以有多个子节点
  3. 叶子节点存储数据:非叶子节点只存索引值

索引的数据结构演变

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 的核心改进:

  1. 非叶子节点不存数据——只存索引 key 和指针,一个节点可以存储更多 key,降低树高
  2. 叶子节点形成有序链表——范围查询只需找到起始叶子节点然后顺序遍历
  3. 所有数据都在叶子节点——查询次数稳定(等于树高)

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 字节,全局自增)

聚簇索引的特征:

  1. 叶子节点存储整行数据——按主键顺序物理排列
  2. 数据物理排序——近似按主键顺序存储(逻辑上连续,物理上通过页链表连接)
  3. 主键查询只需一次索引查找——直接从叶子节点获取数据

主键设计最佳实践:建议使用自增 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 优化的天花板:

  1. B+ 树设计——通过低树高(2~4 层)、叶子节点链表和页内二分查找,实现高效的范围查询与稳定的查询性能
  2. 索引类型——聚簇索引存储整行数据,二级索引存储主键值后回表,合理设计覆盖索引可以消除回表
  3. 索引下推——将过滤条件提前到索引遍历阶段,减少回表次数,是 5.6 版本最重要的优化之一
  4. EXPLAIN 分析——type 字段决定查询效率层级(const→eq_ref→ref→range→index→ALL),Extra 字段揭示覆盖索引和下推优化状态
  5. 实战优化——游标分页处理深度偏移、避免隐式类型转换和函数操作、NOT EXISTS 替代 NOT IN、小表驱动大表 JOIN

最后,索引不是越多越好——每个索引都会增加写入开销和存储空间。理想的优化思路是:分析慢查询 → EXPLAIN 诊断 → 针对性创建索引 → 验证效果 → 监控长期运行


Elasticsearch 核心原理——从倒排索引到分布式搜索

Elasticsearch 核心原理——从倒排索引到分布式搜索

一、引言

Elasticsearch 是基于 Apache Lucene 构建的分布式搜索和分析引擎,也是目前最流行的全文检索引擎之一。它以 RESTful API、实时搜索、分布式架构和强大的聚合分析能力著称,被广泛应用于日志分析、全文搜索、安全监控和商业智能等领域。

本文将深入 Elasticsearch 的核心原理,从底层的倒排索引机制、分词与映射,到上层的集群架构、分片机制和写入查询流程,最后通过实战案例展示 Java 集成的最佳实践。

二、倒排索引原理

2.1 什么是倒排索引

传统关系型数据库使用正向索引(正排索引),即文档到词的映射;而倒排索引(Inverted Index)则是词到文档的映射。这种数据结构使得全文搜索极为高效。

graph LR
    subgraph Forward[正排索引 DocumentTerm]
        D1["Doc1: Elasticsearch is fast"] --> T1["Elasticsearch, is, fast"]
        D2["Doc2: Fast search engine"] --> T2["Fast, search, engine"]
    end

    subgraph Inverted[倒排索引 TermDocument]
        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.durabilityrequest(每次请求 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 本身,而是不合理的索引设计、错误的字段映射和缺乏规划的集群拓扑。理解了底层原理,才能在系统设计时做出正确的取舍。

© 版权声明
THE END
喜欢就支持一下吧
点赞6 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容