222 lines
5.6 KiB
Go
222 lines
5.6 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ClickHouse/clickhouse-go/v2"
|
|
)
|
|
|
|
// Pool 封装 ClickHouse 连接并维护健康状态,DB 故障时不阻塞调用方。
|
|
type Pool struct {
|
|
dsn string
|
|
reconnectInterval time.Duration
|
|
mu sync.RWMutex
|
|
conn clickhouse.Conn
|
|
healthy bool
|
|
}
|
|
|
|
// NewPool 创建 Pool。即使首次连接失败也返回非 nil 实例,后台会持续重试。
|
|
func NewPool(ctx context.Context, dsn string, reconnectInterval time.Duration) *Pool {
|
|
p := &Pool{dsn: dsn, reconnectInterval: reconnectInterval}
|
|
if err := p.connect(ctx); err != nil {
|
|
log.Printf("[db] initial connect failed: %v (service continues without DB)", err)
|
|
}
|
|
go p.watch(ctx)
|
|
return p
|
|
}
|
|
|
|
func (p *Pool) connect(ctx context.Context) error {
|
|
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
opts, err := ClickHouseOptions(p.dsn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
conn, err := clickhouse.Open(opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := conn.Ping(cctx); err != nil {
|
|
_ = conn.Close()
|
|
return err
|
|
}
|
|
// 先 migrate,再 swap:避免新连接 migrate 失败时取代掉旧的可用连接。
|
|
mctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
if err := migrate(mctx, conn); err != nil {
|
|
_ = conn.Close()
|
|
log.Printf("[db] migrate failed: %v", err)
|
|
return err
|
|
}
|
|
p.mu.Lock()
|
|
if p.conn != nil {
|
|
_ = p.conn.Close()
|
|
}
|
|
p.conn = conn
|
|
p.healthy = true
|
|
p.mu.Unlock()
|
|
log.Printf("[db] connected and migrated")
|
|
return nil
|
|
}
|
|
|
|
// ClickHouseOptions converts the supported CLICKHOUSE_URL subset into driver options.
|
|
func ClickHouseOptions(raw string) (*clickhouse.Options, error) {
|
|
if !strings.Contains(raw, "://") {
|
|
if err := validateHostPort(raw); err != nil {
|
|
return nil, fmt.Errorf("invalid CLICKHOUSE_URL: %w", err)
|
|
}
|
|
return &clickhouse.Options{Addr: []string{raw}}, nil
|
|
}
|
|
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid CLICKHOUSE_URL: %w", err)
|
|
}
|
|
if u.Scheme != "clickhouse" && u.Scheme != "clickhouses" {
|
|
return nil, fmt.Errorf("invalid CLICKHOUSE_URL: unsupported scheme %q", u.Scheme)
|
|
}
|
|
if u.Fragment != "" {
|
|
return nil, fmt.Errorf("invalid CLICKHOUSE_URL: fragment is not allowed")
|
|
}
|
|
if err := validateHostPort(u.Host); err != nil {
|
|
return nil, fmt.Errorf("invalid CLICKHOUSE_URL: %w", err)
|
|
}
|
|
|
|
query, err := url.ParseQuery(u.RawQuery)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid CLICKHOUSE_URL: query: %w", err)
|
|
}
|
|
for key, values := range query {
|
|
switch key {
|
|
case "secure", "skip_verify", "compress":
|
|
default:
|
|
return nil, fmt.Errorf("invalid CLICKHOUSE_URL: unsupported parameter %q", key)
|
|
}
|
|
if len(values) != 1 {
|
|
return nil, fmt.Errorf("invalid CLICKHOUSE_URL: parameter %q must occur once", key)
|
|
}
|
|
}
|
|
|
|
wantSecure := u.Scheme == "clickhouses"
|
|
if value, ok := query["secure"]; ok {
|
|
secure, err := strconv.ParseBool(value[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid CLICKHOUSE_URL: secure: %w", err)
|
|
}
|
|
if secure != wantSecure {
|
|
return nil, fmt.Errorf("invalid CLICKHOUSE_URL: secure conflicts with %s", u.Scheme)
|
|
}
|
|
}
|
|
if _, ok := query["skip_verify"]; ok && !wantSecure {
|
|
return nil, fmt.Errorf("invalid CLICKHOUSE_URL: skip_verify requires clickhouses")
|
|
}
|
|
if value, ok := query["skip_verify"]; ok {
|
|
if _, err := strconv.ParseBool(value[0]); err != nil {
|
|
return nil, fmt.Errorf("invalid CLICKHOUSE_URL: skip_verify: %w", err)
|
|
}
|
|
}
|
|
if value, ok := query["compress"]; ok {
|
|
switch value[0] {
|
|
case "true", "false", "none", "zstd", "lz4", "lz4hc", "gzip", "deflate", "br":
|
|
default:
|
|
return nil, fmt.Errorf("invalid CLICKHOUSE_URL: unsupported compression %q", value[0])
|
|
}
|
|
}
|
|
|
|
// Let the driver decode userinfo/path and interpret compression and TLS values.
|
|
if wantSecure {
|
|
query.Set("secure", "true")
|
|
u.RawQuery = query.Encode()
|
|
}
|
|
opts, err := clickhouse.ParseDSN(u.String())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid CLICKHOUSE_URL: %w", err)
|
|
}
|
|
return opts, nil
|
|
}
|
|
|
|
func validateHostPort(address string) error {
|
|
if address == "" {
|
|
return fmt.Errorf("missing host and port")
|
|
}
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return fmt.Errorf("expected host:port: %w", err)
|
|
}
|
|
if host == "" {
|
|
return fmt.Errorf("missing host")
|
|
}
|
|
n, err := strconv.ParseUint(port, 10, 16)
|
|
if err != nil || n == 0 {
|
|
return fmt.Errorf("invalid port %q", port)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// watch 定期探活;不健康时尝试重连。
|
|
func (p *Pool) watch(ctx context.Context) {
|
|
t := time.NewTicker(p.reconnectInterval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
if p.Healthy() {
|
|
if conn := p.Get(); conn != nil {
|
|
pctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
if err := conn.Ping(pctx); err != nil {
|
|
log.Printf("[db] ping failed, marking unhealthy: %v", err)
|
|
p.MarkUnhealthy()
|
|
}
|
|
cancel()
|
|
}
|
|
continue
|
|
}
|
|
if err := p.connect(ctx); err != nil {
|
|
log.Printf("[db] reconnect failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Healthy 报告连接是否可用。
|
|
func (p *Pool) Healthy() bool {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
return p.healthy
|
|
}
|
|
|
|
// MarkUnhealthy 由调用方在写入失败后调用。
|
|
func (p *Pool) MarkUnhealthy() {
|
|
p.mu.Lock()
|
|
p.healthy = false
|
|
p.mu.Unlock()
|
|
}
|
|
|
|
// Get 返回当前连接,可能为 nil。
|
|
func (p *Pool) Get() clickhouse.Conn {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
return p.conn
|
|
}
|
|
|
|
// Close 释放底层连接。
|
|
func (p *Pool) Close() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.conn != nil {
|
|
_ = p.conn.Close()
|
|
p.conn = nil
|
|
}
|
|
p.healthy = false
|
|
}
|