☰
WuKongIM wkdb 只读检查 CLI:离线检视单节点数据目录的 SQL-like 方案
2026/10/16 2:47:32 网站建设 项目流程
  • 即时通讯
  • 后端

【免费下载链接】WuKongIM

More than just IM 不只是即时通讯(IM)

项目地址:https://gitcode.com/gh_mirrors/wu/WuKongIM
点击查看免费下载

本指南基于仓库文档 2026-05-27-wkdb-readonly-cli.md 编写,并对照pkg/db下的实际源码实现进行验证与补充。它面向需要诊断、巡检或离线分析 WuKongIM 节点本地存储的开发者,讲解如何构建一个以只读方式打开单个节点数据目录、并通过有限 SQL-like 查询检视本地元数据与消息存储的命令行工具wkdb。读完本文,你将理解该工具的分层架构(引擎只读打开 → 表级检查 API → 解析/规划/执行层 → CLI),掌握其查询语法、哈希槽推导规则、游标分页语义与安全边界,并能够在自己的节点上复现排障流程。

背景:为什么需要"只读离线"检查工具

WuKongIM 是一个"不只是即时通讯"的开源即时通讯内核,其存储层基于 Pebble 键值引擎构建,pkg/db/meta承载用户、设备、频道、订阅关系等元数据,pkg/db/message承载频道消息日志。在线上服务运行期间直接以读写方式打开这些存储存在风险:可能触发后台压缩、写入 WAL,甚至与正在运行的服务实例产生锁竞争或状态污染。

wkdb的设计目标因此非常明确:以一个节点的数据目录为输入,离线、只读地打开本地元数据与消息存储,用有界的 SQL-like 查询检视内部状态。它不参与集群通信、不接触其他节点、不写任何数据,只回答"这台机器上到底存了什么"。这使其天然适合以下场景:

  • 节点故障后离线取证:确认某个用户、频道、订阅关系在本地是否存在;
  • 数据一致性巡检:核对消息序列号、频道目录、hashslot 迁移进度;
  • 开发调试:在不拉起完整集群的情况下,快速验证pkg/db各表的读写是否符合预期。

计划文档对其边界有明确的定义:wkdb是local-only的,它"does not contact cluster peers and does not return a global cluster view"(不联系集群对端,也不返回全局集群视图)。

整体架构:四层分工

计划文档给出了清晰的架构分层,目标是把"数据库知识"与"命令行体验"解耦:

层归属职责
Pebble 引擎只读能力pkg/db/internal/engine以ReadOnly选项打开物理存储,禁止写入与后台压缩
表级检查辅助pkg/db/meta、pkg/db/message提供只读扫描类型、表模式查询、行转换、分区扫描
解析/规划/执行层pkg/db/inspect解析 SQL-like 子集、推导哈希槽与扫描模式、校验并编解码游标、分发执行
CLI 外壳cmd/wkdb只负责参数解析、配置路径解析、REPL 交互与输出格式化

计划原文强调:"The CLI owns only flag parsing, config-path resolution, REPL handling, and output formatting"(CLI 仅拥有参数解析、配置路径解析、REPL 处理和输出格式化),即所有存储语义都下沉到pkg/db层,CLI 保持轻薄。

值得说明的是实现状态:在当前仓库快照中,pkg/db/internal/engine、pkg/db/meta/inspect.go、pkg/db/message/inspect.go以及pkg/db/inspect(types/parser/planner/cursor/execute/store 及其测试)均已落地;cmd/wkdb目录尚未出现在仓库中,属于计划中待落地的最后一环。阅读下文时,可将源码证据与计划文档互相印证。

第一层:Pebble 引擎的只读打开

计划的 Task 1 是"Engine Read-Only Open",目标是给共享的 Pebble 引擎封装增加只读开关,并保证只读打开后任何写尝试都会被拒绝。

选项字段与传递链

在 pkg/db/internal/engine/db.go 中,Options结构体增加了第三个字段:

// Options controls Pebble engine tuning. type Options struct { // CacheSize configures Pebble block cache bytes. CacheSize int64 // MemTableSize configures Pebble memtable bytes. MemTableSize int64 // ReadOnly opens the engine without allowing writes or background compactions. ReadOnly bool }

该字段随后被传入pebbleOptions,成为底层pebble.Options的ReadOnly属性(见 db.go 第 165-210 行):

popts := &pebble.Options{ CacheSize: opts.CacheSize, MemTableSize: uint64(opts.MemTableSize), MemTableStopWritesThreshold: 4, L0CompactionThreshold: 8, L0StopWritesThreshold: 24, ReadOnly: opts.ReadOnly, BytesPerSync: platformBytesPerSync(), }

这里同时展示了引擎默认调优参数:MemTable 写停阈值为 4、L0 压缩阈值为 8、L0 写停深度为 24,每个 level 都配置了bloom.FilterPolicy(10)布隆过滤器。只读模式下 Pebble 不会执行后台压缩,因此这些压缩相关配置主要服务于读写模式,但ReadOnly传递链保证了上层无需关心物理细节。

只读语义的测试佐证

计划为 Task 1 提供了完整测试,仓库 engine_test.go 中TestOpenReadOnlyRejectsWrites已落地,其验证逻辑分四步:

  1. 以普通模式打开临时目录,写入k=v并提交、关闭;
  2. 以Options{ReadOnly: true}重新打开同一目录;
  3. 通过Get读回k,确认只读打开能读到已提交数据;
  4. 在只读句柄上新建 Batch 执行Set(预期仅暂存、不落盘),随后Commit(true)必须返回错误。
readOnly, err := Open(dir, Options{ReadOnly: true}) // ... got, ok, err := readOnly.Get([]byte("k")) // ... roBatch := readOnly.NewBatch() defer roBatch.Close() if err := roBatch.Set([]byte("next"), []byte("value")); err != nil { t.Fatalf("Set() should stage before Pebble rejects commit: %v", err) } if err := roBatch.Commit(true); err == nil { t.Fatal("Commit(read-only) = nil, want error") }

这个测试的关键洞察是:Set本身可能成功(仅暂存到内存 batch),真正被拒绝的是Commit——即写操作的拒绝点位于 Pebble 提交层,而非上层 API。这让"只读"承诺可信:即使上层误用了写 API,也无法持久化任何变更。

第二层:元数据与消息域的表级检查 API

引擎只读能力之上,计划要求在pkg/db/meta与pkg/db/message两个包内各自暴露"表局部"的只读扫描类型,复用包内已有的未导出表运行时句柄,不对外泄露内部存储格式。

元数据检查:InspectScan 的请求-结果契约

仓库 pkg/db/meta/inspect.go 已完整实现。核心契约如下:

// InspectRow contains one decoded metadata row. type InspectRow map[string]any // InspectCursor identifies the next row position for an inspection scan. type InspectCursor struct { HashSlot HashSlot Primary []any } // InspectScanRequest describes a bounded read-only metadata table scan. type InspectScanRequest struct { Table string HashSlot HashSlot HashSlotSet bool HashSlotCount uint16 Filters map[string]any After *InspectCursor Limit int } // InspectScanResult contains rows and resume metadata from an inspection scan. type InspectScanResult struct { Rows []InspectRow Next *InspectCursor Done bool ScannedRows int ScannedHashSlots []HashSlot }

InspectScan的默认行为(见 inspect.go 第 68-139 行):

  • 请求Limit <= 0时回退为 100;
  • 若HashSlotSet为 true,则只在指定分区内扫描;
  • 若未指定分区,则要求HashSlotCount非零,否则返回ErrInvalidArgument,随后以hash_slot, primary_key顺序执行local bounded scan(本地有界扫描);
  • 通过inspectScanTable泛型辅助函数将请求分发给具体的表运行时,并在扫描时对解码行施加Filters相等过滤,同时记录ScannedRows(过滤前扫描行数)与ScannedHashSlots(触碰过的分区)。

计划要求扫描"分区、行转换、本地有界扫描"全部基于包内已有的未导出表运行时(如userTable、channelTable、subscriberTable),仓库实现正是通过Table[R]泛型与ScanPrimary/ScanPrimaryPrefix完成,例如inspectScanPrimaryPage:

func inspectScanPrimaryPageR any ([]R, KeyParts, bool, error) { if len(prefix) > 0 { return table.ScanPrimaryPrefix(ctx, shard, prefix, cursor, limit) } return table.ScanPrimary(ctx, shard, cursor, limit) }

表覆盖:从计划到实现的演进

计划 Task 3 要求覆盖user、device、channel、subscriber、conversation、cmd_conversation、plugin_binding、channel_runtime_meta、channel_migration、hashslot_migration十张表。当前仓库实现进一步扩展了覆盖面,InspectScan的分发表还包含:

  • 用户/设备域:user、device;
  • 频道域:channel、channel_runtime_meta、subscriber、channel_latest、channel_migration;
  • 会话/成员域:user_channel_membership、user_cmd_channel_membership(计划中的conversation/cmd_conversation在此演进为成员关系表);
  • 插件域:plugin_binding;
  • 迁移域:hashslot_migration;
  • 消息更新与事件域:message_update、message_update_pending、message_update_head、message_update_request、message_event_state、message_event_cursor、message_event_applied、person_directory_task;
  • MQTT 域:mqtt_storage_ledger、mqtt_will、mqtt_source_binding、mqtt_inflight、mqtt_delivery_cursor、mqtt_subscription、mqtt_session。

每张表都有稳定的列映射函数,例如user表输出uid、token、device_flag、device_level、send_ban、send_ban_version;channel表输出channel_id、channel_type、ban、disband、send_ban、subscriber_mutation_version等。计划文档给出的行转换示例(如inspectChannelRow、inspectSubscriberRow)在仓库中均有对应实现,仅字段集合随表结构演进有所增减。

计划还要求InspectTables()暴露可检查表的模式,供 CLI 的show tables/describe使用,仓库中该函数直接返回Tables()(注册表模式集合)。

消息检查:目录与消息行

仓库 pkg/db/message/inspect.go 实现两个入口:

  • InspectChannels:扫描频道目录(catalog),每行输出channel_key、channel_id、channel_type。支持按AfterChannelKey续扫,底层通过encodeCatalogPrefix()构造前缀 span,并用迭代器做有界分页(注意:目录键是长度前缀编码的,遍历顺序并非原始字符串序,计划测试中特别注释了这一点)。
  • InspectMessages:扫描单个频道消息日志。ChannelKey为必填,空值直接返回ErrInvalidArgument;从AfterSeq+1起读,ReadOptions{Limit: req.Limit + 1}读取一个"预读行"用于判断是否还有下一页。消息行包含message_seq、message_id、client_msg_no、from_uid、server_timestamp_ms、payload_hash、payload_size、payload等列;payload以字节切片副本返回,payload_size为实际字节数。

计划测试TestInspectMessagesByChannelKeyAndCursor的语义在仓库中保持:第一页返回message_seq=1并给出Next.AfterSeq,第二页用该游标续读得到message_id=102,验证"按序分页"的完整性。

第三层:pkg/db/inspect 的解析、规划、游标与执行

这是整个方案的核心层,对应计划 Task 5 与 Task 6。它对外提供OpenStore、Store.Query,内部按"解析 → 规划 → 执行"三段式工作。

公共类型与只读 Store

pkg/db/inspect/types.go 定义了查询种类、选项、AST、结果与统计:

var ( ErrInvalidQuery = errors.New("inspect: invalid query") ErrUnsupportedQuery = errors.New("inspect: unsupported query") ErrCursorMismatch = errors.New("inspect: cursor mismatch") ErrHashSlotRequired = errors.New("inspect: hash slot required") ) type QueryKind uint8 const ( QueryShowTables QueryKind = iota + 1 QueryDescribe QuerySelect ) type Options struct { MetaPath string MessagePath string HashSlotCount uint16 DefaultLimit int MaxLimit int }

pkg/db/inspect/store.go 的OpenStore负责以只读方式打开元数据与消息两个物理存储,并统一管理关闭生命周期:

  • 若MetaPath与MessagePath均为空,返回db.ErrInvalidArgument;
  • 对每个非空路径,调用engine.Open(path, engine.Options{ReadOnly: true})打开引擎,再分别包装为meta.NewDB(eng)与message.NewDB(eng);
  • DefaultLimit缺省 100,MaxLimit缺省 10000(常量见 store.go 第 12-15 行);
  • Close使用errors.Join聚合两个引擎的关闭错误,且对消息侧优先关闭MessageDB(其内部会先释放使用计数),保证无泄漏。

SQL-like 子集:语法与拒绝规则

pkg/db/inspect/parser.go 实现了计划中的 SQL 子集解析。计划 Task 5 的测试覆盖了show tables、describe meta.user、select uid, token from meta.user where uid='u1' limit 20,并明确拒绝join。仓库实现采用词法分析(lex)加递归下降的方式,规则如下:

  • 支持三种语句:show tables、describe <table>(desc为别名)、select <cols> from <table> [where ...] [limit N] [cursor '...'];
  • 表名必须形如domain.table(如meta.user、message.message),分两段校验;
  • where只支持key = literal的相等过滤,多个条件用and连接,同一键不可重复出现;字符串字面量用单引号包裹,数字字面量解析为int64;
  • cursor后的游标必须以引号包裹,用于续页;
  • 明确拒绝join、group by、order by、offset,返回ErrUnsupportedQuery;
  • 单引号未闭合、列清单语法错误、limit 非正整数等均返回ErrInvalidQuery。

例如计划中的解析用例:

q, err := Parse("select uid, token from meta.user where uid='u1' limit 20") // q.Kind == QuerySelect, q.Table == "meta.user", q.Limit == 20 // len(q.Columns) == 2, q.Filters["uid"] == "u1"

规划器:扫描模式与哈希槽推导

pkg/db/inspect/planner.go 将 AST 转化为可执行计划。它定义了五种扫描模式:

扫描模式触发条件语义
point-partitionmeta 查询带分区键(如uid、channel_id)由分区键推导单个哈希槽,精确定位
explicit-partitionmeta 查询带hash_slot过滤用户显式指定分区,且校验hash_slot < HashSlotCount
local-boundedmeta 查询无分区键从hash_slot=0起按分区顺序做有界全量扫描
message-channelmessage.message表必须带channel_key过滤,扫描单频道消息日志
message-catalogmessage.channels表扫描频道目录

分区键映射表(partitionKeys)定义了哪些列参与哈希槽推导,例如meta.user → uid、meta.channel → channel_id、meta.subscriber → channel_id、message.message → channel_key。推导逻辑为:

p.HashSlot = hashslot.HashSlotForKey(value, opts.HashSlotCount)

其底层实现位于 pkg/hashslot/hashslottable.go 第 326-331 行:

func HashSlotForKey(key string, hashSlotCount uint16) uint16 { if hashSlotCount == 0 { return 0 } return uint16(crc32.ChecksumIEEE([]byte(key)) % uint32(hashSlotCount)) }

即使用crc32.ChecksumIEEE对键求 CRC32 后对槽数取模——这意味着只要--hash-slot-count与线上集群一致,wkdb就能推导出与运行期完全相同的分区位置。因此计划反复强调--hash-slot-count(或配置文件中的WK_CLUSTER_HASH_SLOT_COUNT)必须正确提供。

规划器还执行两类校验:

  • validateFilters:过滤键必须是该表已注册列(meta 域额外允许hash_slot);
  • validateCursorForPlan:游标的 domain、table、scanMode 必须与当前计划一致;meta 域游标的哈希槽必须小于槽数、且与显式分区一致;message-channel模式下游标的ChannelKey必须等于当前查询的channel_key。

计划中的三条规划测试在仓库中均有对应实现:TestPlanMetaPointPartitionFromUID(带uid过滤 → point-partition)、TestPlanMetaLocalBoundedRequiresHashSlotCount(无槽数 →ErrHashSlotRequired)、TestPlanMessageRequiresChannelKey(message.message无channel_key→ 报错)。

游标:不透明字符串与查询哈希校验

pkg/db/inspect/cursor.go 实现游标编解码。游标是不透明的 URL-safe Base64 字符串,其内部 JSON 负载结构如下:

type cursorPayload struct { Version int `json:"version"` Domain string `json:"domain"` Table string `json:"table"` ScanMode string `json:"scan_mode"` HashSlot uint16 `json:"hash_slot,omitempty"` Primary []any `json:"primary,omitempty"` ChannelKey string `json:"channel_key,omitempty"` AfterSeq uint64 `json:"after_seq,omitempty"` QueryHash string `json:"query_hash"` }

关键安全设计是query-hash 绑定:queryHash以表名|排序后的列|排序后的过滤键值对|limit拼接后做 SHA-256,得到十六进制摘要写入游标。解码时(decodeCursor)会重新计算当前查询的哈希并比对,不匹配即返回ErrCursorMismatch。计划测试TestCursorRejectsQueryMismatch验证了这一点:对同一张表换一个uid过滤值后使用旧游标,必须失败。这从根本上防止了"把 A 查询的游标用在 B 查询上"导致的错乱扫描。

另外,解码时使用json.Decoder.UseNumber()并将主键数字归一化为int64/uint64,避免 JSON 浮点表示破坏主键精度。

执行:结果投影与分页统计

pkg/db/inspect/execute.go 的Store.Query是总入口:

func (s *Store) Query(ctx context.Context, raw string) (Result, error) { q, err := Parse(raw) // ... switch q.Kind { case QueryShowTables: return s.showTables(), nil case QueryDescribe: return s.describe(q.Table) case QuerySelect: p, err := planQuery(s.opts, q) // ... return s.executePlan(ctx, p) } }

执行阶段值得注意的细节:

  • 投影校验与裁剪:validateProjection拒绝未注册列,projectRows按select指定列裁剪输出(*表示全列);
  • 过滤下推与本地过滤结合:meta 查询会将主键前缀过滤(如uid)构造为扫描前缀(inspectPrimaryPrefix)下推到表运行时,其余过滤条件在解码行上做相等匹配;message 域则在scanFilteredMessageCatalog/scanFilteredMessageChannel中循环翻页直至凑够limit或扫描结束;
  • 数值相等语义:valuesEqual将各种整数类型(int/uint/float64 整数值)归一化为带符号幅值比较,避免int64(3)与uint64(3)被误判为不相等;
  • 统计输出:Stats记录ScanMode、ScannedHashSlots、ScannedRows、ReturnedRows、HasMore与NextCursor,其中HasMore由底层Done取反,NextCursor仅在还有下一页时生成。

show tables的输出包含 meta 域全部注册表加上message.channels、message.message;describe输出列名与列类型(inspectColumnTypes中标注了 string、int64、uint64、uint8、bool、bytes、uint64_array、string_array 等类型),为 CLI 的表格渲染提供了元信息。

第四层:wkdb CLI 与配置解析

计划 Task 7 定义了cmd/wkdb的形态:query与repl两个子命令,以及config.go、output.go两个支撑模块。cmd/wkdb目录在当前仓库快照中尚未出现,但计划给出了完整设计,其行为契约如下。

命令行形态

计划 README 给出的示例命令(即该工具预期的最终使用方式):

wkdb --data-dir ./node-1 query "show tables" wkdb --data-dir ./node-1 --hash-slot-count 256 query "select * from meta.user where uid='u1'" wkdb --data-dir ./node-1 --hash-slot-count 256 query "select * from meta.user limit 100" wkdb --data-dir ./node-1 query "select * from message.channels limit 20" wkdb --data-dir ./node-1 query "select * from message.message where channel_key='g1:2' limit 50"

参数一览(来自 计划的 parseFlags 设计):

参数说明默认值
--config指向wukongim.conf的路径(KEY=value格式)空
--data-dir节点数据目录空
--meta-path元数据存储路径由># 1. 先看这台节点上有哪些可检查的表 wkdb --data-dir ./node-1 query "show tables" # 2. 查看 meta.user 的列结构与类型 wkdb --data-dir ./node-1 query "describe meta.user" # 3. 精确查一个用户(自动推导哈希槽) wkdb --data-dir ./node-1 --hash-slot-count 256 query "select * from meta.user where uid='u1'" # 4. 有界扫全量用户(首 100 行),用 next_cursor 续页 wkdb --data-dir ./node-1 --hash-slot-count 256 query "select uid from meta.user limit 100" # 5. 查看某个频道目录 wkdb --data-dir ./node-1 query "select * from message.channels limit 20" # 6. 按 channel_key 读某频道消息日志 wkdb --data-dir ./node-1 query "select message_seq,message_id,from_uid from message.message where channel_key='g1:2' limit 50"

测试与验证策略

计划将质量保证嵌入每个任务(TDD 流程:先写失败测试 → 运行确认编译失败 → 实现 → 验证 PASS → 提交),并在 Task 8 给出了最终验证矩阵:

# 聚焦测试:引擎只读 + meta/message inspect + inspect 解析规划 + CLI go test ./pkg/db/internal/engine ./pkg/db/meta ./pkg/db/message ./pkg/db/inspect ./cmd/wkdb -count=1 # 更广的单测 go test ./pkg/db/... ./cmd/wkdb -count=1

计划还特别设计了两个"防漂移"验证点:

  1. go run ./cmd/wkdb(不带参数)必须以非零退出并打印usage: wkdb [flags] <query|repl>,守护 CLI 入口契约;
  2. go test ./cmd/wukongim ./internal/app -run 'TestConfigApplyDefaultsDerivesStoragePathsFromDataDir|TestLoadConfig' -count=1,用于守护data-dir → data/channellog路径推导假设与运行期配置加载逻辑的一致性,防止存储路径约定漂移导致wkdb打开错误目录。

在当前仓库中,pkg/db/internal/engine、pkg/db/meta、pkg/db/message、pkg/db/inspect四个包的测试均已存在(如 engine_test.go、inspect/parser_test.go、inspect/planner_test.go、inspect/cursor_test.go、inspect/execute_test.go、inspect/store_test.go),cmd/wkdb的测试属于计划中待落地的部分。

小结

wkdb只读检查 CLI 是 WuKongIM 存储层可观测性的一块重要拼图,其设计体现了三个值得借鉴的原则:

  1. 只读即安全:把"禁止写入"下沉到 Pebble 引擎层,用Commit拒绝而非 API 拦截,从物理层面保证离线检查不会污染节点数据;
  2. 知识留在存储层,体验留在 CLI 层:表模式、分区扫描、哈希槽推导全部由pkg/db各包提供,cmd/wkdb只做参数与输出,降低了 CLI 与存储格式的耦合;
  3. 有界与可续:limit封顶、offset不支持、游标绑定查询哈希,既防止了无意识的全量扫描,又保证了分页的确定性与正确性。

对于任何需要"在不启动集群的前提下看清一个节点存了什么"的排障与巡检场景,这套以 wkdb 计划文档 为蓝图、以pkg/db各包为实现的方案,提供了完整的落地路径。当前仓库中核心层(引擎只读、meta/message 检查、inspect 解析/规划/执行)已经就绪,cmd/wkdb命令行外壳可按计划文档的 Task 7/8 继续补齐,最终形成一个"离线、只读、可脚本化"的节点存储检视工具。

  • 即时通讯
  • 后端

【免费下载链接】WuKongIM

More than just IM 不只是即时通讯(IM)

项目地址:https://gitcode.com/gh_mirrors/wu/WuKongIM
点击查看免费下载

相关推荐

上一篇:快速上手 AirBattery:Mac 上一键盯住所有 Apple 设备电量
下一篇:Prisma 生态中的 GraphQL Config 完全指南:.graphqlconfig 配置格式、schemaPath 与 endpoints 深度解析

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询