fix: Redis 降级机制优化:启动离线不崩溃 + 操作级即时降级 + 快速恢复

- 启动时 Redis 不可达不再 Fatal 退出,降级为内存模式继续运行
- FallbackStore 改为操作级降级:Redis 操作失败立即切换到内存,不依赖周期健康检查
- 新增 recoveryLoop:降级后每 5s 轮询尝试恢复,恢复后自动迁移内存会话回 Redis
- 健康检查与清理解耦:检查 30s 间隔,恢复轮询 5s 间隔(原 300s)
- 优化 go-redis 连接池参数(DialTimeout 1s, MaxRetries 1,减少故障阻塞时间)
This commit is contained in:
2026-05-31 12:14:10 +08:00
parent 9d6aebbc0d
commit 3e26431602
4 changed files with 127 additions and 30 deletions

View File

@ -70,14 +70,16 @@ func main() {
r.Static("/uploads/avatars", "./storage/uploads/avatars")
r.Static("/uploads/posts", "./storage/uploads/posts")
// Redis 客户端(可选)
// Redis 客户端(可选,不可达时自动降级为内存存储
var redisClient *redis.Client
if cfg.Redis.Enabled {
redisClient, err = common.NewRedisClient(cfg.Redis)
if err != nil {
log.Fatalf("Redis 连接失败: %v", err)
log.Printf("Redis 不可用,降级为内存存储: %v", err)
redisClient = nil // 强制走纯内存模式
} else {
log.Printf("Redis 已连接 %s:%s (DB=%d)", cfg.Redis.Host, cfg.Redis.Port, cfg.Redis.DB)
}
log.Printf("Redis 已连接 %s:%s (DB=%d)", cfg.Redis.Host, cfg.Redis.Port, cfg.Redis.DB)
} else {
log.Println("Redis 未启用,使用内存存储")
}

View File

@ -3,25 +3,33 @@ package common
import (
"context"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
"metazone.cc/metalab/internal/config"
)
// NewRedisClient 创建 Redis 客户端并验证连接
// NewRedisClient 创建 Redis 客户端并尝试验证连接
// Redis 不可达时不返回错误,而是返回 clientgo-redis 自带自动重连),由调用方决定降级策略
func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port),
Password: cfg.Password,
DB: cfg.DB,
Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port),
Password: cfg.Password,
DB: cfg.DB,
DialTimeout: 1 * time.Second, // 连接超时降低(默认 5s配合 FallbackStore 快速降级
ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second,
MaxRetries: 1, // 最多重试 1 次(默认 3 次),减少故障阻塞时间
PoolSize: 5, // 会话存储不需要大连接池(默认 10*GOMAXPROCS
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("Redis 连接失败: %w", err)
log.Printf("[Redis] 连接失败: %v将降级为内存存储", err)
return client, fmt.Errorf("Redis 连接失败: %w", err)
}
return client, nil

View File

@ -51,7 +51,7 @@ func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSet
if cfg.Redis.Enabled && redisClient != nil {
// Redis 模式 + 自动降级保护
redisStore := session.NewRedisStore(redisClient, idleTimeout, rememberTimeout)
sessionStore = session.NewFallbackStore(redisStore, memStore, cleanupInterval)
sessionStore = session.NewFallbackStore(redisStore, memStore)
log.Println("[Router] 会话存储: Redis带内存降级保护")
} else {
// 纯内存模式

View File

@ -19,14 +19,20 @@ type FallbackStore struct {
memory *MemoryStore
client *redis.Client
healthy atomic.Bool // Redis 当前是否健康
degraded atomic.Bool // 是否处于降级模式(曾因异常而切换到 Memory
migrating atomic.Bool // 是否正在执行迁移(避免并发迁移)
healthy atomic.Bool // Redis 当前是否健康
degraded atomic.Bool // 是否处于降级模式(曾因异常而切换到 Memory
migrating atomic.Bool // 是否正在执行迁移(避免并发迁移)
recovering atomic.Bool // 是否正在执行快速恢复轮询
}
// 健康检查间隔:正常模式下定期检测 Redis 连通性
const healthCheckInterval = 30 * time.Second
// 快速恢复间隔:降级模式下高频轮询,快速切回 Redis
const recoveryInterval = 5 * time.Second
// NewFallbackStore 创建降级保护存储
// checkInterval: 健康检测间隔,建议与 session cleanup_interval 一致
func NewFallbackStore(redis *RedisStore, memory *MemoryStore, checkInterval time.Duration) *FallbackStore {
func NewFallbackStore(redis *RedisStore, memory *MemoryStore) *FallbackStore {
fs := &FallbackStore{
redis: redis,
memory: memory,
@ -34,16 +40,17 @@ func NewFallbackStore(redis *RedisStore, memory *MemoryStore, checkInterval time
}
fs.healthy.Store(true)
// 后台健康检测 goroutine
go fs.healthLoop(checkInterval)
// 后台健康检测 + 快速恢复(独立 goroutine 互不阻塞)
go fs.healthLoop()
go fs.recoveryLoop()
log.Println("[FallbackStore] 已启动 Redis 健康检测(含自动迁移)")
log.Println("[FallbackStore] 已启动 Redis 健康检测(健康检测 30s降级恢复 5s含自动迁移)")
return fs
}
// healthLoop 定期 ping Redis自动升降级 + 静默迁移
func (fs *FallbackStore) healthLoop(interval time.Duration) {
ticker := time.NewTicker(interval)
// healthLoop 定期 ping Redis30s 间隔),自动升降级 + 静默迁移
func (fs *FallbackStore) healthLoop() {
ticker := time.NewTicker(healthCheckInterval)
defer ticker.Stop()
for range ticker.C {
@ -52,12 +59,11 @@ func (fs *FallbackStore) healthLoop(interval time.Duration) {
if ok && !prev {
// Redis 恢复:先切到 Redis新会话立即走 Redis再迁移内存存量
// 先切后迁Snapshot 和 healthy 切换之间零 gap不存在遗漏
fs.healthy.Store(true)
fs.migrateSessions()
fs.degraded.Store(false)
} else if !ok && prev {
// Redis 掉线,标记降级
// Redis 掉线,标记降级(快速恢复由 recoveryLoop 负责)
fs.healthy.Store(false)
fs.degraded.Store(true)
log.Println("[FallbackStore] Redis 不可用,已降级为内存存储")
@ -67,6 +73,33 @@ func (fs *FallbackStore) healthLoop(interval time.Duration) {
}
}
// recoveryLoop 启动后持续监听退化状态一旦降级立即启动快速恢复轮询5s 间隔)
// 退出条件Redis 恢复成功或 connectOnce 竞争到恢复权限后执行迁移
func (fs *FallbackStore) recoveryLoop() {
ticker := time.NewTicker(recoveryInterval)
defer ticker.Stop()
for range ticker.C {
if fs.healthy.Load() {
continue // 正常模式,无需恢复
}
// 降级模式:尝试恢复
if !fs.recovering.CompareAndSwap(false, true) {
continue // 已有恢复在进行中
}
if !fs.ping() {
fs.recovering.Store(false)
continue // 仍未恢复
}
// Redis 已恢复
fs.healthy.Store(true)
fs.migrateSessions()
fs.degraded.Store(false)
fs.recovering.Store(false)
}
}
// migrateSessions 将降级期间内存中的活跃会话同步到 Redis
func (fs *FallbackStore) migrateSessions() {
if !fs.migrating.CompareAndSwap(false, true) {
@ -121,34 +154,88 @@ func (fs *FallbackStore) current() Store {
return fs.memory
}
// ---- Store 接口实现(委托给 current()----
// ---- Store 接口实现(Redis 操作失败时立即降级到内存----
// 不依赖周期性健康检查,操作级故障即时响应
func (fs *FallbackStore) Get(id string) (*Session, error) {
return fs.current().Get(id)
if fs.healthy.Load() {
s, err := fs.redis.Get(id)
if err == nil {
return s, nil
}
log.Printf("[FallbackStore] Redis Get 失败,降级到内存: %v", err)
fs.healthy.Store(false)
fs.degraded.Store(true)
}
return fs.memory.Get(id)
}
func (fs *FallbackStore) Set(s *Session) error {
return fs.current().Set(s)
if fs.healthy.Load() {
if err := fs.redis.Set(s); err == nil {
return nil
}
log.Printf("[FallbackStore] Redis Set 失败,降级到内存")
fs.healthy.Store(false)
fs.degraded.Store(true)
}
return fs.memory.Set(s)
}
func (fs *FallbackStore) Delete(id string) error {
return fs.current().Delete(id)
if fs.healthy.Load() {
if err := fs.redis.Delete(id); err == nil {
return nil
}
log.Printf("[FallbackStore] Redis Delete 失败,降级到内存")
fs.healthy.Store(false)
fs.degraded.Store(true)
}
return fs.memory.Delete(id)
}
func (fs *FallbackStore) DeleteByUID(uid uint) error {
return fs.current().DeleteByUID(uid)
if fs.healthy.Load() {
if err := fs.redis.DeleteByUID(uid); err == nil {
return nil
}
log.Printf("[FallbackStore] Redis DeleteByUID 失败,降级到内存")
fs.healthy.Store(false)
fs.degraded.Store(true)
}
return fs.memory.DeleteByUID(uid)
}
func (fs *FallbackStore) DeleteByUIDExclude(uid uint, excludeSID string) error {
return fs.current().DeleteByUIDExclude(uid, excludeSID)
if fs.healthy.Load() {
if err := fs.redis.DeleteByUIDExclude(uid, excludeSID); err == nil {
return nil
}
log.Printf("[FallbackStore] Redis DeleteByUIDExclude 失败,降级到内存")
fs.healthy.Store(false)
fs.degraded.Store(true)
}
return fs.memory.DeleteByUIDExclude(uid, excludeSID)
}
func (fs *FallbackStore) ListByUID(uid uint) ([]*Session, error) {
return fs.current().ListByUID(uid)
if fs.healthy.Load() {
sessions, err := fs.redis.ListByUID(uid)
if err == nil {
return sessions, nil
}
log.Printf("[FallbackStore] Redis ListByUID 失败,降级到内存: %v", err)
fs.healthy.Store(false)
fs.degraded.Store(true)
}
return fs.memory.ListByUID(uid)
}
func (fs *FallbackStore) Cleanup() int {
return fs.current().Cleanup()
if fs.healthy.Load() {
return fs.redis.Cleanup()
}
return fs.memory.Cleanup()
}
func (fs *FallbackStore) StartCleanup(interval time.Duration) {