Files
seaweedfs/weed/filer/mysql/mysql_store.go
T
Chris Lu 21b4b81edb fix(filer/postgres): default to ON CONFLICT upsert to keep tx alive (#9709)
* fix(filer/postgres): default to ON CONFLICT upsert to keep tx alive

A KvPut from the inode-index secondary write could fail with 23505
(duplicate key) inside a rename's transaction, after which the next
statement returned 25P02 and rename surfaced to FUSE as EIO. Default
the postgres upsert query when enableUpsert=true so INSERTs are
idempotent; the enableUpsert=false escape hatch is preserved for
non-PG-compatible backends.

* fix(filer/mysql): default to ON DUPLICATE KEY UPDATE upsert

Same shape as the postgres default: when enableUpsert=true but no
upsertQuery is configured, install a sensible default so the
inode-index KvPut does not waste a duplicate-key roundtrip on every
entry write. Uses the VALUES() form so the default works on MariaDB
and MySQL >=5.7; the MySQL 8.0.19 row-alias form is left to explicit
config.

* fix(filer): default enableUpsert=true for sql stores

The default-template fallback only kicks in when enableUpsert=true,
so minimal configs that omit the flag entirely were still exposed.
Default it on for postgres/postgres2/mysql/mysql2; an explicit false
in filer.toml still wins because SetDefault only fills absent keys.
2026-05-27 12:23:30 -07:00

167 lines
5.2 KiB
Go

package mysql
import (
"crypto/tls"
"crypto/x509"
"database/sql"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/filer/abstract_sql"
"github.com/seaweedfs/seaweedfs/weed/util"
)
const (
CONNECTION_URL_PATTERN = "%s:%s@tcp(%s:%d)/%s?collation=utf8mb4_bin"
)
func init() {
filer.Stores = append(filer.Stores, &MysqlStore{})
}
type MysqlStore struct {
abstract_sql.AbstractSqlStore
}
func (store *MysqlStore) GetName() string {
return "mysql"
}
func (store *MysqlStore) Initialize(configuration util.Configuration, prefix string) (err error) {
// Absent key keeps a pooled default; an explicit 0 disables the idle pool.
configuration.SetDefault(prefix+"connection_max_idle", 2)
// Default on so minimal configs avoid the duplicate-key roundtrip the
// inode-index KvPut would otherwise emit on every write.
configuration.SetDefault(prefix+"enableUpsert", true)
return store.initialize(
configuration.GetString(prefix+"dsn"),
configuration.GetString(prefix+"upsertQuery"),
configuration.GetBool(prefix+"enableUpsert"),
configuration.GetString(prefix+"username"),
configuration.GetString(prefix+"password"),
configuration.GetString(prefix+"hostname"),
configuration.GetInt(prefix+"port"),
configuration.GetString(prefix+"database"),
configuration.GetInt(prefix+"connection_max_idle"),
configuration.GetInt(prefix+"connection_max_open"),
configuration.GetInt(prefix+"connection_max_lifetime_seconds"),
configuration.GetBool(prefix+"interpolateParams"),
configuration.GetBool(prefix+"enable_tls"),
configuration.GetString(prefix+"ca_crt"),
configuration.GetString(prefix+"client_crt"),
configuration.GetString(prefix+"client_key"),
configuration.GetBool(prefix+"tls_insecure_skip_verify"),
configuration.GetString(prefix+"tls_server_name"),
)
}
func (store *MysqlStore) initialize(dsn string, upsertQuery string, enableUpsert bool, user, password, hostname string, port int, database string, maxIdle, maxOpen,
maxLifetimeSeconds int, interpolateParams bool, enableTls bool, caCrtDir string, clientCrtDir string, clientKeyDir string,
tlsInsecureSkipVerify bool, tlsServerName string) (err error) {
store.SupportBucketTable = false
if !enableUpsert {
upsertQuery = ""
} else if upsertQuery == "" {
upsertQuery = DefaultUpsertQuery
}
store.SqlGenerator = &SqlGenMysql{
CreateTableSqlTemplate: "",
DropTableSqlTemplate: "DROP TABLE `%s`",
UpsertQueryTemplate: upsertQuery,
}
store.RetryableErrorCallback = func(err error) bool {
var mysqlError *mysql.MySQLError
if errors.As(err, &mysqlError) {
if mysqlError.Number == 1213 { // ER_LOCK_DEADLOCK
return true
}
if mysqlError.Number == 1205 { // ER_LOCK_WAIT_TIMEOUT
return true
}
}
return false
}
if dsn == "" {
dsn = fmt.Sprintf(CONNECTION_URL_PATTERN, user, password, hostname, port, database)
if interpolateParams {
dsn += "&interpolateParams=true"
}
}
cfg, err := mysql.ParseDSN(dsn)
if err != nil {
return fmt.Errorf("can not parse DSN error:%w", err)
}
if enableTls {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: tlsInsecureSkipVerify,
ServerName: tlsServerName,
}
// When ca_crt is empty, leave RootCAs nil so Go falls back to the
// system trust store. This is the common case for managed databases
// (RDS, Aiven, ...) whose certs chain to a public CA already on the host.
if caCrtDir != "" {
rootCertPool := x509.NewCertPool()
pem, err := os.ReadFile(caCrtDir)
if err != nil {
return fmt.Errorf("read ca_crt %s: %w", caCrtDir, err)
}
if ok := rootCertPool.AppendCertsFromPEM(pem); !ok {
return fmt.Errorf("failed to append root certificate from %s", caCrtDir)
}
tlsConfig.RootCAs = rootCertPool
}
// Only attempt to load a client keypair when at least one of the paths is
// set. If either is set, both must load successfully — silently skipping
// a typo'd path used to mask broken mTLS setups as confusing handshake
// failures.
if clientCrtDir != "" || clientKeyDir != "" {
cert, err := tls.LoadX509KeyPair(clientCrtDir, clientKeyDir)
if err != nil {
return fmt.Errorf("load mysql client keypair (crt=%s key=%s): %w", clientCrtDir, clientKeyDir, err)
}
tlsConfig.Certificates = []tls.Certificate{cert}
}
// Set TLS directly on the parsed Config rather than registering a global
// "mysql-tls" entry — the global registry is process-wide and would be
// overwritten if a second MysqlStore is initialized with different TLS
// settings.
cfg.TLS = tlsConfig
}
connector, err := mysql.NewConnector(cfg)
if err != nil {
return fmt.Errorf("can not create mysql connector for %s error:%w", maskedDSN(cfg), err)
}
store.DB = sql.OpenDB(connector)
store.DB.SetMaxIdleConns(maxIdle)
store.DB.SetMaxOpenConns(maxOpen)
store.DB.SetConnMaxLifetime(time.Duration(maxLifetimeSeconds) * time.Second)
if err = store.DB.Ping(); err != nil {
return fmt.Errorf("connect to %s error:%v", maskedDSN(cfg), err)
}
return nil
}
func maskedDSN(cfg *mysql.Config) string {
if cfg.Passwd == "" {
return cfg.FormatDSN()
}
return strings.ReplaceAll(cfg.FormatDSN(), cfg.Passwd, "<ADAPTED>")
}