按性能分析逐项落地,实测对比(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 上游实测列表/详情接口与 管理页弹窗按需加载。
136 lines
3.5 KiB
Go
136 lines
3.5 KiB
Go
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()
|
||
}
|
||
}
|