Просмотр исходного кода

feat(redis): 优化配置语义并增强超时与连接池能力,同时保持向后兼容 (#869)

* fix: correct non-standard 'yml' tag to 'yaml' in RedisOpts

* fix: apply MaxActive config to Redis PoolSize

* fix: clarify config semantics, enhance timeout & pool options, and maintain backward compatibility

* test: update unit test for redis

* refactor: apply suggestions from code review

* test: add comprehensive coverage for redis

* refactor: resolve funlen linter errors in redis unit tests

* refactor: remove empty else-if branch in NewRedis function
Gophlet 5 месяцев назад
Родитель
Сommit
7d93d1b9c8
2 измененных файлов с 315 добавлено и 11 удалено
  1. 48 10
      cache/redis.go
  2. 267 1
      cache/redis_test.go

+ 48 - 10
cache/redis.go

@@ -17,14 +17,24 @@ type Redis struct {
 
 // RedisOpts redis 连接属性
 type RedisOpts struct {
-	Host        string `json:"host"         yml:"host"`
-	Username    string `json:"username"                        yaml:"username"`
-	Password    string `json:"password"     yml:"password"`
-	Database    int    `json:"database"     yml:"database"`
-	MaxIdle     int    `json:"max_idle"     yml:"max_idle"`
-	MaxActive   int    `json:"max_active"   yml:"max_active"`
-	IdleTimeout int    `json:"idle_timeout" yml:"idle_timeout"` // second
-	UseTLS      bool   `json:"use_tls"      yml:"use_tls"`      // 是否使用TLS
+	Host         string `json:"host"            yaml:"host"`
+	Username     string `json:"username"        yaml:"username"`
+	Password     string `json:"password"        yaml:"password"`
+	Database     int    `json:"database"        yaml:"database"`
+	MinIdleConns int    `json:"min_idle_conns"  yaml:"min_idle_conns"` // 最小空闲连接数
+	PoolSize     int    `json:"pool_size"       yaml:"pool_size"`      // 连接池大小,0 表示使用默认值(即 CPU 核心数 * 10)
+	MaxRetries   int    `json:"max_retries"     yaml:"max_retries"`    // 最大重试次数,-1 表示不重试,0 表示使用默认值(即 3 次)
+	DialTimeout  int    `json:"dial_timeout"    yaml:"dial_timeout"`   // 连接超时时间(秒),0 表示使用默认值(即 5 秒)
+	ReadTimeout  int    `json:"read_timeout"    yaml:"read_timeout"`   // 读取超时时间(秒),-1 表示不超时,0 表示使用默认值(即 3 秒)
+	WriteTimeout int    `json:"write_timeout"   yaml:"write_timeout"`  // 写入超时时间(秒),-1 表示不超时,0 表示使用默认值(即 ReadTimeout)
+	PoolTimeout  int    `json:"pool_timeout"    yaml:"pool_timeout"`   // 连接池获取连接超时时间(秒),0 表示使用默认值(即 ReadTimeout + 1 秒)
+	IdleTimeout  int    `json:"idle_timeout"    yaml:"idle_timeout"`   // 空闲连接超时时间(秒),-1 表示禁用空闲连接超时检查,0 表示使用默认值(即 5 分钟)
+	UseTLS       bool   `json:"use_tls"         yaml:"use_tls"`        // 是否使用 TLS
+
+	// Deprecated: 应使用 MinIdleConns 代替
+	MaxIdle int `json:"max_idle" yaml:"max_idle"`
+	// Deprecated: 应使用 PoolSize 代替
+	MaxActive int `json:"max_active" yaml:"max_active"`
 }
 
 // NewRedis 实例化
@@ -34,10 +44,38 @@ func NewRedis(ctx context.Context, opts *RedisOpts) *Redis {
 		DB:           opts.Database,
 		Username:     opts.Username,
 		Password:     opts.Password,
-		IdleTimeout:  time.Second * time.Duration(opts.IdleTimeout),
-		MinIdleConns: opts.MaxIdle,
+		MinIdleConns: opts.MinIdleConns,
+		PoolSize:     opts.PoolSize,
+		MaxRetries:   opts.MaxRetries,
 	}
 
+	// 兼容旧的 MaxIdle 参数,仅在未显式设置 MinIdleConns 时生效
+	if opts.MaxIdle > 0 && opts.MinIdleConns == 0 {
+		uniOpt.MinIdleConns = opts.MaxIdle
+	}
+
+	// 兼容旧的 MaxActive 参数,仅在未显式设置 PoolSize 时生效
+	if opts.MaxActive > 0 && opts.PoolSize == 0 {
+		uniOpt.PoolSize = opts.MaxActive
+	}
+
+	applyTimeout := func(seconds int, target *time.Duration) {
+		if seconds > 0 {
+			*target = time.Duration(seconds) * time.Second
+		} else if seconds == -1 {
+			// 当 seconds 为 -1 时,表示禁用超时:按 go-redis 约定,将超时时间设置为负值(如 -1ns)代表「无超时」
+			*target = -1
+		}
+		// 当 seconds 为 0 时,使用 go-redis 的默认超时配置:
+		// 不修改 target,保持其零值(0),由 go-redis 解释为“使用默认值”
+	}
+
+	applyTimeout(opts.DialTimeout, &uniOpt.DialTimeout)
+	applyTimeout(opts.ReadTimeout, &uniOpt.ReadTimeout)
+	applyTimeout(opts.WriteTimeout, &uniOpt.WriteTimeout)
+	applyTimeout(opts.PoolTimeout, &uniOpt.PoolTimeout)
+	applyTimeout(opts.IdleTimeout, &uniOpt.IdleTimeout)
+
 	if opts.UseTLS {
 		h, _, err := net.SplitHostPort(opts.Host)
 		if err != nil {

+ 267 - 1
cache/redis_test.go

@@ -6,6 +6,7 @@ import (
 	"time"
 
 	"github.com/alicebob/miniredis/v2"
+	"github.com/go-redis/redis/v8"
 )
 
 func TestRedis(t *testing.T) {
@@ -18,7 +19,16 @@ func TestRedis(t *testing.T) {
 		timeoutDuration = time.Second
 		ctx             = context.Background()
 		opts            = &RedisOpts{
-			Host: server.Addr(),
+			Host:         server.Addr(),
+			Password:     "",
+			Database:     0,
+			PoolSize:     10,
+			MinIdleConns: 5,
+			DialTimeout:  5,
+			ReadTimeout:  5,
+			WriteTimeout: 5,
+			PoolTimeout:  5,
+			IdleTimeout:  300,
 		}
 		redis = NewRedis(ctx, opts)
 		val   = "silenceper"
@@ -44,3 +54,259 @@ func TestRedis(t *testing.T) {
 		t.Errorf("delete Error , err=%v", err)
 	}
 }
+
+// setupRedisServer 创建并返回一个 miniredis 服务器实例
+func setupRedisServer(t *testing.T) *miniredis.Miniredis {
+	server, err := miniredis.Run()
+	if err != nil {
+		t.Fatal("miniredis.Run Error", err)
+	}
+	t.Cleanup(server.Close)
+	return server
+}
+
+// TestRedisMaxIdleMapping 测试只设置MaxIdle应该映射到MinIdleConns
+func TestRedisMaxIdleMapping(t *testing.T) {
+	server := setupRedisServer(t)
+	ctx := context.Background()
+
+	opts := &RedisOpts{
+		Host:     server.Addr(),
+		Database: 0,
+		MaxIdle:  10,
+	}
+	r := NewRedis(ctx, opts)
+
+	// 获取底层的 UniversalClient 并断言为 *redis.Client
+	client, ok := r.conn.(*redis.Client)
+	if !ok {
+		t.Fatal("无法转换为 *redis.Client")
+	}
+
+	// 注意:MinIdleConns 表示期望的最小空闲连接数,但实际空闲连接数可能不同
+	// 我们需要通过 Options() 来验证配置是否正确应用
+	clientOpts := client.Options()
+	if clientOpts.MinIdleConns != 10 {
+		t.Errorf("期望 MinIdleConns = 10, 实际 = %d", clientOpts.MinIdleConns)
+	}
+}
+
+// TestRedisMaxActiveMapping 测试只设置MaxActive应该映射到PoolSize
+func TestRedisMaxActiveMapping(t *testing.T) {
+	server := setupRedisServer(t)
+	ctx := context.Background()
+
+	opts := &RedisOpts{
+		Host:      server.Addr(),
+		Database:  0,
+		MaxActive: 20,
+	}
+	r := NewRedis(ctx, opts)
+
+	client, ok := r.conn.(*redis.Client)
+	if !ok {
+		t.Fatal("无法转换为 *redis.Client")
+	}
+
+	clientOpts := client.Options()
+	if clientOpts.PoolSize != 20 {
+		t.Errorf("期望 PoolSize = 20, 实际 = %d", clientOpts.PoolSize)
+	}
+}
+
+// TestRedisNewFieldsPriority 测试新字段应该优先于旧字段
+func TestRedisNewFieldsPriority(t *testing.T) {
+	server := setupRedisServer(t)
+	ctx := context.Background()
+
+	opts := &RedisOpts{
+		Host:         server.Addr(),
+		Database:     0,
+		MaxIdle:      5,
+		MinIdleConns: 15,
+		MaxActive:    10,
+		PoolSize:     30,
+	}
+	r := NewRedis(ctx, opts)
+
+	client, ok := r.conn.(*redis.Client)
+	if !ok {
+		t.Fatal("无法转换为 *redis.Client")
+	}
+
+	clientOpts := client.Options()
+	if clientOpts.MinIdleConns != 15 {
+		t.Errorf("期望 MinIdleConns = 15 (新字段优先), 实际 = %d", clientOpts.MinIdleConns)
+	}
+	if clientOpts.PoolSize != 30 {
+		t.Errorf("期望 PoolSize = 30 (新字段优先), 实际 = %d", clientOpts.PoolSize)
+	}
+}
+
+// TestRedisPositiveTimeouts 测试正值超时应该正确应用
+func TestRedisPositiveTimeouts(t *testing.T) {
+	server := setupRedisServer(t)
+	ctx := context.Background()
+
+	opts := &RedisOpts{
+		Host:         server.Addr(),
+		Database:     0,
+		DialTimeout:  10,
+		ReadTimeout:  20,
+		WriteTimeout: 30,
+		PoolTimeout:  40,
+		IdleTimeout:  50,
+	}
+	r := NewRedis(ctx, opts)
+
+	client, ok := r.conn.(*redis.Client)
+	if !ok {
+		t.Fatal("无法转换为 *redis.Client")
+	}
+
+	clientOpts := client.Options()
+	if clientOpts.DialTimeout != 10*time.Second {
+		t.Errorf("期望 DialTimeout = 10s, 实际 = %v", clientOpts.DialTimeout)
+	}
+	if clientOpts.ReadTimeout != 20*time.Second {
+		t.Errorf("期望 ReadTimeout = 20s, 实际 = %v", clientOpts.ReadTimeout)
+	}
+	if clientOpts.WriteTimeout != 30*time.Second {
+		t.Errorf("期望 WriteTimeout = 30s, 实际 = %v", clientOpts.WriteTimeout)
+	}
+	if clientOpts.PoolTimeout != 40*time.Second {
+		t.Errorf("期望 PoolTimeout = 40s, 实际 = %v", clientOpts.PoolTimeout)
+	}
+	if clientOpts.IdleTimeout != 50*time.Second {
+		t.Errorf("期望 IdleTimeout = 50s, 实际 = %v", clientOpts.IdleTimeout)
+	}
+}
+
+// TestRedisNegativeTimeouts 测试-1值应该禁用超时
+func TestRedisNegativeTimeouts(t *testing.T) {
+	server := setupRedisServer(t)
+	ctx := context.Background()
+
+	opts := &RedisOpts{
+		Host:         server.Addr(),
+		Database:     0,
+		DialTimeout:  -1,
+		ReadTimeout:  -1,
+		WriteTimeout: -1,
+		PoolTimeout:  -1,
+		IdleTimeout:  -1,
+	}
+	r := NewRedis(ctx, opts)
+
+	client, ok := r.conn.(*redis.Client)
+	if !ok {
+		t.Fatal("无法转换为 *redis.Client")
+	}
+
+	clientOpts := client.Options()
+	// -1 应该被设置为负值表示禁用超时
+	// DialTimeout, PoolTimeout, IdleTimeout 会被设置为 -1ns
+	if clientOpts.DialTimeout != -1 {
+		t.Errorf("期望 DialTimeout = -1ns (禁用), 实际 = %v", clientOpts.DialTimeout)
+	}
+	// ReadTimeout 和 WriteTimeout 在 go-redis 中有特殊处理
+	// 当设置为负值时,会被规范化为 0,这也表示无超时
+	t.Logf("ReadTimeout = %v (设置为-1后的值)", clientOpts.ReadTimeout)
+	t.Logf("WriteTimeout = %v (设置为-1后的值)", clientOpts.WriteTimeout)
+
+	if clientOpts.PoolTimeout != -1 {
+		t.Errorf("期望 PoolTimeout = -1ns (禁用), 实际 = %v", clientOpts.PoolTimeout)
+	}
+	if clientOpts.IdleTimeout != -1 {
+		t.Errorf("期望 IdleTimeout = -1ns (禁用), 实际 = %v", clientOpts.IdleTimeout)
+	}
+}
+
+// TestRedisZeroTimeouts 测试0值应该使用go-redis默认值
+func TestRedisZeroTimeouts(t *testing.T) {
+	server := setupRedisServer(t)
+	ctx := context.Background()
+
+	opts := &RedisOpts{
+		Host:         server.Addr(),
+		Database:     0,
+		DialTimeout:  0,
+		ReadTimeout:  0,
+		WriteTimeout: 0,
+		PoolTimeout:  0,
+		IdleTimeout:  0,
+	}
+	r := NewRedis(ctx, opts)
+
+	client, ok := r.conn.(*redis.Client)
+	if !ok {
+		t.Fatal("无法转换为 *redis.Client")
+	}
+
+	clientOpts := client.Options()
+	// 0值应该保持为0,由 go-redis 使用默认值
+	// go-redis 的默认值:
+	// DialTimeout: 5s
+	// ReadTimeout: 3s
+	// WriteTimeout: ReadTimeout
+	// PoolTimeout: ReadTimeout + 1s
+	// IdleTimeout: 5min
+
+	if clientOpts.DialTimeout == 0 {
+		t.Error("期望 DialTimeout 使用 go-redis 默认值 (5s), 实际为 0")
+	}
+	if clientOpts.ReadTimeout == 0 {
+		t.Error("期望 ReadTimeout 使用 go-redis 默认值 (3s), 实际为 0")
+	}
+	if clientOpts.WriteTimeout == 0 {
+		t.Error("期望 WriteTimeout 使用 go-redis 默认值 (ReadTimeout), 实际为 0")
+	}
+	if clientOpts.PoolTimeout == 0 {
+		t.Error("期望 PoolTimeout 使用 go-redis 默认值 (ReadTimeout + 1s), 实际为 0")
+	}
+	if clientOpts.IdleTimeout == 0 {
+		t.Error("期望 IdleTimeout 使用 go-redis 默认值 (5min), 实际为 0")
+	}
+}
+
+// TestRedisMixedTimeouts 测试混合超时配置
+func TestRedisMixedTimeouts(t *testing.T) {
+	server := setupRedisServer(t)
+	ctx := context.Background()
+
+	opts := &RedisOpts{
+		Host:         server.Addr(),
+		Database:     0,
+		DialTimeout:  5,  // 正值
+		ReadTimeout:  -1, // 禁用
+		WriteTimeout: 0,  // 使用默认值
+		PoolTimeout:  10, // 正值
+		IdleTimeout:  -1, // 禁用
+	}
+	r := NewRedis(ctx, opts)
+
+	client, ok := r.conn.(*redis.Client)
+	if !ok {
+		t.Fatal("无法转换为 *redis.Client")
+	}
+
+	clientOpts := client.Options()
+	if clientOpts.DialTimeout != 5*time.Second {
+		t.Errorf("期望 DialTimeout = 5s, 实际 = %v", clientOpts.DialTimeout)
+	}
+	// ReadTimeout 设置为 -1,会被 go-redis 处理为 0(无超时)
+	t.Logf("ReadTimeout = %v (设置为-1后的值)", clientOpts.ReadTimeout)
+
+	// WriteTimeout 设置为 0,应该使用 go-redis 的默认值
+	// 默认值通常是 ReadTimeout 的值
+	t.Logf("WriteTimeout = %v (设置为0后使用的默认值)", clientOpts.WriteTimeout)
+
+	if clientOpts.PoolTimeout != 10*time.Second {
+		t.Errorf("期望 PoolTimeout = 10s, 实际 = %v", clientOpts.PoolTimeout)
+	}
+
+	// IdleTimeout 设置为 -1,应该被设置为 -1ns(禁用空闲超时)
+	if clientOpts.IdleTimeout != -1 {
+		t.Errorf("期望 IdleTimeout = -1ns (禁用), 实际 = %v", clientOpts.IdleTimeout)
+	}
+}