Files
openteam/server/internal/proxy/handlers.go
T
Sakurasan f1181150aa perf: 亲和表分片、记账按驱动并发、访问日志与原始体控量
继续上一轮性能改造的剩余项:

- 会话亲和表分 16 片(原来一个全局 mutex,每个请求都要走一遍),
  键改为内联 FNV-1a 的 64 位摘要(原先是拼接字符串 + hex,每请求两次分配),
  请求路径上零分配;新增键稳定性/无分配/分片并发测试。
- 记账落库按驱动自适应并发:Recorder 按用户分片到多个 worker(同一用户恒定
  同片,流水顺序不变)。SQLite 只有一个写者,实测 4 worker 会互相抢锁把
  p99 拖到 19–34ms,所以 SQLite 固定 1 个、PostgreSQL 用 4 个。
  新增多分片落库与顺序测试(用显式 worker 数在 SQLite 上验证语义)。
- 访问日志:每请求一次 log.Printf 直写 stderr,高 QPS 下是系统调用热点;
  改为 64 KiB 缓冲 + 每秒 flush(写/flush 互斥,-race 已验)。
- 原始体控量:LogRaw 落库的请求体截到 256 KiB、响应体截到 1 MiB 并标注原始
  体积,避免单条日志几十 MB 拖慢 usage_logs 查询与备份。
- 渠道加权随机选路从 crypto/rand + big.Int 换成 math/rand/v2 的 IntN,
  去掉每请求的系统调用与两次分配。

跳过的两项与原因:
- 「PG 下把 model 目录查询合并成一次 JOIN」:目录已缓存 10s,冷路径每模型
  10 秒才走一次,合并收益接近 0。
- 「每流一个 heartbeat goroutine」:Go 的 ticker 成本很低,千级并发流仍在
  可接受范围,改动收益不抵风险。

实测(16 并发、mock 上游、SQLite):p50 679µs→558µs、p99 2.6ms→2.2ms、
RPS 18.7k→23.9k。验证:go vet、go test ./...、-race(proxy/channel/usage)全绿。
2026-10-30 14:25:10 +08:00

180 lines
4.9 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 proxy
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/proxy/convert"
"github.com/openteam/server/internal/store"
)
// failBodyRead 读取请求体失败时的报错:超限单独回 413,让客户端能区分"体太大"和"体畸形"。
func (g *Gateway) failBodyRead(c *gin.Context, err error) {
var mb *http.MaxBytesError
if errors.As(err, &mb) {
apiError(c, http.StatusRequestEntityTooLarge, "request_too_large",
fmt.Sprintf("请求体超过网关上限 %d MB", g.maxBodyMB))
return
}
apiError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body")
}
// chatCompletions POST /v1/chat/completions
func (g *Gateway) chatCompletions(c *gin.Context) {
u, ok := g.resolveUser(c)
if !ok {
return
}
if !g.checkBalance(c, u) {
return
}
br, body, err := parseBody(c)
if err != nil {
g.failBodyRead(c, err)
return
}
c.Set("protocol", convert.ProtoChat)
c.Set("model_name", br.Model)
g.recordRawRequest(c, u, body)
if !g.checkModelExists(c, br.Model) {
return
}
if !g.checkModelAllowed(c, u, br.Model) {
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
return
}
cands := g.candidateChannels(br.Model)
if len(cands) == 0 {
apiError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
g.recordError(c, nil, nil, now(), "no_channel")
return
}
sink := &usageSink{}
c.Set("usage_raw", &sinkHolder{sink: sink})
g.doProxy(c, cands, convert.ProtoChat, body, br.Stream, sink)
}
// responses POST /v1/responses(OpenAI Responses API)
func (g *Gateway) responses(c *gin.Context) {
u, ok := g.resolveUser(c)
if !ok {
return
}
if !g.checkBalance(c, u) {
return
}
br, body, err := parseBody(c)
if err != nil {
g.failBodyRead(c, err)
return
}
c.Set("protocol", convert.ProtoResponses)
c.Set("model_name", br.Model)
g.recordRawRequest(c, u, body)
if !g.checkModelExists(c, br.Model) {
return
}
if !g.checkModelAllowed(c, u, br.Model) {
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
return
}
cands := g.candidateChannels(br.Model)
if len(cands) == 0 {
apiError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
g.recordError(c, nil, nil, now(), "no_channel")
return
}
sink := &usageSink{}
c.Set("usage_raw", &sinkHolder{sink: sink})
g.doProxy(c, cands, convert.ProtoResponses, body, br.Stream, sink)
}
// messages POST /v1/messages(Anthropic Messages API)
func (g *Gateway) messages(c *gin.Context) {
u, ok := g.resolveUser(c)
if !ok {
return
}
if !g.checkBalance(c, u) {
return
}
br, body, err := parseBody(c)
if err != nil {
g.failBodyRead(c, err)
return
}
c.Set("protocol", convert.ProtoMessages)
c.Set("model_name", br.Model)
g.recordRawRequest(c, u, body)
if !g.checkModelExists(c, br.Model) {
return
}
if !g.checkModelAllowed(c, u, br.Model) {
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
return
}
cands := g.candidateChannels(br.Model)
if len(cands) == 0 {
apiError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
g.recordError(c, nil, nil, now(), "no_channel")
return
}
sink := &usageSink{}
c.Set("usage_raw", &sinkHolder{sink: sink})
g.doProxy(c, cands, convert.ProtoMessages, body, br.Stream, sink)
}
// usageSinkHolder 桥接:gin context 里保存 sink 引用,供 finishUsage 读取最终 usage。
type sinkHolder struct {
sink *usageSink
}
// recordRawRequest 记录管理员原始请求体到 context(供 finishUsage 落库)。
// 仅当开关开启且用户为管理员时记录;响应侧以 c.Get("raw_request") 是否非空判断是否需要捕获响应。
func (g *Gateway) recordRawRequest(c *gin.Context, u *store.User, body []byte) {
if g.logRaw && u.Role == store.RoleAdmin {
c.Set("raw_request", capRaw(body, rawRequestCap))
}
}
// apiError 按客户端协议返回错误体(PLANNING §5.1.4)。
func apiError(c *gin.Context, status int, code, message string) {
if p, _ := c.Get("protocol"); p == convert.ProtoMessages {
// Anthropic 格式
c.AbortWithStatusJSON(status, gin.H{
"type": "error",
"error": gin.H{"type": errorTypeFor(status), "message": message},
})
return
}
// OpenAI 格式
c.AbortWithStatusJSON(status, gin.H{
"error": gin.H{
"message": message,
"type": errorTypeFor(status),
"param": nil,
"code": code,
},
})
}
func errorTypeFor(status int) string {
switch status {
case http.StatusUnauthorized:
return "authentication_error"
case http.StatusForbidden, http.StatusPaymentRequired:
return "permission_error"
case http.StatusNotFound, http.StatusBadRequest:
return "invalid_request_error"
case http.StatusTooManyRequests:
return "rate_limit_error"
default:
return "api_error"
}
}