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

+46 -3
View File
@@ -7,6 +7,26 @@ export interface RedisKeySummary {
ttl: number;
}
/** 键详情分页响应(大 key 必须分页,避免整值读入内存) */
export interface RedisKeyDetailPage {
key: string;
type: string;
ttl: number;
value: any;
/** 元素总数(string 为字节数);-1 表示未知 */
total: number;
/** 本页条数 */
count: number;
/** 下一页令牌:hash/set 是 SCAN 游标,list/zset 是 offset;空串=没有更多 */
nextToken: string;
/** 是否还有更多数据(或 string 被预览截断) */
truncated: boolean;
previewCap?: number;
}
/** 导出格式 */
export type RedisExportFormat = 'csv' | 'jsonl';
/** 游标分页扫描响应 */
export interface RedisScanPage {
keys: RedisKeySummary[];
@@ -32,9 +52,32 @@ export const redisApi = {
scanKeys: (instanceId: number, params: { db?: number; pattern?: string; type?: string; cursor?: string }) =>
request.get<any, RedisScanPage>('/redis/' + instanceId + '/keys', { params }),
// 键详情
getKeyDetail: (instanceId: number, key: string, db?: number) =>
request.get<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }),
// 键详情(分页:hash/set 走 SCAN 游标,list/zset 走 offset,string 给预览上限)
getKeyDetail: (instanceId: number, key: string, db?: number, opts?: { token?: string; count?: number }) =>
request.get<any, RedisKeyDetailPage>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), {
params: { db: db ?? 0, count: opts?.count, token: opts?.token || undefined },
}),
// 大 key 导出:申请一次性短时效票据(浏览器原生下载带不了 Authorization 头)
createExportTicket: (instanceId: number, key: string, db?: number) =>
request.post<any, { ticket: string; expiresIn: number }>(
'/redis/' + instanceId + '/keys/' + encodeURIComponent(key) + '/export-ticket',
null,
{ params: { db: db ?? 0 } },
),
// 拼接导出下载地址(带票据,可直接给 <a download> / location.href)
buildExportUrl: (instanceId: number, key: string, db: number, format: RedisExportFormat, ticket: string) =>
'/api/v1/redis/' +
instanceId +
'/keys/' +
encodeURIComponent(key) +
'/export?db=' +
(db ?? 0) +
'&format=' +
format +
'&ticket=' +
encodeURIComponent(ticket),
// 慢日志
getSlowlog: (instanceId: number, count?: number) =>
@@ -1,9 +1,11 @@
/**
* Redis 键详情面板:结构化/格式化/编辑三模式查看,TTL 编辑、保存、删除、Hash 行级编辑。
* 必需 props:instanceId/db/onKeyDeleted/onRefreshBrowser;已知限制:大值不分页,纯展示截断。
* 必需 props:instanceId/db/onKeyDeleted/onRefreshBrowser。
* 大 key 已分页(hash/set 走 SCAN 游标,list/zset 走 offset,string 走预览上限),
* 并提供「导出当前页」与「导出全部(服务端流式)」两种导出。
*/
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Copy, Save, Trash2, Table2, Code2, FileText, Search } from 'lucide-react';
import { Copy, Save, Trash2, Table2, Code2, FileText, Search, Download, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { redisApi } from '@/api/redis';
import { toast } from '@/hooks/useToast';
import { ConfirmDialog } from '@/components/ConfirmDialog';
@@ -27,6 +29,14 @@ interface RedisKeyDetail {
type: string;
ttl: number;
value: any;
/** 元素总数(string 为字节数);-1 表示未知 */
total?: number;
/** 下一页令牌(空串=没有更多) */
nextToken?: string;
/** 是否还有更多数据 / string 是否被预览截断 */
truncated?: boolean;
/** string 预览上限(字节) */
previewCap?: number;
}
export interface RedisKeyDetailPanelHandle {
@@ -65,10 +75,21 @@ const RedisKeyDetailPanel = forwardRef<RedisKeyDetailPanelHandle, Props>(functio
});
const [deleteConfirm, setDeleteConfirm] = useState<{ msg: string; onConfirm: () => void } | null>(null);
const loadKeyDetail = (keyName: string) => {
// ── 大 key 分页 ──
/** 每页条数(服务端硬上限 2000) */
const [pageSize, setPageSize] = useState(200);
/** 当前页令牌('' = 第一页);hash/set 是 SCAN 游标,list/zset 是 offset */
const [pageToken, setPageToken] = useState('');
/** 已翻过的令牌,用于「上一页」(SCAN 游标不可逆,只能回放) */
const [tokenStack, setTokenStack] = useState<string[]>([]);
/** 导出进行中(避免重复点击) */
const [exporting, setExporting] = useState(false);
const loadKeyDetail = (keyName: string, token = '', keepStack = false) => {
setDetailLoading(true);
if (!keepStack) setTokenStack([]);
redisApi
.getKeyDetail(instanceId, keyName, db)
.getKeyDetail(instanceId, keyName, db, { token, count: pageSize })
.then((res: any) => {
const detail = res as RedisKeyDetail;
setSelectedKey(detail);
@@ -78,6 +99,7 @@ const RedisKeyDetailPanel = forwardRef<RedisKeyDetailPanelHandle, Props>(functio
setStringFormat(detail.type === 'string' ? 'Json' : 'String');
setInspectValue(null);
setValueSearch('');
setPageToken(token);
})
.catch(() => {
toast('error', '加载 Redis Key 详情失败');
@@ -205,6 +227,124 @@ const RedisKeyDetailPanel = forwardRef<RedisKeyDetailPanelHandle, Props>(functio
});
};
/** 当前打开的 key 名 */
const currentKeyName = () => selectedKey?.key || selectedKey?.name || '';
/** 下一页:把当前令牌压栈,再用 nextToken 拉取 */
const goNextPage = () => {
const next = selectedKey?.nextToken || '';
if (!next) return;
setTokenStack((stack) => [...stack, pageToken]);
loadKeyDetail(currentKeyName(), next, true);
};
/** 上一页:从栈里弹出上一个令牌(SCAN 游标不可逆,只能回放) */
const goPrevPage = () => {
if (tokenStack.length === 0) return;
const prev = tokenStack[tokenStack.length - 1];
setTokenStack((stack) => stack.slice(0, -1));
loadKeyDetail(currentKeyName(), prev, true);
};
/** 改每页条数:回到第一页重新取 */
const changePageSize = (size: number) => {
setPageSize(size);
setTokenStack([]);
const keyName = currentKeyName();
if (!keyName) return;
setDetailLoading(true);
redisApi
.getKeyDetail(instanceId, keyName, db, { token: '', count: size })
.then((res: any) => {
setSelectedKey(res as RedisKeyDetail);
setEditValue(stringifyValue(res.type, res.value));
setPageToken('');
})
.catch(() => toast('error', '加载 Redis Key 详情失败'))
.finally(() => setDetailLoading(false));
};
/** 把当前页数据转成 CSV / JSON 文本(纯前端,不再请求后端) */
const buildPageText = (format: 'csv' | 'json') => {
if (!selectedKey) return '';
const type = selectedKey.type;
const val: any = selectedKey.value;
const csvCell = (v: any) => {
const s = v === null || v === undefined ? '' : String(v);
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const rows: string[][] = [];
if (type === 'hash') {
rows.push(['field', 'value']);
Object.entries(val || {}).forEach(([f, v]) => rows.push([f, String(v ?? '')]));
} else if (type === 'list') {
rows.push(['index', 'value']);
(val || []).forEach((v: any, i: number) => rows.push([String(i), String(v ?? '')]));
} else if (type === 'set') {
rows.push(['member']);
(val || []).forEach((v: any) => rows.push([String(v ?? '')]));
} else if (type === 'zset') {
rows.push(['member', 'score']);
(val || []).forEach((z: any) => rows.push([String(z.Member ?? z.member ?? ''), String(z.Score ?? z.score ?? '')]));
} else {
rows.push(['content']);
rows.push([String(val ?? '')]);
}
if (format === 'csv') return rows.map((r) => r.map(csvCell).join(',')).join('\n');
return JSON.stringify(type === 'string' ? val : val, null, 2);
};
const downloadBlob = (text: string, filename: string, mime: string) => {
const blob = new Blob(['\ufeff' + text], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
/** 导出当前页(纯前端,适合几万行以内,立即完成) */
const exportCurrentPage = (format: 'csv' | 'json') => {
if (!selectedKey) return;
const keyName = currentKeyName();
const text = buildPageText(format);
const safe = keyName.replace(/[^\w.-]+/g, '_').slice(0, 80) || 'redis-key';
downloadBlob(
text,
`${safe}_page${tokenStack.length + 1}.${format === 'csv' ? 'csv' : 'json'}`,
format === 'csv' ? 'text/csv;charset=utf-8' : 'application/json;charset=utf-8',
);
toast('success', '当前页已导出(如需全量请用「导出全部」)');
};
/**
* 导出全部(服务端流式):
* 先把一次性票据拿到手,再交给浏览器原生下载,避免整份数据进浏览器内存。
*/
const exportAll = (format: 'csv' | 'jsonl') => {
if (!selectedKey) return;
const keyName = currentKeyName();
setExporting(true);
redisApi
.createExportTicket(instanceId, keyName, db)
.then((res) => {
if (!res?.ticket) throw new Error('未取到导出票据');
const url = redisApi.buildExportUrl(instanceId, keyName, db, format, res.ticket);
const a = document.createElement('a');
a.href = url;
a.download = '';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
toast('success', '已开始下载(大 key 会边读边写,请耐心等待)');
})
.catch((err) => toast('error', err instanceof Error ? err.message : '导出失败'))
.finally(() => setExporting(false));
};
const renderFormattedValue = () => {
if (!selectedKey) return '';
if (inspectValue) {
@@ -334,6 +474,97 @@ const RedisKeyDetailPanel = forwardRef<RedisKeyDetailPanelHandle, Props>(functio
</div>
</div>
{/* ── 分页 + 导出条(大 key 必看) ── */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-4 py-2 text-xs">
<div className="flex flex-wrap items-center gap-3">
<span className="text-[var(--dg-text-muted)]">
共 <b className="text-[var(--dg-text-primary)]">
{typeof selectedKey.total === 'number' && selectedKey.total >= 0
? selectedKey.total.toLocaleString()
: '?'}
</b>{' '}
{selectedKey.type === 'string' ? '字节' : '条'}
<span className="mx-1">·</span>
当前第 <b className="text-[var(--dg-text-primary)]">{tokenStack.length + 1}</b> 页,每页
</span>
<select
value={pageSize}
onChange={(e) => changePageSize(Number(e.target.value))}
disabled={selectedKey.type === 'string' || detailLoading}
className="h-7 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 disabled:opacity-50"
>
{[100, 200, 500, 1000, 2000].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
<div className="flex items-center gap-1">
<button
onClick={goPrevPage}
disabled={tokenStack.length === 0 || detailLoading}
className="inline-flex h-7 items-center gap-1 rounded-md border border-[var(--dg-border)] px-2 text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-40"
title="上一页"
>
<ChevronLeft className="h-3.5 w-3.5" />
上一页
</button>
<button
onClick={goNextPage}
disabled={!selectedKey.nextToken || detailLoading}
className="inline-flex h-7 items-center gap-1 rounded-md border border-[var(--dg-border)] px-2 text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-40"
title="下一页"
>
下一页
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
{selectedKey.type === 'string' && selectedKey.truncated && (
<span className="rounded bg-amber-500/10 px-2 py-1 text-amber-400">
超大字符串:仅预览前 {Math.round((selectedKey.previewCap || 262144) / 1024)} KB(共{' '}
{selectedKey.total?.toLocaleString() ?? '?'} 字节),完整内容请「导出全部」
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-[var(--dg-text-muted)]">导出</span>
<button
onClick={() => exportCurrentPage('csv')}
className="inline-flex h-7 items-center gap-1 rounded-md border border-[var(--dg-border)] px-2 text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]"
title="仅导出当前页(纯前端生成,立即完成)"
>
<Download className="h-3.5 w-3.5" />
当前页 CSV
</button>
<button
onClick={() => exportCurrentPage('json')}
className="inline-flex h-7 items-center gap-1 rounded-md border border-[var(--dg-border)] px-2 text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]"
title="仅导出当前页(JSON)"
>
<Download className="h-3.5 w-3.5" />
当前页 JSON
</button>
<button
onClick={() => exportAll('csv')}
disabled={exporting}
className="inline-flex h-7 items-center gap-1 rounded-md bg-[var(--dg-blue)] px-2.5 font-medium text-white hover:opacity-90 disabled:opacity-50"
title="服务端流式导出全部数据,边读边写不占内存"
>
{exporting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
导出全部 CSV
</button>
<button
onClick={() => exportAll('jsonl')}
disabled={exporting}
className="inline-flex h-7 items-center gap-1 rounded-md border border-[var(--dg-border)] px-2 text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50"
title="服务端流式导出全部数据(JSON Lines)"
>
导出全部 JSONL
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-4 scrollbar-thin">
{detailLoading ? (
<div className="h-72 animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />