Посилання на статтю в моєму блозі
Тропічні ліси і фікуси-душителі
У тропічних лісах, де завжди тепло, волого і багато зелені живе одна цікава рослина. З незвичайною назвою - фікус-душитель. Чому він отримав таке ім'я? Як з фільму жахів.
Справа в тому, що в таких комфортних тропічних умовах у рослин виникає жорстка конкуренція. Сонячне світло закрито кронами потужних, вікових дерев. Їх міцне коріння викачує всі корисні ресурси з ґрунту - воду, мінерали. В таких умовах пробитися новому паростку вкрай складно. Але фікуси-душителі знайшли вихід. Їх насіння спочатку потрапляють на крони дерев, де багато світла. Там пускають свої втечі. Спочатку вони ростуть повільно. Але по мірі зростання їх коріння спускається вниз до самої землі, обвивають ствол дерева-носія. І як тільки вони добираються до землі - швидкість зростання подвоюється. Всі! Дні дерева-носія полічені. Тепер стовбур не може рости в шир, так як він обвитий фікусом і той його здавлює в своїх гарячих обіймах.
Крона дерева не може отримувати достатньо світла, тому що фікус забирає його собі. Його листя вище. Коріння фікусу висмоктують з ґрунту воду і поживні речовини, так що дереву носієві дістається все менше. У якийсь момент дерево-носій гине, але фікусу воно вже не потрібне. Його стеблі утворюють міцну основу, яка повторює силует своєї жертви. Зазвичай старе дерево повністю згниває в такому висновку і від нього не залишається і сліду. Однак зовнішній образ як і раніше залишається - його в точності повторює сам фікус:
Рефакторинг сервісу програми доставки продуктів
Часто буває необхідно розбити таблицю на дві, або винести частину методів сервісу в окремий сервіс. Добре, якщо ви можете зупинити вашу програму. Користувачі в цей час нічого не роблять - чекають оновлення. Ви поділяєте скриптами дані по таблицях і знову запускаєте програму - тепер користувачі можуть знову працювати. Але іноді такий сценарій неможливий.
Припустимо у нас є додаток, який дозволяє замовляти продукти з магазинів. Оплатити їх. У тому числі і бонусами. Очевидно, самі бонуси нараховуються якоюсь не тривіальною логікою: число покупок, вік, лояльність та інше.
Припустимо є такі дії, які у нас зберігаються в одній таблиці:
- Відкрити замовлення. У такому випадку оформляється сам факт відвідування замовлення та загальна сума. Поки він відкритий в нього можна додавати товари. Потім замовлення збирають, відправляють у доставку і в підсумку замовлення переходить у закритий статус.
- Можна оформити повернення товару. Якщо вам не сподобався кефір - ви оформляєте повернення і вам повертають його ціну.
- Можна списати бонуси з рахунку. У такому випадку частина вартості оплачується цими бонусами.
- Нараховуються бонуси. Яким-небудь алгоритмом - нам не важливо яким конкретно.
- Також замовлення може бути зареєстроване в деякому додатку-партнері (ExternalOrder)
Всі перерахована інформація за замовленнями і користувачам зберігається в таблиці (нехай вона буде називатися OrderHistory):
|
id |
operation_type |
status |
datetime |
user_id |
order_id |
loyality_id |
money |
|
234 |
Order |
Open |
2021-06-02 12:34 |
33231 |
24568 |
null |
1024.00 |
|
233 |
Order |
Open |
2021-06-02 11:22 |
124008 |
236231 |
null |
560.00 |
|
232 |
Refund |
null |
2021-05-30 07:55 |
3456245 |
null |
null |
-2231.20 |
|
231 |
Order |
Closed |
2021-05-30 14:24 |
636327 |
33231 |
null |
4230.10 |
|
230 |
BonusAccrual |
null |
2021-05-30 09:37 |
568458 |
null |
33231 |
500.00 |
|
229 |
Order |
Closed |
2021-06-01 11:45 |
568458 |
242334 |
null |
544.00 |
|
228 |
BonusWriteOff |
null |
2021-05-30 22:15 |
6678678 |
8798237 |
null |
35.00 |
|
227 |
Order |
Closed |
2021-05-30 16:22 |
6678678 |
8798237 |
null |
640.40 |
|
226 |
Order |
Closed |
2021-06-01 17:41 |
456781 |
2323423 |
null |
5640.00 |
|
225 |
ExternalOrder |
Closed |
2021-06-01 23:13 |
368358 |
98788 |
null |
226.00 |
Логіка такої організації даних цілком справедлива на ранньому етапі розробки системи. Адже напевно користувач може подивитися історію своїх дій. Де він одним списком бачить що він замовляв, як нараховувалися і списувалися бонуси. У такому випадку ми просто виводимо записи, що відносяться до нього за вказаний діапазон. Організувати у вигляді однієї таблиці - банальна економія на створенні додаткових таблиць, їх підтримці. Однак, у міру зростання бізнес-логіки і додавання нових типів операцій число стовпців з null значеннями почало зростати. Записів у таблиці - сотні мільйонів. Причому розподілені вони дуже нерівномірно. В основному це операції відкриття і закриття замовлень. Але ось операції нарахування бонусів складають 0.1% від загального числа, проте ці записи використовуються при розрахунку нових бонусів, що відбувається регулярно. У підсумку логіка розрахунку бонусів працює повільніше, ніж якби ці записи зберігалися в окремій таблиці. Ну і розширювати таблицю новими стовпцями не хотілося б надалі. Крім того замовлення в закритому статусі з датою створення більше 2 місяців для бізнес-логіки інтересу не представляють. Вони потрібні тільки для звітів - не більше.
І ось виникає ідея розділити таблицю на дві, три або навіть більше.
Проблема в тому, що ця таблиця - одна з найбільш активно використовуваних в системі (якраз через суміщення в собі даних для різних частин логіки). І простий при її рефакторингу вкрай небажаний.
Зміна структури зберігання у три етапи
Припустимо, що наш legacy монолітний додаток хоч і поганий, але не зовсім. Як мінімум зарезервовано. Тобто працює як мінімум два екземпляри. Так, що при падінні одного з них - другий продовжить обслуговувати користувачів:
Між користувачем і монолітом є проксі, яка з метою спрощення схеми можна не відображати. Просто врахуємо, що запит може випадковим чином виконуватися на будь-якому екземплярі.
Обидва екземпляри працюють з однією базою даних. Реалізуючи патерн Shared Database.
Перший крок до рефакторингу - випуск нової версії моноліту. Яка як і раніше працює зі старою таблицею, як і попередня версія. Але і пише дані в нову таблицю або таблиці. На схемі для наочності показана окрема база даних.
Окрема нова база даних цілком може з'явитися. Однак не завжди. Зважаючи на складнощі забезпечення транзакційності між двома БД. Все в кінцевому рахунку залежить від реалізації і від обмежень бізнес-логіки.
Стосовно нашого прикладу ми могли б отримати наступну структуру для нових таблиць.
BonusOperations:
|
id |
operation_type |
datetime |
user_id |
order_id |
loyality_id |
money |
|
230 |
BonusAccrual |
2021-05-30 09:37 |
568458 |
null |
33231 |
500.00 |
|
228 |
BonusWriteOff |
2021-05-30 22:15 |
6678678 |
8798237 |
null |
35.00 |
Окрему таблицю для даних із зовнішніх систем - ExternalOrders:
|
id |
status |
datetime |
user_id |
order_id |
money |
|
225 |
Closed |
2021-06-01 23:13 |
368358 |
98788 |
226.00 |
Для операцій із замовленнями молодше, ніж 2 тижні (припустимо, що обмеження бізнес-логіки було якраз визначено на цьому рівні. Адже якщо замовлення було зроблено більше двох тижнів тому його не можна скасувати, змінити і таке інше) нова таблиця OrderHistory зі зменшеним числом стовпчиків.
Для решти типів записів - OrderHistoryArchive (старше 2х тижнів). Де тепер також можна видалити кілька зайвих стовпчиків.
Виділення таких архівних даних часто буває зручним. Якщо оперативна частина дуже вимоглива до продуктивності - вона цілком може собі розміщується на швидких SSD дисках. У той час як архівні дані можуть використовуватися один раз на місяць для звіту. І їх більше в рази. Тому розміщуючи їх на дешевих дисках - ми економимо іноді цілком пристойну суму.
За схемою вище ми бачимо, що версія почала дублювати всю інформацію в нову структуру даних. Але поки використовує у своїй бізнес-логіці дані зі старої структури. Запит оброблений версією 2 записується саме в тому форматі, в якому його очікує версія 1. Запит оброблений версією 1 збереже дані, які також використовуються в роботі версії 2.
Моноліт версії 1 і моноліт версії 2 цілком можуть працювати спільно. Лише для тих запитів, які оброблялися монолітом версії 1 в новій базі даних будуть прогалини. Ці пробіли, а також відсутні дані можна буде надалі скопіювати окремим скриптом або утилітою.
Через якийсь час роботи версії 2 ми отримаємо заповнену нову базу даних. Якщо все добре, то ми готові до наступної стадії - переведення основної бізнес-логіки на нову базу даних.
У разі успіху просто видаляємо старі таблиці, оскільки всі дані вже збережені в новій структурі.
Отже. Зовні система ніколи не змінювалася. Однак внутрішня організація радикально змінилася. Можливо під капотом тепер працює нова система. Яка позбавлена недоліків попередньої. Не нагадує фікусів-душителів? Щось схоже є. Тому саме таку назву патерн і отримав - Strangler.
Очевидно, що аналогічним чином можна підходити до рефакторнгу не тільки структуру даних, але і коду. Наприклад, розділяти моноліт на мікросервіси.
Висновки
- Патерн Strangler дозволяє удосконалювати системи з високими вимогами до SLA.
- Для оновлення системи без простою потрібно зробити як мінімум 3 послідовних розгортання на продакшен. Це одна з причин, чому системи вимогливі до показників загального простою помітно дорожче.
- Для швидкої розробки нового функціоналу і рефакторингу потрібно вміти швидко проводити розгортання системи в продакшен. Тому одним з перших кроків при рефакторингу таких легасі систем - зменшення часу розгортання системи. Якщо ми повернемося до того ж фікусу-душителю - він залишився б звичайним бур'яном, якби не ріс набагато швидше дерева-носія.
Все перераховане вище має сенс тільки в тому випадку, якщо дійсно є необхідність. І якщо ми маємо можливість оновити систему ніч або в години найменшого навантаження, звичайно таким подарунком долі потрібно скористатися.
