perf(db): configure database connection pools
Add configurable PostgreSQL and database/sql pool limits, validate invalid environment values, clamp idle connections, and cover the parsing/application logic with unit tests.
This commit is contained in:
@@ -210,6 +210,15 @@ DB_PASSWORD=postgres123!@#
|
||||
# 数据库名称
|
||||
DB_NAME=WeKnora
|
||||
|
||||
# PostgreSQL 全局最大连接数(Compose 内置 PostgreSQL 服务)
|
||||
POSTGRES_MAX_CONNECTIONS=300
|
||||
|
||||
# WeKnora database/sql 连接池
|
||||
DB_MAX_OPEN_CONNS=120
|
||||
DB_MAX_IDLE_CONNS=20
|
||||
DB_CONN_MAX_IDLE_TIME=5m
|
||||
DB_CONN_MAX_LIFETIME=10m
|
||||
|
||||
# 如果使用 redis 作为流处理后端,需要配置以下参数
|
||||
# Redis用户名,Redis 6.0+ ACL 功能支持(可选)
|
||||
# REDIS_USERNAME=
|
||||
|
||||
@@ -79,6 +79,10 @@ services:
|
||||
- DB_USER=${DB_USER:-}
|
||||
- DB_PASSWORD=${DB_PASSWORD:-}
|
||||
- DB_NAME=${DB_NAME:-}
|
||||
- DB_MAX_OPEN_CONNS=${DB_MAX_OPEN_CONNS:-120}
|
||||
- DB_MAX_IDLE_CONNS=${DB_MAX_IDLE_CONNS:-20}
|
||||
- DB_CONN_MAX_IDLE_TIME=${DB_CONN_MAX_IDLE_TIME:-5m}
|
||||
- DB_CONN_MAX_LIFETIME=${DB_CONN_MAX_LIFETIME:-10m}
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- WEKNORA_LANGUAGE=${WEKNORA_LANGUAGE:-zh-CN}
|
||||
# ========== Langfuse (optional observability) ==========
|
||||
@@ -291,6 +295,7 @@ services:
|
||||
postgres:
|
||||
image: paradedb/paradedb:v0.22.2-pg17
|
||||
container_name: WeKnora-postgres
|
||||
command: ["postgres", "-c", "max_connections=${POSTGRES_MAX_CONNECTIONS:-300}"]
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
|
||||
@@ -90,6 +90,62 @@ import (
|
||||
wgrpc "github.com/weaviate/weaviate-go-client/v5/weaviate/grpc"
|
||||
)
|
||||
|
||||
func nonNegativeEnvInt(name string, fallback int) int {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func nonNegativeEnvDuration(name string, fallback time.Duration) time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
if raw == "0" {
|
||||
return 0
|
||||
}
|
||||
|
||||
value, err := time.ParseDuration(raw)
|
||||
if err != nil || value < 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type databasePoolConfig struct {
|
||||
maxOpen int
|
||||
maxIdle int
|
||||
maxIdleTime time.Duration
|
||||
maxLifetime time.Duration
|
||||
}
|
||||
|
||||
func databasePoolConfigFromEnv() databasePoolConfig {
|
||||
cfg := databasePoolConfig{
|
||||
maxOpen: nonNegativeEnvInt("DB_MAX_OPEN_CONNS", 0),
|
||||
maxIdle: nonNegativeEnvInt("DB_MAX_IDLE_CONNS", 10),
|
||||
maxIdleTime: nonNegativeEnvDuration("DB_CONN_MAX_IDLE_TIME", 0),
|
||||
maxLifetime: nonNegativeEnvDuration("DB_CONN_MAX_LIFETIME", 10*time.Minute),
|
||||
}
|
||||
if cfg.maxOpen > 0 && cfg.maxIdle > cfg.maxOpen {
|
||||
cfg.maxIdle = cfg.maxOpen
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func applyDatabasePoolConfig(db *sql.DB, cfg databasePoolConfig) {
|
||||
db.SetMaxOpenConns(cfg.maxOpen)
|
||||
db.SetMaxIdleConns(cfg.maxIdle)
|
||||
db.SetConnMaxIdleTime(cfg.maxIdleTime)
|
||||
db.SetConnMaxLifetime(cfg.maxLifetime)
|
||||
}
|
||||
|
||||
// BuildContainer constructs the dependency injection container
|
||||
// Registers all components, services, repositories and handlers needed by the application
|
||||
// Creates a fully configured application container with proper dependency resolution
|
||||
@@ -608,10 +664,19 @@ func initDatabase(cfg *config.Config) (*gorm.DB, error) {
|
||||
// Limiting to a single open connection serialises all DB access and
|
||||
// prevents "database is locked" errors from concurrent goroutines.
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetConnMaxLifetime(10 * time.Minute)
|
||||
} else {
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
poolCfg := databasePoolConfigFromEnv()
|
||||
applyDatabasePoolConfig(sqlDB, poolCfg)
|
||||
logger.Infof(
|
||||
context.Background(),
|
||||
"Database pool configured: max_open=%d max_idle=%d max_idle_time=%s max_lifetime=%s",
|
||||
poolCfg.maxOpen,
|
||||
poolCfg.maxIdle,
|
||||
poolCfg.maxIdleTime,
|
||||
poolCfg.maxLifetime,
|
||||
)
|
||||
}
|
||||
sqlDB.SetConnMaxLifetime(time.Duration(10) * time.Minute)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNonNegativeEnvInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
fallback int
|
||||
want int
|
||||
}{
|
||||
{name: "uses fallback when unset", raw: "", fallback: 10, want: 10},
|
||||
{name: "accepts positive value", raw: "120", fallback: 10, want: 120},
|
||||
{name: "accepts zero", raw: "0", fallback: 10, want: 0},
|
||||
{name: "rejects negative value", raw: "-1", fallback: 10, want: 10},
|
||||
{name: "rejects invalid value", raw: "many", fallback: 10, want: 10},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("TEST_DB_POOL_INT", tt.raw)
|
||||
if got := nonNegativeEnvInt("TEST_DB_POOL_INT", tt.fallback); got != tt.want {
|
||||
t.Fatalf("nonNegativeEnvInt() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonNegativeEnvDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
fallback time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "uses fallback when unset", raw: "", fallback: 10 * time.Minute, want: 10 * time.Minute},
|
||||
{name: "accepts duration", raw: "5m", fallback: 10 * time.Minute, want: 5 * time.Minute},
|
||||
{name: "accepts zero", raw: "0", fallback: 10 * time.Minute, want: 0},
|
||||
{name: "rejects negative duration", raw: "-1m", fallback: 10 * time.Minute, want: 10 * time.Minute},
|
||||
{name: "rejects invalid duration", raw: "later", fallback: 10 * time.Minute, want: 10 * time.Minute},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("TEST_DB_POOL_DURATION", tt.raw)
|
||||
if got := nonNegativeEnvDuration("TEST_DB_POOL_DURATION", tt.fallback); got != tt.want {
|
||||
t.Fatalf("nonNegativeEnvDuration() = %s, want %s", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabasePoolConfigFromEnv(t *testing.T) {
|
||||
t.Setenv("DB_MAX_OPEN_CONNS", "120")
|
||||
t.Setenv("DB_MAX_IDLE_CONNS", "20")
|
||||
t.Setenv("DB_CONN_MAX_IDLE_TIME", "5m")
|
||||
t.Setenv("DB_CONN_MAX_LIFETIME", "10m")
|
||||
|
||||
got := databasePoolConfigFromEnv()
|
||||
if got.maxOpen != 120 {
|
||||
t.Fatalf("maxOpen = %d, want 120", got.maxOpen)
|
||||
}
|
||||
if got.maxIdle != 20 {
|
||||
t.Fatalf("maxIdle = %d, want 20", got.maxIdle)
|
||||
}
|
||||
if got.maxIdleTime != 5*time.Minute {
|
||||
t.Fatalf("maxIdleTime = %s, want 5m", got.maxIdleTime)
|
||||
}
|
||||
if got.maxLifetime != 10*time.Minute {
|
||||
t.Fatalf("maxLifetime = %s, want 10m", got.maxLifetime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabasePoolConfigClampsIdleToOpen(t *testing.T) {
|
||||
t.Setenv("DB_MAX_OPEN_CONNS", "5")
|
||||
t.Setenv("DB_MAX_IDLE_CONNS", "20")
|
||||
|
||||
got := databasePoolConfigFromEnv()
|
||||
if got.maxIdle != 5 {
|
||||
t.Fatalf("maxIdle = %d, want 5", got.maxIdle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDatabasePoolConfig(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
applyDatabasePoolConfig(db, databasePoolConfig{
|
||||
maxOpen: 120,
|
||||
maxIdle: 20,
|
||||
maxIdleTime: 5 * time.Minute,
|
||||
maxLifetime: 10 * time.Minute,
|
||||
})
|
||||
|
||||
if got := db.Stats().MaxOpenConnections; got != 120 {
|
||||
t.Fatalf("MaxOpenConnections = %d, want 120", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user