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)全绿。
This commit is contained in:
1 parent
8c12207478
commit
f1181150aa
11 files changed
+401
-69
No files matched your search
@@ -10,7 +10,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -242,9 +241,9 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
|
||||
}
|
||||
// 会话亲和:上次应答过该会话的渠道优先(候选集已过滤健康/冷却/绑定),
|
||||
// 让上游提示词缓存被再次命中;命中不了(首次/冷却/下线)走加权随机。
|
||||
affKey := g.affinityKeyFor(c)
|
||||
affKey, hasAff := g.affinityKeyFor(c)
|
||||
startIdx := -1
|
||||
if affKey != "" {
|
||||
if hasAff {
|
||||
if chID, ok := g.aff.get(affKey); ok {
|
||||
for i, cand := range cands {
|
||||
if cand.Channel.ID == chID {
|
||||
@@ -318,7 +317,7 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
|
||||
done, served, retry, st, b := g.proxyOne(c, ch, plan, stream, sink, sh, clientProto)
|
||||
release()
|
||||
if done {
|
||||
if served && affKey != "" {
|
||||
if served && hasAff {
|
||||
g.aff.put(affKey, ch.ID, g.affinityTTL)
|
||||
}
|
||||
return
|
||||
@@ -361,27 +360,27 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
|
||||
g.recordError(c, lastCh, nil, now(), "all_channels_failed")
|
||||
}
|
||||
|
||||
// affinityKeyFor 计算本请求的会话亲和键;亲和关闭或缺少身份要素时返回空。
|
||||
func (g *Gateway) affinityKeyFor(c *gin.Context) string {
|
||||
// affinityKeyFor 计算本请求的会话亲和键;亲和关闭或缺少身份要素时 has=false。
|
||||
func (g *Gateway) affinityKeyFor(c *gin.Context) (key uint64, has bool) {
|
||||
if g.affinityTTL <= 0 {
|
||||
return ""
|
||||
return 0, false
|
||||
}
|
||||
kid, ok := c.Get(CtxKeyID)
|
||||
kID, ok2 := kid.(uint64)
|
||||
if !ok || !ok2 {
|
||||
return ""
|
||||
return 0, false
|
||||
}
|
||||
mn, _ := c.Get("model_name")
|
||||
model, _ := mn.(string)
|
||||
// 亲和键取原始请求体的前缀:每轮重发全量上下文时开头是稳定的(模型、系统提示、
|
||||
// 最早的几条消息),足够区分会话;不必为了它整包解析 JSON。
|
||||
var prefix string
|
||||
var prefix []byte
|
||||
if v, ok := c.Get("req_body"); ok {
|
||||
if raw, ok2 := v.([]byte); ok2 {
|
||||
prefix = string(raw) // affinityKey 内部只取前 512 字节
|
||||
prefix = raw // affinityKey 内部只取前 affinityPrefix 字节
|
||||
}
|
||||
}
|
||||
return affinityKey(kID, model, prefix)
|
||||
return affinityKey(kID, model, prefix), true
|
||||
}
|
||||
|
||||
// streamErrorEvent 把错误构造成客户端协议的 SSE 错误事件(响应头已写出时的唯一交付方式)。
|
||||
@@ -556,7 +555,7 @@ func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, resp *http.R
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = c.Writer.Write(out)
|
||||
if _, ok := c.Get("raw_request"); ok {
|
||||
c.Set("raw_response", string(data)) // 上游原始响应(未转换)
|
||||
c.Set("raw_response", capRaw(data, rawResponseCap)) // 上游原始响应(未转换)
|
||||
}
|
||||
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
|
||||
return true, true, false, 0, nil
|
||||
@@ -575,7 +574,7 @@ func (g *Gateway) streamHoldCopy(c *gin.Context, sh *streamHold, ch *store.Chann
|
||||
var rawResp strings.Builder
|
||||
commitRaw := func() {
|
||||
if capture {
|
||||
c.Set("raw_response", rawResp.String())
|
||||
c.Set("raw_response", capRaw([]byte(rawResp.String()), rawResponseCap))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1036,7 +1035,7 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
||||
} else {
|
||||
// 模型在记账时已被删除(请求进行中被下架):无法定价,按 0 记账留痕。
|
||||
// 虚构单价扣用户的钱既不可审计也无法对账。
|
||||
log.Printf("proxy: trace=%s model %q not found at billing time, cost recorded as 0", trace, mn)
|
||||
accessf("proxy: trace=%s model %q not found at billing time, cost recorded as 0", trace, mn)
|
||||
}
|
||||
// 落库的 input_tokens 存输入总量(含缓存读/写),与上游 message_start 口径一致,便于对账展示。
|
||||
in += cacheRead + cacheCreate
|
||||
@@ -1112,7 +1111,7 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
||||
})
|
||||
|
||||
// 请求级一行日志:usage_logs 落库是异步的且可能丢,排障时日志是唯一即时依据
|
||||
log.Printf("proxy: trace=%s user=%d key=%d model=%q served=%q channel=%d proto=%s in=%d out=%d r=%d cache_r=%d cache_w=%d(1h=%d) cost=%.6f status=%s http=%d latency=%dms err=%s",
|
||||
accessf("proxy: trace=%s user=%d key=%d model=%q served=%q channel=%d proto=%s in=%d out=%d r=%d cache_r=%d cache_w=%d(1h=%d) cost=%.6f status=%s http=%d latency=%dms err=%s",
|
||||
traceStr, uidVal, kidVal, mn, servedModel, chID, p, in, out, reasoning, cacheRead, cacheCreate, cache1h, cost, status,
|
||||
c.Writer.Status(), latency, errCode)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user