Масштабирование блокчейн-инфраструктуры
Инфраструктура, которая нормально работала при 100 пользователях, начинает сыпаться при 10 000. Специфика блокчейн-стека в том, что узкое место часто не там, где ожидаешь: не база данных, не CPU — а RPC нода, которая не успевает отдавать eth_getLogs, или indexer, который отстаёт на 50 блоков, или WebSocket handler, который дропает соединения под нагрузкой. Масштабирование блокчейн-инфраструктуры — это отдельная дисциплина с непривычными паттернами.
Диагностика: где реальное узкое место
Прежде чем что-то масштабировать — измерьте. Типичные узкие места:
// Инструментация RPC вызовов
class InstrumentedProvider {
private metrics: Map<string, number[]> = new Map();
async call(method: string, params: any[]): Promise<any> {
const start = performance.now();
try {
const result = await this.provider.send(method, params);
this.record(method, performance.now() - start);
return result;
} catch (err) {
this.recordError(method);
throw err;
}
}
getPercentiles(method: string) {
const samples = (this.metrics.get(method) || []).sort((a, b) => a - b);
return {
p50: samples[Math.floor(samples.length * 0.5)],
p95: samples[Math.floor(samples.length * 0.95)],
p99: samples[Math.floor(samples.length * 0.99)],
count: samples.length,
};
}
}
Что измерять: latency по каждому RPC методу, queue depth у indexer'а, lag между head блоком ноды и head в вашей БД, throughput WebSocket соединений.
Масштабирование RPC слоя
Пул нод с балансировкой
Единственная нода — single point of failure и bottleneck. Минимальная production конфигурация:
class NodePool {
private nodes: RpcNode[];
private currentIndex = 0;
private healthStatus: Map<string, boolean> = new Map();
async sendRequest(method: string, params: any[]): Promise<any> {
// Round-robin с пропуском нездоровых нод
for (let i = 0; i < this.nodes.length; i++) {
const node = this.nodes[this.currentIndex % this.nodes.length];
this.currentIndex++;
if (!this.healthStatus.get(node.url)) continue;
try {
return await node.send(method, params);
} catch (err) {
// Помечаем ноду как нездоровую при ошибке
this.healthStatus.set(node.url, false);
setTimeout(() => this.healthStatus.set(node.url, true), 30_000);
}
}
throw new Error('All nodes unhealthy');
}
}
Для stateful операций (подписки, pending transactions) — sticky routing: один клиент всегда идёт к одной ноде.
Кеширование RPC ответов
Многие запросы идентичны и кешируемы:
const CACHEABLE_METHODS: Record<string, number> = {
'eth_chainId': 86400, // 24 часа — не меняется
'eth_getCode': 3600, // 1 час — код контракта стабилен
'eth_getBlockByNumber': 60, // 1 мин для не-latest
'eth_getTransactionReceipt': 300, // 5 мин — после финализации не меняется
};
class CachingRpcProxy {
async send(method: string, params: any[]): Promise<any> {
const ttl = CACHEABLE_METHODS[method];
if (!ttl) return this.upstream.send(method, params);
// Не кешируем 'latest' блок
if (params.includes('latest') || params.includes('pending')) {
return this.upstream.send(method, params);
}
const cacheKey = `rpc:${method}:${JSON.stringify(params)}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const result = await this.upstream.send(method, params);
await this.redis.setex(cacheKey, ttl, JSON.stringify(result));
return result;
}
}
eth_getCode — особенно хорошо кешируется: код задеплоенного контракта никогда не меняется. Один запрос на весь срок жизни контракта.
Indexing: от polling к event-driven
Проблема polling
// Плохо: polling каждые 5 секунд
setInterval(async () => {
const balance = await provider.getBalance(address);
if (balance !== lastBalance) notifyUser(balance);
}, 5000);
При 10 000 адресов — 2 000 запросов в секунду только на мониторинг балансов. Нода захлебнётся.
Event-driven через logs
EVM события — правильный инструмент:
class EventIndexer {
private lastProcessedBlock: number;
async start() {
// Подписка на новые блоки
this.provider.on('block', async (blockNumber) => {
await this.processRange(this.lastProcessedBlock + 1, blockNumber);
this.lastProcessedBlock = blockNumber;
});
}
private async processRange(from: number, to: number) {
// Один запрос для всех интересующих событий во всех блоках диапазона
const logs = await this.provider.getLogs({
fromBlock: from,
toBlock: to,
topics: [
// OR-логика: любой из этих event selectors
[
ethers.id('Transfer(address,address,uint256)'),
ethers.id('Approval(address,address,uint256)'),
ethers.id('Deposit(address,uint256)'),
]
],
});
// Группируем по типу события и обрабатываем батчем
const grouped = Map.groupBy(logs, log => log.topics[0]);
await Promise.all([
this.processTransfers(grouped.get(ethers.id('Transfer(...)'))),
this.processApprovals(grouped.get(ethers.id('Approval(...)'))),
]);
}
}
The Graph для сложной индексации
Для сложных запросов (агрегация, исторические данные по пользователям) — The Graph субграф:
# schema.graphql
type User @entity {
id: Bytes!
totalDeposited: BigInt!
transactions: [Transaction!]! @derivedFrom(field: "user")
}
type Transaction @entity {
id: Bytes!
user: User!
amount: BigInt!
blockNumber: BigInt!
timestamp: BigInt!
}
// AssemblyScript handler в subgraph
export function handleDeposit(event: DepositEvent): void {
let user = User.load(event.params.user);
if (!user) {
user = new User(event.params.user);
user.totalDeposited = BigInt.zero();
}
user.totalDeposited = user.totalDeposited.plus(event.params.amount);
user.save();
let tx = new Transaction(event.transaction.hash);
tx.user = event.params.user;
tx.amount = event.params.amount;
tx.blockNumber = event.block.number;
tx.timestamp = event.block.timestamp;
tx.save();
}
Self-hosted Graph Node на production: PostgreSQL 14+ с достаточным I/O, индексация крупного протокола занимает часы. Для начала — Graph Studio (managed), потом мигрируете при необходимости.
WebSocket: масштабирование подписок
WebSocket соединения stateful — нельзя просто добавить nginx upstream и балансировать round-robin. Нужен pub/sub слой:
// Redis Pub/Sub как backbone для WebSocket событий
class WebSocketGateway {
private redisSub: Redis; // subscriber connection
private clients: Map<string, Set<WebSocket>> = new Map();
constructor() {
this.redisSub = new Redis();
// Один subscriber на сервер, события раздаются всем клиентам
this.redisSub.psubscribe('blockchain:*');
this.redisSub.on('pmessage', this.broadcastToClients.bind(this));
}
subscribeClient(ws: WebSocket, topic: string) {
if (!this.clients.has(topic)) this.clients.set(topic, new Set());
this.clients.get(topic)!.add(ws);
}
private broadcastToClients(pattern: string, channel: string, message: string) {
const clients = this.clients.get(channel);
if (!clients) return;
const dead: WebSocket[] = [];
for (const ws of clients) {
if (ws.readyState !== WebSocket.OPEN) { dead.push(ws); continue; }
ws.send(message);
}
dead.forEach(ws => clients.delete(ws));
}
}
Отдельный сервис публикует события в Redis при новом блоке или транзакции. WS-серверы подписаны на Redis и раздают своим клиентам. Горизонтальное масштабирование: добавляйте WS-серверы, все они читают один Redis канал.
Управление нагрузкой на ноды
Request coalescing
Множество одновременных запросов на один и тот же ресурс:
class RequestCoalescer {
private pending: Map<string, Promise<any>> = new Map();
async get(key: string, fetcher: () => Promise<any>): Promise<any> {
// Если запрос уже летит — ждём его результат
if (this.pending.has(key)) {
return this.pending.get(key);
}
const promise = fetcher().finally(() => this.pending.delete(key));
this.pending.set(key, promise);
return promise;
}
}
// Использование: 100 одновременных запросов на eth_getBalance одного адреса
// превращаются в 1 RPC запрос
const coalescer = new RequestCoalescer();
const balance = await coalescer.get(
`balance:${address}:latest`,
() => provider.getBalance(address)
);
Multicall для батчевых запросов
// Вместо 100 отдельных вызовов balanceOf — один multicall
import { Multicall3 } from '@ethcall/core';
const multicall = new Multicall3({ ethersProvider: provider });
const calls = addresses.map(address => ({
target: tokenAddress,
callData: erc20Interface.encodeFunctionData('balanceOf', [address]),
}));
const results = await multicall.aggregate(calls);
const balances = results.map((result, i) =>
erc20Interface.decodeFunctionResult('balanceOf', result)[0]
);
Один HTTP запрос вместо 100. Для read-heavy приложений с большим числом адресов — обязательный паттерн.
Мониторинг и observability
Prometheus метрики для блокчейн-инфраструктуры:
import { Counter, Histogram, Gauge } from 'prom-client';
const rpcLatency = new Histogram({
name: 'rpc_request_duration_ms',
help: 'RPC request latency in milliseconds',
labelNames: ['method', 'node', 'status'],
buckets: [10, 50, 100, 250, 500, 1000, 2500, 5000],
});
const indexerLag = new Gauge({
name: 'indexer_block_lag',
help: 'Blocks behind chain head',
labelNames: ['network'],
});
const wsConnections = new Gauge({
name: 'websocket_connections_total',
help: 'Active WebSocket connections',
});
Алерты в Grafana:
-
indexer_block_lag > 10— indexer отстаёт, что-то тормозит -
rpc_request_duration_ms{p95} > 2000— нода деградирует -
rate(rpc_errors_total[5m]) > 10— нода возвращает ошибки -
websocket_connections_total > 8000— приближаемся к лимиту fd
Архитектурные решения: когда что применять
| Проблема | Решение | Сложность |
|---|---|---|
| RPC нода — bottleneck | Пул нод + балансировка | Низкая |
| Повторяющиеся одинаковые запросы | Request coalescing + Redis cache | Низкая |
| 100+ адресов — мониторинг балансов | Multicall + event indexing | Средняя |
| WS под нагрузкой дропает соединения | Redis pub/sub backbone | Средняя |
| Исторические запросы медленные | Erigon/Reth archive + query optimization | Средняя |
| Сложная аналитика on-chain данных | The Graph subgraph | Высокая |
| Мультисеть, тысячи событий/сек | Kafka + event streaming | Высокая |
Масштабирование всегда начинается с измерений. Добавляйте сложность только там, где profiling показал реальное узкое место.







