惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Tor Project blog
月光博客
月光博客
P
Proofpoint News Feed
大猫的无限游戏
大猫的无限游戏
N
News and Events Feed by Topic
The Cloudflare Blog
博客园_首页
NISL@THU
NISL@THU
量子位
A
Arctic Wolf
Y
Y Combinator Blog
Spread Privacy
Spread Privacy
Engineering at Meta
Engineering at Meta
F
Fortinet All Blogs
酷 壳 – CoolShell
酷 壳 – CoolShell
Cyberwarzone
Cyberwarzone
The GitHub Blog
The GitHub Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
F
Full Disclosure
C
Cisco Blogs
Security Latest
Security Latest
T
The Exploit Database - CXSecurity.com
T
Tenable Blog
PCI Perspectives
PCI Perspectives
S
Security Affairs
Forbes - Security
Forbes - Security
Hugging Face - Blog
Hugging Face - Blog
C
CERT Recently Published Vulnerability Notes
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
博客园 - 聂微东
H
Hacker News: Front Page
S
Securelist
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
D
Darknet – Hacking Tools, Hacker News & Cyber Security
罗磊的独立博客
S
SegmentFault 最新的问题
C
Cyber Attacks, Cyber Crime and Cyber Security
The Hacker News
The Hacker News
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
S
Security @ Cisco Blogs
The Last Watchdog
The Last Watchdog
小众软件
小众软件
Hacker News - Newest:
Hacker News - Newest: "LLM"
Google DeepMind News
Google DeepMind News
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
IT之家
IT之家
Last Week in AI
Last Week in AI
爱范儿
爱范儿
AWS News Blog
AWS News Blog
MongoDB | Blog
MongoDB | Blog

Jiajun的技术笔记

你好,2026! TiDB 源码阅读(六):TiDB Coprocessor 源码解析 性能优化的核心思想 TiDB 源码阅读(四):AST、逻辑计划、物理计划 CockroachDB Serverless Architecture podman 无故退出 Cursor Control-L (CTRL-L) Keyboard Shortcuts in Terminal Replace docker with podman Using xmonad with xfce4 A RC script for freebsd frpc 自己动手写一个k8s controller AI 会取代你的(编程)岗位吗? 自建DERP服务器提升Tailscale连接速度(使用Nginx转发) 自动升级Docker容器 再读《程序员修炼之道-从小工到专家》 让浏览器下载文件 再读《软件随想录》/《黑客与画家》/《软技能》 HTTP 压力测试中的 Coordinated Omission 2的补码 编程语言中的 context 是什么? flutter macOS 构建出错 Flatpak 使用小记 Golang CAS 操作是怎么实现的 PostgreSQL 当MQ来使用 Clash 结合 工作VPN 的网络设计 使用 PostgreSQL 搭建 JuiceFS PostgreSQL 配置优化和日志分析 有GitHub Copilot?那就可以搭建你的ChatGPT4服务 窗口函数的使用(以PG为例) 读《为什么学生不喜欢上学》 OpenAI Prompt Engineering 摘录和总结 读《打造真正的新产品》 VueJS 总结 Linux 自动挂载 alist 提供的webdav FreeBSD 使用 vm-bhyve 安装Debian虚拟机 FreeBSD 和 Linux 网卡聚合实现提速 GPT 帮我搞定了时区转换问题 长任务系统如何处理? macOS/Linux 编译 InputLeap 使用开源软KVM - synergy-core 解决 macOS 终端hostname一直变化问题 KVM 共享 Intel 集成显卡 PromQL 备忘 读《格鲁夫给经理人的第一课》 读《打开心智》 为什么要把复杂的联表操作拆成多个单表查询? 红包系统的设计 MySQL Index Condition Pushdown Optimization Go mod 简明教程 OpenWRT 使用 Android/iOS USB 网络 搭建旁路由 Golang gRPC 错误处理 编写可维护的单元测试代码 OAuth 2 详解(六):Authorization Code Flow with PKCE OAuth 2 详解(五):Device Authorization Flow OAuth 2 详解(三):Resource Owner Password Credentials Grant OAuth 2 详解(四):Client Credentials Flow OAuth 2 详解(二):Implict Grant Flow OAuth 2 详解(一):简介及 Authorization Code 模式 ElasticSearch 学习笔记 三种git流程以及发版模型 错误处理实践 权限模型(RBAC/ABAC) OIDC(OpenID Connect) 简介 任务队列简介 PostgreSQL 操作笔记 使用Drone CI构建CI/CD系统 Golang migrate 做数据库变更管理 使用PostgreSQL做搜索引擎 Nginx 源码阅读(三): 连接池、内存池 Nginx 源码阅读(二): 请求处理 Nginx 源码阅读(一): 启动流程 Go 泛型简明教程 KVM 显卡穿透给 Windows 使用 HTTP Router 处理 Telegram Bot 按钮回调 使用反射(reflect)对结构体赋值 GIN 是如何绑定参数的 你好 2022(2021 年终总结) 用Go导入大型CSV到PostgreSQL 使用 OpenWRT 搭建软路由 使用软KVM切换器 barrier 共享键鼠 SQL 防注入及原理 使用 gomock 测试 Go 代码 gevent不是黑魔法(二): gevent 实现 gevent不是黑魔法(一): greenlet 实现 用 entgo 替代 gorm 应用内使用crontab不是那么方便 单测时要不要 mock 数据库? Sentry 自建指南 用selenium完成自动化任务 用闲置的安卓手机做垃圾电话短信过滤 推荐三个时间管理工具 一次事故反思 当JS遇到uint64:JS整数溢出问题 SQLite3 存储以及ACID原理 Redis源码阅读:pub/sub实现 Redis源码阅读:zset实现 Redis源码阅读:bitmap 位图的运算 Redis源码阅读:set是怎么做交并集运算的? Redis源码阅读:list实现(ziplist, quicklist)
TiDB 源码阅读(五):索引
Jiajun Huang · 2025-10-05 · via Jiajun的技术笔记

索引的相关概念

数据库索引可以按照多种维度进行分类。

1. 按数据结构分类

这是最根本的分类方式,因为它直接决定了索引的效率和适用场景。

索引类型 数据结构 工作原理 优点 缺点 常见数据库支持
B+树索引 B+树 多路平衡搜索树。所有数据都存储在叶子节点,并形成有序链表。非叶子节点只存储键值。 范围查询效率高(叶子节点链表)、查询稳定(每次查询深度相同)、磁盘IO少(节点大小通常设置为页的整数倍)。 写入时需要维护树结构平衡,有一定开销。 MySQL(InnoDB)、PostgreSQL、Oracle、SQL Server 的默认/主索引类型。
哈希索引 哈希表 对索引键计算哈希码,直接映射到数据行的物理地址。 等值查询极快(O(1)时间复杂度)。 完全无法支持范围查询、不支持排序、哈希冲突问题。 MySQL的Memory引擎、PostgreSQL(可创建)、Redis。
全文索引 倒排索引 记录单词(或词组)到其所在文档/记录的映射。 专为全文搜索设计,支持关键词匹配、相关性排序、模糊查询等。 占用空间大,维护成本高。 MySQL(MyISAM/InnoDB)、PostgreSQL(GIN)、Elasticsearch(核心)。
R-Tree索引 R树 专门用于存储空间数据(如点、线、面)的平衡树。 高效支持空间查询,如“查找附近的所有点”、“查找这个矩形内的所有多边形”。 仅适用于空间数据类型。 MySQL、PostgreSQL(PostGIS)、Oracle。
Trie树索引 字典树 一种专门处理字符串的前缀树。 前缀查询效率极高,例如查询所有以“abc”开头的字符串。 内存消耗可能较大。 较少直接用于磁盘数据库,更多见于内存数据库或特定场景。

2. 按存储内容(或与数据的耦合程度)分类

这个分类主要针对B+树索引,描述了索引节点中存储了什么。

索引类型 存储内容 特点 例子
聚集索引 索引的叶子节点直接存储完整的数据行 1. 一个表只能有一个聚集索引,因为数据行本身只能按一种顺序物理存储。
2. 由于数据行就在叶子节点,通过聚集索引访问数据非常高效。
InnoDB的表必须有且只有一个聚集索引:
– 如果定义了主键,主键就是聚集索引。
– 没有主键,则第一个UNIQUE非空索引是聚集索引。
– 都没有,则隐式创建一个ROWID作为聚集索引。
非聚集索引 索引的叶子节点存储的是索引键值和指向数据行的“指针” 1. 一个表可以有多个非聚集索引
2. 查询数据需要一次“回表”操作:先通过非聚集索引找到主键,再通过主键(聚集索引)去查找完整数据行。
在InnoDB中,除了聚集索引,其他所有索引都是非聚集索引,其叶子节点存储的是主键值。在MyISAM中,主键索引和其他索引都是非聚集索引,叶子节点存储的是数据的物理地址

聚集索引的英文是 Clustered Index;在MySQL中,非聚集索引,也叫次级索引(Secondary Index)。

回表示例(InnoDB): 假设users表,id是主键(聚集索引),我们在name上建立了非聚集索引。 执行 SELECT * FROM users WHERE name = ‘Alice’; 1. 在name索引的B+树中找到‘Alice’,得到对应的主键id(比如 10)。 2. 再用这个id=10去主键(聚集索引)的B+树中查找,最终拿到完整的数据行。 第二步就是“回表”,如果查询只涉及索引列,则可以避免回表。

3. 按索引字段的数量和特性分类

索引类型 字段数量 描述 示例SQL(MySQL)
单列索引 1 只基于一个表字段创建的索引。 CREATE INDEX idx_name ON users(name);
组合索引 ≥2 基于多个表字段创建的索引,也叫复合索引。 CREATE INDEX idx_name_age ON users(name, age);
覆盖索引 - 这是一个特优情况,而非一种索引类型。如果一个索引包含了查询所需要的所有字段,则查询无需回表,直接从索引中获取数据,这个索引就称为覆盖索引。 CREATE INDEX idx_covering ON users(name, age);
SELECT name, age FROM users WHERE name = ‘Alice’; <– 这个查询就使用了覆盖索引。

覆盖索引,不能单独算作索引的一种类别,只是恰好查询所涉及到的所有字段都在索引中,无需回表即可完成查询。

组合索引的最左前缀原则: 对于组合索引 (A, B, C),它相当于建立了 (A), (A, B), (A, B, C) 三个索引。查询时必须从最左列开始匹配,否则索引失效。 - 有效:WHERE A=1, WHERE A=1 AND B=2, WHERE A=1 AND B=2 AND C=3 - 无效WHERE B=2, WHERE C=3, WHERE B=2 AND C=3

4. 按数据的唯一性分类

索引类型 约束 特点
唯一索引 索引键值必须唯一,不允许重复。 1. 一个表可以有多个唯一索引。
2. 唯一索引可以保证数据的唯一性,是数据库的一种约束。
3. 主键是一种特殊的唯一索引(不允许NULL)。
普通索引 无唯一性约束。 允许重复值和NULL值(取决于字段定义)。

索引不是越多越好,它是以空间换时间的策略。每个索引都会降低INSERT、UPDATE、DELETE的速度,因为数据变更时需要同时维护所有相关的索引结构。因此,创建索引需要根据实际的查询需求进行权衡和设计。

谓词下推

谓词下推的作用,就是在数据库查询处理过程中,尽早地执行数据过滤操作,以减少后续操作需要处理的数据量,从而极大提升查询性能。

通过谓词下推,可以提前过滤掉无用的数据,减少了数据的传输量,减少IO,节省CPU和内存的使用,最终提升查询速度。

1. 逻辑优化阶段的主入口

谓词下推作为一条逻辑优化规则,在 pkg/planner/core/optimizer.go 的第 89-117 行定义的 optRuleList 中:

var optRuleList = []base.LogicalOptRule{
	&GcSubstituter{},
	&rule.ColumnPruner{},
	&ResultReorder{},
	&rule.BuildKeySolver{},
	&DecorrelateSolver{},
	&SemiJoinRewriter{},
	&AggregationEliminator{},
	&SkewDistinctAggRewriter{},
	&ProjectionEliminator{},
	&MaxMinEliminator{},
	&rule.ConstantPropagationSolver{},
	&ConvertOuterToInnerJoin{},
	&PPDSolver{},                           // ← 谓词下推规则在这里
	&OuterJoinEliminator{},
	&rule.PartitionProcessor{},
	&rule.CollectPredicateColumnsPoint{},
	&AggregationPushDownSolver{},
	&DeriveTopNFromWindow{},
	&rule.PredicateSimplification{},
	&PushDownTopNOptimizer{},
	&rule.SyncWaitStatsLoadPoint{},
	&JoinReOrderSolver{},
	&rule.ColumnPruner{},
	&PushDownSequenceSolver{},
	&EliminateUnionAllDualItem{},
	&EmptySelectionEliminator{},
	&ResolveExpand{},
}

2. PPDSolver 规则实现

pkg/planner/core/rule_predicate_push_down.go 中:

func (*PPDSolver) Optimize(_ context.Context, lp base.LogicalPlan) (base.LogicalPlan, bool, error) {
	planChanged := false
	_, p, err := lp.PredicatePushDown(nil)
	return p, planChanged, err
}

这个规则从根节点开始递归调用每个逻辑算子的 PredicatePushDown 方法。

3. 各个逻辑算子的谓词下推实现

每个逻辑算子都实现了 PredicatePushDown 接口方法:

LogicalSelection(选择算子)

pkg/planner/core/operator/logicalop/logical_selection.go 中: - 将谓词条件继续向下推到子节点 - 不能下推的条件保留在当前 Selection 节点

DataSource(数据源)

pkg/planner/core/operator/logicalop/logical_datasource.go156-168 行:

// PredicatePushDown implements base.LogicalPlan.<1st> interface.
func (ds *DataSource) PredicatePushDown(predicates []expression.Expression) ([]expression.Expression, base.LogicalPlan, error) {
	predicates = ruleutil.ApplyPredicateSimplification(ds.SCtx(), predicates, true, nil)
	// Add tidb_shard() prefix to the condtion for shard index in some scenarios
	// TODO: remove it to the place building logical plan
	predicates = utilfuncp.AddPrefix4ShardIndexes(ds, ds.SCtx(), predicates)
	ds.AllConds = predicates
	dual := Conds2TableDual(ds, ds.AllConds)
	if dual != nil {
		return nil, dual, nil
	}
	ds.PushedDownConds, predicates = expression.PushDownExprs(util.GetPushDownCtx(ds.SCtx()), predicates, kv.UnSpecified)
	return predicates, ds, nil
}

在这里,谓词会被尽可能推到存储层(TiKV/TiFlash)。

LogicalJoin(连接算子)

pkg/planner/core/operator/logicalop/logical_join.go 中: - 根据 JOIN 类型(Inner/Left/Right/Outer)决定哪些谓词可以推到左右子树 - 处理 ON 条件和 WHERE 条件

4. 物理计划阶段的谓词下推

还有针对 TiFlash 的特殊优化,在 pkg/planner/core/operator/physicalop/tiflash_predicate_push_down.go 中: - 对于 TiFlash 扫描,有专门的谓词下推逻辑 - 考虑选择性、代价等因素决定是否下推

TiDB 中是如何使用索引的?

在逻辑计划构建阶段,getPossibleAccessPaths 函数会为每个表生成所有可能的访问路径:

func getPossibleAccessPaths(ctx base.PlanContext, tableHints *hint.PlanHints, indexHints []*ast.IndexHint, tbl table.Table, dbName, tblName ast.CIStr, check bool, hasFlagPartitionProcessor bool) ([]*util.AccessPath, error) {
	tblInfo := tbl.Meta()
	publicPaths := make([]*util.AccessPath, 0, len(tblInfo.Indices)+2)
	tp := kv.TiKV
	if tbl.Type().IsClusterTable() {
		tp = kv.TiDB
	}
	tablePath := &util.AccessPath{StoreType: tp}
	fillContentForTablePath(tablePath, tblInfo)
	publicPaths = append(publicPaths, tablePath)
    // ...
}

AccessPath 结构体定义了一条访问路径的所有信息:

// AccessPath indicates the way we access a table: by using single index, or by using multiple indexes,
// or just by using table scan.
type AccessPath struct {
	Index          *model.IndexInfo
	FullIdxCols    []*expression.Column
	FullIdxColLens []int
	IdxCols        []*expression.Column
	IdxColLens     []int
	// ConstCols indicates whether the column is constant under the given conditions for all index columns.
	ConstCols []bool
	Ranges    []*ranger.Range
	// CountAfterAccess is the row count after we apply range seek and before we use other filter to filter data.
	// For index merge path, CountAfterAccess is the row count after partial paths and before we apply table filters.
	CountAfterAccess float64
	// MinCountAfterAccess is a lower bound on CountAfterAccess, accounting for risks that could
	// lead to overestimation, such as assuming correlation with exponential backoff when columns are actually independent.
	// Case MinCountAfterAccess > 0 : we've encountered risky scenarios and have a potential lower row count estimation
	// Default MinCountAfterAccess = 0 : we have not identified risks that could lead to lower row count
	MinCountAfterAccess float64
	// MaxCountAfterAccess is an upper bound on the CountAfterAccess, accounting for risks that could
	// lead to underestimation, such as assuming independence between non-index columns.
	// Case MaxCountAfterAccess > 0 : we've encountered risky scenarios and have a potential greater row count estimation
	// Default MaxCountAfterAccess = 0 : we have not identified risks that could lead to greater row count
	MaxCountAfterAccess float64
	// CountAfterIndex is the row count after we apply filters on index and before we apply the table filters.
	CountAfterIndex float64
	AccessConds     []expression.Expression
	EqCondCount     int
	EqOrInCondCount int
	IndexFilters    []expression.Expression
	TableFilters    []expression.Expression

之后做的事情,就是要从多个访问路径中找出最好的那条,其中用到了一种算法叫做“天际线剪枝”,用于从多个可用的路径中过滤掉 明显不好用的索引,为之后的访问路径筛选减少候选者。

天际线剪枝(Skyline Pruning)

1. 为什么需要 Skyline Pruning?

在一个复杂的查询中(尤其是涉及多个谓词和排序的查询),数据库系统可能会找到许多个“可能有用”的索引。例如:

  • 一个在 (a) 上的索引
  • 一个在 (a, b) 上的索引
  • 一个在 (b, a) 上的索引
  • 一个在 (a, b, c) 上的索引

如果对每一个候选索引都进行详细的代价估算(这涉及到 I/O 成本、CPU 成本等计算,本身比较昂贵),优化器会花费很多时间。Skyline Pruning 的目标就是在进入代价估算阶段之前,先淘汰掉那些明显没有竞争力的索引。


2. Skyline 的概念来源

“Skyline” 这个词来源于多目标优化问题。想象一个城市的天际线,那些摩天大楼就是“Skyline points”,因为它们至少在某个方向上(比如高度、位置)没有被其他建筑完全超越。

在索引选择的上下文中,我们也是在多个“维度”上比较索引,例如:

  • 查询谓词的匹配度:索引能覆盖多少个 WHERE 子句中的列?
  • 排序属性:索引是否能避免排序操作(满足 ORDER BYGROUP BY)?
  • 覆盖查询:索引是否包含查询所需的所有列,从而无需回表?

一个索引的“天际线”就是那些在所有这些维度上,不被其他任何索引完全支配的索引集合。


3. Skyline Pruning 如何工作?

我们通过一个具体的例子来说明。

查询:

SELECT * FROM orders
WHERE customer_id = 123 AND order_date > ‘2023-01-01’
ORDER BY order_date DESC;

可用索引: 1. idx_customer (customer_id) 2. idx_date (order_date) 3. idx_customer_date (customer_id, order_date) 4. idx_date_customer (order_date, customer_id) 5. idx_customer_status (customer_id, status) – 一个不相关的索引

现在我们用 Skyline Pruning 来分析:

维度 1:等值匹配列(最左前缀匹配) - 查询有 customer_id = 123 这个等值谓词。 - idx_customer: 匹配 1 个等值列。 - idx_customer_date: 匹配 1 个等值列 (customer_id)。 - idx_date_customer: 匹配 0 个等值列(因为最左列 order_date 是范围查询)。 - idx_date: 匹配 0 个等值列。 - idx_customer_status: 匹配 1 个等值列。

维度 2:范围匹配列 - 查询有 order_date > ‘2023-01-01’ 这个范围谓词。 - idx_customer_date: 在 customer_id 等值匹配后,可以匹配范围列 order_date。 - idx_date_customer: 可以直接匹配范围列 order_date。 - idx_date: 可以直接匹配范围列 order_date。 - idx_customeridx_customer_status:无法匹配这个范围列。

维度 3:排序属性 - 查询需要按 order_date DESC 排序。 - idx_customer_date: 由于数据先按 customer_id 排序,再按 order_date 排序,对于一个固定的 customer_id,其内部的 order_date 是有序的。所以它可以避免排序。 - idx_date_customer: 数据首先按 order_date 排序,这正好满足 ORDER BY order_date,所以它也可以完美避免排序。 - 其他索引:都无法避免排序。

开始剪枝:

  1. 比较 idx_customeridx_customer_date:

    • 在等值匹配列上,两者打平(都是1)。
    • 在范围匹配列上,idx_customer_date 胜出(它能匹配,而 idx_customer 不能)。
    • 在排序属性上,idx_customer_date 胜出(它能避免排序,而 idx_customer 不能)。
    • 结论idx_customer所有维度上都被 idx_customer_date 支配。因此,idx_customer 被剪枝。
  2. 比较 idx_dateidx_date_customer:

    • 在等值匹配列上,idx_date_customer 胜出(它能利用 customer_id 进行过滤,虽然是第二列)。
    • 在范围匹配列上,两者打平(都能匹配)。
    • 在排序属性上,两者打平(都能完美避免排序)。
    • 结论idx_date 在至少一个维度上(等值匹配)比 idx_date_customer 差,且没有在任何维度上更好。因此,idx_dateidx_date_customer 支配,被剪枝。
  3. idx_customer_status:

    • 它在任何维度上都不如 idx_customer_date,所以也会被剪枝。

经过 Skyline Pruning 后,剩下的候选索引是: - idx_customer_date - idx_date_customer

现在,查询优化器只需要对这两个“精英”索引进行详细的代价估算,以选出最终的执行计划。这大大减少了优化时间。

基于成本的索引选择

天际线剪枝算法之后,可能仍然会有多个访问路径,此时,就会用基于成本(用统计数据)的选择来选出最佳访问路径,这也是 为什么TiDB在导入数据完成之后要做ANALYZE TABLE之后,才能获得最佳效果的原因。

func physicalOptimize(logic base.LogicalPlan) (plan base.PhysicalPlan, cost float64, err error) {
    // ...
    t, err := logic.FindBestTask(prop)
    // ...
}

不过此处过于专业,我也没全搞明白,就不展开了(问了很多AI,但是从来没搞过这块,大家有兴趣可以问问AI,我就不贴了)。

总结

本文中,我们总结了一下索引的类型,然后学习了谓词下推,通过谓词下推,可以提前把过滤条件下放到最底端,从而减少 产生的数据;接着我们学习了在有多条访问路径的时候,数据库是如何通过天际线剪枝,过滤掉明显不合格的路径,最终 通过基于成本的选择,产生访问路径。此外我们还有一点没有介绍到,就是索引合并(index merge)技术,通过索引合并, 可以在没有明显优势的单个索引,但是有多个索引且过滤条件为 AND 或者 OR 的情况下用上索引,提高访问效率,不过 本文没有详细介绍这点。