Browse Source

fix(services):MySQL 建表同步遇到并发 DDL 冲突时重试,修正式服 0.2.9 home 启动 panic

业务容器里 gateway/home/api/mcp/timer 同时拉起,echomeet_record 由 home 的 echomeet 模块与 api 模块
同时 AutoMigrate。AutoMigrate 是「查列在不在 → ALTER」两步非原子,两边都查到缺列、都去加,
后到者报 1060 Duplicate column,模块初始化失败 panic,home 的接口全部 code:11。
2026-09-26 正式服发 0.2.9(新加 computeuser/computedevices 两列)时 api 抢先加成功,home 挂了几分钟,
已回滚 0.2.8 恢复。测试服当时是先后顺序错开才没撞上。

CreateTable 遇到 1050/1060/1061(对方已建好表/列/索引)时等 0.5s/1s 再同步,最多 3 次;
其它错误照旧直接返回。postgres 的 CreateTable 表已存在即跳过、不加列,没有这个竞态,未改。

验证:go build/vet 通过,TestIsConcurrentDDLConflict 通过。

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 2 weeks ago
parent
commit
f9e18480bd
  1. 26
      apps/services/lego/sys/mysql/createtable_test.go
  2. 35
      apps/services/lego/sys/mysql/mysql.go

26
apps/services/lego/sys/mysql/createtable_test.go

@ -0,0 +1,26 @@
package mysql
import (
"errors"
"fmt"
"testing"
mysqldrv "github.com/go-sql-driver/mysql"
)
// 只有「对方已经建好了」这类冲突才重试;其余错误(权限、语法、连接断开)照旧直接返回。
func TestIsConcurrentDDLConflict(t *testing.T) {
cases := map[error]bool{
&mysqldrv.MySQLError{Number: 1060, Message: "Duplicate column name 'computeuser'"}: true,
&mysqldrv.MySQLError{Number: 1050, Message: "Table already exists"}: true,
&mysqldrv.MySQLError{Number: 1061, Message: "Duplicate key name"}: true,
fmt.Errorf("wrap: %w", &mysqldrv.MySQLError{Number: 1060}): true,
&mysqldrv.MySQLError{Number: 1142, Message: "ALTER command denied"}: false,
errors.New("driver: bad connection"): false,
}
for err, want := range cases {
if got := isConcurrentDDLConflict(err); got != want {
t.Errorf("%v → %v, want %v", err, got, want)
}
}
}

35
apps/services/lego/sys/mysql/mysql.go

@ -1,8 +1,11 @@
package mysql package mysql
import ( import (
"errors"
"fmt" "fmt"
"time"
mysqldrv "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -51,11 +54,41 @@ func (this *MySql) Raw(sql string, values ...interface{}) (tx *gorm.DB) {
return return
} }
// CreateTable 建表 / 同步表结构(gorm AutoMigrate)。
//
// ⚠️ 同一张表可能被多个进程在启动时同时同步:业务容器里 gateway/home/api/mcp/timer 是一起拉起的,
// 例如 echomeet_record 就同时由 home 的 echomeet 模块与 api 模块建。AutoMigrate 是「先查列在不在、
// 再 ALTER」两步,不是原子的——两边都查到缺列、都去加,后到的那个必然报 1060 Duplicate column,
// 调用方把它当初始化失败,整个模块 panic、接口全部 code:11。2026-09-26 正式服发 0.2.9 时
// home 就这样挂了几分钟(新加 computeuser/computedevices 两列,api 抢先加成功)。
// 所以遇到这类「对方已经建好了」的冲突,等一下再同步一次:第二遍能看到对方加好的列,自然通过。
func (this *MySql) CreateTable(tName string, model any) (err error) { func (this *MySql) CreateTable(tName string, model any) (err error) {
err = this.db.Table(tName).AutoMigrate(model) for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
}
if err = this.db.Table(tName).AutoMigrate(model); err == nil || !isConcurrentDDLConflict(err) {
return
}
this.options.Log.Warnf("mysql CreateTable %s 与其它进程并发建表冲突,稍后重试(第%d次): %v", tName, attempt+1, err)
}
return return
} }
// isConcurrentDDLConflict 是否是「别的进程已经把这个表/列/索引建好了」这一类冲突:
// 1050 表已存在、1060 列已存在、1061 索引名已存在。
func isConcurrentDDLConflict(err error) bool {
var me *mysqldrv.MySQLError
if !errors.As(err, &me) {
return false
}
switch me.Number {
case 1050, 1060, 1061:
return true
}
return false
}
// 获取表对象 // 获取表对象
func (this *MySql) Table(tName string) (tx *gorm.DB) { func (this *MySql) Table(tName string) (tx *gorm.DB) {
tx = this.db.Table(tName) tx = this.db.Table(tName)

Loading…
Cancel
Save