diff --git a/TOC.md b/TOC.md index 7c098a39ec6c..bd81c9179fa6 100644 --- a/TOC.md +++ b/TOC.md @@ -415,6 +415,7 @@ - [使用系统变量 `tidb_snapshot` 读取历史数据](/read-historical-data.md) - 最佳实践 - [TiDB 最佳实践](/best-practices/tidb-best-practices.md) + - [多列索引优化最佳实践](/best-practices/multi-column-index-best-practices.md) - [Java 应用开发最佳实践](/best-practices/java-app-best-practices.md) - [HAProxy 最佳实践](/best-practices/haproxy-best-practices.md) - [高并发写入场景最佳实践](/best-practices/high-concurrency-best-practices.md) diff --git a/best-practices/multi-column-index-best-practices.md b/best-practices/multi-column-index-best-practices.md new file mode 100644 index 000000000000..3025badd3b0b --- /dev/null +++ b/best-practices/multi-column-index-best-practices.md @@ -0,0 +1,263 @@ +--- +title: 多列索引优化最佳实践 +summary: 了解如何在 TiDB 中高效使用多列索引,并应用高级优化技巧。 +--- + +# 多列索引优化最佳实践 + +在当今数据驱动的世界中,高效处理大数据集上的复杂查询对于保持应用响应和性能至关重要。对于 TiDB 这样专为高规模、高需求环境设计的分布式 SQL 数据库来说,优化数据访问路径是实现高效查询的关键。 + +索引是提升查询性能的重要工具,可以避免全表扫描。TiDB 的查询优化器能够利用多列索引 (Multi-Column Indexes) 智能过滤数据,处理复杂的查询条件,这在传统数据库(如 MySQL)中往往难以实现。 + +本文将介绍多列索引的工作原理、重要性,以及 TiDB 如何将复杂的查询条件优化为高效的数据访问路径。通过这些优化,你可以获得更快的响应速度、最小化的表扫描和流畅的性能表现,即使在大规模场景下也不例外。 + +如果没有这些优化,在大型 TiDB 数据库中的查询性能会迅速下降。全表扫描和低效过滤会让毫秒级的查询变成分钟级,内存消耗过大还可能导致内存溢出 (Out of Memory, OOM) 错误,尤其是在资源受限的环境下。TiDB 的优化方式确保只访问相关数据,从而保持低延迟和高效的内存使用,即使面对最复杂的查询也能应对自如。 + +## 前提条件 + +- 多列索引功能在 TiDB v8.3.0 及以上版本可用。 +- 使用该功能前,需将[优化器 Fix Control **54337**](/optimizer-fix-controls.md#54337-从-v830-版本开始引入) 设置为 `ON`。 + +## 背景:多列索引 + +本文以一个租房信息表为例,每条记录包含唯一 ID、城市、卧室数、租金和可入住日期: + +```sql +CREATE TABLE listings ( + listing_id INT PRIMARY KEY AUTO_INCREMENT, + city VARCHAR(100) NOT NULL, + bedrooms INT NOT NULL, + price DECIMAL(10, 2) NOT NULL, + availability_date DATE NOT NULL +); +``` + +假设该表在全美国有 2000 万条房源。如果你想查找租金低于 2000 美元的房源,可以在 `price` 列上建索引。这样优化器只需扫描 `[-inf, 2000.00)` 范围的数据,假设 70% 房源高于 2000 美元,实际扫描量约为 1400 万行。执行计划如下: + +```sql +-- 查询 1:查找租金低于 2000 的房源 +EXPLAIN FORMAT = "brief" SELECT * FROM listings WHERE price < 2000; +``` + +``` ++-----------------------------+---------+----------------------------------------------+---------------------------+ +| id | task | access object | operator info | ++-----------------------------+---------+----------------------------------------------+---------------------------+ +| IndexLookUp | root | | | +| ├─IndexRangeScan(Build) | root | table: listings, index: price_idx(price) | range: [-inf, 2000.00) | +| └─TableRowIDScan(Probe) | root | table: listings | | ++-----------------------------+---------+----------------------------------------------+---------------------------+ +``` + +虽然这样能提升性能,但返回的结果仍然数量庞大。若你需要更精确的房源,可以增加过滤条件,如指定城市、卧室数和最高租金。例如,查找旧金山两居室且租金低于 2000 美元的房源,结果会大大缩小,可能只剩几十条。 + +为优化此类查询,可以为 `city`、`bedrooms` 和 `price` 建多列索引: + +```sql +CREATE INDEX idx_city_bedrooms_price ON listings (city, bedrooms, price); +``` + +SQL 中的多列索引按字典序排序。以 `(city, bedrooms, price)` 为例,数据先按 `city` 排序,再在每个 `city` 内按 `bedrooms` 排序,最后在每个 `(city, bedrooms)` 内按 `price` 排序。这样 TiDB 能高效利用每个条件: + +1. 先按 `city` 过滤; +2. 再按 `bedrooms` 过滤; +3. 最后按 `price` 过滤。 + +## 示例数据 + +下表展示了多列索引如何细化搜索结果: + +| 城市 | 卧室数 | 租金 | +| -------------- | ------ | ----- | +| San Diego | 1 | 1000 | +| San Diego | 1 | 1500 | +| San Diego | 2 | 1000 | +| San Diego | 2 | 2500 | +| San Diego | 3 | 1000 | +| San Diego | 3 | 2500 | +| San Francisco | 1 | 1000 | +| San Francisco | 1 | 1500 | +| San Francisco | 2 | 1000 | +| San Francisco | 2 | 1500 | +| San Francisco | 3 | 2500 | +| San Francisco | 3 | 3000 | + +## 优化查询与结果 + +利用多列索引,TiDB 能高效定位旧金山两居室且租金低于 2000 美元的房源: + +```sql +-- 查询 2:查找旧金山两居室且租金低于 2000 的房源 +EXPLAIN FORMAT = "brief" + SELECT * FROM listings + WHERE city = 'San Francisco' AND bedrooms = 2 AND price < 2000; +``` + +``` ++------------------------+------+---------------------------------------------------------------------------------------------+---------------------------------+ +| id | task | access object | operator info | ++------------------------+------+---------------------------------------------------------------------------------------------+---------------------------------+ +| IndexLookUp | root | | | +| ├─IndexRangeScan(Build)| root |table:listings,index:idx_city_bedrooms_price ["San Francisco" 2 -inf,(city, bedrooms, price)]|range:["San Francisco" 2 2000.00)| +| └─TableRowIDScan(Probe)| root |table:listings | | ++------------------------+------+---------------------------------------------------------------------------------------------+---------------------------------+ +``` + +该查询在示例数据中返回: + +| 城市 | 卧室数 | 租金 | +| -------------- | ------ | ----- | +| San Francisco | 2 | 1000 | +| San Francisco | 2 | 1500 | + +通过多列索引,TiDB 避免了不必要的行扫描,大幅提升查询性能。 + +## 索引范围推导 (Index Range Derivation) + +TiDB 优化器内置了强大的范围推导组件。它会根据查询条件和相关索引列,生成高效的索引范围,并传递给表访问组件,决定最优的数据访问方式。 + +对于每个表,表访问组件会评估所有可用索引,计算每个索引的范围和访问成本,选择成本最低的路径。这一过程结合了范围推导和成本评估,确保数据检索既高效又节省资源。 + +下图展示了 TiDB 如何通过范围推导和成本评估协同选择最优表访问路径: + +![表访问路径选择](/media/best-practices/multi-column-index-table-access-path-selection.png) + +多列过滤条件往往比上述示例更复杂,可能包含 **AND**、**OR** 或两者组合。TiDB 的范围推导子系统能高效处理这些情况,生成最具选择性的索引范围。 + +一般来说,**OR** 条件生成的范围会做 **UNION**,**AND** 条件生成的范围会做 **INTERSECT**,从而实现尽可能精确的数据过滤。 + +## 多列索引中的析取条件(`OR` 条件) + +当查询中包含 `OR` 条件(Disjunctive Predicates, 析取谓词)时,优化器会分别处理每个条件,为每部分生成范围。如果范围有重叠,则合并为一个连续范围;否则保留为多个独立范围,均可用于索引扫描。 + +### 示例 1:重叠范围 + +假设要查找纽约两居室,租金在以下两个重叠区间的房源: + +- 租金在 1000~2000 美元之间 +- 租金在 1500~2500 美元之间 + +优化器会将两个区间合并为 1000 ~ 2500。查询及执行计划如下: + +```sql +-- 查询 3:重叠租金区间 +EXPLAIN FORMAT = "brief" + SELECT * FROM listings + WHERE (city = 'New York' AND bedrooms = 2 AND price >= 1000 AND price < 2000) + OR (city = 'New York' AND bedrooms = 2 AND price >= 1500 AND price < 2500); +``` + +``` ++-------------------------+------+----------------------------------------------------------------------+--------------------------------------------------+ +| id | task | access object | operator info | ++-------------------------+------+----------------------------------------------------------------------+--------------------------------------------------+ +| IndexLookUp | root | | | +| ├─IndexRangeScan(Build) | root | table:listings,index:idx_city_bedrooms_price(city, bedrooms, price) | range:["New York" 2 1000.00,"New York" 2 2500.00)| +| └─TableRowIDScan(Probe) | root | table:listings | | ++-------------------------+------+----------------------------------------------------------------------+--------------------------------------------------+ +``` + +### 示例 2:不重叠范围 + +再比如查找旧金山或圣地亚哥的一居室,分别在不同租金区间: + +- 旧金山一居室,租金 1500 ~ 2500 +- 圣地亚哥一居室,租金 1000 ~ 1500 + +由于区间不重叠,执行计划中会保留两个独立范围: + +```sql +-- 查询 4:不同城市的不重叠区间 + +EXPLAIN FORMAT = "brief" + SELECT * FROM listings + WHERE + (city = 'San Francisco' AND bedrooms = 1 AND price >= 1500 AND price < 2500) + OR (city = 'San Diego' AND bedrooms = 1 AND price >= 1000 AND price < 1500); +``` + +``` ++-------------------------+------+--------------------------------------------------------------------+------------------------------------------------------------+ +| id | task | access object | operator info | ++-------------------------+------+--------------------------------------------------------------------+------------------------------------------------------------+ +| IndexLookUp | root | | | +| ├─IndexRangeScan(Build) | root | table:listings,index:idx_city_bedrooms_price(city, bedrooms, price)| range:["San Francisco" 1 1500.00,"San Francisco" 1 2500.00)| +| └─TableRowIDScan(Probe) | root | table:listings | ["San Diego" 1 1000.00,"San Diego" 1 1500.00) | ++-------------------------+------+--------------------------------------------------------------------+------------------------------------------------------------+ +``` + +通过合并或保留独立范围,优化器能高效利用索引处理 `OR` 条件,避免无谓扫描,提升性能。 + +## 多列索引中的合取条件(`AND` 条件) + +对于 **AND** 条件(合取条件),TiDB 优化器会为每个条件生成范围,并取其交集(`INTERSECT`),得到最精确的索引访问范围。如果某条件包含多个范围,TiDB 会组合这些范围,确保结果最优。 + +### 示例 1:表结构 + +假设有如下表 t1: + +```sql +CREATE TABLE t1 ( + a1 INT, + b1 INT, + c1 INT, + KEY iab (a1,b1) +); +``` + +有如下查询条件: + +```sql +(a1, b1) > (1, 10) AND (a1, b1) < (10, 20) +``` + +TiDB 优化器处理步骤如下: + +1. 拆解表达式 + + - `(a1, b1) > (1, 10)` 等价于 `(a1 > 1) OR (a1 = 1 AND b1 > 10)` + - `(a1, b1) < (10, 20)` 等价于 `(a1 < 10) OR (a1 = 10 AND b1 < 20)` + + 合并后为: + + ```sql + ((a1 > 1) OR (a1 = 1 AND b1 > 10)) AND ((a1 < 10) OR (a1 = 10 AND b1 < 20)) + ``` + +2. 推导并组合范围 + + - `(a1, b1) > (1, 10)` 推导出 `(1, +inf]` 和 `(1, 10, 1, +inf]` + - `(a1, b1) < (10, 20)` 推导出 `[-inf, 10)` 和 `[10, -inf, 10, 20)` + + 最终范围为 `(1, 10, 1, +inf] UNION (1, 10) UNION [10, -inf, 10, 20)` + +### 示例 2:查询计划 + +查询计划如下: + +```sql +-- 查询 5:多列合取条件 +EXPLAIN FORMAT = "brief" + SELECT * FROM t1 + WHERE (a1, b1) > (1, 10) AND (a1, b1) < (10, 20); +``` + +``` ++-------------------------+------+----------------------------+-------------------------------------------+ +| id | task | access object | operator info | ++-------------------------+------+----------------------------+-------------------------------------------+ +| IndexLookUp | root | | | +| ├─IndexRangeScan(Build) | root | table:t1,index:iab(a1, b1) | range:(1 10,1 +inf],(1,10)[10 -inf,10 20) | +| └─TableRowIDScan(Probe) | root | table:t1 | | ++-------------------------+------+----------------------------+-------------------------------------------+ +``` + +假设表有 5 亿行,通过优化后只需访问约 4000 行,仅占总数据的 0.0008%。查询延迟从两分钟降至几毫秒。 + +与 MySQL 需全表扫描不同,TiDB 优化器可高效处理复杂行表达式,充分利用推导范围。 + +## 总结 + +TiDB 优化器通过多列索引和高级范围推导,大幅降低复杂 SQL 查询的数据访问成本。无论是合取(`AND`)还是析取(`OR`)条件,TiDB 都能将行表达式转化为最优访问路径,缩短查询时间,提升性能。与 MySQL 不同,TiDB 支持多列索引上的并集与交集操作,能高效处理复杂过滤条件。在实际应用中,优化后查询可在几毫秒内完成,而未优化时可能需两分钟以上,极大降低了延迟。 + +更多 TiDB 与 MySQL 架构差异及其对可扩展性、可靠性和 HTAP 工作负载的影响,详见 [MySQL vs. TiDB: A Guide to Open Source Database Selection](https://www.pingcap.com/ebook-whitepaper/tidb-vs-mysql-product-comparison-guide/)。 diff --git a/media/best-practices/multi-column-index-table-access-path-selection.png b/media/best-practices/multi-column-index-table-access-path-selection.png new file mode 100644 index 000000000000..141ec2c21d87 Binary files /dev/null and b/media/best-practices/multi-column-index-table-access-path-selection.png differ