构建 Go 语言多租户应用:基于 PostgreSQL 的动态数据库连接管理

19次阅读

构建 Go 语言多租户应用:基于 PostgreSQL 的动态数据库连接管理

本文介绍如何在 go 中实现多租户架构下的动态数据库连接切换,通过主库(master db)统一管理租户元数据,并按请求上下文(如子域名)实时加载对应 postgresql 租户库连接,兼顾性能、安全与可维护性。

go 构建的多租户系统中,为每个租户分配独立 PostgreSQL 数据库是常见且推荐的隔离策略。但关键挑战在于:如何安全、高效、可扩展地按请求动态切换数据库连接? 直接使用全局 map[String]*sql.DB 缓存所有租户连接看似简单,却存在显著风险——连接泄漏、内存失控、缺乏连接池生命周期管理,且难以应对租户动态增删。

✅ 推荐方案:分层连接 + 懒加载 + 连接池复用

核心思路是将数据库连接管理解耦为两层:

  1. 主库(Master DB):单一、长期存活的连接,仅用于查询租户元数据(如 tenants 表),结构示例如下:

    CREATE TABLE tenants (     id SERIAL PRIMARY KEY,     subdomain VARCHAR(64) UNIQUE NOT NULL,     db_name VARCHAR(64) NOT NULL,     host VARCHAR(128) DEFAULT 'localhost',     port INT DEFAULT 5432,     username VARCHAR(64) DEFAULT 'app_user',     password TEXT );
  2. 租户库(Tenant DB):按需创建、带连接池、带缓存的 *sql.DB 实例。不预热所有租户连接,而是首次请求时解析租户标识(如子域名)、查主库、构造 DSN、初始化连接池并缓存(推荐使用 sync.Map 或带 TTL 的 LRU 缓存)。

以下是关键实现步骤与代码示例:

1. 定义租户连接工厂

import (     "database/sql"     "fmt"     "sync"     _ "github.com/lib/pq" )  type TenantDB struct {     *sql.DB     Subdomain string }  type DBManager struct {     masterDB *sql.DB     cache    sync.Map // map[string]*TenantDB }  func NewDBManager(masterDSN string) (*DBManager, error) {     db, err := sql.Open("postgres", masterDSN)     if err != nil {         return nil, err     }     db.SetMaxOpenConns(10)     return &DBManager{masterDB: db}, nil }  func (m *DBManager) GetTenantDB(subdomain string) (*TenantDB, error) {     // 先查缓存     if cached, ok := m.cache.Load(subdomain); ok {         return cached.(*TenantDB), nil     }      // 查主库获取租户 DB 配置     var dbName, host, username, password string     var port int     err := m.masterDB.QueryRow(         "SELECT db_name, host, port, username, password FROM tenants WHERE subdomain = $1",         subdomain,     ).Scan(&dbName, &host, &port, &username, &password)     if err != nil {         return nil, fmt.Errorf("tenant %s not found or DB config error: %w", subdomain, err)     }      // 构造租户 DSN     dsn := fmt.Sprintf("host=%s port=%d dbname=%s user=%s password=%s sslmode=disable",         host, port, dbName, username, password)      // 打开租户连接池(设置合理池参数)     tenantDB, err := sql.Open("postgres", dsn)     if err != nil {         return nil, fmt.Errorf("failed to open tenant DB %s: %w", dbName, err)     }     tenantDB.SetMaxOpenConns(20)     tenantDB.SetMaxIdleConns(5)     tenantDB.SetConnMaxLifetime(1 * time.Hour)      // 写入缓存(注意:生产环境建议加 TTL 防止 stale connection)     wrapped := &TenantDB{DB: tenantDB, Subdomain: subdomain}     m.cache.Store(subdomain, wrapped)     return wrapped, nil }

2. 在 http 中间件中自动注入租户 DB

func TenantDBMiddleware(dbm *DBManager) func(http.Handler) http.Handler {     return func(next http.Handler) http.Handler {         return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {             // 从 Host 或自定义 Header 提取子域名(如 xyz.example.com → "xyz")             host := r.Host             subdomain := strings.Split(host, ".")[0] // 简化示例,实际请使用更健壮的解析              tenantDB, err := dbm.GetTenantDB(subdomain)             if err != nil {                 http.Error(w, "Tenant not available", http.StatusServiceUnavailable)                 return             }              // 将租户 DB 注入 request context             ctx := context.WithValue(r.Context(), "tenant_db", tenantDB)             next.ServeHTTP(w, r.WithContext(ctx))         })     } }  // 使用示例 handler func UserHandler(w http.ResponseWriter, r *http.Request) {     tenantDB := r.Context().Value("tenant_db").(*TenantDB)     var name string     err := tenantDB.QueryRow("SELECT name FROM users WHERE id = $1", 1).Scan(&name)     if err != nil {         http.Error(w, err.Error(), http.StatusInternalServerError)         return     }     json.NewEncoder(w).Encode(map[string]string{"name": name}) }

⚠️ 重要注意事项

  • 缓存失效:sync.Map 不支持 TTL,生产环境应集成 golang.org/x/exp/maps(Go 1.21+)或使用 lru 库配合定时清理,避免配置变更后连接未更新。
  • 凭证安全:切勿明文存储密码;推荐使用 Vault、AWS Secrets Manager 或环境变量 + 启动时注入。
  • 连接健康检查:定期调用 tenantDB.Ping() 并剔除失效连接(可在后台 goroutine 中执行)。
  • 主库高可用:主库是单点瓶颈,务必启用连接池、监控慢查询,并考虑读写分离或缓存租户配置(如 redis)降低主库压力。
  • 事务边界:租户 DB 连接不可跨请求复用,每个请求应使用独立 *sql.Tx,严禁跨租户事务。

该方案平衡了灵活性与稳定性:主库轻量可控,租户库按需加载、池化复用、缓存加速,既避免资源浪费,又满足多租户强隔离需求。随着租户规模增长,还可平滑演进为分片路由或数据库代理层(如 PgBouncer + 自定义路由)。

text=ZqhQzanResources