mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-23 06:53:52 +08:00
🐛 fix(connection): 优化多数据源连接数占用
- 测试连接改为隔离连接,成功后立即关闭并避免写入全局缓存 - 新增通用 SQL 连接池配置,限制网络型数据源空闲连接长期占用 - Redis 测试连接改为临时客户端并立即释放 - MySQL 连接数超限时释放同实例缓存连接并重试 - 补充连接释放、缓存重试和连接池参数回归测试
This commit is contained in:
@@ -348,6 +348,7 @@ func normalizeConnectionReleaseMatchConfig(config connection.ConnectionConfig) c
|
||||
normalized := normalizeCacheKeyConfig(config)
|
||||
normalized.Database = ""
|
||||
normalized.RedisDB = 0
|
||||
normalized.ConnectionParams = ""
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -358,6 +359,72 @@ func getConnectionReleaseMatchKey(config connection.ConnectionConfig) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
type cachedDatabaseCloseTarget struct {
|
||||
key string
|
||||
inst db.Database
|
||||
}
|
||||
|
||||
func (a *App) releaseCachedDatabaseConnectionsForConfig(config connection.ConnectionConfig) int {
|
||||
if a == nil {
|
||||
return 0
|
||||
}
|
||||
return a.releaseCachedDatabaseConnectionsByMatchKey(getConnectionReleaseMatchKey(config))
|
||||
}
|
||||
|
||||
func (a *App) releaseCachedDatabaseConnectionsByMatchKey(targetKey string) int {
|
||||
if a == nil || strings.TrimSpace(targetKey) == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
targets := make([]cachedDatabaseCloseTarget, 0)
|
||||
a.mu.Lock()
|
||||
for key, entry := range a.dbCache {
|
||||
entryConfig := entry.config
|
||||
if strings.TrimSpace(entryConfig.Type) == "" {
|
||||
continue
|
||||
}
|
||||
if getConnectionReleaseMatchKey(entryConfig) != targetKey {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, cachedDatabaseCloseTarget{key: key, inst: entry.inst})
|
||||
delete(a.dbCache, key)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
for _, target := range targets {
|
||||
if target.inst == nil {
|
||||
continue
|
||||
}
|
||||
if closeErr := target.inst.Close(); closeErr != nil {
|
||||
logger.Error(closeErr, "关闭缓存连接失败:缓存Key=%s", shortCacheKey(target.key))
|
||||
}
|
||||
}
|
||||
|
||||
return len(targets)
|
||||
}
|
||||
|
||||
func isMySQLMaxUserConnectionsError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(normalizeErrorMessage(err))
|
||||
return strings.Contains(message, "max_user_connections") ||
|
||||
(strings.Contains(message, "error 1226") && strings.Contains(message, "has exceeded"))
|
||||
}
|
||||
|
||||
func withMySQLMaxUserConnectionsHint(err error, released int) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isMySQLMaxUserConnectionsError(err) {
|
||||
return err
|
||||
}
|
||||
if released > 0 {
|
||||
return fmt.Errorf("%w;数据库账号连接数已达上限(max_user_connections),GoNavi 已释放同一连接实例的 %d 个缓存连接并重试;若仍失败,请关闭 Navicat/其他客户端连接或提高数据库用户 max_user_connections", err, released)
|
||||
}
|
||||
return fmt.Errorf("%w;数据库账号连接数已达上限(max_user_connections),GoNavi 未找到可释放的同实例缓存连接;请关闭 Navicat/其他客户端连接或提高数据库用户 max_user_connections", err)
|
||||
}
|
||||
|
||||
func shortCacheKey(cacheKey string) string {
|
||||
shortKey := cacheKey
|
||||
if len(shortKey) > 12 {
|
||||
@@ -638,11 +705,10 @@ func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, erro
|
||||
}
|
||||
|
||||
func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Database, error) {
|
||||
resolvedConfig, err := a.resolveConnectionSecrets(config)
|
||||
effectiveConfig, err := a.resolveEffectiveConnectionConfig(config)
|
||||
if err != nil {
|
||||
return nil, wrapConnectError(config, err)
|
||||
return nil, err
|
||||
}
|
||||
effectiveConfig := applyGlobalProxyToConnection(resolvedConfig)
|
||||
if supported, reason := driverRuntimeSupportStatusFunc(effectiveConfig.Type); !supported {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(effectiveConfig.Type))
|
||||
@@ -670,6 +736,14 @@ func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Datab
|
||||
return dbInst, nil
|
||||
}
|
||||
|
||||
func (a *App) resolveEffectiveConnectionConfig(config connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
resolvedConfig, err := a.resolveConnectionSecrets(config)
|
||||
if err != nil {
|
||||
return config, wrapConnectError(config, err)
|
||||
}
|
||||
return applyGlobalProxyToConnection(resolvedConfig), nil
|
||||
}
|
||||
|
||||
func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing bool) (db.Database, error) {
|
||||
resolvedConfig, err := a.resolveConnectionSecrets(config)
|
||||
if err != nil {
|
||||
@@ -771,9 +845,14 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
initialKey := key
|
||||
dbInst, connectedConfig, err := a.connectDatabaseWithStartupRetry(resolvedConfig)
|
||||
if err != nil {
|
||||
failedKey := getCacheKey(connectedConfig)
|
||||
a.recordConnectFailureByKey(failedKey, err)
|
||||
return nil, err
|
||||
retryInst, retryConfig, retryErr := a.retryConnectAfterMySQLMaxUserConnections(resolvedConfig, connectedConfig, err)
|
||||
if retryErr != nil {
|
||||
failedKey := getCacheKey(retryConfig)
|
||||
a.recordConnectFailureByKey(failedKey, retryErr)
|
||||
return nil, retryErr
|
||||
}
|
||||
dbInst = retryInst
|
||||
connectedConfig = retryConfig
|
||||
}
|
||||
a.clearConnectFailureByKey(initialKey)
|
||||
effectiveConfig = connectedConfig
|
||||
@@ -800,6 +879,28 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
return dbInst, nil
|
||||
}
|
||||
|
||||
func (a *App) retryConnectAfterMySQLMaxUserConnections(rawConfig connection.ConnectionConfig, failedConfig connection.ConnectionConfig, err error) (db.Database, connection.ConnectionConfig, error) {
|
||||
if !isMySQLMaxUserConnectionsError(err) {
|
||||
return nil, failedConfig, err
|
||||
}
|
||||
|
||||
released := a.releaseCachedDatabaseConnectionsForConfig(failedConfig)
|
||||
logger.Warnf("检测到 MySQL 用户连接数超限,已释放同实例缓存连接:%s 数量=%d", formatConnSummary(failedConfig), released)
|
||||
if released <= 0 {
|
||||
return nil, failedConfig, withMySQLMaxUserConnectionsHint(err, released)
|
||||
}
|
||||
|
||||
dbInst, connectedConfig, retryErr := a.connectDatabaseWithStartupRetry(rawConfig)
|
||||
if retryErr != nil {
|
||||
if isMySQLMaxUserConnectionsError(retryErr) {
|
||||
return nil, connectedConfig, withMySQLMaxUserConnectionsHint(retryErr, released)
|
||||
}
|
||||
return nil, connectedConfig, retryErr
|
||||
}
|
||||
logger.Infof("MySQL 用户连接数超限释放缓存后重连成功:%s 释放数量=%d", formatConnSummary(connectedConfig), released)
|
||||
return dbInst, connectedConfig, nil
|
||||
}
|
||||
|
||||
func (a *App) getCachedConnectFailureByKey(key string) (cachedConnectFailure, time.Duration, bool) {
|
||||
if a == nil || strings.TrimSpace(key) == "" {
|
||||
return cachedConnectFailure{}, 0, false
|
||||
|
||||
@@ -81,27 +81,7 @@ func (a *App) DBReleaseConnection(config connection.ConnectionConfig) connection
|
||||
logger.Error(wrapped, "DBReleaseConnection 解析连接密文失败:%s", formatConnSummary(config))
|
||||
return connection.QueryResult{Success: false, Message: wrapped.Error()}
|
||||
}
|
||||
targetKey := getConnectionReleaseMatchKey(applyGlobalProxyToConnection(resolvedConfig))
|
||||
closed := 0
|
||||
|
||||
a.mu.Lock()
|
||||
for key, entry := range a.dbCache {
|
||||
entryConfig := entry.config
|
||||
if strings.TrimSpace(entryConfig.Type) == "" {
|
||||
continue
|
||||
}
|
||||
if getConnectionReleaseMatchKey(entryConfig) != targetKey {
|
||||
continue
|
||||
}
|
||||
if entry.inst != nil {
|
||||
if closeErr := entry.inst.Close(); closeErr != nil {
|
||||
logger.Error(closeErr, "DBReleaseConnection 关闭缓存连接失败:缓存Key=%s", shortCacheKey(key))
|
||||
}
|
||||
}
|
||||
delete(a.dbCache, key)
|
||||
closed++
|
||||
}
|
||||
a.mu.Unlock()
|
||||
closed := a.releaseCachedDatabaseConnectionsForConfig(applyGlobalProxyToConnection(resolvedConfig))
|
||||
|
||||
logger.Infof("DBReleaseConnection 已释放数据库连接:%s 数量=%d", formatConnSummary(resolvedConfig), closed)
|
||||
return connection.QueryResult{Success: true, Message: "连接已释放", Data: map[string]int{"closed": closed}}
|
||||
@@ -115,16 +95,50 @@ func (a *App) TestConnection(config connection.ConnectionConfig) connection.Quer
|
||||
logger.Warnf("TestConnection 参数校验失败:耗时=%s %s 原因=%s", time.Since(started).Round(time.Millisecond), formatConnSummary(testConfig), err.Error())
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
_, err := a.getDatabaseForcePing(testConfig)
|
||||
dbInst, err := a.openDatabaseIsolated(testConfig)
|
||||
if err != nil {
|
||||
dbInst, err = a.retryIsolatedTestConnectionAfterMySQLMaxUserConnections(testConfig, err)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error(err, "TestConnection 连接测试失败:耗时=%s %s", time.Since(started).Round(time.Millisecond), formatConnSummary(testConfig))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if dbInst != nil {
|
||||
if closeErr := dbInst.Close(); closeErr != nil {
|
||||
logger.Error(closeErr, "TestConnection 释放临时连接失败:耗时=%s %s", time.Since(started).Round(time.Millisecond), formatConnSummary(testConfig))
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("连接成功但释放测试连接失败:%v", closeErr)}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("TestConnection 连接测试成功:耗时=%s %s", time.Since(started).Round(time.Millisecond), formatConnSummary(testConfig))
|
||||
return connection.QueryResult{Success: true, Message: "连接成功"}
|
||||
}
|
||||
|
||||
func (a *App) retryIsolatedTestConnectionAfterMySQLMaxUserConnections(config connection.ConnectionConfig, err error) (db.Database, error) {
|
||||
if !isMySQLMaxUserConnectionsError(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
effectiveConfig, resolveErr := a.resolveEffectiveConnectionConfig(config)
|
||||
if resolveErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
released := a.releaseCachedDatabaseConnectionsForConfig(effectiveConfig)
|
||||
logger.Warnf("测试连接检测到 MySQL 用户连接数超限,已释放同实例缓存连接:%s 数量=%d", formatConnSummary(effectiveConfig), released)
|
||||
if released <= 0 {
|
||||
return nil, withMySQLMaxUserConnectionsHint(err, released)
|
||||
}
|
||||
|
||||
dbInst, retryErr := a.openDatabaseIsolated(config)
|
||||
if retryErr != nil {
|
||||
if isMySQLMaxUserConnectionsError(retryErr) {
|
||||
return nil, withMySQLMaxUserConnectionsHint(retryErr, released)
|
||||
}
|
||||
return nil, retryErr
|
||||
}
|
||||
return dbInst, nil
|
||||
}
|
||||
|
||||
func (a *App) MongoDiscoverMembers(config connection.ConnectionConfig) connection.QueryResult {
|
||||
config.Type = "mongodb"
|
||||
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
)
|
||||
|
||||
type releaseRecordingDB struct {
|
||||
closed int
|
||||
closed int
|
||||
connect func(config connection.ConnectionConfig) error
|
||||
}
|
||||
|
||||
func (f *releaseRecordingDB) Connect(config connection.ConnectionConfig) error { return nil }
|
||||
func (f *releaseRecordingDB) Connect(config connection.ConnectionConfig) error {
|
||||
if f.connect != nil {
|
||||
return f.connect(config)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *releaseRecordingDB) Close() error {
|
||||
f.closed++
|
||||
return nil
|
||||
@@ -214,3 +222,114 @@ func TestDBReleaseConnectionClosesAllDatabaseCacheEntriesForSameInstance(t *test
|
||||
t.Fatalf("expected only unrelated cache entry to remain, got %d", len(app.dbCache))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestConnectionUsesIsolatedConnectionAndClosesIt(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
|
||||
proxySnapshot := currentGlobalProxyConfig()
|
||||
defer func() {
|
||||
newDatabaseFunc = originalNewDatabaseFunc
|
||||
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
|
||||
if _, err := setGlobalProxyConfig(proxySnapshot.Enabled, proxySnapshot.Proxy); err != nil {
|
||||
t.Fatalf("restore global proxy failed: %v", err)
|
||||
}
|
||||
}()
|
||||
if _, err := setGlobalProxyConfig(false, proxySnapshot.Proxy); err != nil {
|
||||
t.Fatalf("disable global proxy failed: %v", err)
|
||||
}
|
||||
|
||||
testDB := &releaseRecordingDB{}
|
||||
newDatabaseFunc = func(dbType string) (db.Database, error) {
|
||||
return testDB, nil
|
||||
}
|
||||
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
result := app.TestConnection(connection.ConnectionConfig{
|
||||
Type: "mysql",
|
||||
Host: "127.0.0.1",
|
||||
Port: 3306,
|
||||
User: "root",
|
||||
Database: "app",
|
||||
})
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("expected test connection success, got %s", result.Message)
|
||||
}
|
||||
if testDB.closed != 1 {
|
||||
t.Fatalf("expected isolated test connection to be closed once, got %d", testDB.closed)
|
||||
}
|
||||
if len(app.dbCache) != 0 {
|
||||
t.Fatalf("test connection must not write global db cache, got %d entries", len(app.dbCache))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDatabaseReleasesSameInstanceCacheAndRetriesOnMaxUserConnections(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
|
||||
proxySnapshot := currentGlobalProxyConfig()
|
||||
defer func() {
|
||||
newDatabaseFunc = originalNewDatabaseFunc
|
||||
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
|
||||
if _, err := setGlobalProxyConfig(proxySnapshot.Enabled, proxySnapshot.Proxy); err != nil {
|
||||
t.Fatalf("restore global proxy failed: %v", err)
|
||||
}
|
||||
}()
|
||||
if _, err := setGlobalProxyConfig(false, proxySnapshot.Proxy); err != nil {
|
||||
t.Fatalf("disable global proxy failed: %v", err)
|
||||
}
|
||||
|
||||
connectCalls := 0
|
||||
newDatabaseFunc = func(dbType string) (db.Database, error) {
|
||||
return &releaseRecordingDB{
|
||||
connect: func(config connection.ConnectionConfig) error {
|
||||
connectCalls++
|
||||
if connectCalls == 1 {
|
||||
return errors.New("Error 1226 (42000): User 'yangguofeng' has exceeded the 'max_user_connections' resource (current value: 5)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
mainConfig := connection.ConnectionConfig{Type: "mysql", Host: "db.example.com", Port: 3306, User: "yangguofeng", Database: "main"}
|
||||
analyticsConfig := mainConfig
|
||||
analyticsConfig.Database = "analytics"
|
||||
analyticsConfig.ConnectionParams = "charset=utf8mb4"
|
||||
otherConfig := mainConfig
|
||||
otherConfig.User = "other"
|
||||
|
||||
mainDB := &releaseRecordingDB{}
|
||||
analyticsDB := &releaseRecordingDB{}
|
||||
otherDB := &releaseRecordingDB{}
|
||||
app.dbCache[getCacheKey(mainConfig)] = cachedDatabase{inst: mainDB, config: normalizeCacheKeyConfig(mainConfig)}
|
||||
app.dbCache[getCacheKey(analyticsConfig)] = cachedDatabase{inst: analyticsDB, config: normalizeCacheKeyConfig(analyticsConfig)}
|
||||
app.dbCache[getCacheKey(otherConfig)] = cachedDatabase{inst: otherDB, config: normalizeCacheKeyConfig(otherConfig)}
|
||||
|
||||
targetConfig := mainConfig
|
||||
targetConfig.Database = "target"
|
||||
targetConfig.ConnectionParams = "timeout=10"
|
||||
|
||||
inst, err := app.getDatabase(targetConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("expected retry after releasing cached same-instance connections, got %v", err)
|
||||
}
|
||||
if inst == nil {
|
||||
t.Fatal("expected database instance")
|
||||
}
|
||||
if connectCalls != 2 {
|
||||
t.Fatalf("expected one failed connect and one retry, got %d calls", connectCalls)
|
||||
}
|
||||
if mainDB.closed != 1 || analyticsDB.closed != 1 {
|
||||
t.Fatalf("expected same-instance cached connections closed, got main=%d analytics=%d", mainDB.closed, analyticsDB.closed)
|
||||
}
|
||||
if otherDB.closed != 0 {
|
||||
t.Fatalf("expected other user cache to remain open, got closed=%d", otherDB.closed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,31 @@ func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisCli
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (a *App) openRedisClientIsolated(config connection.ConnectionConfig) (redis.RedisClient, error) {
|
||||
resolvedConfig, err := a.resolveConnectionSecrets(config)
|
||||
if err != nil {
|
||||
wrapped := wrapConnectError(config, err)
|
||||
logger.Error(wrapped, "Redis 密文解析失败:%s", formatRedisConnSummary(config))
|
||||
return nil, wrapped
|
||||
}
|
||||
|
||||
effectiveConfig := applyGlobalProxyToConnection(resolvedConfig)
|
||||
connectConfig, proxyErr := resolveDialConfigWithProxyFunc(effectiveConfig)
|
||||
if proxyErr != nil {
|
||||
wrapped := wrapConnectError(effectiveConfig, proxyErr)
|
||||
logger.Error(wrapped, "Redis 代理准备失败:%s", formatRedisConnSummary(effectiveConfig))
|
||||
return nil, wrapped
|
||||
}
|
||||
|
||||
client, connectedConfig, connectErr := connectRedisClientWithLegacyRootFallback(connectConfig)
|
||||
if connectErr != nil {
|
||||
wrapped := wrapConnectError(connectedConfig, connectErr)
|
||||
logger.Error(wrapped, "Redis 临时连接失败:%s", formatRedisConnSummary(connectedConfig))
|
||||
return nil, wrapped
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func connectRedisClientWithLegacyRootFallback(config connection.ConnectionConfig) (redis.RedisClient, connection.ConnectionConfig, error) {
|
||||
client := newRedisClientFunc()
|
||||
if err := client.Connect(config); err == nil {
|
||||
@@ -237,7 +262,20 @@ func (a *App) RedisConnect(config connection.ConnectionConfig) connection.QueryR
|
||||
|
||||
// RedisTestConnection tests a Redis connection (alias for RedisConnect)
|
||||
func (a *App) RedisTestConnection(config connection.ConnectionConfig) connection.QueryResult {
|
||||
return a.RedisConnect(config)
|
||||
config.Type = "redis"
|
||||
client, err := a.openRedisClientIsolated(config)
|
||||
if err != nil {
|
||||
logger.Error(err, "RedisTestConnection 连接失败:%s", formatRedisConnSummary(config))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if client != nil {
|
||||
if closeErr := client.Close(); closeErr != nil {
|
||||
logger.Error(closeErr, "RedisTestConnection 释放临时连接失败:%s", formatRedisConnSummary(config))
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("连接成功但释放测试连接失败:%v", closeErr)}
|
||||
}
|
||||
}
|
||||
logger.Infof("RedisTestConnection 连接成功:%s", formatRedisConnSummary(config))
|
||||
return connection.QueryResult{Success: true, Message: "连接成功"}
|
||||
}
|
||||
|
||||
// RedisScanKeys scans keys matching a pattern
|
||||
|
||||
@@ -12,6 +12,7 @@ type capturingRedisClient struct {
|
||||
connectConfig connection.ConnectionConfig
|
||||
deletedHashKey string
|
||||
deletedHashFields []string
|
||||
closed int
|
||||
}
|
||||
|
||||
func (c *capturingRedisClient) Connect(config connection.ConnectionConfig) error {
|
||||
@@ -19,7 +20,10 @@ func (c *capturingRedisClient) Connect(config connection.ConnectionConfig) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *capturingRedisClient) Close() error { return nil }
|
||||
func (c *capturingRedisClient) Close() error {
|
||||
c.closed++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *capturingRedisClient) Ping() error { return nil }
|
||||
|
||||
@@ -119,6 +123,49 @@ func (c *scriptedRedisClient) Connect(config connection.ConnectionConfig) error
|
||||
return c.connectErr
|
||||
}
|
||||
|
||||
func TestRedisTestConnectionUsesIsolatedClientAndClosesIt(t *testing.T) {
|
||||
originalNewRedisClientFunc := newRedisClientFunc
|
||||
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
|
||||
proxySnapshot := currentGlobalProxyConfig()
|
||||
defer func() {
|
||||
newRedisClientFunc = originalNewRedisClientFunc
|
||||
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
|
||||
if _, err := setGlobalProxyConfig(proxySnapshot.Enabled, proxySnapshot.Proxy); err != nil {
|
||||
t.Fatalf("restore global proxy failed: %v", err)
|
||||
}
|
||||
CloseAllRedisClients()
|
||||
}()
|
||||
CloseAllRedisClients()
|
||||
if _, err := setGlobalProxyConfig(false, proxySnapshot.Proxy); err != nil {
|
||||
t.Fatalf("disable global proxy failed: %v", err)
|
||||
}
|
||||
|
||||
client := &capturingRedisClient{}
|
||||
newRedisClientFunc = func() redislib.RedisClient {
|
||||
return client
|
||||
}
|
||||
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
result := app.RedisTestConnection(connection.ConnectionConfig{
|
||||
Type: "redis",
|
||||
Host: "127.0.0.1",
|
||||
Port: 6379,
|
||||
})
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("expected redis test connection success, got %s", result.Message)
|
||||
}
|
||||
if client.closed != 1 {
|
||||
t.Fatalf("expected isolated redis test client to be closed once, got %d", client.closed)
|
||||
}
|
||||
if len(redisCache) != 0 {
|
||||
t.Fatalf("redis test connection must not write global redis cache, got %d entries", len(redisCache))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisConnectResolvesSavedSecretsByConnectionID(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
||||
@@ -643,6 +643,7 @@ func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error {
|
||||
break
|
||||
}
|
||||
c.conn = clickhouse.OpenDB(opts)
|
||||
configureSQLConnectionPool(c.conn, "clickhouse")
|
||||
if err := c.Ping(); err != nil {
|
||||
lastProtocolErr = err
|
||||
failureMessage := clickHouseAttemptFailureMessage(protocol, err)
|
||||
|
||||
@@ -34,10 +34,13 @@ func (c *CustomDB) Connect(config connection.ConnectionConfig) error {
|
||||
if err != nil {
|
||||
return formatCustomDriverOpenError(driver, err)
|
||||
}
|
||||
configureSQLConnectionPool(db, driver)
|
||||
c.conn = db
|
||||
c.driver = driver
|
||||
c.pingTimeout = getConnectTimeout(config)
|
||||
if err := c.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
c.conn = nil
|
||||
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -110,6 +110,7 @@ func (d *DamengDB) Connect(config connection.ConnectionConfig) error {
|
||||
failures = append(failures, fmt.Sprintf("第%d次连接打开失败: %v", idx+1, err))
|
||||
continue
|
||||
}
|
||||
configureSQLConnectionPool(db, "dameng")
|
||||
d.conn = db
|
||||
d.pingTimeout = getConnectTimeout(attempt)
|
||||
if err := d.Ping(); err != nil {
|
||||
|
||||
@@ -187,6 +187,7 @@ func (d *DirosDB) Connect(config connection.ConnectionConfig) error {
|
||||
errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败: %v", address, err))
|
||||
continue
|
||||
}
|
||||
configureSQLConnectionPool(db, "diros")
|
||||
|
||||
timeout := getConnectTimeout(candidateConfig)
|
||||
ctx, cancel := utils.ContextWithTimeout(timeout)
|
||||
|
||||
@@ -179,6 +179,7 @@ func (g *GaussDB) Connect(config connection.ConnectionConfig) error {
|
||||
failures = append(failures, fmt.Sprintf("%s 数据库=%s 打开连接失败: %v", sslLabel, dbName, err))
|
||||
continue
|
||||
}
|
||||
configureSQLConnectionPool(dbConn, "gaussdb")
|
||||
g.conn = dbConn
|
||||
|
||||
if err := g.Ping(); err != nil {
|
||||
@@ -233,6 +234,7 @@ func (g *GaussDB) ensureSearchPath(baseDSN string) {
|
||||
|
||||
newDB, err := sql.Open("gaussdb", newDSN)
|
||||
if err == nil {
|
||||
configureSQLConnectionPool(newDB, "gaussdb")
|
||||
newDB.SetConnMaxLifetime(5 * time.Minute)
|
||||
oldConn := g.conn
|
||||
g.conn = newDB
|
||||
|
||||
@@ -103,6 +103,7 @@ func (h *HighGoDB) Connect(config connection.ConnectionConfig) error {
|
||||
failures = append(failures, fmt.Sprintf("第%d次连接打开失败: %v", idx+1, err))
|
||||
continue
|
||||
}
|
||||
configureSQLConnectionPool(db, "highgo")
|
||||
h.conn = db
|
||||
h.pingTimeout = getConnectTimeout(attempt)
|
||||
if err := h.Ping(); err != nil {
|
||||
|
||||
@@ -141,9 +141,12 @@ func (i *IrisDB) Connect(config connection.ConnectionConfig) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||
}
|
||||
configureSQLConnectionPool(db, "iris")
|
||||
i.conn = db
|
||||
i.pingTimeout = getConnectTimeout(runConfig)
|
||||
if err := i.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
i.conn = nil
|
||||
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||
}
|
||||
cleanupOnFailure = false
|
||||
|
||||
@@ -157,6 +157,7 @@ func (k *KingbaseDB) Connect(config connection.ConnectionConfig) error {
|
||||
failures = append(failures, fmt.Sprintf("第%d次连接打开失败: %v", idx+1, err))
|
||||
continue
|
||||
}
|
||||
configureSQLConnectionPool(db, "kingbase")
|
||||
k.conn = db
|
||||
k.pingTimeout = getConnectTimeout(attempt)
|
||||
if err := k.Ping(); err != nil {
|
||||
@@ -175,8 +176,9 @@ func (k *KingbaseDB) Connect(config connection.ConnectionConfig) error {
|
||||
// 将 search_path 参数拼入 DSN
|
||||
finalDSN := dsn + " search_path=" + quoteConnValue(searchPathStr)
|
||||
if finalDB, err := sql.Open("kingbase", finalDSN); err == nil {
|
||||
k.pingTimeout = getConnectTimeout(attempt)
|
||||
configureSQLConnectionPool(finalDB, "kingbase")
|
||||
finalDB.SetConnMaxLifetime(5 * time.Minute)
|
||||
k.pingTimeout = getConnectTimeout(attempt)
|
||||
|
||||
// 临时将 k.conn 指向 finalDB 来做 ping 测试
|
||||
oldConn := k.conn
|
||||
|
||||
@@ -49,10 +49,13 @@ func (m *MariaDB) Connect(config connection.ConnectionConfig) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||
}
|
||||
configureSQLConnectionPool(db, "mariadb")
|
||||
m.conn = db
|
||||
m.pingTimeout = getConnectTimeout(config)
|
||||
|
||||
if err := m.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
m.conn = nil
|
||||
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -45,6 +45,21 @@ func parseMySQLDriverCharsetsForTest(t *testing.T, dsn string) []string {
|
||||
return charsets
|
||||
}
|
||||
|
||||
func TestConfigureSQLConnectionPoolCapsOpenConnections(t *testing.T) {
|
||||
dbConn, err := sql.Open("mysql", "root@tcp(127.0.0.1:1)/test")
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open failed: %v", err)
|
||||
}
|
||||
defer dbConn.Close()
|
||||
|
||||
configureSQLConnectionPool(dbConn, "mysql")
|
||||
|
||||
stats := dbConn.Stats()
|
||||
if stats.MaxOpenConnections != defaultSQLMaxOpenConns {
|
||||
t.Fatalf("expected max open connections %d, got %d", defaultSQLMaxOpenConns, stats.MaxOpenConnections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLDSN_MergesConnectionParamsWithDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -847,6 +847,7 @@ func (m *MySQLDB) Connect(config connection.ConnectionConfig) error {
|
||||
}
|
||||
continue
|
||||
}
|
||||
configureSQLConnectionPool(db, candidateConfig.Type)
|
||||
|
||||
timeout := getConnectTimeout(candidateConfig)
|
||||
ctx, cancel := utils.ContextWithTimeout(timeout)
|
||||
|
||||
@@ -621,6 +621,7 @@ func (o *OceanBaseDB) connectOracleViaOBClient(config connection.ConnectionConfi
|
||||
errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败:%v", address, err))
|
||||
continue
|
||||
}
|
||||
configureSQLConnectionPool(db, "oceanbase")
|
||||
|
||||
timeout := getConnectTimeout(candidateConfig)
|
||||
ctx, cancel := utils.ContextWithTimeout(timeout)
|
||||
@@ -741,6 +742,7 @@ func (o *OceanBaseDB) Connect(config connection.ConnectionConfig) error {
|
||||
errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败:%v", address, err))
|
||||
continue
|
||||
}
|
||||
configureSQLConnectionPool(db, "oceanbase")
|
||||
|
||||
timeout := getConnectTimeout(candidateConfig)
|
||||
ctx, cancel := utils.ContextWithTimeout(timeout)
|
||||
|
||||
@@ -161,6 +161,7 @@ func (o *OracleDB) Connect(config connection.ConnectionConfig) error {
|
||||
failures = append(failures, fmt.Sprintf("第%d次连接打开失败: %v", idx+1, err))
|
||||
continue
|
||||
}
|
||||
configureSQLConnectionPool(db, "oracle")
|
||||
o.conn = db
|
||||
o.pingTimeout = getConnectTimeout(attempt)
|
||||
if err := o.Ping(); err != nil {
|
||||
|
||||
@@ -159,6 +159,7 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
|
||||
failures = append(failures, fmt.Sprintf("%s 数据库=%s 打开连接失败: %v", sslLabel, dbName, err))
|
||||
continue
|
||||
}
|
||||
configureSQLConnectionPool(dbConn, "postgres")
|
||||
p.conn = dbConn
|
||||
|
||||
// Force verification
|
||||
@@ -604,6 +605,7 @@ func (p *PostgresDB) ensureSearchPath(baseDSN string) {
|
||||
|
||||
newDB, err := sql.Open("postgres", newDSN)
|
||||
if err == nil {
|
||||
configureSQLConnectionPool(newDB, "postgres")
|
||||
newDB.SetConnMaxLifetime(5 * time.Minute)
|
||||
oldConn := p.conn
|
||||
p.conn = newDB
|
||||
|
||||
27
internal/db/sql_pool.go
Normal file
27
internal/db/sql_pool.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSQLMaxOpenConns = 4
|
||||
defaultSQLConnMaxLifetime = 30 * time.Minute
|
||||
defaultSQLConnMaxIdleTime = 30 * time.Second
|
||||
)
|
||||
|
||||
func configureSQLConnectionPool(db *sql.DB, dbType string) {
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
||||
case "sqlite", "duckdb":
|
||||
return
|
||||
}
|
||||
db.SetMaxOpenConns(defaultSQLMaxOpenConns)
|
||||
db.SetMaxIdleConns(0)
|
||||
db.SetConnMaxIdleTime(defaultSQLConnMaxIdleTime)
|
||||
db.SetConnMaxLifetime(defaultSQLConnMaxLifetime)
|
||||
}
|
||||
@@ -176,10 +176,13 @@ func (s *SqlServerDB) Connect(config connection.ConnectionConfig) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||
}
|
||||
configureSQLConnectionPool(db, "sqlserver")
|
||||
s.conn = db
|
||||
s.pingTimeout = getConnectTimeout(config)
|
||||
|
||||
if err := s.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
s.conn = nil
|
||||
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -270,6 +270,7 @@ func (s *StarRocksDB) Connect(config connection.ConnectionConfig) error {
|
||||
errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败: %v", address, err))
|
||||
continue
|
||||
}
|
||||
configureSQLConnectionPool(db, "starrocks")
|
||||
|
||||
timeout := getConnectTimeout(candidateConfig)
|
||||
ctx, cancel := utils.ContextWithTimeout(timeout)
|
||||
|
||||
@@ -96,6 +96,7 @@ func (t *TDengineDB) Connect(config connection.ConnectionConfig) error {
|
||||
failures = append(failures, fmt.Sprintf("第%d次连接打开失败: %v", idx+1, err))
|
||||
continue
|
||||
}
|
||||
configureSQLConnectionPool(db, "tdengine")
|
||||
t.conn = db
|
||||
t.pingTimeout = getConnectTimeout(attempt)
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ func (v *VastbaseDB) Connect(config connection.ConnectionConfig) error {
|
||||
failures = append(failures, fmt.Sprintf("第%d次连接打开失败: %v", idx+1, err))
|
||||
continue
|
||||
}
|
||||
configureSQLConnectionPool(db, "vastbase")
|
||||
v.conn = db
|
||||
v.pingTimeout = getConnectTimeout(attempt)
|
||||
if err := v.Ping(); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user