perf(queue): 限制大媒体任务内存并消除准入惊群

将同步与异步非文本任务的准入唤醒改为按任务和全局队首推进,批量续租等待者,避免大量请求同时争抢 advisory lock 和数据库连接。

对 Base64 请求解析、素材解码、上游媒体执行和结果物化增加分层并发限制,复用请求素材并释放重复 Gemini wire 数据;生产 API 默认入口解析并发 16,媒体物化并发 8。

新增 1000 个同步 Gemini 图像编辑请求的模拟上游压力验收,10 MiB 输入和输出下全部成功,Heap 峰值增长约 2.58 GiB,并验证共享上传哈希、单次 attempt 与本地零落盘。

验证:go test ./... -count=1;go vet ./...;gofmt -l 无输出;kubectl kustomize deploy/kubernetes/production;10 MiB Gemini Base64 千任务压力测试通过。
This commit is contained in:
2026-07-30 13:13:32 +08:00
parent fd3b6bf042
commit ea58d21d03
26 changed files with 1980 additions and 74 deletions
@@ -9,9 +9,12 @@ import (
"fmt"
"mime"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
@@ -41,6 +44,11 @@ type requestAssetOptions struct {
Source string
}
type requestAssetLock struct {
mu sync.Mutex
refs int
}
func (s *Server) prepareTaskRequest(ctx context.Context, r *http.Request, user *auth.User, body map[string]any) (preparedTaskRequest, error) {
preparedBody, err := s.prepareRequestAssetRefs(ctx, body)
if err != nil {
@@ -120,13 +128,9 @@ func (s *Server) prepareRequestAssetValue(ctx context.Context, value any, path [
sort.Strings(keys)
for _, key := range keys {
item := typed[key]
if decoded, ok, err := requestAssetFromValue(key, path, item, typed); err != nil {
if ref, ok, err := s.prepareRequestAssetField(ctx, key, path, item, typed); err != nil {
return nil, err
} else if ok {
ref, err := s.ensureRequestAsset(ctx, decoded)
if err != nil {
return nil, err
}
next[key] = requestAssetWrapper(ref)
continue
}
@@ -152,6 +156,67 @@ func (s *Server) prepareRequestAssetValue(ctx context.Context, value any, path [
}
}
func (s *Server) prepareRequestAssetField(ctx context.Context, key string, path []string, value any, siblings map[string]any) (map[string]any, bool, error) {
text, ok := value.(string)
if !ok {
return nil, false, nil
}
raw := strings.TrimSpace(text)
if raw == "" || mediaURLString(raw) {
return nil, false, nil
}
if !strings.HasPrefix(strings.ToLower(raw), "data:") &&
!strictRequestBase64Field(key, path) &&
!likelyRequestBase64MediaField(key, path, raw) {
return nil, false, nil
}
if err := s.acquireMediaRequestSlot(ctx); err != nil {
return nil, false, err
}
defer s.releaseMediaRequestSlot()
decoded, ok, err := requestAssetFromValue(key, path, value, siblings)
if err != nil || !ok {
return nil, ok, err
}
ref, err := s.ensureRequestAsset(ctx, decoded)
return ref, true, err
}
func (s *Server) acquireMediaRequestSlot(ctx context.Context) error {
if s.mediaRequestSlots == nil {
return nil
}
select {
case s.mediaRequestSlots <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Server) releaseMediaRequestSlot() {
if s.mediaRequestSlots != nil {
<-s.mediaRequestSlots
}
}
func (s *Server) acquireMediaRequestBodySlot(ctx context.Context) (func(), error) {
if s.mediaRequestBodySlots == nil {
return func() {}, nil
}
select {
case s.mediaRequestBodySlots <- struct{}{}:
var once sync.Once
return func() {
once.Do(func() {
<-s.mediaRequestBodySlots
})
}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func requestAssetFromValue(key string, path []string, value any, siblings map[string]any) (decodedRequestAsset, bool, error) {
text, ok := value.(string)
if !ok {
@@ -215,6 +280,8 @@ func (s *Server) ensureRequestAssetWithOptions(ctx context.Context, decoded deco
if contentType == "" {
contentType = "application/octet-stream"
}
release := s.acquireRequestAssetLock(sha + "\x00" + contentType + "\x00" + options.UploadScene + "\x00" + strconv.FormatBool(options.RequirePublicURL))
defer release()
now := time.Now()
if existing, ok, err := s.store.FindRequestAsset(ctx, sha, contentType); err != nil && !store.IsUndefinedDatabaseObject(err) {
return nil, err
@@ -286,6 +353,31 @@ func (s *Server) ensureRequestAssetWithOptions(ctx context.Context, decoded deco
return requestAssetRef(asset), nil
}
func (s *Server) acquireRequestAssetLock(key string) func() {
s.requestAssetLocksMu.Lock()
if s.requestAssetLocks == nil {
s.requestAssetLocks = map[string]*requestAssetLock{}
}
entry := s.requestAssetLocks[key]
if entry == nil {
entry = &requestAssetLock{}
s.requestAssetLocks[key] = entry
}
entry.refs++
s.requestAssetLocksMu.Unlock()
entry.mu.Lock()
return func() {
entry.mu.Unlock()
s.requestAssetLocksMu.Lock()
entry.refs--
if entry.refs == 0 {
delete(s.requestAssetLocks, key)
}
s.requestAssetLocksMu.Unlock()
}
}
func requestConversationKey(r *http.Request, body map[string]any) string {
if r != nil {
if value := strings.TrimSpace(r.Header.Get("X-EasyAI-Conversation-ID")); value != "" {
@@ -397,6 +489,16 @@ func requestAssetStillUsable(asset store.RequestAsset, now time.Time) bool {
if asset.ExpiresAt != nil && !asset.ExpiresAt.After(now) {
return false
}
if strings.EqualFold(strings.TrimSpace(asset.StorageProvider), "local_static") {
localPath := strings.TrimSpace(asset.LocalPath)
info, err := os.Stat(localPath)
if localPath == "" || err != nil || !info.Mode().IsRegular() {
return false
}
if asset.ByteSize > 0 && info.Size() != asset.ByteSize {
return false
}
}
return strings.TrimSpace(asset.URL) != ""
}