Розмір облікового запису в Solana безпосередньо визначає мінімальний депозит для звільнення від оренди (rent-exempt minimum) та загальну вартість створення й утримання даних на ланцюгу. Коли обліковий запис наближається до граничних 10 КБ або коли орендний депозит стає економічно невиправданим, оптимізація простору переходить з категорії бажаних покращень у категорію технічних обмежень. Нижче — покрокова інженерна методологія зменшення розміру облікового запису: від точного вимірювання до безпечної міграції.
Аналіз поточного використання простору
Вимірювання фактичного розміру
Перш ніж оптимізувати, необхідно отримати точні цифри. Два основні способи:
- CLI: команда solana account <ADDRESS> повертає поле Size у байтах.
- RPC: виклик getAccountInfo з параметром encoding: "base64" повертає поле data (масив байтів) та dataLen. Значення dataLen — це фактичний розмір даних облікового запису.
Зверніть увагу: dataLen може бути більшим за суму розмірів полів вашої структури, якщо обліковий запис було створено з запасом (наприклад, через realloc у більший бік без зменшення назад).
Розбивка за полями
Загального інструменту для автоматичної розбивки Borsh-серіалізованих даних за полями немає. Практичний підхід — написати скрипт (Rust або TypeScript), який десеріалізує обліковий запис і обчислює розмір кожного поля за правилами Borsh:
| Тип даних | Розмір у Borsh (байти) |
|---|---|
| u8, i8, bool | 1 |
| u16, i16 | 2 |
| u32, i32 | 4 |
| u64, i64, f64 | 8 |
| Pubkey | 32 |
| Option<T> | 1 (дескримінант) + розмір T (якщо Some) |
| Vec<T> | 4 (довжина як u32) + розмір T × кількість елементів |
| String | 4 (довжина як u32) + кількість UTF-8 байтів |
| [T; N] | розмір T × N (без префікса довжини) |
| Enum з даними | 1 (дескримінант) + розмір найбільшого варіанту |
Скрипт має вивести таблицю: назва поля, тип, розмір у байтах, частка від загального обсягу. Це дозволяє одразу побачити, які поля споживають найбільше простору.
Визначення пріоритетів оптимізації
Сортуйте поля за спожитим простором у порядку спадання. Оптимізація поля на 1000 байтів дає більший ефект, ніж економія 2 байтів на десяти полях. Фокусуйтеся на:
- Колекціях (Vec) з великою кількістю елементів — особливо Vec<Pubkey> та Vec<u64>.
- Рядках (String) з надлишковими даними.
- Резервних масивах (наприклад, [u8; 128]), які заповнені частково або не використовуються взагалі.
Оптимізація структур даних
Правильний вибір цілочисельних типів
Найпоширеніше джерело надлишкового споживання — використання u64 там, де достатньо меншого типу. Кожне таке поле, замінене з u64 на u32, економить 4 байти; на u16 — 6 байтів; на u8 — 7 байтів.
Критерій вибору: максимальне фактичне значення поля, помножене на запас (наприклад, ×2 або ×10 залежно від бізнес-логіки), має поміщатися в обраний тип. Наприклад, якщо лічильник транзакцій користувача ніколи не перевищить 65 535, u16 достатньо замість u64 — економія 6 байтів на одному полі.
Типові кандидати на звуження типу:
- Лічильники, індекси, порядкові номери.
- Таймстемпи (якщо потрібна точність до секунд, а не до наносекунд — u32 вміщує ~136 років у секундах).
- Відсотки, комісії, ваги (з фіксованою точкою замість плаваючої).
Обробка необов'язкових значень
Option<T> у Borsh коштує 1 байт додатково поверх типу T. Якщо у вас є кілька незалежних необов'язкових прапорців (Option<bool>), кожен з них займає 2 байти. Заміна трьох таких полів на одне поле u8 з бітовими прапорцями зменшує споживання з 6 байтів до 1.
Для Option<u64> (9 байтів) розгляньте можливість заміни на u64 зі спеціальним значенням-маркером (наприклад, u64::MAX для "не встановлено"), якщо таке значення не є валідним для бізнес-логіки. Це економить 1 байт на поле.
Порядок полів та серіалізація Borsh
На відміну від деяких інших форматів серіалізації, Borsh записує поля структури строго в порядку їх оголошення в коді без вставки вирівнювання (padding) між полями. Це означає, що перестановка полів у структурі не змінює загальний розмір серіалізованих даних. Однак перестановка змінює бінарний формат, що робить існуючі облікові записи недесеріалізованими — це вимагає міграції (про це нижче).
Практичний висновок: порядок полів не впливає на розмір у Borsh, але будь-яка зміна порядку — це зміна схеми, яка потребує міграції даних.
Zero-copy структури: окремий випадок
Якщо ви використовуєте zero_copy (атрибути #[account(zero_copy)] в Anchor або ручна робота з Pod-трейтами), дані відображаються безпосередньо на пам'ять без серіалізації. У цьому випадку Rust-вирівнювання (alignment) працює повною мірою: компілятор може додавати заповнення між полями для вирівнювання за адресою.
Приклад: структура з полем u8 (1 байт, вирівнювання 1) та полем u64 (8 байтів, вирівнювання 8) у zero-copy займе 16 байтів замість 9 — 7 байтів заповнення між полями. У звичайному Borsh це було б 9 байтів.
Для zero-copy структур правило просте: групуйте поля за спаданням розміру вирівнювання (u64 та Pubkey спочатку, потім u32, потім u16, потім u8). Або використовуйте атрибут #[repr(C)] і явно контролюйте порядок. Перевіряйте фактичний розмір через std::mem::size_of::<YourStruct>().
Компресія повторюваних полів
Бітові маски замість масивів булевих значень
Масив Vec<bool> з N елементів у Borsh займає 4 + N байтів (4 байти на довжину + 1 байт на елемент). Заміна на u8 з бітовою маскою дозволяє зберегти до 8 прапорців у 1 байті без префікса довжини. Для 32 прапорців економія становить (4 + 32) − 4 = 32 байти. Для 256 прапорців — (4 + 256) − 32 = 228 байтів.
Реалізація: зберігайте бітову маску як [u8; K], де K = ceil(N / 8). Доступ до i-го біта: mask[i / 8] & (1 << (i % 8)).
Таблиці пошуку для повторюваних Pubkey
Це одна з найпотужніших оптимізацій для облікових записів, що зберігають колекції Pubkey. Кожен Pubkey займає 32 байти. Якщо в обліковому записі зберігається 100 Pubkey — це 3200 байтів лише на ключі.
Патерн: створюється окремий обліковий запис-таблиця пошуку (lookup account), який містить масив Pubkey. В основному обліковому записі замість Pubkey зберігаються індекси (u16, 2 байти) у цю таблицю. Економія на одному посиланні: 30 байтів. На 100 посиланнях: 3000 байтів.
Обмеження: цей підхід доцільний, коли одні й ті ж Pubkey повторюються між різними обліковими записами або всередині одного облікового запису. Якщо кожен Pubkey унікальний і не повторюється, таблиця пошуку лише додатково ускладнить архітектуру без економії.
Додаткова перевага: оновлення Pubkey в таблиці пошуку автоматично відображається на всіх облікових записах, що посилаються на цей індекс.
Коли компресія недоцільна
- Малі обсяги даних. Економія 10–20 байтів рідко виправдовує складність додаткової логіки серіалізації/десеріалізації та ризики помилок.
- Збільшення compute-витрат. Доступ до стиснених даних потребує додаткових обчислень (бітові операції, індексація в таблицю пошуку). Якщо обліковий запис читається часто в межах однієї транзакції, перевірте, чи не перевищить це ліміт compute units. Оцінку compute-витрат виконуйте окремо — це тема сусіднього розділу.
- Втрата зручності на клієнті. Якщо стиснуті дані потрібно часто розкодовувати на фронтенді, переконайтеся, що клієнтська бібліотека коректно працює з новим форматом.
Міграція даних при зміні схеми
Будь-яка зміна структури облікового запису, що змінює бінарний формат Borsh (додавання, видалення, перестановка полів, зміна типів), робить існуючі облікові записи несумісними з новим кодом. Міграція обов'язкова.
Перерозподіл через realloc
Системний виклик realloc дозволяє змінити розмір даних облікового запису без створення нового. Підходить для сценаріїв, де схема не змінюється, а потрібно лише звільнити зайвий простір (наприклад, після видалення елементів з Vec).
Передумови:
- Обліковий запис повинен бути writable.
- Платник (payer) повинен мати достатньо SOL для збереження rent-exempt статусу після збільшення (при зменшенні — додаткові SOL не потрібні).
- Перевірте актуальні ліміти на розмір перерозподілу за одну інструкцію в документації Solana — ці значення змінюються з оновленнями клієнта.
Очікуваний результат: dataLen облікового запису змінюється на вказане значення. При зменшенні розміру дані обрізаються з кінця — переконайтеся, що серіалізовані дані поміщаються в новий розмір до виклику realloc.
Важливий нюанс: при зменшенні розміру через realloc надлишковий SOL не повертається автоматично. Rent-exempt мінімум зменшується, але різниця залишається на балансі облікового запису. Щоб повернути SOL, потрібен окремий виклик інструкції, яка зніме зайві кошти (withdraw або close).
Ризик: якщо викликати realloc з меншим розміром, ніж фактичний обсяг серіалізованих даних, дані будуть пошкоджені. Завжди серіалізуйте дані в новий формат до виклику realloc і перевіряйте отриманий розмір.
Патерн двох облікових записів
Для зміни схеми (додавання/видалення полів, зміна типів) використовується патерн створення нового облікового запису з оптимізованою структурою, копіювання даних зі старого та закриття старого.
Кроки міграції в межах однієї інструкції:
- Створити новий обліковий запис з оптимізованою структурою (менший розмір, відповідний rent-exempt депозит).
- Десеріалізувати старий обліковий запис за старою схемою.
- Заповнити нову структуру даними зі старої, застосовуючи перетворення (звуження типів, компресію, заміну Pubkey на індекси тощо).
- Серіалізувати нову структуру та записати в новий обліковий запис.
- Закрити старий обліковий запис — rent-exempt депозит повертається вказаному адресату.
Переваги: атомарність (всі кроки в одній транзакції), повернення SOL з старого облікового запису, немає ризику пошкодження даних через realloc.
Обмеження: якщо обліковий запис є PDA (Program Derived Address), його адреса визначена seeds. Створення "нового" PDA з тими самими seeds неможливе — це той самий обліковий запис. У цьому випадку потрібен або realloc (якщо зміна схеми сумісна з існуючим обліковим записом), або перехід на нові seeds із міграцією посилань у всіх пов'язаних облікових записах.
План відкату та перевірка
Перед розгортанням міграції в mainnet:
- Реалізуйте міграцію на devnet. Створіть тестові облікові записи з реальними даними (не синтетичними — експортуйте з mainnet через RPC, якщо це допустимо з погляду безпеки).
- Перевірте розмір. Після міграції викличте getAccountInfo і порівняйте dataLen з очікуваним розміром нової структури. Вони мають збігатися байт-в-байт.
- Перевірте цілісність даних. Десеріалізуйте новий обліковий запис за новою схемою і порівняйте кожне поле з вихідними даними. Зверніть увагу на звуження типів — перевірте граничні значення, при яких може відбутися переповнення.
- Перевірте всі споживачі. Знайдіть усі інструкції та CPI-виклики, що читають цей обліковий запис, і переконайтеся, що вони працюють з новою схемою.
- Зафіксуйте план відкату. Якщо міграція використовує патерн двох облікових записів, відкат можливий лише якщо старий обліковий запис ще не закрито. Зберігайте старі облікові записи до повного верифікування нової схеми в production. Якщо міграція вже проведена і старі облікові записи закриті, відкат вимагає зворотної міграції (з нової схеми в стару) — реалізуйте її заздалегідь як окрему інструкцію.
Типова помилка: розгортання нової програми з оновленою схемою без міграції існуючих облікових записів. Результат — всі існуючі облікові записи стають недоступними для нової програми (помилка десеріалізації). Це не помилка інфраструктури, а логічна помилка розробника: зміна формату даних без перетворення наявних екземплярів.
Наступний логічний крок: після оптимізації розміру облікових записів та проведення міграції перевірте, чи не змінилася потреба програми в compute units — компактніші структури можуть зменшити обсяг серіалізації/десеріалізації, але додаткова логіка (індексація, розпакування) може її збільшити. Оцінку compute-витрат виконуйте окремо.