Дякую за питання!
1. Про витоки. Це не теорія. Я підняв sidecar PgBouncer у transaction mode і гнав навантаження. RAW-режим реально впав із тисячами LEAKS і FAIL_CLOSED. Ще важливіший момент: під час фінальної перевірки APP-режим один раз упав із 1 leak на 1000 запитів. Причина — reset app.is_admin йшов поза BEGIN і потрапляв на інший backend, ніж запит. Тому довелося нейтралізувати is_admin вже всередині транзакції.
2. Про is_admin замість окремої ролі. Тут ви маєте рацію: окремий admin-пул чистіше і безпечніше. У мене це is_admin — прагматичний компроміс, бо вузькі операції (міграції, APScheduler) потрібні рідко, і заводити окремий пул лише задля них — оверхед. Роблю це безпечно: transaction-local GUC + примусовий скид всередині транзакції + Semgrep, який забороняє session-level прапорці. Якби проєктував з нуля — взяв б окрему роль.
3. Про Redis lock. SET NX EX — атомарна операція в Redis, тому двоє не можуть одночасно отримати lock. Токен із pid+uuid захищає від того, щоб не скинути чужий. TTL + refresh кожні 5 с — захист від завислого лідера. Ви праві, що готовий Lock() — надійніша обгортка, і для нового коду я б рекомендував саме його. Мій ручний приклад — мінімалістична імплементація, яку було простіше вписати в існуючий код.
Ще раз дякую — ці питання показують, що в статті варто було чіткіше пояснити trade-offs.
Дуже цікавий коментар, я справді ще не мав досвіду з цим, тож цікаво було про це дізнатися. Дякую! 🙂
Дякую за крутий фідбек та реальні приклади з платіжних систем! 🙂
Ось саме тому я і написав цю статтю 🙂 Якщо хоча б один розробник після прочитання не допустить цей баг — вже варто було.
Decimal — це класичний і правильний вибір для більшості проєктів. Але у великих SaaS-системах з купою інтеграцій у нього з’являються свої болючі нюанси.
По-перше, це серіалізація в API. JSON не знає, що таке Decimal. Довелося б або віддавати його як рядок (і змушувати TypeScript-фронтенд парсити його назад), або конвертувати у float для мережі (що перекреслює всю точність). Ціле число в копійках проходить крізь будь-які API, мобільні додатки та вебхуки без жодного викривлення типів.
По-друге, це база даних. У Postgres BIGINT зберігається, індексується і працює швидше, ніж довільний NUMERIC чи DECIMAL під великим навантаженням.
Ну і нативна арифметика з int у Python все одно швидша та легша за Decimal. Тому int в базі — це вибір на користь простоти інтеграцій та швидкості роботи БД!
Ви праві, це класична проблема попозиційного заокруглення.
У фінтеху та е-комерс її вирішують двома стандартними шляхами:
1) Розрахунок від загальної суми: податок або акциз нараховується не на кожен товар окремо, а на сумарну вартість усього кошика, і заокруглюється лише один раз наприкінці. Це зводить максимальну похибку до 0.5 копійки на все замовлення.
2) Внутрішня висока точність: для проміжних розрахунків у пам’яті (податки, знижки, комісії) ми використовуємо базисні пункти (basis points, 1/10000) або умовні «мікрокопійки», а фінальне заокруглення до цілих копійок робимо вже один раз — при записі фінального інвойсу в базу та відправці на платіжний шлюз.
Саме тому в статті я зазначив, що висока точність використовується як проміжне значення в пам’яті під час обчислень, але фінальний результат у БД завжди фіксується як ціле число (int).
Дякую за чудове питання. Поправте мене, якщо я десь помиляюсь.
Те, що САБ Казначейства працює в копійках — це найкращий доказ для всієї статті 🤝
Ви порушили дуже важливе питання щодо розподілу залишкових копійок. Дійсно, якщо ми ділимо умовну 101 копійку порівну, виникає оця «залишкова» копійка, яку неможливо розділити фізично.
Я вирішую це досить просто: округлюю частки до найближчої копійки донизу (floor), а весь залишок (зазвичай 1 копійку) за логікою бізнесу примусово докидаю на головний рахунок отримувача.
Це вимагає додаткових перевірок у коді, але повністю убезпечує від фінансових дірок, які обов’язково створив би float.
Дякую за такий крутий коментар і за те, що підсвітили цю тему!
Дякую за коментар!
Повністю згоден з вами щодо простоти int. Коли система масштабується і доводиться передавати гроші через API, черги повідомлень та різні фронтенди, серіалізувати звичайні цілі числа в JSON набагато безпечніше та простіше, ніж тягнути Decimal з його специфікою через усі інтеграційні шари.
Щодо IEEE 754 — ви абсолютно праві. Я свідомо не хотів перевантажувати статтю лонгридом про теорію двійкового кодування дробів та нескінченні періодичні дроби, але ви сформулювали це максимально лаконічно та влучно. Дякую за чудове доповнення до матеріалу!
У CPython немає поняття «числа на стеку» — усі змінні (і int, і float) під капотом є об’єктами (PyObject) і алокуються виключно на купі (heap). Тому в контексті Python-розробки (про яку стаття) цей аргумент не зовсім релевантний.
Щодо бібліотек, які «декодують фінансові значення у float в пам’яті для швидкості» — у фінтех-системах це зазвичай вважається критичним архітектурним дефектом. У пам’яті для грошей використовують або decimal.Decimal, або int (копійки). Використовувати float в оперативній пам’яті для транзакцій — це невиправданий ризик втрати точності.
У протоколі FIX тип Qty (кількість) змінили з int на float виключно через появу дробових лотів (наприклад, купівля 0.001 BTC чи 1.5 акцій). Кількість — це не гроші. Баланси рахунків та транзакційні суми (Amt) у float не рахують.
Дякую за дискусію, але я все ж таки орієнтувався на сучасні стандарти Stripe та Adyen, а не на застарілі компроміси.
Використання float у системах деяких банків США — це не архітектурний орієнтир, а технічний борг, з яким вони борються роками. Сучасні платіжні гіганти (Stripe, Shopify та інші) у своїх API працюють виключно з integer у мінімальній одиниці валюти. У міжнародних стандартах транзакцій (ISO 8583) типу float немає взагалі.
Писати про це на професійному ресурсі варто саме тому, що навіть досвідчені інженери іноді намагаються виправдати плаваючу точку у фінансових розрахунках.
Саме так! У базі копійки зберігаються якраз у BIGINT — про всяк випадок, якщо обороти SmartHire долетять до масштабів держборгу США 🙂
Мені довелося погуглити, що таке «лампорти», але логіка на 100% правильна: рахувати все в мінімальних неподільних одиницях — це єдиний надійний шлях для будь-якої системи.
Дякую за підтримку та цікаве доповнення!
Мій бакалаврат — «Фінанси та банківська справа», якщо цікаво 🙂
Щодо float з епсилонами — це валідний підхід для наукових обчислень чи геймдеву.
Але жоден банк, Stripe чи будь-яка платіжна система не зберігає транзакції у float. Тільки цілі числа. Це не думка — це галузевий стандарт.
Про банківське округлення (ROUND_HALF_EVEN), до речі, я якраз і написав у статті — ми використовуємо його для розрахунку відсотків.
Дякую! Про маркетинг і залучення — повністю згоден. Зараз сам на цьому етапі 🙂
600 таблиць і ізоляція «на совісті розробників» — це саме той сценарій, якого я намагаюся уникнути з першого дня 😅
Мігрувати RLS на такому масштабі — це справді нетривіальна задача. Тут, мабуть, реалістичніше не «додати RLS на всі 600 таблиць», а виділити критичні таблиці з PII та фінансовими даними і покрити їх в першу чергу. На масштабі це стає операційним кошмаром, як ви вже, мабуть, переконались.
А що банк конкретно вимагає? Audit log на рівні БД, чи саме policy-based ізоляцію?
Класична ситуація коли комплаєнс приходить пізніше архітектури 😄
Для спільних проєктів між тенантами RLS ускладнюється — потрібна таблиця-зв’язка типу project_tenant_access з явними правами, а policy виглядає приблизно так:
CREATE POLICY project_access ON projects
USING (
EXISTS (
SELECT 1 FROM project_tenant_access
WHERE project_id = projects.id
AND tenant_id = current_setting('app.tenant_id')
)
);Тоді банк бачить ізоляцію на рівні БД, а не тільки логіки аплікації.
Але якщо архітектура вже велика, то міграція на RLS це окремий біль. Як у вас зараз реалізована ізоляція на рівні аплікації?
Погоджуюсь, циклічні імпорти — це скоріше сигнал про проблему в архітектурі ніж рішення.
У моєму випадку це легасі з раннього етапу, поступово рефакторю. Актуальних бенчмарків поки немає — рішення було прийнято інтуїтивно на ранньому етапі. Це хороший привід виміряти і переглянути підхід. Дякую за зауваження, є над чим подумати!
Гарне зауваження щодо GIL! Ви правду кажете, що потоки в CPython не дають справжнього паралелізму для CPU-bound задач через GIL. Але to_thread тут вирішує іншу проблему — не прискорення шифрування, а звільнення event loop від блокування. AES-256 на одному телефонному номері виконується за мікросекунди, тому thread contention на практиці незначний. Якщо б таких операцій було тисячі одночасно — так, краще ProcessPoolExecutor.
Щодо lazy imports — допомагає зменшити час старту модуля і уникнути циклічних імпортів у великій кодовій базі. Але погоджуюсь, для статті це не найкращий приклад.
Дякую за фідбек! Імпорти всередині функцій — свідоме рішення для lazy loading у великій кодовій базі, але погоджуюсь що для прикладу в статті це виглядає неохайно.
Щодо to_thread — гарне зауваження, варто було розкрити.
Коротко: encrypt_phone — це CPU-bound операція (AES-256), і щоб не блокувати event loop asyncio, вона виконується в окремому потоці через to_thread. Можливо винесу це в окрему замітку.
Дякую! Так, мульти-тенант стафф з кількома тенантами — це окремий біль. У мене це вирішено через tenant_staff з ролями.
Щодо float — інженерна параноя тут виправдана 😄
У другій частині розберу більш детально з прикладами.
Радий однодумцям!
Ось я про це заздалегідь подумав і вирішив перевірити)