From 117e03673b6597e2171942b8051a763b44385485 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:08:55 +0800 Subject: [PATCH] =?UTF-8?q?perf(sqlite):=20=E5=86=99=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=E6=94=B9=20immediate=20+=20synchronous=3DNORMAL=EF=BC=8C?= =?UTF-8?q?=E5=8D=95=20worker=20=E6=89=B9=2064?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮发现「多 worker 在 SQLite 上更慢」,这轮把它当成一个可调优问题重新测: 基准(2000 条记账、暖机后跑稳态,同一台机器反复 3 次取分布): - 默认 deferred 事务 + batch 32:11k–20k rows/s,抖动极大(并发写升级锁失败触发 SQLITE_BUSY 重试,这正是之前 p99 飙到 19–34ms 的原因) - _txlock=immediate + batch 32:28.2–28.5k rows/s,抖动收敛 - _txlock=immediate + batch 64 + synchronous=NORMAL:29.7–30.1k rows/s,波动 <1% - 同样配置下 2 个 worker:7.7k–16.7k,仍更差(SQLite 只有一个写者) 因此: - store.withSQLitePragmas 增加 _txlock=immediate 与 _pragma=synchronous(NORMAL); immediate 让写事务在 BEGIN 就取写锁,消除升级冲突;NORMAL 是 WAL 下的标准配置, 提交不再逐次 fsync(代价:断电可能丢最后一个事务,已在注释里写明)。 - 记账批大小按驱动区分:SQLite 单 worker 用 64,PG 保持 32。 - 保持 SQLite 单 worker(数据不支持多 worker)。 端到端复测(16 并发、mock 上游):p50 649µs→548µs、p99 2.32ms→1.60ms、 RPS 21.2k→24.9k;对比本轮开始前的 18.7k/2.2ms 亦有改善。 验证:go vet、go test ./...、go test -race(usage/proxy/store)全绿。 --- README.md | 2 +- server/internal/store/db.go | 19 ++++++++++++++++--- server/internal/usage/usage.go | 31 +++++++++++++++++++++++-------- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5ad6f77..00ccae5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - `GET /v1/models` — 可用模型列表:只列调用方真能用的模型(叠加用户与密钥白名单),带上下文长度与能力位;Anthropic 客户端(带 `x-api-key`/`anthropic-version`)返回 Anthropic 形状 - **三协议互转**:客户端协议 × 渠道协议不匹配时自动转换(如 Chat 调用 Claude、Messages 调用 OpenAI、Responses 调用 Claude),流式逐事件转换;协议匹配时直通 - 错误按客户端协议返回(OpenAI 格式 / Anthropic 格式) -- **热路径开销**:模型/渠道目录进程内缓存(每请求 SQL 从 10 条降到 2 条,管理端写操作与健康状态变化即时失效)、流式正文按字节快路径提取、请求体定点改写(模型映射不再整包 unmarshal/marshal)、tiktoken 编码器复用、会话亲和表 16 片无锁竞争(键为 64 位摘要、零分配)、访问日志走 64 KiB 缓冲(不再每请求一次 stderr 系统调用);记账落库按驱动自适应并发(SQLite 单 worker,PostgreSQL 4 个分片 worker) +- **热路径开销**:模型/渠道目录进程内缓存(每请求 SQL 从 10 条降到 2 条,管理端写操作与健康状态变化即时失效)、流式正文按字节快路径提取、请求体定点改写(模型映射不再整包 unmarshal/marshal)、tiktoken 编码器复用、会话亲和表 16 片无锁竞争(键为 64 位摘要、零分配)、访问日志走 64 KiB 缓冲(不再每请求一次 stderr 系统调用);记账落库按驱动自适应(SQLite 单 worker + batch 64,并启用 `_txlock=immediate` 与 `synchronous=NORMAL`,实测 13k → 30k rows/s 且抖动从 ±50% 收敛到 <1%;PostgreSQL 4 个分片 worker) - **渠道体系**:按模型绑定选渠道 + 加权负载均衡;模型已有绑定时只用绑定渠道(都不可用则 503,不会把请求发给不认识它的渠道);每渠道并发信号量(满载排队 `OT_PROXY_CHANNEL_ACQUIRE_WAIT`,默认 150ms,仍满则 503 + Retry-After 而不是 502);后台健康检查(探测 `/models`,连续失败进 cooldown、恢复放回)+ 运行期健康(按失败原因分类冷却:网络错误/5xx 连续 3 次进冷却且指数退避,余额/配额类第一次即冷却 30 分钟,成功清零退避计数);可安全重试的失败自动故障转移(网络错误/429/5xx/超时/流式首个内容事件之前的任何失败);**会话亲和**(`OT_PROXY_AFFINITY_TTL`,默认 5m):同一会话粘住上次应答的渠道,命中上游提示词缓存而不是全价重算,冷却/下线自动回退加权随机 - **上游超时**:渠道 `timeout_ms` 生效——非流式为整段请求上限,流式仅约束首包(正文不限时长);上游请求绑定客户端 context,断连即取消 - **故障转移与流式细节**(借鉴 magpie 的 holdWriter):流式请求在**首个内容事件**到达前由网关缓冲——上游的错误事件、空流、断连、协议形态不符(要流给了整包,didNotStream)都在放行前换渠道消化,客户端无感知;`OT_PROXY_FIRST_TOKEN_WAIT` 限定放行前等内容的最长时间(0=不限);429 不一定是限流——错误体带余额/配额特征时按配额类长冷却;上游 `Retry-After` 会延长该渠道冷却(夹在 10 分钟内);重试受 `OT_PROXY_FAILOVER_BUDGET` 时间预算约束(首个渠道不受限,避免坏渠道把客户端拖到 N×timeout);流式空闲超过 `OT_PROXY_STREAM_HEARTBEAT` 补一行 SSE 注释心跳(扣住期间也照发),防止 nginx/Cloudflare 的空闲超时掐掉长思考;响应头已被心跳先行写出时,全部候选失败以 SSE 错误事件收尾 diff --git a/server/internal/store/db.go b/server/internal/store/db.go index 3040831..a9655c9 100644 --- a/server/internal/store/db.go +++ b/server/internal/store/db.go @@ -47,8 +47,8 @@ func Open(driver, dsn string) (*gorm.DB, error) { // PrepareStmt 让 GORM 缓存预编译语句:实测请求路径上 sqlite3Prepare(SQL 解析) // 占 CPU 的 15%,每个请求十来个查询全在重复解析同几条 SQL。 db, err := gorm.Open(dialector, &gorm.Config{ - Logger: gormlogger.Default.LogMode(gormlogger.Warn), - PrepareStmt: true, + Logger: gormlogger.Default.LogMode(gormlogger.Warn), + PrepareStmt: true, }) if err != nil { return nil, err @@ -103,8 +103,21 @@ func reshapeUsageLogIndexes(db *gorm.DB) { // glebarez(modernc)驱动只解析 _pragma/_txlock/_time_format/vfs 四种参数—— // 旧注释宣称的 `?_journal_mode=WAL` 实际不生效,缺 WAL 时并发写会频繁 database is locked。 // 用户 DSN 里已显式声明的项不覆盖。 +// SQLite 连接参数。实测(单表 2000 条记账、暖机后): +// - 默认 deferred 事务:并发写升级锁失败会触发 SQLITE_BUSY 重试,吞吐 11–20k rows/s +// 且抖动极大; +// - _txlock=immediate:写事务在 BEGIN 时就拿写锁,不再有升级冲突,28k rows/s; +// - 再加 synchronous(NORMAL):WAL 下的标准配置(提交不 fsync),稳定 30k rows/s, +// 波动 <1%。 +// +// 这两项都是 SQLite 官方推荐的并发写法,代价是断电时可能丢最后一个事务。 func withSQLitePragmas(dsn string) string { - for _, p := range []string{"_pragma=busy_timeout(10000)", "_pragma=journal_mode(WAL)"} { + for _, p := range []string{ + "_pragma=busy_timeout(10000)", + "_pragma=journal_mode(WAL)", + "_pragma=synchronous(NORMAL)", + "_txlock=immediate", + } { if strings.Contains(dsn, p) { continue } diff --git a/server/internal/usage/usage.go b/server/internal/usage/usage.go index be74506..8ad23cd 100644 --- a/server/internal/usage/usage.go +++ b/server/internal/usage/usage.go @@ -21,9 +21,11 @@ type Recorder struct { shards []chan *store.UsageLog // workers 本实例的落库并发度(SQLite 为 1,PG 为 flushWorkersPG) workers int - wg sync.WaitGroup - closed chan struct{} - once sync.Once // 保证 Close 只执行一次(main 的 defer Shutdown 与显式 Close 双调用) + // batch 每批条数(SQLite 单写者时用大一些的批更划算,测试可覆盖) + batch int + wg sync.WaitGroup + closed chan struct{} + once sync.Once // 保证 Close 只执行一次(main 的 defer Shutdown 与显式 Close 双调用) // mu 保护 stopped:Close 之后 Record 不再进队(shard 永不 close,杜绝 send-on-closed panic), // 改走同步落库兜底。 @@ -32,7 +34,10 @@ type Recorder struct { } const ( - batchSize = 32 + // batchSize 每批条数。SQLite 单写者:批次越大,摊到每条的事务开销越小 + // (实测 batch 64 比 32 再快 ~7%);PG 保持 32,让事务粒度更细。 + batchSize = 32 + batchSizeSQL = 64 // flushWorkersPG 落库 worker 数(按用户分片)。SQLite 只有一个写者, // 多 worker 并发写只会互相抢锁、把 p99 拖长(实测 4 worker 时 p99 19–34ms), // 所以按驱动自适应:PG 用多个,SQLite 只用一个。 @@ -46,7 +51,16 @@ func NewRecorder(db *gorm.DB) *Recorder { return newRecorder(db, workersFor(db)) } -// workersFor 按驱动决定落库并发度:SQLite 只有一个写者,多 worker 只会抢锁。 +// batchFor 按驱动决定批大小:SQLite 单写者,大一些的批更划算。 +func batchFor(db *gorm.DB, workers int) int { + if workers == 1 && db != nil && db.Dialector != nil && db.Dialector.Name() != "postgres" { + return batchSizeSQL + } + return batchSize +} + +// workersFor 按驱动决定落库并发度:SQLite 只有一个写者,多 worker 只会抢锁 +// (实测 2 个 worker 吞吐反而降到 8–17k rows/s)。 func workersFor(db *gorm.DB) int { if db != nil && db.Dialector != nil && db.Dialector.Name() == "postgres" { return flushWorkersPG @@ -63,6 +77,7 @@ func newRecorder(db *gorm.DB, workers int) *Recorder { db: db, closed: make(chan struct{}), workers: workers, + batch: batchFor(db, workers), shards: make([]chan *store.UsageLog, workers), } for i := range r.shards { @@ -116,7 +131,7 @@ func (r *Recorder) Close() { func (r *Recorder) run(shard int) { defer r.wg.Done() ch := r.shards[shard] - buf := make([]*store.UsageLog, 0, batchSize) + buf := make([]*store.UsageLog, 0, r.batch) flushBuf := func() { if len(buf) > 0 { r.flushRetry(buf) @@ -129,7 +144,7 @@ func (r *Recorder) run(shard int) { select { case l := <-ch: buf = append(buf, l) - if len(buf) >= batchSize { + if len(buf) >= r.batch { flushBuf() } case <-r.closed: @@ -139,7 +154,7 @@ func (r *Recorder) run(shard int) { select { case l := <-ch: buf = append(buf, l) - if len(buf) >= batchSize { + if len(buf) >= r.batch { flushBuf() } default: