Files
rond-with-you/internal/db/db.go
T
2026-09-18 13:05:49 +08:00

154 lines
4.4 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 db
import (
"context"
"database/sql"
_ "embed"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
_ "github.com/jackc/pgx/v5/stdlib"
)
//go:embed schema.sql
var schemaSQL string
// WarmUp 在后台把连接先建好放进池里。
//
// 建连接是这套系统里最贵的一步(见上面 SetMaxIdleConns 的注释),冷启动时
// 第一个页面会把这笔开销全吃掉;并发建 n 条(不是串行)只需约一次建连的时间。
// 失败就放弃——真实的报错由后续请求自己暴露,这里不必打扰启动流程。
func WarmUp(d *sql.DB, n int) {
for i := 0; i < n; i++ {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
conn, err := d.Conn(ctx)
if err != nil {
return
}
conn.Close() // 归还到池里,之后就是热连接
}()
}
}
func Open(dsn string) (*sql.DB, error) {
d, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
// 空闲连接数必须与上限相同:PostgreSQL 建连接的开销远大于查询本身
// (本机实测建连 ~300ms、查询 ~2ms,psql 也一样)。池里留不住连接时,
// 每个并发请求都要重新建连,页面耗时反而比串行还高。
d.SetMaxOpenConns(8)
d.SetMaxIdleConns(8)
d.SetConnMaxLifetime(time.Hour)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err = d.PingContext(ctx)
if err == nil {
return d, nil
}
// 配置文件里写了一个还不存在的库:先用维护库把它建出来再连一次。
// 只在「库不存在」时兜底——口令错、端口不通这些要原样报出来,
// 否则连错服务器也会被当成「建个新的」。
if !isDatabaseMissing(err) {
d.Close()
return nil, fmt.Errorf("连接 PostgreSQL 失败: %w", err)
}
if cerr := createDatabase(ctx, dsn); cerr != nil {
d.Close()
return nil, fmt.Errorf("数据库不存在,自动创建失败: %w", cerr)
}
if err := d.PingContext(ctx); err != nil {
d.Close()
return nil, fmt.Errorf("连接 PostgreSQL 失败(库已创建): %w", err)
}
return d, nil
}
// isDatabaseMissing 判断错误是不是「目标库不存在」(SQLSTATE 3D000)。
// 驱动有时只给文本,所以再按消息兜一层。
func isDatabaseMissing(err error) bool {
if err == nil {
return false
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "3D000" {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "database") && strings.Contains(msg, "does not exist")
}
// createDatabase 连到维护库把 dsn 里指定的库建出来。
// 库名走 pgx.Identifier 转义,配置里写什么名字都不会拼坏语句。
func createDatabase(ctx context.Context, dsn string) error {
cc, err := pgx.ParseConfig(dsn)
if err != nil {
return fmt.Errorf("解析 dsn 失败: %w", err)
}
name := cc.Database
if name == "" {
return errors.New("dsn 里没有指定库名")
}
// 维护库优先 postgres,个别部署会删掉它,那就退回 template1
var lastErr error
for _, maint := range []string{"postgres", "template1"} {
mc, err := pgx.ParseConfig(dsn)
if err != nil {
return fmt.Errorf("解析 dsn 失败: %w", err)
}
mc.Database = maint
conn, err := pgx.ConnectConfig(ctx, mc)
if err != nil {
lastErr = err
continue
}
_, err = conn.Exec(ctx, "CREATE DATABASE "+pgx.Identifier{name}.Sanitize())
conn.Close(ctx)
if err != nil {
// 并发启动时可能已经被另一个实例建好了,那就算成功
if strings.Contains(strings.ToLower(err.Error()), "already exists") {
return nil
}
return fmt.Errorf("%w(当前账号可能没有 CREATEDB 权限)", err)
}
log.Printf("数据库 %s 不存在,已自动创建", name)
return nil
}
return fmt.Errorf("连不上维护库(postgres / template1): %w", lastErr)
}
func Migrate(ctx context.Context, d *sql.DB) error {
if _, err := d.ExecContext(ctx, schemaSQL); err != nil {
return fmt.Errorf("初始化表结构失败: %w", err)
}
return nil
}
func IsUniqueViolation(err error) bool {
if err == nil {
return false
}
s := err.Error()
return contains(s, "SQLSTATE 23505") || contains(s, "duplicate key value")
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
})()
}