Files
MyGoNavi/internal/app/query_history_store.go
Syngnat 034e968888 feat(sql-analysis): 完善慢 SQL 监控与执行计划诊断
- 完善慢查询采集、聚合、迁移与跨进程持久化

- 加固只读诊断边界并补齐多数据库执行计划解析

- 优化慢 SQL 面板、计划图、响应式布局与交互体验

- 补充后端、前端及 SQL Server 会话回归测试
2026-07-11 16:25:07 +08:00

808 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
)
// 慢 SQL 历史存储。
//
// 设计要点:
// - 每个连接指纹一份 JSONL 文件(路径:<configDir>/query_history/<connFp>.jsonl
// - 追加写:慢 SQL 完成后追加一行 JSON确保读取与清空操作有序
// - 5MB 滚动:写入前检查文件大小,超阈值则 rename 为 .jsonl.1 并新建空文件
// - 读 TopN全量加载到内存按字段排序 + SQL 指纹聚合
// - 不引入 SQLite项目现有持久化都是 JSON依赖一致性优先
const (
queryHistoryDirName = "query_history"
queryHistoryFileMaxBytes = 5 * 1024 * 1024
queryHistorySlowThresholdMs int64 = 500 // 低于 500ms 不记录,避免历史爆炸
queryHistoryPreviewRunes = 200 // SQL 预览截断长度
queryHistorySQLRunes = 64 * 1024 // 保留用于诊断的 SQL上限避免历史文件被超大语句撑爆
queryHistoryDefaultResults = 100
queryHistoryMaxResults = 500
queryHistoryFingerprintVersion = "query-history-v1"
)
var queryHistoryStoreLocks sync.Map
// queryHistoryStore 是单连接的慢 SQL 历史存储。
// 并发安全:同一连接的多条 SQL 可能并发执行,写入加锁。
type queryHistoryStore struct {
mu *sync.Mutex
filePath string
}
// newQueryHistoryStore 按连接指纹构造 store。configDir 为空时用 resolveAppConfigDir。
func newQueryHistoryStore(configDir, connFingerprint string) *queryHistoryStore {
if strings.TrimSpace(configDir) == "" {
configDir = resolveAppConfigDir()
}
fp := sanitizeFingerprintForFilename(connFingerprint)
filePath := filepath.Clean(filepath.Join(configDir, queryHistoryDirName, fp+".jsonl"))
lock, _ := queryHistoryStoreLocks.LoadOrStore(filePath, &sync.Mutex{})
return &queryHistoryStore{
mu: lock.(*sync.Mutex),
filePath: filePath,
}
}
// Append 追加一条执行记录。低于阈值的查询自动跳过。
// 失败仅记日志,不影响主查询流程。
func (s *queryHistoryStore) Append(record connection.QueryExecutionRecord) error {
if record.DurationMs < queryHistorySlowThresholdMs {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(s.filePath), 0o700); err != nil {
logger.Warnf("创建慢查询历史目录失败:%v path=%s", err, filepath.Dir(s.filePath))
return err
}
fileLock, err := acquireQueryHistoryFileLock(s.filePath + ".lock")
if err != nil {
logger.Warnf("锁定慢查询历史失败:%v path=%s", err, s.filePath)
return err
}
defer func() {
if err := fileLock.Close(); err != nil {
logger.Warnf("释放慢查询历史锁失败:%v path=%s", err, s.filePath)
}
}()
return s.appendLocked(record)
}
// appendLocked writes one record while the caller owns both s.mu and the
// cross-process lock for s.filePath. Migration uses this primitive so copying
// and clearing a legacy store can be one atomic critical section.
func (s *queryHistoryStore) appendLocked(record connection.QueryExecutionRecord) error {
s.tightenFilePermissions()
// 检查大小并 rotaterotate 失败不阻塞写入)
if info, err := os.Stat(s.filePath); err == nil && info.Size() >= queryHistoryFileMaxBytes {
rotated := s.filePath + ".1"
// 已有 .1 文件则先删除(只保留一个历史文件)
_ = os.Remove(rotated)
if err := os.Rename(s.filePath, rotated); err != nil {
logger.Warnf("慢查询历史 rotate 失败:%v path=%s", err, s.filePath)
}
}
file, err := os.OpenFile(s.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
logger.Warnf("打开慢查询历史文件失败:%v path=%s", err, s.filePath)
return err
}
defer file.Close()
if err := file.Chmod(0o600); err != nil {
logger.Warnf("收紧慢查询历史文件权限失败:%v path=%s", err, s.filePath)
}
payload, err := json.Marshal(record)
if err != nil {
logger.Warnf("序列化慢查询记录失败:%v", err)
return err
}
payload = append(payload, '\n')
if _, err := file.Write(payload); err != nil {
logger.Warnf("写入慢查询历史失败:%v path=%s", err, s.filePath)
return err
}
return nil
}
// withQueryHistoryStoresLocked locks multiple stores in stable path order at
// both the process and OS levels. Stable ordering prevents two migrations with
// reversed source/target stores from deadlocking.
func withQueryHistoryStoresLocked(stores []*queryHistoryStore, operation func() error) (resultErr error) {
unique := make([]*queryHistoryStore, 0, len(stores))
seen := make(map[string]struct{}, len(stores))
for _, store := range stores {
if store == nil {
continue
}
if _, exists := seen[store.filePath]; exists {
continue
}
seen[store.filePath] = struct{}{}
unique = append(unique, store)
}
sort.Slice(unique, func(i, j int) bool { return unique[i].filePath < unique[j].filePath })
for _, store := range unique {
store.mu.Lock()
}
defer func() {
for index := len(unique) - 1; index >= 0; index-- {
unique[index].mu.Unlock()
}
}()
fileLocks := make([]*queryHistoryFileLock, 0, len(unique))
defer func() {
for index := len(fileLocks) - 1; index >= 0; index-- {
resultErr = errors.Join(resultErr, fileLocks[index].Close())
}
}()
for _, store := range unique {
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
return err
}
fileLock, err := acquireQueryHistoryFileLock(store.filePath + ".lock")
if err != nil {
return err
}
fileLocks = append(fileLocks, fileLock)
store.tightenFilePermissions()
}
if operation == nil {
return nil
}
return operation()
}
func (s *queryHistoryStore) clearLocked() error {
for _, path := range []string{s.filePath, s.filePath + ".1"} {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
// LoadTopN 加载历史并按指定字段排序,返回前 N 条。
// sortBy: "duration" | "frequency" | "rowsReturned" | "recent"dedupe=true 时聚合同 SQL 指纹的执行统计。
func (s *queryHistoryStore) LoadTopN(sortBy string, limit int, dedupe bool) ([]connection.QueryExecutionRecord, error) {
records, err := s.LoadAll()
if err != nil {
return nil, err
}
if dedupe {
records = aggregateQueryRecords(records)
}
sortQueryRecords(records, sortBy)
limit = normalizeQueryHistoryLimit(limit)
if len(records) > limit {
records = records[:limit]
}
return records, nil
}
// LoadAll returns a consistent in-memory snapshot without holding the store lock
// during JSON decoding. Callers that combine current and legacy fingerprints can
// aggregate and sort the complete record set once.
func (s *queryHistoryStore) LoadAll() ([]connection.QueryExecutionRecord, error) {
s.mu.Lock()
if err := os.MkdirAll(filepath.Dir(s.filePath), 0o700); err != nil {
s.mu.Unlock()
return nil, err
}
fileLock, err := acquireQueryHistoryFileLock(s.filePath + ".lock")
if err != nil {
s.mu.Unlock()
return nil, err
}
s.tightenFilePermissions()
snapshots := s.readFileSnapshots()
lockErr := fileLock.Close()
s.mu.Unlock()
if lockErr != nil {
return nil, lockErr
}
// JSON 解码、聚合和排序可能比文件读取耗时得多;使用内存快照后在锁外处理,
// 避免用户刷新历史时阻塞刚执行完成的慢查询返回。
return decodeQueryHistorySnapshots(snapshots), nil
}
func (s *queryHistoryStore) tightenFilePermissions() {
if _, err := os.Stat(filepath.Dir(s.filePath)); err == nil {
if err := os.Chmod(filepath.Dir(s.filePath), 0o700); err != nil {
logger.Warnf("收紧慢查询历史目录权限失败:%v path=%s", err, filepath.Dir(s.filePath))
}
}
for _, path := range []string{s.filePath, s.filePath + ".1", s.filePath + ".lock"} {
if err := os.Chmod(path, 0o600); err != nil && !os.IsNotExist(err) {
logger.Warnf("收紧慢查询历史文件权限失败:%v path=%s", err, path)
}
}
}
func normalizeQueryHistoryLimit(limit int) int {
if limit <= 0 {
return queryHistoryDefaultResults
}
if limit > queryHistoryMaxResults {
return queryHistoryMaxResults
}
return limit
}
// Clear 删除主文件 + rotate 文件。文件不存在视为成功。
func (s *queryHistoryStore) Clear() error {
s.mu.Lock()
defer s.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(s.filePath), 0o700); err != nil {
return err
}
fileLock, err := acquireQueryHistoryFileLock(s.filePath + ".lock")
if err != nil {
return err
}
defer fileLock.Close()
return s.clearLocked()
}
// readFileSnapshots 在同一连接锁内读取主文件和 rotate 文件,保证快照不会
// 与 append/rotate/clear 交错;耗时解析在解锁后完成。
func (s *queryHistoryStore) readFileSnapshots() [][]byte {
snapshots := make([][]byte, 0, 2)
for _, path := range []string{s.filePath + ".1", s.filePath} {
payload, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
logger.Warnf("打开慢查询历史失败:%v path=%s", err, path)
}
continue
}
snapshots = append(snapshots, payload)
}
return snapshots
}
// decodeQueryHistorySnapshots 解码内存快照;单行损坏时跳过该行,不阻塞整体加载。
func decodeQueryHistorySnapshots(snapshots [][]byte) []connection.QueryExecutionRecord {
var records []connection.QueryExecutionRecord
for _, payload := range snapshots {
scanner := bufio.NewScanner(bytes.NewReader(payload))
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 单行最大 1MB足够大 SQL
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var r connection.QueryExecutionRecord
if err := json.Unmarshal([]byte(line), &r); err != nil {
continue
}
records = append(records, r)
}
if err := scanner.Err(); err != nil {
logger.Warnf("解析慢查询历史快照失败:%v", err)
}
}
return records
}
type queryRecordAggregate struct {
representative connection.QueryExecutionRecord
count int64
totalDuration float64
maxDuration int64
maxRowsRead int64
maxRowsReturn int64
}
// aggregateQueryRecords 按 SQLFingerprint 聚合,并以最新记录承载完整 SQL 与最近执行时间。
// 旧版 JSONL 没有聚合字段时按单次执行处理,保持向后兼容。
func aggregateQueryRecords(records []connection.QueryExecutionRecord) []connection.QueryExecutionRecord {
if len(records) == 0 {
return records
}
aggregates := make(map[string]*queryRecordAggregate, len(records))
for index, record := range records {
key := record.SQLFingerprint
if key == "" {
key = fmt.Sprintf("__unfingerprinted__:%s:%d", record.ID, index)
}
count := record.ExecutionCount
if count <= 0 {
count = 1
}
avgDuration := record.AvgDurationMs
if avgDuration <= 0 {
avgDuration = float64(record.DurationMs)
}
maxDuration := record.MaxDurationMs
if maxDuration < record.DurationMs {
maxDuration = record.DurationMs
}
aggregate, ok := aggregates[key]
if !ok {
aggregate = &queryRecordAggregate{representative: record}
aggregates[key] = aggregate
} else if record.ExecutedAt.After(aggregate.representative.ExecutedAt) {
aggregate.representative = record
}
aggregate.count += count
aggregate.totalDuration += avgDuration * float64(count)
if maxDuration > aggregate.maxDuration {
aggregate.maxDuration = maxDuration
}
if record.RowsRead > aggregate.maxRowsRead {
aggregate.maxRowsRead = record.RowsRead
}
if record.RowsReturned > aggregate.maxRowsReturn {
aggregate.maxRowsReturn = record.RowsReturned
}
}
result := make([]connection.QueryExecutionRecord, 0, len(aggregates))
for _, aggregate := range aggregates {
record := aggregate.representative
record.ExecutionCount = aggregate.count
record.AvgDurationMs = aggregate.totalDuration / float64(aggregate.count)
record.MaxDurationMs = aggregate.maxDuration
record.RowsRead = aggregate.maxRowsRead
record.RowsReturned = aggregate.maxRowsReturn
result = append(result, record)
}
return result
}
// dedupeQueryRecords 保留旧调用语义,实际返回聚合后的同 SQL 记录。
func dedupeQueryRecords(records []connection.QueryExecutionRecord) []connection.QueryExecutionRecord {
return aggregateQueryRecords(records)
}
// sortQueryRecords 按字段原地排序。sortBy 不识别时按 recent。
func sortQueryRecords(records []connection.QueryExecutionRecord, sortBy string) {
sortBy = strings.TrimSpace(sortBy)
sort.SliceStable(records, func(i, j int) bool {
left, right := records[i], records[j]
var leftValue, rightValue float64
switch sortBy {
case "duration":
leftValue = float64(maxInt64(left.MaxDurationMs, left.DurationMs))
rightValue = float64(maxInt64(right.MaxDurationMs, right.DurationMs))
case "frequency":
leftValue = float64(maxInt64(left.ExecutionCount, 1))
rightValue = float64(maxInt64(right.ExecutionCount, 1))
case "rowsReturned":
leftValue = float64(left.RowsReturned)
rightValue = float64(right.RowsReturned)
case "rowsRead":
leftValue = float64(left.RowsRead)
rightValue = float64(right.RowsRead)
default:
leftValue = float64(left.ExecutedAt.UnixNano())
rightValue = float64(right.ExecutedAt.UnixNano())
}
if leftValue != rightValue {
return leftValue > rightValue
}
if !left.ExecutedAt.Equal(right.ExecutedAt) {
return left.ExecutedAt.After(right.ExecutedAt)
}
return left.SQLFingerprint < right.SQLFingerprint
})
}
func maxInt64(left, right int64) int64 {
if left > right {
return left
}
return right
}
// buildSQLFingerprint 把 SQL 归一化为指纹(替换字面量为 ?、去注释、小写化关键字、sha256 前 16 字节)。
// 用于跨执行去重:同一 SQL 不同参数值视为同一指纹。
func buildSQLFingerprint(sql string) string {
normalized := normalizeSQLForFingerprint(sql)
if normalized == "" {
return ""
}
hash := sha256.Sum256([]byte(normalized))
return hex.EncodeToString(hash[:16])
}
// buildQueryHistoryConnectionFingerprint 在 saved-query 指纹不可用时,为 ID-only / DSN-only
// 连接提供只含哈希的安全回退logicalDB 用于隔离 Oracle schema 与自定义连接逻辑库。
func buildQueryHistoryConnectionFingerprint(config connection.ConnectionConfig, logicalDB string) (string, bool) {
runConfig := normalizeRunConfig(config, logicalDB)
logicalDB = strings.TrimSpace(logicalDB)
if logicalDB == "" {
logicalDB = strings.TrimSpace(runConfig.Database)
}
// 已保存连接的 ID 是最稳定的用户身份修改主机、SSL、SSH 或凭据时,
// 慢查询历史仍应跟随同一连接。logicalDB 继续隔离数据库/schema。
if id := strings.TrimSpace(runConfig.ID); id != "" {
return hashQueryHistoryFingerprint("id", strings.ToLower(strings.TrimSpace(runConfig.Type)), id, logicalDB), true
}
usesOpaqueEndpoint := queryHistoryUsesOpaqueEndpoint(runConfig)
if baseFingerprint, ok := buildConnectionFingerprint(runConfig); !usesOpaqueEndpoint && ok && baseFingerprint != "" {
if logicalDB == "" || strings.TrimSpace(runConfig.Database) == logicalDB {
return baseFingerprint, true
}
return hashQueryHistoryFingerprint("base", baseFingerprint, logicalDB), true
}
hosts := strings.Join(normalizeFingerprintHosts(runConfig.Hosts), ",")
identityParts := []string{
strings.ToLower(strings.TrimSpace(runConfig.Type)),
strings.ToLower(strings.TrimSpace(runConfig.Driver)),
strings.TrimSpace(runConfig.Host),
fmt.Sprintf("%d", runConfig.Port),
hosts,
strings.TrimSpace(runConfig.User),
strings.TrimSpace(runConfig.Database),
logicalDB,
strings.TrimSpace(runConfig.DSN),
strings.TrimSpace(runConfig.URI),
strings.TrimSpace(runConfig.ConnectionParams),
}
hasConnectionIdentity := false
for index, part := range identityParts {
if index == 0 || index == 3 || index == 6 || index == 7 {
continue
}
if part != "" {
hasConnectionIdentity = true
break
}
}
if !hasConnectionIdentity && strings.TrimSpace(runConfig.Database) == "" && logicalDB == "" {
return "", false
}
return hashQueryHistoryFingerprint("fallback", identityParts...), true
}
func queryHistoryUsesOpaqueEndpoint(config connection.ConnectionConfig) bool {
return strings.TrimSpace(config.Host) == "" && len(normalizeFingerprintHosts(config.Hosts)) == 0 &&
(strings.TrimSpace(config.DSN) != "" || strings.TrimSpace(config.URI) != "")
}
func hashQueryHistoryFingerprint(kind string, parts ...string) string {
material := queryHistoryFingerprintVersion + "\x00" + kind + "\x00" + strings.Join(parts, "\x00")
hash := sha256.Sum256([]byte(material))
return queryHistoryFingerprintVersion + ":" + hex.EncodeToString(hash[:])
}
// normalizeSQLForFingerprint 简化 SQL 用于指纹计算。
// 策略:
// - 去掉前后空白
// - 替换字符串字面量 'xxx' 为 ?
// - 替换数字字面量为 ?
// - 去除行注释与块注释,并规范化无意义空白
// - 小写化未加引号的 SQL 文本,保留可能大小写敏感的引用标识符
func normalizeSQLForFingerprint(sql string) string {
text := strings.TrimSpace(sql)
if text == "" {
return ""
}
var builder strings.Builder
builder.Grow(len(text))
pendingSpace := false
appendPendingSpace := func(next byte) {
if !pendingSpace || builder.Len() == 0 {
pendingSpace = false
return
}
current := builder.String()
previous := current[len(current)-1]
if !isFingerprintPunctuation(previous) && !isFingerprintPunctuation(next) {
builder.WriteByte(' ')
}
pendingSpace = false
}
for i := 0; i < len(text); {
ch := text[i]
if ch == '$' {
if end, ok := skipPostgresDollarQuotedLiteral(text, i); ok {
appendPendingSpace('?')
builder.WriteByte('?')
i = end
continue
}
}
switch {
case isSQLWhitespace(ch):
pendingSpace = true
i++
case ch == '-' && i+1 < len(text) && text[i+1] == '-':
pendingSpace = true
i += 2
for i < len(text) && text[i] != '\n' && text[i] != '\r' {
i++
}
case ch == '#' && (i == 0 || isSQLWhitespace(text[i-1])):
pendingSpace = true
i++
for i < len(text) && text[i] != '\n' && text[i] != '\r' {
i++
}
case ch == '/' && i+1 < len(text) && text[i+1] == '*':
pendingSpace = true
i += 2
for i+1 < len(text) && !(text[i] == '*' && text[i+1] == '/') {
i++
}
if i+1 < len(text) {
i += 2
} else {
i = len(text)
}
case ch == '\'':
appendPendingSpace('?')
builder.WriteByte('?')
i = skipSQLQuotedLiteral(text, i, '\'')
case ch == '"' || ch == '`':
appendPendingSpace(ch)
start := i
i = skipSQLQuotedIdentifier(text, i, ch)
builder.WriteString(text[start:i])
case ch == '[':
appendPendingSpace(ch)
start := i
i = skipSQLBracketIdentifier(text, i)
builder.WriteString(text[start:i])
case isSQLNumberStart(text, i):
appendPendingSpace('?')
builder.WriteByte('?')
i = skipSQLNumber(text, i)
default:
appendPendingSpace(ch)
if ch >= 'A' && ch <= 'Z' {
ch = ch + ('a' - 'A')
}
builder.WriteByte(ch)
i++
}
}
return strings.TrimSpace(builder.String())
}
func isSQLWhitespace(ch byte) bool {
switch ch {
case ' ', '\t', '\n', '\r', '\f', '\v':
return true
default:
return false
}
}
func isFingerprintPunctuation(ch byte) bool {
return strings.ContainsRune("(),;=<>!+-*/%|&^~.", rune(ch))
}
func skipSQLQuotedLiteral(text string, start int, quote byte) int {
for i := start + 1; i < len(text); i++ {
if text[i] == '\\' && i+1 < len(text) {
i++
continue
}
if text[i] != quote {
continue
}
if i+1 < len(text) && text[i+1] == quote {
i++
continue
}
return i + 1
}
return len(text)
}
func skipSQLQuotedIdentifier(text string, start int, quote byte) int {
for i := start + 1; i < len(text); i++ {
if text[i] != quote {
continue
}
if i+1 < len(text) && text[i+1] == quote {
i++
continue
}
return i + 1
}
return len(text)
}
func skipSQLBracketIdentifier(text string, start int) int {
for i := start + 1; i < len(text); i++ {
if text[i] != ']' {
continue
}
if i+1 < len(text) && text[i+1] == ']' {
i++
continue
}
return i + 1
}
return len(text)
}
func skipPostgresDollarQuotedLiteral(text string, start int) (int, bool) {
if start < 0 || start >= len(text) || text[start] != '$' {
return start, false
}
tagEnd := start + 1
if tagEnd < len(text) && text[tagEnd] != '$' {
first := text[tagEnd]
if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') {
return start, false
}
for tagEnd < len(text) && text[tagEnd] != '$' {
if !isSQLIdentifierByte(text[tagEnd]) {
return start, false
}
tagEnd++
}
}
if tagEnd >= len(text) || text[tagEnd] != '$' {
return start, false
}
delimiter := text[start : tagEnd+1]
contentStart := tagEnd + 1
closingOffset := strings.Index(text[contentStart:], delimiter)
if closingOffset < 0 {
return len(text), true
}
return contentStart + closingOffset + len(delimiter), true
}
func isSQLIdentifierByte(ch byte) bool {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '$'
}
func isSQLNumberStart(text string, index int) bool {
if index < 0 || index >= len(text) {
return false
}
ch := text[index]
if ch < '0' || ch > '9' {
return false
}
return index == 0 || !isSQLIdentifierByte(text[index-1])
}
func skipSQLNumber(text string, start int) int {
i := start
if i+1 < len(text) && text[i] == '0' && (text[i+1] == 'x' || text[i+1] == 'X') {
i += 2
for i < len(text) && ((text[i] >= '0' && text[i] <= '9') || (text[i] >= 'a' && text[i] <= 'f') || (text[i] >= 'A' && text[i] <= 'F')) {
i++
}
return i
}
for i < len(text) && text[i] >= '0' && text[i] <= '9' {
i++
}
if i < len(text) && text[i] == '.' {
i++
for i < len(text) && text[i] >= '0' && text[i] <= '9' {
i++
}
}
if i < len(text) && (text[i] == 'e' || text[i] == 'E') {
exponentStart := i
i++
if i < len(text) && (text[i] == '+' || text[i] == '-') {
i++
}
digitStart := i
for i < len(text) && text[i] >= '0' && text[i] <= '9' {
i++
}
if digitStart == i {
return exponentStart
}
}
return i
}
// buildQueryPreview 截断 SQL 为人类可读预览。
func buildQueryPreview(sql string) string {
text := strings.TrimSpace(sql)
if text == "" {
return ""
}
// 把多行/制表符折叠为单空格(保留语义但节省存储)
text = strings.ReplaceAll(text, "\n", " ")
text = strings.ReplaceAll(text, "\r", " ")
text = strings.ReplaceAll(text, "\t", " ")
// 折叠连续空白
for strings.Contains(text, " ") {
text = strings.ReplaceAll(text, " ", " ")
}
runes := []rune(text)
if len(runes) <= queryHistoryPreviewRunes {
return text
}
return string(runes[:queryHistoryPreviewRunes-1]) + "…"
}
// buildQuerySQLText 保留可用于重新诊断的原始 SQL并限制单条记录体积。
func buildQuerySQLText(sql string) (string, bool) {
text := strings.TrimSpace(sql)
runes := []rune(text)
if len(runes) <= queryHistorySQLRunes {
return text, false
}
return string(runes[:queryHistorySQLRunes]), true
}
// sanitizeFingerprintForFilename 把指纹字符串安全化(只保留字母数字下划线)。
func sanitizeFingerprintForFilename(fp string) string {
var builder strings.Builder
builder.Grow(len(fp))
for _, ch := range fp {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' {
builder.WriteRune(ch)
}
}
result := builder.String()
if result == "" {
return "default"
}
return result
}
// buildQueryExecutionRecord 是埋点时的便利构造器,组装一条完整记录。
func buildQueryExecutionRecord(config connection.ConnectionConfig, logicalDB, dbType, sql string, durationMs int64, rowsRead, rowsReturned int64) connection.QueryExecutionRecord {
connFP, _ := buildQueryHistoryConnectionFingerprint(config, logicalDB)
sqlText, sqlTruncated := buildQuerySQLText(sql)
statementCount := 0
for _, statement := range splitSQLStatementsForDialect(dbType, sql) {
if strings.TrimSpace(trimLeadingSQLComments(statement)) != "" {
statementCount++
}
}
return connection.QueryExecutionRecord{
ID: fmt.Sprintf("qhr-%d", time.Now().UnixNano()),
ConnectionFP: connFP,
SQLFingerprint: buildSQLFingerprint(sql),
SQLPreview: buildQueryPreview(sql),
SQLText: sqlText,
SQLTruncated: sqlTruncated,
Diagnosable: explainSupportedDBTypes[normalizeExplainLexicalDBType(dbType)] && isSafeExplainQuery(dbType, sql),
StatementCount: statementCount,
DBType: dbType,
DurationMs: durationMs,
ExecutionCount: 1,
AvgDurationMs: float64(durationMs),
MaxDurationMs: durationMs,
RowsRead: rowsRead,
RowsReturned: rowsReturned,
ExecutedAt: time.Now(),
}
}