feat: Redis/Valkey 添加慢日志/客户端/内存诊断/大Key扫描

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
耗子
2026-08-19 22:41:40 +08:00
parent 0dfe577fc4
commit 77aa2a3450
9 changed files with 494 additions and 7 deletions
+2 -2
View File
@@ -102,12 +102,12 @@ func initAce() (*app.Ace, func(), error) {
postgresqlApp := postgresql.NewApp(locale, config, databaseServerRepo, settingRepo, taskRepo)
prometheusApp := prometheus.NewApp(config, locale, taskRepo)
pureftpdApp := pureftpd.NewApp(locale)
redisApp := redis.NewApp(locale, databaseServerRepo)
redisApp := redis.NewApp(locale, databaseServerRepo, taskRepo)
rocketmqApp := rocketmq.NewApp(locale)
rsyncApp := rsync.NewApp(locale)
s3fsApp := s3fs.NewApp(locale)
supervisorApp := supervisor.NewApp(locale)
valkeyApp := valkey.NewApp(locale, databaseServerRepo)
valkeyApp := valkey.NewApp(locale, databaseServerRepo, taskRepo)
loader := bootstrap.NewLoader(apacheApp, clickhouseApp, codeserverApp, dockerApp, elasticsearchApp, fail2banApp, frpApp, giteaApp, grafanaApp, kafkaApp, mariadbApp, memcachedApp, minioApp, mongodbApp, mysqlApp, nginxApp, openrestyApp, opensearchApp, perconaApp, pgadminApp, phpmyadminApp, podmanApp, postgresqlApp, prometheusApp, pureftpdApp, redisApp, rocketmqApp, rsyncApp, s3fsApp, supervisorApp, valkeyApp)
manager, err := bootstrap.NewSession(config, db, slogLogger)
if err != nil {
+260 -3
View File
@@ -1,41 +1,50 @@
package redis
import (
"context"
"fmt"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/go-chi/chi/v5"
redigo "github.com/gomodule/redigo/redis"
"github.com/leonelquinteros/gotext"
"github.com/samber/lo"
"github.com/spf13/cast"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/common"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/db"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/shell"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/tools"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
t *gotext.Locale
databaseServerRepo biz.DatabaseServerRepo
taskRepo biz.TaskRepo
slug string // 服务名与配置目录名,如 redis、valkey
name string // 展示名,如 Redis、Valkey
}
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) *App {
return New("redis", "Redis", t, databaseServerRepo)
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, taskRepo biz.TaskRepo) *App {
return New("redis", "Redis", t, databaseServerRepo, taskRepo)
}
func New(slug, name string, t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) *App {
func New(slug, name string, t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, taskRepo biz.TaskRepo) *App {
return &App{
t: t,
databaseServerRepo: databaseServerRepo,
taskRepo: taskRepo,
slug: slug,
name: name,
}
@@ -47,6 +56,13 @@ func (s *App) Route(r chi.Router) {
r.Post("/config", s.UpdateConfig)
r.Get("/config_tune", s.GetConfigTune)
r.Post("/config_tune", s.UpdateConfigTune)
// 性能诊断
r.Get("/slow_log", s.SlowLog)
r.Post("/slow_log/reset", s.ResetSlowLog)
r.Get("/clients", s.ClientList)
r.Post("/clients/kill", s.KillClient)
r.Get("/memory", s.MemoryStatus)
r.Post("/bigkeys", s.ScanBigKeys)
}
func (s *App) Status() string {
@@ -185,6 +201,247 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
service.Success(w, nil)
}
// SlowLog 获取慢日志
func (s *App) SlowLog(w http.ResponseWriter, r *http.Request) {
conn, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer conn.Close()
reply, err := conn.Exec("SLOWLOG", "GET", 50)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
rows, err := redigo.Values(reply, nil)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
entries := make([]SlowLogEntry, 0, len(rows))
for _, row := range rows {
item, itemErr := redigo.Values(row, nil)
if itemErr != nil || len(item) < 4 {
continue
}
entry := SlowLogEntry{}
entry.ID, _ = redigo.Int64(item[0], nil)
if ts, tsErr := redigo.Int64(item[1], nil); tsErr == nil {
entry.Time = time.Unix(ts, 0).Format(time.DateTime)
}
entry.DurationUs, _ = redigo.Int64(item[2], nil)
if cmd, cmdErr := redigo.Strings(item[3], nil); cmdErr == nil {
entry.Command = strings.Join(cmd, " ")
}
if len(item) > 4 {
entry.Client, _ = redigo.String(item[4], nil)
}
entries = append(entries, entry)
}
service.Success(w, entries)
}
// ResetSlowLog 重置慢日志
func (s *App) ResetSlowLog(w http.ResponseWriter, r *http.Request) {
conn, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer conn.Close()
if _, err = conn.Exec("SLOWLOG", "RESET"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// ClientList 获取客户端连接列表
func (s *App) ClientList(w http.ResponseWriter, r *http.Request) {
conn, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer conn.Close()
raw, err := redigo.String(conn.Exec("CLIENT", "LIST"))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
clients := make([]Client, 0)
for line := range strings.SplitSeq(strings.TrimSpace(raw), "\n") {
if line == "" {
continue
}
fields := map[string]string{}
for kv := range strings.FieldsSeq(line) {
if key, value, found := strings.Cut(kv, "="); found {
fields[key] = value
}
}
clients = append(clients, Client{
ID: fields["id"],
Addr: fields["addr"],
Name: fields["name"],
DB: fields["db"],
Age: fields["age"],
Idle: fields["idle"],
Cmd: fields["cmd"],
})
}
service.Success(w, clients)
}
// KillClient 踢除客户端连接
func (s *App) KillClient(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ClientKill](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
conn, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer conn.Close()
killed, err := redigo.Int64(conn.Exec("CLIENT", "KILL", "ID", req.ID))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if killed == 0 {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("client %d not found", req.ID))
return
}
service.Success(w, nil)
}
// MemoryStatus 获取内存诊断信息
func (s *App) MemoryStatus(w http.ResponseWriter, r *http.Request) {
conn, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer conn.Close()
memory := Memory{Items: make([]types.NV, 0)}
memory.Doctor, err = redigo.String(conn.Exec("MEMORY", "DOCTOR"))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
reply, err := conn.Exec("MEMORY", "STATS")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
values, err := redigo.Values(reply, nil)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
stats := map[string]string{}
for i := 0; i+1 < len(values); i += 2 {
key, keyErr := redigo.String(values[i], nil)
if keyErr != nil {
continue
}
// 值可能为整数或字符串,嵌套数组(如 db.0)跳过
if v, vErr := redigo.Int64(values[i+1], nil); vErr == nil {
stats[key] = cast.ToString(v)
continue
}
if v, vErr := redigo.String(values[i+1], nil); vErr == nil {
stats[key] = v
}
}
// 各版本键名存在差异,仅展示存在的指标
items := []struct {
key string
name string
bytes bool
}{
{"peak.allocated", s.t.Get("Peak Allocated"), true},
{"total.allocated", s.t.Get("Total Allocated"), true},
{"startup.allocated", s.t.Get("Startup Allocated"), true},
{"dataset.bytes", s.t.Get("Dataset Size"), true},
{"dataset.percentage", s.t.Get("Dataset Percentage"), false},
{"keys.count", s.t.Get("Keys Count"), false},
{"keys.bytes-per-key", s.t.Get("Bytes Per Key"), true},
{"allocator-fragmentation.ratio", s.t.Get("Allocator Fragmentation Ratio"), false},
{"fragmentation", s.t.Get("Fragmentation Ratio"), false},
}
for _, item := range items {
value, ok := stats[item.key]
if !ok {
continue
}
if item.bytes {
value = tools.FormatBytes(cast.ToFloat64(value))
}
memory.Items = append(memory.Items, types.NV{Name: item.name, Value: value})
}
service.Success(w, memory)
}
// ScanBigKeys 扫描大 Key(异步任务)
func (s *App) ScanBigKeys(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(s.confPath())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
withPassword := ""
if password := confval.Directive.Get(config, "requirepass"); password != "" {
withPassword = " -a " + password
}
task := new(biz.Task)
task.Key = s.slug + ":bigkeys"
task.Name = s.t.Get("Scan %s big keys", s.name)
task.Status = biz.TaskStatusWaiting
task.Shell = fmt.Sprintf("%s-cli%s --bigkeys", s.slug, withPassword)
if err = s.taskRepo.Push(task); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// connect 从配置文件读取端口与密码建立连接
func (s *App) connect(ctx context.Context) (*db.Redis, error) {
config, err := io.Read(s.confPath())
if err != nil {
return nil, err
}
port := confval.Directive.Get(config, "port")
if port == "" {
port = "6379"
}
password := confval.Directive.Get(config, "requirepass")
return db.NewRedis(ctx, "", password, "127.0.0.1:"+port)
}
func (s *App) confPath() string {
return filepath.Join(app.Root, "server", s.slug, s.slug+".conf")
}
+33
View File
@@ -1,5 +1,7 @@
package redis
import "github.com/acepanel/panel/v3/pkg/types"
// ConfigTune Redis 协议兼容服务的配置调整
type ConfigTune struct {
// 常规设置
@@ -16,3 +18,34 @@ type ConfigTune struct {
Appendonly string `form:"appendonly" json:"appendonly" validate:"in:yes,no"`
Appendfsync string `form:"appendfsync" json:"appendfsync" validate:"in:always,everysec,no"`
}
// ClientKill 踢除客户端连接请求
type ClientKill struct {
ID int64 `form:"id" json:"id" validate:"required"`
}
// SlowLogEntry 慢日志条目
type SlowLogEntry struct {
ID int64 `json:"id"`
Time string `json:"time"`
DurationUs int64 `json:"duration_us"`
Command string `json:"command"`
Client string `json:"client"`
}
// Client 客户端连接信息
type Client struct {
ID string `json:"id"`
Addr string `json:"addr"`
Name string `json:"name"`
DB string `json:"db"`
Age string `json:"age"`
Idle string `json:"idle"`
Cmd string `json:"cmd"`
}
// Memory 内存诊断信息
type Memory struct {
Doctor string `json:"doctor"`
Items []types.NV `json:"items"`
}
+2 -2
View File
@@ -12,9 +12,9 @@ type App struct {
redis *redis.App
}
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) *App {
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, taskRepo biz.TaskRepo) *App {
return &App{
redis: redis.New("valkey", "Valkey", t, databaseServerRepo),
redis: redis.New("valkey", "Valkey", t, databaseServerRepo, taskRepo),
}
}
+10
View File
@@ -11,4 +11,14 @@ export default {
configTune: (): any => http.Get('/apps/redis/config_tune'),
// 保存配置调整参数
saveConfigTune: (data: any): any => http.Post('/apps/redis/config_tune', data),
// 慢日志
slowLog: (): any => http.Get('/apps/redis/slow_log'),
resetSlowLog: (): any => http.Post('/apps/redis/slow_log/reset'),
// 客户端连接
clients: (): any => http.Get('/apps/redis/clients'),
killClient: (id: number): any => http.Post('/apps/redis/clients/kill', { id }),
// 内存诊断
memory: (): any => http.Get('/apps/redis/memory'),
// 扫描大 Key
scanBigKeys: (): any => http.Post('/apps/redis/bigkeys'),
}
+10
View File
@@ -6,4 +6,14 @@ export default {
saveConfig: (config: string): any => http.Post('/apps/valkey/config', { config }),
configTune: (): any => http.Get('/apps/valkey/config_tune'),
saveConfigTune: (data: any): any => http.Post('/apps/valkey/config_tune', data),
// 慢日志
slowLog: (): any => http.Get('/apps/valkey/slow_log'),
resetSlowLog: (): any => http.Post('/apps/valkey/slow_log/reset'),
// 客户端连接
clients: (): any => http.Get('/apps/valkey/clients'),
killClient: (id: number): any => http.Post('/apps/valkey/clients/kill', { id }),
// 内存诊断
memory: (): any => http.Get('/apps/valkey/memory'),
// 扫描大 Key
scanBigKeys: (): any => http.Post('/apps/valkey/bigkeys'),
}
+5
View File
@@ -10,6 +10,8 @@ import redis from '@/api/apps/redis'
import ServiceStatus from '@/components/common/ServiceStatus.vue'
import RedisConfigTuneView from './RedisConfigTuneView.vue'
import RedisPerformanceView from '@/views/apps/redis/RedisPerformanceView.vue'
const { $gettext } = useGettext()
const currentTab = ref('status')
@@ -87,6 +89,9 @@ const handleSaveConfig = () => {
<n-tab-pane name="config-tune" :tab="$gettext('Parameter Tuning')">
<redis-config-tune-view />
</n-tab-pane>
<n-tab-pane name="performance" :tab="$gettext('Performance')">
<redis-performance-view :api="redis" />
</n-tab-pane>
<n-tab-pane name="load" :tab="$gettext('Load Status')">
<n-data-table
striped
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { NButton } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import type redis from '@/api/apps/redis'
import { useConfirm } from '@/components/system/composables/useConfirm'
const props = defineProps<{
api: typeof redis
}>()
const { $gettext } = useGettext()
const { confirmDelete, confirmAction } = useConfirm()
const { data: slowLog, send: refreshSlowLog } = useRequest(props.api.slowLog, {
initialData: [],
})
const { data: clients, send: refreshClients } = useRequest(props.api.clients, {
initialData: [],
})
const { data: memory, send: refreshMemory } = useRequest(props.api.memory, {
initialData: { doctor: '', items: [] },
})
const slowLogColumns: any = [
{ title: 'ID', key: 'id', width: 90 },
{ title: $gettext('Time'), key: 'time', width: 180 },
{
title: $gettext('Duration (ms)'),
key: 'duration_us',
width: 130,
render: (row: any) => Math.round(row.duration_us / 10) / 100,
},
{ title: $gettext('Command'), key: 'command', minWidth: 300, ellipsis: { tooltip: true } },
{ title: $gettext('Client'), key: 'client', width: 160, ellipsis: { tooltip: true } },
]
const clientColumns: any = [
{ title: 'ID', key: 'id', width: 90 },
{ title: $gettext('Address'), key: 'addr', width: 170, ellipsis: { tooltip: true } },
{ title: $gettext('Name'), key: 'name', width: 120, ellipsis: { tooltip: true } },
{ title: $gettext('Database'), key: 'db', width: 90 },
{ title: $gettext('Age (s)'), key: 'age', width: 90 },
{ title: $gettext('Idle (s)'), key: 'idle', width: 90 },
{ title: $gettext('Command'), key: 'cmd', minWidth: 150, ellipsis: { tooltip: true } },
{
title: $gettext('Actions'),
key: 'actions',
width: 110,
render(row: any) {
return h(
NButton,
{
size: 'small',
type: 'error',
onClick: async () => {
const ok = await confirmDelete({
title: $gettext('Confirm Kill'),
content: $gettext('Are you sure you want to kill connection %{ addr }?', {
addr: row.addr,
}),
positiveText: $gettext('Kill'),
})
if (ok) handleKillClient(Number(row.id))
},
},
{ default: () => $gettext('Kill') },
)
},
},
]
const memoryColumns: any = [
{ title: $gettext('Property'), key: 'name', minWidth: 200, ellipsis: { tooltip: true } },
{ title: $gettext('Current Value'), key: 'value', minWidth: 200, ellipsis: { tooltip: true } },
]
const handleResetSlowLog = async () => {
const ok = await confirmAction({
type: 'warning',
title: $gettext('Confirm Reset'),
content: $gettext('Are you sure you want to reset the slow log?'),
})
if (!ok) return
useRequest(props.api.resetSlowLog()).onSuccess(() => {
window.$message.success($gettext('Reset successfully'))
refreshSlowLog()
})
}
const handleKillClient = (id: number) => {
useRequest(props.api.killClient(id)).onSuccess(() => {
window.$message.success($gettext('Killed successfully'))
refreshClients()
})
}
const handleScanBigKeys = async () => {
const ok = await confirmAction({
type: 'info',
title: $gettext('Confirm Scan'),
content: $gettext(
'Scanning traverses all keys and may take a while on large datasets. The result will be in the task log.',
),
})
if (!ok) return
useRequest(props.api.scanBigKeys()).onSuccess(() => {
window.$message.success($gettext('Task submitted, please check progress in background tasks'))
})
}
</script>
<template>
<n-tabs type="segment" animated>
<n-tab-pane name="slow-log" :tab="$gettext('Slow Log')">
<n-flex vertical>
<n-flex>
<n-button type="primary" @click="() => refreshSlowLog()">
{{ $gettext('Refresh') }}
</n-button>
<n-button type="warning" @click="handleResetSlowLog">
{{ $gettext('Reset Slow Log') }}
</n-button>
</n-flex>
<n-data-table
striped
:columns="slowLogColumns"
:data="slowLog"
:scroll-x="860"
max-height="60vh"
/>
</n-flex>
</n-tab-pane>
<n-tab-pane name="clients" :tab="$gettext('Clients')">
<n-flex vertical>
<n-flex>
<n-button type="primary" @click="() => refreshClients()">
{{ $gettext('Refresh') }}
</n-button>
</n-flex>
<n-data-table
striped
:columns="clientColumns"
:data="clients"
:scroll-x="920"
max-height="60vh"
/>
</n-flex>
</n-tab-pane>
<n-tab-pane name="memory" :tab="$gettext('Memory')">
<n-flex vertical>
<n-flex>
<n-button type="primary" @click="() => refreshMemory()">
{{ $gettext('Refresh') }}
</n-button>
<n-button type="info" @click="handleScanBigKeys">
{{ $gettext('Scan Big Keys') }}
</n-button>
</n-flex>
<n-alert v-if="memory.doctor" type="info">
{{ memory.doctor }}
</n-alert>
<n-data-table striped :columns="memoryColumns" :data="memory.items" :scroll-x="400" />
</n-flex>
</n-tab-pane>
</n-tabs>
</template>
+5
View File
@@ -10,6 +10,8 @@ import valkey from '@/api/apps/valkey'
import ServiceStatus from '@/components/common/ServiceStatus.vue'
import ValkeyConfigTuneView from './ValkeyConfigTuneView.vue'
import RedisPerformanceView from '@/views/apps/redis/RedisPerformanceView.vue'
const { $gettext } = useGettext()
const currentTab = ref('status')
@@ -87,6 +89,9 @@ const handleSaveConfig = () => {
<n-tab-pane name="config-tune" :tab="$gettext('Parameter Tuning')">
<valkey-config-tune-view />
</n-tab-pane>
<n-tab-pane name="performance" :tab="$gettext('Performance')">
<redis-performance-view :api="valkey" />
</n-tab-pane>
<n-tab-pane name="load" :tab="$gettext('Load Status')">
<n-data-table
striped