Моделювання даних для індексатора Solana визначає, наскільки швидко ваш застосунок зможе запитувати історичні стани, відновлюватися після збоїв і масштабуватися з ростом кількості транзакцій. Нижче наведено інженерний підхід до проєктування схеми, який враховує специфіку серіалізації Borsh, обмеження розміру облікових записів та необхідність валідації цілісності без повторних запитів до RPC.

Відображення облікових записів на реляційну схему

Кожен тип облікового запису програми відображається на окрему таблицю. Первинний ключ — публічний ключ облікового запису (32 байти, зберігається як `bytea` у PostgreSQL або `BLOB` у MySQL). Це гарантує унікальність і дозволяє обробляти перезапис облікових записів через `UPSERT`.

Базова структура таблиці для Anchor-програми:

  • pubkey — первинний ключ, `bytea(32)`
  • discriminator — `bytea(8)`, Anchor-дискримінатор типу
  • slot — `bigint`, слот останньої підтвердженої зміни
  • write_version — `bigint`, версія запису з RPC (допомагає виявляти гонки при індексації)
  • data — розпарсені поля згідно зі схемою облікового запису
  • raw_data — `bytea`, необов'язкове поле для повного збереження серіалізованих даних (корисно для відкладеного парсингу)

Зв'язки між обліковими записами відображаються через зовнішні ключі. Якщо обліковий запис `Order` містить поле `maker: Pubkey`, у реляційній схемі це стає стовпцем `maker bytea(32) REFERENCES accounts(pubkey)`. Уникайте циклічних залежностей: якщо два облікові записи посилаються один на одного, одну з посилань краще зберігати без обмеження `FOREIGN KEY` і перевіряти цілісність на рівні логіки індексатора.

PDA (Derived Program Address) не потребують окремої таблиці, але варто додати стовпець `is_pda boolean` і, за потреби, `pda_seeds jsonb` для зручного дебагу. Це не несе навантаження на запити, але суттєво полегшує діагностику в production.

Нормалізація проти денормалізації

Повна нормалізація (кожне поле-посилання — через зовнішній ключ) дає консистентність, але створює багато `JOIN` при типових запитах. Для індексаторів рекомендована гібридна стратегія: основні зв'язки нормалізовані, а часто запитувані агреговані дані (наприклад, поточний баланс токена для облікового запису) денормалізуються в окремий стовпець або матеріалізований вигляд. Оновлення денормалізованих даних виконуйте в межі тієї ж транзакції бази даних, що й основний `UPSERT`.

Обробка змінних розмірів даних

Облікові записи Solana мають фіксований максимальний розмір (10 MB, але на практиці рідко перевищують 10 KB через обмеження на виділення простору). Проте всередині облікового запису дані можуть мати змінну довжину: `Vec<t>`, `String`, опціональні поля.</t>

При серіалізації Borsh змінні масиви зберігаються як 4-байтовий префікс довжини (little-endian `u32`) і далі — елементи. При моделюванні є два підходи:

Парсинг у структуровані стовпці. Масиви з невеликою кількістю елементів (до ~50) розгортаються в окрему пов'язану таблицю. Наприклад, обліковий запис із полем `validators: Vec<pubkey>` відображається на основну таблицю `accounts` і дочірню `account_validators(account_pubkey, index, validator_pubkey)`. Це дозволяє індексувати окремі елементи масиву.</pubkey>

Збереження як JSONB. Для масивів складних структур або коли пошук по елементах не потрібен, дані зберігаються як `jsonb`. Це прискорює запис, але ускладнює запити. Вибирати цей варіант варто, лише якщо ви впевнені, що патерни запитів не вимагатимуть індексації по елементах масиву.

Обробка TLV-патернів Anchor

Починаючи з Anchor 0.29+, програми можуть використовувати TLV (Type-Length-Value) для розширення облікових записів без міграції. TLV-дані зберігаються як суцільний `bytea` у додатковому стовпці `tlv_data`. Парсинг TLV виконується окремим модулем індексатора, оскільки структура залежить від конкретної програми. Не намагайтеся розпарсити TLV під час основного `UPSERT` — це збільшить латентність обробки слота.

Ризики та межі

  • Масиви з тисячами елементів (наприклад, списки холдерів токена) не варто розгортати в рядки таблиці — це призведе до вибуху розміру бази даних. Для таких випадків використовуйте потокове читання з подальшою агрегацією або окремий процес збирання.
  • Змінні рядки (`String`) без обмеження довжини в схемі програми можуть бути довільними. Завжди додавайте обмеження `CHECK (length(field) <= N)` у схемі бази даних, де N відповідає логічному максимуму з контракту.

Індексація Anchor-дискримінаторів

Кожен Anchor-обліковий запис починається з 8-байтового дискримінатора — перших 8 байтів SHA-256 хешу рядка `"account:<accountname>"`. Дискримінатор дозволяє визначити тип облікового запису без повного десеріалізування, що критично для продуктивності індексатора.</accountname>

У базі даних дискримінатор зберігається як `bytea(8)`. Обов'язковий індекс:

  • Композитний індекс: `(discriminator, slot DESC)` — дозволяє ефективно фільтрувати облікові записи за типом і отримувати найсвіжіші стани.
  • Унікальний індекс: `(discriminator, pubkey)` — гарантує, що один обліковий запис не може мати два різних дискримінатори в межах однієї таблиці (це виявляє помилки парсингу).

Обробка не-Anchor програм

Для програм без Anchor дискримінатор відсутній. У такому разі використовуйте константний маркер (наприклад, `'\x00\x00\x00\x00\x00\x00\x00\x00'::bytea`) або взагалі приберіть стовпець `discriminator` з таблиць таких програм. Не намагайтеся уніфікувати схему для Anchor і не-Anchor програм в одну таблицю — це створить зайву складність без практичної користі.

Перевірка коректності дискримінатора

Під час обробки кожного оновлення облікового запису індексатор повинен перевіряти, що дискримінатор у серіалізованих даних збігається з очікуваним для цієї таблиці. Незбіг означає одну з двох речей: обліковий запис було закрито та перерозподілено під інший тип, або сталася помилка парсингу. У обох випадках запис повинен бути логований, а оновлення — відхилене.

Валідація цілісності даних

Індексатор працює з потоковими даними, де втрата або спотворення одного повідомлення може призвести до хронічної невідповідності з ланцюгом. Валідація поділяється на три рівні.

Рівень 1: Структурна валідація при записі

  • Розмір даних: довжина `raw_data` має збігатися з `data_len` з RPC-відповіді.
  • Власник: `owner` облікового запису має дорівнювати `program_id` програми, для якої створено цю таблицю.
  • Дискримінатор: перші 8 байтів мають збігатися з очікуваним значенням.
  • Рентабельність: якщо обліковий запис не позначений як `is_rent_exempt`, це має бути явно зафіксовано (такі облікові записи можуть бути видалені).

Рівень 2: Перехресна валідація

Періодично (наприклад, кожні 1000 слотів) індексатор повинен перевіряти вибірку облікових записів безпосередньо через RPC і порівнювати з даними в базі. Алгоритм:

  1. Вибрати 10–50 облікових записів випадковим чином з кожної таблиці.
  2. Отримати їхні поточні стани через `getAccountInfo`.
  3. Порівняти `data`, `lamports`, `owner` та `slot`.
  4. За невідповідності — записати інцидент і запустити повторну індексацію діапазону слотів, де сталася розбіжність.

Не порівнюйте `raw_data` побайтово, якщо ви зберігаєте лише розпарсені поля — порівняння виконуйте через повторний серіалізатор (Borsh serialize з вашої схеми) і порівняння результату з оригінальними байтами.

Рівень 3: Відновлення після розбіжностей

Якщо перехресна валідація виявила розбіжність, відновлення виконується повторною підпискою на діапазон слотів через WebSocket (`slotSubscribe` з фільтрацією) або через `getBlocks` з подальшим `getBlock`. План відкату:

  1. Зафіксувати поточний стан таблиці (timestamp, максимальний коректний слот).
  2. Видалити всі записи зі `slot` більшим за останній перевірений коректний слот.
  3. Перезапустити індексацію з цього слоту.
  4. Після завершення — повторити перехресну валідацію.

Якщо джерелом даних є geyser-плагін (потокова передача слотів безпосередньо від валідатора), переконайтеся, що ви зберігаєте `slot` і `parent_slot` для кожного оновлення. Це дозволяє виявляти пропущені слоти (розрив у послідовності `parent_slot`) і ініціювати відновлення до появи розриву.

Типові причини порушення цілісності

  • Перезапис облікового запису в межах одного слота: кілька транзакцій у одному слоті модифікують один обліковий запис. Індексатор повинен обробляти оновлення в порядку транзакцій усередині слоту, а не в порядку отримання повідомлень.
  • Закриття та перерозподіл облікового запису: обліковий запис закривається (lamports скидаються до нуля, data очищається), а потім той самий pubkey використовується для нового облікового запису в тому ж слоті. Без перевірки `write_version` індексатор може проігнорувати закриття.
  • Розрив у потоці слотів: при використанні WebSocket-підписки мережеві збої призводять до пропуску слотів. Без перевірки послідовності `parent_slot` це залишається непоміченим до наступної перехресної валідації.

Моделювання даних для індексатора — це не одноразова завдання проєктування схеми, а постійний компроміс між швидкістю запису, гнучкістю запитів і надійністю відновлення. Починайте з нормальної схеми з обов'язковими індексами на дискримінатор і слот, додавайте денормалізацію лише після профілювання реальних запитів, і завжди передбачайте механізм перевірки цілісності з можливістю локалізованого відкату.

Джерела