perf: 流式错误判定预过滤、再减一个写索引,加可选明细保留期

- 流式放行前的上游错误判定加廉价子串预过滤("error" / response.failed):
  长思考阶段几十上百条事件不再逐条 json.Unmarshal。
- usage_logs 去掉 idx_channel_created:没有任何查询按 channel_id 过滤该表
  (渠道统计走 created_at 时间窗后在内存分组),复合索引只是每行一次多余的
  B-tree 插入;schema v9 迁移里显式 DROP 旧库索引。至此该表只剩
  (user_id, created_at)、created_at 两个真被用到的索引。
- 新增可选保留期(OT_USAGE_RETENTION_DAYS,默认 0=永久保留):每天分批
  (2000 行/批、批间 50ms)清理过期 usage_logs,避免长期运行后时间窗查询
  与备份随表增长变慢;余额流水 balance_logs 不在清理范围。

参数复测(记账稳态吞吐,3 次取中位):
- batch 64(当前)30.2k rows/s;batch 128 19.0k、batch 256 14.3k(更差,保持 64)
- wal_autocheckpoint(4000)、cache_size(-64MB) 与当前配置无差别,不引入

验证:go vet、go test ./...、-race(usage/proxy/store)全绿;新增保留期三项测试
(只删明细不动流水、未开启不动数据、跨分批删完)。
This commit is contained in:
Sakurasan committed 2026-10-30 16:45:57 +08:00
1 parent 8e5fe16611
commit 531a03c836
9 files changed
+224 -20

No files matched your search

+3 -2
View File
@@ -27,7 +27,8 @@ import (
// idx_user_created 是 user_id 单列,光改 struct tag 不会重建,需删掉重迁一次。
// v7:usage_logs 加 reasoning_tokens / cache_write_1h_tokens / served_model 三列。
// v8:去掉 usage_logs 上没人查询的 key_id / trace_id 索引(每行两次多余的 B-tree 插入)。
const schemaVersion = 8
// v9:同样去掉没人查询的 idx_channel_created。
const schemaVersion = 9
// Open 打开数据库连接并自动迁移。
// 开发默认 SQLite(自动追加 WAL 与 busy_timeout,见 withSQLitePragmas),生产可切 postgres。
@@ -137,7 +138,7 @@ func withSQLitePragmas(dsn string) string {
// dropUnusedUsageLogIndexes 删除历史库里已经没用的索引:AutoMigrate 不会自动删索引,
// 去掉 struct tag 只是不再新建,旧库上它们还会继续拖慢每次插入。
func dropUnusedUsageLogIndexes(db *gorm.DB) {
for _, name := range []string{"idx_usage_logs_key_id", "idx_usage_logs_trace_id"} {
for _, name := range []string{"idx_usage_logs_key_id", "idx_usage_logs_trace_id", "idx_channel_created"} {
if err := db.Exec("DROP INDEX IF EXISTS " + name).Error; err != nil {
log.Printf("store: drop index %s: %v", name, err)
}
+9 -6
View File
@@ -187,10 +187,12 @@ type UsageLog struct {
RequestID string `gorm:"size:128" json:"request_id"` // 上游 request id
// TraceID/KeyID 不建索引:没有查询按它们过滤(幂等检查走 balance_logs.ref_id),
// 每个索引都是每行一次 B-tree 插入,在记账这条最热的写路径上纯属写放大。
TraceID string `gorm:"size:64" json:"trace_id"`
UserID uint64 `gorm:"index:idx_user_created,priority:1;not null" json:"user_id"`
KeyID uint64 `json:"key_id"`
ChannelID uint64 `gorm:"index:idx_channel_created,priority:1" json:"channel_id"`
TraceID string `gorm:"size:64" json:"trace_id"`
UserID uint64 `gorm:"index:idx_user_created,priority:1;not null" json:"user_id"`
KeyID uint64 `json:"key_id"`
// 与 key_id/trace_id 同理:没有任何查询按 channel_id 过滤 usage_logs
// (渠道统计走 created_at 时间窗后在内存里分组),复合索引纯属写放大。
ChannelID uint64 `json:"channel_id"`
ModelID uint64 `json:"model_id"`
ModelName string `gorm:"size:128" json:"model_name"`
Protocol string `gorm:"size:32" json:"protocol"` // responses|chat|messages
@@ -210,8 +212,9 @@ type UsageLog struct {
ErrorCode *string `json:"error_code,omitempty"`
RawRequest string `gorm:"type:text" json:"raw_request"` // 客户端原始请求体(未转换)
RawResponse string `gorm:"type:text" json:"raw_response"` // 上游原始响应(未转换;流式为全部 SSE 事件)
// CreatedAt 同时参与 user/channel 两个复合索引(时间窗过滤),自身单列索引保留给全表时间扫
CreatedAt time.Time `gorm:"index;index:idx_user_created,priority:2;index:idx_channel_created,priority:2" json:"created_at"`
// CreatedAt 参与 (user, created) 复合索引(用户侧时间窗过滤),单列索引服务
// 全表时间窗扫描(渠道统计、运营总览)。
CreatedAt time.Time `gorm:"index;index:idx_user_created,priority:2" json:"created_at"`
}
// UsageDaily 日粒度预聚合(PLANNING §6.6)