- 后端
- 即时通讯
- 社交
- 游戏开发
【免费下载链接】nakama
Scalable open-source game backend server: multiplayer, matchmaking, leaderboards, chat, and social features for games.
vellum 是一个用 Go 实现的有限状态转换器(Finite State Transducer,FST)库,核心能力是把[]byte键映射到uint64值,并支持按字典序枚举全部键。本文以 vendor 目录下的 vellum README 为主体,结合 vellum 源码 展开,讲解 FST 的构建、查询、序列化格式与命令行工具,帮助你理解这套被 Bleve 系全文索引(如 bluge/ice 段格式)广泛采用的核心数据结构,并能在自己的 Go 项目中直接复用。
vellum 是什么:一个面向生产环境的 FST 库
根据 README,vellum 实现了一个 FST(finite state transducer),具备两项核心能力:
- 在键(
[]byte)与值(uint64)之间建立映射; - 按字典序(lexicographic order)枚举全部键。
该实现还额外追求几个工程目标:
- 构建 FST 时内存使用有界(bounded memory use);
- 构建过程中即向底层 Writer 流式输出数据(streaming out FST data while building);
- 支持运行时 mmap 文件以承载超大型 FST(可选)。
包级别的 vellum.go 包注释 与 README 相互印证:整个库分为"构建"与"使用"两个阶段。构建阶段要求按键的字典序插入键值对,数据会流式写入底层 Writer,结束时必须调用Close();使用阶段可以通过Open()(磁盘 + mmap)或Load()(内存字节)载入 FST,然后执行Contains()、Get()与范围遍历。
从当前仓库的依赖关系看,vellum 作为 vendored 依赖随 nakama 一起分发(见 modules.txt),并被 Bleve 系的索引段实现 blugelabs/ice 所引用(如 ice/dict.go、ice/v2/dict.go 均依赖 vellum 的 FST 能力做词典查询)。
快速上手:构建一个 FST
构建 FST 的统一入口是New(),它接收一个io.Writer,FST 在构建过程中会尽可能早地把数据流式写入该 Writer。构建器有一个硬性约束:必须按字典序插入键,乱序插入会返回错误(源码中对应ErrOutOfOrder = errors.New("values not inserted in lexicographic order"),见 vellum.go,并在 builder.go 的 Insert 中通过bytes.Compare检查)。插入完最后一个键后,必须调用Close(),它会冲刷所有剩余数据到底层 Writer。
构建到内存
var buf bytes.Buffer builder, err := vellum.New(&buf, nil) if err != nil { log.Fatal(err) }构建到磁盘
f, err := os.Create("/tmp/vellum.fst") if err != nil { log.Fatal(err) } builder, err := vellum.New(f, nil) if err != nil { log.Fatal(err) }按字典序插入键值对
err = builder.Insert([]byte("cat"), 1) if err != nil { log.Fatal(err) } err = builder.Insert([]byte("dog"), 2) if err != nil { log.Fatal(err) } err = builder.Insert([]byte("fish"), 3) if err != nil { log.Fatal(err) } err = builder.Close() if err != nil { log.Fatal(err) }注意:Insert对键的字典序要求是"非递减"语义——源码中bytes.Compare(key, b.last) < 0才会报错,即相同键重复插入不会被拒绝;空键会被特殊处理,直接作为根节点的 final output(见 builder.go)。此外Insert内部会执行"找公共前缀 + 重设输出值 + 增量编译"三步(findCommonPrefixAndSetOutput→compileFrom→addSuffix),详见下文"构建原理"。
使用 FST:载入、查询与遍历
构建完成(Close()之后)产出的字节即可用于实例化 FST。
内存载入与磁盘打开
fst, err := vellum.Load(buf.Bytes()) if err != nil { log.Fatal(err) }fst, err := vellum.Open("/tmp/vellum.fst") if err != nil { log.Fatal(err) }两者的语义区别在 vellum.go 中有清晰体现:Open()默认走 mmap(把文件映射进地址空间,不整体读入内存),Load()则直接基于你提供的字节切片构造 FST。从 fst.go 的new()函数 看,二者最终都会解析 16 字节头部(版本号 + 类型)、按版本加载对应 decoder 并读取条目数len。
按键取值
val, exists, err = fst.Get([]byte("dog")) if err != nil { log.Fatal(err) } if exists { fmt.Printf("contains dog with val: %d\n", val) } else { fmt.Printf("does not contain dog") }源码中 FST.Get / FST.Contains 的实现要点:遍历过程中对每条命中的转移(transition)累加输出值,最终状态若为 final 则再加上该状态的 final output——这也解释了为什么 README 特别提醒"值为 0 不代表键不存在,必须看第二个返回值exists"。另外 FST 还提供了单线程专用的 Reader(带 prealloc 状态复用),以及 GetMinKey / GetMaxKey 用于快速拿到字典序最小/最大的键。
范围遍历
itr, err := fst.Iterator(startKeyInclusive, endKeyExclusive) for err == nil { key, val := itr.Current() fmt.Printf("contains key: %s val: %d", key, val) err = itr.Next() } if err != nil { log.Fatal(err) }迭代器的区间是左闭右开的(startKeyInclusive包含、endKeyExclusive不包含),遍历到区间末尾或 FST 末尾时返回ErrIteratorDone(定义见 vellum.go)。fst_iterator.go 中的FSTIterator实现了完整的 Iterator 接口:Current()(注意返回的键字节只在下次Next/Seek/Close前有效,需要长期保存必须自行拷贝)、Next()、Seek(key)(定位到指定键;键不存在时落到下一个更大的键)、Reset()(复用迭代器)与Close()。迭代内部维护状态栈/键栈/值栈,并在回溯时对单转移的线性后缀做批量弹出优化(见 fst_iterator.go)。
配合自动机做约束搜索
迭代器还支持传入自动机做过滤:fst.Search(aut Automaton, start, end)(见 fst.go)。automaton.go 定义了Automaton接口:Start()、IsMatch()、CanMatch()、WillAlwaysMatch()与Accept(state, byte),并提供了总是匹配的AlwaysMatch实现(nil自动机会在迭代器内部被替换为它,见 fst_iterator.go)。围绕该接口,vellum 自带三个可组合的自动机实现:
- regexp 子包:把正则表达式编译为 DFA 自动机,用于正则检索;
- levenshtein 子包:提供模糊匹配自动机,支持
FuzzyAutomaton(在 automaton.go 中扩展了EditDistance()与MatchAndDistance()),迭代器可据此报告命中键的编辑距离; - utf8 子包:在字节层面描述 Unicode 编码区间的自动机(详见下文"Unicode 字符串"一节)。
构建器的高级配置:BuilderOpts
New()的第二个参数是*BuilderOpts,允许高级用户定制构建行为(定义见 vellum.go):
| 字段 | 含义 | 默认值(builder.go) |
|---|---|---|
Encoder | 使用哪个编码器版本序列化 FST | 1(即 v1 编码器versionV1,见 encoder_v1.go) |
RegistryTableSize | 状态注册表(registry)哈希表大小,用于去重合并等价状态 | 10000 |
RegistryMRUSize | 注册表 MRU(最近使用)缓存的容量 | 2 |
传入nil时使用上面的默认配置。从 builder.go 的newBuilder可以看到:注册表按RegistryTableSize建表、按RegistryMRUSize维护最近命中缓存,它是构建阶段控制内存与去重效率的关键旋钮;Encoder则通过 encoding.go 的loadEncoder按版本号查注册表加载编码器,版本未被注册会报no encoder for version %d registered。构建器还提供Reset(w)复用同一 Builder 对象构建新 FST(见 builder.go)。
构建原理:增量编译、输出值分摊与状态合并
README 用"are/4、ate/2、see/3"三个键值对的四步示意图(docs/demo1.png至docs/demo4.png,当前仓库 vendor 目录未附带这些图片)讲解了构建的核心思路。结合 builder.go 源码,可以把原理归纳为三点:
1. 输出值沿路径分摊(output prefix/suffix)。插入"are"→4后,插入"ate"→2时,二者共享前缀"a"。findCommonPrefixAndSetOutput(builder.go)会把输出值分摊到转移上:outputPrefix取两个输出中较小者作为公共前缀输出,outputSub计算差值、outputCat做拼接(builder.go)。这样遍历时对每条转移的输出求和,依然能得到原始键对应的值——README 特意强调"转移上的值被调整过,使得遍历时求和仍得到预期值"。
2. 尚未确定的状态先挂起(unfinished stack)。Builder 内部维护一个"未完成节点"栈(unfinishedNodes,builder.go),新键的后缀先作为未冻结节点压栈;只有当后续插入不会再改动它们时(即已经可以确定该状态不会再有新转移加入),compileFrom才会把它们编译成最终状态。README 中的描述是:插入"ate"后状态 5 看似与状态 3 相同、状态 4 看似与状态 2 相同,但"还不能合并,因为未来的插入可能改变它们";直到插入"see"后,才确定状态 5、4 不会再变,于是用与之相同的状态 3、2 替换之。
3. 等价状态注册去重(registry + state pool)。compile(builder.go)先查注册表registry.entry(node),命中则直接复用已有地址,未命中才交给 encoder 编码新状态并登记地址;builderNode.equiv负责判断两个节点(final 标志、final 输出、转移的 in/addr/out)是否完全等价(builder.go)。这就是"状态 7、8 在Close()后安全替换为 2、3"的机制来源:Close()调用compileFrom(0)把所有挂起状态彻底冻结并走注册表去重(builder.go)。builder 节点还通过builderNodePool(单链表对象池,builder.go)与 unfinished 栈的 cache 反复复用,避免高频分配——这正是"内存使用有界"的工程基础。
序列化格式与编码器/解码器机制
README 提到序列化格式有专门文档(docs/format.md,当前仓库未附带该文件)。从源码可以确认 v1 格式的关键事实(encoding.go 与 encoder_v1.go):
- 文件以16 字节头部开头(
headerSize = 16):前 8 字节小端序版本号,后 8 字节类型(encoding.go、decodeHeader); - 编码器与解码器按版本注册进全局映射表(
registerEncoder/registerDecoder,encoding.go),FST 载入时按头部版本号反查加载对应 decoder,实现多版本兼容; - v1 编码器(encoder_v1.go)对状态做多种紧凑编码分支:空 final 状态直接返回地址 0;单转移且输出为 0、指向最近地址的状态用
transitionNext标志位(1<<6)压缩;其余走多转移编码encodeStateMany。转移计数用oneTransition = 1<<7位标志区分"单转移/多转移",final 状态用stateFinal = 1<<6标志;输出值使用变长打包(WritePackedUintIn,见 pack.go),末尾有 16 字节 footer(footerSizeV1 = 16)。解码侧由 decoder_v1.go 负责按相同位布局还原状态图。
这种"版本化头部 + 注册式编解码器 + 位级紧凑编码"的设计,使 vellum 文件既能保持较小的磁盘占用,又具备向前演进的余地。
mmap 与 nommap 构建标签
README 专门解答了"在没有 mmap 的系统上怎么办"。源码中 mmap 逻辑由构建标签隔离:
- 默认(vellum_mmap.go):
Open()通过mmap.Map(f, mmap.RDONLY, 0)只读映射整个文件,mmapWrapper.Close()负责 Unmap 并关闭底层文件句柄(vellum_mmap.go),从而支持超大型 FST 而不整体占用进程内存; - 无 mmap 环境(vellum_nommap.go):使用
nommap构建标签编译时,Open()会把整个文件读入内存再Load()。注意:此模式下整个 FST 会被完整读入内存。
# 在无 mmap 的系统上构建 vellum go build -tags nommap若通过Open()打开了 FST,使用完毕务必调用fst.Close()——它负责 Unmap 并关闭文件(见 fst.go,源码注释明确要求"任何创建的 FST 实例都必须调用 Close()")。
Unicode 字符串如何使用
vellum 是字节级的实现:FST 的转移单位是单个byte而不是 rune。README 明确说明:可以用 Unicode 字符串,但该实现只认识你选择的字节表示,要匹配成功必须使用某种规范化的字节表示(canonical byte representation);未来可能在底层字节转移之上做编码感知的遍历。
为支持上层按编码区间匹配,库内提供了 utf8 子包:它把 Unicode 码点区间编码成字节级自动机,可结合Search实现按 Unicode 字符(而非逐字节)的约束遍历。例如可以用它构建"匹配任意汉字区间"之类的字节自动机,再交给 FST 迭代器过滤。
命令行工具与状态图可视化
README 指出 cmd/vellum 子目录(当前仓库未附带该子目录)提供了一个命令行工具,包含若干子命令用于创建、检查和查询 vellum 文件。其中dot子命令可以从 vellum 文件生成 Graphviz DOT 格式的状态转移图,再交给 graphviz 工具转成图片:
vellum dot myFile.vellum > output.dot dot -Tpng output.dot -o output.png这是排查 FST 结构、调试状态合并效果最直观的手段:dot子命令导出文本格式的 DOT 描述,dot -Tpng负责渲染为 PNG 图片。
合并多个 FST:Merge
除了单机构建,vellum.go 还提供Merge(w, opts, itrs, f):遍历传入的多个Iterator,对重复键调用MergeFunc决定合并后的值,再把结果流式构建到新的 Writer。其内部组合了 merge_iterator.go 的多路归并迭代器与标准 Builder 流程。这在多段索引(如 bluge/ice 的段合并)场景中非常实用——多个旧段各自的 FST 可以按键归并成一个新段 FST。
本项目中的定位与参考实现脉络
在当前仓库中,vellum 是随 nakama 一起 vendored 的第三方依赖(vendor/github.com/blevesearch/vellum/ 目录),主要服务于 Bleve 系全文索引链路——blugelabs/ice 及其 v2(ice/v2)的词典(dict)与 posting 文件都依赖 vellum 的 FST 做键值映射与有序枚举,ice自身的 README 也将其列为核心依赖。因此理解 vellum 的构建/查询语义,是理解 nakama 所携带的全文索引栈工作方式的基础。
从 README 的"项目由来"一节可以看出其设计脉络:作者在 Bleve 项目中意识到 FST 对搜索类任务的价值,最初参考了 mafsa 项目,但 mafsa 不在构建时流式落盘、且以 rune 为转移单位、又已停止维护;因此 vellum 改为以 byte 为转移单位并支持流式构建,后续又吸收了 BurntSushi/fst 的诸多技术。这也解释了本文前面反复强调的三个设计事实:流式 Writer、字节级转移、构建期增量冻结与状态去重。
- 后端
- 即时通讯
- 社交
- 游戏开发
【免费下载链接】nakama
Scalable open-source game backend server: multiplayer, matchmaking, leaderboards, chat, and social features for games.
相关推荐
Go 语言 FST 构建与检索实战:深入 vellum 有限状态转换器库
Go 语言 FST 构建与检索实战:深入 vellum 有限状态转换器库 vellum 是一个用 Go 实现的 FST(Finite State Transdu
后端认证鉴权数据库无服务开发工具云原生OpenCloud 中的 vellum:Go 语言实现的有限状态转换器(FST)构建、序列化与查询指南
OpenCloud 中的 vellum:Go 语言实现的有限状态转换器(FST)构建、序列化与查询指南 导读 本文围绕 OpenCloud 仓库中随 Bleve
后端微服务存储认证鉴权gh-ost 依赖剖析:numcpus——Go 跨平台 CPU 数量查询库
gh ost 依赖剖析:numcpus——Go 跨平台 CPU 数量查询库 导读 本文围绕 gh ost 仓库 vendored 依赖 github.com/t
数据库运维
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考