Model Checking: 使用 Stateright 形式化验证 Raft Lite
你可以在这里找到源代码.
Model checking的背景
分布式系统由于消息可能丢失、延迟或重复,因此相对复杂。很难推断分布式系统的正确性。
手写证明是验证分布式系统正确性的一种常见方式。例如,Raft论文在第5.4节提供了Raft安全性属性的证明。许多证明看起来似乎是正确的,但实际上可能是错误的(我并不是说Raft论文的证明是错误的)。许多经过同行评审的论文在其证明方面被发现是错误的(例如SOCT2、SDT等)。
Model checking是一种形式化验证技术,可以用来验证分布式系统的正确性。它是一种系统性的方法,用于检查模型是否满足给定的规范。Model checking探索模型的所有可能状态。如果在任何状态下违反了属性,模型检查器将报告它,开发人员可以相应地修复错误。
然而,model checking并不是万能的。检查模型的所有可能状态是不可行的。模型的状态空间可能非常大。在实践中,我们通常会将状态空间限制在一个合理的大小。例如,我们可以指定搜索的深度为20步,或者我们可以指定最大消息数为100。即使有这些限制,model checking仍然是一个强大的工具,可以发现分布式系统中的错误。
很多公司使用TLA+来验证他们的核心算法(例如AWS、Azure等)。然而,TLA+通常用于验证高层算法。即使算法本身被验证了,算法的实现可能仍然是错误的,因为程序员可能会犯错误。
Stateright 是一个model checking的Rust库。与使用高级语言如TLA+不同,stateright使用Rust来建模系统。这意味着我们可以直接验证低级实现,而无需将高级算法转换为低级实现。
Stateright 介绍
你可以在Stateright的Github仓库 找到stateright的详细信息,也可以在这里找到一个很好的教程。如果你不想阅读它们,我将在这里简要介绍stateright。如果你已经熟悉stateright,可以跳过这一部分。
我们通常将分布式系统建模为通过网络通信的多个节点。Stateright提供了一个抽象,称为ActorModel.
指定节点逻辑
为了定义节点的逻辑,你需要实现Actor trait,即on_start
、on_msg
和on_timeout
方法。
pub trait Actor: Sized {
...
fn on_start(&self, id: Id, o: &mut Out<Self>) -> Self::State;
fn on_msg(&self, id: Id, state: &mut Cow<'_, Self::State>, src: Id, msg: Self::Msg, o: &mut Out<Self>);
fn on_timeout(&self, id: Id, state: &mut Cow<'_, Self::State>, _timer: &Self::Timer, o: &mut Out<Self>);
}
指定系统模型
关于分布式系统的假设可以分为以下几种类型:
- 网络行为(例如消息丢失)
- 时间行为(例如延迟)
- 节点行为(例如崩溃)
对于1和2,stateright提供了一个现成的网络实现:
pub enum Network<Msg>
where
Msg: Eq + Hash,
{
UnorderedDuplicating(HashableHashSet<Envelope<Msg>>),
UnorderedNonDuplicating(HashableHashMap<Envelope<Msg>, usize>),
Ordered(BTreeMap<(Id, Id), VecDeque<Msg>>),
}
As long as invariants do not check the network state, losing a message is indistinguishable from an unlimited delay.
对于3,stateright提供了一种方式来指定系统中的最多崩溃的节点数:
pub fn max_crashes(mut self, max_crashes: usize) -> Self;
指定要检查的属性
最后,你需要使用Property结构体来指定要检查的属性。
条件本质上是一个返回布尔值的闭包。如果条件为真,则属性满足。你需要提供闭包来指定要检查的属性。
pub struct Property<M: Model> {
pub expectation: Expectation,
pub name: &'static str,
pub condition: fn(&M, &M::State) -> bool,
}
使用Stateright验证Raft Lite
指定节点逻辑
Raft Lite是这样设计的,算法是运行时不可知的。任何实现了Runner
trait的运行时环境都可以用来运行Raft Lite算法。
Raft Lite 算法使用Runner
与其他节点交互(例如发送投票请求)。我之前实现了一个RealRunner
来在真实环境中运行Raft Lite算法。利用RealRunner
,我使用Raft Lite构建了StorgataDB, 一个分布式的RESP兼容的KV数据库系统。
为了验证Raft Lite算法,我实现了一个CheckerRunner
,它将Raft Lite算法包装到模型检查器中。
pub trait Runner {
fn init_state(&mut self) -> NodeState;
fn vote_request(&mut self, peer_id: u64, vote_request_args: VoteRequestArgs);
fn log_request(&mut self, peer_id: u64, log_request_args: LogRequestArgs);
fn forward_broadcast(&mut self, broadcast_args: BroadcastArgs);
...
}
实现的关键部分是step
函数,它处理任何事件,并返回节点应该采取的操作。这使得算法的行为类似于状态机。
impl RaftProtocol<CheckerRunner> {
pub(crate) fn step(&mut self, event: Event) -> Vec<StepOutput> {
self.dispatch_event(event); // dispatch_event is the core logic of the Raft Lite algorithm
self.runner.collect_output()
}
}
而对于Actor
trait的实现,只是在每次收到消息或触发超时时调用step
函数。
impl Actor for CheckerActor {
...
fn on_msg(
&self,
state: &mut Cow<Self::State>,
msg: Self::Msg,
o: &mut Out<Self>,
) {
// translate the message to the event input of the step function
let event = match msg {
CheckerMessage::VoteRequest(vote_request_args) => Event::VoteRequest(vote_request_args),
CheckerMessage::VoteResponse(vote_response_args) => {
Event::VoteResponse(vote_response_args)
}
CheckerMessage::LogRequest(log_request_args) => Event::LogRequest(log_request_args),
CheckerMessage::LogResponse(log_response_args) => Event::LogResponse(log_response_args),
CheckerMessage::Broadcast(payload) => Event::Broadcast(payload),
};
process_event(state, event, o);
}
fn on_timeout(
&self,
_id: Id,
state: &mut Cow<Self::State>,
timer: &Self::Timer,
o: &mut Out<Self>,
) {
// translate the timer to the event input of the step function
let event = match timer {
CheckerTimer::ElectionTimeout => Event::ElectionTimeout,
CheckerTimer::ReplicationTimeout => Event::ReplicationTimeout,
};
process_event(state, event, o);
}
}
fn process_event(
state: &mut Cow<CheckerState>,
event: Event,
o: &mut Out<CheckerActor>,
) {
let state = state.to_mut();
// call the step function to process the event, and get the actions that the node should take
let step_output = state.raft_protocol.step(event);
// take the actions
for output in step_output {
match output {
StepOutput::DeliverMessage(payload) => {
state.delivered_messages.push(payload);
}
StepOutput::VoteRequest(peer_id, vote_request_args) => {
o.send(
Id::from(peer_id as usize),
CheckerMessage::VoteRequest(vote_request_args),
);
}
...
StepOutput::ElectionTimerReset => {
o.set_timer(CheckerTimer::ElectionTimeout, model_timeout());
}
...
}
}
}
指定系统模型
- 网络行为:Raft Lite使用一个非重复的无序网络。这意味着消息不会重复,也不会按顺序到达。这是Raft Lite的默认行为,但可以通过传递参数来更改。
- 节点假设:节点是崩溃-停止的,最多可以容忍少于大多数节点的崩溃-停止。
指定要检查的属性
我指定了两个属性来检查正确性:Election Safety
和State Machine Safety
:
- Election Safety: 对于某一个给定的term,最多只有一个leader被选举(也可能没有)。
- State Machine Safety: 如果某个节点已经将某个日志条目交付给应用层,那么没有其他节点将会在相同的位置交付不同的日志条目。
实际上,State Machine Safety
是我们唯一需要关心的属性(Raft的目标是实现一个复制状态机)。然而,为了给读者提供一个更简单的例子,我也检查了Election Safety
:
.property(Expectation::Always, "Election Safety", |_, state| {
// at most one leader can be elected in a given term
let mut leaders_term = HashSet::new(); // `term` set if we found a leader in the term already
for s in &state.actor_states {
if s.raft_protocol.state.current_role == crate::raft_protocol::Role::Leader
&& !leaders_term.insert(s.raft_protocol.state.current_term)
{
return false;
}
}
true
})
你可以看到,这个属性在Rust语言中非常容易表达。你只需要编写一个返回布尔值的闭包。
严格来说,Election Safety
属性应该在所有状态的上下文中一起检查,而不是分别检查每个状态。为了简单起见,我只是分别检查了每个状态。
运行model checker
我们可以通过指定最大步数来运行model checker,以限制状态空间。
它会打印出所有违反属性的反例。此外,它还会打印出满足某些属性的例子。例如,这里我指定了两个liveness属性,模型检查器打印出了满足属性的例子。
由于没有在Raft Lite算法中找到反例,这意味着算法在指定的属性上是正确的。
$ cargo run -- -m check --depth 10
Checking. states=4, unique=4, depth=1
Checking. states=475674, unique=133824, depth=10
Checking. states=917040, unique=256771, depth=10
Done. states=924710, unique=259150, depth=10, sec=3
Discovered "Election Liveness" example Path[3]:
- Timeout(Id(0), ElectionTimeout)
- Deliver { src: Id(0), dst: Id(1), msg: VoteRequest(VoteRequestArgs { cid: 0, cterm: 1, clog_length: 0, clog_term: 0 }) }
- Deliver { src: Id(1), dst: Id(0), msg: VoteResponse(VoteResponseArgs { voter_id: 1, term: 1, granted: true }) }
Fingerprint path: 13280538127433316798/18417327358524522001/10876327409151634344/11261648250825353397
Discovered "Log Liveness" example Path[6]:
- Timeout(Id(2), ElectionTimeout)
- Deliver { src: Id(2), dst: Id(0), msg: VoteRequest(VoteRequestArgs { cid: 2, cterm: 1, clog_length: 0, clog_term: 0 }) }
- Deliver { src: Id(0), dst: Id(2), msg: VoteResponse(VoteResponseArgs { voter_id: 0, term: 1, granted: true }) }
- Deliver { src: Id(2), dst: Id(2), msg: Broadcast([50]) }
- Deliver { src: Id(2), dst: Id(0), msg: LogRequest(LogRequestArgs { leader_id: 2, term: 1, prefix_len: 0, prefix_term: 0, leader_commit: 0, suffix: [LogEntry { term: 1, payload: [50] }] }) }
- Deliver { src: Id(0), dst: Id(2), msg: LogResponse(LogResponseArgs { follower: 0, term: 1, ack: 1, success: true }) }
Fingerprint path: 13280538127433316798/5012304960666992246/2656658050571602193/12788966706765998312/12610557528799436519/6208176474896103011/7212898540444505159
另外,我们也可以在Web UI中运行模型检查器:
$ cargo run -- -m explore
Exploring state space for Raft on http://localhost:3000
你可以点击选择下一步操作,并在操作后查看状态转换。此外,你还可以找到系统的时序图。
下面是Web UI的截图,我模拟了选举过程,两个节点在不同的term中被选举为leader。