From 1522c87c745c64e5ff7b3637d727476837ce0d7d Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Thu, 23 Apr 2026 15:54:56 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20statsRepo=20?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E7=AB=9E=E6=80=81=E6=9D=A1=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20upsert=20=E4=BF=9D=E8=AF=81=E5=8E=9F?= =?UTF-8?q?=E5=AD=90=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 GORM clause.OnConflict 替代事务包装 - Record 和 BatchUpdate 方法改用 upsert 模式 - 修复 UsageStats 的 GORM struct tag,确保 AutoMigrate 创建正确的 UNIQUE 约束 - 更新 usage-statistics spec 以反映 upsert 操作 MySQL 并发测试验证:10 并发调用 → request_count = 10 --- backend/internal/config/models.go | 4 +- .../internal/repository/stats_repo_impl.go | 70 +++++++++---------- openspec/specs/usage-statistics/spec.md | 35 ++++++++-- 3 files changed, 63 insertions(+), 46 deletions(-) diff --git a/backend/internal/config/models.go b/backend/internal/config/models.go index 628d184..8add81e 100644 --- a/backend/internal/config/models.go +++ b/backend/internal/config/models.go @@ -29,8 +29,8 @@ type Model struct { // UsageStats 用量统计 type UsageStats struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - ProviderID string `gorm:"not null;index" json:"provider_id"` - ModelName string `gorm:"not null;index" json:"model_name"` + ProviderID string `gorm:"not null;index;uniqueIndex:idx_provider_model_date" json:"provider_id"` + ModelName string `gorm:"not null;index;uniqueIndex:idx_provider_model_date" json:"model_name"` RequestCount int `gorm:"default:0" json:"request_count"` Date time.Time `gorm:"type:date;not null;uniqueIndex:idx_provider_model_date" json:"date"` } diff --git a/backend/internal/repository/stats_repo_impl.go b/backend/internal/repository/stats_repo_impl.go index 7692b33..ca9e7d8 100644 --- a/backend/internal/repository/stats_repo_impl.go +++ b/backend/internal/repository/stats_repo_impl.go @@ -1,10 +1,10 @@ package repository import ( - "errors" "time" "gorm.io/gorm" + "gorm.io/gorm/clause" "nex/backend/internal/config" "nex/backend/internal/domain" @@ -22,47 +22,43 @@ func (r *statsRepository) Record(providerID, modelName string) error { today := time.Now().Format("2006-01-02") todayTime, _ := time.Parse("2006-01-02", today) - return r.db.Transaction(func(tx *gorm.DB) error { - var stats config.UsageStats - err := tx.Where("provider_id = ? AND model_name = ? AND date = ?", - providerID, modelName, todayTime).First(&stats).Error + stats := config.UsageStats{ + ProviderID: providerID, + ModelName: modelName, + RequestCount: 1, + Date: todayTime, + } - if errors.Is(err, gorm.ErrRecordNotFound) { - stats = config.UsageStats{ - ProviderID: providerID, - ModelName: modelName, - RequestCount: 1, - Date: todayTime, - } - return tx.Create(&stats).Error - } else if err != nil { - return err - } - - return tx.Model(&stats).Update("request_count", gorm.Expr("request_count + 1")).Error - }) + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "provider_id"}, + {Name: "model_name"}, + {Name: "date"}, + }, + DoUpdates: clause.Assignments(map[string]interface{}{ + "request_count": gorm.Expr("request_count + 1"), + }), + }).Create(&stats).Error } func (r *statsRepository) BatchUpdate(providerID, modelName string, date time.Time, delta int) error { - return r.db.Transaction(func(tx *gorm.DB) error { - var stats config.UsageStats - err := tx.Where("provider_id = ? AND model_name = ? AND date = ?", - providerID, modelName, date).First(&stats).Error + stats := config.UsageStats{ + ProviderID: providerID, + ModelName: modelName, + RequestCount: delta, + Date: date, + } - if errors.Is(err, gorm.ErrRecordNotFound) { - return tx.Create(&config.UsageStats{ - ProviderID: providerID, - ModelName: modelName, - RequestCount: delta, - Date: date, - }).Error - } else if err != nil { - return err - } - - return tx.Model(&stats). - Update("request_count", gorm.Expr("request_count + ?", delta)).Error - }) + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "provider_id"}, + {Name: "model_name"}, + {Name: "date"}, + }, + DoUpdates: clause.Assignments(map[string]interface{}{ + "request_count": gorm.Expr("request_count + ?", delta), + }), + }).Create(&stats).Error } func (r *statsRepository) Query(providerID, modelName string, startDate, endDate *time.Time) ([]domain.UsageStats, error) { diff --git a/openspec/specs/usage-statistics/spec.md b/openspec/specs/usage-statistics/spec.md index 7c5d4d0..abca1fa 100644 --- a/openspec/specs/usage-statistics/spec.md +++ b/openspec/specs/usage-statistics/spec.md @@ -93,7 +93,21 @@ - **WHEN** 同时处理多个并发请求 - **THEN** 网关 SHALL 使用原子操作正确增加每个请求的计数 - **THEN** 不 SHALL 因并发写入而丢失统计 -- **THEN** SHALL 使用 StatsBuffer 的内存计数器 +- **THEN** SHALL 使用 upsert 操作保证原子性 + +#### Scenario: 并发调用 Record 方法 + +- **WHEN** 多个 goroutine 并发调用 StatsRepository.Record +- **THEN** SHALL 使用 INSERT ... ON DUPLICATE KEY UPDATE (MySQL) 或 INSERT ... ON CONFLICT DO UPDATE (SQLite) +- **THEN** SHALL 保证所有并发调用的计数都被正确累加 +- **THEN** 不 SHALL 因 UNIQUE 约束冲突而丢失数据 + +#### Scenario: 并发调用 BatchUpdate 方法 + +- **WHEN** 多个 goroutine 并发调用 StatsRepository.BatchUpdate +- **THEN** SHALL 使用 upsert 操作保证原子性 +- **THEN** SHALL 正确累加所有 delta 值 +- **THEN** 不 SHALL 因并发写入而丢失统计 ### Requirement: 使用 service 层处理业务逻辑 @@ -125,14 +139,14 @@ Service SHALL 通过 StatsRepository 访问数据。 - **WHEN** StatsBuffer 刷新统计 - **THEN** SHALL 调用 StatsRepository.BatchUpdate -- **THEN** SHALL 使用事务更新或创建统计记录 +- **THEN** SHALL 使用 upsert 操作更新或创建统计记录 - **THEN** SHALL 支持增量更新(request_count + delta) -#### Scenario: 事务处理 +#### Scenario: upsert 操作 - **WHEN** 记录统计 -- **THEN** SHALL 在 repository 层使用数据库事务 -- **THEN** SHALL 确保并发安全 +- **THEN** SHALL 在 repository 层使用 upsert 操作 +- **THEN** SHALL 保证原子性和并发安全 ### Requirement: 统计查询优化 @@ -168,11 +182,18 @@ StatsRepository SHALL 新增 BatchUpdate 方法支持批量增量更新。 #### Scenario: BatchUpdate 更新现有记录 - **WHEN** 调用 BatchUpdate 且当日记录存在 -- **THEN** SHALL 使用事务更新 request_count = request_count + delta +- **THEN** SHALL 使用 upsert 操作更新 request_count = request_count + delta +- **THEN** SHALL 保证原子性,无竞态条件 - **THEN** SHALL 不创建新记录 #### Scenario: BatchUpdate 创建新记录 - **WHEN** 调用 BatchUpdate 且当日记录不存在 - **THEN** SHALL 创建新记录,request_count = delta -- **THEN** SHALL 使用事务保证原子性 +- **THEN** SHALL 使用 upsert 操作保证原子性 + +#### Scenario: BatchUpdate 并发安全 + +- **WHEN** 多个 BatchUpdate 调用同时执行 +- **THEN** SHALL 保证所有 delta 都被正确累加 +- **THEN** SHALL 不因并发冲突而丢失数据