SOLVIZ

How Solana Works

블록 생성부터 전파, 합의, 트랜잭션 수신까지 — 그것을 실제로 돌리는 agave v4.2.0 코드로 짚었다.

anza-xyz/agave · tag v4.2.0

글 Kyungmin Jeon · 처음 게시

01

블록은 어떻게 만들어지나

블록은 한 번에 써지지 않는다. 리더는 트랜잭션을 실행하면서, 그 결과를 계속 돌아가는 시계에 하나씩 찍어 넣는다.

자기 차례가 오면 한 밸리데이터가 리더(leader)가 되어 블록을 쓴다. 이 리더는 트랜잭션을 한 건씩 처리하지 않는다. 뱅킹 스테이지(banking stage)가 서로 건드리는 계정이 다른 트랜잭션들을 여러 워커 스레드에 동시에 넘긴다. 그래서 계정이 겹치지 않는 송금은 나란히 실행된다.

실행이 끝난 배치는 곧바로 Proof of History에 기록된다. 솔라나가 합의에 들어가기 전에 이미 시간 순서를 갖는 이유가 이것이다. 순서는 리더가 블록을 만드는 그 순간에 해시 사슬 안으로 이미 새겨진다.

스케줄러뱅킹 워커PoH 기록기PoH 사슬계정이 겹치지 않는 트랜잭션같은 계정을 쓰는 트랜잭션PoH 해시
PoH에 기록된 트랜잭션
0 / 4
1계정을 보고 나눈다
2동시에 실행하고 PoH에 기록한다
3충돌한 트랜잭션은 그다음에 실행된다

Figure 1. 트랜잭션이 뱅킹 워커들에 배분되고, 계정이 충돌하면 직렬로 처리된다. 실행된 각 배치는 PoH 사슬의 entry가 된다.

코드에서는 `process_and_record_transactions`가 먼저 나이·수수료 검사를 하고, 배치를 실행한 뒤 그 결과를 recorder에 넘긴다. recorder는 `record_batches`를 불러 각 배치의 해시를 PoH 사슬에 섞고 entry를 덧붙인다. 실행할 게 없을 때도 tick이 시계를 계속 돌리기 때문에, 슬롯은 언제나 일정한 속도로 나아간다.

agave v4.2.0core/src/banking_stage/consumer.rs:124
124pub fn process_and_record_transactions(
125 &self,
126 bank: &Bank,
127 txs: &[impl TransactionWithMeta],
128) -> ProcessTransactionBatchOutput {
129 let mut error_counters = TransactionErrorMetrics::default();
130 let pre_results = vec![Ok(()); txs.len()];
131 let check_results = bank.check_transactions(
132 txs, &pre_results, bank.max_processing_age(),
133 true, &mut error_counters,
134 );
135 // ...
136 let mut output = self.process_and_record_transactions_with_pre_results(
137 bank, txs, check_results, /* flags */
138 );

process_and_record_transactions배치를 나이·QoS 검사한 뒤 실행하고 기록한다. "실행하고 나서 찍는다"는 넘김의 시작점이다.

agave v4.2.0poh/src/poh_recorder.rs:349
349pub fn record(
350 &mut self,
351 bank_id: BankId,
352 mixins: Vec<Hash>,
353 transaction_batches: Vec<Vec<VersionedTransaction>>,
354) -> Result<RecordSummary> {
355 // ...
356 let tick_height = self.tick_height();
357 let (mut poh_lock, _) = measure_us!(self.poh.lock().unwrap());
358 let (mixed_in, _) =
359 measure_us!(poh_lock.record_batches(&mixins, &mut self.entries));

recordrecord_batches가 각 배치 해시를 PoH 사슬에 섞고 entry를 덧붙인다. 트랜잭션이 시간상 못 박히는 순간이다.

핵심 개념

리더는 실행과 시간 기록을 번갈아 한다. 배치가 기록되는 순간 그 시간상 위치는 이미 정해진다. 순서는 합의로 따로 정하는 게 아니라 블록을 만드는 과정에서 저절로 생긴다.

02

블록은 어떻게 퍼지나

리더 하나가 수천 개 밸리데이터에게 블록을 통째로 보낼 수는 없다. 그래서 아예 그렇게 하지 않는다.

블록은 클러스터 전체에 빠르게 닿아야 한다. 그런데 리더 혼자서는 모두에게 보낼 대역폭이 없다. 터빈(Turbine)은 이 문제를 콘텐츠 전송망(CDN)과 같은 방식으로 푼다.

먼저 블록을 shred로 나눈다. 그다음 리더는 모두에게 뿌리지 않고, 작은 앵커 집합에만 shred를 보낸다. 그 노드들이 다시 다음 층으로 전달한다. 어떤 노드도 정해진 수보다 많은 이웃에게 전달하지 않는다.

리더1층2층데이터 shred코딩 shred
1블록을 shred로 쪼갠다
2리더는 1층에만 보낸다
31층이 2층으로 넘긴다

Figure 2. 블록이 데이터 shred와 코딩 shred로 쪼개진 뒤 트리로 퍼진다. 리더는 앵커 집합에 보내고, 그 집합이 다음으로 재전송한다.

shred는 고정 크기의 소실복원(erasure) 배치로 묶인다. `DATA_SHREDS_PER_FEC_BLOCK = 32`, 즉 데이터 32개에 코딩 32개가 붙어서, 3분의 1까지 잃어도 복원할 수 있다. 트리 폭은 `DATA_PLANE_FANOUT = 200`으로 정해진다. 노드 하나가 최대 200개 이웃에게 재전송한다는 뜻이다. `get_retransmit_peers`는 결정적이고 지분 가중된 뒤섞기 안에서 자기 위치를 보고 담당 이웃을 계산한다. 그래서 밸리데이터들은 따로 조율하지 않고도 같은 트리에 합의한다.

agave v4.2.0ledger/src/shred.rs:118
118pub const DATA_SHREDS_PER_FEC_BLOCK: usize = 32;
119pub const CODING_SHREDS_PER_FEC_BLOCK: usize = 32;
120pub const SHREDS_PER_FEC_BLOCK: usize =
121 DATA_SHREDS_PER_FEC_BLOCK + CODING_SHREDS_PER_FEC_BLOCK;

FEC block geometry소실복원 배치마다 데이터 32 + 코딩 32. 3분의 1까지 잃어도 블록이 복원된다.

agave v4.2.0turbine/src/cluster_nodes.rs:47
47pub(crate) const DATA_PLANE_FANOUT: usize = 200;
48pub(crate) const MAX_NUM_TURBINE_HOPS: usize = 4;
49// ...
50fn get_retransmit_peers<T>(
51 fanout: usize,
52 pred: impl Fn(T) -> bool, // identifies this node
53 nodes: impl IntoIterator<Item = T>,
54) -> (usize, impl Iterator<Item = T>) {
55 let index = /* this node's position in the shuffle */;
56 let offset = index.saturating_sub(1) % fanout;
57 let anchor = index - offset;
58 let peers = (anchor * fanout + offset + 1..).step_by(fanout).take(fanout);
59 (index, peers)
60}

DATA_PLANE_FANOUT / get_retransmit_peers노드마다 최대 200개 이웃에 전달한다. 담당 이웃은 결정적 뒤섞기 속 자기 인덱스에서 계산되므로, 트리에 별도 조율이 필요 없다.

핵심 개념

터빈은 약간의 중복(코딩 shred)과 몇 홉의 지연을 내주는 대신, 네트워크가 커져도 감당되는 대역폭을 얻는다. 어느 한 노드도 병목이 되지 않는다.

03

네트워크는 어떻게 합의하나

밸리데이터는 투표를 위해 멈추지 않는다. 블록이 흘러가는 대로 투표하고, 한 번 던진 표는 되돌릴수록 값이 커진다.

밸리데이터는 블록을 받아 재생(replay)해 본 뒤 결과를 확인하고 투표한다. 솔라나의 합의인 Tower BFT는 투표를 하려고 체인을 멈추지 않는다.

각 투표는 락아웃(lockout)을 통해 밸리데이터를 그 분기에 점점 더 깊이 묶는다. 락아웃이 깊을수록 분기를 갈아타면 잃는 게 커진다. 그래서 합의는 저절로 굳어진다. 지분을 태우는 슬래싱은 없다. 되돌릴 수 없게 만드는 힘은 자기 표를 뒤집는 비용이 계속 커진다는 데서 나온다.

26%22%16%20%16%2/3투표 지분한 밸리데이터의 락아웃 (슬롯)처리확인최종 확정표3분의 2 문턱
투표 지분
0%
1표는 지분만큼 쌓인다
23분의 2를 넘으면 확인된다
3표를 더할수록 락아웃이 2배가 된다

Figure 3. 밸리데이터가 지분의 초다수가 동의할 때까지 투표한다. 락아웃은 깊이에 따라 2배씩 커지고, 블록은 processed → confirmed → finalized로 넘어간다.

슬롯은 투표 지분이 전체 지분의 3분의 2를 넘는 순간 confirmed가 된다. `is_slot_confirmed`가 검사하는 게 정확히 이것이다. 투표 전에는 `is_locked_out`이 그 표를 넣어 본다고 가정하고, 살아남는 표 중 하나라도 다른 분기에 있으면 투표를 거부한다. Tower는 두 깊이 `VOTE_THRESHOLD_DEPTH_SHALLOW = 4`와 `VOTE_THRESHOLD_DEPTH = 8`에서 지분을 확인한다. 한 분기에 락아웃이 충분히 쌓이면 뒤집기가 사실상 불가능해진다. 사용자에게는 이것이 약 12~13초 뒤의 finalized로 보인다.

agave v4.2.0core/src/consensus.rs:585
585fn is_slot_confirmed(
586 &self,
587 slot: Slot,
588 voted_stakes: &VotedStakes,
589 total_stake: Stake,
590) -> bool {
591 voted_stakes
592 .get(&slot)
593 .map(|stake| (*stake as f64 / total_stake as f64) > self.threshold_size)
594 .unwrap_or(false)
595}

is_slot_confirmed투표된 지분이 전체 지분의 threshold_size(3분의 2)를 넘으면 그 슬롯은 confirmed다.

agave v4.2.0core/src/consensus.rs:827
827pub fn is_locked_out(&self, slot: Slot, ancestors: &HashSet<Slot>) -> bool {
828 if !self.is_recent(slot) {
829 return true;
830 }
831 let mut vote_state = self.vote_state.clone();
832 vote_state.process_next_vote_slot(slot);
833 for vote in &vote_state.votes {
834 if slot != vote.slot() && !ancestors.contains(&vote.slot()) {
835 return true;
836 }
837 }
838 false
839}

is_locked_out표를 넣어 본다고 가정한다. 살아남는 표 중 하나라도 다른 분기에 있으면 이 슬롯은 아직 락아웃 상태다. 되돌리기를 비싸게 만드는 장치다.

핵심 개념

confirmed는 지분 문턱이고, finalized는 쌓인 락아웃이다. 솔라나는 명시적 체크포인트로 멈추는 대신, 되돌리기를 시간이 갈수록 비싸게 만들어 빠른 합의를 얻는다.

04

트랜잭션은 어떻게 들어오나

앞의 모든 일이 일어나기 전에, 당신의 트랜잭션은 먼저 리더에게 닿아야 하고, 정말 당신 것임을 증명해야 한다.

서명된 트랜잭션은 네트워크 패킷 형태로 리더에게 도착해 TPU로 들어간다. 머무를 공개 멤풀이 없다. 패킷은 곧장 파이프라인으로 들어간다.

세 단계가 순서대로 돈다. FetchStage가 소켓에서 패킷 배치를 모은다. SigVerifyStage가 서명을 검증해 위조를 걸러낸다. 살아남은 것만 BankingStage에 닿는다.

UDP 소켓FetchStageSigVerifyStageBankingStage폐기검증 전 패킷서명 통과서명 불량
서명 검사 통과
0 / 6
1FetchStage가 패킷을 모은다
2서명은 배치째 검사한다
3살아남은 것만 뱅킹으로 간다

Figure 4. 패킷이 FetchStage → SigVerifyStage → BankingStage로 흐른다. 서명이 잘못된 트랜잭션은 실행에 닿기 전에 폐기된다.

단계들은 채널로 이어진다. FetchStage의 sender가 SigVerifyStage로, 검증된 출력이 다시 BankingStage로 흘러 들어간다. 실제 서명 검사는 `ed25519_verify_serial`인데, 트랜잭션 하나씩이 아니라 배치 전체를 한 번에 검증한다. 리더가 네트워크 속도를 따라잡는 방법이 바로 이 배치 처리다. 이 관문을 지난 뒤에야 트랜잭션은 첫 단원의 스케줄러에 닿는다. 그렇게 블록 생성으로 고리가 다시 이어진다.

agave v4.2.0core/src/tpu.rs:184
184let fetch_stage = FetchStage::new_with_sender(
185 tpu_vote_sockets, exit.clone(), &packet_sender, /* ... */
186);
187// ...
188let (sigverify_stage, _) = SigVerifyStage::new(
189 packet_receiver, vote_packet_receiver,
190 non_vote_sender, tpu_vote_sender, /* ... */
191);
192// ...
193let banking_stage = BankingStage::new_num_threads(
194 block_production_method, poh_recorder.clone(),
195 transaction_recorder, non_vote_receiver, /* ... */
196);

Fetch → SigVerify → Banking세 단계가 순서대로 생성되고 채널로 이어진다. 리더의 수신 조립 라인이다.

agave v4.2.0core/src/sigverify.rs:322
322let (_, verify_time_us) = measure_us!(sigverify::ed25519_verify_serial(
323 &mut batch,
324 reject_non_vote,
325 enable_tx_v1,
326));
327let num_valid_packets = sigverify::count_valid_packets(std::iter::once(&batch));
328// ...
329let banking_packet_batch = BankingPacketBatch::new(vec![batch]);

ed25519_verify_serial진짜 암호 관문. 패킷 배치 전체를 한 번에 서명 검사하고, 불량 패킷은 banking에 닿지 못한다.

핵심 개념

멤풀이 없다는 것은 공개 대기실이 없다는 뜻이다. 트랜잭션은 곧장 리더로 전달돼 배치로 검증되고, 실행되거나 폐기된다. 이 파이프라인이 트랜잭션의 일생 전체를 한 노드 안에 압축해 담고 있다.

위 모든 코드는 anza-xyz/agave 태그 v4.2.0에서 그대로 인용했다.