🐛 fix(redis-scan): 修复大数据量下命名空间加载不完整问题

- 前后端 Redis SCAN 游标统一为字符串传递,避免 Number 精度丢失
- RedisScanKeys 增加 string/number 游标兼容解析,异常游标降级并告警
- 新增游标解析单测
- refs #135
This commit is contained in:
Syngnat
2026-02-28 12:32:22 +08:00
parent 439625a49c
commit 4de3f408c5
7 changed files with 167 additions and 15 deletions

View File

@@ -281,6 +281,23 @@ const getRedisScanLoadCount = (pattern: string, append: boolean): number => {
return append ? REDIS_KEY_SEARCH_LOAD_MORE_COUNT : REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT;
};
const normalizeRedisCursor = (value: unknown): string => {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed === '' ? '0' : trimmed;
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
return '0';
}
return Math.trunc(value).toString();
}
if (typeof value === 'bigint') {
return value.toString();
}
return '0';
};
const normalizeKeySegment = (segment: string): string => {
return segment === '' ? EMPTY_SEGMENT_LABEL : segment;
};
@@ -384,7 +401,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const [keys, setKeys] = useState<RedisKeyInfo[]>([]);
const [loading, setLoading] = useState(false);
const [searchPattern, setSearchPattern] = useState('*');
const [cursor, setCursor] = useState<number>(0);
const [cursor, setCursor] = useState<string>('0');
const [hasMore, setHasMore] = useState(false);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [keyValue, setKeyValue] = useState<RedisValue | null>(null);
@@ -433,7 +450,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const loadKeys = useCallback(async (
pattern: string = '*',
fromCursor: number = 0,
fromCursor: string = '0',
append: boolean = false,
targetCount?: number
) => {
@@ -454,7 +471,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
if (res.success) {
const result = res.data;
const scannedKeys = Array.isArray(result?.keys) ? result.keys : [];
const nextCursor = Number(result?.cursor || 0);
const nextCursor = normalizeRedisCursor(result?.cursor);
if (append) {
setKeys(prev => {
const keyMap = new Map<string, RedisKeyInfo>();
@@ -466,7 +483,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
setKeys(scannedKeys);
}
setCursor(nextCursor);
setHasMore(nextCursor !== 0);
setHasMore(nextCursor !== '0');
} else {
message.error('加载 Key 失败: ' + res.message);
}
@@ -483,14 +500,14 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
}, [getConfig]);
useEffect(() => {
loadKeys(searchPattern, 0, false, getRedisScanLoadCount(searchPattern, false));
loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false));
}, [redisDB]);
const handleSearch = (value: string) => {
const pattern = value.trim() || '*';
setSearchPattern(pattern);
setCursor(0);
loadKeys(pattern, 0, false, getRedisScanLoadCount(pattern, false));
setCursor('0');
loadKeys(pattern, '0', false, getRedisScanLoadCount(pattern, false));
};
const handleLoadMore = () => {
@@ -501,8 +518,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
const handleRefresh = () => {
setCursor(0);
loadKeys(searchPattern, 0, false, getRedisScanLoadCount(searchPattern, false));
setCursor('0');
loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false));
};
const loadKeyValue = async (key: string) => {

View File

@@ -137,7 +137,7 @@ export interface RedisKeyInfo {
export interface RedisScanResult {
keys: RedisKeyInfo[];
cursor: number;
cursor: string;
}
export interface RedisValue {

View File

@@ -124,7 +124,7 @@ export function RedisListSet(arg1:connection.ConnectionConfig,arg2:string,arg3:n
export function RedisRenameKey(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:number):Promise<connection.QueryResult>;
export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:string|number,arg4:number):Promise<connection.QueryResult>;
export function RedisSelectDB(arg1:connection.ConnectionConfig,arg2:number):Promise<connection.QueryResult>;

View File

@@ -4,6 +4,9 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"sync"
@@ -107,14 +110,20 @@ func (a *App) RedisTestConnection(config connection.ConnectionConfig) connection
}
// RedisScanKeys scans keys matching a pattern
func (a *App) RedisScanKeys(config connection.ConnectionConfig, pattern string, cursor uint64, count int64) connection.QueryResult {
func (a *App) RedisScanKeys(config connection.ConnectionConfig, pattern string, cursor any, count int64) connection.QueryResult {
config.Type = "redis"
client, err := a.getRedisClient(config)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
result, err := client.ScanKeys(pattern, cursor, count)
parsedCursor, err := parseRedisScanCursor(cursor)
if err != nil {
logger.Warnf("RedisScanKeys 游标解析失败已回退到起始游标cursor=%v err=%v", cursor, err)
parsedCursor = 0
}
result, err := client.ScanKeys(pattern, parsedCursor, count)
if err != nil {
logger.Error(err, "RedisScanKeys 扫描失败pattern=%s", pattern)
return connection.QueryResult{Success: false, Message: err.Error()}
@@ -123,6 +132,82 @@ func (a *App) RedisScanKeys(config connection.ConnectionConfig, pattern string,
return connection.QueryResult{Success: true, Data: result}
}
func parseRedisScanCursor(cursor any) (uint64, error) {
switch v := cursor.(type) {
case nil:
return 0, nil
case uint64:
return v, nil
case uint32:
return uint64(v), nil
case uint16:
return uint64(v), nil
case uint8:
return uint64(v), nil
case uint:
return uint64(v), nil
case int64:
if v < 0 {
return 0, fmt.Errorf("游标不能为负数: %d", v)
}
return uint64(v), nil
case int32:
if v < 0 {
return 0, fmt.Errorf("游标不能为负数: %d", v)
}
return uint64(v), nil
case int16:
if v < 0 {
return 0, fmt.Errorf("游标不能为负数: %d", v)
}
return uint64(v), nil
case int8:
if v < 0 {
return 0, fmt.Errorf("游标不能为负数: %d", v)
}
return uint64(v), nil
case int:
if v < 0 {
return 0, fmt.Errorf("游标不能为负数: %d", v)
}
return uint64(v), nil
case float64:
return parseRedisScanCursorFromFloat(v)
case float32:
return parseRedisScanCursorFromFloat(float64(v))
case json.Number:
return parseRedisScanCursor(strings.TrimSpace(v.String()))
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" {
return 0, nil
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
if err != nil {
return 0, fmt.Errorf("无效游标: %q", v)
}
return parsed, nil
default:
return 0, fmt.Errorf("不支持的游标类型: %T", cursor)
}
}
func parseRedisScanCursorFromFloat(value float64) (uint64, error) {
if math.IsNaN(value) || math.IsInf(value, 0) {
return 0, fmt.Errorf("无效浮点游标: %v", value)
}
if value < 0 {
return 0, fmt.Errorf("游标不能为负数: %v", value)
}
if math.Trunc(value) != value {
return 0, fmt.Errorf("游标必须为整数: %v", value)
}
if value > float64(math.MaxUint64) {
return 0, fmt.Errorf("游标超出范围: %v", value)
}
return uint64(value), nil
}
// RedisGetValue gets the value of a key
func (a *App) RedisGetValue(config connection.ConnectionConfig, key string) connection.QueryResult {
config.Type = "redis"

View File

@@ -0,0 +1,50 @@
package app
import (
"encoding/json"
"testing"
)
func TestParseRedisScanCursor(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
input any
want uint64
wantErr bool
}{
{name: "nil defaults to zero", input: nil, want: 0},
{name: "empty string defaults to zero", input: " ", want: 0},
{name: "string cursor", input: "123", want: 123},
{name: "uint64 cursor", input: uint64(456), want: 456},
{name: "int cursor", input: int(789), want: 789},
{name: "float cursor", input: float64(42), want: 42},
{name: "json number cursor", input: json.Number("88"), want: 88},
{name: "negative int rejected", input: -1, wantErr: true},
{name: "fraction float rejected", input: float64(1.5), wantErr: true},
{name: "invalid string rejected", input: "abc", wantErr: true},
{name: "unsupported type rejected", input: true, wantErr: true},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := parseRedisScanCursor(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got nil (value=%d)", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("parseRedisScanCursor() mismatch, want=%d got=%d", tc.want, got)
}
})
}
}

View File

@@ -26,7 +26,7 @@ type RedisKeyInfo struct {
// RedisScanResult represents the result of a SCAN operation
type RedisScanResult struct {
Keys []RedisKeyInfo `json:"keys"`
Cursor uint64 `json:"cursor"`
Cursor string `json:"cursor"`
}
// RedisClient defines the interface for Redis operations

View File

@@ -175,7 +175,7 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
return &RedisScanResult{
Keys: r.loadRedisKeyInfos(ctx, keys),
Cursor: currentCursor,
Cursor: strconv.FormatUint(currentCursor, 10),
}, nil
}