From 0548a8a5d49ef82c8114822a6c14a50a3e03aafc Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 13 Jul 2026 15:43:15 +0800 Subject: [PATCH] fix(redis): add maintenance notification startup switch Default Redis maintenance notification negotiation to disabled and apply the startup-only setting to both platform and Asynq clients. --- .env.example | 2 + config.example.yaml | 1 + docs/changelog/index.md | 1 + docs/reference/configuration.md | 1 + internal/config/config.go | 1 + internal/config/config_test.go | 14 ++++++ internal/config/model.go | 33 +++++++------- internal/db/redis.go | 73 +++++++++++++++++------------- internal/db/redis_test.go | 25 +++++++++++ internal/task/utils.go | 80 ++++++++++++++++++++++++++------- internal/task/utils_test.go | 73 ++++++++++++++++++++++++++++++ 11 files changed, 240 insertions(+), 64 deletions(-) create mode 100644 internal/config/config_test.go create mode 100644 internal/db/redis_test.go create mode 100644 internal/task/utils_test.go diff --git a/.env.example b/.env.example index e340eb24..1a57e08d 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,8 @@ REDIS_ADDR=redis:6379 # REDIS_DB=0 REDIS_KEY_PREFIX=openflare: # REDIS_POOL_SIZE=100 +# 启动时开关;修改后需重启服务 +REDIS_MAINT_NOTIFICATIONS=false # compose 宿主机映射端口(仅 docker-compose 使用) # REDIS_PORT=6379 diff --git a/config.example.yaml b/config.example.yaml index 7f0b9fe5..2f91aa11 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -67,6 +67,7 @@ redis: max_retries: 3 pool_timeout: 4 conn_max_idle_time: 300 + maint_notifications: false # 启动时开关;启用 Redis maintenance notifications 自动协商,修改后需重启 # ─── Logging ──────────────────────────────────────────────────────────────────── log: diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 9d20987f..4eab60bb 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -36,6 +36,7 @@ sidebar: false ### 修复 - GORM SQL 语句改为仅在全局 `debug` 日志级别输出;生产默认日志级别不再记录查询文本,慢查询与执行错误也不包含 SQL 参数,避免节点访问令牌等敏感信息写入应用日志。 +- 新增 `redis.maint_notifications` 启动开关并默认关闭,平台与 Asynq Redis 客户端仅在显式开启时进行 maintenance notifications 自动协商,避免不支持该命令的 Redis 服务持续输出握手 fallback 警告。 - WAF 规则编辑器支持通过按钮或键盘删除普通节点和连线,节点属性栏改为选中节点后按需显示,并修复拖动节点时因 React Flow 初始化状态丢失导致的画面闪烁与 error 015。 - WAF 规则编辑器新增启用/停用控制,并移除已废弃的规则组侧站点绑定入口;站点规则顺序统一在反代路由详情中管理。 - 修复 Agent 心跳无法从活动 WAF 运行图发现 IP 组引用的问题,并对发布与同步的完整 IP 组快照增加 20 MiB 聚合容量保护。 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 9a743a0f..b9a906ef 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -97,6 +97,7 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环 | `redis.db` | `REDIS_DB` | Redis 逻辑数据库编号 | `0` | | `redis.key_prefix` | `REDIS_KEY_PREFIX` | 系统在 Redis 中使用的键前缀 | `openflare:` | | `redis.pool_size` | `REDIS_POOL_SIZE` | Redis 连接池大小 | `100` | +| `redis.maint_notifications` | `REDIS_MAINT_NOTIFICATIONS` | 启动时是否启用 Redis maintenance notifications 自动协商;兼容性不明时保持关闭,修改后需重启 | `false` | ### 4. ClickHouse 配置 (`clickhouse:`) diff --git a/internal/config/config.go b/internal/config/config.go index 900937be..1c4ca204 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -253,6 +253,7 @@ func applyEnvOverrides(c *configModel) { c.Redis.DB = envInt("REDIS_DB", c.Redis.DB) c.Redis.KeyPrefix = envStr("REDIS_KEY_PREFIX", c.Redis.KeyPrefix) c.Redis.PoolSize = envInt("REDIS_POOL_SIZE", c.Redis.PoolSize) + c.Redis.MaintNotifications = envBool("REDIS_MAINT_NOTIFICATIONS", c.Redis.MaintNotifications) // ─── ClickHouse ─── if v, ok := os.LookupEnv("CLICKHOUSE_HOST"); ok { diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000..8620f9af --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,14 @@ +package config + +import "testing" + +func TestApplyEnvOverridesRedisMaintNotifications(t *testing.T) { + t.Setenv("REDIS_MAINT_NOTIFICATIONS", "true") + + cfg := &configModel{} + applyEnvOverrides(cfg) + + if !cfg.Redis.MaintNotifications { + t.Fatal("REDIS_MAINT_NOTIFICATIONS=true was not applied") + } +} diff --git a/internal/config/model.go b/internal/config/model.go index 0c8722af..7fe0b7c1 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -88,22 +88,23 @@ type clickHouseConfig struct { // redisConfig Redis配置 type redisConfig struct { - Enabled bool `mapstructure:"enabled"` - Addrs []string `mapstructure:"addrs"` - Username string `mapstructure:"username"` - Password string `mapstructure:"password"` - DB int `mapstructure:"db"` - ClusterMode bool `mapstructure:"cluster_mode"` - MasterName string `mapstructure:"master_name"` - KeyPrefix string `mapstructure:"key_prefix"` - PoolSize int `mapstructure:"pool_size"` - MinIdleConn int `mapstructure:"min_idle_conn"` - DialTimeout int `mapstructure:"dial_timeout"` - ReadTimeout int `mapstructure:"read_timeout"` - WriteTimeout int `mapstructure:"write_timeout"` - MaxRetries int `mapstructure:"max_retries"` - PoolTimeout int `mapstructure:"pool_timeout"` - ConnMaxIdleTime int `mapstructure:"conn_max_idle_time"` + Enabled bool `mapstructure:"enabled"` + Addrs []string `mapstructure:"addrs"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + DB int `mapstructure:"db"` + ClusterMode bool `mapstructure:"cluster_mode"` + MasterName string `mapstructure:"master_name"` + KeyPrefix string `mapstructure:"key_prefix"` + PoolSize int `mapstructure:"pool_size"` + MinIdleConn int `mapstructure:"min_idle_conn"` + DialTimeout int `mapstructure:"dial_timeout"` + ReadTimeout int `mapstructure:"read_timeout"` + WriteTimeout int `mapstructure:"write_timeout"` + MaxRetries int `mapstructure:"max_retries"` + PoolTimeout int `mapstructure:"pool_timeout"` + ConnMaxIdleTime int `mapstructure:"conn_max_idle_time"` + MaintNotifications bool `mapstructure:"maint_notifications"` } // logConfig 日志配置 diff --git a/internal/db/redis.go b/internal/db/redis.go index 02c0a98d..25327675 100644 --- a/internal/db/redis.go +++ b/internal/db/redis.go @@ -35,45 +35,46 @@ func init() { if cfg.ClusterMode { // Cluster 模式 Redis = redis.NewClusterClient(&redis.ClusterOptions{ - Addrs: cfg.Addrs, - Username: cfg.Username, - Password: cfg.Password, - PoolSize: cfg.PoolSize, - MinIdleConns: cfg.MinIdleConn, - DialTimeout: time.Duration(cfg.DialTimeout) * time.Second, - ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second, - WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second, - MaxRetries: cfg.MaxRetries, - PoolTimeout: time.Duration(cfg.PoolTimeout) * time.Second, - ConnMaxIdleTime: time.Duration(cfg.ConnMaxIdleTime) * time.Second, - MaintNotificationsConfig: &maintnotifications.Config{ - Mode: maintnotifications.ModeDisabled, - }, + Addrs: cfg.Addrs, + Username: cfg.Username, + Password: cfg.Password, + PoolSize: cfg.PoolSize, + MinIdleConns: cfg.MinIdleConn, + DialTimeout: time.Duration(cfg.DialTimeout) * time.Second, + ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second, + WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second, + MaxRetries: cfg.MaxRetries, + PoolTimeout: time.Duration(cfg.PoolTimeout) * time.Second, + ConnMaxIdleTime: time.Duration(cfg.ConnMaxIdleTime) * time.Second, + MaintNotificationsConfig: redisMaintNotificationsConfig(cfg.MaintNotifications), }) log.Println("[Redis] initialized in Cluster mode") } else { // Standalone 或 Sentinel 模式 - Redis = redis.NewUniversalClient(&redis.UniversalOptions{ - Addrs: cfg.Addrs, - MasterName: cfg.MasterName, // 非空时启用 Sentinel - Username: cfg.Username, - Password: cfg.Password, - DB: cfg.DB, - PoolSize: cfg.PoolSize, - MinIdleConns: cfg.MinIdleConn, - DialTimeout: time.Duration(cfg.DialTimeout) * time.Second, - ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second, - WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second, - MaxRetries: cfg.MaxRetries, - PoolTimeout: time.Duration(cfg.PoolTimeout) * time.Second, - ConnMaxIdleTime: time.Duration(cfg.ConnMaxIdleTime) * time.Second, - MaintNotificationsConfig: &maintnotifications.Config{ - Mode: maintnotifications.ModeDisabled, - }, - }) + options := &redis.UniversalOptions{ + Addrs: cfg.Addrs, + MasterName: cfg.MasterName, // 非空时启用 Sentinel + Username: cfg.Username, + Password: cfg.Password, + DB: cfg.DB, + PoolSize: cfg.PoolSize, + MinIdleConns: cfg.MinIdleConn, + DialTimeout: time.Duration(cfg.DialTimeout) * time.Second, + ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second, + WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second, + MaxRetries: cfg.MaxRetries, + PoolTimeout: time.Duration(cfg.PoolTimeout) * time.Second, + ConnMaxIdleTime: time.Duration(cfg.ConnMaxIdleTime) * time.Second, + MaintNotificationsConfig: redisMaintNotificationsConfig(cfg.MaintNotifications), + } if cfg.MasterName != "" { + client := redis.NewFailoverClient(options.Failover()) + // FailoverOptions 暂不暴露该配置,在首次建连前写入客户端选项。 + client.Options().MaintNotificationsConfig = redisMaintNotificationsConfig(cfg.MaintNotifications) + Redis = client log.Println("[Redis] initialized in Sentinel mode") } else { + Redis = redis.NewUniversalClient(options) log.Println("[Redis] initialized in Standalone mode") } } @@ -97,6 +98,14 @@ func init() { } } +func redisMaintNotificationsConfig(enabled bool) *maintnotifications.Config { + mode := maintnotifications.ModeDisabled + if enabled { + mode = maintnotifications.ModeAuto + } + return &maintnotifications.Config{Mode: mode} +} + // PrefixedKey 返回带前缀的 Key func PrefixedKey(key string) string { prefix := config.Config.Redis.KeyPrefix diff --git a/internal/db/redis_test.go b/internal/db/redis_test.go new file mode 100644 index 00000000..ace2e50e --- /dev/null +++ b/internal/db/redis_test.go @@ -0,0 +1,25 @@ +package db + +import ( + "testing" + + "github.com/redis/go-redis/v9/maintnotifications" +) + +func TestRedisMaintNotificationsConfig(t *testing.T) { + for _, test := range []struct { + name string + enabled bool + want maintnotifications.Mode + }{ + {name: "disabled by default", enabled: false, want: maintnotifications.ModeDisabled}, + {name: "auto when enabled", enabled: true, want: maintnotifications.ModeAuto}, + } { + t.Run(test.name, func(t *testing.T) { + cfg := redisMaintNotificationsConfig(test.enabled) + if cfg.Mode != test.want { + t.Fatalf("maintenance notifications mode = %v, want %v", cfg.Mode, test.want) + } + }) + } +} diff --git a/internal/task/utils.go b/internal/task/utils.go index bd8d004a..a3f0d4d3 100644 --- a/internal/task/utils.go +++ b/internal/task/utils.go @@ -7,8 +7,47 @@ package task import ( "github.com/Rain-kl/Wavelet/internal/config" "github.com/hibiken/asynq" + "github.com/redis/go-redis/v9" + "github.com/redis/go-redis/v9/maintnotifications" ) +type redisClientConnOpt struct { + options redis.Options +} + +func (opt redisClientConnOpt) MakeRedisClient() interface{} { + return redis.NewClient(&opt.options) +} + +type redisClusterConnOpt struct { + options redis.ClusterOptions +} + +func (opt redisClusterConnOpt) MakeRedisClient() interface{} { + return redis.NewClusterClient(&opt.options) +} + +type redisFailoverConnOpt struct { + options redis.FailoverOptions + maintNotificationsEnabled bool +} + +func (opt redisFailoverConnOpt) MakeRedisClient() interface{} { + client := redis.NewFailoverClient(&opt.options) + // go-redis v9.16 does not expose maintenance notification settings on + // FailoverOptions, so apply the configured mode before the client is used. + client.Options().MaintNotificationsConfig = maintNotificationsConfig(opt.maintNotificationsEnabled) + return client +} + +func maintNotificationsConfig(enabled bool) *maintnotifications.Config { + mode := maintnotifications.ModeDisabled + if enabled { + mode = maintnotifications.ModeAuto + } + return &maintnotifications.Config{Mode: mode} +} + // RedisOpt asynq Redis 连接配置(兼容 Standalone/Sentinel/Cluster) var RedisOpt asynq.RedisConnOpt @@ -26,20 +65,26 @@ func NewRedisConnOpt() asynq.RedisConnOpt { addrs := cfg.Addrs if cfg.ClusterMode { - return asynq.RedisClusterClientOpt{ - Addrs: addrs, - Username: cfg.Username, - Password: cfg.Password, + return redisClusterConnOpt{ + options: redis.ClusterOptions{ + Addrs: addrs, + Username: cfg.Username, + Password: cfg.Password, + MaintNotificationsConfig: maintNotificationsConfig(cfg.MaintNotifications), + }, } } if cfg.MasterName != "" { - return asynq.RedisFailoverClientOpt{ - MasterName: cfg.MasterName, - SentinelAddrs: addrs, - Username: cfg.Username, - Password: cfg.Password, - DB: cfg.DB, + return redisFailoverConnOpt{ + maintNotificationsEnabled: cfg.MaintNotifications, + options: redis.FailoverOptions{ + MasterName: cfg.MasterName, + SentinelAddrs: addrs, + Username: cfg.Username, + Password: cfg.Password, + DB: cfg.DB, + }, } } @@ -47,12 +92,15 @@ func NewRedisConnOpt() asynq.RedisConnOpt { if len(addrs) > 0 { addr = addrs[0] } - return asynq.RedisClientOpt{ - Addr: addr, - Username: cfg.Username, - Password: cfg.Password, - DB: cfg.DB, - PoolSize: cfg.PoolSize, + return redisClientConnOpt{ + options: redis.Options{ + Addr: addr, + Username: cfg.Username, + Password: cfg.Password, + DB: cfg.DB, + PoolSize: cfg.PoolSize, + MaintNotificationsConfig: maintNotificationsConfig(cfg.MaintNotifications), + }, } } diff --git a/internal/task/utils_test.go b/internal/task/utils_test.go new file mode 100644 index 00000000..f4356c5c --- /dev/null +++ b/internal/task/utils_test.go @@ -0,0 +1,73 @@ +package task + +import ( + "testing" + + "github.com/Rain-kl/Wavelet/internal/config" + "github.com/redis/go-redis/v9" + "github.com/redis/go-redis/v9/maintnotifications" +) + +func TestNewRedisConnOptConfiguresMaintenanceNotifications(t *testing.T) { + previous := config.Config.Redis + t.Cleanup(func() { config.Config.Redis = previous }) + + for _, test := range []struct { + name string + enabled bool + want maintnotifications.Mode + }{ + {name: "disabled by default", enabled: false, want: maintnotifications.ModeDisabled}, + {name: "auto when enabled", enabled: true, want: maintnotifications.ModeAuto}, + } { + t.Run(test.name, func(t *testing.T) { + config.Config.Redis.MaintNotifications = test.enabled + + t.Run("standalone", func(t *testing.T) { + config.Config.Redis.ClusterMode = false + config.Config.Redis.MasterName = "" + config.Config.Redis.Addrs = []string{"127.0.0.1:6379"} + + client, ok := NewRedisConnOpt().MakeRedisClient().(*redis.Client) + if !ok { + t.Fatal("standalone option did not create *redis.Client") + } + defer func() { _ = client.Close() }() + assertMaintenanceNotificationsMode(t, client.Options().MaintNotificationsConfig, test.want) + }) + + t.Run("cluster", func(t *testing.T) { + config.Config.Redis.ClusterMode = true + config.Config.Redis.MasterName = "" + config.Config.Redis.Addrs = []string{"127.0.0.1:6379"} + + client, ok := NewRedisConnOpt().MakeRedisClient().(*redis.ClusterClient) + if !ok { + t.Fatal("cluster option did not create *redis.ClusterClient") + } + defer func() { _ = client.Close() }() + assertMaintenanceNotificationsMode(t, client.Options().MaintNotificationsConfig, test.want) + }) + + t.Run("sentinel", func(t *testing.T) { + config.Config.Redis.ClusterMode = false + config.Config.Redis.MasterName = "openflare" + config.Config.Redis.Addrs = []string{"127.0.0.1:26379"} + + client, ok := NewRedisConnOpt().MakeRedisClient().(*redis.Client) + if !ok { + t.Fatal("sentinel option did not create *redis.Client") + } + defer func() { _ = client.Close() }() + assertMaintenanceNotificationsMode(t, client.Options().MaintNotificationsConfig, test.want) + }) + }) + } +} + +func assertMaintenanceNotificationsMode(t *testing.T, cfg *maintnotifications.Config, want maintnotifications.Mode) { + t.Helper() + if cfg == nil || cfg.Mode != want { + t.Fatalf("maintenance notifications mode = %v, want %v", cfg, want) + } +}