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)全绿。
This commit is contained in:
1 parent
117e03673b
commit
8e5fe16611
12 files changed
+391
-16
No files matched your search
@@ -0,0 +1,110 @@
|
||||
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()
|
||||
}
|
||||
Reference in new issue
Block a user