Deep EVM #23: 성능 디버깅 — 데이터베이스 읽기가 지연 시간을 죽일 때
Engineering Team
성능 문제: 데이터베이스 읽기 증폭
프로덕션 환경에서 블록 처리 시간이 갑자기 3배로 증가했습니다. 코드 변경은 없었지만, 블록당 트랜잭션 수가 증가하면서 성능이 급격히 저하되었습니다. 원인을 추적하니 데이터베이스 읽기 증폭이었습니다 — 단일 상태 전환을 위해 수백 번의 데이터베이스 읽기가 발생하고 있었습니다.
데이터베이스 읽기 증폭은 고성능 시스템에서 가장 교활한 성능 킬러 중 하나입니다. 프로파일링에서 단일 핫스팟으로 나타나지 않기 때문입니다 — 수천 번의 작은 읽기로 분산됩니다.
문제 분석: O(N) 읽기 패턴
초기 구현은 각 트랜잭션을 순차적으로 실행하고, 각 실행에서 필요한 모든 상태를 데이터베이스에서 직접 읽었습니다:
struct NaiveExecutor {
db: Arc<MDBX>,
}
impl NaiveExecutor {
fn execute_block(&self, block: &Block) -> Result<Vec<Receipt>> {
let mut receipts = Vec::new();
for tx in &block.transactions {
// 각 트랜잭션이 데이터베이스에서 직접 읽기
let sender_balance = self.db.get_balance(tx.from)?;
let sender_nonce = self.db.get_nonce(tx.from)?;
let code = self.db.get_code(tx.to)?;
// 컨트랙트 실행 중에도 더 많은 읽기
let receipt = self.execute_tx(tx)?;
receipts.push(receipt);
// 상태 변경을 데이터베이스에 직접 쓰기
self.db.put_balance(tx.from, new_balance)?;
self.db.put_nonce(tx.from, new_nonce)?;
}
Ok(receipts)
}
}
블록에 100개의 트랜잭션이 있고, 각 트랜잭션이 평균 20개의 상태를 읽으면, 블록당 2,000번의 데이터베이스 읽기가 발생합니다. MDBX에서 각 읽기가 1-5 마이크로초이므로, 읽기만으로 2-10ms가 소요됩니다.
하지만 진짜 문제는 더 심각합니다. 같은 주소가 블록 내에서 여러 번 참조되면, 같은 데이터를 반복적으로 읽습니다. 인기 있는 DEX 컨트랙트가 블록의 50개 트랜잭션에서 호출되면, 그 컨트랙트의 코드와 스토리지를 50번 읽습니다.
해결책: CacheDB 패턴
핵심 통찰은 블록 실행을 두 단계로 분리하는 것입니다: 먼저 필요한 모든 상태를 메모리로 가져온 다음, 메모리 내에서 실행합니다:
struct CacheDB {
accounts: HashMap<Address, AccountInfo>,
storage: HashMap<(Address, U256), U256>,
code: HashMap<B256, Bytes>,
db: Arc<MDBX>,
}
impl CacheDB {
fn new(db: Arc<MDBX>) -> Self {
Self {
accounts: HashMap::new(),
storage: HashMap::new(),
code: HashMap::new(),
db,
}
}
fn get_account(&mut self, address: Address) -> Result<&AccountInfo> {
if !self.accounts.contains_key(&address) {
// 캐시 미스 — 데이터베이스에서 한 번만 로드
let info = self.db.get_account_info(address)?;
self.accounts.insert(address, info);
}
Ok(self.accounts.get(&address).unwrap())
}
fn get_storage(&mut self, address: Address, slot: U256) -> Result<U256> {
let key = (address, slot);
if !self.storage.contains_key(&key) {
let value = self.db.get_storage(address, slot)?;
self.storage.insert(key, value);
}
Ok(*self.storage.get(&key).unwrap())
}
}
이제 같은 주소에 대한 반복적인 읽기는 HashMap 조회(~50나노초)로 해결되며, 데이터베이스 접근(~3마이크로초)보다 60배 빠릅니다.
성능 측정: 전과 후
벤치마크 결과:
| 지표 | 순진한 구현 | CacheDB |
|---|---|---|
| 블록당 DB 읽기 | 2,847 | 312 |
| 블록 실행 시간 | 14.2ms | 3.8ms |
| P99 지연 시간 | 28.5ms | 6.1ms |
| 읽기 증폭 비율 | 9.1x | 1.0x |
읽기 횟수가 89% 감소했고, 실행 시간은 73% 단축되었습니다.
사전 로딩 최적화
CacheDB를 더 최적화하려면 블록의 트랜잭션 접근 목록을 분석하여 필요한 상태를 미리 배치로 로드합니다:
impl CacheDB {
fn prefetch_block(&mut self, block: &Block) -> Result<()> {
// 블록의 모든 트랜잭션에서 참조되는 주소 수집
let mut addresses: HashSet<Address> = HashSet::new();
let mut storage_keys: HashSet<(Address, U256)> = HashSet::new();
for tx in &block.transactions {
addresses.insert(tx.from);
if let Some(to) = tx.to {
addresses.insert(to);
}
// EIP-2930 접근 목록이 있으면 활용
for entry in &tx.access_list {
addresses.insert(entry.address);
for key in &entry.storage_keys {
storage_keys.insert((entry.address, *key));
}
}
}
// 배치 읽기 — 단일 데이터베이스 트랜잭션으로
let accounts = self.db.batch_get_accounts(&addresses)?;
for (addr, info) in accounts {
self.accounts.insert(addr, info);
}
let values = self.db.batch_get_storage(&storage_keys)?;
for ((addr, slot), value) in values {
self.storage.insert((addr, slot), value);
}
Ok(())
}
}
이 접근법은 많은 작은 읽기를 소수의 배치 읽기로 통합하여 I/O 오버헤드를 최소화합니다.
프로파일링 방법론
읽기 증폭을 진단하기 위한 도구와 방법:
perf를 사용한 시스템 수준 프로파일링
perf record -g --call-graph dwarf ./target/release/node --block 18000000
perf report --hierarchy
tracing을 사용한 애플리케이션 수준 계측
use tracing::{instrument, info_span};
use std::sync::atomic::{AtomicU64, Ordering};
static DB_READS: AtomicU64 = AtomicU64::new(0);
#[instrument(skip(self))]
fn execute_block(&mut self, block: &Block) -> Result<Vec<Receipt>> {
DB_READS.store(0, Ordering::Relaxed);
let result = self.inner_execute(block)?;
let reads = DB_READS.load(Ordering::Relaxed);
tracing::info!(
block = block.number,
db_reads = reads,
txs = block.transactions.len(),
"Block executed"
);
Ok(result)
}
실제 환경에서의 교훈
이 최적화에서 배운 핵심 교훈:
- 프로파일링 먼저 — 추측하지 마세요. 측정하고 데이터에 기반하여 최적화합니다.
- 읽기 증폭 추적 — 논리적 작업당 물리적 읽기 횟수를 모니터링합니다.
- 배치 우선 — 가능하면 개별 읽기 대신 배치 읽기를 사용합니다.
- 캐싱은 지역성을 활용 — 같은 데이터가 반복 접근되면 캐시가 매우 효과적입니다.
- O(N)과 O(affected) 구분 — 블록의 전체 트랜잭션 수(N)가 아닌 영향을 받는 고유 상태(affected)에 비례해야 합니다.
결론
데이터베이스 읽기 증폭은 고성능 Rust 시스템에서 흔한 성능 문제입니다. CacheDB 패턴은 이 문제에 대한 우아한 해결책을 제공합니다: 메모리 내 캐시를 데이터베이스 앞에 배치하여 반복적인 읽기를 제거합니다. 사전 로딩과 배치 읽기는 추가적인 최적화를 제공합니다. 핵심은 항상 프로파일링에서 시작하고, 실제 데이터에 기반하여 최적화하는 것입니다.