TiDB 自动 Analyze 的优先级队列设计解析:从随机扫描到加权调度

发布时间:2026/9/10 11:46:20
TiDB 自动 Analyze 的优先级队列设计解析:从随机扫描到加权调度 TiDB 自动 Analyze 的优先级队列设计解析从随机扫描到加权调度【免费下载链接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.项目地址: https://gitcode.com/GitHub_Trending/ti/tidb自动统计信息收集Auto Analyze是 TiDB 优化器生成高质量执行计划的基础设施。本设计文档《Priority Queue for Auto Analyze》提出以优先级队列 加权评分取代原先的随机扫描调度用来根治多表场景下 auto analyze 的饥饿、失败阻塞与长尾延迟三大顽疾。读完本文你将掌握该队列的权重公式与各因子含义、分区表与失败分析的特殊处理、系统变量tidb_enable_auto_analyze_priority_queue的真实语义以及它在仓库pkg/statistics/handle/autoanalyze/priorityqueue中的落地实现与设计差异。动机与背景旧自动分析的三类用户投诉Auto Analyze 是一个在后台自动为表收集统计信息的任务。当用户表数量很大时TiDB 必须持续在后台分析这些表让优化器始终基于最新统计信息生成最优计划。但在设计本文前旧实现积累了如下用户投诉自动分析重试机制有缺陷某一张表反复分析失败会卡住整个自动分析流程串行推进一张失败表即可阻塞全局。存在饥饿问题大表分析耗时长小表分析被不断延后。随机选择算法有偏由于每次用随机顺序挑选待分析表部分表可能长期得不到分析。为此需要引入优先级队列将随机选谁先分析变为按可量化指标加权排序后逐个分析。旧自动分析流程回顾设计文档梳理了当时的执行链路TiDB bootstrap 过程中完成 stats worker 的加载与更新派生出专用 worker 执行自动分析任务每个 stats lease 周期触发一次 auto-analyze 检查并且只在 TiDB owner 实例上运行检查当前是否位于允许执行自动分析的时间窗内对应实现见 autoanalyze.go 中的时间窗判断为避免同一张表失败导致流程卡死先将库与表的顺序随机化逐个尝试执行 auto-analyze SQL判定这张表是否值得分析若统计信息为 pseudo尚未真正加载无需分析若表太小可能不值得分析应把资源留给更大的表若表从未被分析过需要分析若表已达到autoAnalyzeRatio阈值需要分析用modify_count / count计算变更比若索引没有统计信息无论如何都要分析。问题在于这是一个随机选择 串行执行算法单节点上一个接一个同步执行遇到失败表就重试到卡死遇到大表就饿死小表。由于分析任务在单一节点上对每张表同步执行改造思路自然是对需要分析的表做加权排序。新方案的三条基本规则只有总行数超过 1000 行的表才会被纳入考虑仍沿用tidb_auto_analyze_ratio判定表是否需要分析默认值 0.5即表内行数被修改超过 50% 时触发表一旦进入自动分析队列就必须被赋予一个权重来决定执行顺序。关于队列刷新节奏文档特别指出自动分析队列的刷新时间不仅取决于设定频率还取决于上一个分析任务的执行耗时因此继续沿用旧的每 3 秒刷新一次队列方案是安全的唯一的代价是集群完全空闲时检查本身会带来可接受的 CPU 开销。未来可以演进为增量更新队列而非整队列重建。权重设计四项指标与加权公式权重因子表因子含义权重计算方法变更百分比Percentage of Change自上次分析以来行变更的比例对从未分析过的表固定视为 100%log10(1 Change Ratio)对数曲线用于压缩量级表大小Table Size行数 × 待分析列数小表优先级应高于大表先做对数变换log10(1 Table Size)再取惩罚项1 - log10(1 Table Size)分析间隔Analysis Interval距上次分析的时长间隔越大优先级越高log10(1 √Analysis Interval)对间隔取平方根可进一步压缩大值的增速特殊事件Special Event例如表新增了索引但尚未分析HasNewIndexWithoutStats: 2设计上要求三个连续变量在总分中保持固定比例表变更比Change Ratio占 60%表大小Size占 10%分析间隔Analysis Interval占 30%评分公式设计文档同时记录了两种候选公式加权和与加权积priority_score 0.6 × log10(1 ChangeRatio) 0.1 × (1 - log10(1 TableSize)) 0.3 × log10(1 √AnalysisInterval) special_event[event]以及另一种尝试priority_score change_percentage[size] * last_failed_time_weight[interval] * special_event[event]。文档同时说明上述比例来自当时样本数据的经验值需要更多测试校准并且这些比例应暴露为配置项以便不同场景下精细调控。源码落地实现采用加权和公式当前仓库在 calculator.go 中实现了该公式且代码注释直接引用了本设计文档。先看权重常量定义calculator.goconst ( // EventNone represents no special event. EventNone 0.0 // EventNewIndex represents a special event for newly added indexes. EventNewIndex 2.0 ) // TODO: make these configurable. const ( changeRatioWeight 0.6 sizeWeight 0.1 analysisInterval 0.3 )可以看到 60%/10%/30% 的比例已经固化注释中保留TODO: make these configurable与设计文档应暴露为配置的设想一致但尚未配置化。核心计算逻辑calculator.gofunc (pc *PriorityCalculator) CalculateWeight(job AnalysisJob) float64 { // We multiply the priority_score by 100 to increase its magnitude. This ensures that // when we apply the log10 function, the resulting value is more meaningful and reasonable. indicators : job.GetIndicators() changeRatio : 100 * indicators.ChangePercentage return changeRatioWeight*math.Log10(1changeRatio) sizeWeight*(1-math.Log10(1indicators.TableSize)) analysisInterval*math.Log10(1math.Sqrt(indicators.LastAnalysisDuration.Seconds())) pc.GetSpecialEvent(job) }实现相对设计的两处演进值得注意变更比放大 100 倍后再取对数ChangePercentage是一个 0~1或大于 1的比率实现先乘 100 放大量级再套log10(1·)避免因基数过小导致对数变换失真。特殊事件在实现中只保留了新增索引GetSpecialEvent只在job.HasNewlyAddedIndex()时为真时返回EventNewIndex 2.0否则为0.0。它作为加性权重叠加在加权和之上。入队时queue.go会对大表施加体积惩罚理论上可能算出负权重因此实现会在权重大于 0 时才入队并打日志防止负权重干扰堆排序weight : pq.calculator.CalculateWeight(job) if weight 0 { statslogutil.StatsSampleLogger().Warn(Table gets a negative weight, ...) } job.SetWeight(weight) return pq.syncFields.inner.addOrUpdate(job)三项指示器的计算方式每次权重计算依赖AnalysisJob提供的三项指示器job.gotype Indicators struct { ChangePercentage float64 // modifiedCount / last time analysis count TableSize float64 // table size in rows * len(columns) LastAnalysisDuration time.Duration // duration from the last analysis to now }指示器的真实来源在 analysis_job_factory.goCalculateChangePercentage表已分析则按ModifyCount / analyzeRowCount计算未分析返回unanalyzedTableDefaultChangePercentage 1即 100%对应文档中未分析表变更比固定为 100%CalculateTableSizeRealtimeCount × 列数与文档中行数 × 列数定义一致GetTableLastAnalyzeDuration用LastAnalyzeVersion写入统计的 TSO换算距上次分析时长对从未分析的表按30 分钟前估算unanalyzedTableDefaultLastUpdateDuration -30 * time.Minute避免间隔为零导致该项恒为 0。分区表静态裁剪与动态裁剪的不同策略分区表是优先级计算中最大的分叉点设计文档给出了明确规则静态裁剪static模式不需要合并全局统计信息直接把每个分区当作一张普通表独立计算权重、独立入队动态裁剪dynamic模式需要找出所有待分析分区计算平均变更百分比并把整张分区表作为一个整体条目放进队列。设计文档配套了伪代码汇总核心逻辑function calculateAvgChangeForPartitions(partitionStats, defs, autoAnalyzeRatio): totalChangePercent 0; count 0; partitionNames [] for each def in defs: tblStats partitionStats[def.ID] changePercent calculateChangePercentage(tblStats, autoAnalyzeRatio) if changePercent is 0: continue totalChangePercent changePercent append def.Name to partitionNames count 1 avgChange totalChangePercent / count return avgChange, partitionNames function calculateChangePercentage(tblStats, autoAnalyzeRatio): if tblStats.Pseudo or tblStats.RealtimeCount AutoAnalyzeMinCnt: return 0 if not TableAnalyzed(tblStats): return 1 tblCnt tblStats.RealtimeCount if histCnt tblStats.GetAnalyzeRowCount() 0: tblCnt histCnt res tblStats.ModifyCount / tblCnt if res autoAnalyzeRatio: return res return 0落地实现沿用了完全一致的思路并做了细化三种 Job 类型分别对应三种实体非分区表NonPartitionedTableAnalysisJob、静态分区StaticPartitionTableAnalysisJob、动态分区表DynamicPartitionedTableAnalysisJob对应文件分别为 non_partitioned_table_analysis_job.go、static_partitioned_table_analysis_job.go、dynamic_partitioned_table_analysis_job.go。静态裁剪在 queue.go 中逐分区创建独立 Job未达阈值或统计未加载的分区会被GetPartitionStats过滤掉。动态裁剪CreateDynamicPartitionedTableAnalysisJob调用CalculateIndicatorsForPartitions对满足阈值条件的每个分区累计变更百分比、体积与距上次分析时长最后对满足条件的分区取平均作为一个整体 Job 入队analysis_job_factory.go。与伪代码一致完全未达到阈值的分区不参与平均仅由达标分区得出均值。动态分区表的新增索引同样按分区聚合CheckNewlyAddedIndexesNeedAnalyzeForPartitionedTable返回索引 ID → 需要补分析的分区 ID 列表。失败分析保护避免同一张表反复失败阻塞队列只设计权重还不够。若某张表反复失败任其每次都被弹出并再次尝试队列仍会被它卡死。因此文档引入二次校验从优先级队列取出表之后再判断它此刻是否真的适合被分析判断依据是距上次失败分析的结束时间。判定规则若距上次失败分析的时间间隔 2 × 平均自动分析间隔才认为该表有效、值得分析。文档特别强调该检查只在从队列取出表后执行——因为从 TiKV 拉取这些历史信息开销昂贵逐表预检会浪费大量资源。设计文档伪代码function IsValidToAnalyze(j): if j.Weight is 0: return false lastFailedAnalysisDuration getLastFailedAnalysisDuration(j.DBName, j.TableName) if err: return false averageAnalysisDuration getAverageAnalysisDuration(j.DBName, j.TableName) if err: return false if lastFailedAnalysisDuration 2 * averageAnalysisDuration: return false return true落地的有效性与取数实现当前实现把这一判断放在了每个 Job 执行前的ValidateAndPrepare阶段以 non_partitioned_table_analysis_job.go 为例先校验 schema/table/partition 是否仍存在再调用包级函数isValidToAnalyzejob.go。它细分了三种场景比设计伪代码更健壮刚刚失败lastFailedAnalysisDuration justFailed即距离为 0 秒直接跳过因为 last analysis just failed只有失败记录、没有任何成功记录averageAnalysisDuration NoRecord退化为与常量defaultFailedAnalysisWaitTime 30 * time.Minute比较失败不足 30 分钟则跳过见 job.go成功与失败记录都存在执行设计中的主规则——失败间隔 2 × 平均分析时长则跳过避免短时间反复失败。取数 SQL 定义在 interval.go全部查询mysql.analyze_jobs系统表平均时长取该表最近 5 次成功分析statefinished AND fail_reason IS NULL的TIMESTAMPDIFF均值对分区场景则从所有分区中合并取最近 5 次成功记录上次失败时长取最近一条失败记录距今的秒数statefailed多分区时按分区各取最近失败、再取最小值采取保守策略无记录返回哨兵NoRecord -1失败持续 0 秒返回justFailed 0对于时钟偏移等异常数据负时长也有防御性兜底。设计 FAQ 中如何得到上次失败时间的答案与此呼应mysql.analyze_jobs携带fail_reason列最新失败分析可从中查出这正是所有失败相关判断的数据来源。数据流与运行架构设计文档给出的数据流非常简单默认每 3 秒从优先级队列取出一张表进行分析。队列维护是拉模型 周期刷新Worker 定时从堆顶取任务同时由后台 goroutine 周期性刷新队列内容保证队列里的 Job 始终反映最新的表变更状态。当前实现的多 goroutine 架构落地后的队列比每 3 秒 pop 一次复杂得多queue.go 文件头用一份 ASCII 架构图完整标注了四类 goroutineAuto-Analyze Worker主 ticker 循环注释指明在domain.autoAnalyzeWorker()每 stats lease约 3 秒根据是否 owner、RunAutoAnalyze是否开启调用Initialize()/Close()并通过Pop()取任务提交执行Queue WorkerInitialize()时启动的后台 goroutinerun()周期性执行三项维护任务Job Executor实际执行ANALYZE的 goroutine结束时回调 success/failure hookDDL Handlerschema 变更时调用HandleDDLEvent()。后台维护周期常量queue.go周期常量用途每 2 分钟dmlChangesFetchIntervalProcessDMLChanges()增量拉取发生 DML 变更的表并重建/更新其 Job每 10 分钟lastAnalysisDurationRefreshIntervalRefreshLastAnalysisDuration()重算所有在队 Job 的距上次分析时长并更新权重每 5 分钟mustRetryJobRequeueIntervalRequeueMustRetryJobs()把 must-retry 表重新建 Job 入队增量维护与初始化重建ProcessDMLChanges通过版本号增量处理利用GetNextCheckVersionWithOffset()取高水位版本只处理stats.Version lastFetchTimestamp的表避免每次全表扫描注释给出的性能参考扫描约 100 万张表的统计信息并处理变更不足 100ms。在rebuildWithoutLock即首次初始化fetchAllTablesAndBuildAnalysisJobs中也有一个关键设计先取高水位版本号再遍历建 Job因为遍历 100 万张表约需 1 分钟若先遍历再取版本会漏掉期间的 DML 变更反之先取版本最多导致部分变更被重复处理而 DML 处理是幂等的可接受。DML 到达后的 Job 维护分两条路径queue.go堆中尚无该表 →tryCreateJob非分区表/静态分区/动态分区分别建 Job堆中已有该表 →tryUpdateJob动态分区表因无法只更新单个分区而整体重建 Job普通表则只更新ChangePercentage与TableSize两个指示器后原地更新堆。此外队列用runningJobs与mustRetryJobs两张 map 防止重复分析与丢变更已在运行的表若再次触发变更如运行中新增索引会被标入mustRetryJobs待当前分析结束、5 分钟周期到达后重新取元信息建 Job——这对应设计文档同一张表被同时多次分析时以最近一次成功结果为准的语义同时规避了并发重复ANALYZE。堆结构与生命周期安全堆本体位于 heap.go它移植自 Kubernetes client-go 的队列堆并做了裁剪Less按GetWeight()降序排列权重越大越靠前支持addOrUpdate、pop、peek、delete等操作每个 Job 以TableID为键去重。关闭与重建方面queue.go 的注释给出两条重要安全约定Close()必须在锁外等待后台 goroutine 退出wg.Wait()否则持锁等待会让持有同一把锁的 Queue Worker 永远阻塞形成死锁owner 切换后运行中的 Job 允许跑完hook 以runningJobs nil判空优雅降级即使初始化期间发生 owner 移交队列也会在下一个约 3 秒的 ticker 周期内被Close()最坏只是浪费几秒资源可接受。关于内存队列采用全量驻留内存的设计注释给出实测参考约 100 万张表的队列内存占用在 300~500 MiB 量级可以接受且通常无需同时分析的表的数量远小于此queue.go。常见问题设计 FAQ集群中能同时运行多少个 auto-analyze 任务只有一个。自动分析的 worker 与任务只在 owner 节点上执行。同一张表同时被分析多次会怎样采用最近一次成功的分析结果。为什么不直接把大表队列和小表队列分开当前整个集群只有 owner 节点能在自身实例上提交任务即便分成两个队列仍会互相阻塞除非 owner 能同时提交大小表任务但那会给该节点带来额外压力。而且大表/小表本身没有清晰边界按行数加权更合理。如何取得上次失败自动分析的时间从mysql.analyze_jobs中查找最近一次失败记录它有fail_reason列。如何识别表是否有新增索引bootstrap 过程中索引与列的统计信息都会加载并缓存利用该缓存即可判断是否存在没有统计信息的新索引。实现上CheckIndexesNeedAnalyze会逐个索引检查GetIdx为空且ColAndIdxExistenceMap未分析过的 Public 索引analysis_job_factory.go。如何判断一张表从未被分析通过缓存若某张表没有任何索引/列的已加载统计信息说明它从未被分析。实现上对从未分析的表返回 100% 变更比强制其尽快入队。测试设计设计文档要求同时覆盖正确性与性能两类测试重点校验优先级计算的正确性、以及队列运行不拖累系统。功能测试场景从未被分析过的表已分析但索引缺少统计信息的表多张表同时发生大量变更验证队列能分配出合理优先级单张表大幅变更但分析失败验证能按上次失败间隔给到正确优先级以上场景的混合。当前仓库为此提供了成体系单测分布在 priorityqueue 目录下queue_test.go生命周期、DML 变更、重建、heap_test.go堆行为、calculator_test.go权重计算、interval_test.go失败/平均时长查询含负数等防御场景、三类 Job 各自的*_test.go以及queue_ddl_handler_test.goDDL 事件处理。特别的calculatoranalysis 子目录用 golden 文件testdata/calculated_priorities.golden.csv对大批表的权重计算结果做回归比对保证评分公式的行为可被精确追踪。兼容性测试与开关语义变化重要设计文档提出为给用户提供控制手段将引入一个新的系统变量用于启停优先级队列并设想默认关闭OFF、经充分验证后再转为默认开启。这一设想在后续开发中被反转当前仓库中的真实语义是系统变量为全局布尔变量tidb_enable_auto_analyze_priority_queue定义见 tidb_vars.go默认值已是trueDefTiDBEnableAutoAnalyzePriorityQueue true见 tidb_vars.go注册在 sysvar.goScope 为 Global、类型为 Bool其Validation校验会拒绝将该变量设为 OFF并返回错误tidb_enable_auto_analyze_priority_queuehas been deprecated and TiDB will always use priority queue to schedule auto analyze。也就是说以当前代码为准TiDB 已经固定使用优先级队列调度自动分析这个开关仅保留了兼容性外壳无法再关闭。这一实现比设计文档走得更远读者在阅读旧版设计时应以仓库现状为准。性能测试权重分计算应当只读取内存中的统计缓存与常量时间运算不应成为瓶颈设计文档计划通过充分压测确认优先级队列不引入性能问题。当前queue.go中对超过 150ms 的慢操作会输出慢日志slowLogThreshold 150 * time.Millisecond且各周期操作日志用SampleLoggerFactory限流每 15 分钟最多一条这些也是避免运维侧噪声的工程细节。影响与风险开启优先级队列后自动分析的调度从随机选择变为加权排序。从用户视角看整体体验更合理变更越多、体积越小、等待越久的表越先被分析失败表被自动降级冷却而非反复阻塞。设计上预留的风险点是随机性消失后极端情况下个别表可能长期得不到调度——因此权重公式中保留距上次分析时长30% 的权重让等待时间能持续抬升优先级形成自愈机制。业界方案调研与对比设计文档在选型时横向调研了三个数据库的做法可作为理解本方案取舍的参照。CockroachDBCRDB基本思路按以下条件触发统计信息刷新——没有任何统计信息时距上次刷新时间过长过长基于最近几次刷新耗时的滑动平均IMPORT/RESTORE之后任何 schema 变更之后以及每次INSERT/UPDATE/DELETE变更后按概率触发。CRDB 引入两个 cluster setting 作为表必须累计多少脏行才刷新的输入加大任一设置都会降低刷新频率设置默认值说明sql.stats.automatic_collection.fraction_stale_rows0.2触发刷新的每表脏行目标比例sql.stats.automatic_collection.min_stale_rows500触发刷新的每表脏行目标下限其中min_stale_rows主要影响小表刷新频率fraction_stale_rows对大表影响更大并支持对单表单独配置。实现上每个 server 启动一个 refresherrefresher 每 1 分钟尝试触发一次刷新用 map 累积各表 mutation 计数核心逻辑maybeRefreshStats依据平均全量刷新时长判断太久没刷新再用targetRows : rowCount*fraction minStaleRows计算目标脏行生成[0, targetRows)区间的伪随机数以概率触发命中后通过 CRDB 的 Job 框架执行CREATE STATISTICS ... AS OF SYSTEM TIME。若命中ConcurrentCreateStatsError则视是否必须刷新来决定清 0 计数还是下次强制触发。FAQ 要点任何 SQL 写路径都可调用NotifyMutation通知 refresher冲突判定来自数据库中的 Job 记录检查是否有更早的 pending/running/paused CreateStats 任务执行节点取决于调度框架——自动分析任务带 inline executor 在事务内执行 SQL调度上每节点各自尝试取可执行任务并受最大可运行任务数约束。MySQLInnoDB基本思路innodb_stats_auto_recalc默认开启控制表内超过 10% 行被修改时是否自动重算统计也可在建表/改表时用STATS_AUTO_RECALC子句单表配置。实现上用recalc_pool存放待处理的表写路径调用row_update_statistics_if_needed靠stat_modified_counter累计行变更当counter n_rows / 10时把表推入recalc_pool后台专用线程dict_stats_thread负责统计收集由dict_stats_event唤醒即使无信号也会周期性醒来处理时若遇到大量小热表会把它们放回池中留待下一轮再取。FAQ 要点服务端同样只有一个 stats 线程、每次只取一张表运行。SQL Server基本思路AUTO_UPDATE_STATISTICS开启时由查询优化器判断统计是否过期并在查询用到时更新。SQL Server 201613.x起在兼容级别 130 下采用随表基数递减的动态重编译阈值表类型表基数n重编译阈值修改次数临时表n 66临时表6 n 500500永久表n 500500临时或永久表n 500MIN(500 0.20 × n, √(1000 × n))例如 200 万行的表500 0.20 × 2,000,000 400,500与√(1000 × 2,000,000) ≈ 44,721取较小者即每约 44,721 次修改触发一次统计更新。对照可见CRDB 与 MySQL 主要回答何时触发一次刷新调度仍偏简单TiDB 的设计核心差异在于把触发判定与多个表之间谁先执行分开引入显式的多因子加权评分从而在同一 owner 串行执行约束下最大化整体统计时效。未解决问题设计文档保留了一个开放问题把时间间隔作为指标后是否会妨碍未来更新队列而非整体重建的演进因为每次任何表变化后都需要重算所有表的时间间隔以确定新优先级。文档给出的设想是容忍暂时性延迟例如累计 100 次更新后再整体重建一次队列。延伸阅读设计原文docs/design/2023-11-29-priority-queue-for-auto-analyze.md队列主体与生命周期pkg/statistics/handle/autoanalyze/priorityqueue/queue.go权重公式实现pkg/statistics/handle/autoanalyze/priorityqueue/calculator.goJob 接口与失败保护pkg/statistics/handle/autoanalyze/priorityqueue/job.go失败/平均时长取数 SQLpkg/statistics/handle/autoanalyze/priorityqueue/interval.go三类 Job 的工厂与指示器计算pkg/statistics/handle/autoanalyze/priorityqueue/analysis_job_factory.go堆按权重降序pkg/statistics/handle/autoanalyze/priorityqueue/heap.goDDL 事件联动pkg/statistics/handle/autoanalyze/priorityqueue/queue_ddl_handler.go开关变量注册与校验pkg/sessionctx/variable/sysvar.gotidb_enable_auto_analyze_priority_queue变量默认值见 pkg/sessionctx/vardef/tidb_vars.go【免费下载链接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.项目地址: https://gitcode.com/GitHub_Trending/ti/tidb创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考