-
Notifications
You must be signed in to change notification settings - Fork 6.2k
planner: add trivial plan fast path for simple full-table scans #67376
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 3 commits
7cf9d74
d513a54
dd47425
cfe8b02
e4648f4
7c02742
069ba47
71da7a0
b023304
f175c68
dc8a3ca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| // Copyright 2026 PingCAP, Inc. | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package pointget | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/pingcap/tidb/pkg/testkit" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestTrivialPlan(t *testing.T) { | ||
| store := testkit.CreateMockStore(t) | ||
| tk := testkit.NewTestKit(t, store) | ||
| tk.MustExec("use test") | ||
|
|
||
| // Table with no secondary indexes — eligible for trivial plan. | ||
| tk.MustExec("drop table if exists t_simple") | ||
| tk.MustExec("create table t_simple(a int primary key, b int, c varchar(32))") | ||
| tk.MustExec("insert into t_simple values(1, 10, 'hello'), (2, 20, 'world'), (3, 30, 'foo')") | ||
|
|
||
| // Basic full table scan: SELECT * | ||
| rows := tk.MustQuery("select * from t_simple").Sort().Rows() | ||
| require.Len(t, rows, 3) | ||
| require.Equal(t, "1", rows[0][0]) | ||
| require.Equal(t, "hello", rows[0][2]) | ||
|
|
||
| // SELECT with specific columns. | ||
| rows = tk.MustQuery("select a, c from t_simple").Sort().Rows() | ||
| require.Len(t, rows, 3) | ||
| require.Equal(t, "foo", rows[2][1]) | ||
|
|
||
| // SELECT with column alias. | ||
| rows = tk.MustQuery("select a as id, b as val from t_simple").Sort().Rows() | ||
| require.Len(t, rows, 3) | ||
|
|
||
| // Verify EXPLAIN shows a table scan plan. | ||
| tk.MustQuery("explain format = 'brief' select * from t_simple").Check(testkit.Rows( | ||
| "TableReader 10000.00 root data:TableFullScan", | ||
| "└─TableFullScan 10000.00 cop[tikv] table:t_simple keep order:false, stats:pseudo", | ||
| )) | ||
| } | ||
|
|
||
| func TestTrivialPlanFallback(t *testing.T) { | ||
| store := testkit.CreateMockStore(t) | ||
| tk := testkit.NewTestKit(t, store) | ||
| tk.MustExec("use test") | ||
| tk.MustExec("drop table if exists t_idx, t_part, t_gen") | ||
|
|
||
| // Table with a secondary index — should NOT use trivial plan (optimizer | ||
| // needs to decide between table scan and index scan). | ||
| tk.MustExec("create table t_idx(a int primary key, b int, index idx_b(b))") | ||
| tk.MustExec("insert into t_idx values(1, 10), (2, 20)") | ||
| rows := tk.MustQuery("select * from t_idx").Sort().Rows() | ||
| require.Len(t, rows, 2) | ||
|
|
||
| // Partitioned table — should NOT use trivial plan. | ||
| tk.MustExec("create table t_part(a int primary key, b int) partition by hash(a) partitions 4") | ||
| tk.MustExec("insert into t_part values(1, 10), (2, 20)") | ||
| rows = tk.MustQuery("select * from t_part").Sort().Rows() | ||
| require.Len(t, rows, 2) | ||
|
|
||
| // Table with virtual generated column — should NOT use trivial plan. | ||
| tk.MustExec("create table t_gen(a int primary key, b int, c int as (a + b))") | ||
| tk.MustExec("insert into t_gen(a, b) values(1, 10), (2, 20)") | ||
| rows = tk.MustQuery("select * from t_gen").Sort().Rows() | ||
| require.Len(t, rows, 2) | ||
|
|
||
| // Query with WHERE clause on a trivial table — should NOT use trivial plan. | ||
| tk.MustExec("drop table if exists t_no_idx") | ||
| tk.MustExec("create table t_no_idx(a int primary key, b int)") | ||
| tk.MustExec("insert into t_no_idx values(1, 10), (2, 20)") | ||
| rows = tk.MustQuery("select * from t_no_idx where b > 5").Rows() | ||
| require.Len(t, rows, 2) | ||
|
|
||
| // Query with ORDER BY — should NOT use trivial plan. | ||
| rows = tk.MustQuery("select * from t_no_idx order by b").Rows() | ||
| require.Len(t, rows, 2) | ||
|
|
||
| // Query with LIMIT — should NOT use trivial plan. | ||
| rows = tk.MustQuery("select * from t_no_idx limit 1").Rows() | ||
| require.Len(t, rows, 1) | ||
|
|
||
| // Query with DISTINCT — should NOT use trivial plan. | ||
| rows = tk.MustQuery("select distinct b from t_no_idx").Rows() | ||
| require.Len(t, rows, 2) | ||
|
|
||
| // Query with expression in SELECT — should NOT use trivial plan | ||
| // (buildSchemaFromFields returns nil for non-column expressions). | ||
| rows = tk.MustQuery("select a + 1 from t_no_idx").Rows() | ||
| require.Len(t, rows, 2) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,256 @@ | ||
| // Copyright 2026 PingCAP, Inc. | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package core | ||
|
|
||
| import ( | ||
| "github.com/pingcap/tidb/pkg/domain" | ||
| "github.com/pingcap/tidb/pkg/expression" | ||
| "github.com/pingcap/tidb/pkg/kv" | ||
| "github.com/pingcap/tidb/pkg/meta/metadef" | ||
| "github.com/pingcap/tidb/pkg/meta/model" | ||
| "github.com/pingcap/tidb/pkg/parser/ast" | ||
| "github.com/pingcap/tidb/pkg/parser/mysql" | ||
| "github.com/pingcap/tidb/pkg/planner/core/base" | ||
| "github.com/pingcap/tidb/pkg/planner/core/operator/physicalop" | ||
| "github.com/pingcap/tidb/pkg/planner/core/resolve" | ||
| "github.com/pingcap/tidb/pkg/planner/property" | ||
| "github.com/pingcap/tidb/pkg/planner/util/fixcontrol" | ||
| "github.com/pingcap/tidb/pkg/sessionctx/stmtctx" | ||
| "github.com/pingcap/tidb/pkg/statistics" | ||
| "github.com/pingcap/tidb/pkg/types" | ||
| "github.com/pingcap/tidb/pkg/util/ranger" | ||
| ) | ||
|
|
||
| // TryTrivialPlan attempts to build a physical plan directly for trivial queries | ||
| // that don't benefit from the full optimization pipeline. This is a fast path | ||
| // for queries like "SELECT * FROM t" or "SELECT a, b FROM t" on tables where | ||
| // the only viable plan is a full table scan (no secondary indexes, no TiFlash | ||
| // replicas, no predicates, etc.). | ||
| // | ||
| // This avoids all logical optimization rules, stats loading, cost estimation, | ||
| // and post-optimization passes that are unnecessary when the plan is predetermined. | ||
| // It is designed for execution performance, not EXPLAIN accuracy. | ||
| func TryTrivialPlan(ctx base.PlanContext, node *resolve.NodeW) (base.Plan, types.NameSlice) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Trivial plan skips UnionScan requirements Why: Returns a plain PhysicalTableReader even when dirty-txn reads or local temp/cache tables require UnionScan. For local temp tables, the TableReader runs in dummy mode and returns no rows. For dirty transactions, this breaks read-your-writes semantics. Evidence: TryTrivialPlan returns before logical plan building that would add UnionScan at pkg/planner/core/logical_plan_builder.go:5276: if dirty || tableInfo.TempTableType == model.TempTableLocal ||
tableInfo.TableCacheStatusType == model.TableCacheStatusEnable {
... LogicalUnionScan ...
}The early return at pkg/planner/optimize.go:264 bypasses this critical wrapping. |
||
| if checkStableResultMode(ctx) { | ||
| return nil, nil | ||
| } | ||
| if fixcontrol.GetBoolWithDefault(ctx.GetSessionVars().OptimizerFixControl, fixcontrol.Fix52592, false) { | ||
| return nil, nil | ||
| } | ||
|
|
||
| // Skip during EXPLAIN — the normal optimizer produces richer plan | ||
| // metadata (costs, stats annotations) that EXPLAIN output depends on. | ||
| if ctx.GetSessionVars().StmtCtx.InExplainStmt { | ||
| return nil, nil | ||
| } | ||
|
|
||
| sel, ok := node.Node.(*ast.SelectStmt) | ||
| if !ok { | ||
| return nil, nil | ||
| } | ||
| if !isTrivialSelect(sel) { | ||
| return nil, nil | ||
| } | ||
|
|
||
| // Single table, no joins or subqueries. | ||
| tblName, tblAlias := getSingleTableNameAndAlias(sel.From) | ||
| if tblName == nil { | ||
| return nil, nil | ||
| } | ||
| // Index hints (USE/FORCE/IGNORE INDEX) affect path selection. | ||
| if len(tblName.IndexHints) > 0 { | ||
| return nil, nil | ||
| } | ||
|
|
||
| // Look up the already-resolved table info from the resolve context. | ||
| resolveCtx := node.GetResolveContext() | ||
| tnW := resolveCtx.GetTableName(tblName) | ||
| if tnW == nil { | ||
| return nil, nil | ||
| } | ||
| tblInfo := tnW.TableInfo | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Trivial plan bypasses table-source semantics validation Why: Can scan VIEWs, SEQUENCEs, or explicit PARTITION clauses instead of erroring/expanding/converting, changing results or suppressing expected errors. Evidence: pkg/planner/core/trivial_plan.go:83 never checks tblInfo.IsView()/IsSequence()/IsBaseTable() before building a PhysicalTableScan. Normal planner at pkg/planner/core/logical_plan_builder.go:5006 expands views via BuildDataSourceFromView and at :5041 converts sequences to TableDual. The fast path bypasses all of this, producing empty/wrong results for |
||
| if tblInfo == nil { | ||
| return nil, nil | ||
| } | ||
|
|
||
| // Resolve database name. | ||
| dbName := tblName.Schema | ||
| if dbName.L == "" { | ||
| dbName = ast.NewCIStr(ctx.GetSessionVars().CurrentDB) | ||
| } | ||
|
|
||
| // System/memory tables use special executors; skip fast path. | ||
| if metadef.IsMemDB(dbName.L) { | ||
| return nil, nil | ||
| } | ||
|
|
||
| // Views and sequences need the full optimizer to expand their definitions. | ||
| if tblInfo.IsView() || tblInfo.IsSequence() { | ||
| return nil, nil | ||
| } | ||
|
|
||
| if !isTrivialTable(tblInfo) { | ||
| return nil, nil | ||
| } | ||
|
|
||
| // Privilege check. | ||
| if err := checkFastPlanPrivilege(ctx, dbName.L, tblInfo.Name.L, mysql.SelectPriv); err != nil { | ||
| return nil, nil | ||
| } | ||
|
|
||
| // Build output schema and column names from the SELECT field list. | ||
| // buildSchemaFromFields returns nil when the field list contains expressions, | ||
| // function calls, or other constructs that need the full planner. | ||
| schema, names := buildSchemaFromFields(dbName, tblInfo, tblAlias, sel.Fields.Fields) | ||
| if schema == nil { | ||
| return nil, nil | ||
| } | ||
|
|
||
| // Build the full set of table columns (for row-size estimation) and the | ||
| // pruned set of scan columns (matching the schema the SELECT requests). | ||
| allColumns := make([]*model.ColumnInfo, 0, len(tblInfo.Columns)) | ||
| tblCols := make([]*expression.Column, 0, len(tblInfo.Columns)) | ||
| for i, col := range tblInfo.Columns { | ||
| if col.State == model.StatePublic { | ||
| allColumns = append(allColumns, col) | ||
| tblCols = append(tblCols, colInfoToColumn(col, i)) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Trivial plan breaks SELECT output column mapping Why: PhysicalTableScan.Columns is derived from an ID-set in table order, dropping select-list ordering/duplicates/pseudo columns. This causes queries like Evidence: pkg/planner/core/trivial_plan.go:129 builds Columns by deduping on col.ID and iterating in table order: schemaColIDs[col.ID] = struct{}{}
...
for _, col := range allColumns {
if _, ok := schemaColIDs[col.ID]; ok {
columns = append(columns, col)
}
}This loses SELECT field ordering and duplicates. The scan output won't match the schema positions, causing wrong results. |
||
| } | ||
|
|
||
| // Prune scan columns to only those present in the output schema. This | ||
| // ensures TiKV returns only the columns the client expects. | ||
| schemaColIDs := make(map[int64]struct{}, schema.Len()) | ||
| for _, col := range schema.Columns { | ||
| schemaColIDs[col.ID] = struct{}{} | ||
| } | ||
| columns := make([]*model.ColumnInfo, 0, schema.Len()) | ||
| for _, col := range allColumns { | ||
| if _, ok := schemaColIDs[col.ID]; ok { | ||
| columns = append(columns, col) | ||
| } | ||
| } | ||
|
|
||
| // Row count estimate from stats cache (no synchronous loading). | ||
| rowCount := trivialRowCountEstimate(ctx, tblInfo) | ||
| statsInfo := &property.StatsInfo{RowCount: rowCount} | ||
|
|
||
| // Provide a HistColl so downstream callers (e.g. GetAvgRowSize for | ||
| // network buffer sizing) don't hit a nil dereference. | ||
| pseudoHist := statistics.PseudoHistColl(tblInfo.ID, false) | ||
|
|
||
| ctx.GetSessionVars().PlanID.Store(0) | ||
| ctx.GetSessionVars().PlanColumnID.Store(0) | ||
|
|
||
| // Determine the correct full range based on handle type. | ||
| var scanRanges ranger.Ranges | ||
| if tblInfo.IsCommonHandle { | ||
| scanRanges = ranger.FullRange() | ||
| } else { | ||
| isUnsigned := false | ||
| if tblInfo.PKIsHandle { | ||
| if pkColInfo := tblInfo.GetPkColInfo(); pkColInfo != nil { | ||
| isUnsigned = mysql.HasUnsignedFlag(pkColInfo.GetFlag()) | ||
| } | ||
| } | ||
| scanRanges = ranger.FullIntRange(isUnsigned) | ||
| } | ||
|
|
||
| // Build PhysicalTableScan. | ||
| ts := physicalop.PhysicalTableScan{ | ||
| Table: tblInfo, | ||
| Columns: columns, | ||
| DBName: dbName, | ||
| TableAsName: &tblAlias, | ||
| Ranges: scanRanges, | ||
| AccessCondition: nil, | ||
| StoreType: kv.TiKV, | ||
| TblCols: tblCols, | ||
| TblColHists: &pseudoHist, | ||
| }.Init(ctx, 0) | ||
| ts.SetSchema(schema.Clone()) | ||
| ts.SetStats(statsInfo) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // Wrap in PhysicalTableReader. | ||
| tr := physicalop.PhysicalTableReader{ | ||
| TablePlan: ts, | ||
| StoreType: kv.TiKV, | ||
| IsCommonHandle: tblInfo.IsCommonHandle, | ||
| }.Init(ctx, 0) | ||
| // Init() flattens TablePlan into TablePlans, copies schema, sets ReadReqType. | ||
| tr.SetStats(statsInfo) | ||
|
|
||
| // Record table access for privilege and lock checking. | ||
| ctx.GetSessionVars().StmtCtx.Tables = []stmtctx.TableEntry{ | ||
| {DB: dbName.L, Table: tblInfo.Name.L}, | ||
| } | ||
|
|
||
| return tr, names | ||
| } | ||
|
|
||
| // isTrivialSelect checks whether a SelectStmt is simple enough for the trivial | ||
| // fast path: single table, no predicates, no ordering, no grouping, no limits, | ||
| // no hints, no locking, no CTEs, no DISTINCT, no INTO. | ||
| func isTrivialSelect(sel *ast.SelectStmt) bool { | ||
| return sel.Kind == ast.SelectStmtKindSelect && | ||
| sel.From != nil && | ||
| sel.Where == nil && | ||
| sel.GroupBy == nil && | ||
| sel.Having == nil && | ||
| sel.OrderBy == nil && | ||
| sel.Limit == nil && | ||
| sel.WindowSpecs == nil && | ||
| !sel.Distinct && | ||
| sel.SelectIntoOpt == nil && | ||
| sel.LockInfo == nil && | ||
| sel.With == nil && | ||
| len(sel.TableHints) == 0 | ||
| } | ||
|
|
||
| // isTrivialTable checks whether a table's metadata allows the trivial plan | ||
| // fast path: no partitioning, no TiFlash replicas, no secondary indexes, | ||
| // and no virtual generated columns. | ||
| func isTrivialTable(tblInfo *model.TableInfo) bool { | ||
| if tblInfo.GetPartitionInfo() != nil { | ||
| return false | ||
| } | ||
| if tblInfo.TiFlashReplica != nil && tblInfo.TiFlashReplica.Available { | ||
| return false | ||
| } | ||
| for _, idx := range tblInfo.Indices { | ||
| if idx.State == model.StatePublic && !idx.Primary { | ||
| return false | ||
| } | ||
| } | ||
| for _, col := range tblInfo.Columns { | ||
| if col.IsGenerated() && !col.GeneratedStored { | ||
| return false | ||
| } | ||
| } | ||
| return true | ||
| } | ||
|
|
||
| // trivialRowCountEstimate returns a row count estimate without triggering | ||
| // synchronous stats loading. Uses the cached RealtimeCount when available, | ||
| // falling back to PseudoRowCount. | ||
| func trivialRowCountEstimate(ctx base.PlanContext, tblInfo *model.TableInfo) float64 { | ||
| statsHandle := domain.GetDomain(ctx).StatsHandle() | ||
| if statsHandle != nil { | ||
| statsTbl := statsHandle.GetPhysicalTableStats(tblInfo.ID, tblInfo) | ||
| if statsTbl != nil && statsTbl.RealtimeCount > 0 { | ||
| return float64(statsTbl.RealtimeCount) | ||
| } | ||
| } | ||
| return statistics.PseudoRowCount | ||
|
terry1purcell marked this conversation as resolved.
Outdated
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -257,6 +257,13 @@ func optimizeNoCache(ctx context.Context, sctx sessionctx.Context, node *resolve | |
| if fp != nil { | ||
| return fp, fp.OutputNames(), nil | ||
| } | ||
|
|
||
| // Try the trivial plan fast path for simple full-table scans where the | ||
| // plan is predetermined (no secondary indexes, no TiFlash, no predicates). | ||
| // This skips logical optimization, stats loading, and cost estimation. | ||
| if tp, tpNames := core.TryTrivialPlan(pctx, node); tp != nil { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Trivial plan ignores sql_select_limit Why: core.TryAddExtraLimit is applied only after the fast-path returns, so trivial plan can return unlimited rows when sql_select_limit is set (non-default configuration). Evidence: pkg/planner/optimize.go:264 returns early: if tp, tpNames := core.TryTrivialPlan(pctx, node); tp != nil {
return tp, tpNames, nil
}vs pkg/planner/optimize.go:276 which applies the limit: stmtNode = core.TryAddExtraLimit(sctx, stmtNode)The fast path skips this rewrite entirely. |
||
| return tp, tpNames, nil | ||
| } | ||
| } | ||
|
|
||
| enableUseBinding := sessVars.UsePlanBaselines | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Tests don't prove the fast path is used
Why: TestTrivialPlan validates EXPLAIN output, but TryTrivialPlan explicitly returns nil in EXPLAIN mode (pkg/planner/core/trivial_plan.go:55), so the test can pass even if the fast path never triggers for actual queries.
Evidence: pkg/planner/core/trivial_plan.go:55:
The test at pkg/planner/core/tests/pointget/trivial_plan_test.go:50 checks
explain format='brief' select * from t_simple, which will always use the normal optimizer path, not the trivial fast path.