perf(sqlite): 写事务改 immediate + synchronous=NORMAL,单 worker 批 64
上一轮发现「多 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)全绿。
This commit is contained in:
1 parent
f1181150aa
commit
117e03673b
3 files changed
+40
-12
No files matched your search
@@ -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 错误事件收尾
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user