Concurrent writes до одного облікового запису в Solana призводять до відхилення однієї з транзакцій із помилкою AccountInUse. Це не баг runtime, а наслідок детермінованого паралельного виконання в Sealevel. Нижче наведено інженерний розбір причини конфліктів, перевірені патерни їх уникнення та методи моніторингу, які можна застосувати в production без зміни архітектури валідатора.
Природа конфліктів записів у Solana
Sealevel — рушій виконання Solana — обробляє транзакції в межах одного слота паралельно, якщо їхні множини облікових записів не перетинаються у режимі запису. Коли дві транзакції в одному слоті мають спільний обліковий запис із правом writable, runtime блокує цей обліковий запис для першої транзакції, а другу відхиляє з помилкою TransactionError::AccountInUse.
Ключові факти, які визначають поведінку:
- Читання не конфліктує. Дві транзакції можуть одночасно читати один обліковий запис, якщо жодна з них не має прапорця writable.
- Конфлікт прив'язаний до слота. Транзакції в різних слотах не конфліктують між собою, навіть якщо пишуть в один обліковий запис.
- Порядок у слоті не гарантується. Runtime не застосовує FIFO для транзакцій, що надійшли від одного клієнта. Порядок визначається внутрішнім планувальником валідатора.
- Підтвердження не означає успіх наступної. Навіть якщо перша транзакція підтверджена, друга, надіслана слідом, може опинитися в тому ж слоті й отримати AccountInUse.
Типовий сценарій: користувач натискає кнопку двічі, фронтенд формує дві транзакції з однаковим writable обліковим записом, обидві потрапляють в один слот — одна успішна, інша відхилена. У high-throughput системах (маркетплейси, ігрові рушії, агрегатори) така ситуація виникає системно, а не через помилку користувача.
Патерни чергування транзакцій
Оскільки runtime не надає примітивів блокування на рівні облікових записів, серіалізацію записів треба реалізовувати на стороні клієнта або через програмну логіку на ланцюзі.
Послідовна відправка з підтвердженням
Найпростіший патерн: клієнт відправляє транзакцію, чекає фінального підтвердження (finalized або хоча б confirmed) і лише тоді формує наступну з тим самим writable обліковим записом.
Передумови: клієнт має доступ до RPC-вузла з достовірним підтвердженням статусу транзакції.
Перевірка: після кожної відправки викликати getSignatureStatuses і переконатися, що статус не є null і err дорівнює null перед відправкою наступної.
Обмеження: затримка між транзакціями складається з часу обробки слота (≈400 мс) та кількості слотів до фіналізації (залежить від конфігурації). Для послідовності з 10 транзакцій це мінімум 4–6 секунд.
Durable nonce як заміна recent blockhash
Коли транзакція створюється з recentBlockhash, вона дійсна лише в межах приблизно 60–90 секунд. Якщо клієнт чекає підтвердження і цей час витікає, наступна транзакція з тим самим nonce буде відхилена як BlockhashNotFound.
Durable nonce (облікововий запис типу System з інструкцією AdvanceNonceAccount) дозволяє створювати транзакції, які не мають часового вікна дійсності за blockhash. Це корисно, коли черга транзакцій формується заздалегідь або коли затримки між відправками непередбачувані.
Ризик: якщо транзакція з durable nonce відхилена з іншої причини (наприклад, InstructionError), nonce вже просунуто, і повторна відправка тієї самої транзакції неможлива — треба формувати нову.
Партіціонування стану
Замість серіалізації доступу до одного облікового запису — розділити стан на кілька незалежних облікових записів, кожен з яких обслуговує окремий сегмент навантаження.
Приклад: замість одного облікового запису global_state, у який пишуть усі користувачі, створити N облікових записів shard_state_0…shard_state_N-1 і маршрутизувати запис за хешем від ідентифікатора користувача.
Критерій вибору: патерн має сенс, коли логіка дозволяє консистентно зводити результати з різних шардів під час читання, а частота записів на один шард не перевищує одну транзакцію на слот.
Обмеження: ускладнює читання — клієнт або програма має агрегувати дані з кількох облікових записів, що збільшує compute budget при складних запитах.
Використання PDA для серіалізації доступу
Program Derived Address (PDA) можна використовувати як програмний «м'ютекс»: створити PDA-обліковий запис, який слугує маркером зайнятості ресурсу. Транзакція, що хоче писати в цільовий обліковий запис, спочатку перевіряє і захоплює lock-обліковий запис.
Логіка роботи:
- Програма має інструкцію acquire_lock, яка записує в PDA-обліковий запис ідентифікатор заявника (наприклад, публічний ключ підписувача) і мітку часу слота.
- Інструкція do_work перевіряє, що lock належить поточному підписувачу, і виконує зміну цільового облікового запису.
- Інструкція release_lock скидає PDA-обліковий запис у вільний стан.
Критичне обмеження: цей патерн не усуває конфлікт AccountInUse. Він переміщує конфлікт з цільового облікового запису на lock-обліковий запис. Якщо дві транзакції в одному слоті спробують викликати acquire_lock для одного PDA, одна з них отримає AccountInUse на рівні lock-обліковика.
Реальна цінність PDA-серіалізації — не в усуненні конфліктів на рівні слота, а в:
- Захисті від логічних гонок. Навіть якщо транзакції потрапляють у різні слоти, без lock-механізму друга транзакція може перевірити стан, який уже застарів після першої. PDA-lock гарантує, що логіка check-then-act атомарна в межах однієї транзакції.
- Уникненні подвійних операцій. Наприклад, подвійне списання токенів, коли дві транзакції в різних слотах читають однаковий баланс і обидва списують.
Коли PDA-lock доречний: коли є ризик логічної гонки між транзакціями в різних слотах, а не для вирішення AccountInUse у межах одного слота.
Коли PDA-lock марний: коли проблема виключно в тому, що кілька транзакцій потрапляють в один слот. У цьому випадку потрібне клієнтське чергування або партіціонування.
Відкат: якщо lock-обліковий запис залишився зайнятим через невдалу транзакцію (наприклад, do_work впав з InstructionError, а release_lock не виконався), потрібна окрема інструкція force_release з перевіркою таймауту (наприклад, якщо пройшло більше 150 слотів із моменту захоплення). Цю інструкцію слід захистити додатковою авторизацією (authority), щоб уникнути витіснення легітимних заявників.
Моніторинг частоти конфліктів
Без моніторингу неможливо відрізнити одиничні випадкові конфлікти від системної проблеми, що вимагає зміни архітектури.
Збір даних
Основне джерело — аналіз відхилених транзакцій. Для кожної транзакції, що повернула помилку, перевіряти поле err у відповіді getSignatureStatuses. Помилка AccountInUse має таку структуру в JSON-представленні:
- TransactionError::AccountInUse — вказує на конфлікт запису в межах слота.
Для систематичного збору:
- На стороні сервісу, що відправляє транзакції, логувати всі відповіді від RPC з ненульовим err.
- Розрізняти помилки префлайту (preflight failure, коли RPC-вузол відхиляє транзакцію до передачі валідатору) і помилки на ланцюзі (транзакція потрапила в слот, але була відхилена runtime). AccountInUse — це помилка на ланцюзі.
- Агрегувати за віконами часу (наприклад, 1 хвилина) і за обліковими записами, які викликали конфлікт.
Метрики, які варто відстежувати
| Метрика | Опис | Де обчислювати |
|---|---|---|
| conflict_rate_total | Відношення кількості AccountInUse до загальної кількості відправлених транзакцій за період | Клієнтський сервіс |
| conflict_rate_per_account | Те саме, але розбите за pubkey облікового запису, що викликав конфлікт | Клієнтський сервіс |
| conflict_slot_correlation | Чи збігаються конфліктні транзакції в один слот (потребує додаткового запиту getBlock за slot транзакції) | Аналітичний pipeline |
| retry_success_rate | Який відсоток конфліктних транзакцій успішно проходить при повторній відправці в наступному слоті | Клієнтський сервіс |
Коли конфлікти — проблема, а коли — норма
Орієнтовні порогові значення (перевірте для вашого конкретного навантаження, ці цифри не є універсальними стандартом):
- conflict_rate_total < 1% — зазвичай не потребує втручання. Повторна відправка вирішує проблему.
- conflict_rate_total 1–5% — варто проаналізувати conflict_rate_per_account. Якщо конфлікти зосереджені на 1–2 облікових записах — це кандидат на партіціонування або перегляд логіки доступу.
- conflict_rate_total > 5% — архітектурна проблема. Серіалізація на клієнті або партіціонування стану є обов'язковими.
Алертинг
Налаштуйте сповіщення не на кожен конфлікт, а на перевищення порогу conflict_rate_total за вікном 5 хвилин. Додатково — на появу нового облікового запису в топ-3 conflict_rate_per_account, якщо раніше його не було. Це дозволяє виявити регресію до того, як вона вплине на користувацький досвід.
Перевірка коректності моніторингу: штучно відправте дві транзакції з однаковим writable обліковим записом з мінімальним інтервалом через скрипт. Переконайтеся, що одна з них з'явилася в логах як AccountInUse і відобразилася в метриках.
План відкату: якщо зміна в логіці чергування або партіціонуванні призвела до зростання інших помилок (наприклад, ComputeBudgetExceeded через агрегацію шардів), поверніться до попередньої версії серіалізації і перевірте метрики конфліктів за попередні 24 години для порівняння.