fix(database): 数据库连接 - 消费 libpq keepalive 参数并默认限制闲置事务 (#737)
pgx 不识别 DSN 中 libpq 的 keepalives* 参数,会作为服务端运行参数发送导致连接被拒;改为在建连前消费并应用到 TCP 拨号器。 未显式配置时为每条连接默认设置 idle_in_transaction_session_timeout=5min,避免卡住的事务长期持锁;DSN 显式配置时以 DSN 为准。 建连钩子先复制共享的 RuntimeParams,避免多条连接之间累积修改。 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.13
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/google/uuid v1.5.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/qiniu/go-sdk/v7 v7.27.0
|
||||
github.com/redis/go-redis/v9 v9.22.0
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.75
|
||||
@@ -44,7 +45,6 @@ require (
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"yingce/backend/internal/logging"
|
||||
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -39,7 +40,10 @@ func Open(config Config) (*gorm.DB, error) {
|
||||
if dsn == "" {
|
||||
return nil, errors.New("PostgreSQL 模式必须配置 DATABASE_URL")
|
||||
}
|
||||
return gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logging.GormLogger()})
|
||||
return gorm.Open(postgres.New(postgres.Config{
|
||||
DSN: dsn,
|
||||
OptionOpenDB: []stdlib.OptionOpenDB{stdlib.OptionBeforeConnect(configurePostgresConnection)},
|
||||
}), &gorm.Config{Logger: logging.GormLogger()})
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库驱动:%s", driver)
|
||||
}
|
||||
|
||||
86
backend/internal/database/postgres_connection.go
Normal file
86
backend/internal/database/postgres_connection.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// defaultIdleInTransactionSessionTimeout 限制事务内等待下一条命令的时间,
|
||||
// 避免卡住的 idle in transaction 会话长期持有锁。DSN 显式配置时以 DSN 为准。
|
||||
const defaultIdleInTransactionSessionTimeout = "5min"
|
||||
|
||||
var libpqKeepaliveParams = []string{"keepalives", "keepalives_idle", "keepalives_interval", "keepalives_count"}
|
||||
|
||||
// configurePostgresConnection 在每次建立物理连接前调整 pgx 连接配置。
|
||||
func configurePostgresConnection(_ context.Context, config *pgx.ConnConfig) error {
|
||||
// stdlib 传入的是浅拷贝,RuntimeParams 与连接器共享,修改前必须复制,否则会跨连接累积。
|
||||
config.RuntimeParams = maps.Clone(config.RuntimeParams)
|
||||
if config.RuntimeParams == nil {
|
||||
config.RuntimeParams = make(map[string]string)
|
||||
}
|
||||
if err := applyLibpqKeepalives(config); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := config.RuntimeParams["idle_in_transaction_session_timeout"]; !exists {
|
||||
// PostgreSQL 按顺序处理 options,DSN 原有 options 放在后面,同名设置以 DSN 为准。
|
||||
options := "-c idle_in_transaction_session_timeout=" + defaultIdleInTransactionSessionTimeout
|
||||
if existing := strings.TrimSpace(config.RuntimeParams["options"]); existing != "" {
|
||||
options += " " + existing
|
||||
}
|
||||
config.RuntimeParams["options"] = options
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyLibpqKeepalives 消费 libpq 的 keepalives* 参数并应用到 TCP 拨号器。
|
||||
// pgx 不识别这些参数,会把它们当作服务端运行参数发送,导致 PostgreSQL 拒绝连接。
|
||||
// 未配置任何 keepalives* 参数时保持 pgx 默认拨号器(Go 默认 keepalive)不变。
|
||||
func applyLibpqKeepalives(config *pgx.ConnConfig) error {
|
||||
values := make(map[string]int)
|
||||
for _, key := range libpqKeepaliveParams {
|
||||
raw, ok := config.RuntimeParams[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delete(config.RuntimeParams, key)
|
||||
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || value < 0 || (key == "keepalives" && value > 1) {
|
||||
return fmt.Errorf("invalid PostgreSQL connection parameter %s=%q", key, raw)
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: config.ConnectTimeout}
|
||||
if enabled, ok := values["keepalives"]; ok && enabled == 0 {
|
||||
dialer.KeepAlive = -1
|
||||
} else {
|
||||
// 未配置的项使用 Go 默认值(0);libpq 的 0 表示操作系统默认值,对应 Go 的 -1。
|
||||
setting := func(key string) int {
|
||||
value, ok := values[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
if value == 0 {
|
||||
return -1
|
||||
}
|
||||
return value
|
||||
}
|
||||
dialer.KeepAliveConfig = net.KeepAliveConfig{
|
||||
Enable: true,
|
||||
Idle: time.Duration(setting("keepalives_idle")) * time.Second,
|
||||
Interval: time.Duration(setting("keepalives_interval")) * time.Second,
|
||||
Count: setting("keepalives_count"),
|
||||
}
|
||||
}
|
||||
config.DialFunc = dialer.DialContext
|
||||
return nil
|
||||
}
|
||||
81
backend/internal/database/postgres_connection_linux_test.go
Normal file
81
backend/internal/database/postgres_connection_linux_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// clientSocketKeepalive 在当前进程中按端口找到客户端 socket,读取其 keepalive 选项。
|
||||
func clientSocketKeepalive(port int) ([4]int, bool) {
|
||||
entries, err := os.ReadDir("/proc/self/fd")
|
||||
if err != nil {
|
||||
return [4]int{}, false
|
||||
}
|
||||
for _, entry := range entries {
|
||||
fd, err := strconv.Atoi(entry.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
addr, err := syscall.Getsockname(fd)
|
||||
inet, ok := addr.(*syscall.SockaddrInet4)
|
||||
if err != nil || !ok || inet.Port != port {
|
||||
continue
|
||||
}
|
||||
var values [4]int
|
||||
for i, option := range [][2]int{
|
||||
{syscall.SOL_SOCKET, syscall.SO_KEEPALIVE},
|
||||
{syscall.IPPROTO_TCP, syscall.TCP_KEEPIDLE},
|
||||
{syscall.IPPROTO_TCP, syscall.TCP_KEEPINTVL},
|
||||
{syscall.IPPROTO_TCP, syscall.TCP_KEEPCNT},
|
||||
} {
|
||||
if values[i], err = syscall.GetsockoptInt(fd, option[0], option[1]); err != nil {
|
||||
return [4]int{}, false
|
||||
}
|
||||
}
|
||||
return values, true
|
||||
}
|
||||
return [4]int{}, false
|
||||
}
|
||||
|
||||
func TestPostgresOpenAppliesLibpqKeepaliveToSocket(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, params string
|
||||
want [4]int
|
||||
}{
|
||||
{"explicit", "keepalives_idle=90 keepalives_interval=20 keepalives_count=6", [4]int{1, 90, 20, 6}},
|
||||
{"partial keeps Go defaults", "keepalives_idle=45", [4]int{1, 45, 15, 9}},
|
||||
{"no params keeps Go defaults", "", [4]int{1, 15, 15, 9}},
|
||||
{"disabled", "keepalives=0", [4]int{0, -1, -1, -1}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := newFakePostgresServer(t)
|
||||
var mu sync.Mutex
|
||||
var observed [][4]int
|
||||
server.onStartup = func(conn net.Conn) {
|
||||
// 已收到启动消息,说明客户端拨号已完成且 socket 选项已设置。
|
||||
values, ok := clientSocketKeepalive(conn.RemoteAddr().(*net.TCPAddr).Port)
|
||||
if !ok {
|
||||
values = [4]int{-2, -2, -2, -2}
|
||||
}
|
||||
mu.Lock()
|
||||
observed = append(observed, values)
|
||||
mu.Unlock()
|
||||
}
|
||||
openFakePostgres(t, server, tc.params, 0)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, values := range observed {
|
||||
if values[0] == -2 {
|
||||
t.Fatal("client socket not found")
|
||||
}
|
||||
if values[0] != tc.want[0] || (tc.want[0] == 1 && values != tc.want) {
|
||||
t.Fatalf("keepalive (enabled, idle, interval, count) = %v, want %v", values, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
161
backend/internal/database/postgres_connection_test.go
Normal file
161
backend/internal/database/postgres_connection_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakePostgresServer 只实现启动握手和简单查询,用于检查客户端发送的启动参数。
|
||||
type fakePostgresServer struct {
|
||||
listener net.Listener
|
||||
onStartup func(conn net.Conn)
|
||||
mu sync.Mutex
|
||||
startups []map[string]string
|
||||
}
|
||||
|
||||
func newFakePostgresServer(t *testing.T) *fakePostgresServer {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &fakePostgresServer{listener: listener}
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go server.serve(conn)
|
||||
}
|
||||
}()
|
||||
return server
|
||||
}
|
||||
|
||||
func (s *fakePostgresServer) port() int {
|
||||
return s.listener.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func (s *fakePostgresServer) recorded() []map[string]string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]map[string]string(nil), s.startups...)
|
||||
}
|
||||
|
||||
func (s *fakePostgresServer) serve(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
var size uint32
|
||||
if err := binary.Read(conn, binary.BigEndian, &size); err != nil || size < 8 {
|
||||
return
|
||||
}
|
||||
body := make([]byte, size-4)
|
||||
if _, err := io.ReadFull(conn, body); err != nil {
|
||||
return
|
||||
}
|
||||
params := map[string]string{}
|
||||
fields := bytes.Split(body[4:], []byte{0})
|
||||
for i := 0; i+1 < len(fields) && len(fields[i]) > 0; i += 2 {
|
||||
params[string(fields[i])] = string(fields[i+1])
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.startups = append(s.startups, params)
|
||||
s.mu.Unlock()
|
||||
if s.onStartup != nil {
|
||||
s.onStartup(conn)
|
||||
}
|
||||
// AuthenticationOk + ReadyForQuery
|
||||
_, _ = conn.Write([]byte{'R', 0, 0, 0, 8, 0, 0, 0, 0, 'Z', 0, 0, 0, 5, 'I'})
|
||||
header := make([]byte, 5)
|
||||
for {
|
||||
if _, err := io.ReadFull(conn, header); err != nil {
|
||||
return
|
||||
}
|
||||
payload := make([]byte, binary.BigEndian.Uint32(header[1:])-4)
|
||||
if _, err := io.ReadFull(conn, payload); err != nil {
|
||||
return
|
||||
}
|
||||
switch header[0] {
|
||||
case 'Q': // EmptyQueryResponse + ReadyForQuery
|
||||
_, _ = conn.Write([]byte{'I', 0, 0, 0, 4, 'Z', 0, 0, 0, 5, 'I'})
|
||||
case 'X':
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openFakePostgres(t *testing.T, server *fakePostgresServer, params string, pings int) []map[string]string {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("host=127.0.0.1 port=%d user=test dbname=test sslmode=disable %s", server.port(), params)
|
||||
db, err := Open(Config{Driver: "postgres", DSN: dsn})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
// 不保留空闲连接,每次 Ping 都会建立新的物理连接。
|
||||
sqlDB.SetMaxIdleConns(0)
|
||||
for range pings {
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
}
|
||||
startups := server.recorded()
|
||||
if len(startups) < pings+1 {
|
||||
t.Fatalf("startup count = %d, want at least %d", len(startups), pings+1)
|
||||
}
|
||||
return startups
|
||||
}
|
||||
|
||||
func TestPostgresOpenConsumesLibpqKeepaliveParams(t *testing.T) {
|
||||
server := newFakePostgresServer(t)
|
||||
startups := openFakePostgres(t, server, "keepalives=1 keepalives_idle=90 keepalives_interval=20 keepalives_count=6", 0)
|
||||
for _, params := range startups {
|
||||
for key := range params {
|
||||
if strings.HasPrefix(key, "keepalives") {
|
||||
t.Fatalf("libpq parameter %s sent to server: %v", key, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresOpenRejectsInvalidKeepaliveParam(t *testing.T) {
|
||||
server := newFakePostgresServer(t)
|
||||
dsn := fmt.Sprintf("host=127.0.0.1 port=%d user=test dbname=test sslmode=disable keepalives_idle=abc", server.port())
|
||||
if _, err := Open(Config{Driver: "postgres", DSN: dsn}); err == nil || !strings.Contains(err.Error(), "keepalives_idle") {
|
||||
t.Fatalf("expected invalid keepalives_idle error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresOpenSetsIdleInTransactionTimeoutPerConnection(t *testing.T) {
|
||||
server := newFakePostgresServer(t)
|
||||
startups := openFakePostgres(t, server, "options='-c statement_timeout=4s'", 3)
|
||||
want := "-c idle_in_transaction_session_timeout=5min -c statement_timeout=4s"
|
||||
for i, params := range startups {
|
||||
// 每条连接都应得到相同的 options,不能因共享 RuntimeParams 而跨连接累积。
|
||||
if params["options"] != want {
|
||||
t.Fatalf("connection %d options = %q, want %q", i, params["options"], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresOpenKeepsExplicitIdleInTransactionTimeout(t *testing.T) {
|
||||
server := newFakePostgresServer(t)
|
||||
startups := openFakePostgres(t, server, "idle_in_transaction_session_timeout=0 options='-c statement_timeout=4s'", 1)
|
||||
for i, params := range startups {
|
||||
if params["idle_in_transaction_session_timeout"] != "0" || params["options"] != "-c statement_timeout=4s" {
|
||||
t.Fatalf("connection %d overrode explicit timeout: %v", i, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user