Масштабування блокчейн-інфраструктури
Інфраструктура, яка нормально працювала при 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 — особливо добре кешується: код задеплоенного контракту ніколи не міняється. Один запрос на весь срок життя контракту.
Індексація: від 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 показав реальне вузьке місце.







