feat(redis): 大 key 分页 + 流式导出(含票据鉴权)+ 查询行数上限默认 5000

=== 一、问题:大 key 会把内存打爆,且查询结果没有导出 ===
RedisGetKeyDetail 原来对每种类型都是全量读取:
  string → GET        list → LRANGE 0 -1     set → SMEMBERS
  zset   → ZRANGE 0 -1                        hash → HGETALL
然后整包序列化返回。像 PERP:market:* 这类盘口/kline key,
一个 key 几十万条就会:Go 侧整值入内存 → JSON 序列化翻倍 → 浏览器渲染卡死。

=== 二、后端:键详情分页(统一令牌,兼容四种类型)===
GET /redis/:id/keys/:key?db=&count=&token=
  count  默认 200,硬上限 2000
  token  语义按类型区分,前端无感知:
           list / zset  → 十进制 offset(LRANGE / ZRANGE 区间)
           hash / set   → Redis SCAN 游标(HSCAN / SSCAN)
           string       → 忽略
  返回   total / count / nextToken / truncated / previewCap

string 特殊处理:不能分页,改为预览上限 256KB
  - 用 STRLEN 拿真实大小(O(1))
  - 超过上限时用 GETRANGE 只取前 256KB,truncated=true
  - 完整内容只能靠导出

新增 wrapper 方法:HScan / ZScan / HLen / ZCard / StrLen / GetRange
(HGETALL、SMEMBERS、ZRANGE 0 -1 一律不再用于详情读取)

=== 三、后端:流式导出(两段式票据,避免令牌进 URL 日志)===
① POST /redis/:id/keys/:key/export-ticket   (走 Bearer 鉴权)
     → HMAC-SHA256(JWTSecret, instance|db|key|exp) 生成票据,2 分钟过期,
       绑定具体 instance/db/key,不能跨 key 复用
② GET  /redis/:id/keys/:key/export?db=&format=csv|jsonl&ticket=
     → 注册在鉴权组【之外】,靠票据校验
     为什么这么做:浏览器原生下载(<a download>/window.open)带不了
     Authorization 头;若前端用 axios 取 blob,整份导出会进浏览器内存,
     与「大 key 不爆内存」的目标相悖。

导出实现全程流式(服务端内存 = 单批 + 64KB 缓冲):
  string → GETRANGE 256KB 分块
  list   → LRANGE 每批 1000
  zset   → ZRANGE WITHSCORES 每批 1000
  hash   → HSCAN 游标每批 1000
  set    → SSCAN 游标每批 1000
单次上限 200 万行;CSV 带 UTF-8 BOM(Excel 直接打开不乱码);
列顺:hash(field,value) / list(index,value) / set(member) / zset(rank,member,score)

=== 四、前端:详情面板加分页条 + 4 个导出按钮 ===
RedisKeyDetailPanel:
  - 「共 N 条 · 第 X 页 · 每页 [100/200/500/1000/2000]」+ 上一页/下一页
  - SCAN 游标不可逆,上一页用令牌栈回放
  - string 超预览上限时显示黄色提示「仅预览前 256KB,共 N 字节」
  - 导出四档:当前页 CSV / 当前页 JSON(纯前端,瞬时)
             导出全部 CSV / 导出全部 JSONL(服务端流式,边读边写)
  顺带修掉头注释里「已知限制:大值不分页」的过时说明。

=== 五、查询行数上限:默认 1000 → 5000,并修掉硬编码 ===
用户要求:「默认设置里面有查询最大上限吧 比如 mysql pg 查询最大Limit限制 5000」

发现两个问题:
1. seed 默认值只有 1000(main.go query_limit_max)
2. ExecuteQuery 里还有一段硬编码:
     if input.Limit <= 0 { input.Limit = maxLimit; if input.Limit > 1000 { input.Limit = 1000 } }
   → 管理员把设置改成 5000 也白搭,前端不传 limit 时永远被压回 1000

修复:
  - seed 默认值 1000 → 5000(Value + DefaultValue)
  - getSettingInt("query_limit_max", 1000) → 5000
  - 删掉硬编码 1000 封顶,唯一口径就是设置值
  - 线上已有记录的 value 已通过 PUT /settings 改成 5000(seed 只在缺失时插入,不会自动更新旧值)

对 MySQL / PostgreSQL / Redis / MongoDB 统一生效(ExecuteQuery 按 db_type 分发,
limit 在入口处统一 clamp)。

=== 六、实测结果(线上)===
分页:
  zset PERP:market:kline:symbol_kline_12h_AAVEUSDT_PERP_MARK
        total=83,count=5 → 第1页 5 条 nextToken=5 → 第2页 5 条 nextToken=10,数据不同 ✅
  hash ST:market:ticker:symbol_ticker
        total=27,count=3 → 第1页 3 字段 nextToken=24 → 第2页 3 字段(完全不同)✅
  string PERP:market:depth:symbol_depth_BTCUSDT_PERP
        STRLEN=14192 → 导出 14192 字节,完全一致 ✅(实时 key 两次采样有差异属正常)
导出:
  zset CSV  200 / 30970 字节 / 84 行(header+83)✅
  zset JSONL 200 / 84 行 ✅
  string CSV 200 / 分块 GETRANGE 拼接完整 ✅
  无票据 → 403 ✅   伪造票据 → 403 ✅
查询上限:
  limit=99999 → 5000 行 ✅    limit=0(默认)→ 5000 行 ✅
SQL 导出 CSV(原已存在 ExportQueryCSV,支持 mysql/pg/redis/mongo)200 / 101 行 ✅

镜像:lghost/opsxsql:latest + v0.1 = sha256:68fc996681c6
This commit is contained in:
opsx committed 2026-10-27 09:14:02 +00:00
1 parent 48c8fddbe9
commit 7dfbc0b680
7 files changed
+854 -22

No files matched your search

+173 -9
View File
@@ -277,6 +277,52 @@ func (w *redisClientWrapper) SScan(ctx context.Context, key string, cursor uint6
return w.Client.SScan(ctx, key, cursor, match, count)
}
// HScan 分批遍历 hash —— 大 key 不能用 HGETALL 整体读,会打爆内存。
func (w *redisClientWrapper) HScan(ctx context.Context, key string, cursor uint64, match string, count int64) *redis.ScanCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.HScan(ctx, key, cursor, match, count)
}
return w.Client.HScan(ctx, key, cursor, match, count)
}
// ZScan 分批遍历 zset 成员(不含 score,仅在需要成员集合时使用)。
func (w *redisClientWrapper) ZScan(ctx context.Context, key string, cursor uint64, match string, count int64) *redis.ScanCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.ZScan(ctx, key, cursor, match, count)
}
return w.Client.ZScan(ctx, key, cursor, match, count)
}
// 以下几个 count/长度类命令用于分页时给出 total,都是 O(1),不会因为 key 大而变慢。
func (w *redisClientWrapper) HLen(ctx context.Context, key string) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.HLen(ctx, key)
}
return w.Client.HLen(ctx, key)
}
func (w *redisClientWrapper) ZCard(ctx context.Context, key string) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.ZCard(ctx, key)
}
return w.Client.ZCard(ctx, key)
}
func (w *redisClientWrapper) StrLen(ctx context.Context, key string) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.StrLen(ctx, key)
}
return w.Client.StrLen(ctx, key)
}
// GetRange 按字节区间读取字符串,用于超大 string 的分页预览与流式导出。
func (w *redisClientWrapper) GetRange(ctx context.Context, key string, start, end int64) *redis.StringCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.GetRange(ctx, key, start, end)
}
return w.Client.GetRange(ctx, key, start, end)
}
func writeRedisKeyValue(ctx context.Context, client *redisClientWrapper, key string, keyType string, value interface{}) error {
switch keyType {
case "string":
@@ -556,6 +602,24 @@ func RedisDbsize(c *gin.Context) {
}
// GET /api/v1/redis/:id/keys/:key
// Redis 键详情分页参数:默认每页 200 条,硬上限 2000 条。
// 大 key(如 PERP:market:depth:* 盘口快照)必须分页,否则 HGETALL / LRANGE 0 -1
// 会把整个值读进内存再整包序列化,极易把 Pod 内存打爆、前端也会卡死。
const (
redisKeyPageDefault = 200
redisKeyPageMax = 2000
// string 类型无法「分页」,改为给一个预览上限,超出时标记 truncated 并给出总字节数。
redisStringPreviewBytes = 256 << 10
)
// GET /api/v1/redis/:id/keys/:key?db=0&count=200&token=<分页令牌>
//
// token 语义由类型决定:
// - list / zset → 十进制 offset(偏移量)
// - hash / set → Redis SCAN 游标
// - string → 忽略
//
// 返回 token(下一页令牌,空串=没有更多)、total、truncated。
func RedisGetKeyDetail(c *gin.Context) {
id := c.Param("id")
key := c.Param("key")
@@ -565,6 +629,15 @@ func RedisGetKeyDetail(c *gin.Context) {
db, _ = strconv.Atoi(dbParam)
}
count, _ := strconv.Atoi(c.DefaultQuery("count", strconv.Itoa(redisKeyPageDefault)))
if count <= 0 {
count = redisKeyPageDefault
}
if count > redisKeyPageMax {
count = redisKeyPageMax
}
pageToken := c.Query("token")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
resp.NotFound(c, "Instance not found")
@@ -587,26 +660,117 @@ func RedisGetKeyDetail(c *gin.Context) {
}
var value interface{}
var total int64 = -1
nextToken := ""
truncated := false
offset := int64(0)
if pageToken != "" {
if n, err := strconv.ParseInt(pageToken, 10, 64); err == nil && n > 0 {
offset = n
}
}
scanCursor := uint64(0)
if pageToken != "" {
if n, err := strconv.ParseUint(pageToken, 10, 64); err == nil {
scanCursor = n
}
}
switch keyType {
case "string":
value, _ = client.Get(ctx, key).Result()
size, _ := client.StrLen(ctx, key).Result()
total = size
// 用 GETRANGE 只取预览区间,避免超大字符串整串读入
if size > redisStringPreviewBytes {
value, _ = client.GetRange(ctx, key, 0, redisStringPreviewBytes-1).Result()
truncated = true
} else {
value, _ = client.Get(ctx, key).Result()
}
case "list":
value, _ = client.LRange(ctx, key, 0, -1).Result()
total, _ = client.LLen(ctx, key).Result()
items, err := client.LRange(ctx, key, offset, offset+int64(count)-1).Result()
if err != nil {
resp.InternalErr(c, err)
return
}
if items == nil {
items = []string{}
}
value = items
if offset+int64(len(items)) < total {
nextToken = strconv.FormatInt(offset+int64(len(items)), 10)
truncated = true
}
case "set":
value, _ = client.SMembers(ctx, key).Result()
total, _ = client.SCard(ctx, key).Result()
members, next, err := client.SScan(ctx, key, scanCursor, "", int64(count)).Result()
if err != nil {
resp.InternalErr(c, err)
return
}
if members == nil {
members = []string{}
}
value = members
if next != 0 {
nextToken = strconv.FormatUint(next, 10)
truncated = true
}
case "zset":
value, _ = client.ZRangeWithScores(ctx, key, 0, -1).Result()
total, _ = client.ZCard(ctx, key).Result()
items, err := client.ZRangeWithScores(ctx, key, offset, offset+int64(count)-1).Result()
if err != nil {
resp.InternalErr(c, err)
return
}
if items == nil {
items = []redis.Z{}
}
value = items
if offset+int64(len(items)) < total {
nextToken = strconv.FormatInt(offset+int64(len(items)), 10)
truncated = true
}
case "hash":
value, _ = client.HGetAll(ctx, key).Result()
total, _ = client.HLen(ctx, key).Result()
flat, next, err := client.HScan(ctx, key, scanCursor, "", int64(count)).Result()
if err != nil {
resp.InternalErr(c, err)
return
}
// HScan 返回扁平 [field, value, field, value, ...],这里还原成 map,
// 与旧的 HGetAll 返回结构保持一致,前端无需改动取值方式。
m := make(map[string]string, len(flat)/2)
for i := 0; i+1 < len(flat); i += 2 {
m[flat[i]] = flat[i+1]
}
value = m
if next != 0 {
nextToken = strconv.FormatUint(next, 10)
truncated = true
}
default:
value = nil
}
c.JSON(http.StatusOK, gin.H{
"key": key,
"type": keyType,
"value": value,
"ttl": ttlMs,
"key": key,
"type": keyType,
"value": value,
"ttl": ttlMs,
"total": total,
"count": count,
"nextToken": nextToken,
"truncated": truncated,
"pageToken": pageToken,
"previewCap": redisStringPreviewBytes,
})
}