- 即时通讯
- 后端
【免费下载链接】WuKongIM
More than just IM 不只是即时通讯(IM)
本指南基于仓库文档 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已落地,其验证逻辑分四步:
- 以普通模式打开临时目录,写入
k=v并提交、关闭; - 以
Options{ReadOnly: true}重新打开同一目录; - 通过
Get读回k,确认只读打开能读到已提交数据; - 在只读句柄上新建 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-partition | meta 查询带分区键(如uid、channel_id) | 由分区键推导单个哈希槽,精确定位 |
explicit-partition | meta 查询带hash_slot过滤 | 用户显式指定分区,且校验hash_slot < HashSlotCount |
local-bounded | meta 查询无分区键 | 从hash_slot=0起按分区顺序做有界全量扫描 |
message-channel | message.message表 | 必须带channel_key过滤,扫描单频道消息日志 |
message-catalog | message.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 给出了最终验证矩阵: 计划还特别设计了两个"防漂移"验证点:
在当前仓库中, 小结
对于任何需要"在不启动集群的前提下看清一个节点存了什么"的排障与巡检场景,这套以 wkdb 计划文档 为蓝图、以 赞
【免费下载链接】WuKongIMMore than just IM 不只是即时通讯(IM) 项目地址:https://gitcode.com/gh_mirrors/wu/WuKongIM 相关推荐
上一篇:快速上手 AirBattery:Mac 上一键盯住所有 Apple 设备电量 下一篇:Prisma 生态中的 GraphQL Config 完全指南:.graphqlconfig 配置格式、schemaPath 与 endpoints 深度解析 创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考 |