Files
Sakurasan 8e5fe16611 perf: 鉴权缓存让命中请求零 SQL,去掉两个无用索引
profile(16 并发)显示请求路径的 CPU 有 ~70% 花在鉴权上:每个代理请求都要用
密钥哈希查 api_keys、再用 user_id 查 users,两条随机读。

- 新增鉴权缓存(OT_PROXY_AUTH_CACHE_TTL,默认 2s,0=关闭):缓存密钥与用户行,
  但密钥状态、用户状态、密钥过期仍按当前时间逐请求判定;管理端写操作与用户的
  密钥写操作经中间件立即清空缓存。余额随用户行一起缓存,只影响「≤0 就 402」
  这道软闸,最长滞后一个 TTL(请求照样计费)。
- last_used_at 节流改由缓存内的 touch 表记账(原先按 DB 行时间戳判断,行被缓存后
  时间戳不再变化,会导致每请求都写一次库)。touch 用 sync.Map:早先与缓存共用一把
  排他锁,16 并发下互斥饥饿把吞吐从 31k 拖到 3.2k(p99 7ms),换 sync.Map 后消失。
- 去掉 usage_logs 上从未被查询过滤的 key_id / trace_id 索引(没有查询按它们过滤,
  每个索引都是每行一次 B-tree 插入);schema v8 迁移里显式 DROP 旧库上的索引。

实测(16 并发、mock 上游、SQLite):
- 鉴权缓存开:p50 131–177µs、p99 0.6–1.2ms、RPS 56k–81k
- 鉴权缓存关:p50 535–631µs、p99 1.6–2.8ms、RPS 21k–27k
(4 并发下同样受益:30k→41k)

测试:新增缓存命中零 SQL、状态/过期重判、失效、TTL 到期、缓存关闭、并发七项;
go vet、go test ./...、-race(proxy/usage/store/api)全绿。
2026-10-30 16:31:41 +08:00

111 lines
3.1 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 (
"sync"
"time"
"github.com/openteam/server/internal/store"
)
// 鉴权缓存:每个代理请求都要用密钥哈希查 api_keys、再用 user_id 查 users,
// 这两条随机读在 profile 里占请求路径 CPU 的 ~70%。
//
// 缓存的是「密钥 + 用户」这两行,但**状态与过期每次请求都重新判定**:
// 密钥停用、用户停用、密钥过期都按缓存值判断,最长延迟就是 TTL。
// 因此 TTL 要短(默认 2s),且管理端/用户的写操作会立即清空缓存。
//
// 余额也随用户行一起缓存:它只用于「余额 ≤ 0 就 402」这道软闸,
// 最长 2s 的滞后不会造成实际损失(请求照样计费,余额允许为负)。
const (
authCacheMax = 8192
authCacheDefault = 2 * time.Second
)
type authEntry struct {
key store.APIKey
user store.User
until time.Time
}
type authCache struct {
mu sync.RWMutex
m map[string]authEntry
ttl time.Duration
// touch 每个密钥最近一次写 last_used_at 的时间。行被缓存后它自己是不会变的,
// 节流必须按这里的记录判断,否则每请求都会写一次库(反而比不缓存更糟)。
//
// 用 sync.Map 而不是和 m 共用一把锁:这个表是「每请求读、每分钟写一次」的
// 读多写少结构。早先共用一个 Mutex 时,16 并发下每个请求都要抢排他锁,
// 直接把吞吐从 31k 拖到 3.2k(Goroutine 互斥饥饿,p99 7ms)。
touch sync.Map // keyID(uint64) → time.Time
}
func newAuthCache(ttl time.Duration) *authCache {
if ttl < 0 {
ttl = 0
}
return &authCache{m: map[string]authEntry{}, ttl: ttl}
}
// on 缓存是否启用。
func (a *authCache) on() bool { return a != nil && a.ttl > 0 }
func (a *authCache) get(hash string) (store.APIKey, store.User, bool) {
if !a.on() {
return store.APIKey{}, store.User{}, false
}
a.mu.RLock()
e, ok := a.m[hash]
a.mu.RUnlock()
if !ok || time.Now().After(e.until) {
return store.APIKey{}, store.User{}, false
}
return e.key, e.user, true
}
func (a *authCache) put(hash string, k store.APIKey, u store.User) {
if !a.on() {
return
}
a.mu.Lock()
defer a.mu.Unlock()
if len(a.m) >= authCacheMax {
now := time.Now()
for h, e := range a.m {
if now.After(e.until) {
delete(a.m, h)
}
}
if len(a.m) >= authCacheMax {
return // 全是活的:放弃本次缓存,下次请求再查库
}
}
a.m[hash] = authEntry{key: k, user: u, until: time.Now().Add(a.ttl)}
}
// shouldTouch 该密钥现在是否应该写一次 last_used_at(每 every 最多一次)。
// 快路径是 sync.Map 的无锁读。
func (a *authCache) shouldTouch(keyID uint64, every time.Duration) bool {
if a == nil {
return true
}
now := time.Now()
if v, ok := a.touch.Load(keyID); ok {
if t, ok := v.(time.Time); ok && now.Sub(t) < every {
return false
}
}
a.touch.Store(keyID, now)
return true
}
// clear 清空缓存(密钥/用户/渠道等发生写操作后调用)。
func (a *authCache) clear() {
if a == nil {
return
}
a.mu.Lock()
a.m = map[string]authEntry{}
a.mu.Unlock()
}