Files
openteam/server/internal/store/db.go
T
Sakurasan 8c12207478 perf: 目录缓存与流式正文快路径,请求热路径开销减半
按性能分析逐项落地,实测对比(16 并发、mock 上游零延迟、SQLite):

- 每请求 SQL 10 → 2:模型/渠道目录(模型行、绑定、渠道行)进程内缓存 10s,
  运行期状态(冷却、健康)仍每次实时判定;管理端写操作经中间件、
  健康状态变化经回调立即失效。新增 TestProxyRequestSQLBudget 钉住预算。
- 端到端 p50 1.3–1.6ms → 0.66–0.70ms,RPS 3.5–8.9k → 16–18k。
- 流式正文累积:字符串 += 改 strings.Builder(原为 O(n²)),
  逐行 JSON 提取改字节快路径,并加廉价的子串预过滤、拿到输出 usage 后停累积。
  500 块:949µs/7.9MB 分配 → 83µs/136KB。
- 请求体定点改写:模型映射从整包 unmarshal/marshal 改为顶层字段替换
  (100KB:585µs → 29µs),stream_options 注入同理;requestText 改为
  只在「上游没报用量」时按需提取,亲和键改用原始体前缀。
- 渠道满载背压:新增 Acquire 有界排队(OT_PROXY_CHANNEL_ACQUIRE_WAIT,
  默认 150ms);排队仍满时返回 503 + Retry-After 而不是 502。
  实测渠道上限 4、16 并发:无排队 81 次 503;排队 150ms 后 400/400 成功。
- 记账按批聚合:同批同用户一次扣款、流水批量插入、日表按 (user,model,日期)
  聚合,替代每行 5 条语句;幂等仍靠 ref_id 唯一索引兜底。
- gorm 开启 PrepareStmt(SQL 解析此前占 CPU 15%);tiktoken 编码器按模型复用。
- 管理端用量列表不再返回原始请求/响应体(可能几十 MB),
  改为 has_raw 标记 + GET /admin/usage/:id 按需拉取,前端弹窗打开时取。

验证:go vet、go test ./...、go test -race(proxy/channel/usage/tokenizer)、
vue-tsc、eslint、vite build 全绿;本地起 mock 上游实测列表/详情接口与
管理页弹窗按需加载。
2026-10-30 14:14:21 +08:00

165 lines
5.8 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 store
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
// schemaVersion 当前 schema 版本。struct 变更(加列/改列/删列)时递增,
// 触发一次 AutoMigrate 并把新版本写入库(SQLite 用 PRAGMA user_version)。
// AutoMigrate 对已有表的列判定不收敛(每次都重建表:CREATE __temp + INSERT SELECT + DROP),
// 大表上一次重建数十秒且每次重启重演,所以之后版本未变就直接跳过。
//
// 注意:c39c3e7 给 Model 加了 cache_write_price/context_length/supports_tools/
// supports_vision/supports_reasoning 等列并删掉了 display_name,但当时漏改这里,
// 导致已建库的实例永远跳过重迁移、新增模型时 Create 因缺列而失败。这里补到 3 触发一次迁移。
// v4:users 加 token_version(会话撤销)。
// v5:usage_logs 补 key_id/channel_id 索引与 (user,created)/(channel,created) 复合索引。
// v6:修正 v5 复合索引列序——AutoMigrate 按名跳过已存在的索引,旧库上的
// idx_user_created 是 user_id 单列,光改 struct tag 不会重建,需删掉重迁一次。
// v7:usage_logs 加 reasoning_tokens / cache_write_1h_tokens / served_model 三列。
const schemaVersion = 7
// Open 打开数据库连接并自动迁移。
// 开发默认 SQLite(自动追加 WAL 与 busy_timeout,见 withSQLitePragmas),生产可切 postgres。
func Open(driver, dsn string) (*gorm.DB, error) {
var dialector gorm.Dialector
switch driver {
case "postgres":
dialector = postgresDialector(dsn)
default:
dsn = withSQLitePragmas(dsn)
// 确保 SQLite 文件所在目录存在
if dir := sqliteDir(dsn); dir != "" {
_ = os.MkdirAll(dir, 0o755)
}
dialector = sqlite.Open(dsn)
}
// PrepareStmt 让 GORM 缓存预编译语句:实测请求路径上 sqlite3Prepare(SQL 解析)
// 占 CPU 的 15%,每个请求十来个查询全在重复解析同几条 SQL。
db, err := gorm.Open(dialector, &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Warn),
PrepareStmt: true,
})
if err != nil {
return nil, err
}
configurePool(db, driver)
if driver != "postgres" && currentSQLiteVersion(db) >= schemaVersion {
log.Printf("store: connected driver=%s (schema up-to-date v%d, skip migrate)", driver, schemaVersion)
return db, nil
}
if err := db.AutoMigrate(AllModels()...); err != nil {
return nil, err
}
reshapeUsageLogIndexes(db)
if driver != "postgres" {
setSQLiteVersion(db, schemaVersion)
}
log.Printf("store: connected driver=%s (migrated, schema v%d)", driver, schemaVersion)
// 将已有渠道的超时时间从 120000ms 更新为 300000ms(幂等操作)
db.Model(&Channel{}).Where("timeout_ms = ?", 120000).Update("timeout_ms", 300000)
return db, nil
}
// reshapeUsageLogIndexes 一次性重建 usage_logs 的复合索引:AutoMigrate 按名跳过已存在的
// 索引,历史库上的 idx_user_created 是 user_id 单列、idx_channel_created 形状同样不对,
// struct tag 改了也不会生效。删掉后紧接的 AutoMigrate 按新定义重建。
// 用 system_configs 打标记:PG 每次启动都走迁移路径,没有标记会反复重建索引。
func reshapeUsageLogIndexes(db *gorm.DB) {
var done string
db.Model(&SystemConfig{}).Where("key = ?", "index_reshape_v6").Pluck("value", &done)
if done != "" {
return
}
for _, name := range []string{"idx_user_created", "idx_channel_created"} {
if db.Migrator().HasIndex(&UsageLog{}, name) {
if err := db.Migrator().DropIndex(&UsageLog{}, name); err != nil {
log.Printf("store: drop legacy index %s: %v", name, err)
return
}
}
}
if err := db.AutoMigrate(&UsageLog{}); err != nil {
log.Printf("store: recreate usage_logs indexes: %v", err)
return
}
db.Create(&SystemConfig{Key: "index_reshape_v6", Value: `"done"`})
log.Printf("store: usage_logs indexes reshaped (user+created / channel+created)")
}
// withSQLitePragmas 给 DSN 补上 busy_timeout 与 WAL。
// glebarez(modernc)驱动只解析 _pragma/_txlock/_time_format/vfs 四种参数——
// 旧注释宣称的 `?_journal_mode=WAL` 实际不生效,缺 WAL 时并发写会频繁 database is locked。
// 用户 DSN 里已显式声明的项不覆盖。
func withSQLitePragmas(dsn string) string {
for _, p := range []string{"_pragma=busy_timeout(10000)", "_pragma=journal_mode(WAL)"} {
if strings.Contains(dsn, p) {
continue
}
if strings.Contains(dsn, "?") {
dsn += "&" + p
} else {
dsn += "?" + p
}
}
return dsn
}
// configurePool 连接池上限。此前全仓无任何配置:PG 连接数无上限,
// SQLite 多连接并发写叠加无 WAL 加剧锁竞争。
func configurePool(db *gorm.DB, driver string) {
sqlDB, err := db.DB()
if err != nil {
return
}
if driver == "postgres" {
sqlDB.SetMaxOpenConns(30)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
return
}
// SQLite:WAL 下读写可并发,写仍串行——限连接数让写竞争在 busy_timeout 内排队
sqlDB.SetMaxOpenConns(8)
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
}
// currentSQLiteVersion 读取 PRAGMA user_version。
func currentSQLiteVersion(db *gorm.DB) int {
var v int
db.Raw("PRAGMA user_version").Scan(&v)
return v
}
// setSQLiteVersion 写入 PRAGMA user_version。
func setSQLiteVersion(db *gorm.DB, v int) {
db.Exec(fmt.Sprintf("PRAGMA user_version = %d", v))
}
// sqliteDir 提取 SQLite DSN 中的目录部分(忽略 file: 前缀与查询参数)。
func sqliteDir(dsn string) string {
d := dsn
if i := strings.IndexByte(d, '?'); i >= 0 {
d = d[:i]
}
if strings.HasPrefix(d, "file:") {
d = d[len("file:"):]
}
if d == "" || d == ":memory:" || strings.Contains(d, "::") {
return ""
}
return filepath.Dir(d)
}