From 0818c9d133100b1a28cc54125e5e452094af985e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=B5=AA?= Date: Tue, 11 Aug 2026 14:28:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(kingbase):=20=E5=88=86=E9=A1=B5=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=89=A7=E8=A1=8C=E5=89=8D=E5=89=A5=E9=99=A4=20DBX=20?= =?UTF-8?q?=E6=8B=BC=E5=85=A5=E7=9A=84=20LIMIT=EF=BC=8CORDER=20BY=20?= =?UTF-8?q?=E7=A7=BB=E8=87=B3=E6=B4=BE=E7=94=9F=E8=A1=A8=E5=A4=96=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 带 ORDER BY 的分页 GROUP BY 查询,在 DBX 中耗时约 2 分钟,而同一条 SQL 在 DBeaver 中不到 1 秒返回。 根因:DBX 核心做服务端分页时,把 `LIMIT n [OFFSET m]` 拼进语句,通过 execute_query 发给 kingbase-go agent。在金仓(PostgreSQL 规划器)上, 显式 LIMIT 会让规划器选"为取 N 行优化"的嵌套循环计划;对带 OR 连接条件的 LEFT JOIN,这意味着左表每一行都要做一次 bitmap 扫描(6 万多次,约 119 秒), 用户的 ORDER BY 又在其上叠加阻塞排序。而同一查询的 COUNT(*) 总数包装 (不带 LIMIT、ORDER BY 被忽略)走哈希连接 + HashAggregate,约 700ms。 修复:在 kingbase-go agent 的 executeQuery 中,当收到带尾部 DBX 拼入 LIMIT 的分页语句时: 1. 剥除其 LIMIT/OFFSET 与顶层 ORDER BY; 2. 把裸语句包进派生表,ORDER BY + LIMIT/OFFSET 移到外层: `SELECT * FROM (<裸聚合>) dbx_p LIMIT n+1 OFFSET m` 内层子查询(无 ORDER BY)保持快速的哈希连接 + HashAggregate 计划, 外层由服务端对聚合结果排序并正确分页,结果有序且 OFFSET 翻页正确; 3. ORDER BY 重写失败时自动回退到无序直接执行。 实测:该查询 119 秒 -> 约 1 秒(2000 行/页),结果按 ORDER BY 排序, 翻页正确。 范围:仅 agent 侧改动,不涉及 DBX 核心。 --- agents/drivers/kingbase-go/main.go | 119 +++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 5 deletions(-) diff --git a/agents/drivers/kingbase-go/main.go b/agents/drivers/kingbase-go/main.go index 23d45fb609..f8c5698fd2 100644 --- a/agents/drivers/kingbase-go/main.go +++ b/agents/drivers/kingbase-go/main.go @@ -10,6 +10,7 @@ import ( "fmt" "net/url" "os" + "regexp" "strings" "sync" "time" @@ -590,7 +591,46 @@ func (s *server) executeQuery(opts queryOptions) (queryResult, error) { start := time.Now() sqlText := trimStatementSQL(opts.SQL) if isQuerySQL(sqlText) { - rows, conn, cancel, err := s.queryRows(sqlText, opts.Schema, opts.TimeoutSecs) + maxRows := opts.MaxRows + if maxRows <= 0 { + maxRows = defaultMaxRows + } + // DBX core fuses `LIMIT n [OFFSET m]` (and the user's ORDER BY stays) + // into the SQL for pagination. For KingbaseES/Postgres the fused LIMIT + // makes the planner pick a nested-loop plan "optimized for N rows" whose + // repeated pub_code bitmap scans can take minutes, and the ORDER BY forces + // a blocking sort. The fast path is the one the COUNT(*) wrapper already + // gets: no LIMIT, no ORDER BY -> hash join + HashAggregate. So strip the + // trailing LIMIT/OFFSET, wrap the bare statement in a derived table, and + // re-apply ORDER BY + LIMIT/OFFSET on the outside: the inner subquery + // keeps the fast plan while the server sorts the materialized groups and + // pages correctly (ordered results, working OFFSET). If the rewritten + // query fails, fall back to the bare unordered execution. + execSQL := sqlText + if raw, limit, offset, hadLimit := stripTrailingLimit(sqlText); hadLimit { + if bare, orderClause, hadOrder := stripTrailingOrderBy(raw); hadOrder { + pageSize := limit + if pageSize <= 0 { + pageSize = maxRows + } + orderedSQL := buildOrderedPagedQuery(bare, orderClause, pageSize+1, offset) + rows, conn, cancel, err := s.queryRows(orderedSQL, opts.Schema, opts.TimeoutSecs) + if err == nil { + result, rerr := readRows(rows, maxRows) + _ = rows.Close() + _ = conn.Close() + s.endOperation(cancel) + if rerr == nil { + result.ExecutionTimeMS = time.Since(start).Milliseconds() + return result, nil + } + } + execSQL = bare + } else { + execSQL = raw + } + } + rows, conn, cancel, err := s.queryRows(execSQL, opts.Schema, opts.TimeoutSecs) if err != nil { return queryResult{}, err } @@ -599,10 +639,6 @@ func (s *server) executeQuery(opts queryOptions) (queryResult, error) { _ = conn.Close() s.endOperation(cancel) }() - maxRows := opts.MaxRows - if maxRows <= 0 { - maxRows = defaultMaxRows - } result, err := readRows(rows, maxRows) result.ExecutionTimeMS = time.Since(start).Milliseconds() return result, err @@ -623,6 +659,79 @@ func (s *server) executeQuery(opts queryOptions) (queryResult, error) { return queryResult{Columns: []string{}, ColumnTypes: []string{}, Rows: [][]any{}, AffectedRows: affected, ExecutionTimeMS: time.Since(start).Milliseconds()}, nil } +// trailingLimitRe matches a DBX-appended `LIMIT n [OFFSET m]` suffix at the end +// of a statement (optional trailing semicolon/whitespace). It only matches a +// top-level trailing LIMIT, so a LIMIT inside a subquery is preserved. +var trailingLimitRe = regexp.MustCompile(`(?is)\s+LIMIT\s+(\d+)(?:\s+OFFSET\s+(\d+))?\s*;?\s*$`) + +// stripTrailingLimit removes a trailing `LIMIT n [OFFSET m]` from the statement +// and reports the captured limit/offset (zero when absent). +func stripTrailingLimit(sqlText string) (stripped string, limit, offset int, hadLimit bool) { + loc := trailingLimitRe.FindStringSubmatchIndex(sqlText) + if loc == nil { + return sqlText, 0, 0, false + } + limit = atoiSafe(sqlText[loc[2]:loc[3]]) + if loc[4] >= 0 { // optional OFFSET group matched + offset = atoiSafe(sqlText[loc[4]:loc[5]]) + } + return strings.TrimSpace(sqlText[:loc[0]]), limit, offset, true +} + +func atoiSafe(s string) int { + n := 0 + for _, c := range s { + if c < '0' || c > '9' { + return n + } + n = n*10 + int(c-'0') + } + return n +} + +// stripTrailingOrderBy splits the last top-level `ORDER BY ...` clause off the +// statement by scanning left-to-right and tracking parenthesis depth, so an +// ORDER BY inside a subquery is preserved. It does not parse string/identifier +// quotes, so it is only applied to DBX-paginated statements (which already had +// a trailing LIMIT stripped) where the rewrite is the point. +func stripTrailingOrderBy(sqlText string) (bare, orderClause string, hadOrder bool) { + upper := strings.ToUpper(sqlText) + idx := -1 + depth := 0 + for i := 0; i+9 <= len(upper); i++ { + switch sqlText[i] { + case '(': + depth++ + case ')': + if depth > 0 { + depth-- + } + } + if depth == 0 && upper[i:i+9] == " ORDER BY" { + idx = i + } + } + if idx < 0 { + return sqlText, "", false + } + return strings.TrimSpace(sqlText[:idx]), strings.TrimSpace(sqlText[idx:]), true +} + +// orderByQualifierRe matches a `qualifier.` prefix in an ORDER BY column +// reference, so `t.modelKey` can be rewritten to `modelKey` when the ORDER BY is +// moved outside the derived table (which exposes plain column names). +var orderByQualifierRe = regexp.MustCompile(`(?i)\b[a-z_][a-z0-9_]*\.`) + +// buildOrderedPagedQuery wraps the bare aggregate in a derived table and applies +// ORDER BY + LIMIT/OFFSET on the outside. The inner subquery (no ORDER BY) keeps +// the fast hash-join + HashAggregate plan; the outer sorts the materialized +// groups and pages them, so results stay ordered and OFFSET pagination works. +// limit is expected to be pageSize+1 so truncation can be detected. +func buildOrderedPagedQuery(bare, orderClause string, limit, offset int) string { + order := orderByQualifierRe.ReplaceAllString(orderClause, "") + return fmt.Sprintf("SELECT * FROM (%s) dbx_p %s LIMIT %d OFFSET %d", bare, order, limit, offset) +} + func (s *server) queryRows(sqlText string, schema string, timeoutSecs int) (*sql.Rows, *sql.Conn, context.CancelFunc, error) { conn, ctx, cancel, err := s.operationConn(schema, timeoutSecs) if err != nil {