Files
openteam/server/internal/channel/health.go
T
Sakurasan 8c12207478 perf: 目录缓存与流式正文快路径,请求热路径开销减半
按性能分析逐项落地,实测对比(16 并发、mock 上游零延迟、SQLite):

- 每请求 SQL 10 → 2:模型/渠道目录(模型行、绑定、渠道行)进程内缓存 10s,
  运行期状态(冷却、健康)仍每次实时判定;管理端写操作经中间件、
  健康状态变化经回调立即失效。新增 TestProxyRequestSQLBudget 钉住预算。
- 端到端 p50 1.3–1.6ms → 0.66–0.70ms,RPS 3.5–8.9k → 16–18k。
- 流式正文累积:字符串 += 改 strings.Builder(原为 O(n²)),
  逐行 JSON 提取改字节快路径,并加廉价的子串预过滤、拿到输出 usage 后停累积。
  500 块:949µs/7.9MB 分配 → 83µs/136KB。
- 请求体定点改写:模型映射从整包 unmarshal/marshal 改为顶层字段替换
  (100KB:585µs → 29µs),stream_options 注入同理;requestText 改为
  只在「上游没报用量」时按需提取,亲和键改用原始体前缀。
- 渠道满载背压:新增 Acquire 有界排队(OT_PROXY_CHANNEL_ACQUIRE_WAIT,
  默认 150ms);排队仍满时返回 503 + Retry-After 而不是 502。
  实测渠道上限 4、16 并发:无排队 81 次 503;排队 150ms 后 400/400 成功。
- 记账按批聚合:同批同用户一次扣款、流水批量插入、日表按 (user,model,日期)
  聚合,替代每行 5 条语句;幂等仍靠 ref_id 唯一索引兜底。
- gorm 开启 PrepareStmt(SQL 解析此前占 CPU 15%);tiktoken 编码器按模型复用。
- 管理端用量列表不再返回原始请求/响应体(可能几十 MB),
  改为 has_raw 标记 + GET /admin/usage/:id 按需拉取,前端弹窗打开时取。

验证:go vet、go test ./...、go test -race(proxy/channel/usage/tokenizer)、
vue-tsc、eslint、vite build 全绿;本地起 mock 上游实测列表/详情接口与
管理页弹窗按需加载。
2026-10-30 14:14:21 +08:00

136 lines
3.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package channel
import (
"context"
"log"
"net/http"
"sync"
"time"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/store"
"gorm.io/gorm"
)
// HealthConfig 健康检查参数。
type HealthConfig struct {
Interval time.Duration // 探测周期
FailThreshold int // 连续失败 N 次进入 cooldown
Timeout time.Duration // 单次探测超时
}
// HealthMonitor 定时探测渠道健康状态。
type HealthMonitor struct {
db *gorm.DB
enc *crypto.Encryptor
cfg HealthConfig
hc *http.Client
mu sync.Mutex
fail map[uint64]int // 渠道连续失败次数
// OnChange 健康状态变化后的回调(目录缓存据此立即失效,不必等 TTL)。
OnChange func()
}
func NewHealthMonitor(db *gorm.DB, enc *crypto.Encryptor, cfg HealthConfig) *HealthMonitor {
if cfg.Interval <= 0 {
cfg.Interval = 60 * time.Second
}
if cfg.FailThreshold <= 0 {
cfg.FailThreshold = 2
}
if cfg.Timeout <= 0 {
cfg.Timeout = 5 * time.Second
}
return &HealthMonitor{
db: db, enc: enc, cfg: cfg,
hc: &http.Client{Timeout: cfg.Timeout},
fail: map[uint64]int{},
}
}
// Start 启动后台探测循环(ctx 取消即停止)。
func (h *HealthMonitor) Start(ctx context.Context) {
go func() {
ticker := time.NewTicker(h.cfg.Interval)
defer ticker.Stop()
h.probeAll()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
h.probeAll()
}
}
}()
log.Printf("health: monitor started (interval=%s, threshold=%d)", h.cfg.Interval, h.cfg.FailThreshold)
}
// probeAll 探测所有启用渠道并更新健康状态。
func (h *HealthMonitor) probeAll() {
var chs []store.Channel
if err := h.db.Where("enabled = ?", true).Find(&chs).Error; err != nil {
return
}
for i := range chs {
h.probe(&chs[i])
}
}
// probe 单渠道探测:GET {base}/v1/models。
func (h *HealthMonitor) probe(ch *store.Channel) {
key, err := h.enc.Decrypt(ch.APIKeyEnc)
if err != nil {
h.record(ch, false)
return
}
url := ch.UpstreamURL("", "/models")
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
// Anthropic 官方 API 只认 x-api-key(Bearer 被忽略),探测必须带上才不会误判不健康。
// 两个头同时发:官方 API 忽略 Bearer,OpenAI 兼容渠道忽略 x-api-key,互不干扰。
for _, f := range ch.FormatsEffective() {
if f == store.FormatMessages {
req.Header.Set("x-api-key", key)
req.Header.Set("anthropic-version", "2023-06-01")
break
}
}
resp, err := h.hc.Do(req)
ok := err == nil && resp.StatusCode >= 200 && resp.StatusCode < 300
if resp != nil {
resp.Body.Close()
}
h.record(ch, ok)
}
// record 记录一次探测结果,按阈值切换健康状态。
func (h *HealthMonitor) record(ch *store.Channel, ok bool) {
h.mu.Lock()
defer h.mu.Unlock()
prev := ch.HealthStatus
if ok {
h.fail[ch.ID] = 0
if prev != store.ChannelHealthHealthy {
h.setStatus(ch, store.ChannelHealthHealthy)
log.Printf("health: channel %q recovered -> healthy", ch.Name)
}
return
}
h.fail[ch.ID]++
if h.fail[ch.ID] >= h.cfg.FailThreshold && prev != store.ChannelHealthCooldown {
h.setStatus(ch, store.ChannelHealthCooldown)
log.Printf("health: channel %q -> cooldown (%d consecutive failures)", ch.Name, h.fail[ch.ID])
}
}
func (h *HealthMonitor) setStatus(ch *store.Channel, status string) {
h.db.Model(&store.Channel{}).Where("id = ?", ch.ID).Update("health_status", status)
ch.HealthStatus = status
if h.OnChange != nil {
h.OnChange()
}
}