You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
284 lines
11 KiB
284 lines
11 KiB
package postgres
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/plugin/dbresolver"
|
|
)
|
|
|
|
func newSys(options Options) (sys *Postgres, err error) {
|
|
sys = &Postgres{options: options}
|
|
err = sys.init()
|
|
return
|
|
}
|
|
|
|
// withExecQueryMode 确保 DSN 带 default_query_exec_mode=cache_describe。
|
|
//
|
|
// 为什么是 cache_describe 而不是 exec/simple_protocol:
|
|
// - 用匿名 prepared statement(不保留命名语句),故兼容 Supabase 6543 事务模式连接池;
|
|
// - 会向服务端 Describe 拿到参数 OID(如 int4),据此把 Go 值按目标列类型编码——
|
|
// protobuf 枚举(命名 int32)因此被正确编码成整数;
|
|
// - Describe 结果带缓存,避免每次查询多一次往返。
|
|
//
|
|
// 反例:exec 模式不 Describe、只按 Go 类型猜,对"命名 int32 + 带 Stringer"的枚举会退化成
|
|
// 文本走 Stringer,把枚举编成名字(如 "Admin"),integer 列报 22P02;simple_protocol 同病。
|
|
// 该参数是 pgx 私有的,由 pgx.ParseConfig 消费、不会发给 PostgreSQL 服务端。
|
|
func withExecQueryMode(dsn string) string {
|
|
if strings.Contains(dsn, "default_query_exec_mode") {
|
|
return dsn
|
|
}
|
|
sep := "?"
|
|
if strings.Contains(dsn, "?") {
|
|
sep = "&"
|
|
}
|
|
return dsn + sep + "default_query_exec_mode=cache_describe"
|
|
}
|
|
|
|
// ——— 连接池硬保护 ———
|
|
// Supabase pooler 的 max_client_conn 是全项目共享的硬上限(Small 规格固定 400),
|
|
// 单进程池必须严格受控:绝不允许"无限制"或"大量空闲"连接,否则极易把全项目额度打满。
|
|
// 这些常量是兜底保护,无论上层配置怎么填都不会被突破。
|
|
const (
|
|
defaultMaxOpenConns = 5 // 未配置或配置非法(<=0)时的保守默认
|
|
maxAllowedOpenConns = 10 // 硬上限:即便配置写大,也强制夹到这里
|
|
hardMaxIdleConns = 1 // 最多保留 1 条空闲连接,杜绝大量空闲堆积
|
|
poolConnMaxIdleTime = 30 * time.Second // 空闲超 30s 立即回收,连接尽快还给 pooler
|
|
poolConnMaxLifetime = 30 * time.Minute // 连接最长存活,定期重建避免老连接
|
|
)
|
|
|
|
type Postgres struct {
|
|
options Options
|
|
db *gorm.DB
|
|
mu sync.Mutex
|
|
existing map[string]bool //已存在表缓存,避免每张表都向远端探测
|
|
}
|
|
|
|
// safePoolSize 把配置的池大小夹到 [1, maxAllowedOpenConns]。
|
|
// 关键保护:绝不返回 0——database/sql 里 SetMaxOpenConns(0) 等于"无限制",
|
|
// 是把 Supabase pooler 打满的最大隐患;配置为 0/负数一律回落到保守默认值。
|
|
func (this *Postgres) safePoolSize() int {
|
|
n := int(this.options.MaxPoolSize)
|
|
if n <= 0 {
|
|
this.options.Log.Errorf("postgres MaxPoolSize=%d 非法(<=0),已回落默认 %d(SetMaxOpenConns(0) 会变无限制,必须兜底)", this.options.MaxPoolSize, defaultMaxOpenConns)
|
|
n = defaultMaxOpenConns
|
|
}
|
|
if n > maxAllowedOpenConns {
|
|
this.options.Log.Errorf("postgres MaxPoolSize=%d 超出硬上限 %d,已强制夹到 %d(防止单进程吃光全项目连接额度)", this.options.MaxPoolSize, maxAllowedOpenConns, maxAllowedOpenConns)
|
|
n = maxAllowedOpenConns
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (this *Postgres) init() (err error) {
|
|
// 走 pgx cache_describe 模式(由 withExecQueryMode 注入 default_query_exec_mode=cache_describe):
|
|
// 用匿名 prepared statement,故可对接 Supabase 6543 事务模式连接池。详见 withExecQueryMode 注释。
|
|
// DSN 端口请用 6543(事务模式)。
|
|
// 连接失败直接返回错误,不重试——由上层决定如何处理。
|
|
if this.db, err = gorm.Open(postgres.Open(withExecQueryMode(this.options.Dsn)), &gorm.Config{}); err != nil {
|
|
this.options.Log.Errorf("connect postgres failed: %v", err)
|
|
return
|
|
}
|
|
// 连接池硬保护:开连接数受 safePoolSize 夹取(永不为 0/无限制),空闲连接 ≤1、空闲超 30s 立即回收,
|
|
// 保证单进程绝不出现"大量空闲连接"长期占用全项目共享的 max_client_conn 额度。
|
|
maxOpen := this.safePoolSize()
|
|
maxIdle := hardMaxIdleConns
|
|
if maxIdle > maxOpen {
|
|
maxIdle = maxOpen
|
|
}
|
|
if sqlDB, e := this.db.DB(); e == nil {
|
|
sqlDB.SetMaxOpenConns(maxOpen)
|
|
sqlDB.SetMaxIdleConns(maxIdle)
|
|
sqlDB.SetConnMaxIdleTime(poolConnMaxIdleTime)
|
|
sqlDB.SetConnMaxLifetime(poolConnMaxLifetime)
|
|
}
|
|
// 读写分离:配了只读副本 DSN 就挂 dbresolver——
|
|
// 读(SELECT: FindOne/Find/Raw) 自动走副本,写(Insert/Save/Update/Delete/Exec)与 DDL/事务 走主库。
|
|
// 副本同样是 Supabase 6543 事务池,需注入 cache_describe,并设同样保守的小连接池。
|
|
// 读后写同一行等强一致场景请用 FindOnePrimary 强制回主库(绕开复制延迟)。
|
|
if this.options.ReadDsn != "" {
|
|
resolver := dbresolver.Register(dbresolver.Config{
|
|
Replicas: []gorm.Dialector{postgres.Open(withExecQueryMode(this.options.ReadDsn))},
|
|
Policy: dbresolver.RandomPolicy{},
|
|
}).
|
|
SetMaxOpenConns(maxOpen).
|
|
SetMaxIdleConns(maxIdle).
|
|
SetConnMaxIdleTime(poolConnMaxIdleTime).
|
|
SetConnMaxLifetime(poolConnMaxLifetime)
|
|
if e := this.db.Use(resolver); e != nil {
|
|
// 注册失败不致命:退回单库(主库),仅记录错误。
|
|
this.options.Log.Errorf("register read replica failed, fallback to single db: %v", e)
|
|
} else {
|
|
this.options.Log.Infof("postgres read replica enabled")
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (this *Postgres) Exec(sql string, values ...interface{}) (tx *gorm.DB) {
|
|
return this.db.Exec(sql, values...)
|
|
}
|
|
|
|
func (this *Postgres) Raw(sql string, values ...interface{}) (tx *gorm.DB) {
|
|
return this.db.Raw(sql, values...)
|
|
}
|
|
|
|
// loadExisting 一次性把当前 schema 下已有表名加载进缓存(懒加载,仅首次发一条查询)。
|
|
func (this *Postgres) loadExisting() (err error) {
|
|
this.mu.Lock()
|
|
defer this.mu.Unlock()
|
|
if this.existing != nil {
|
|
return
|
|
}
|
|
var names []string
|
|
if err = this.db.Raw(`SELECT tablename FROM pg_tables WHERE schemaname = CURRENT_SCHEMA()`).Scan(&names).Error; err != nil {
|
|
return
|
|
}
|
|
this.existing = make(map[string]bool, len(names))
|
|
for _, n := range names {
|
|
this.existing[n] = true
|
|
}
|
|
return
|
|
}
|
|
|
|
// CreateTable 建表。表已存在则跳过 AutoMigrate —— AutoMigrate 会对每张表发数十条 schema 探测查询,
|
|
// 在远端高延迟(如跨洋 Supabase)下会让启动慢到几分钟。表结构已由迁移工具建好;
|
|
// 后续 schema 变更请走迁移工具/手动 DDL,而非依赖每次启动 AutoMigrate。
|
|
func (this *Postgres) CreateTable(tName string, model any) (err error) {
|
|
if err = this.loadExisting(); err != nil {
|
|
return
|
|
}
|
|
this.mu.Lock()
|
|
exists := this.existing[tName]
|
|
this.mu.Unlock()
|
|
if exists {
|
|
return
|
|
}
|
|
if err = this.db.Table(tName).AutoMigrate(model); err != nil {
|
|
return
|
|
}
|
|
this.mu.Lock()
|
|
this.existing[tName] = true
|
|
this.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
// AutoIncrementStart 设置表自增主键起始值。
|
|
// 仅当表为空时把序列重置到 start,避免覆盖线上已有的序列位置。
|
|
func (this *Postgres) AutoIncrementStart(tName, column string, start uint64) (err error) {
|
|
var n int64
|
|
if err = this.db.Table(tName).Count(&n).Error; err != nil {
|
|
return
|
|
}
|
|
if n > 0 {
|
|
return // 已有数据,保持现有序列不动
|
|
}
|
|
// setval(seq, start, false) 使下一个 nextval 恰好等于 start
|
|
err = this.db.Exec(`SELECT setval(pg_get_serial_sequence(?, ?), ?, false)`,
|
|
tName, column, start).Error
|
|
return
|
|
}
|
|
|
|
// AutoIncrementFloor 抬高表自增主键的下限:保证之后新插入的行 id >= start。
|
|
//
|
|
// 与 AutoIncrementStart 的区别是「表里已有数据也照样生效」——已有行的 id 原样保留,
|
|
// 只把序列推到 max(当前最大 id, start-1),因此新行从 start 起(或从已有最大值 +1 起,
|
|
// 若它本就更大)。用于给已上线的表补上带业务含义的 id 段(如方案商 0xAB01 起):
|
|
// 只用 AutoIncrementStart 的话,表一旦有过一行就永远停在 1、2、3…。
|
|
//
|
|
// 语义与 MySQL 的 `ALTER TABLE ... AUTO_INCREMENT = n` 一致(MySQL 侧本就是下限语义)。
|
|
func (this *Postgres) AutoIncrementFloor(tName, column string, start uint64) (err error) {
|
|
if start == 0 {
|
|
return
|
|
}
|
|
// setval(..., v, true) 让下一个 nextval = v+1,故取 v = GREATEST(表内最大 id, 序列当前值, start-1)。
|
|
// 三者都要参与:
|
|
// - 表内最大 id —— 保证不会退回去撞上已有行;
|
|
// - 序列当前值 —— 保证不会把已经发到更高位的序列往回拨(回滚过的插入会让序列超前于最大 id);
|
|
// - start-1 —— 本次要抬到的下限。
|
|
err = this.db.Exec(
|
|
`SELECT setval(pg_get_serial_sequence(?, ?), GREATEST(
|
|
(SELECT COALESCE(MAX(`+column+`), 0) FROM `+tName+`),
|
|
COALESCE(pg_sequence_last_value(pg_get_serial_sequence(?, ?)), 0),
|
|
?), true)`,
|
|
tName, column, tName, column, start-1).Error
|
|
return
|
|
}
|
|
|
|
// 获取表对象
|
|
func (this *Postgres) Table(tName string) (tx *gorm.DB) {
|
|
return this.db.Table(tName)
|
|
}
|
|
|
|
// 查询数据
|
|
func (this *Postgres) FindOne(tName string, model any, query interface{}, args ...interface{}) (err error) {
|
|
result := this.db.Table(tName).Where(query, args...).First(model)
|
|
if result.Error != nil {
|
|
err = result.Error
|
|
}
|
|
return
|
|
}
|
|
|
|
// FindOnePrimary 强制走主库(dbresolver.Write)。用于"读后写同一行"等强一致场景,
|
|
// 避免读到只读副本的复制延迟旧数据。未注册副本时该 Clause 被忽略,行为等同 FindOne。
|
|
func (this *Postgres) FindOnePrimary(tName string, model any, query interface{}, args ...interface{}) (err error) {
|
|
result := this.db.Clauses(dbresolver.Write).Table(tName).Where(query, args...).First(model)
|
|
if result.Error != nil {
|
|
err = result.Error
|
|
}
|
|
return
|
|
}
|
|
|
|
// 查询数据
|
|
func (this *Postgres) Find(tName string, models any, query interface{}, args ...interface{}) (err error) {
|
|
result := this.db.Table(tName).Where(query, args...).Find(models)
|
|
if result.Error != nil {
|
|
err = result.Error
|
|
}
|
|
return
|
|
}
|
|
|
|
// 插入数据
|
|
func (this *Postgres) Insert(tName string, model any) (err error) {
|
|
result := this.db.Table(tName).Create(model)
|
|
if result.Error != nil {
|
|
err = result.Error
|
|
}
|
|
return
|
|
}
|
|
|
|
// 更新指定字段
|
|
func (this *Postgres) Update(tName string, where, change map[string]interface{}) (err error) {
|
|
result := this.db.Table(tName).Where(where).Updates(change)
|
|
if result.Error != nil {
|
|
err = result.Error
|
|
}
|
|
return
|
|
}
|
|
|
|
func (this *Postgres) Save(tName string, model any) (err error) {
|
|
err = this.db.Table(tName).Save(model).Error
|
|
return
|
|
}
|
|
|
|
// 删除数据
|
|
func (this *Postgres) Delete(tName string, query interface{}, args ...interface{}) (err error) {
|
|
result := this.db.Table(tName).Where(query, args...).Unscoped().Delete(nil)
|
|
if result.Error != nil {
|
|
err = result.Error
|
|
}
|
|
return
|
|
}
|
|
|
|
// 删除表
|
|
func (this *Postgres) DropTable(tName string) (err error) {
|
|
return this.db.Migrator().DropTable(tName)
|
|
}
|
|
|
|
// 事务
|
|
func (this *Postgres) Begin() (tx *gorm.DB) {
|
|
return this.db.Begin()
|
|
}
|
|
|