mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-20 20:35:14 +08:00
- 完善慢查询采集、聚合、迁移与跨进程持久化 - 加固只读诊断边界并补齐多数据库执行计划解析 - 优化慢 SQL 面板、计划图、响应式布局与交互体验 - 补充后端、前端及 SQL Server 会话回归测试
209 lines
7.8 KiB
Go
209 lines
7.8 KiB
Go
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"GoNavi-Wails/internal/connection"
|
||
"GoNavi-Wails/internal/logger"
|
||
)
|
||
|
||
// 慢 SQL 历史的 Wails 绑定入口。
|
||
//
|
||
// 前端调用:
|
||
// - GetSlowQueries(connectionId, dbName, sortBy, limit) → []QueryExecutionRecord
|
||
// - ClearSlowQueries(connectionId, dbName) → 错误(清空当前连接的历史)
|
||
//
|
||
// sortBy: "duration" | "frequency" | "rowsReturned" | "recent"
|
||
|
||
// GetSlowQueries 返回当前连接的慢 SQL 历史,按 SQL 指纹聚合执行统计、按指定字段排序后取前 N。
|
||
// limit <= 0 时返回前 100 条。
|
||
func (a *App) GetSlowQueries(config connection.ConnectionConfig, dbName, sortBy string, limit int) connection.QueryResult {
|
||
fingerprints, ok := queryHistoryConnectionFingerprints(config, dbName)
|
||
if !ok {
|
||
return connection.QueryResult{Success: false, Message: a.appText("query_history.backend.error.connection_fingerprint_invalid", nil)}
|
||
}
|
||
|
||
limit = normalizeQueryHistoryLimit(limit)
|
||
primaryFingerprint := fingerprints[0]
|
||
primaryStore := newQueryHistoryStore(a.configDir, primaryFingerprint)
|
||
failedLegacyStores := make([]*queryHistoryStore, 0)
|
||
for _, fingerprint := range fingerprints[1:] {
|
||
legacyStore := newQueryHistoryStore(a.configDir, fingerprint)
|
||
if err := migrateQueryHistoryStore(legacyStore, primaryStore, primaryFingerprint); err != nil {
|
||
logger.Warnf("GetSlowQueries 迁移旧历史失败:legacyFp=%s targetFp=%s err=%v", fingerprint, primaryFingerprint, err)
|
||
failedLegacyStores = append(failedLegacyStores, legacyStore)
|
||
}
|
||
}
|
||
|
||
records, err := primaryStore.LoadAll()
|
||
if err != nil {
|
||
logger.Warnf("GetSlowQueries 加载失败:connFp=%s err=%v", primaryFingerprint, err)
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
for _, legacyStore := range failedLegacyStores {
|
||
storeRecords, err := legacyStore.LoadAll()
|
||
if err != nil {
|
||
logger.Warnf("GetSlowQueries 加载旧历史失败:path=%s err=%v", legacyStore.filePath, err)
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
records = append(records, storeRecords...)
|
||
}
|
||
records = dedupeRawQueryHistoryRecords(records)
|
||
records = aggregateQueryRecords(records)
|
||
sortQueryRecords(records, strings.TrimSpace(sortBy))
|
||
if len(records) > limit {
|
||
records = records[:limit]
|
||
}
|
||
return connection.QueryResult{Success: true, Message: a.appText("query_history.backend.message.loaded", nil), Data: records}
|
||
}
|
||
|
||
// ClearSlowQueries 清空当前连接的慢 SQL 历史。
|
||
// 删除主文件 + rotate 文件(.1)。
|
||
func (a *App) ClearSlowQueries(config connection.ConnectionConfig, dbName string) connection.QueryResult {
|
||
fingerprints, ok := queryHistoryConnectionFingerprints(config, dbName)
|
||
if !ok {
|
||
return connection.QueryResult{Success: false, Message: a.appText("query_history.backend.error.connection_fingerprint_invalid", nil)}
|
||
}
|
||
for _, fingerprint := range fingerprints {
|
||
if err := newQueryHistoryStore(a.configDir, fingerprint).Clear(); err != nil {
|
||
logger.Warnf("ClearSlowQueries 失败:connFp=%s err=%v", fingerprint, err)
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
}
|
||
return connection.QueryResult{Success: true, Message: a.appText("query_history.backend.message.cleared", nil)}
|
||
}
|
||
|
||
// queryHistoryConnectionFingerprints returns the stable ID-based fingerprint and
|
||
// the previous host/config-based fingerprint for backward-compatible reads/clear.
|
||
func queryHistoryConnectionFingerprints(config connection.ConnectionConfig, dbName string) ([]string, bool) {
|
||
primary, ok := buildQueryHistoryConnectionFingerprint(config, dbName)
|
||
if !ok || strings.TrimSpace(primary) == "" {
|
||
return nil, false
|
||
}
|
||
fingerprints := []string{primary}
|
||
seen := map[string]struct{}{primary: {}}
|
||
for _, legacyConfig := range []connection.ConnectionConfig{normalizeRunConfig(config, dbName), config} {
|
||
legacy, legacyOK := buildConnectionFingerprint(legacyConfig)
|
||
if queryHistoryUsesOpaqueEndpoint(legacyConfig) || !legacyOK || strings.TrimSpace(legacy) == "" {
|
||
continue
|
||
}
|
||
if _, exists := seen[legacy]; exists {
|
||
continue
|
||
}
|
||
seen[legacy] = struct{}{}
|
||
fingerprints = append(fingerprints, legacy)
|
||
}
|
||
return fingerprints, true
|
||
}
|
||
|
||
func migrateQueryHistoryStore(source, target *queryHistoryStore, targetFingerprint string) error {
|
||
if source == nil || target == nil || source.filePath == target.filePath {
|
||
return nil
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(target.filePath), 0o700); err != nil {
|
||
return err
|
||
}
|
||
migrationLock, err := acquireQueryHistoryFileLock(target.filePath + ".migration.lock")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer migrationLock.Close()
|
||
// Source and target data locks stay held for the complete copy+clear window.
|
||
// A legacy process that appends concurrently waits until clear finishes and
|
||
// then writes a new source record, which the next read migrates; no record is
|
||
// deleted between a stale snapshot and source.Clear().
|
||
return withQueryHistoryStoresLocked([]*queryHistoryStore{source, target}, func() error {
|
||
sourceRecords := decodeQueryHistorySnapshots(source.readFileSnapshots())
|
||
if len(sourceRecords) == 0 {
|
||
return nil
|
||
}
|
||
targetRecords := decodeQueryHistorySnapshots(target.readFileSnapshots())
|
||
seen := make(map[string]struct{}, len(targetRecords)+len(sourceRecords))
|
||
for _, record := range targetRecords {
|
||
seen[queryHistoryRawRecordKey(record)] = struct{}{}
|
||
}
|
||
for _, record := range sourceRecords {
|
||
key := queryHistoryRawRecordKey(record)
|
||
if _, exists := seen[key]; exists {
|
||
continue
|
||
}
|
||
record.ConnectionFP = targetFingerprint
|
||
if err := target.appendLocked(record); err != nil {
|
||
return err
|
||
}
|
||
seen[key] = struct{}{}
|
||
}
|
||
return source.clearLocked()
|
||
})
|
||
}
|
||
|
||
func dedupeRawQueryHistoryRecords(records []connection.QueryExecutionRecord) []connection.QueryExecutionRecord {
|
||
if len(records) < 2 {
|
||
return records
|
||
}
|
||
seen := make(map[string]struct{}, len(records))
|
||
result := make([]connection.QueryExecutionRecord, 0, len(records))
|
||
for _, record := range records {
|
||
key := queryHistoryRawRecordKey(record)
|
||
if _, exists := seen[key]; exists {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
result = append(result, record)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func queryHistoryRawRecordKey(record connection.QueryExecutionRecord) string {
|
||
if id := strings.TrimSpace(record.ID); id != "" {
|
||
return "id:" + id
|
||
}
|
||
payload, _ := json.Marshal(record)
|
||
return "record:" + string(payload)
|
||
}
|
||
|
||
// recordQueryExecution 追加一条慢查询记录。仅慢查询会触发一次小型追加写;同步写入确保
|
||
// 执行完成后立刻可见,并避免异步追加与清空操作发生乱序。
|
||
func (a *App) recordQueryExecution(config connection.ConnectionConfig, dbName, dbType, sql string, durationMs, rowsRead, rowsReturned int64) {
|
||
if durationMs < queryHistorySlowThresholdMs {
|
||
return
|
||
}
|
||
runConfig := normalizeRunConfig(config, dbName)
|
||
record := buildQueryExecutionRecord(runConfig, dbName, dbType, sql, durationMs, rowsRead, rowsReturned)
|
||
if strings.TrimSpace(record.ConnectionFP) == "" {
|
||
logger.Warnf("跳过慢查询记录:连接指纹无效 dbType=%s", dbType)
|
||
return
|
||
}
|
||
store := newQueryHistoryStore(a.configDir, record.ConnectionFP)
|
||
store.Append(record)
|
||
}
|
||
|
||
// queryResultRowsReturned 只统计真实查询结果集,affectedRows 写操作摘要不计入返回行数。
|
||
func queryResultRowsReturned(result connection.QueryResult) int64 {
|
||
if result.Data == nil {
|
||
return 0
|
||
}
|
||
switch data := result.Data.(type) {
|
||
case []map[string]interface{}:
|
||
return int64(len(data))
|
||
case []connection.ResultSetData:
|
||
var total int64
|
||
for _, resultSet := range data {
|
||
if isAffectedRowsResultSet(resultSet) {
|
||
continue
|
||
}
|
||
total += int64(len(resultSet.Rows))
|
||
}
|
||
return total
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
func isAffectedRowsResultSet(resultSet connection.ResultSetData) bool {
|
||
return len(resultSet.Columns) == 1 && strings.EqualFold(strings.TrimSpace(resultSet.Columns[0]), "affectedRows")
|
||
}
|