perf: Sprint 19 P0/P1 性能优化落地

P0(高优先级):
- P0-1: 确认数据库复合索引已存在(GORM tag),composite_index_test 验证通过
- P0-2: 连接池调优 MaxIdleConns 5→10, ConnMaxLifetime 30min→5min
- P0-3: Redis 智能探测(ProbeRedis),无 Redis 自动降级到纯内存模式

P1(中优先级):
- P1-1: GZIP 压缩中间件(compress/gzip 标准库,零新依赖)
- P1-2: 权限缓存 TTL 30min→5min
- P1-3: Argon2id 启动自适应校准(CalibrateArgon2id)

历史优化(含本次提交):
- L1Cache O(n)→O(1) LRU 重构
- Auth 中间件 DB 查询合并 + 5s L1 缓存
- Logger 异步化(4096 缓冲通道)

验证: go build/vet/test 41/41 PASS, govulncheck 无漏洞
This commit is contained in:
2026-04-18 22:57:44 +08:00
parent 85285c16d1
commit 7b047e2f11
11 changed files with 1231 additions and 154 deletions

View File

@@ -74,14 +74,10 @@ func (m *AuthMiddleware) Required() gin.HandlerFunc {
return
}
if m.isPasswordChangedSinceTokenIssued(c.Request.Context(), claims.UserID, claims.PCE) {
c.JSON(http.StatusUnauthorized, apierrors.New(http.StatusUnauthorized, "UNAUTHORIZED", "密码已更新,请重新登录"))
c.Abort()
return
}
if !m.isUserActive(c.Request.Context(), claims.UserID) {
c.JSON(http.StatusUnauthorized, apierrors.New(http.StatusUnauthorized, "UNAUTHORIZED", "账号不可用,请重新登录"))
// Perf: merge two separate DB round-trips (password-change check + active check)
// into a single cached user-state validation.
if denyReason := m.validateUserState(c.Request.Context(), claims.UserID, claims.PCE); denyReason != "" {
c.JSON(http.StatusUnauthorized, apierrors.New(http.StatusUnauthorized, "UNAUTHORIZED", denyReason))
c.Abort()
return
}
@@ -103,7 +99,7 @@ func (m *AuthMiddleware) Optional() gin.HandlerFunc {
token := m.extractToken(c)
if token != "" {
claims, err := m.jwt.ValidateAccessToken(token)
if err == nil && !m.isJTIBlacklisted(c.Request.Context(), claims.JTI) && !m.isPasswordChangedSinceTokenIssued(c.Request.Context(), claims.UserID, claims.PCE) && m.isUserActive(c.Request.Context(), claims.UserID) {
if err == nil && !m.isJTIBlacklisted(c.Request.Context(), claims.JTI) && m.validateUserState(c.Request.Context(), claims.UserID, claims.PCE) == "" {
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("token_jti", claims.JTI)
@@ -146,24 +142,82 @@ func (m *AuthMiddleware) isJTIBlacklisted(ctx context.Context, jti string) bool
return false
}
// isPasswordChangedSinceTokenIssued 检查用户密码是否在令牌发放后已更改
// 如果 tokenPCE 为 0旧令牌则不检查向后兼容
func (m *AuthMiddleware) isPasswordChangedSinceTokenIssued(ctx context.Context, userID int64, tokenPCE int64) bool {
if tokenPCE == 0 {
// 旧令牌没有密码变更时间戳,不拦截
return false
// validateUserState performs a single cached DB lookup that replaces the two
// previously separate checks: isPasswordChangedSinceTokenIssued + isUserActive.
//
// Returns "" on success, or an i18n-ready denial message on failure.
// Results are cached for 5 seconds per user to reduce DB pressure under high
// concurrency (e.g. 100 VU × 10 req/s = 1 000 auth middleware calls/s against
// the same hot user IDs).
func (m *AuthMiddleware) validateUserState(ctx context.Context, userID int64, tokenPCE int64) string {
if m.userRepo == nil {
return ""
}
// Check short-lived user-state cache (5 s TTL).
stateCacheKey := fmt.Sprintf("user_state:%d", userID)
if cached, ok := m.l1Cache.Get(stateCacheKey); ok {
if state, ok := cached.(userStateEntry); ok {
// tokenPCE > 0 means the JWT was issued for a user who had already
// changed their password at least once. Zero/negative values come from
// users whose PasswordChangedAt is still the Go zero-time, meaning they
// have never changed it — skip the check in that case.
if tokenPCE > 0 && state.passwordChangedAt > 0 && tokenPCE < state.passwordChangedAt {
return "密码已更新,请重新登录"
}
if !state.active {
return "账号不可用,请重新登录"
}
return ""
}
}
// Cache miss — single DB round-trip.
user, err := m.userRepo.GetByID(ctx, userID)
if err != nil {
return "账号不可用,请重新登录"
}
state := userStateEntry{
active: user.Status == domain.UserStatusActive,
passwordChangedAt: 0,
}
if !user.PasswordChangedAt.IsZero() {
state.passwordChangedAt = user.PasswordChangedAt.Unix()
}
// Cache for 5 seconds — short enough to reflect account lock/disable promptly.
m.l1Cache.Set(stateCacheKey, state, 5*time.Second)
// Same guard: tokenPCE <= 0 means no password-change time in the JWT → skip.
if tokenPCE > 0 && state.passwordChangedAt > 0 && tokenPCE < state.passwordChangedAt {
return "密码已更新,请重新登录"
}
if !state.active {
return "账号不可用,请重新登录"
}
return ""
}
// InvalidateUserStateCache removes the user-state cache entry so the next
// request picks up fresh data. Call this after status change or password reset.
func (m *AuthMiddleware) InvalidateUserStateCache(userID int64) {
m.l1Cache.Delete(fmt.Sprintf("user_state:%d", userID))
}
// isPasswordChangedSinceTokenIssued 检查用户密码是否在令牌发放后已更改
// Deprecated: use validateUserState for combined check with caching.
func (m *AuthMiddleware) isPasswordChangedSinceTokenIssued(ctx context.Context, userID int64, tokenPCE int64) bool {
if tokenPCE == 0 {
return false
}
if m.userRepo == nil {
return false
}
user, err := m.userRepo.GetByID(ctx, userID)
if err != nil || user.PasswordChangedAt.IsZero() {
return false
}
// 如果令牌的 PCE < 用户密码变更时间,说明密码在令牌发放后已更改
return tokenPCE < user.PasswordChangedAt.Unix()
}
@@ -195,7 +249,10 @@ func (m *AuthMiddleware) loadUserRolesAndPerms(ctx context.Context, userID int64
permCodes = append(permCodes, perm.Code)
}
m.l1Cache.Set(cacheKey, userPermEntry{roles: roleCodes, perms: permCodes}, 30*time.Minute)
// P1-2 权限缓存 TTL 调优5min原 30min
// 理由:角色/权限变更后最长 5min 生效,与 userStateEntry TTL 保持一致。
// 若需立即生效,调用 InvalidateUserPermCache(userID) 主动驱逐。
m.l1Cache.Set(cacheKey, userPermEntry{roles: roleCodes, perms: permCodes}, 5*time.Minute)
return roleCodes, permCodes
}
@@ -240,3 +297,10 @@ type userPermEntry struct {
roles []string
perms []string
}
// userStateEntry caches the minimal user state needed for auth checks.
// TTL is 5 s so that account lock/disable takes effect within seconds.
type userStateEntry struct {
active bool
passwordChangedAt int64 // Unix timestamp; 0 means never changed
}