Tendermint 设计哲学解读:可读性、封装与并发优先的工程原则
Tendermint 设计哲学解读:可读性、封装与并发优先的工程原则
本篇文章以 Tendermint 仓库根目录下的 PHILOSOPHY.md 为骨架,系统梳理 Tendermint Core 在可读性、性能取舍、模块封装与多核并发方面的核心设计目标与工程准则,并结合 types.ValidatorSet、libs/rand.Rand、libs/sync、共识 Reactor 等真实源码实现进行佐证。读完本文,你将理解 Tendermint 为什么坚持"defer 解锁、少用 channel、控制 goroutine 数量"等看似保守的编码约束,并能在自己的 Go 项目中复用它"先正确、可调试,再谈性能"的设计方法论。
一、Tendermint 的六大设计目标
文档开篇即明确:Tendermint(以及 Cosmos SDK 和相关库)的设计目标共有六条,它们是后续所有工程取舍的总纲:
- Simplicity and Legibility(简洁与可读性)
- Parallel performance(并行性能,即充分利用多核架构的能力)
- Ability to evolve the codebase bug-free(让代码库在演进过程中保持无 bug)
- Debuggability(可调试性)
- Complete correctness that considers all edge cases, esp in concurrency(完整性正确性,尤其要考虑并发的所有边界情况)
- Future-proof modular architecture, message protocol, APIs, and encapsulation(面向未来的模块化架构、消息协议、API 与封装)
注意,这里"并行性能"被列在第二位,且它的前缀是"可读性"。这与通常"性能至上"的工程直觉相反,原因是:只有可读的代码才能安全地演进、才能被快速调试,最终才有可能持续优化。六条目标共同指向一个结论——正确性与可维护性优先于绝对性能。
二、理由:可读性是长期维护的第一生产力
2.1 defer 解锁:防止未来重构引入死锁
文档给出了一个极具代表性的反例:
func (obj *MyObj) something() {
mtx.Lock()
obj.something = other
mtx.Unlock()
}
这段代码当前是正确的,但它埋下了隐患:未来某次重构若把 other 替换成 other.String(),而这个表达式内部又试图获取同一把锁,就会引入死锁。由于锁的释放点与加锁点分离,这类 bug 很难通过目视检查发现。
因此文档给出的准则是:尽可能使用 defer 语句解锁,即使它带来额外开销:
mtx.Lock()
defer mtx.Unlock()
如果确实需要提前释放锁以优化性能,正确的做法不是手动提前 Unlock,而是通过更小的函数拆解来缩小 defer 的作用域——把"需要提前解锁"的临界区提取成独立的小函数,让 defer 在该函数返回时自然解锁。
这一准则在仓库源码中得到了系统性贯彻。以共识核心 consensus/state.go 为例,几乎所有状态访问方法都遵循"加锁后立即 defer 解锁"的写法,例如 GetState、GetLastHeight、GetRoundState、GetValidators 等(参见 consensus/state.go#L220-L262):
func (cs *State) GetState() sm.State {
cs.mtx.RLock()
defer cs.mtx.RUnlock()
return cs.state.Copy()
}
共识 Reactor 的 PeerState 同样如此,GetRoundState、ToJSON、GetHeight、SetHasProposal 等方法全部是 Lock() 后紧跟 defer mtx.Unlock()(参见 consensus/reactor.go#L1070-L1119)。在 consensus/reactor.go 中搜索 defer ps.mtx.Unlock() 可以找到数十处同类写法,这并非偶然,而是设计哲学的落地。
2.2 惯用 for 循环优先于自定义计数器
类似的演进风险也存在于循环中:文档强调,应始终优先使用惯用的 for 循环(如 for range),而不是依赖自定义计数器。因为循环体随着需求演进会变得越来越复杂,使用自定义计数器时,目视检查循环正确性的难度会越来越大;而惯用写法能让循环边界与迭代语义一目了然。
2.3 配套的调试基建
可调试性在工程上还有更具体的支撑。仓库的 Makefile 支持通过 TENDERMINT_BUILD_OPTIONS=race 构建带竞态检测的二进制(Makefile#L24-L28):
# handle race
ifeq (race,$(findstring race,$(TENDERMINT_BUILD_OPTIONS)))
CGO_ENABLED=1
BUILD_FLAGS += -race
endif
这为"并发正确性优先"提供了可验证的手段:在开发与测试阶段用 -race 构建运行,即可在数据竞争发生的第一时间暴露问题,从而把并发边界情况纳入正确性保障。
三、关于性能:不做 2x 性能的奴隶,做标准规范的实现者
这是全文最核心、也最容易被误解的一段。Tendermint 的立场是:
- 性能让步于正确性:即使存在比当前实现快 2 倍或 3 倍的替代实现,只要软件不能正常工作、会死锁、或者 bug 无法调试,那么这些性能优势就没有意义。
- 多核并发的下限保证:通过充分利用多核并发,Tendermint 的实现"至少能落在理论可能性能的一个数量级范围内"。
- Go 是手段而非终点:选择 Go 是因为可以用比其他语言更快的速度开发整条模块化技术栈。Tendermint Core 永远不会比未来用 Rust、Java、C++ 编写的 Tendermint Spec 实现更快,因为 Go 本身就不是为极限速度设计的;Tendermint 的真正价值是成为并发 BFT 软件的标准规范实现(standard specification)——通过聚焦消息协议(如 ABCI、p2p 消息)与封装(如 IAVL 模块、相对独立的 reactor),为未来在更可优化的语言中的再实现提供可参照的标准。
- 真正的瓶颈在应用层:文档明确指出,性能瓶颈通常在应用层,而且并不需要支持超出"充分去中心化"规模的验证人集合——约 100~300 个验证人(配合委托型 bonded PoS)就足够了。
- 优化方向的重定向:与其把 Tendermint 优化到贴近金属(down to the metal),不如把精力放在"交付功能完整的软件、使其足够好用、可调试、可维护、可充当未来实现的规范"上。
四、关于封装:可复用、易用对的结构块
4.1 结构级 mutex 的取舍
为了创建可维护、可前瞻优化的软件,关键路径是:开发封装良好、性质清晰可理解的对象,并把它们作为构建块,组合成更高层的封装对象(meta-objects)。
文档给出的判断是:在没有 goroutine 竞争(contention)的情况下,mutex 的开销对 Tendermint 的设计目标而言足够便宜。因此它鼓励直接使用结构级 mutex 构建并发安全的结构:
- 用于非并发逻辑时:性能足够好;
- 用于并发逻辑时:行为仍然正确。
即"一个结构声明,两种场景通吃"。文档点名了两个典型例子:types.ValidatorSet 和 rand.Rand。
4.2 实例佐证:types.ValidatorSet 的副本语义
从源码看,types/validator_set.go 中 ValidatorSet 的注释明确写着 "NOTE: Not goroutine-safe."(参见 types/validator_set.go#L49-L58),也就是说它本身不内置 mutex,而是通过约定来保证安全:所有 get/set 操作都返回值的副本(copy)。
这一约定的执行非常彻底。例如:
GetByAddress与GetByIndex都返回val.Copy(),调用方拿到的是快照而非内部引用(types/validator_set.go#L270-L289);GetProposer()同样返回vals.Proposer.Copy()(types/validator_set.go#L325-L333);- 需要修改提议人优先级时,提供
CopyIncrementProposerPriority——在副本上做增量并返回新集合,原始集合不受影响(types/validator_set.go#L105-L111)。
因此,ValidatorSet 是一把"易于用对的锁":它把并发保护的责任外包给调用方,通过值拷贝这种最简单的安全方式规避了共享可变状态的复杂度。共识状态访问层在使用它时,则统一在外层加 cs.mtx 读写锁并返回副本(如 consensus/state.go#L257-L262 的 GetValidators 返回 cs.state.Validators.Copy().Validators)。
4.3 实例佐证:rand.Rand 的 mutex 保护
文档用大量篇幅讨论了 rand.Rand。背景是 Go 标准库的说明:默认的包级 Source 可以被多个 goroutine 并发安全地使用(因为它受 lockedSource 保护),但由 NewSource 创建的 Source 不是并发安全的。
Tendermint 的结论是:不应依赖全局 Source,而应创建自己的 Rand/Source 实例(尤其为了测试的确定性),并用 mutex 保护它。仓库中的实现位于 libs/rand/random.go:
type Rand struct {
tmsync.Mutex
rand *mrand.Rand
}
- 它内嵌了
tmsync.Mutex,注释明确写着 "All of the methods here are suitable for concurrent use. This is achieved by using a mutex lock on all of the provided methods."(libs/rand/random.go#L15-L25); - 种子来自
crypto/rand的 OS 随机性(cRandBytes),内部使用math/rand的 PRNG,但所有方法都不适合密码学用途(libs/rand/random.go#L15-L18); - 每个方法(
Seed、Int63、Intn、Perm、Bytes等)都在Lock/Unlock保护下访问内部rand字段,同时对外暴露了全局便捷函数(Seed、Str、Intn等)与实例方法两套 API。
文档还解释了为什么要自研而非直接用标准库:有时你想知道代码里哪里用到了 Rand,这时只需插入一行日志即可对 Rand 的使用注入可观察性(inspectability);此外还能用自定义方法扩展 Rand 的功能。这正体现了"封装服务于可调试性"。
4.4 公开 vs 未公开方法:抽象边界的信号
封装的另一个关键点是暴露(exported)与未暴露(unexported)方法的选择。代码读者应当一眼看出:哪些方法在什么场景下使用、什么用法是安全的。文档指出,这部分的解决之道包括:
- 用未导出方法隐藏实现细节;
- 在方法命名约定(如下划线)上配合良好的文档与代码组织;
- 如果暴露的方法太多、副作用不清晰,说明抽象设计有问题,应当回头重新设计。
五、关于并发:多核架构是长期竞争力的前提
Tendermint 要保持长期相关性,就必须利用好多核架构。问题本身的形态决定了这一点无法回避:
- 共识发生在并发的 p2p gossip 网络上;
- RPC 需要同时处理大量订阅者(subscriber)的请求。
因此并发设计能力是 Tendermint 开发的硬性要求,尤其体现在 reactor 设计与 RPC 请求处理上。
在源码中,并发模型是"有限的、每 peer 常数个 goroutine"。以共识 Reactor 为例,AddPeer 为每个新 peer 启动三个固定 goroutine(consensus/reactor.go#L199-L201):
go conR.gossipDataRoutine(peer, peerState)
go conR.gossipVotesRoutine(peer, peerState)
go conR.queryMaj23Routine(peer, peerState)
外加一个全局的 updateRoundStateRoutine 定时刷新 RoundState 快照(consensus/reactor.go#L539-L551)。由于 peer 数量本身受配置上限约束,goroutine 总量是可控的常数倍。
六、工程指南:为(足够的)性能与并发而设计
文档给出了一份可直接照做的行动清单:
- 没有竞争时,mutex 锁足够便宜,不要为无谓的竞争优化而牺牲简单性;
- 没有分析或观测证据证明某段代码在热路径(hot path)上,就不要优化它;
- 不要过度使用 channel——当"mutex 锁 + 封装"足够时,优先用锁;
- "需要 drain channel"往往是未考虑到的边界情况的信号,遇到时应反思并发设计而非打补丁;
- 创建 O(N) 的一次性 goroutine 通常是技术债,要尽早处理;避免用大量 goroutine 修补不完整的并发设计,至少要意识到这笔债并停止继续投入。另一方面,Tendermint 设计上只支持有限数量的 peer(例如 10 或 20 个),所以"每个 peer 常数个(O(C))goroutine"总量仍是 O(C×P)=常数;
- 尽可能用 defer 解锁;想提前解锁时,就拆出更多使用 defer 的模块化小函数。
关于 peer 数量的配置依据,在 config/config.go 中可以看到默认值:max_num_inbound_peers 默认 40、max_num_outbound_peers 默认 10(config/config.go#L603-L604),且均不允许为负数(config/config.go#L638-L642);p2p 的 Switch 会依据这两个上限拒绝超额连接(p2p/switch.go#L726-L731)。这也印证了"goroutine 总量受 peer 上限约束、保持常数"的设计。
七、格言:团队的工程价值观
文档最后以六条格言收束,它们是上述所有原则的高度凝练:
- Premature optimization kills(过早优化是万恶之源)
- Readability is paramount(可读性至高无上)
- Beautiful is better than fast(优美优于快速)
- In the face of ambiguity, refuse the temptation to guess(面对歧义,拒绝猜测的诱惑)
- In the face of bugs, refuse the temptation to cover the bug(面对 bug,拒绝掩盖 bug 的诱惑)
- There should be one-- and preferably only one --obvious way to do it(做一件事应当只有一种显而易见的方式)
八、总结:从哲学到代码的落地闭环
回顾整篇 PHILOSOPHY.md,Tendermint 的设计哲学可以概括为一条清晰的决策链:
- 可读性与可调试性是正确性长期演进的基石(对应"defer 解锁"与"惯用 for 循环"的编码铁律);
- 性能是"足够好"而非"极限好",通过多核并发保证一个数量级内的差距,同时把精力投向应用层与生态;
- 封装通过"结构级 mutex + 值拷贝 + 公开/未公开方法纪律"制造容易用对、难以用错的构建块(
ValidatorSet、rand.Rand即为范本); - 并发通过"有限 peer、每 peer 常数个 goroutine、defer 解锁、少用 channel"保持可验证性;
- 测试基建(
-race构建)为并发正确性兜底。
这套哲学不是为了写文档而写文档——从 consensus/state.go、consensus/reactor.go、types/validator_set.go、libs/rand/random.go 到 libs/sync/sync.go 与 libs/sync/deadlock.go(后者通过 build tag 引入 go-deadlock 实现在调试构建中的死锁检测),每一处源码都能与本文的原则一一对应。对任何希望在 Go 中构建长生命周期、高并发、可维护系统的开发者而言,这份文档都是一份值得反复对照的工程范本。