fix(redis): 避免 Redis 搜索因稀疏 SCAN MATCH 返回空结果 (#746)

fixes #738

Redis SCAN MATCH 可能连续返回空批次,但后续 cursor 页仍然存在匹配 key。
此前搜索模式最多扫描 16 轮,可能导致首次点击搜索无数据,而点击“加载更多”后才出现结果。

移除搜索模式的固定轮数限制,改由已有的搜索时间上限和目标数量限制兜底。
新增回归测试,验证搜索可以继续越过 16 个空匹配页并返回后续结果。
This commit is contained in:
Syngnat
2026-07-27 22:19:49 +08:00
committed by GitHub
2 changed files with 81 additions and 4 deletions

View File

@@ -47,7 +47,6 @@ const (
redisScanMaxDuration = 12 * time.Second
redisSearchMaxTargetCount int64 = 1000
redisSearchMaxStepCount int64 = 1000
redisSearchMaxRounds = 16
redisSearchMaxDuration = 3 * time.Second
)
@@ -552,7 +551,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
}
@@ -604,7 +605,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
}
}
@@ -653,7 +654,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
}
}

View File

@@ -14,6 +14,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"testing"
goredis "github.com/redis/go-redis/v9"
)
@@ -741,6 +742,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() {