improve:优化管理后台接口查询性能
This commit is contained in:
1 parent
710a275ba4
commit
6370e87c8e
4 files changed
+193
-34
No files matched your search
@@ -16,6 +16,11 @@ build/
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
# AI
|
||||
.claude
|
||||
.codegraph
|
||||
.workbuddy
|
||||
|
||||
# Environment files with sensitive data
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -123,7 +123,6 @@ type AdminUsageLogFilter struct {
|
||||
ID int64 `json:"id" dc:"用量记录ID"`
|
||||
TenantID int64 `json:"tenant_id" dc:"租户ID"`
|
||||
UserID int64 `json:"user_id" dc:"用户ID"`
|
||||
Username string `json:"username" dc:"用户名(模糊匹配)"`
|
||||
ApiKeyID int64 `json:"api_key_id" dc:"API Key ID"`
|
||||
ChannelID int64 `json:"channel_id" dc:"渠道ID"`
|
||||
Model string `json:"model" dc:"模型名称"`
|
||||
|
||||
@@ -358,7 +358,10 @@ func validateUsageLogDateRange(start, end string) error {
|
||||
}
|
||||
|
||||
// buildUsageLogFilter 构建用量日志查询的 WHERE 条件与绑定参数(列表 / 统计 / 导出共用,保证筛选口径一致)。
|
||||
// 注意:Username 筛选条件引用 t.username,调用方需按需 JOIN tnt_users(别名 t)。
|
||||
// 条件只引用主表别名 u,不依赖任何 JOIN —— 计数与统计路径因此可以完全不联表,
|
||||
// 只有需要展示名称的列表 / 导出才挂 LEFT JOIN。
|
||||
// 用户维度只支持 user_id 精确筛选(前端从下拉选择带出 ID),不提供用户名模糊匹配:
|
||||
// 前导通配 LIKE 在分区大表的筛选路径上无法走索引,且前端本就没有该查询条件。
|
||||
func buildUsageLogFilter(f v1.AdminUsageLogFilter) (where string, args []any) {
|
||||
var conditions []string
|
||||
|
||||
@@ -374,10 +377,6 @@ func buildUsageLogFilter(f v1.AdminUsageLogFilter) (where string, args []any) {
|
||||
conditions = append(conditions, "u.user_id = ?")
|
||||
args = append(args, f.UserID)
|
||||
}
|
||||
if f.Username != "" {
|
||||
conditions = append(conditions, "t.username LIKE ?")
|
||||
args = append(args, "%"+f.Username+"%")
|
||||
}
|
||||
if f.ApiKeyID > 0 {
|
||||
conditions = append(conditions, "u.api_key_id = ?")
|
||||
args = append(args, f.ApiKeyID)
|
||||
@@ -422,9 +421,15 @@ func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListR
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
where, args := buildUsageLogFilter(req.AdminUsageLogFilter)
|
||||
|
||||
fromClause := "bil_usage_logs u LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id LEFT JOIN tnt_projects p ON u.project_id = p.id LEFT JOIN tnt_tenants tn ON u.tenant_id = tn.id LEFT JOIN api_keys ak ON u.api_key_id = ak.id"
|
||||
// 展示用联表:只有列表需要租户 / 成员 / 项目 / Key 名称。四个联表目标都是主键列
|
||||
// (1:1,不放大行数),但仍会被分区大表实打实执行 4 次 —— 所以只给列表用。
|
||||
joinedFrom := "bil_usage_logs u LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id LEFT JOIN tnt_projects p ON u.project_id = p.id LEFT JOIN tnt_tenants tn ON u.tenant_id = tn.id LEFT JOIN api_keys ak ON u.api_key_id = ak.id"
|
||||
|
||||
countSQL := "SELECT COUNT(*) AS total FROM " + fromClause + where
|
||||
// 计数不展示任何名称,且 buildUsageLogFilter 的条件只引用 u.*,
|
||||
// 因此 4 个 LEFT JOIN 可以整体剥离。
|
||||
// 这是本接口最重的一次查询:bil_usage_logs 是分区大表、created_at 上只有 BRIN
|
||||
// 索引(不支持精确计数),每翻一页都要重算一次,少 4 次 join 探测收益最大。
|
||||
countSQL := "SELECT COUNT(*) AS total FROM bil_usage_logs u" + where
|
||||
countResult, err := g.DB().Ctx(ctx).Query(ctx, countSQL, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -435,7 +440,7 @@ func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListR
|
||||
}
|
||||
|
||||
dataSQL := `SELECT u.id, u.tenant_id, COALESCE(tn.name, '') AS tenant_name, u.user_id, COALESCE(t.username, '') AS username, u.project_id, COALESCE(p.name, '') AS project_name, u.api_key_id, COALESCE(ak.name, '') AS api_key_name, u.channel_id, u.channel_name, u.channel_type, u.model_name, u.requested_model, u.upstream_model, u.relay_mode, u.request_type, u.input_tokens, u.output_tokens, u.cache_creation_tokens, u.cache_read_tokens, u.cache_creation_5m_tokens, u.cache_creation_1h_tokens, u.reasoning_tokens, u.audio_input_tokens, u.audio_output_tokens, u.image_output_tokens, u.input_cost, u.output_cost, u.cache_creation_cost, u.cache_read_cost, u.total_cost, u.actual_cost, u.currency, u.billing_mode, u.billing_source, u.rate_multiplier, u.latency_ms, u.first_token_ms, u.status, u.error_message, u.retry_index, u.client_ip, u.user_agent, u.service_tier, u.reasoning_effort, u.stream_end_reason, u.image_count, u.image_size, u.pre_deduct_amount, u.refund_amount, u.supplement_amount, u.billing_summary, u.billing_snapshot, u.inbound_endpoint, u.request_id, u.task_id, u.created_at
|
||||
FROM ` + fromClause + where + ` ORDER BY u.created_at DESC LIMIT ? OFFSET ?`
|
||||
FROM ` + joinedFrom + where + ` ORDER BY u.created_at DESC LIMIT ? OFFSET ?`
|
||||
dataArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize)
|
||||
result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, dataArgs...)
|
||||
if err != nil {
|
||||
@@ -466,18 +471,14 @@ func (s *sAdmin) GetUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSu
|
||||
}
|
||||
where, args := buildUsageLogFilter(req.AdminUsageLogFilter)
|
||||
|
||||
// 统计聚合不展示名称,仅在按用户名筛选时才 JOIN 用户表,避免大表 SUM 背负无关 JOIN
|
||||
fromClause := "bil_usage_logs u"
|
||||
if req.Username != "" {
|
||||
fromClause += " LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id"
|
||||
}
|
||||
|
||||
// 统计聚合不展示名称,筛选条件也只引用 u.*,全程无需联表:
|
||||
// 大表 SUM 不再背负任何 join。
|
||||
summarySQL := `SELECT
|
||||
COALESCE(SUM(u.total_cost), 0) AS total_cost,
|
||||
COALESCE(SUM(u.output_tokens), 0) AS total_output_tokens,
|
||||
COALESCE(SUM(u.input_tokens), 0) AS total_input_tokens,
|
||||
COALESCE(SUM(u.cache_read_tokens), 0) AS total_cache_read
|
||||
FROM ` + fromClause + where
|
||||
FROM bil_usage_logs u` + where
|
||||
|
||||
summaryResult, err := g.DB().Ctx(ctx).Query(ctx, summarySQL, args...)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,10 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
@@ -32,7 +35,14 @@ const (
|
||||
// workbenchCacheKey 整份收集结果的缓存键。菜单红点轮询与页面刷新共用,
|
||||
// 避免每个管理员每 30s 各打一遍库。
|
||||
workbenchCacheKey = "workbench:summary:v1"
|
||||
// workbenchCacheTTL 新鲜期:结果超过这个年龄即视为陈旧,触发后台刷新。
|
||||
workbenchCacheTTL = 30 * time.Second
|
||||
// workbenchCacheHardTTL Redis 上的物理 TTL,纯兜底:即使进程在后台刷新途中挂掉,
|
||||
// 陈旧结果也不会被无限期当成有效数据返回。
|
||||
workbenchCacheHardTTL = 10 * time.Minute
|
||||
// workbenchCollectTimeout 一次完整收集的独立超时。收集发生在请求路径之外
|
||||
// (后台刷新),必须有自带的截止时间,不能挂在请求上下文上。
|
||||
workbenchCollectTimeout = 30 * time.Second
|
||||
|
||||
// 各项判定阈值。选值理由见对应 collector 注释。
|
||||
wbFrozenStaleHours = 2 // 冻结超过 2 小时未释放视为异常
|
||||
@@ -49,6 +59,15 @@ const (
|
||||
wbCronStallFallbackInterval = 24 * time.Hour
|
||||
)
|
||||
|
||||
// workbenchGroup 合并并发回源的 singleflight 组。
|
||||
// 缓存为空(首次启动 / Redis 丢数据)时,角标轮询与页面刷新会同时到达,
|
||||
// 没有它就会 N 个请求各跑一遍完整收集,把冷启动放大成 N 倍的库压力。
|
||||
var workbenchGroup singleflight.Group
|
||||
|
||||
// workbenchRefreshing 标记「已有后台刷新在跑」。
|
||||
// 陈旧命中时每个请求都想刷新,用 CAS 保证全局同时只有一次刷新。
|
||||
var workbenchRefreshing atomic.Bool
|
||||
|
||||
// wbItem 内部待办结构:比 API 的 v1.WorkbenchItem 多一个 perm 字段。
|
||||
// 工作台跨域聚合,必须按权限逐条过滤 —— 只有 support:view 的客服不该在工作台
|
||||
// 看到钱包偏差和订单金额,否则等于绕过 RBAC 泄露财务数据。
|
||||
@@ -64,6 +83,10 @@ type wbCollected struct {
|
||||
Breakers []v1.WorkbenchBreaker `json:"breakers"`
|
||||
Metrics []v1.WorkbenchMetric `json:"metrics"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
// GeneratedAtUnix 缓存新鲜度的判定依据(Unix 秒)。
|
||||
// 刻意不复用 GeneratedAt:那个字符串由 gtime 格式化后对外展示,格式细节
|
||||
// 不该被缓存逻辑依赖;这里用明确的时间戳,解析歧义为零。
|
||||
GeneratedAtUnix int64 `json:"generated_at_unix"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -144,21 +167,105 @@ func (s *sAdmin) GetWorkbenchBadges(ctx context.Context, _ *v1.AdminWorkbenchBad
|
||||
// 收集与过滤
|
||||
// ============================================================
|
||||
|
||||
// collectWorkbenchCached 读缓存,未命中则重新收集。
|
||||
// 缓存失败一律降级为直接收集 —— 工作台不能因为 Redis 抖动就打不开。
|
||||
// collectWorkbenchCached 读缓存;命中即返回,陈旧则「先返回旧值 + 后台刷新」。
|
||||
//
|
||||
// 三层保护,针对菜单角标每 30s 轮询这一固定负载:
|
||||
// 1. 新鲜命中:直接返回,零 DB 开销;
|
||||
// 2. 陈旧命中:立即返回旧值,同时触发一次后台刷新(stale-while-revalidate)——
|
||||
// 工作台是故障时最该能打开的页面,宁可给一份 30 秒前的数据,也不要让轮询
|
||||
// 把请求堵在十几条联表查询上;
|
||||
// 3. 完全未命中(首次启动 / Redis 丢数据):同步收集,由 singleflight 保证
|
||||
// 并发请求只触发一次。
|
||||
//
|
||||
// 缓存读写失败一律降级为直接收集 —— 工作台不能因为 Redis 抖动就打不开。
|
||||
func (s *sAdmin) collectWorkbenchCached(ctx context.Context) *wbCollected {
|
||||
if v, err := g.Redis().Get(ctx, workbenchCacheKey); err == nil && !v.IsEmpty() {
|
||||
var col wbCollected
|
||||
if json.Unmarshal(v.Bytes(), &col) == nil {
|
||||
return &col
|
||||
if col, ok := readWorkbenchCache(ctx); ok {
|
||||
if wbCacheFresh(col) {
|
||||
return col
|
||||
}
|
||||
s.refreshWorkbenchAsync(ctx)
|
||||
return col
|
||||
}
|
||||
return s.collectWorkbenchSync(ctx)
|
||||
}
|
||||
|
||||
col := s.collectWorkbench(ctx)
|
||||
// readWorkbenchCache 读并解析整份收集结果。任何一步失败都当作未命中。
|
||||
func readWorkbenchCache(ctx context.Context) (*wbCollected, bool) {
|
||||
v, err := g.Redis().Get(ctx, workbenchCacheKey)
|
||||
if err != nil || v.IsEmpty() {
|
||||
return nil, false
|
||||
}
|
||||
var col wbCollected
|
||||
if json.Unmarshal(v.Bytes(), &col) != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &col, true
|
||||
}
|
||||
|
||||
// wbCacheFresh 判断缓存是否还在新鲜期内。
|
||||
// GeneratedAtUnix 缺失(例如版本升级后读到旧格式缓存)时判为陈旧:那会触发
|
||||
// 恰好一次刷新把字段补上,之后自愈,不会形成刷新循环。
|
||||
func wbCacheFresh(col *wbCollected) bool {
|
||||
if col.GeneratedAtUnix <= 0 {
|
||||
return false
|
||||
}
|
||||
return time.Since(time.Unix(col.GeneratedAtUnix, 0)) < workbenchCacheTTL
|
||||
}
|
||||
|
||||
// collectWorkbenchSync 冷启动路径:同步收集,singleflight 保证并发只跑一次。
|
||||
func (s *sAdmin) collectWorkbenchSync(ctx context.Context) *wbCollected {
|
||||
v, _, _ := workbenchGroup.Do(workbenchCacheKey, func() (any, error) {
|
||||
// 双检:等锁期间别的请求可能已经把缓存写好了
|
||||
if col, ok := readWorkbenchCache(ctx); ok {
|
||||
return col, nil
|
||||
}
|
||||
return s.collectWorkbenchAndCache(ctx), nil
|
||||
})
|
||||
if col, ok := v.(*wbCollected); ok {
|
||||
return col
|
||||
}
|
||||
// 理论不可达(Do 恒返回 *wbCollected);兜底直接收集,保证工作台永远有数据
|
||||
return s.collectWorkbenchAndCache(ctx)
|
||||
}
|
||||
|
||||
// refreshWorkbenchAsync 后台刷新一次缓存。
|
||||
// 全局只允许一个刷新在跑:陈旧期间所有轮询请求都会走到这里,不去重等于把
|
||||
// 「每个请求各刷一次」原样搬到后台。
|
||||
func (s *sAdmin) refreshWorkbenchAsync(ctx context.Context) {
|
||||
if !workbenchRefreshing.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
// 脱离请求生命周期:请求返回后刷新必须继续,否则角标轮询一结束刷新就被取消,
|
||||
// 缓存永远等不到更新。值(trace id 等)保留,取消信号丢弃。
|
||||
bg := context.WithoutCancel(ctx)
|
||||
go func() {
|
||||
defer workbenchRefreshing.Store(false)
|
||||
// 后台刷新跑在裸 goroutine 上,没有 HTTP 服务的 panic 恢复兜底 ——
|
||||
// 任一 collector 空指针都不能把整个进程带走,落一条日志就够了。
|
||||
// defer 顺序:本函数先注册,后执行,确保 recover 之后标志位一定被清掉。
|
||||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
g.Log().Errorf(bg, "workbench: 后台刷新 panic: %v", e)
|
||||
}
|
||||
}()
|
||||
s.collectWorkbenchAndCache(bg)
|
||||
}()
|
||||
}
|
||||
|
||||
// collectWorkbenchAndCache 收集一次并写回缓存。
|
||||
//
|
||||
// 收集统一跑在「脱离请求生命周期 + 自带超时」的上下文上:结果会写进全实例共享的
|
||||
// 缓存,不能被某个客户端断连中断 —— 否则一份半截结果(最坏是「全站无待办」)
|
||||
// 会被当成有效数据缓存下来。
|
||||
func (s *sAdmin) collectWorkbenchAndCache(ctx context.Context) *wbCollected {
|
||||
rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), workbenchCollectTimeout)
|
||||
defer cancel()
|
||||
|
||||
col := s.collectWorkbench(rctx)
|
||||
|
||||
if b, err := json.Marshal(col); err == nil {
|
||||
if _, err := g.Redis().Do(ctx, "SET", workbenchCacheKey, b, "EX", int(workbenchCacheTTL.Seconds())); err != nil {
|
||||
g.Log().Warningf(ctx, "workbench: 写缓存失败: %v", err)
|
||||
if _, err := g.Redis().Do(rctx, "SET", workbenchCacheKey, b, "EX", int(workbenchCacheHardTTL.Seconds())); err != nil {
|
||||
g.Log().Warningf(rctx, "workbench: 写缓存失败: %v", err)
|
||||
}
|
||||
}
|
||||
return col
|
||||
@@ -217,23 +324,70 @@ func (s *sAdmin) filterWorkbenchItems(ctx context.Context, items []wbItem) []wbI
|
||||
return out
|
||||
}
|
||||
|
||||
// collectWorkbench 依次跑各 collector。单个失败不影响整体。
|
||||
// collectWorkbench 并发跑各 collector。单个失败不影响整体。
|
||||
//
|
||||
// 四个域 + 首屏指标之间没有任何数据依赖,串行执行只会把一次工作台刷新累加成
|
||||
// 「十几条联表查询耗时之和」。并发后总耗时≈最慢的那一条(通常是资金域 ——
|
||||
// 它要对最多 30 个租户逐个读 Redis)。
|
||||
//
|
||||
// wg.Wait() 返回后才合并结果,各 goroutine 之间没有共享写入;合并顺序固定为
|
||||
// 可用性 → 资金 → 客户 → 系统,保证同样的数据产出稳定的 items 顺序。
|
||||
//
|
||||
// 用 WaitGroup 而非 errgroup:collector 全部自带兜底(失败只记 warning 并返回空),
|
||||
// 这里不存在可返回的错误。
|
||||
func (s *sAdmin) collectWorkbench(ctx context.Context) *wbCollected {
|
||||
col := &wbCollected{GeneratedAt: gtime.Now().Format(time.RFC3339)}
|
||||
now := gtime.Now()
|
||||
col := &wbCollected{
|
||||
GeneratedAt: now.Format(time.RFC3339),
|
||||
GeneratedAtUnix: now.Unix(),
|
||||
}
|
||||
|
||||
var (
|
||||
models []v1.WorkbenchModelAvail
|
||||
breakers []v1.WorkbenchBreaker
|
||||
availItems []wbItem
|
||||
moneyItems []wbItem
|
||||
customerItems []wbItem
|
||||
systemItems []wbItem
|
||||
metrics []v1.WorkbenchMetric
|
||||
)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(5)
|
||||
go wbSafe(ctx, &wg, "availability", func() { models, breakers, availItems = s.wbCollectAvailability(ctx) })
|
||||
go wbSafe(ctx, &wg, "money", func() { moneyItems = s.wbCollectMoney(ctx) })
|
||||
go wbSafe(ctx, &wg, "customer", func() { customerItems = s.wbCollectCustomer(ctx) })
|
||||
go wbSafe(ctx, &wg, "system", func() { systemItems = s.wbCollectSystem(ctx) })
|
||||
go wbSafe(ctx, &wg, "metrics", func() { metrics = s.wbCollectMetrics(ctx) })
|
||||
wg.Wait()
|
||||
|
||||
models, breakers, availItems := s.wbCollectAvailability(ctx)
|
||||
col.Models = models
|
||||
col.Breakers = breakers
|
||||
|
||||
col.Items = append(col.Items, availItems...)
|
||||
col.Items = append(col.Items, s.wbCollectMoney(ctx)...)
|
||||
col.Items = append(col.Items, s.wbCollectCustomer(ctx)...)
|
||||
col.Items = append(col.Items, s.wbCollectSystem(ctx)...)
|
||||
|
||||
col.Metrics = s.wbCollectMetrics(ctx)
|
||||
col.Items = append(col.Items, moneyItems...)
|
||||
col.Items = append(col.Items, customerItems...)
|
||||
col.Items = append(col.Items, systemItems...)
|
||||
col.Metrics = metrics
|
||||
return col
|
||||
}
|
||||
|
||||
// wbSafe 跑单个 collector,负责 wg.Done 并吞掉 panic。
|
||||
// collector 自身已经兜住了「查询报错」(记 warning 返回空),这里补的是
|
||||
// 「不可恢复错误」:并发化之后 panic 发生在子 goroutine,HTTP 中间件的
|
||||
// 恢复逻辑接不住它 —— 不 recover 就是一次空指针带走整个服务。
|
||||
//
|
||||
// defer 顺序:wg.Done 先注册后执行,保证 recover 之后计数一定被减掉
|
||||
// (否则一次 panic 就会让 collectWorkbench 永久卡在 wg.Wait)。
|
||||
func wbSafe(ctx context.Context, wg *sync.WaitGroup, name string, fn func()) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
g.Log().Errorf(ctx, "workbench: collector %s panic: %v", name, e)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
// wbNew 构造一条待办。
|
||||
func wbNew(key, sev, domain, perm, title, desc, actionText, route string, query map[string]string, at *gtime.Time) wbItem {
|
||||
it := wbItem{Perm: perm}
|
||||
|
||||
Reference in new issue
Block a user