Bug Report
Please answer these questions before submitting your issue. Thanks!
1. Minimal reproduce step (Required)
set global tidb_enable_prepared_plan_cache = 1;
set global tidb_plan_cache_invalidation_on_fresh_stats = 1;
set global tidb_stats_load_pseudo_timeout = 1;
set session tidb_stats_load_sync_wait = 1;
drop table if exists t;
create table t(a int primary key, b int, key idx_b(b));
insert into t values
(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9),(10,10);
analyze table t all columns;
prepare st from 'select * from t where b > ?';
set @p = 3;
execute st using @p;
execute st using @p;
select @@last_plan_from_cache;
-- sync-load timeout already enqueues background processing; wait until it finishes
-- poll and ensure needed stats are no longer allEvicted/unInitialized
show stats_histograms where db_name='test' and table_name='t';
execute st using @p;
select @@last_plan_from_cache;
Observed in our case: after async load success, @@last_plan_from_cache is still 1 (the plan generated under sync-load timeout fallback is reused).
2. What did you expect to see? (Required)
If a plan was generated when sync load timed out and optimizer fell back to pseudo/partial stats,
then once related stats become fully loaded, the cached plan should be invalidated (or bypassed once)
so optimizer can re-plan with loaded stats.
3. What did you see instead (Required)
The cached plan keeps being reused (@@last_plan_from_cache = 1) after stats become fully loaded in memory,
until an operation that bumps stats version (e.g. ANALYZE TABLE) or manual cache flush happens.
4. What is your TiDB version? (Required)
Built from current master branch for code verification:
(Please replace with SELECT tidb_version() output if needed in production reproductions.)
Code snippets (suspected root cause)
- Timeout fallback keeps SQL going and can still allow cacheable plan generation:
// pkg/planner/core/rule/rule_collect_plan_stats.go
if err != nil {
stmtCtx.IsSyncStatsFailed = true
if vardef.StatsLoadPseudoTimeout.Load() {
stmtCtx.AppendWarning(err)
return nil
}
}
- Plan cache invalidation on fresh stats is keyed by stats version hash only:
// pkg/planner/core/plan_cache_utils.go
if sctx.GetSessionVars().PlanCacheInvalidationOnFreshStats {
var statsVerHash uint64
for _, t := range stmt.tables {
statsVerHash += getLatestVersionFromStatsTable(sctx, t.Meta(), t.Meta().ID)
}
hash = codec.EncodeUint(hash, statsVerHash)
}
getLatestVersionFromStatsTable uses LastUpdateVersion from columns/indices:
// pkg/planner/core/logical_plan_builder.go
statsTbl.ForEachColumnImmutable(func(_ int64, col *statistics.Column) bool {
version = max(version, col.LastUpdateVersion)
return false
})
statsTbl.ForEachIndexImmutable(func(_ int64, idx *statistics.Index) bool {
version = max(version, idx.LastUpdateVersion)
return false
})
- Sync-load mainly flips load status (
allEvicted/fullLoaded) but does not bump LastUpdateVersion:
// pkg/statistics/handle/syncload/stats_syncload.go
if fullLoad {
colHist.StatsLoadedStatus = statistics.NewStatsFullLoadStatus()
} else {
colHist.StatsLoadedStatus = statistics.NewStatsAllEvictedStatus()
}
Impact
- A fallback plan generated under timeout can remain cached even after stats are fully loaded.
- Self-healing is delayed unless stats version changes.
- Can cause long-tail latency regressions for hot prepared statements.
Workarounds
- Increase
tidb_stats_load_sync_wait to reduce timeout fallback probability.
- Trigger
ANALYZE TABLE to bump stats version.
- Use
/*+ IGNORE_PLAN_CACHE() */ on affected SQL.
- Flush plan cache manually if needed.
Bug Report
Please answer these questions before submitting your issue. Thanks!
1. Minimal reproduce step (Required)
Observed in our case: after async load success,
@@last_plan_from_cacheis still1(the plan generated under sync-load timeout fallback is reused).2. What did you expect to see? (Required)
If a plan was generated when
sync loadtimed out and optimizer fell back to pseudo/partial stats,then once related stats become fully loaded, the cached plan should be invalidated (or bypassed once)
so optimizer can re-plan with loaded stats.
3. What did you see instead (Required)
The cached plan keeps being reused (
@@last_plan_from_cache = 1) after stats become fully loaded in memory,until an operation that bumps stats version (e.g.
ANALYZE TABLE) or manual cache flush happens.4. What is your TiDB version? (Required)
Built from current
masterbranch for code verification:d2b270aa57(Please replace with
SELECT tidb_version()output if needed in production reproductions.)Code snippets (suspected root cause)
getLatestVersionFromStatsTableusesLastUpdateVersionfrom columns/indices:allEvicted/fullLoaded) but does not bumpLastUpdateVersion:Impact
Workarounds
tidb_stats_load_sync_waitto reduce timeout fallback probability.ANALYZE TABLEto bump stats version./*+ IGNORE_PLAN_CACHE() */on affected SQL.