基于历史证明(PoH)共识的区块链系统实现
Mini-Solana 是一个学习性质的 Solana 区块链简化实现,旨在帮助开发者深入理解 Solana 的核心架构和设计理念。
为什么创建这个项目?
- 学习 Solana 独特的 PoH (Proof of History) 和 Tower BFT 共识机制
- 理解高性能区块链的流水线架构设计
- 掌握 Rust 在分布式系统中的应用实践
实现范围: 本项目实现了 Solana 的核心子系统,包括 PoH 时钟、交易处理流水线、多节点共识、Gossip 网络发现等,但简化了分片(Shred)、Turbine 传播树等生产级优化。
# 克隆仓库
git clone https://github.com/Cishoon/mini-solana
cd mini-solana
# 编译所有 crate
cargo buildcd mini-solana/validator
./init.sh
./create-genesis.sh
./run.sh 1
# 支持多验证节点,可开启另一个窗口运行 ./run.sh 2 ./run.sh 3打开新终端,使用客户端与验证器交互:
# 生成新钱包
cargo run -p mini-solana-client -- keygen -a alice
# 查看公钥
cargo run -p mini-solana-client -- pubkey -a alice
# 查询余额
cargo run -p mini-solana-client -- balance -a alice
# 从 faucet 转账给 alice(需要先获取 alice 的公钥)
cargo run -p mini-solana-client -- transfer -a validator <alice_pubkey> 100000- PoH (Proof of History) - 可验证延迟函数实现全局时钟,通过连续 SHA-256 哈希生成不可伪造的时间链
- Tower BFT 投票机制 - 基于 lockout 的分叉选择,防止验证者快速切换分叉
- ForkChoice 分叉权重计算 - 基于 stake 权重选择最佳分叉
- TPU (Transaction Processing Unit) - 交易处理流水线,支持 Leader/Forwarding 双模式
- Bank 账户状态管理 - 原子执行交易并维护账户余额
- BankForks 分叉管理 - 多分叉状态隔离,支持并行处理
- PohRecorder - 将交易批量混入 PoH 哈希链,生成 Entry
- BroadcastStage - Entry 签名与网络广播
- Leader 调度 - 基于 stake 的轮换机制,每 4 个 slot 轮换一次
- TVU (Transaction Validation Unit) - 区块验证流水线
- VerifyStage - 验证 Entry 的 Leader 签名是否与 Leader Schedule 匹配
- RetransmitStage - Entry 转发与 Blockstore 存储
- ReplayStage - PoH 验证与交易重放,更新 BankForks
- Gossip 协议 - 节点发现与状态同步
- CRDS (Cluster Replicated Data Store) - 集群数据存储,维护 ContactInfo、EpochSlots、Vote
- EpochSlots - 节点 slot 进度广播
- Repair Service - 缺失 slot 修复,保证数据完整性
- Blockstore - 按 slot 的 Entry 持久化存储
- SignedEntry - 带 Leader 签名的 Entry 存储格式
- 账本重放 - 启动时从创世块重建状态
- JSON-RPC 服务 - 提供 getBalance、sendTransaction、getRecentBlockhash 等接口
- CLI 客户端 - 支持 keygen、balance、transfer 命令
Mini-Solana 采用 Cargo workspace 组织,包含三个 crate:
| Crate | 职责 |
|---|---|
| mini-solana-sdk | 核心类型定义:Pubkey、Keypair、Transaction、Entry、Hash 等基础数据结构 |
| validator | 验证器节点:PoH 生成、交易处理、区块验证、Gossip 网络、账本存储 |
| client | CLI 客户端:密钥管理、余额查询、转账操作 |
Validator 内部采用模块化设计,主要分为以下几个子系统:
- Core - 核心逻辑:Bank 状态管理、PoH 时钟、TPU/TVU 流水线、Tower 共识
- Gossip - 网络层:节点发现、元数据同步、投票传播
- Ledger - 存储层:Blockstore 持久化、Slot 元数据管理
- RPC - 接口层:JSON-RPC 服务,供客户端调用
graph TB
subgraph Client["Client (CLI)"]
CLI[CLI Commands]
RpcClient[RPC Client]
end
subgraph SDK["mini-solana-sdk"]
Types[Core Types]
Crypto[Crypto Primitives]
end
subgraph Validator["Validator"]
subgraph TPU["TPU (Leader Mode)"]
TpuReceiver[TPU Receiver]
PohRecorder[PoH Recorder]
PohService[PoH Service]
BroadcastStage[Broadcast Stage]
end
subgraph TVU["TVU (Follower Mode)"]
TvuReceiver[TVU Receiver]
VerifyStage[Verify Stage]
RetransmitStage[Retransmit Stage]
ReplayStage[Replay Stage]
end
subgraph Core["Core"]
Bank[Bank]
BankForks[BankForks]
Tower[Tower BFT]
ForkChoice[Fork Choice]
LeaderSchedule[Leader Schedule]
end
subgraph Network["Network"]
Gossip[Gossip Service]
CRDS[CRDS]
Repair[Repair Service]
end
subgraph Storage["Storage"]
Blockstore[Blockstore]
end
RPC[JSON-RPC Server]
end
CLI --> RpcClient
RpcClient --> RPC
RPC --> TpuReceiver
TpuReceiver --> PohRecorder
PohRecorder --> PohService
PohService --> BroadcastStage
BroadcastStage --> Blockstore
BroadcastStage --> TvuReceiver
TvuReceiver --> VerifyStage
VerifyStage --> RetransmitStage
RetransmitStage --> Blockstore
RetransmitStage --> ReplayStage
ReplayStage --> BankForks
PohRecorder --> Bank
ReplayStage --> Tower
Tower --> ForkChoice
ForkChoice --> BankForks
Gossip --> CRDS
Repair --> Blockstore
Repair --> CRDS
SDK --> Validator
SDK --> Client
在传统区块链(如比特币、以太坊)中,节点之间没有统一的时钟。当两笔交易几乎同时发生时,不同节点可能看到不同的顺序。为了解决这个问题,传统方案需要:
- 比特币:等待 10 分钟出一个块,用"最长链"来确定顺序
- 以太坊:等待 12 秒出一个块,通过多轮通信达成共识
这些方案的共同问题是:需要等待网络通信来确认时间顺序,速度慢。
PoH (Proof of History) 的核心思想是:用计算来证明时间的流逝。
想象一下,如果我让你连续做 100 万道数学题,每道题的答案都依赖上一道题的结果:
题目1: 计算 SHA256("起始值") → 答案1
题目2: 计算 SHA256(答案1) → 答案2
题目3: 计算 SHA256(答案2) → 答案3
...
题目100万: 计算 SHA256(答案999999) → 答案100万
由于每道题必须等上一道题算完才能开始(SHA-256 无法并行),所以:
- 如果你给我看答案100万,我就知道你确实花了时间算完了前面所有题
- 我可以快速验证:验证阶段可以并行
- 任何人都无法伪造:你不可能跳过中间步骤直接得到答案100万
这就是 PoH 的本质:一条用 CPU 算力"烧"出来的时间链。
PoH 引擎维护一个不断增长的哈希链,支持三种操作:
┌─────────────────────────────────────────────────────────────────────────────┐
│ PoH 哈希链示意图 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ [Hash] [Hash] [Hash] [Record] [Hash] [Tick] │
│ │ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ ▼ │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │H1 │ ───► │H2 │ ───► │H3 │ ───► │H4 │ ───► │H5 │ ───► │H6 │ │
│ └───┘ └───┘ └───┘ └─┬─┘ └───┘ └───┘ │
│ │ │ │
│ ▼ ▼ │
│ 交易混入 时间检查点 │
│ (Entry) (Tick Entry) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| 模式 | 公式 | 说明 | 产出 |
|---|---|---|---|
| Hash | H_next = SHA256(H_current) |
纯粹的时间流逝,像时钟滴答 | 无 |
| Record | H_next = SHA256(H_current + 交易哈希) |
把交易"盖章"到时间链上 | Entry(含交易) |
| Tick | H_next = SHA256(H_current) |
每隔固定次数产生一个时间检查点 | Tick Entry(空) |
当交易被 Record 进哈希链时,会产生"雪崩效应":
假设原本的哈希链是:
H1 → H2 → H3 → H4 → H5
在 H2 之后插入交易 Tx:
H1 → H2 → H2' → H3' → H4' → H5'
↑
Tx 混入
H2' = SHA256(H2 + Tx),之后所有哈希都变了!
这意味着:
- 交易 Tx 被永久锚定在 H2 和 H3' 之间
- 任何人都可以验证 Tx 确实发生在这个时间点
- 无法篡改:如果有人想把 Tx 移到别的位置,整条链都会对不上
你可能会问:为什么要浪费 CPU 算哈希?直接 sleep(1秒) 不行吗?
答案是:sleep 可以作弊。
如果用 sleep,恶意节点可以:
- 关掉 sleep,全速运算
- 瞬间生成未来 10 年的所有区块
- 网络无法分辨哪个是"真历史"
而 PoH 的哈希链无法加速——你必须一个一个算,这就是"Proof of History"名字的由来:哈希链本身就是时间流逝的证明。
// 简化的 PoH 工作流程
loop {
// 1. 推进时间(批量哈希提高效率)
let need_tick = poh.hash(100); // 连续算 100 次哈希
if need_tick {
// 到达 Tick 边界,必须先产生时间检查点
let tick_entry = poh.tick();
broadcast(tick_entry);
}
// 2. 有交易时,把交易"盖章"到时间链上
if let Some(tx) = recv_transaction() {
let entry = poh.record(tx.hash());
broadcast(entry);
}
}| 特性 | 比特币 | 以太坊 | Solana (PoH) |
|---|---|---|---|
| 出块时间 | ~10 分钟 | ~12 秒 | ~400 毫秒 |
| 时间证明 | 工作量证明 (PoW) | 权益证明 (PoS) | 历史证明 (PoH) |
| 确定顺序 | 等待多个确认 | 等待最终性 | 哈希链即顺序 |
| 吞吐量 | ~7 TPS | ~30 TPS | ~65,000 TPS |
PoH 让 Solana 能够在不等待网络共识的情况下确定交易顺序,这是其高性能的关键。
TPU (Transaction Processing Unit) 是交易处理的核心流水线,负责接收、执行、打包和广播交易:
| 阶段 | 职责 | 输入 | 输出 |
|---|---|---|---|
| RPC Server | 接收客户端请求,解码交易 | Base58 编码的交易 | Transaction 对象 |
| PohRecorder | 将交易混入 PoH 哈希链,生成 Entry | Transaction | Entry |
| Bank | 原子执行交易,更新账户状态 | Transaction | 状态变更 |
| BroadcastStage | 签名 Entry,写入 Blockstore,广播给其他节点 | Entry | SignedEntry |
当一笔交易到达 RPC Server 后,经历以下步骤:
// 1. RPC 接收并解码交易
let tx: Transaction = bincode::deserialize(&tx_bytes)?;
// 2. 检查是否是 Leader
if poh_recorder.has_bank() {
// 是 Leader:直接处理
tx_recorder.record_transactions(vec![tx])?;
} else {
// 不是 Leader:转发给当前 Leader
forward_transaction(&tx_bytes)?;
}
// 3. PohRecorder 处理交易
// a) 计算交易哈希
let tx_hash = hash_transaction(&transactions);
// b) 混入 PoH 哈希链
let poh_entry = poh.record(tx_hash);
// c) 构造 Entry
let entry = Entry { num_hashes, hash, transactions };
// d) Bank 执行交易
bank.process_entry(&entry)?;
// e) 发送 Entry 给 BroadcastStage
entry_sender.send(entry)?;
// 4. BroadcastStage 处理 Entry
// a) 签名 Entry
let signed_entry = SignedEntry::new(slot, entry, keypair);
// b) 写入 Blockstore
blockstore.write_signed_entry(&signed_entry)?;
// c) 广播给其他验证者
broadcast_to_peers(&signed_entry)?;Bank 是账户状态管理器,负责原子执行交易:
pub fn process_transaction(&mut self, tx: &Transaction) -> Result<(), String> {
// 1. 验证 recent_blockhash(防止重放攻击)
if !self.is_blockhash_valid(&tx.message.recent_blockhash) {
return Err("Invalid recent blockhash");
}
// 2. 验证签名
if !tx.verify_signatures() {
return Err("Invalid signature");
}
// 3. 克隆账户状态(用于原子性回滚)
let mut accounts_snapshot = self.accounts.clone();
// 4. 执行所有指令
for ix in &tx.message.instructions {
process_instruction(&mut accounts_snapshot, ix)?;
}
// 5. 全部成功,提交状态
self.accounts = accounts_snapshot;
Ok(())
}关键设计:
- 原子性:先在副本上执行所有指令,全部成功才提交
- recent_blockhash:每个 slot 结束时注册新的 blockhash,旧的会过期(最多保留 150 个)
- 签名验证:确保交易由正确的私钥签名
TPU 根据当前节点是否是 Leader,自动切换两种模式:
Producing 模式(当前节点是 Leader):
poh_recorder.has_bank()返回true- 交易直接由本地 PohRecorder 处理
- Bank 执行交易并更新状态
- BroadcastStage 签名并广播 Entry
Forwarding 模式(当前节点不是 Leader):
poh_recorder.has_bank()返回false- 从 LeaderSchedule 查找当前 Leader
- 从 CRDS 获取 Leader 的 TPU 地址
- 通过 UDP 转发交易给 Leader
// RPC 中的模式判断
async fn send_transaction(&self, tx_base58: String) -> Result<String> {
let tx = decode_transaction(&tx_base58)?;
if self.poh_recorder.has_bank() {
// Producing 模式:本地处理
self.tx_recorder.record_transactions(vec![tx])?;
} else {
// Forwarding 模式:转发给 Leader
let leader = self.leader_schedule.leader_at(current_slot);
let leader_tpu = self.crds.get_contact_info(&leader)?.tpu;
udp_socket.send_to(&tx_bytes, leader_tpu)?;
}
Ok(signature)
}Leader 节点通过 TPU Receiver 接收其他节点转发的交易:
Non-Leader RPC ──UDP──► Leader TPU Receiver ──► PohRecorder
TPU Receiver 监听 UDP 端口,反序列化收到的交易,提交给 PohRecorder 处理。
TVU (Transaction Validation Unit) 是非 Leader 节点接收、验证和重放区块的核心流水线。当其他验证者生产区块时,TVU 负责:
- 接收 Leader 广播的 Entry
- 验证 Leader 签名
- 转发给其他节点
- 重放交易更新本地状态
| 阶段 | 职责 | 关键操作 |
|---|---|---|
| TVU Receiver | 接收网络数据包 | 监听 UDP 端口,反序列化 TvuPacket |
| Verify Stage | 验证 Leader 签名 | 检查签名者是否是该 slot 的合法 Leader |
| Retransmit Stage | 转发与存储 | 转发给其他节点,写入 Blockstore |
| Replay Stage | 重放交易 | 验证 PoH 哈希链,执行交易,更新 BankForks |
项目使用 Rust 的 rayon 库实现并行验证。核心思想是:每个 Entry 的验证只依赖它的前一个 Entry 的哈希,而这些哈希在验证前就已经知道了(存储在 Entry 中)。因此可以预先构建 prev_hashes 数组,然后并行验证所有 Entry:
use rayon::prelude::*;
/// 验证 Entry 列表的哈希链完整性
/// 使用 rayon 并行实现,提高验证效率
pub fn verify_entries(entries: &[Entry], starting_hash: &Hash) -> bool {
if entries.is_empty() {
return true;
}
// 构建每个 Entry 的前置哈希数组
// [starting_hash, entry[0].hash, entry[1].hash, ..., entry[n-2].hash]
let prev_hashes: Vec<Hash> = std::iter::once(*starting_hash)
.chain(entries.iter().map(|e| e.hash))
.take(entries.len()) // 只取前 n 个
.collect();
// 使用 rayon 并行验证每个 Entry
// par_iter() 自动将工作分配到多个线程
prev_hashes
.par_iter()
.zip(entries.par_iter())
.all(|(prev_hash, entry)| entry.verify(prev_hash))
}为什么可以并行?
Entry[0]: verify(starting_hash) → entry[0].hash ✓
Entry[1]: verify(entry[0].hash) → entry[1].hash ✓ ← 这些验证互不依赖!
Entry[2]: verify(entry[1].hash) → entry[2].hash ✓
Entry[3]: verify(entry[2].hash) → entry[3].hash ✓
每个 entry.verify(prev_hash) 只需要:
- 输入:
prev_hash(已知,存储在前一个 Entry 中) - 计算:根据
num_hashes和transactions计算期望哈希 - 比较:期望哈希 ==
entry.hash
这三步完全独立,不依赖其他 Entry 的验证结果,因此可以完美并行。
Retransmit Stage 负责将收到的 Entry 转发给其他节点,形成类似"洪泛"的传播模式:
Leader ──► Node A ──► Node C
│
└──► Node D
│
└──► Node B ──► Node E
转发规则:
- 不回传:不发送给数据来源节点
- 不自发:不发送给自己
- 过滤旧 slot:超过阈值的旧 slot 不转发(但仍写入 Blockstore)
- Repair 包不转发:修复请求的响应包只存储,不转发
// 转发过滤逻辑
fn should_retransmit(packet: &TvuPacket, from: &Pubkey, my_pubkey: &Pubkey) -> bool {
// 不回传给来源
if packet.header.source == *from { return false; }
// 不发给自己
if *from == *my_pubkey { return false; }
// Repair 包不转发
if packet.header.packet_type == PacketType::Repair { return false; }
true
}Replay Stage 是 TVU 的核心,负责将收到的 Entry 重放到本地状态:
// Replay 一个完整的 slot
pub fn replay_slot(slot: Slot, blockstore: &Blockstore, bank_forks: &BankForks) -> Result<()> {
// 1. 读取 slot 的所有 Entry
let entries = blockstore.get_slot_entries(slot)?;
// 2. 获取父 slot 的最后哈希
let parent_hash = get_parent_last_hash(slot, blockstore)?;
// 3. 验证 PoH 哈希链
if !verify_entries(&entries, &parent_hash) {
return Err("PoH verification failed");
}
// 4. 从父 Bank 创建新 Bank
let parent_slot = get_parent_slot(slot)?;
let child_bank = bank_forks.new_from_parent(parent_slot, slot)?;
// 5. 执行所有交易
for entry in &entries {
if !entry.is_tick() {
child_bank.process_entry(entry)?;
}
}
// 6. 冻结 Bank,注册 blockhash
child_bank.freeze();
Ok(())
}Slot 可重放条件:
is_full = true:所有 Entry 都已收到- 父 slot 已在 BankForks 中
- 当前 slot 尚未在 BankForks 中
Replay Stage 还负责检测角色转换。当重放完 slot N 后,检查自己是否是 slot N+1 的 Leader:
// 重放完成后检查角色转换
fn check_role_transition(completed_slot: Slot) {
let next_slot = completed_slot + 1;
// 检查是否是下一个 slot 的 Leader
if leader_schedule.is_leader_at(next_slot, &my_pubkey) {
// 创建新 Bank
let new_bank = bank_forks.new_from_parent(completed_slot, next_slot)?;
// 获取父 slot 的最后哈希作为 PoH 起点
let last_hash = get_last_hash(completed_slot)?;
// 切换到 TPU 模式
poh_recorder.reset_to_slot(next_slot, new_bank, last_hash);
log::info!("Role transition: becoming leader for slot {}", next_slot);
}
}在分布式网络中,数据包可能丢失或延迟。当节点发现某些 slot 不完整时,需要主动向其他节点请求缺失的数据。
Repair Service 包含两个线程:
┌─────────────────────────────────────────────────────────────────────────────┐
│ Repair Service │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────┐ │
│ │ Listener Thread │ │
│ │ - 监听 repair_port (UDP) │ │
│ │ - 接收 RepairRequest │ │
│ │ - 检查 slot 完整性 │ │
│ │ - 发送 SignedEntry 给请求者 │ │
│ └─────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ Requester Thread │ │
│ │ - 定期扫描缺失的 slot │ │
│ │ - 从 CRDS 查找拥有该 slot 的节点 │ │
│ │ - 发送 RepairRequest 给邻居 │ │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Requester 线程定期扫描 Blockstore,找出需要修复的 slot:
// 扫描需要修复的 slot
pub fn get_need_repair_slots(&self, highest_known_slot: Slot) -> Vec<Slot> {
let mut need_repair = Vec::new();
// 遍历从 root 到 highest_known_slot 的所有 slot
for slot in self.root_slot..=highest_known_slot {
// 跳过已完整的 slot
if self.is_full(slot) { continue; }
// 跳过完全没有数据的 slot(可能还没开始)
if !self.has_any_data(slot) { continue; }
// 这个 slot 有部分数据但不完整,需要修复
need_repair.push(slot);
}
need_repair
}不是随机选择节点,而是从 CRDS 的 EpochSlots 中找到确实拥有该 slot 的节点:
// 从 CRDS 获取拥有指定 slot 的节点
pub fn get_peers_with_slot(&self, slot: Slot, exclude: &Pubkey) -> Vec<ContactInfo> {
let mut peers = Vec::new();
for epoch_slots in self.epoch_slots.values() {
// 检查该节点是否有这个 slot
if epoch_slots.has_slot(slot) {
// 排除自己
if epoch_slots.from != *exclude {
if let Some(contact) = self.get_contact_info(&epoch_slots.from) {
peers.push(contact.clone());
}
}
}
}
peers
}Repair 响应时,直接转发原始的 SignedEntry,不重新签名:
// 处理 Repair 请求
fn handle_repair_request(request: &RepairRequest, blockstore: &Blockstore) {
// 获取原始的 SignedEntry(保留 Leader 签名)
let signed_entries = blockstore.get_slot_signed_entries(request.slot)?;
for signed_entry in signed_entries {
// 直接转发,不重新签名!
// 这样接收方可以验证原始 Leader 的签名
let packet = TvuPacket::repair(my_pubkey, signed_entry);
socket.send_to(&packet.to_bytes()?, request.tvu_addr)?;
}
}这样设计的好处:
- 接收方可以用 Leader Schedule 验证签名
- 防止恶意节点伪造数据
- 保持数据的可追溯性
在分布式区块链网络中,节点需要解决几个关键问题:
- 节点发现:新节点如何找到其他节点?
- 状态同步:节点如何知道其他节点的状态(如拥有哪些 slot)?
- 投票传播:验证者的投票如何快速传播给其他节点?
Gossip 协议通过"八卦"式的点对点通信解决这些问题:每个节点定期向邻居广播自己的信息,邻居再传播给它们的邻居,最终信息扩散到整个网络。
CRDS 是 Gossip 协议的核心数据结构,存储集群中所有已知节点的元数据:
┌─────────────────────────────────────────────────────────────────────────────┐
│ CRDS 数据结构 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CrdsKey = (Pubkey, Label) │ │
│ │ │ │
│ │ Label 类型: │ │
│ │ - ContactInfo: 节点网络地址 │ │
│ │ - EpochSlots: 节点已处理的 slot │ │
│ │ - Vote: 验证者投票 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Table: HashMap<CrdsKey, CrdsValue> │ │
│ │ │ │
│ │ (NodeA, ContactInfo) → { gossip: 1.2.3.4:8000, tpu: ..., ... } │ │
│ │ (NodeA, EpochSlots) → { slots: [0,1,2,5,6], wallclock: ... } │ │
│ │ (NodeA, Vote) → { slots: [5], hash: ..., wallclock: ... } │ │
│ │ (NodeB, ContactInfo) → { gossip: 5.6.7.8:8000, tpu: ..., ... } │ │
│ │ ... │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
关键设计:
- 复合键:
(Pubkey, Label)允许每个节点存储多种类型的数据 - Wallclock 时间戳:只接受比现有数据更新的信息,防止旧数据覆盖新数据
- 自动过期:定期清理超时未更新的节点信息
| 类型 | 用途 | 更新频率 |
|---|---|---|
| ContactInfo | 节点网络地址(gossip/tpu/tvu/repair 端口) | 每 15 秒 |
| EpochSlots | 节点已处理的 slot 列表,用于 Repair 发现 | 每 2 秒 |
| Vote | 验证者投票,用于 ForkChoice 权重计算 | 投票时立即广播 |
enum GossipMessage {
// 心跳检测
Ping { from: Pubkey, timestamp: u64 },
Pong { from: Pubkey, ping_timestamp: u64 },
// Push 模型:主动推送自己的更新
PushContactInfo(ContactInfo), // 广播节点地址
PushEpochSlots(EpochSlots), // 广播已处理的 slot
PushVote(Vote), // 广播投票
// Pull 模型:请求其他节点的信息
PullRequest { from: Pubkey },
PullResponse { contacts: Vec<ContactInfo>, epoch_slots: Vec<EpochSlots> },
}┌─────────────────────────────────────────────────────────────────────────────┐
│ GossipService │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 主循环 (tokio::select!) │ │
│ │ │ │
│ │ 1. 接收 UDP 消息 ──► 更新 CRDS │ │
│ │ 2. 每 15s ──► 广播 ContactInfo │ │
│ │ 3. 每 2s ──► 广播 EpochSlots │ │
│ │ 4. 每 15s ──► 清理过期节点 │ │
│ │ 5. 投票时 ──► 立即广播 Vote │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
新节点加入网络时:
1. 新节点启动,配置 entrypoint(入口节点地址)
2. 向 entrypoint 发送 PullRequest
3. entrypoint 返回 PullResponse(所有已知节点信息)
4. 新节点将收到的信息存入 CRDS
5. 开始定期广播自己的 ContactInfo
6. 其他节点收到后,也将新节点加入 CRDS
// 连接入口节点
pub fn connect_to_entrypoints(&self) {
let msg = GossipMessage::PullRequest { from: self.my_pubkey };
for entrypoint in &self.config.entrypoints {
self.send_to(&msg, entrypoint)?;
log::info!("Sent PullRequest to entrypoint {}", entrypoint);
}
}EpochSlots 是 Repair 机制的关键:
1. 节点 A 完成 slot 5 的重放
2. 节点 A 更新本地 EpochSlots,添加 slot 5
3. 节点 A 通过 Gossip 广播 EpochSlots
4. 节点 B 收到后更新 CRDS
5. 节点 C 发现缺失 slot 5
6. 节点 C 查询 CRDS,发现节点 A 和 B 有 slot 5
7. 节点 C 向节点 A 发送 RepairRequest
CompressedSlots 压缩存储:
EpochSlots 使用位图压缩存储 slot 列表,每个 bit 代表一个 slot:
slots: [0, 1, 2, 5, 6, 10]
位图表示 (first_slot=0):
byte[0] = 0b01100111 // slots 0,1,2,5,6
byte[1] = 0b00000100 // slot 10
空间效率:65536 个 slot 只需 8KB
验证者投票后,通过 Gossip 快速传播给其他节点:
// Tower 投票后调用
pub fn broadcast_vote(&self, vote: Vote) {
// 1. 存入本地 CRDS
self.crds.write().unwrap().insert_vote(vote.clone());
// 2. 广播给所有已知节点
let msg = GossipMessage::PushVote(vote);
for peer in self.crds.read().unwrap().get_peers(&self.my_pubkey) {
self.send_to(&msg, &peer.gossip)?;
}
}投票传播的重要性:
- ForkChoice 需要知道其他验证者的投票来计算分叉权重
- 快速传播投票可以加速共识收敛
- 投票信息比 Entry 小得多,传播更快
在 Solana 官方实现中,Leader 调度是一个复杂的系统:
- 基于 stake 权重的随机选择
- 每个 epoch(约 2 天)重新计算调度表
- 使用 VRF(可验证随机函数)确保公平性
Mini-Solana 采用简化方案:Leader 列表直接写在创世配置中,按固定顺序轮换。
Leader Index = (slot / 4) % leaders.len()
- 每 4 个 slot 轮换一次 Leader
- 轮换顺序由创世配置中的
leaders数组决定 - 所有节点使用相同的创世配置,因此调度结果一致
Leader 列表在创世配置 (genesis.json) 中定义:
{
"creation_time": 1234567890,
"accounts": { ... },
"leaders": [
"Validator1Pubkey...",
"Validator2Pubkey...",
"Validator3Pubkey..."
],
"stakes": { ... }
}在分布式区块链网络中,由于网络延迟和分区,不同节点可能看到不同的区块顺序,导致分叉。Tower BFT 是 Solana 的共识机制,解决两个核心问题:
- 分叉选择:当存在多个分叉时,选择哪个作为"正确"的链?
- 防止快速切换:如何防止验证者在分叉之间频繁切换(可能导致双花攻击)?
BankForks:管理多个分叉状态
- 每个 slot 对应一个 Bank(账户状态快照)
- 分叉形成树状结构,root 是已确认不会回滚的 slot
- 支持从任意父 slot 创建子 slot
ForkChoice:基于 stake 权重选择最佳分叉
- 记录每个验证者的投票
- 计算每个分叉的累计权重(包含所有子孙的投票)
- 选择权重最高的分叉作为"最佳分叉"
Tower:本地投票状态管理
- 维护投票栈(最多 32 个投票)
- 实现 Lockout 机制,防止快速切换分叉
- 决定何时可以投票、何时被锁定
Lockout 是 Tower BFT 的核心安全机制。每次投票都会产生一个"锁定期",在锁定期内不能切换到其他分叉。
锁定期计算公式:
lockout_duration = 2^confirmation_count slots
confirmation_count:该投票被后续投票确认的次数- 每次在同一分叉上投票,之前所有投票的 confirmation_count 都 +1
- 锁定期呈指数增长,使得切换分叉的代价越来越高
可视化示例(运行 cargo test test_tower_lockout_visualization -- --nocapture):
╔══════════════════════════════════════════════════════════════════════════════════════════╗
║ 🔒 Tower BFT Lockout Mechanism Demo ║
║ Showing how lockout prevents rapid fork switching ║
╚══════════════════════════════════════════════════════════════════════════════════════════╝
📋 Lockout Formula: duration = 2^confirmation_count slots
┌─────────┬───────────────────┬────────────────────────────────────────────────────────────┐
│ Vote │ Confirmation Cnt │ Lockout Duration │
├─────────┼───────────────────┼────────────────────────────────────────────────────────────┤
│ Slot 1 │ 0 │ 1 slots █ │
│ Slot 2 │ 1 │ 2 slots ██ │
│ Slot 3 │ 2 │ 4 slots ████ │
│ Slot 4 │ 3 │ 8 slots ████████ │
│ Slot 5 │ 4 │ 16 slots ████████████████ │
│ Slot 6 │ 5 │ 32 slots ████████████████████████████████ │
│ Slot 7 │ 6 │ 64 slots ████████████████████████████████████████████████████████████████ │
└─────────┴───────────────────┴────────────────────────────────────────────────────────────┘
💡 Key insight: Each new vote DOUBLES the lockout of previous votes!
This makes it exponentially harder to switch forks as you vote deeper.
┌──────────────────────────────────────────────────────────────────────────────┐
│ 🗳️ Can Vote Check Examples │
├──────────────────────────────────────────────────────────────────────────────┤
│ Vote on Slot 9 (same fork): ✅ ALLOWED │
│ Vote on Slot 9 (different fork): ❌ BLOCKED │
└──────────────────────────────────────────────────────────────────────────────┘
关键洞察:
- 在同一分叉上连续投票是安全的(锁定期不影响)
- 切换到不同分叉需要等待所有冲突投票的锁定期过期
- 投票越深,切换代价越高(指数增长)
以下是一个完整的分叉选择演示(运行 cargo test test_fork_choice_visualization_demo -- --nocapture):
初始分叉结构:
0
|
1
/ | \
2 5 8
/| |
3 4 9
|
6
|
7
Round 1: 初始状态 - 无投票
╔══════════════════════════════════════════════════════════════╗
║ 🌳 Fork Tree Visualization ║
╠══════════════════════════════════════════════════════════════╣
║ [Slot 0] 💰0
║ ╰──[Slot 1] 💰0
║ ├──[Slot 2] 💰0
║ │ ├──[Slot 3] 💰0
║ │ │ ╰──[Slot 6] 💰0
║ │ │ ╰──[Slot 7] 💰0
║ │ ╰──[Slot 4] 💰0
║ ├──[Slot 5] 💰0
║ ╰──[Slot 8] 💰0
║ ╰──[Slot 9] 💰0 🏆
╠══════════════════════════════════════════════════════════════╣
║ Legend: 🏆 = Best Fork 💰 = Stake Weight 🗳️ = Votes ║
╚══════════════════════════════════════════════════════════════╝
Round 2: Alice (stake=100) 投票给 Slot 7
╔══════════════════════════════════════════════════════════════╗
║ 🌳 Fork Tree Visualization ║
╠══════════════════════════════════════════════════════════════╣
║ [Slot 0] 💰100
║ ╰──[Slot 1] 💰100
║ ├──[Slot 2] 💰100
║ │ ├──[Slot 3] 💰100
║ │ │ ╰──[Slot 6] 💰100
║ │ │ ╰──[Slot 7] 💰100 🗳️ [Alice(100)] 🏆
║ │ ╰──[Slot 4] 💰0
║ ├──[Slot 5] 💰0
║ ╰──[Slot 8] 💰0
║ ╰──[Slot 9] 💰0
╠══════════════════════════════════════════════════════════════╣
║ Legend: 🏆 = Best Fork 💰 = Stake Weight 🗳️ = Votes ║
╚══════════════════════════════════════════════════════════════╝
Round 3: Bob (stake=150) 投票给 Slot 9
╔══════════════════════════════════════════════════════════════╗
║ 🌳 Fork Tree Visualization ║
╠══════════════════════════════════════════════════════════════╣
║ [Slot 0] 💰250
║ ╰──[Slot 1] 💰250
║ ├──[Slot 2] 💰100
║ │ ├──[Slot 3] 💰100
║ │ │ ╰──[Slot 6] 💰100
║ │ │ ╰──[Slot 7] 💰100 🗳️ [Alice(100)]
║ │ ╰──[Slot 4] 💰0
║ ├──[Slot 5] 💰0
║ ╰──[Slot 8] 💰150
║ ╰──[Slot 9] 💰150 🗳️ [Bob(150)] 🏆
╠══════════════════════════════════════════════════════════════╣
║ Legend: 🏆 = Best Fork 💰 = Stake Weight 🗳️ = Votes ║
╚══════════════════════════════════════════════════════════════╝
Round 4: Charlie (stake=200) 投票给 Slot 4
╔══════════════════════════════════════════════════════════════╗
║ 🌳 Fork Tree Visualization ║
╠══════════════════════════════════════════════════════════════╣
║ [Slot 0] 💰450
║ ╰──[Slot 1] 💰450
║ ├──[Slot 2] 💰300
║ │ ├──[Slot 3] 💰100
║ │ │ ╰──[Slot 6] 💰100
║ │ │ ╰──[Slot 7] 💰100 🗳️ [Alice(100)]
║ │ ╰──[Slot 4] 💰200 🗳️ [Charlie(200)] 🏆
║ ├──[Slot 5] 💰0
║ ╰──[Slot 8] 💰150
║ ╰──[Slot 9] 💰150 🗳️ [Bob(150)]
╠══════════════════════════════════════════════════════════════╣
║ Legend: 🏆 = Best Fork 💰 = Stake Weight 🗳️ = Votes ║
╚══════════════════════════════════════════════════════════════╝
Round 6: Bob 改投 Slot 4 - Fork 2 成为最重分叉!
╔══════════════════════════════════════════════════════════════╗
║ 🌳 Fork Tree Visualization ║
╠══════════════════════════════════════════════════════════════╣
║ [Slot 0] 💰700
║ ╰──[Slot 1] 💰700
║ ├──[Slot 2] 💰450
║ │ ├──[Slot 3] 💰100
║ │ │ ╰──[Slot 6] 💰100
║ │ │ ╰──[Slot 7] 💰100 🗳️ [Alice(100)]
║ │ ╰──[Slot 4] 💰350 🗳️ [Charlie(200), Bob(150)] 🏆
║ ├──[Slot 5] 💰100 🗳️ [Alice(100)]
║ ╰──[Slot 8] 💰150
║ ╰──[Slot 9] 💰150 🗳️ [Bob(150)]
╠══════════════════════════════════════════════════════════════╣
║ Legend: 🏆 = Best Fork 💰 = Stake Weight 🗳️ = Votes ║
╚══════════════════════════════════════════════════════════════╝
╔════════════════════════════════════════════════════════════════╗
║ 📊 Final Summary ║
╠════════════════════════════════════════════════════════════════╣
║ Fork weights (subtree totals): ║
║ Slot 2 subtree: 450 (includes 3,4,6,7) ║
║ Slot 5 subtree: 100 ║
║ Slot 8 subtree: 150 (includes 9) ║
║ ║
║ 🏆 Best fork tip: Slot 4 ║
╚════════════════════════════════════════════════════════════════╝
ForkChoice 使用贪心算法选择最佳分叉:
// 从 root 开始,每层选择权重最高的子节点
fn best_slot(&self, bank_forks: &BankForks) -> Option<Slot> {
let mut current = bank_forks.root();
loop {
let children = bank_forks.get_children(current);
if children.is_empty() {
return Some(current); // 叶子节点即为最佳 slot
}
// 选择子树权重最高的子节点
current = children
.into_iter()
.max_by_key(|&child| self.compute_subtree_weight(child, bank_forks))
.unwrap();
}
}子树权重计算:
- 直接权重:直接投票给该 slot 的 stake 总和
- 子树权重:直接权重 + 所有子孙的直接权重
这确保了投票会"向上传播"——投票给 Slot 7 也会增加 Slot 6、3、2、1、0 的权重。
// ReplayStage 重放成功后
fn on_replay_success(slot: Slot, hash: Hash) {
// 1. 检查是否可以投票(Lockout 检查)
let ancestors = bank_forks.get_ancestors(slot);
if !tower.can_vote(slot, hash, &ancestors) {
log::info!("Locked out, cannot vote for slot {}", slot);
return;
}
// 2. 记录投票到本地 Tower
tower.record_vote(slot, hash);
// 3. 更新 ForkChoice
fork_choice.add_vote(&my_pubkey, slot);
// 4. 通过 Gossip 广播投票
gossip.broadcast_vote(Vote { slot, hash });
}你可以运行以下命令查看完整的可视化演示:
# 分叉选择演示
cargo test test_fork_choice_visualization_demo -- --nocapture
# Lockout 机制演示
cargo test test_tower_lockout_visualization -- --nocapture