Files
xlgo-core/database/manager.go
T
杭州明婳科技 18e4f7e891 fix: ServerConfig 加 Host 字段 + 面向用户文案统一中文
- ServerConfig 新增 Host 字段:空=监听所有接口(兼容),设值=绑定指定地址
  (127.0.0.1 仅本机 / 内网IP 绑定指定网卡),避免生产暴露 0.0.0.0
- app.go 启动地址改 host:port,日志区分"所有接口"/指定地址
- 面向用户/调用的文案统一中文:
  - middleware/recover.go: "Panic recovered"→"panic 已恢复","Panic: %v"→"服务器内部错误: %v"
  - middleware/logger.go: 5 处日志消息改中文
  - middleware/metrics.go: 3 个 Prometheus Help 改中文
  - app.go/database/manager.go/logger.go: 英文 error 改中文
- 保留英文:JSON 协议字段名、Prometheus metric Name、MySQL 错误串匹配、技术专有名词
- CLI 模板与 example 配置加 host 注释

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:12:40 +08:00

621 lines
16 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 database
import (
"context"
"errors"
"fmt"
"math/rand"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/logger"
"go.uber.org/zap"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
type dbModeContextKey struct{}
const (
dbModeMaster = "master"
dbModeReplica = "replica"
)
// ReplicaPicker 从库选择策略
type ReplicaPicker interface {
Pick(replicas []*gorm.DB) *gorm.DB
}
// RoundRobinPicker 轮询选择从库
type RoundRobinPicker struct {
counter uint64
}
// Pick 轮询选择一个从库
func (p *RoundRobinPicker) Pick(replicas []*gorm.DB) *gorm.DB {
if len(replicas) == 0 {
return nil
}
n := atomic.AddUint64(&p.counter, 1)
return replicas[int(n-1)%len(replicas)]
}
// RandomPicker 随机选择从库
type RandomPicker struct{}
// Pick 随机选择一个从库
func (p *RandomPicker) Pick(replicas []*gorm.DB) *gorm.DB {
if len(replicas) == 0 {
return nil
}
return replicas[rand.Intn(len(replicas))]
}
// Manager 数据库管理器,持有主库与从库连接实例
type Manager struct {
cfg *config.Config
master *gorm.DB
replicas []*gorm.DB
picker ReplicaPicker
mu sync.Mutex
// #21 健康自愈
healthy atomic.Bool // 主库是否健康
replicaHealthy []atomic.Bool // 每个从库的健康标记,索引与 replicas 对齐
probeFailures int // 主库连续探活失败次数
probeMu sync.Mutex // 保护 probeFailures
replicaHealthSet bool // replicaHealthy 是否已按 replicas 长度初始化
}
// NewManager 创建数据库管理器
func NewManager(cfg *config.Config) *Manager {
return &Manager{cfg: cfg, picker: &RandomPicker{}}
}
// SetPicker 设置从库选择策略
func (m *Manager) SetPicker(p ReplicaPicker) {
if p == nil {
return
}
m.mu.Lock()
m.picker = p
m.mu.Unlock()
}
// Picker 返回当前从库选择策略
func (m *Manager) Picker() ReplicaPicker {
m.mu.Lock()
defer m.mu.Unlock()
return m.picker
}
// Master 返回主库实例
func (m *Manager) Master() *gorm.DB {
return m.master
}
// Replicas 返回所有从库实例
func (m *Manager) Replicas() []*gorm.DB {
return m.replicas
}
// Replica 按策略选择一个从库;无从库时返回主库。
// #21:启用探活后,自动过滤不健康的从库;全不健康时回退到全部从库(仍可服务)。
func (m *Manager) Replica() *gorm.DB {
if len(m.replicas) == 0 {
return m.master
}
m.mu.Lock()
defer m.mu.Unlock()
pool := m.replicas
// 启用探活且至少有一个健康标记时,仅从健康从库中选取
if m.replicaHealthSet {
var healthy []*gorm.DB
for i, r := range m.replicas {
if i < len(m.replicaHealthy) && m.replicaHealthy[i].Load() {
healthy = append(healthy, r)
}
}
if len(healthy) > 0 {
pool = healthy
}
// healthy 为空时回退到全部 replicas,避免读流量完全中断
}
if m.picker != nil {
if db := m.picker.Pick(pool); db != nil {
return db
}
}
return pool[0]
}
// IsHealthy 返回主库当前健康状态(#21)。供 readiness/health 探针联动。
func (m *Manager) IsHealthy() bool {
return m.healthy.Load()
}
// initReplicaHealth 按 replicas 数量初始化健康标记(全部为健康)。
func (m *Manager) initReplicaHealth() {
m.mu.Lock()
defer m.mu.Unlock()
if m.replicaHealthSet {
return
}
m.replicaHealthy = make([]atomic.Bool, len(m.replicas))
for i := range m.replicaHealthy {
m.replicaHealthy[i].Store(true)
}
m.replicaHealthSet = true
}
// StartProbing 启动主库与从库的健康探活后台循环(#21)。
// 阻塞调用方,应通过 App.Go 在独立 goroutine 运行;ctx 取消时退出。
// 周期 ping 主库,连续失败达阈值后标记不健康(IsHealthy=false);
// 同时 ping 各从库,失败则从读流量剔除,恢复后自动重新纳入。
func (m *Manager) StartProbing(ctx context.Context) {
m.initReplicaHealth()
interval := 30 * time.Second
if m.cfg != nil && m.cfg.Database.HealthCheckInterval > 0 {
interval = m.cfg.Database.HealthCheckInterval
}
threshold := 3
if m.cfg != nil && m.cfg.Database.HealthCheckFailureThreshold > 0 {
threshold = m.cfg.Database.HealthCheckFailureThreshold
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.probeOnce(ctx, threshold)
}
}
}
// probeOnce 执行一轮主库+从库探活并更新健康标记。
func (m *Manager) probeOnce(ctx context.Context, threshold int) {
// 主库
if err := m.HealthCheck(ctx); err != nil {
m.probeMu.Lock()
m.probeFailures++
if m.probeFailures >= threshold {
if m.healthy.Load() {
logger.Warnf("数据库主库连续探活失败 %d 次,标记为不健康: %v", m.probeFailures, err)
}
m.healthy.Store(false)
}
m.probeMu.Unlock()
} else {
m.probeMu.Lock()
if m.probeFailures >= threshold && !m.healthy.Load() {
logger.Info("数据库主库探活恢复,重新标记为健康")
}
m.probeFailures = 0
m.probeMu.Unlock()
m.healthy.Store(true)
}
// 从库
m.mu.Lock()
replicas := make([]*gorm.DB, len(m.replicas))
copy(replicas, m.replicas)
healthSet := m.replicaHealthSet
m.mu.Unlock()
if !healthSet {
return
}
for i, r := range replicas {
if r == nil {
continue
}
sqlDB, err := r.DB()
if err != nil {
if i < len(m.replicaHealthy) {
m.replicaHealthy[i].Store(false)
}
continue
}
if err := sqlDB.PingContext(ctx); err != nil {
if i < len(m.replicaHealthy) && m.replicaHealthy[i].Load() {
logger.Warnf("数据库从库 #%d 探活失败,暂时剔除读流量: %v", i, err)
}
if i < len(m.replicaHealthy) {
m.replicaHealthy[i].Store(false)
}
} else {
if i < len(m.replicaHealthy) {
m.replicaHealthy[i].Store(true)
}
}
}
}
// FromContext 根据上下文选择数据库
func (m *Manager) FromContext(ctx context.Context) *gorm.DB {
mode, ok := ctx.Value(dbModeContextKey{}).(string)
if !ok {
return m.Replica()
}
switch mode {
case dbModeMaster:
return m.master
case dbModeReplica:
return m.Replica()
default:
return m.Replica()
}
}
// Open 打开主库连接
func (m *Manager) Open(ctx context.Context) error {
if m.cfg == nil {
return errors.New("数据库配置未设置")
}
return m.InitDB(m.cfg)
}
// OpenWithReplicas 打开主库与从库连接
func (m *Manager) OpenWithReplicas(ctx context.Context, replicaDSNs []string) error {
if m.cfg == nil {
return errors.New("数据库配置未设置")
}
return m.InitDBWithReplicas(m.cfg, replicaDSNs)
}
// Close 关闭主库与全部从库连接
func (m *Manager) Close() error {
var errs []error
if m.master != nil {
sqlDB, err := m.master.DB()
if err != nil {
errs = append(errs, err)
} else if err := sqlDB.Close(); err != nil {
errs = append(errs, err)
}
}
for _, replica := range m.replicas {
if replica == nil {
continue
}
sqlDB, err := replica.DB()
if err != nil {
errs = append(errs, err)
continue
}
if err := sqlDB.Close(); err != nil {
errs = append(errs, err)
}
}
m.master = nil
m.replicas = nil
return errors.Join(errs...)
}
// HealthCheck 健康检查,主库不可达时返回错误
func (m *Manager) HealthCheck(ctx context.Context) error {
if m.master == nil {
return errors.New("数据库主库未初始化")
}
sqlDB, err := m.master.DB()
if err != nil {
return err
}
return sqlDB.PingContext(ctx)
}
// DefaultManager 默认数据库管理器
var DefaultManager = &Manager{picker: &RandomPicker{}}
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定
func (m *Manager) InitDB(cfg *config.Config) error {
var err error
m.cfg = cfg
// GORM 日志配置
var gormLogLevel gormlogger.LogLevel
if cfg.IsDevelopment() {
gormLogLevel = gormlogger.Info
} else {
gormLogLevel = gormlogger.Warn
}
gormConfig := &gorm.Config{
Logger: gormlogger.Default.LogMode(gormLogLevel),
}
// 重试配置
maxRetries := 5
retryDelay := time.Second
var lastErr error
for i := range maxRetries {
// 连接主库
m.master, err = gorm.Open(Dialector(cfg), gormConfig)
if err == nil {
sqlDB, err := m.master.DB()
if err == nil {
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
sqlDB.SetConnMaxLifetime(time.Hour)
if cfg.Database.ConnMaxIdleTime > 0 {
sqlDB.SetConnMaxIdleTime(cfg.Database.ConnMaxIdleTime)
}
m.healthy.Store(true) // 主库连通即标记健康(#21
if err := sqlDB.Ping(); err == nil {
logger.Info("数据库主库连接成功",
zap.String("driver", driverDescription(cfg.Database.Driver)),
zap.String("host", cfg.Database.Host),
zap.Int("port", cfg.Database.Port))
return nil
} else {
// Ping 失败(如服务端暂时不可达)视作可重试
lastErr = err
}
} else {
lastErr = err
}
} else {
lastErr = err
// 不可恢复的错误(认证失败、未知数据库、DSN 非法等)直接返回,不必重试
if !isTransientDBError(err) {
return fmt.Errorf("数据库连接失败(不可恢复): %w", err)
}
}
logger.Warnf("数据库连接失败,第 %d/%d 次重试: %v", i+1, maxRetries, lastErr)
time.Sleep(retryDelay)
retryDelay *= 2
if retryDelay > 30*time.Second {
retryDelay = 30 * time.Second
}
}
return fmt.Errorf("数据库连接失败(重试 %d 次): %w", maxRetries, lastErr)
}
// isTransientDBError 判断数据库连接错误是否值得重试。
// 认证失败、未知数据库、非法 DSN/驱动等属于配置类错误,重试无意义,直接返回更友好。
func isTransientDBError(err error) bool {
if err == nil {
return true
}
msg := err.Error()
nonTransient := []string{
"Access denied", // MySQL 认证失败(用户名/密码错误)
"authentication plugin", // MySQL 认证插件不支持
"Unknown database", // MySQL 目标库不存在
"invalid DSN", // DSN 语法错误
"unknown driver", // 驱动未注册
"unsupported driver", // 驱动不支持
}
for _, sub := range nonTransient {
if strings.Contains(msg, sub) {
return false
}
}
return true
}
// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定
// replicaDSNs: 从库连接字符串列表(需与主库驱动匹配)
func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) error {
// 先初始化主库
if err := m.InitDB(cfg); err != nil {
return err
}
m.replicas = nil
// 初始化从库
if len(replicaDSNs) > 0 {
var gormLogLevel gormlogger.LogLevel
if cfg.IsDevelopment() {
gormLogLevel = gormlogger.Info
} else {
gormLogLevel = gormlogger.Warn
}
gormConfig := &gorm.Config{
Logger: gormlogger.Default.LogMode(gormLogLevel),
}
for i, dsn := range replicaDSNs {
replicaDB, err := gorm.Open(dialectorForDSN(cfg.Database.Driver, dsn), gormConfig)
if err != nil {
logger.Warnf("数据库从库 %d 连接失败: %v", i+1, err)
continue
}
sqlDB, err := replicaDB.DB()
if err != nil {
logger.Warnf("数据库从库 %d 获取连接池失败: %v", i+1, err)
continue
}
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns / 2) // 从库连接数可适当减少
sqlDB.SetConnMaxLifetime(time.Hour)
if err := sqlDB.Ping(); err != nil {
logger.Warnf("数据库从库 %d Ping 失败: %v", i+1, err)
continue
}
m.replicas = append(m.replicas, replicaDB)
logger.Info("数据库从库连接成功", zap.Int("index", i+1))
}
}
return nil
}
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定
func InitDB(cfg *config.Config) error {
return DefaultManager.InitDB(cfg)
}
// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定
func InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) error {
return DefaultManager.InitDBWithReplicas(cfg, replicaDSNs)
}
// GetReadDB 获取读库实例(按策略选择从库)
func GetReadDB() *gorm.DB {
return DefaultManager.Replica()
}
// GetWriteDB 获取写库实例(主库)
func GetWriteDB() *gorm.DB {
return DefaultManager.Master()
}
// GetDB 获取数据库实例(默认主库,兼容旧代码)
func GetDB() *gorm.DB {
return DefaultManager.Master()
}
// GetReplicas 获取所有从库实例
func GetReplicas() []*gorm.DB {
return DefaultManager.Replicas()
}
// SetReplicaPicker 设置默认管理器的从库选择策略
func SetReplicaPicker(p ReplicaPicker) {
DefaultManager.SetPicker(p)
}
// UseMaster 强制使用主库(用于事务或需要实时数据的场景)
func UseMaster(ctx context.Context) context.Context {
return context.WithValue(ctx, dbModeContextKey{}, dbModeMaster)
}
// UseReplica 强制使用从库(用于报表查询等场景)
func UseReplica(ctx context.Context) context.Context {
return context.WithValue(ctx, dbModeContextKey{}, dbModeReplica)
}
// GetDBFromContext 根据上下文选择数据库
func GetDBFromContext(ctx context.Context) *gorm.DB {
return DefaultManager.FromContext(ctx)
}
// AutoMigrate 自动迁移数据库表结构(由应用通过 WithMigrator/WithModels 注册)
func AutoMigrate() error {
logger.Info("数据库表结构迁移完成")
return nil
}
// Close 关闭主库连接(兼容旧代码,从库连接请使用 CloseAll)
func Close() error {
if DefaultManager.master == nil {
return nil
}
sqlDB, err := DefaultManager.master.DB()
if err != nil {
return err
}
err = sqlDB.Close()
DefaultManager.master = nil
return err
}
// CloseAll 关闭所有数据库连接(包括从库)
func CloseAll() error {
return DefaultManager.Close()
}
// Transaction 事务操作(自动使用主库)
func Transaction(fn func(tx *gorm.DB) error) error {
if DefaultManager.master == nil {
return errors.New("数据库未初始化")
}
return DefaultManager.master.Transaction(fn)
}
// TransactionWithContext 带上下文的事务操作
func TransactionWithContext(ctx context.Context, fn func(tx *gorm.DB) error) error {
if DefaultManager.master == nil {
return errors.New("数据库未初始化")
}
return DefaultManager.master.WithContext(ctx).Transaction(fn)
}
// ReadQuery 读查询(自动路由到从库)
func ReadQuery(ctx context.Context, model any, query string, args ...any) error {
db := GetDBFromContext(ctx)
if db == nil {
return errors.New("数据库未初始化")
}
return db.WithContext(ctx).Where(query, args...).Find(model).Error
}
// WriteQuery 写查询(强制使用主库)
func WriteQuery(ctx context.Context, model any, query string, args ...any) error {
if DefaultManager.master == nil {
return errors.New("数据库未初始化")
}
return DefaultManager.master.WithContext(ctx).Where(query, args...).Find(model).Error
}
// HealthCheck 健康检查
func HealthCheck() map[string]bool {
result := make(map[string]bool)
// 检查主库
if DefaultManager.master != nil {
sqlDB, err := DefaultManager.master.DB()
if err == nil && sqlDB.Ping() == nil {
result["master"] = true
} else {
result["master"] = false
}
} else {
result["master"] = false
}
// 检查从库
for i, replica := range DefaultManager.replicas {
if replica != nil {
sqlDB, err := replica.DB()
if err == nil && sqlDB.Ping() == nil {
result[fmt.Sprintf("replica_%d", i+1)] = true
} else {
result[fmt.Sprintf("replica_%d", i+1)] = false
}
} else {
result[fmt.Sprintf("replica_%d", i+1)] = false
}
}
return result
}
// IsDBHealthy 返回主库探活健康状态(#21)。
// 与 HealthCheck()(实时 ping)不同,这是后台探活维护的缓存标记,
// 供 readiness 探针快速判断是否接流量,避免每次探针都同步 ping。
func IsDBHealthy() bool {
return DefaultManager.IsHealthy()
}
// StartDBProbing 启动主库/从库探活后台循环(#21)。
// 阻塞,应通过 App.Go 在独立 goroutine 运行;ctx 取消时退出。
func StartDBProbing(ctx context.Context) {
DefaultManager.StartProbing(ctx)
}