Files
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

163 lines
6.0 KiB
Go
Raw Permalink 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 (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/openteam/server/internal/channel"
"github.com/openteam/server/internal/store"
)
// 会话亲和:同一会话(同密钥+模型+同首条消息)粘住上次应答的渠道——
// 即使加权随机此时会选别的渠道;不同会话不受影响,照常负载均衡。
func TestAffinitySticksToAnsweringChannel(t *testing.T) {
var ch1Hits, ch2Hits atomic.Int64
ch1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ch1Hits.Add(1)
_, _ = io.WriteString(w, `{"id":"ok","choices":[{"message":{"role":"assistant","content":"from-1"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
}))
defer ch1.Close()
ch2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ch2Hits.Add(1)
_, _ = io.WriteString(w, `{"id":"ok","choices":[{"message":{"role":"assistant","content":"from-2"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
}))
defer ch2.Close()
h := newHarness(t, ch1.URL, 0, true, nil)
h.gw.affinityTTL = 5 * time.Minute
addChannel(t, h, "c2", ch2.URL, 1)
pushWeightTo(t, h, "c1", 1000)
sameConv := `{"model":"m1","messages":[{"role":"user","content":"会话的第一条消息"}]}`
// 第一轮:权重压在渠道1,应答来自渠道1,亲和绑定建立
if w := h.run(t, nil, sameConv); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "from-1") {
t.Fatalf("第一轮应来自渠道1:%d %s", w.Code, w.Body.String())
}
// 反转权重:加权随机此刻会选渠道2
pushWeightTo(t, h, "c1", 1)
pushWeightTo(t, h, "c2", 1000)
// 同一会话第二轮:仍应粘在渠道1(缓存值得保留)
if w := h.run(t, nil, sameConv); !strings.Contains(w.Body.String(), "from-1") {
t.Fatalf("同一会话应粘住渠道1:%s", w.Body.String())
}
// 不同会话(不同首条消息):走加权随机,落到渠道2
otherConv := `{"model":"m1","messages":[{"role":"user","content":"另一个会话的开场"}]}`
if w := h.run(t, nil, otherConv); !strings.Contains(w.Body.String(), "from-2") {
t.Fatalf("不同会话应走加权随机落到渠道2:%s", w.Body.String())
}
if ch1Hits.Load() != 2 || ch2Hits.Load() != 1 {
t.Fatalf("命中次数不符:ch1=%d ch2=%d", ch1Hits.Load(), ch2Hits.Load())
}
}
// 亲和渠道进入运行期冷却后自动失效,回退加权随机;不会把请求发给冷却中的渠道。
func TestAffinityRespectsCooldown(t *testing.T) {
ch1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("冷却中的渠道1 不应收到请求")
_, _ = io.WriteString(w, `{"id":"bad"}`)
}))
defer ch1.Close()
ch2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"id":"ok","choices":[{"message":{"role":"assistant","content":"from-2"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
}))
defer ch2.Close()
h := newHarness(t, ch1.URL, 0, true, nil)
h.gw.affinityTTL = 5 * time.Minute
addChannel(t, h, "c2", ch2.URL, 1)
// 预置亲和绑定指向渠道1,随后让渠道1 进运行期冷却
var ch1row store.Channel
h.db.Where("name = ?", "c1").First(&ch1row)
h.gw.aff.put(affinityKey(1, "m1", []byte("会话的第一条消息")), ch1row.ID, 5*time.Minute)
for i := 0; i < 3; i++ {
h.gw.ch.ReportFailure(&ch1row, channel.FailRate, 0)
}
w := h.run(t, nil, `{"model":"m1","messages":[{"role":"user","content":"会话的第一条消息"}]}`)
if !strings.Contains(w.Body.String(), "from-2") {
t.Fatalf("冷却后亲和应失效并切换渠道2:%d %s", w.Code, w.Body.String())
}
}
// 亲和键:同输入稳定、任一要素不同即不同;请求路径上不分配。
func TestAffinityKeyStable(t *testing.T) {
prefix := []byte(`{"model":"m1","messages":[{"role":"user","content":"hi"}]}`)
a := affinityKey(7, "m1", prefix)
if b := affinityKey(7, "m1", prefix); a != b {
t.Fatal("同一会话的键必须稳定")
}
if affinityKey(8, "m1", prefix) == a {
t.Fatal("不同密钥应得到不同键")
}
if affinityKey(7, "m2", prefix) == a {
t.Fatal("不同模型应得到不同键")
}
if affinityKey(7, "m1", []byte("other")) == a {
t.Fatal("不同前缀应得到不同键")
}
// 前缀只有前 affinityPrefix 字节参与:同一个开头、更长的尾部不影响键
head := make([]byte, affinityPrefix)
for i := range head {
head[i] = byte('a' + i%26)
}
tailA := append(append([]byte{}, head...), []byte("AAAA")...)
tailB := append(append([]byte{}, head...), []byte("BBBB")...)
if affinityKey(7, "m1", tailA) != affinityKey(7, "m1", tailB) {
t.Fatal("超过前缀长度的部分不应影响键")
}
if affinityKey(7, "m1", head) != affinityKey(7, "m1", tailA) {
t.Fatal("尾部长短不同但前 512 字节相同,键应一致")
}
inner := append([]byte{}, head...)
inner[len(inner)-1] = 'z' // 改动前缀内最后一个字节
if affinityKey(7, "m1", head) == affinityKey(7, "m1", inner) {
t.Fatal("前缀以内的差异必须影响键")
}
}
func TestAffinityKeyNoAlloc(t *testing.T) {
prefix := []byte(`{"model":"m1"}`)
allocs := testing.AllocsPerRun(200, func() { _ = affinityKey(7, "m1", prefix) })
if allocs != 0 {
t.Fatalf("affinityKey 分配了 %v 次,期望 0", allocs)
}
}
// 分片表的过期与并发安全(-race 下验证)。
func TestAffinityTableSharded(t *testing.T) {
tbl := newAffinityTable()
tbl.put(1, 10, 50*time.Millisecond)
tbl.put(2, 11, time.Minute)
if id, ok := tbl.get(1); !ok || id != 10 {
t.Fatalf("get(1) = %d/%v", id, ok)
}
if tbl.len() != 2 {
t.Fatalf("len = %d, 期望 2", tbl.len())
}
time.Sleep(60 * time.Millisecond)
if _, ok := tbl.get(1); ok {
t.Fatal("过期条目应失效")
}
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
for j := 0; j < 200; j++ {
k := uint64(n*1000 + j)
tbl.put(k, k, time.Minute)
tbl.get(k)
}
}(i)
}
wg.Wait()
}