- 即时通讯
- 后端
【免费下载链接】WuKongIM
More than just IM 不只是即时通讯(IM)
本指南以 WuKongIM 仓库内 DB Inspect 实施计划 为主体,结合
pkg/db/inspect、internal/access/manager、internal/access/node、internal/usecase/management、internal/app与web/src的真实源码,完整讲解 WuKongIM 如何为管理端(Web Manager)提供一套只读的「DB 检查」控制台:从浏览器发出受限 SQL、经 HTTP 鉴权与权限校验、再到节点本地只读存储引擎扫描的整条调用链。读完本文,你将掌握该功能的架构分层、SQL 子集语法、五种扫描模式、HTTP 路由与错误映射、跨节点 RPC 编解码,以及 Web 页面与 API Client 的接入方式。
功能定位:为运行态数据库提供「只读放大镜」
WuKongIM 在长期运行中,slotmeta(元数据)与messages(消息)两套基于 Pebble 的本地存储是排障、容量评估与数据治理的关键对象。DB Inspect 功能的定位非常明确:
- 在管理端 Web 页面(
/system/db)提供一个只读的数据库检查控制台; - 底层由
pkg/db/inspect提供受限 SQL 子集的解释执行能力,直接以只读方式打开节点本地存储; - 不引入任何写操作、不触发 repair/compaction、不做跨集群合并查询——它只回答「某个节点本地数据库里现在有什么」这一类诊断问题。
实施计划中反复强调的三条硬性约束(也全部体现在最终代码里):
- 全部只读:
pkg/db/inspect的OpenStore以engine.Options{ReadOnly: true}打开存储; - 不接受文件系统路径:HTTP/Web 请求只能传
node_id与查询文本,存储路径由internal/app从Config.DataDir推导; - 不新增
WK_配置键:所有参数(如 HashSlotCount)从既有集群配置派生。
总体架构:浏览器只访问 Manager HTTP
从实施计划的 Architecture 一节可以看出,整个功能是四层职责分离的:
浏览器(Web DB Inspect 页面) -> Manager HTTP 路由(internal/access/manager) 解析 HTTP、鉴权、权限校验、DTO 转换 -> Management Usecase(internal/usecase/management) 决定本地执行还是远程转发 -> 本地:internal/app 推导存储路径并适配 pkg/db/inspect -> 远程:internal/access/node 通过 clusterv2 RPC 转发到目标节点 -> 目标节点 internal/app 的 dbInspectReader -> 目标节点本地 pkg/db/inspect Store核心原则是:浏览器永远只和 Manager HTTP 打交道,跨节点查询由 Manager 侧在集群 RPC 层完成,用户无需关心节点网络细节。
技术栈
| 层次 | 技术 |
|---|---|
| HTTP 服务 | Go + Gin(internal/access/manager) |
| 集群节点 RPC | clusterv2 节点 RPC(internal/access/node) |
| 只读检查引擎 | pkg/db/inspect |
| Web 管理端 | React + TypeScript + React Router + React Intl |
| 前端测试 | Vitest(沿用现有 Web manager shell) |
核心数据契约:DTO 与统计信息
管理端 usecase 层定义了一组 JSON 友好的 DTO,见 internal/usecase/management/db_inspect.go。
请求结构DBInspectQueryRequest
| 字段 | 类型 | 说明 |
|---|---|---|
NodeID | uint64 | 选择要检查的节点本地 DB;0表示本地节点 |
Query | string | 只读检查 SQL 语句 |
响应结构DBInspectQueryResponse
| 字段 | 类型 | 说明 |
|---|---|---|
NodeID | uint64 | 实际执行查询的节点 |
GeneratedAt | time.Time | 结果产生时间 |
Rows | []DBInspectRow | 动态结果行(map[string]any) |
Stats | DBInspectStats | 扫描工作量与分页状态汇总 |
统计信息DBInspectStats是理解该功能诊断价值的关键:
| 字段 | 类型 | 含义 |
|---|---|---|
ScanMode | string | pkg/db/inspect使用的扫描策略名称 |
ScannedHashSlots | []uint16 | 扫描触及的元数据哈希槽列表 |
ScannedRows | int | 扫描过程中解码并考虑过的行数 |
ReturnedRows | int | 实际返回给调用方的行数 |
HasMore | bool | 是否还有下一页 |
NextCursor | string | 当HasMore为真时,用于续扫下一页的游标 |
这套统计设计让运维人员能直接判断查询的"成本":扫描了多少哈希槽、看了多少行、返回了多少行,从而识别全槽扫描(local-bounded)这类昂贵查询。
pkg/db/inspect:受限 SQL 子集的只读检查引擎
pkg/db/inspect是整个功能的心脏,由 types.go、parser.go、planner.go、execute.go、cursor.go 组成。
支持的三种查询形态
QueryKind定义了检查引擎能处理的全部操作:
const ( QueryShowTables QueryKind = iota + 1 // show tables:列出可检查的表 QueryDescribe // describe/desc:描述某张表的列 QuerySelect // select:从某张可检查表扫描行 )对应语法:
show tables—— 列出meta.*与message.*域下全部可检查表;describe meta.user(或desc)—— 输出指定表的列元数据;select ... from ... [where ...] [limit N] [cursor '...']—— 受限行扫描。
词法与语法边界
解析器实现了一个小型 lexer(见 parser.go),支持的关键字子集仅包括show / tables / describe / desc / select / from / where / and / limit / cursor,字面量支持单引号字符串与十进制整数。
明确不支持并返回ErrUnsupportedQuery的语法包括:
join、offset;group by、order by;- 函数、子查询、多表查询等超出子集的内容一律返回
ErrInvalidQuery。
LIMIT的取值还受边界约束:normalizeLimit会以DefaultLimit(缺省时默认100)兜底、以MaxLimit(缺省时默认10000)封顶(常量定义见 store.go)。也就是说,忘写limit的查询最多返回 100 行,写再大的 limit 也只会返回 10000 行——这是"有界只读"(bounded read-only)的直接体现。
表目录与五种扫描模式
规划器(planner.go)先校验表是否存在(inspectTableExists),再根据表与过滤条件选择扫描策略。元数据域的可检查表及分区键注册在partitionKeys:
| 表 | 分区键 |
|---|---|
meta.user | uid |
meta.device | uid |
meta.channel | channel_id |
meta.channel_runtime_meta | channel_id |
meta.subscriber | channel_id |
meta.user_channel_membership | uid |
meta.user_cmd_channel_membership | uid |
meta.plugin_binding | uid |
meta.channel_migration | channel_id |
message.message | channel_key |
planMetaQuery与planMessageQuery共同决定了五种扫描模式(ScanMode):
| 扫描模式 | 常量 | 触发条件 |
|---|---|---|
point-partition | scanModePointPartition | 元数据表带分区键等值过滤(如where uid='u1'),可精确定位单个哈希槽 |
explicit-partition | scanModeExplicitPartition | 显式指定where hash_slot=N |
local-bounded | scanModeLocalBounded | 元数据表无分区过滤,需遍历全部哈希槽(成本最高,受HashSlotCount约束) |
message-channel | scanModeMessageChannel | 消息表message.message带channel_key过滤 |
message-catalog | scanModeMessageCatalog | 消息域目录表message.channels |
需要特别说明ErrHashSlotRequired:当元数据表需要按分区键定位或全槽扫描,而Options.HashSlotCount为 0(未配置槽数)时,规划器会拒绝执行。这正是"存储路径与槽数由 app 层推导、调用方不可注入"这一设计约束的体现。
执行与结果投影
Store.Query(execute.go)按解析出的 Kind 分发:showTables()枚举metadb.InspectTables()并追加message.channels、message.message两行;describe()输出列元数据;select则进入executePlan,把plan转换为metadb.InspectScanRequest交给元数据/消息存储的只读扫描接口,最后用projectRows做列投影并汇总Stats。
游标分页
分页通过cursor 'xxx'语法续扫:响应里的NextCursor拼回查询即可取下一页(Web 页面用appendCursor把query + " cursor 'abc'"拼接)。游标与请求的扫描模式、表、分区槽严格绑定(validateCursorForPlan),任何不匹配都会返回ErrCursorMismatch,从机制上杜绝"跨扫描翻页"造成的错乱。
Management Usecase:本地 vs 远程的路由决策
internal/usecase/management/db_inspect.go 定义了三个对外方法:
ListDBInspectTables(ctx, nodeID)—— 等价于QueryDBInspect(..., "show tables");DescribeDBInspectTable(ctx, nodeID, domain, table)—— 对domain、table做白名单校验(validDBInspectIdentifier,只允许字母/数字/下划线,杜绝路径穿越)后拼接"describe " + domain + "." + table;QueryDBInspect(ctx, req)—— 通用入口。
路由核心是resolveDBInspectNode:
localID := a.cluster.NodeID() if nodeID == 0 || nodeID == localID { return localID, true, nil // 本地执行 } snapshot, err := a.cluster.LocalControlSnapshot(ctx) // 在控制快照中查找 nodeID // 找到 -> 远程执行;未找到 -> metadb.ErrInvalidArgument即:node_id为空或等于本节点 → 走本地DBInspectReader;否则先在集群控制快照里确认节点存在,再走RemoteDBInspectReader(NodeDBInspectQuery)。未知节点、空查询、非法表名都会返回metadb.ErrInvalidArgument;未装配读取器时返回ErrDBInspectUnavailable。这一路由逻辑在 db_inspect_test.go 中有完整覆盖(本地空节点、显式本地节点、远程节点、非法请求、不可用五类场景)。
App 层:存储路径推导与只读 Store 适配
internal/app/db_inspect.go 的dbInspectReader是 app 组合根提供的本地读取器,职责是把「配置路径」翻译成「inspect 引擎选项」:
store, err := inspect.OpenStore(inspect.Options{ MetaPath: filepath.Join(r.dataDir, "slotmeta"), MessagePath: filepath.Join(r.dataDir, "messages"), HashSlotCount: r.hashSlotCount, })路径固定为DataDir/slotmeta与DataDir/messages——调用方永远无法注入任意路径。装配发生在 internal/app/wiring.go 的newDBInspectReader与wireManagerDBInspectRPC:HashSlotCount从cfg.Cluster.Slots.HashSlotCount读取,为 0 时默认16。数据目录为空时直接返回 nil,随后 usecase 层会以ErrDBInspectUnavailable兜底。
Manager HTTP 路由与权限
路由注册在 internal/access/manager/server.go:
dbInspect := s.engine.Group("/manager") if s.auth.enabled() { dbInspect.Use(s.requirePermission("cluster.db", "r")) } dbInspect.GET("/db/inspect/tables", s.handleDBInspectTables) dbInspect.GET("/db/inspect/tables/:domain/:table", s.handleDBInspectTable) dbInspect.POST("/db/inspect/query", s.handleDBInspectQuery)三个端点全部挂在/manager前缀下,且当鉴权开启时要求cluster.db资源的r(读)权限——这也是 Web 端权限模型里新增的最小权限面。
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /manager/db/inspect/tables | 列出可检查表,可选?node_id= |
GET | /manager/db/inspect/tables/:domain/:table | 描述某张表,可选?node_id= |
POST | /manager/db/inspect/query | 执行只读查询,Body 为{"node_id":2,"query":"show tables"} |
HTTP DTO(internal/access/manager/db_inspect.go)与 usecase DTO 一一对应,node_id解析允许为空(即本地节点)。错误映射writeDBInspectError把底层错误翻译成稳定的 HTTP 语义:
| 底层错误 | HTTP 状态 | 错误码 |
|---|---|---|
dbinspect.ErrCursorMismatch | 400 | invalid_cursor |
dbinspect.ErrInvalidQuery/ErrUnsupportedQuery/metadb.ErrInvalidArgument | 400 | invalid_request |
managementusecase.ErrDBInspectUnavailable | 503 | service_unavailable |
| 其他 | 500 | internal_error |
跨节点 RPC:把只读查询送到目标节点
远程查询依赖 clusterv2 节点 RPC,服务 ID 为RPCManagerDBInspect(见pkg/cluster/net/ids.go)。
确定性版本化编解码
internal/access/node/manager_db_inspect_codec.go 定义了带 magic 前缀的 JSON 信封:
- 请求前缀:
W K V I 1; - 响应前缀:
W K V i 1(大小写区分方向); - 请求体:
{"node_id":2,"query":"show tables"}; - 响应体:
{"status":"ok","page":{...}}(page即完整DBInspectQueryResponse)。
magic 前缀保证任何"非本服务编解码"的 payload 都会被拒绝,避免 RPC 服务间串扰。
状态映射
internal/access/node/manager_db_inspect_rpc.go 定义了服务端与客户端双向状态映射:
- 服务端
HandleManagerDBInspectRPC:解码 → 检查适配器是否装配(未装配返回unavailable)→ 调用本地读取器 → 用managerDBInspectRPCStatusForError把错误归类为ok / invalid_request / invalid_cursor / unavailable / rejected / canceled / deadline; - 客户端
NodeDBInspectQuery:把状态码还原为 Go 错误,其中invalid_request、invalid_cursor、unavailable、rejected统一归并为ErrDBInspectUnavailable,上下文取消/超时则还原为context.Canceled/context.DeadlineExceeded。
接收节点始终只检查自己的本地 DB,不会二次路由转发,也不会修改任何存储。
infra 适配器
internal/infra/cluster/management_db_inspect.go 的ManagementDBInspectReader是集群侧适配器:它持有ManagementDBInspectNode(暴露NodeID()与CallRPC),内部用accessnode.NewClient(node)把NodeDBInspectQuery变成一次CallRPC(targetNode, RPCManagerDBInspect, body)调用。HTTP 解析、查询校验、表元数据整形、游标处理与存储路径推导全部留在 infra 适配器之外,保持单一职责。
Web 管理端:API Client、导航与页面
API Client 与类型
web/src/lib/manager-api.ts新增三个函数,全部走统一的jsonManagerFetch:
export function getDBInspectTables(params?: { nodeId?: number }) export function getDBInspectTable(domain: string, table: string, params?: { nodeId?: number }) export function queryDBInspect(input: ManagerDBInspectQueryInput)对应类型(web/src/lib/manager-api.types.ts):ManagerDBInspectRow、ManagerDBInspectStats、ManagerDBInspectQueryResponse等,JSON 字段(node_id、scan_mode、next_cursor等)与后端 DTO 完全对齐。
导航与路由
- 导航(
web/src/lib/navigation.ts):System 分组新增/system/db项(图标 Database),并登记旧路径别名/db-inspect; - 路由(
web/src/app/router.tsx):System 子路由system/db渲染DBInspectPage,同时保留db-inspect → /system/db的 301 式重定向; - 国际化:
web/src/i18n/messages/en.ts与zh-CN.ts新增整套文案(导航标题、页面标题、按钮、错误提示),中文文案如"dbInspect.title": "DB 检查"、"dbInspect.query.run": "执行查询"。
页面交互
web/src/pages/db-inspect/page.tsx实现完整交互:
- 表列表:加载节点列表与
show tables结果,按 domain 分组展示(meta.*/message.*),点击某表触发describe,右侧展示列名与类型; - 查询区:节点下拉框(默认选中
is_local节点)+ 查询输入框 + 6 条内置模板(show tables、describe meta.user、select * from meta.user where uid='u1' limit 20、select * from meta.channel where channel_id='g1' limit 20、select * from message.channels limit 50、select * from message.message where channel_key='g1:2' limit 20); - 结果区:动态列宽表格(
rowColumns从结果行推导列,renderCell处理 null/数组/对象),顶部StatsStrip展示scan_mode、scanned/returned 行数、哈希槽列表; - 分页:
has_more && next_cursor时出现「Next page」按钮,调用appendCursor续扫; - 错误处理:403 → forbidden、503 → unavailable 分别渲染;查询失败时保留输入框中的查询文本,方便修改重试。
页面行为由web/src/pages/db-inspect/page.test.tsx覆盖:加载表列表并执行查询、描述选中表、游标翻页、失败保留文本四大场景。
测试与验证体系
实施计划把每一层都配上了独立可运行的测试,仓库中这些测试均已落地:
| 层次 | 测试命令 | 覆盖点 |
|---|---|---|
| usecase 路由 | go test ./internal/usecase/management -run 'TestDBInspect' | 本地/远程路由、非法请求、不可用、便捷查询 |
| app 读取器 | go test ./internal/app -run 'TestDBInspectReader' | 存储路径推导、只读扫描、缺 DataDir 时不可用 |
| manager HTTP | go test ./internal/access/manager -run 'TestDBInspect' | 权限(403)、成功、校验(400)、不可用(503) |
| 节点 RPC | go test ./internal/access/node -run 'TestManagerDBInspect' | 编解码往返、服务端/客户端、状态映射 |
| infra 适配器 | go test ./internal/infra/cluster -run 'TestManagementDBInspectReader' | 路由到目标节点 RPC |
| Web | cd web && bun run test -- src/pages/db-inspect/page.test.tsx src/lib/manager-api.test.ts src/lib/navigation.test.ts src/app/router.test.tsx | 页面行为、API 路径/请求体、导航元数据、路由覆盖 |
| 综合 | go test ./internal/usecase/management ./internal/access/manager ./internal/access/node ./internal/infra/cluster ./internal/app ./pkg/db/inspect | 全链路回归 |
设计约束与安全边界小结
回顾整个实现,DB Inspect 的安全与边界设计可以总结为五条:
- 只读引擎:
pkg/db/inspect以 ReadOnly 打开存储,无任何写路径; - 无路径注入:存储路径只能由 app 层从
DataDir推导,HTTP/Web 永远无法传入文件系统路径; - 受限 SQL:解析器只接受白名单子集,
group by / order by / join / offset等一律拒绝; - 有界扫描:默认
LIMIT 100、上限10000,统计信息暴露扫描成本; - 最小权限:
cluster.db:r独立于节点管理权限,鉴权开启时未授权用户返回 403。
这套设计让运维人员可以安全地"看一眼"任意节点的本地数据库,而不会触碰集群写入路径——这正是 WuKongIM 管理面(Manager)与数据面解耦的又一次落地实践。
- 即时通讯
- 后端
【免费下载链接】WuKongIM
More than just IM 不只是即时通讯(IM)
相关推荐
终极指南:如何构建Pinterest高并发时序数据库Goku系统
终极指南:如何构建Pinterest高并发时序数据库Goku系统 system design resources项目是互联网上最优质的系统设计资源集合,其中包含
教程Pikiwidb 多 DB(Multi-DB)管理实战:从 `info keyspace` 到 `dbslaveof` 的命令演进与源码解读
Pikiwidb 多 DB(Multi DB)管理实战:从 info keyspace 到 dbslaveof 的命令演进与源码解读 自 3.1.0 版本起,P
数据库KV存储后端Yii 2 分页(Pagination)完全指南:从 DB 查询分页到 LinkPager 分页按钮
Yii 2 分页(Pagination)完全指南:从 DB 查询分页到 LinkPager 分页按钮 本指南围绕 Yii 2 框架的 yii\data\Pagi
后端Web框架
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考