TiDB预研-分页查询、连接查询、执行计划
目录
- 分页查询原理
- 连接查询原理
- 查询计划分析
https://docs.pingcap.com/zh/tidb/stable/dev-guide-join-tables/
https://cn.pingcap.com/blog/tidb-query-optimization-and-tuning-1/
https://github.com/pingcap/blog-cn/blob/master/how-to-use-tidb.md
分页查询
深分页问题
传统MySQL关系数据库依靠按照某个key排序,然后limit…offset实现分页查询,数据量大的时候,深分页的时候,效率就会很慢。
因为MySQL的limit…offset是取全部数据,然后再丢弃offset之前的数据,因为,limit语句会先扫描offset+n行,然后再丢弃掉前offset行,返回后n行数据。也就是说limit 100000,10,就会扫描100010行,而limit 0,10,只扫描10行。
针对这种问题,优化方案
- 游标分页
- 覆盖索引
- 业务层优化
1、游标分页(Cursor-based Pagination)
原理:基于唯一递增字段(如主键)作为游标,避免扫描跳过数据。
优化offset过大效率低的问题:当偏移量offset过大的时候,使用limit的效率就不是那么高了,可以进行优化。(1)如果id键无序,可以使用父查询将in替换成连接查询inner join(2)如果id键有序,可以使用id>= 、limit。参考之前:Nebula Graph学习篇3_多线程完成6000w+关系数据迁移_nebula limit-CSDN博客
-- 第一页
SELECT * FROM table WHERE id > 0 ORDER BY id LIMIT 10;
-- 后续页(假设上一页最后一条id=10)
SELECT * FROM table WHERE id > 10 ORDER BY id LIMIT 10;
对于多条件查询,可以采用标签记录法每次把当前页的最后一条记录的id返回给前端,前端下次查询的时候在带过来,参考:千万级数据深分页查询SQL性能优化实践 | 京东云技术团队
select id,biz_content,pin FROM follow_fans_1
where biz_content = #{bizContent} and id < #{lastId}
order by id desc
limit 10;# 最后一页数据不足10条的时候,可能导致全表扫描超时
# 因此最后一个需要一个条件 > minId,这个查询可以异步去查询防缓存,因为也有可能超时,或者大数据离线去计算。
优点:时间复杂度稳定为 O(N),适合海量数据分页
2、覆盖索引优化
原理:通过覆盖索引直接获取分页所需的主键,减少回表操作。
SELECT t.* FROM table t
JOIN (SELECT id FROM table ORDER BY id LIMIT 100000, 10) tmp
ON t.id = tmp.id;
3、业务层优化
限制最大分页深度:如仅允许访问前 1000 页,避免极端深分页场景。
预加载与缓存:对静态数据提前分页缓存,减少实时查询压力
分库分表非分片键分页查询
传统分库分表的索引都是在每个mysql实例里,跟着表走的。分库分表规则,一般都是根据表中的userid用户字段或组合性较高的字段来做切分库或者表的键,相同的userid将会落在相同的库或者表。
但是上述情况下,表中的索引字段假设为code,则code="aaa"的可能会因为不同的userid落在不同的库中,需要查询全量的库和表后,再重新聚合,这样就会增加CPU、内存查询的消耗、还有TCP连接握手的消耗。
针对这种问题,优化方案
- 使用二级索引表
- 构建反向索引
- 索引冗余字段
1、使用二级索引表
如针对code、userid、table_index、db_index在创建一个表,查询的时候先到这个表找出具体的userid,然后到具体的分库、分表取数据,可以一定程度避免全表扫描。
优点:可以快速定位具体数据位置,减少全表扫描,实现简单
缺点:需要维护额外的索引表,存在双写一致性问题,索引表可能成为性能瓶颈
2、构建反向索引
另外一种方案是使用ES等中间件构建反向索引,存储某个关键字出现的分库、分表
{"code": "aaa","locations": [{"db": "db0", "table": "table1"},{"db": "db1", "table": "table2"}]
}
优点:查询性能好,支持复杂查询,可扩展性强
缺点:系统复杂度增加,需要额外维护ES集群,存在数据同步延迟
3、分片时考虑多个字段
如将code 和 userid 组合作为分片键,
-- 分片规则:hash(user_id + code) % n
优点:实现简单,无需额外组件,性能好
缺点:不够灵活,可能导致数据分布不均,不适合所有场景
TiDB的分页查询
TiDB 的分页查询在分布式环境下有以下特点:
- 数据分片处理:数据分布在多个 TiKV 节点上,查询时需要从多个节点收集数据,在 TiDB 层汇总排序后再应用 LIMIT 和 OFFSET
- 执行计划优化:当有索引时,TiDB 优先使用索引扫描,对于 WHERE id > X LIMIT Y 模式,可以利用索引直接定位,对于简单的 LIMIT OFFSET,需要扫描所有符合条件的行
- 内部实现机制优化:使用 Coprocessor 框架将部分计算下推到 TiKV,在TiDB节点不用对所有的表数据处理,TiDB 节点负责最终的排序和限制操作,对于大偏移量的分页,TiDB 仍需处理大量中间结果
Coprocessor部分计算下推举个例子
-- 按工资排序
-- 其中dept_id = 101数据在多个Region以及TiKV节点
SELECT * FROM employees
WHERE dept_id = 101
ORDER BY salary DESC
LIMIT 10;1、 dept_id没有索引的情况
- 同一个 dept_id 的数据可能分散在多个 TiKV 节点
- 数据按主键范围进行分片存储
- 需要扫描所有 Region 查找 dept_id = 101 的数据2、dept_id没有索引的情况
- 建立 dept_id 索引后,相同 dept_id 的索引数据会相对集中
- 通过索引可以快速定位数据位置
- 减少需要扫描的 Region 数量-- 下推到TiKV
1. TiKV 节点执行:- 过滤 dept_id = 101- 本地排序- 返回局部有序结果2. TiDB 节点执行:- 合并多个有序结果集- 应用 LIMIT
注意
1、避免大偏移量:
使用 WHERE id > last_id 代替 OFFSET
大偏移量会导致大量数据扫描和丢弃2、索引优化:
确保 ORDER BY 子句中的列有索引
理想情况是使用聚簇索引或覆盖索引3、合理的页大小:
避免过大的 LIMIT 值,这会增加网络传输和内存压力
使用估算总数:
对于大表,使用 EXPLAIN ANALYZE 或近似计数而非 COUNT(*)
使用自增唯一键作为游标取代 limit offset 分页需要注意的是,由于 TiDB 里面自增列只保证自增且唯一,所以有可能自增列会存在空洞(例如:limit 10 offset 2 有可能 ID 返回的 3,4,5,6,7,30001,30002,30003,30004,30005),还有一点就是自增 ID 的顺序与 数据 Insert 的顺序 没有必然的关系。另外,更加建议使用的是自增唯一键,而非自增主键。自增主键有可能会引发写热点问题。
连接查询原理
查询过程
TiDB Server 主要是拆分子任务,将计算下推到存储TiKV层,join都是在TiDB Server进行的,有些过滤条件或聚合函数是可以下推到tikv,减少传输到TiDB Server的数据量
- SQL 解析与优化:TiDB Server 接收 SQL 请求后,会通过 SQL 解析器生成抽象语法树(AST),优化器基于统计信息和代价模型生成分布式执行计划。对于连接查询(如 JOIN),优化器会评估不同执行策略(如 Hash Join、Index Join)的代价,选择最优路径。对于复杂 JOIN,TiDB 可能将部分计算(如 Hash Join 的构建阶段)下推到 TiKV,提升执行效率。
- 分布式执行计划:TiDB 的优化器会将连接操作拆分为多个子任务,根据数据分布将计算下推到存储层(TiKV),避免全量数据传输。
- 若 JOIN 键与数据分片(Region)分布一致,可直接在 TiKV 节点本地执行部分计算
- 若数据分散在多个 TiKV 节点,TiDB 会协调节点间数据交换(如 Shuffle Merge Join),最终合并结果返回客户端
- 执行引擎与 Coprocessor:TiKV 的 Coprocessor 模块负责处理下推的计算任务(如过滤、聚合),减少网络传输。
Join相关算法
TiDB 支持下列三种常规的表连接算法,优化器会根据所连接表的数据量等因素来选择合适的 Join 算法去执行。你可以通过 EXPLAIN 语句来查看查询使用了何种算法进行 Join。
- Index Join:适合预计需要连接的行数较少(一般小于1万行),类似MySQL中的Join算法,先读取一个表t1的数据,然后根据t1中匹配到的每行数据,依次探查t2中的数据,对内存的消耗比较小,如果需要大量的探查,速度可能小于其他Join算法
- Hash Join:简单理解就是将一个表创建map,扫描另外一个表数据判断map中是否存在key,适合需要连接的行数较多,运行速度比Index Join要快,但是需要消耗更多的内存,超过设置的内存阈值可以使用磁盘,在 Hash Join 操作中,TiDB 首先读取 Build 端的数据并将其缓存在 Hash Table 中,然后再读取 Probe 端的数据,使用 Probe 端的数据根据连接key的条件来探查 Hash Table 以获得所需行,TiDB 中的 Hash Join 算子是多线程的,并且可以并发执行。
- Merge Join:这个算法通常会占用更少的内存,但执行时间会更久。当数据量太大,或系统内存不足时,建议尝试使用,Merge Join 是一种特殊的 Join 算法。当两个关联表要 Join 的字段需要按排好的顺序读取时,适用 Merge Join 算法。由于 Build 端和 Probe 端的数据都会读取,这种算法的 Join 操作是流式的,类似“拉链式合并”的高效版。Merge Join 占用的内存要远低于 Hash Join,但 Merge Join 不能并发执行。
举个Merge Join的场景例子
### Merge Join 示例假设我们有两张表:
- `Orders` (订单表,按 customer_id 排序)
- `Customers` (客户表,按 customer_id 排序)#### Orders 表
| order_id | customer_id | amount |
|----------|-------------|---------|
| 1 | 101 | 200 |
| 2 | 101 | 300 |
| 3 | 102 | 150 |
| 4 | 104 | 400 |#### Customers 表
| customer_id | name |
|-------------|----------|
| 101 | Alice |
| 102 | Bob |
| 104 | Charlie |
| 105 | David |### Merge Join 执行过程1. 两个表已经按 customer_id 排序
2. 同时读取两个表的第一行
3. 比较 customer_id:- 如果相等,生成连接结果- 如果不等,移动较小值的游标#### 执行结果
| order_id | customer_id | amount | name |
|----------|-------------|---------|---------|
| 1 | 101 | 200 | Alice |
| 2 | 101 | 300 | Alice |
| 3 | 102 | 150 | Bob |
| 4 | 104 | 400 | Charlie |### 优点
- 内存占用少
- 不需要构建哈希表
- 适合大数据集### 缺点
- 要求输入数据已排序
- 不能并行执行
如果发现 TiDB 的优化器没有按照最佳的 Join 算法去执行。你也可以通过 Optimizer Hints 强制 TiDB 使用更好的 Join 算法去执行。
EXPLAIN SELECT /*+ INL_JOIN(t1, t2) */ * FROM t1 INNER JOIN t2 ON t1.id = t2.t1_id;
EXPLAIN SELECT /*+ HASH_JOIN(t1, t2) */ * FROM t1 INNER JOIN t2 ON t1.id = t2.t1_id;
Hash Join以及Runtime Filter
TiDB 提供了 Runtime Filter 功能,针对 Hash Join 进行性能优化,大幅提升 Hash Join 的执行速度。具体优化使用方式见 Runtime Filter。
Runtime Filter在某些条件可以加快Hash Join的效率,可以在查询规划阶段构建完HashTable之后,将filter相关条件应用于另外一张表扫数据过程中提前过滤用不到的数据,减少扫描时间和网络IO开销。
Hash Join 通过将右表的数据构建为 Hash Table,并将左表的数据不断匹配 Hash Table 来完成 Join。
如果在匹配过程中,发现一部分 Join Key 值无法命中 Hash Table,则说明这部分数据不存在于右表,也不会出现在最终的 Join 结果中。因此,如果能够在扫描时提前过滤掉这部分 Join Key 的数据,将减少扫描时间和网络开销,从而大幅提升 Join 效率。
Runtime Filter 是在查询规划阶段生成的一种动态取值谓词。该谓词与 TiDB Selection 中的其他谓词具有相同作用,都应用于 Table Scan 操作上,用于筛选不满足谓词条件的行为。唯一的区别在于,Runtime Filter 中的参数取值来自于 Hash Join 构建过程中产生的结果
Join Reorder 算法
在多表多个join之间,而 Join 的执行效率和各个表参与 Join 的顺序有关系。
因此优化器需要实现一种决定 Join 顺序的算法。目前 TiDB 中存在两种 Join Reorder 算法,贪心算法和动态规划算法。
- Join Reorder 贪心算法:在所有参与 Join 的节点中,选择行数最小的表与其他各表分别做一次 Join 的结果估算,然后选择其中结果最小的一对进行 Join,再继续这个过程进入下一轮的选择和 Join,直到所有的节点都完成 Join。
- Join Reorder 动态规划算法:在所有参与 Join 的节点中,枚举所有可能的 Join 顺序,然后选择最优的 Join 顺序。
当优化器选择的 Join 顺序并不够好时,你可以使用 STRAIGHT_JOIN 语法让 TiDB 强制按照 FROM 子句中所使用的表的顺序做联合查询。
EXPLAIN SELECT *
FROM authors a STRAIGHT_JOIN book_authors ba STRAIGHT_JOIN books b
WHERE b.id = ba.book_id AND ba.author_id = a.id;
查询计划分析
TiDB的优化器主要有逻辑优化、物理优化,参考:
TiDB 查询优化及调优系列(一)TiDB 优化器简介 | PingCAP 平凯星辰
TiDB 的查询计划是由一系列的执行算子构成,这些算子是为返回查询结果而执行的特定步骤,例如表扫描算子,聚合算子,Join 算子等
目前 TiDB 的计算任务分为两种不同的 task计算:cop task 和 root task。
- cop task 是指TiDB下推到 TiKV 、TiFlash中的 Coprocessor 执行的计算任务
- root task 是指在 TiDB 中执行的计算任务。
参考:
TiDB 查询优化及调优系列(二)TiDB 查询计划简介 | PingCAP 平凯星辰
慢查询日志,参考
TiDB 查询优化及调优系列(三)慢查询诊断监控及排查 | TiDB Books