feat: 添加模型并发控制与分布式锁
重新启用任务并发限制逻辑,使用 Redis 计数器控制模型并发数; 引入分布式锁防止重复创建,并优化优雅退出顺序确保请求完成; 移除 video_duration 计费计算,支持外部传入 taskId,为轮询查询添加独立超时上下文。
This commit is contained in:
+22
-23
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"model-gateway/service/gateway"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
@@ -159,28 +158,28 @@ func ExtractRequestBilling(ctx context.Context, config map[string]any, requestPa
|
||||
}
|
||||
}
|
||||
|
||||
if compute, ok := config["compute"].(map[string]any); ok {
|
||||
for targetField, rule := range compute {
|
||||
r := rule.(map[string]any)
|
||||
dependsOn := gconv.String(r["depends_on"])
|
||||
dependsValue := gconv.Bool(r["depends_value"])
|
||||
|
||||
if gconv.Bool(data[dependsOn]) != dependsValue {
|
||||
continue
|
||||
}
|
||||
|
||||
switch r["service"] {
|
||||
case "video_duration":
|
||||
urls := extractVideoUrls(requestPayload)
|
||||
if len(urls) > 0 {
|
||||
resp, err := gateway.GetVideoDuration(ctx, urls)
|
||||
if err == nil {
|
||||
data[targetField] = resp.TotalDuration
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//if compute, ok := config["compute"].(map[string]any); ok {
|
||||
// for targetField, rule := range compute {
|
||||
// r := rule.(map[string]any)
|
||||
// dependsOn := gconv.String(r["depends_on"])
|
||||
// dependsValue := gconv.Bool(r["depends_value"])
|
||||
//
|
||||
// if gconv.Bool(data[dependsOn]) != dependsValue {
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// switch r["service"] {
|
||||
// case "video_duration":
|
||||
// urls := extractVideoUrls(requestPayload)
|
||||
// if len(urls) > 0 {
|
||||
// resp, err := gateway.GetVideoDuration(ctx, urls)
|
||||
// if err == nil {
|
||||
// data[targetField] = resp.TotalDuration
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -40,9 +40,10 @@ func main() {
|
||||
<-quit
|
||||
|
||||
g.Log().Infof(ctx, "[main] 收到退出信号,开始优雅退出...")
|
||||
cancel()
|
||||
// 关闭 gateway server(RouteRegister 内部是 go Httpserver.Run() 启动的)
|
||||
// 先关闭 gateway server,等待 in-flight 请求处理完成
|
||||
_ = http.Httpserver.Shutdown()
|
||||
// 再取消上下文,避免活跃请求被中断
|
||||
cancel()
|
||||
}
|
||||
|
||||
func startAutoRunner(ctx context.Context) {
|
||||
|
||||
@@ -14,6 +14,7 @@ type CreateTaskReq struct {
|
||||
EpicycleId int64 `json:"epicycleId" dc:"轮次ID"`
|
||||
BuildType int64 `json:"buildType" dc:"构建类型:1-提示词构建 2-节点构建"`
|
||||
BuildModelName string `json:"buildModelName" json:"buildModelName" dc:"构建模型名称"`
|
||||
TaskId string `json:"taskId" dc:"任务ID"`
|
||||
}
|
||||
|
||||
type CreateTaskRes struct {
|
||||
|
||||
+190
-66
@@ -15,7 +15,10 @@ import (
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/database/gredis"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -26,7 +29,10 @@ type taskService struct{}
|
||||
|
||||
// Create 创建任务
|
||||
func (s *taskService) Create(ctx context.Context, req *dto.CreateTaskReq) (res *dto.CreateTaskRes, err error) {
|
||||
taskID := uuid.NewString()
|
||||
taskID := req.TaskId
|
||||
if taskID == "" {
|
||||
taskID = uuid.NewString()
|
||||
}
|
||||
startAt := time.Now()
|
||||
|
||||
// 1) 获取用户信息
|
||||
@@ -49,81 +55,196 @@ func (s *taskService) Create(ctx context.Context, req *dto.CreateTaskReq) (res *
|
||||
if model == nil || (model.Enabled != nil && *model.Enabled != 1) {
|
||||
return nil, errors.New("模型不存在或未启用")
|
||||
}
|
||||
lockKey := fmt.Sprintf("lock:tenantId-%s:model-%s", gconv.String(userInfo.TenantId), req.ModelName)
|
||||
success, e := Lock(ctx, lockKey, -1, int64(time.Minute.Seconds()*5), func(ctx context.Context) error {
|
||||
const (
|
||||
keyExpireSec = 600 // 计数Key兜底过期时间 10min
|
||||
waitInterval = 10 * time.Second // 轮询等待间隔
|
||||
)
|
||||
// Redis 操作统一使用独立上下文,避免外部 ctx canceled
|
||||
redisCtx := context.WithoutCancel(ctx)
|
||||
// 模型并发计数Key
|
||||
concurrencyKey := fmt.Sprintf("model:concurrency:%s", req.ModelName)
|
||||
maxCon := gconv.Int64(model.MaxConcurrency)
|
||||
|
||||
// TODO: 排队控制暂时关闭,后续需要时取消注释
|
||||
// limit := queue.GetRuntimeQueueLimit(ctx, req.ModelName, model.MaxConcurrency*2)
|
||||
// if limit > 0 {
|
||||
// ok, err := queue.AcquireQueueSlot(ctx, req.ModelName, taskID, limit, model.TimeoutSeconds)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// if !ok {
|
||||
// return nil, errors.New("任务排队已满,请稍后再试")
|
||||
// }
|
||||
// }
|
||||
// 循环尝试获取并发名额,超限则等待重试
|
||||
var held bool // 标记当前是否持有未释放的计数
|
||||
for {
|
||||
// 检测全局上下文取消
|
||||
if ctx.Err() != nil {
|
||||
if held {
|
||||
g.Redis().Decr(redisCtx, concurrencyKey)
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
// 计数自增
|
||||
currentCon, e := g.Redis().Incr(redisCtx, concurrencyKey)
|
||||
if e != nil {
|
||||
if held {
|
||||
g.Redis().Decr(redisCtx, concurrencyKey)
|
||||
}
|
||||
glog.Errorf(ctx, "redis incr concurrency key err: %v", e)
|
||||
return e
|
||||
}
|
||||
held = true
|
||||
// 首次创建Key时设置过期时间(避免重复执行EXPIRE)
|
||||
exists, errr := g.Redis().Exists(redisCtx, concurrencyKey)
|
||||
if errr == nil && exists == 1 {
|
||||
g.Redis().Expire(redisCtx, concurrencyKey, keyExpireSec)
|
||||
}
|
||||
|
||||
// 3) 构建任务实体
|
||||
task := &entity.ModelGatewayTask{
|
||||
ModelName: model.ModelName,
|
||||
TaskID: taskID,
|
||||
State: public.TaskStatusRunning,
|
||||
BizName: req.BizName,
|
||||
CallbackURL: req.CallbackUrl,
|
||||
RequestPayload: &entity.RequestPayload{
|
||||
Body: req.RequestPayload,
|
||||
Headers: util.ParseHeadMsgHeaders(model.HeadMsg),
|
||||
},
|
||||
EpicycleId: req.EpicycleId,
|
||||
BuildModelName: req.BuildModelName,
|
||||
}
|
||||
// 未超限:跳出循环,执行业务
|
||||
if currentCon <= maxCon {
|
||||
glog.Infof(ctx, "并发数: %s %d/%d", concurrencyKey, currentCon, maxCon)
|
||||
break
|
||||
}
|
||||
// 超限立刻回减,撤销本次计数
|
||||
g.Redis().Decr(redisCtx, concurrencyKey)
|
||||
held = false
|
||||
glog.Infof(ctx, "并发超限等待: %s %d/%d", concurrencyKey, currentCon, maxCon)
|
||||
time.Sleep(waitInterval)
|
||||
}
|
||||
|
||||
// 4) 插入任务记录
|
||||
id, err := dao.ModelGatewayTask.Insert(ctx, task)
|
||||
if err != nil {
|
||||
// TODO: 恢复排队逻辑后,此处需要回滚排队占位
|
||||
// queue.ReleaseQueueSlot(ctx, req.ModelName, taskID)
|
||||
return nil, err
|
||||
}
|
||||
task.Id = id
|
||||
// 3) 构建任务实体
|
||||
task := &entity.ModelGatewayTask{
|
||||
ModelName: model.ModelName,
|
||||
TaskID: taskID,
|
||||
State: public.TaskStatusRunning,
|
||||
BizName: req.BizName,
|
||||
CallbackURL: req.CallbackUrl,
|
||||
RequestPayload: &entity.RequestPayload{
|
||||
Body: req.RequestPayload,
|
||||
Headers: util.ParseHeadMsgHeaders(model.HeadMsg),
|
||||
},
|
||||
EpicycleId: req.EpicycleId,
|
||||
BuildModelName: req.BuildModelName,
|
||||
}
|
||||
|
||||
// 5) 记录操作日志(非关键路径,失败不影响主流程)
|
||||
ip, ua := "", ""
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
ip = utils.GetLocalIP()
|
||||
ua = r.UserAgent()
|
||||
}
|
||||
_, _ = dao.ModelGatewayLogsOp.Insert(ctx, &entity.ModelGatewayLogsOp{
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
APIPath: "/task/createTask",
|
||||
HttpMethod: "POST",
|
||||
BizName: req.BizName,
|
||||
ModelName: req.ModelName,
|
||||
TaskID: taskID,
|
||||
OpType: "createTask",
|
||||
Success: 1,
|
||||
CostMs: time.Since(startAt).Milliseconds(),
|
||||
RequestPayload: task.RequestPayload,
|
||||
ResponsePayload: gdb.Map{"taskId": taskID},
|
||||
})
|
||||
// 4) 插入任务记录
|
||||
id, errr := dao.ModelGatewayTask.Insert(ctx, task)
|
||||
if errr != nil {
|
||||
g.Redis().Decr(redisCtx, concurrencyKey)
|
||||
// TODO: 恢复排队逻辑后,此处需要回滚排队占位
|
||||
//queue.ReleaseQueueSlot(ctx, req.ModelName, taskID)
|
||||
return errr
|
||||
}
|
||||
task.Id = id
|
||||
|
||||
// 6) 模型计费
|
||||
if len(model.BillingConfig) > 0 {
|
||||
requestData := util.ExtractRequestBilling(ctx, model.BillingConfig, req.RequestPayload)
|
||||
// 请求数据作为计费记录的基础字段,先存入数组
|
||||
task.BillingData = append(task.BillingData, requestData)
|
||||
_, _ = dao.ModelGatewayTask.Update(ctx, &entity.ModelGatewayTask{
|
||||
SQLBaseDO: beans.SQLBaseDO{Id: task.Id},
|
||||
BillingData: task.BillingData,
|
||||
// 5) 记录操作日志(非关键路径,失败不影响主流程)
|
||||
ip, ua := "", ""
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
ip = utils.GetLocalIP()
|
||||
ua = r.UserAgent()
|
||||
}
|
||||
_, _ = dao.ModelGatewayLogsOp.Insert(ctx, &entity.ModelGatewayLogsOp{
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
APIPath: "/task/createTask",
|
||||
HttpMethod: "POST",
|
||||
BizName: req.BizName,
|
||||
ModelName: req.ModelName,
|
||||
TaskID: taskID,
|
||||
OpType: "createTask",
|
||||
Success: 1,
|
||||
CostMs: time.Since(startAt).Milliseconds(),
|
||||
RequestPayload: task.RequestPayload,
|
||||
ResponsePayload: gdb.Map{"taskId": taskID},
|
||||
})
|
||||
}
|
||||
|
||||
// 7) 异步执行任务
|
||||
go AsyncWorker.handleOne(util.AsyncCtx(ctx), task, model, req)
|
||||
// 6) 模型计费
|
||||
if len(model.BillingConfig) > 0 {
|
||||
requestData := util.ExtractRequestBilling(ctx, model.BillingConfig, req.RequestPayload)
|
||||
// 请求数据作为计费记录的基础字段,先存入数组
|
||||
task.BillingData = append(task.BillingData, requestData)
|
||||
_, _ = dao.ModelGatewayTask.Update(ctx, &entity.ModelGatewayTask{
|
||||
SQLBaseDO: beans.SQLBaseDO{Id: task.Id},
|
||||
BillingData: task.BillingData,
|
||||
})
|
||||
}
|
||||
|
||||
// 7) 异步执行任务
|
||||
go AsyncWorker.handleOne(util.AsyncCtx(ctx), task, model, req)
|
||||
|
||||
return nil
|
||||
})
|
||||
if e != nil {
|
||||
err = e
|
||||
return
|
||||
}
|
||||
if !success {
|
||||
err = gerror.New("任务排队已满,请稍后再试")
|
||||
return
|
||||
}
|
||||
|
||||
return &dto.CreateTaskRes{TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
// Lock 分布式锁 纯原生命令、无Lua、隔离上下文防 context canceled
|
||||
func Lock(ctx context.Context, key string, limit, expireSeconds int64, fn func(ctx context.Context) error) (success bool, err error) {
|
||||
if limit <= 0 {
|
||||
limit = -1
|
||||
}
|
||||
|
||||
// 过期时间合法校验(单位:秒)
|
||||
const maxExpireSec = 86400 * 7
|
||||
if expireSeconds < 1 || expireSeconds > maxExpireSec {
|
||||
glog.Warningf(ctx, "锁过期时间非法,原值:%d,兜底为60秒", expireSeconds)
|
||||
expireSeconds = 60
|
||||
}
|
||||
|
||||
lockVal := "1"
|
||||
|
||||
LOOP:
|
||||
// 检测父级上下文取消,防止无限重试阻塞 goroutine
|
||||
if ctx.Err() != nil {
|
||||
return false, ctx.Err()
|
||||
}
|
||||
if limit != -1 {
|
||||
if limit < 0 {
|
||||
return false, errors.New("锁重试次数耗尽,获取锁失败")
|
||||
}
|
||||
limit--
|
||||
}
|
||||
|
||||
// 核心:创建独立上下文,不受外部 ctx 取消影响
|
||||
redisCtx := context.WithoutCancel(ctx)
|
||||
|
||||
// 加锁
|
||||
val, err := g.Redis().Set(redisCtx, key, lockVal, gredis.SetOption{
|
||||
TTLOption: gredis.TTLOption{
|
||||
EX: &expireSeconds,
|
||||
},
|
||||
NX: true,
|
||||
})
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "redis set lock failed: %v", err)
|
||||
time.Sleep(time.Second)
|
||||
goto LOOP
|
||||
}
|
||||
|
||||
if val.Bool() {
|
||||
// 执行业务逻辑(使用原上下文)
|
||||
runErr := fn(ctx)
|
||||
|
||||
// 释放锁:同样使用独立上下文 + 先GET再DEL防误删
|
||||
getRes, err := g.Redis().Get(redisCtx, key)
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "redis get lock value failed: %v", err)
|
||||
} else if getRes.String() == lockVal {
|
||||
_, delErr := g.Redis().Del(redisCtx, key)
|
||||
if delErr != nil {
|
||||
glog.Errorf(ctx, "redis del lock failed: %v", delErr)
|
||||
}
|
||||
}
|
||||
|
||||
return true, runErr
|
||||
}
|
||||
|
||||
// 抢锁失败,休眠重试
|
||||
time.Sleep(time.Second)
|
||||
goto LOOP
|
||||
}
|
||||
|
||||
// GetResult 获取任务结果
|
||||
func (s *taskService) GetResult(ctx context.Context, taskID string) (res *dto.GetTaskResultRes, err error) {
|
||||
t, err := dao.ModelGatewayTask.Get(ctx, &entity.ModelGatewayTask{
|
||||
@@ -260,7 +381,10 @@ func (s *taskService) QueryPendingTasks(ctx context.Context, req *dto.QueryPendi
|
||||
if err != nil || model == nil || model.QueryConfig == nil {
|
||||
continue
|
||||
}
|
||||
result, err := util.PullTaskResult(ctx, nil, model.QueryConfig, model.HeadMsg)
|
||||
// 每个任务使用独立的超时上下文,防止单个任务阻塞整个轮询
|
||||
pullCtx, pullCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
result, err := util.PullTaskResult(pullCtx, nil, model.QueryConfig, model.HeadMsg)
|
||||
pullCancel()
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[轮询] 查询失败 taskID=%s err=%v", t.TaskID, err)
|
||||
continue
|
||||
|
||||
@@ -189,10 +189,11 @@ func (w *asyncWorker) handleOne(ctx context.Context, task *entity.ModelGatewayTa
|
||||
g.Log().Errorf(ctx, "[handleOne] 更新DB失败 taskId=%s err=%v", task.TaskID, err)
|
||||
return
|
||||
}
|
||||
|
||||
go gateway.TriggerCallback(util.AsyncCtx(ctx), task)
|
||||
concurrencyKey := fmt.Sprintf("model:concurrency:%s", req.ModelName)
|
||||
g.Redis().Decr(ctx, concurrencyKey)
|
||||
gateway.TriggerCallback(ctx, task)
|
||||
if req.EpicycleId != 0 {
|
||||
go gateway.TriggerPromptsCallback(util.AsyncCtx(ctx), task, req.EpicycleId)
|
||||
gateway.TriggerPromptsCallback(ctx, task, req.EpicycleId)
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[handleOne] 成功 taskId=%s duration=%ds fileType=%s",
|
||||
@@ -550,6 +551,8 @@ func (w *asyncWorker) failTask(ctx context.Context, t *entity.ModelGatewayTask,
|
||||
t.State = 3
|
||||
t.ErrorMsg = errMsg
|
||||
t.DurationSeconds = int64(time.Since(startTime).Seconds())
|
||||
concurrencyKey := fmt.Sprintf("model:concurrency:%s", t.ModelName)
|
||||
g.Redis().Decr(ctx, concurrencyKey)
|
||||
_, _ = dao.ModelGatewayTask.Update(ctx, t) // 更新任务状态
|
||||
go gateway.TriggerCallback(util.AsyncCtx(ctx), t) // 触发回调
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user