From f9e18480bd45f440c81da1258eba447ccb90a304 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Sat, 26 Sep 2026 10:14:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(services)=EF=BC=9AMySQL=20=E5=BB=BA?= =?UTF-8?q?=E8=A1=A8=E5=90=8C=E6=AD=A5=E9=81=87=E5=88=B0=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=20DDL=20=E5=86=B2=E7=AA=81=E6=97=B6=E9=87=8D=E8=AF=95=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=BC=8F=E6=9C=8D=200.2.9=20home=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=20panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务容器里 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) --- .../lego/sys/mysql/createtable_test.go | 26 ++++++++++++++ apps/services/lego/sys/mysql/mysql.go | 35 ++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 apps/services/lego/sys/mysql/createtable_test.go diff --git a/apps/services/lego/sys/mysql/createtable_test.go b/apps/services/lego/sys/mysql/createtable_test.go new file mode 100644 index 00000000..b098ee76 --- /dev/null +++ b/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) + } + } +} diff --git a/apps/services/lego/sys/mysql/mysql.go b/apps/services/lego/sys/mysql/mysql.go index 2708b539..547a3406 100644 --- a/apps/services/lego/sys/mysql/mysql.go +++ b/apps/services/lego/sys/mysql/mysql.go @@ -1,8 +1,11 @@ package mysql import ( + "errors" "fmt" + "time" + mysqldrv "github.com/go-sql-driver/mysql" "gorm.io/driver/mysql" "gorm.io/gorm" ) @@ -51,11 +54,41 @@ func (this *MySql) Raw(sql string, values ...interface{}) (tx *gorm.DB) { 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) { - 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 } +// 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) { tx = this.db.Table(tName)