From 203e8aac6c61e69acdaed2dfd66f62f20d35a806 Mon Sep 17 00:00:00 2001 From: liuqiufeng Date: Mon, 27 Jul 2026 21:22:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(redis):=20=E9=81=BF=E5=85=8D=20Redis=20?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=9B=A0=E7=A8=80=E7=96=8F=20SCAN=20MATCH=20?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E7=A9=BA=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/redis/redis_impl.go | 9 ++-- internal/redis/redis_impl_test.go | 76 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/internal/redis/redis_impl.go b/internal/redis/redis_impl.go index 55b874a0..9433bd77 100644 --- a/internal/redis/redis_impl.go +++ b/internal/redis/redis_impl.go @@ -47,7 +47,6 @@ const ( redisScanMaxDuration = 12 * time.Second redisSearchMaxTargetCount int64 = 1000 redisSearchMaxStepCount int64 = 1000 - redisSearchMaxRounds = 16 redisSearchMaxDuration = 3 * time.Second ) @@ -546,7 +545,9 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( if scanStepCount > redisSearchMaxStepCount { scanStepCount = redisSearchMaxStepCount } - maxRounds = redisSearchMaxRounds + // SCAN MATCH 可能连续返回空批次,但后续 cursor 页仍然存在匹配 key。 + // 搜索模式不使用固定轮数限制,改由 maxDuration 和 targetCount 兜底。 + maxRounds = 0 maxDuration = redisSearchMaxDuration } @@ -598,7 +599,7 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( nodeCursor = nextCursor round++ - if nodeCursor == 0 || round >= maxRounds { + if nodeCursor == 0 || (maxRounds > 0 && round >= maxRounds) { break } } @@ -650,7 +651,7 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( currentCursor = nextCursor round++ - if currentCursor == 0 || round >= maxRounds { + if currentCursor == 0 || (maxRounds > 0 && round >= maxRounds) { break } } diff --git a/internal/redis/redis_impl_test.go b/internal/redis/redis_impl_test.go index d655a257..6f4a031e 100644 --- a/internal/redis/redis_impl_test.go +++ b/internal/redis/redis_impl_test.go @@ -15,6 +15,7 @@ import ( "sort" "strconv" "strings" + "sync" "testing" goredis "github.com/redis/go-redis/v9" @@ -775,6 +776,81 @@ func TestListRemoveUsesLRemForOneMatchingValue(t *testing.T) { t.Fatalf("expected LREM command, got %v", commands) } +func TestRedisSearchScanContinuesPastEmptyMatchedPages(t *testing.T) { + var mu sync.Mutex + scanCalls := 0 + const searchPattern = "*[lL][aA][tT][eE]*" + + redisScanResponse := func(cursor string, keys ...string) string { + var builder strings.Builder + builder.WriteString("*2\r\n") + builder.WriteString(redisBulkString(cursor)) + builder.WriteString(fmt.Sprintf("*%d\r\n", len(keys))) + for _, key := range keys { + builder.WriteString(redisBulkString(key)) + } + return builder.String() + } + + addr := startRedisProtocolTestServer(t, func(args []string) string { + command := strings.ToUpper(strings.TrimSpace(args[0])) + switch command { + case "HELLO": + return "-ERR unknown command 'HELLO'\r\n" + case "CLIENT": + return "-ERR unknown subcommand\r\n" + case "SCAN": + mu.Lock() + defer mu.Unlock() + scanCalls++ + for i := 0; i+1 < len(args); i++ { + if strings.EqualFold(args[i], "MATCH") && args[i+1] != searchPattern { + t.Fatalf("expected SCAN MATCH %q, got command %v", searchPattern, args) + } + } + if scanCalls <= 16 { + return redisScanResponse(strconv.Itoa(scanCalls)) + } + return redisScanResponse("0", "late:user:1") + case "TYPE": + return "+string\r\n" + case "TTL": + return ":-1\r\n" + } + return "+OK\r\n" + }) + + rawClient := goredis.NewClient(&goredis.Options{ + Addr: addr, + Protocol: 2, + }) + client := &RedisClientImpl{ + client: rawClient, + singleClient: rawClient, + } + defer client.Close() + + result, err := client.ScanKeys(searchPattern, 0, 10) + if err != nil { + t.Fatalf("ScanKeys returned error: %v", err) + } + if result == nil || len(result.Keys) != 1 { + t.Fatalf("expected one searched key after empty pages, got %#v", result) + } + if result.Keys[0].Key != "late:user:1" { + t.Fatalf("expected late:user:1, got %#v", result.Keys[0]) + } + if result.Cursor != "0" { + t.Fatalf("expected completed cursor, got %q", result.Cursor) + } + + mu.Lock() + defer mu.Unlock() + if scanCalls <= 16 { + t.Fatalf("expected ScanKeys to continue past 16 empty search pages, got %d calls", scanCalls) + } +} + func TestRedisSelectDBReconnectsWithSentinelConfig(t *testing.T) { oldConnect := redisDBSwitchConnect defer func() {