DB optimization: pool, golang-migrate, consolidate to single Postgres

- Fix DB_NAME=db_yal -> mydb in api_yal .env
- Add connection pool (MaxOpenConns 25, MaxIdleConns 10, ConnMaxLifetime 30m)
- Replace GORM AutoMigrate with golang-migrate in api_yal and api_bb
- Create embedded SQL migrations for both APIs
- Add DB_SCHEMA support to api_bb config
- Consolidate to single Postgres: db_bb -> schema 'bb' on db container
- Remove db_bb service, bb-network, db_bb volume from compose
- Remove api_tp targets from Makefile
- Clean up old migrate.go
This commit is contained in:
valitovgaziz
2026-06-12 10:47:41 +05:00
parent ec83b97c25
commit b0350abfbe
18 changed files with 610 additions and 206 deletions
+5
View File
@@ -6,6 +6,7 @@ require (
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/golang-migrate/migrate/v4 v4.18.2
golang.org/x/crypto v0.43.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.0
@@ -15,8 +16,12 @@ require (
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/stretchr/testify v1.11.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/sys v0.37.0 // indirect
)
+2 -6
View File
@@ -32,7 +32,8 @@ func (a *App) Initialize() error {
// Инициализация базы данных
dbConfig := &database.Config{
URL: a.cfg.DatabaseURL,
URL: a.cfg.DatabaseURL,
Schema: a.cfg.DBSchema,
}
a.db = database.NewDatabase(dbConfig)
@@ -46,11 +47,6 @@ func (a *App) Initialize() error {
return err
}
// Выполнение миграций
if err := a.db.Migrate(); err != nil {
return err
}
// Настройка роутера
router := routes.SetupRouter(a.db.DB, a.cfg)
@@ -11,6 +11,7 @@ import (
type Config struct {
Port string
DatabaseURL string
DBSchema string
StaticURL string `env:"STATIC_URL" envDefault:"http://localhost:8080"`
JWTSecret string `env:"JWT_SECRET,required"`
@@ -34,6 +35,7 @@ func Load() *Config {
return &Config{
Port: port,
DatabaseURL: databaseURL,
DBSchema: getEnv("DB_SCHEMA", "public"),
JWTSecret: jwtSecret,
}
}
+60 -16
View File
@@ -1,9 +1,15 @@
package database
import (
"api_bb/migrations"
"database/sql"
"fmt"
"strings"
"time"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
"go.uber.org/zap"
"gorm.io/driver/postgres"
"gorm.io/gorm"
@@ -17,26 +23,34 @@ type Database struct {
}
type Config struct {
URL string
URL string
Schema string
}
func NewDatabase(cfg *Config) *Database {
if cfg.Schema == "" {
cfg.Schema = "public"
}
return &Database{
cfg: cfg,
}
}
// Connect устанавливает соединение с базой данных
func (d *Database) Connect() error {
zapLogger := logger.Get()
// Логирование попытки подключения к БД
zapLogger.Info("attempting to connect to database",
zap.String("host", ExtractHostFromDSN(d.cfg.URL)),
zap.String("database", ExtractDBNameFromDSN(d.cfg.URL)),
zap.String("schema", d.cfg.Schema),
)
db, err := gorm.Open(postgres.Open(d.cfg.URL), &gorm.Config{})
dsn := d.cfg.URL
if d.cfg.Schema != "public" {
dsn = dsn + fmt.Sprintf(" search_path=%s", d.cfg.Schema)
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
zapLogger.Error("failed to connect to database",
zap.Error(err),
@@ -47,7 +61,21 @@ func (d *Database) Connect() error {
d.DB = db
// Логирование успешного подключения к БД
zapLogger.Info("Configure connection pool")
sqlDB, err := d.DB.DB()
if err != nil {
return fmt.Errorf("failed to get underlying sql.DB: %w", err)
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
zapLogger.Info("Run database migrations")
if err := d.runMigrations(sqlDB); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
zapLogger.Info("Migrations completed successfully")
zapLogger.Info("successfully connected to database",
zap.String("host", ExtractHostFromDSN(d.cfg.URL)),
zap.String("database", ExtractDBNameFromDSN(d.cfg.URL)),
@@ -56,7 +84,32 @@ func (d *Database) Connect() error {
return nil
}
// Ping проверяет соединение с базой данных
func (d *Database) runMigrations(sqlDB *sql.DB) error {
zapLogger := logger.Get()
source, err := iofs.New(migrations.FS, ".")
if err != nil {
return fmt.Errorf("failed to create migration source: %w", err)
}
driver, err := postgres.WithInstance(sqlDB, &postgres.Config{})
if err != nil {
return fmt.Errorf("failed to create postgres driver: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "postgres", driver)
if err != nil {
return fmt.Errorf("failed to create migrate instance: %w", err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
zapLogger.Error("Migration error", zap.Error(err))
return fmt.Errorf("failed to apply migrations: %w", err)
}
return nil
}
func (d *Database) Ping() error {
zapLogger := logger.Get()
@@ -75,7 +128,6 @@ func (d *Database) Ping() error {
return nil
}
// Close закрывает соединение с базой данных
func (d *Database) Close() error {
zapLogger := logger.Get()
@@ -99,11 +151,7 @@ func (d *Database) Close() error {
return nil
}
// Вспомогательные функции для работы с DSN
// ExtractHostFromDSN извлекает хост из DSN строки
func ExtractHostFromDSN(dsn string) string {
// Простая реализация для PostgreSQL DSN
parts := strings.Split(dsn, " ")
for _, part := range parts {
if strings.HasPrefix(part, "host=") {
@@ -113,9 +161,7 @@ func ExtractHostFromDSN(dsn string) string {
return "unknown"
}
// ExtractDBNameFromDSN извлекает имя базы данных из DSN строки
func ExtractDBNameFromDSN(dsn string) string {
// Простая реализация для PostgreSQL DSN
parts := strings.Split(dsn, " ")
for _, part := range parts {
if strings.HasPrefix(part, "dbname=") {
@@ -125,9 +171,7 @@ func ExtractDBNameFromDSN(dsn string) string {
return "unknown"
}
// MaskPassword маскирует пароль в DSN строке для безопасного логирования
func MaskPassword(dsn string) string {
// Простая реализация - заменяет пароль на ***
parts := strings.Split(dsn, " ")
for i, part := range parts {
if strings.HasPrefix(part, "password=") {
@@ -136,4 +180,4 @@ func MaskPassword(dsn string) string {
}
}
return strings.Join(parts, " ")
}
}
@@ -1,105 +0,0 @@
package database
import (
"go.uber.org/zap"
"api_bb/internal/models"
"api_bb/pkg/logger"
)
// Migrate выполняет автоматические миграции для всех моделей
func (d *Database) Migrate() error {
zapLogger := logger.Get()
zapLogger.Info("starting database migration")
// Список всех моделей для миграции
models := []interface{}{
&models.User{},
&models.News{},
&models.Comment{},
&models.Review{},
&models.UserStats{},
&models.Workout{},
&models.Achievement{},
&models.Event{},
&models.EventRegistration{},
&models.PersonalBest{},
&models.TrainingPlan{},
&models.EmailVerification{},
// Добавьте другие модели здесь
}
for _, model := range models {
modelName := getModelName(model)
zapLogger.Debug("migrating model", zap.String("model", modelName))
if err := d.DB.AutoMigrate(model); err != nil {
zapLogger.Error("failed to migrate model",
zap.String("model", modelName),
zap.Error(err),
)
return err
}
}
zapLogger.Info("database migration completed successfully")
return nil
}
// MigrateModels выполняет миграции для конкретных моделей
func (d *Database) MigrateModels(models ...interface{}) error {
zapLogger := logger.Get()
zapLogger.Info("starting migration for specific models",
zap.Int("model_count", len(models)),
)
for _, model := range models {
modelName := getModelName(model)
zapLogger.Debug("migrating model", zap.String("model", modelName))
if err := d.DB.AutoMigrate(model); err != nil {
zapLogger.Error("failed to migrate model",
zap.String("model", modelName),
zap.Error(err),
)
return err
}
}
zapLogger.Info("models migration completed successfully")
return nil
}
// getModelName возвращает имя модели для логирования
func getModelName(model interface{}) string {
switch model.(type) {
case *models.User:
return "User"
case *models.News:
return "News"
case *models.Comment:
return "Comment"
case *models.Review:
return "Reviews"
case *models.UserStats:
return "Статистика Пользователя"
case *models.Workout:
return "Тренировки пользователя"
case *models.Achievement:
return "Достижения пользователя"
case *models.Event:
return "Событие"
case *models.EventRegistration:
return "Администрирование события"
case *models.PersonalBest:
return "Персональные достижения"
case *models.TrainingPlan:
return "Тренировочный план"
case *models.EmailVerification:
return "Верификация email"
default:
return "Unknown"
}
}
@@ -0,0 +1,14 @@
DROP TABLE IF EXISTS galleries CASCADE;
DROP TABLE IF EXISTS email_verifications CASCADE;
DROP TABLE IF EXISTS training_workouts CASCADE;
DROP TABLE IF EXISTS training_plans CASCADE;
DROP TABLE IF EXISTS personal_bests CASCADE;
DROP TABLE IF EXISTS event_registrations CASCADE;
DROP TABLE IF EXISTS events CASCADE;
DROP TABLE IF EXISTS achievements CASCADE;
DROP TABLE IF EXISTS workouts CASCADE;
DROP TABLE IF EXISTS user_stats CASCADE;
DROP TABLE IF EXISTS reviews CASCADE;
DROP TABLE IF EXISTS comments CASCADE;
DROP TABLE IF EXISTS news CASCADE;
DROP TABLE IF EXISTS users CASCADE;
@@ -0,0 +1,217 @@
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
first_name VARCHAR(255) NOT NULL,
last_name VARCHAR(255) NOT NULL,
avatar VARCHAR(255),
phone VARCHAR(255),
experience VARCHAR(255),
goals VARCHAR(255),
newsletter BOOLEAN DEFAULT FALSE,
role VARCHAR(255) DEFAULT 'user',
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
email_verified BOOLEAN DEFAULT FALSE,
verified_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at);
CREATE TABLE IF NOT EXISTS news (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
title VARCHAR(255) NOT NULL,
excerpt VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
image VARCHAR(255),
category VARCHAR(20) NOT NULL,
views BIGINT DEFAULT 0,
author_id BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_news_deleted_at ON news(deleted_at);
CREATE TABLE IF NOT EXISTS comments (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
content TEXT NOT NULL,
news_id BIGINT NOT NULL,
author_id BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS reviews (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
rating BIGINT NOT NULL,
text TEXT NOT NULL,
achievement VARCHAR(255),
distance VARCHAR(50),
improvement VARCHAR(100),
trainings BIGINT DEFAULT 0,
verified BOOLEAN DEFAULT FALSE,
author_id BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_reviews_deleted_at ON reviews(deleted_at);
CREATE TABLE IF NOT EXISTS user_stats (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
total_distance DECIMAL(10,2) DEFAULT 0,
total_time BIGINT DEFAULT 0,
avg_pace VARCHAR(20),
workouts_count BIGINT DEFAULT 0,
current_streak BIGINT DEFAULT 0,
longest_streak BIGINT DEFAULT 0,
weekly_distance DECIMAL(8,2) DEFAULT 0,
monthly_distance DECIMAL(8,2) DEFAULT 0,
best_5k VARCHAR(20),
best_10k VARCHAR(20),
best_half VARCHAR(20),
best_marathon VARCHAR(20),
last_workout TIMESTAMPTZ,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_stats_user_id ON user_stats(user_id);
CREATE TABLE IF NOT EXISTS workouts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
type VARCHAR(20) NOT NULL,
distance_km DECIMAL(5,2) NOT NULL,
duration_min BIGINT NOT NULL,
pace VARCHAR(20),
calories BIGINT DEFAULT 0,
notes TEXT,
date TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_workouts_user_id ON workouts(user_id);
CREATE INDEX IF NOT EXISTS idx_workouts_date ON workouts(date);
CREATE TABLE IF NOT EXISTS achievements (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
type VARCHAR(20) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
result VARCHAR(100),
distance VARCHAR(50),
date TIMESTAMPTZ NOT NULL,
verified BOOLEAN DEFAULT FALSE,
badge_image VARCHAR(500),
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_achievements_user_id ON achievements(user_id);
CREATE TABLE IF NOT EXISTS events (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
date TIMESTAMPTZ NOT NULL,
location VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
distance VARCHAR(50),
participants_count BIGINT DEFAULT 0,
max_participants BIGINT DEFAULT 0,
registration_open BOOLEAN DEFAULT TRUE,
image VARCHAR(500),
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS event_registrations (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
event_id BIGINT NOT NULL,
status VARCHAR(50) DEFAULT 'pending',
notes TEXT,
result_time VARCHAR(20),
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS personal_bests (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
distance_type VARCHAR(20) NOT NULL,
time VARCHAR(20) NOT NULL,
pace VARCHAR(20),
date TIMESTAMPTZ NOT NULL,
verified BOOLEAN DEFAULT FALSE,
event_name VARCHAR(255),
location VARCHAR(255),
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_personal_bests_user_id ON personal_bests(user_id);
CREATE TABLE IF NOT EXISTS training_plans (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
weeks BIGINT NOT NULL DEFAULT 12,
workouts_per_week BIGINT NOT NULL DEFAULT 3,
target_distance VARCHAR(50),
target_date TIMESTAMPTZ,
current_week BIGINT DEFAULT 1,
completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_training_plans_user_id ON training_plans(user_id);
CREATE TABLE IF NOT EXISTS training_workouts (
id BIGSERIAL PRIMARY KEY,
plan_id BIGINT NOT NULL,
week BIGINT NOT NULL,
day BIGINT NOT NULL,
type VARCHAR(20) NOT NULL,
description TEXT,
distance_km DECIMAL(5,2),
duration_min BIGINT,
completed BOOLEAN DEFAULT FALSE,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_training_workouts_plan_id ON training_workouts(plan_id);
CREATE TABLE IF NOT EXISTS email_verifications (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
token VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_email_verifications_user_id ON email_verifications(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_email_verifications_token ON email_verifications(token);
CREATE INDEX IF NOT EXISTS idx_email_verifications_deleted_at ON email_verifications(deleted_at);
CREATE TABLE IF NOT EXISTS galleries (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
image_path VARCHAR(500) NOT NULL,
category VARCHAR(20) NOT NULL,
author_id BIGINT NOT NULL,
event_date TIMESTAMPTZ,
views BIGINT DEFAULT 0,
likes BIGINT DEFAULT 0,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_galleries_author_id ON galleries(author_id);
+6
View File
@@ -0,0 +1,6 @@
package migrations
import "embed"
//go:embed *.sql
var FS embed.FS