Смяна на жива система (strangler fig, expand–contract)
Накратко: Strangler fig сменя стара система на стъпки, като пред нея стои рутер, който мести трафика към новата, а expand–contract сменя данните с двоен запис, backfill и помиряване.
Проблем
Голямото превключване
Модулът за поръчки е от 2016. Той смята сумата: под 50 € се плащат 5 € доставка, от 50 € нагоре доставката е безплатна. Екипът пише нов модул, с нов код и нова таблица. Планът е: в събота вечер се спира сайтът, старото се изключва, новото се пуска.
Помисли какво става, ако новото има грешка, която тестовете не са хванали. В понеделник купувачите от три държави виждат грешни суми. Връщането към старото вече е трудно: от събота новото пише поръчки в новата таблица, а старото не ги познава. Изборът е между лош и по-лош: да се поправя на живо, или да се връща назад и да се губят поръчки.
Проблемът не е в новия код. Проблемът е, че превключването е една голяма стъпка без връщане.
Идеята
Strangler fig: новото расте около старото
Името е на растение: смокинята удушвач покълва върху друго дърво, пуска корени надолу около него и с години го заменя. Дървото изчезва, а формата остава.
Така се сменя и система. Пред старото се слага посредник (тук Router), с който говори останалият магазин. В началото той праща всичко на старото. После новото поема малка част, после по-голяма, накрая всичко. Старото се изключва последно, когато вече не получава нищо. Това е strangler fig.
Всяка стъпка е малка и има връщане: ако новото се държи зле, процентът се сваля. Router е същото място като anti-corruption layer: граница, зад която може да се сменя каквото е отзад, без останалите да разберат.
Виж го
Router в код
Двата модула отговарят на един и същ въпрос: колко струва поръчката.
// старият модул (2016)class Legacy { total(order) { const s = order.subtotal; return s >= 50 ? s : s + 5; }}const FREE_FROM = 50;const DELIVERY = 5;class Fresh { total(order) { const free = order.subtotal >= FREE_FROM; return order.subtotal + (free ? 0 : DELIVERY); }}JavaScript няма интерфейси: стига Legacy и Fresh да имат метод total. Router също е такъв модул:
class Router { byLegacy = 0; byFresh = 0; mismatches = [];
constructor(legacy, fresh, percent) { this.legacy = legacy; this.fresh = fresh; this.percent = percent; }
total(order) { // една и съща поръчка винаги отива на едно място if ((order.id % 10) * 10 < this.percent) { this.byFresh++; return this.fresh.total(order); } this.byLegacy++; const answer = this.legacy.total(order); // в сянка: новото смята, но не отговаря if (this.fresh.total(order) !== answer) this.mismatches.push(order.id); return answer; }}Поръчката отива при новото, ако последната ѝ цифра по 10 е под процента: при 10% само цифрата 0, при 50% цифрите от 0 до 4. Когато отговаря старото, новото смята същата поръчка в сянка: резултатът му само се сравнява, купувачът не го вижда.
Виж го
В сянка
Първата версия на новото:
// първата версия на новото: забравена безплатна доставкаclass FreshDraft { total(order) { return order.subtotal + DELIVERY; }}Десет поръчки, при 0%:
const orders = [ { id: 1001, subtotal: 30 }, { id: 1002, subtotal: 64 }, { id: 1003, subtotal: 18 }, { id: 1004, subtotal: 45 }, { id: 1005, subtotal: 120 }, { id: 1006, subtotal: 9 }, { id: 1007, subtotal: 50 }, { id: 1008, subtotal: 22 }, { id: 1009, subtotal: 75 }, { id: 1010, subtotal: 40 },];const router = new Router(new Legacy(), new FreshDraft(), 0);for (const o of orders) router.total(o);console.log("0%: старото " + router.byLegacy + ", новото " + router.byFresh);console.log("разлики: " + "[" + router.mismatches.join(", ") + "]");0%: старото 10, новото 0разлики: [1002, 1005, 1007, 1009]Всичките 10 са отговорени от старото, и всички суми са верни. А списъкът казва, че новото се разминава при 4 поръчки, всичките от 50 € нагоре: забравило е безплатната доставка. Грешката е намерена върху истинските поръчки, без нито един купувач да я види. Поправката е Fresh от горе.
class Router { byLegacy = 0; byFresh = 0; mismatches = [];
constructor(legacy, fresh, percent) { this.legacy = legacy; this.fresh = fresh; this.percent = percent; }
total(order) { // една и съща поръчка винаги отива на едно място if ((order.id % 10) * 10 < this.percent) { this.byFresh++; return this.fresh.total(order); } this.byLegacy++; const answer = this.legacy.total(order); // в сянка: новото смята, но не отговаря if (this.fresh.total(order) !== answer) this.mismatches.push(order.id); return answer; }}Стъпка по стъпка, с думи
- Router стои пред стария модул за поръчки (2016) и новия. Магазинът говори само с Router, а той решава кой отговаря.
- 0%: старото отговаря на всичките 10. Новото смята в сянка и се разминава в 4: 1002, 1005, 1007, 1009. Всички са над 50 €: забравило е безплатната доставка.
- Поправено и пак 0%: 0 разлики. Купувачите не са видели нито една грешна сума.
- 10%: поръчката с последна цифра 0 (1010) отива при новото. Една поръчка винаги отива на едно място, затова купувачът не скача между двете.
- 50%: 5 от 10 при новото. Ако то се държи зле, връщането е едно число: пак 10, и същите поръчки се връщат при старото.
- 100%: новото отговаря на всичко. Старото не получава нищо, но още работи и още има данните си.
- Изключването на старото е точката без връщане. Тя е последна, след седмици с 0 разлики и с архив на данните му.
Виж го
Стъпка по стъпка
С поправеното ново, през целия план:
const orders = [ { id: 1001, subtotal: 30 }, { id: 1002, subtotal: 64 }, { id: 1003, subtotal: 18 }, { id: 1004, subtotal: 45 }, { id: 1005, subtotal: 120 }, { id: 1006, subtotal: 9 }, { id: 1007, subtotal: 50 }, { id: 1008, subtotal: 22 }, { id: 1009, subtotal: 75 }, { id: 1010, subtotal: 40 },];const legacy = new Legacy();const fresh = new Fresh();for (const percent of [0, 10, 50, 100]) { const router = new Router(legacy, fresh, percent); for (const o of orders) router.total(o); console.log(String(percent).padStart(3) + "%: новото " + router.byFresh + ", старото " + router.byLegacy + ", разлики " + router.mismatches.length);} 0%: новото 0, старото 10, разлики 0 10%: новото 1, старото 9, разлики 0 50%: новото 5, старото 5, разлики 0100%: новото 10, старото 0, разлики 0Разликите остават 0 и на 10%, и на 50%. В истински магазин между две стъпки минават дни: всяка стъпка се гледа, докато грешките, времето за отговор и оплакванията не покажат, че е спокойно.
Изборът е по номера на поръчката, а не случаен. Така една поръчка винаги отива на едно място, и ако процентът се свали от 50 на 10, същите поръчки се връщат при старото. В истински проект изборът е по нещо, което държи купувача на едно място: номера на купувача или на кошницата.
Процентът е настройка (флаг), а не код: в истински проект се сменя без ново пускане, затова и връщането е за секунди.
Пробвай
Познай изхода
const percent = 30;for (const id of [1007, 1012, 1020]) console.log(id + " " + ((id % 10) * 10 < percent ? "новото" : "старото"));1007 % 10 е 7, по 10 е 70, а 70 не е под 30: старото. 1012 дава 20, под 30: новото. 1020 дава 0, също новото. При 30% новото взема цифрите 0, 1 и 2.
Пробвай
Намери грешката
Колега пише по-кратък Router: „10% са 10%, нека е случайно“. Купувачите започват да пишат, че сумата в кошницата се мени при всяко презареждане. На кой ред е грешката?
Избери реда с грешката.
Изборът е случаен при всяко извикване: една и съща поръчка веднъж отива при старото, веднъж при новото. Докато двете не смятат еднакво, купувачът вижда две суми. И връщането на процента назад не връща същите поръчки. Изборът трябва да е по нещо постоянно, като id % 10 * 10 < percent в Router горе. Липсва и сянката: без нея никой не знае дали двете смятат еднакво.
Идеята
Данните: expand–contract
Кодът се сменя с Router. Данните са по-трудни: новото иска нова таблица. В старата сумата е текст с десетична запетая ("64,00"), в новата е число в центове (6400). А докато трафикът се мести, и двете таблици трябва да са верни.
В урока за миграциите промяната на колона беше на стъпки: разширяване, преход, свиване. Тук е същото, но за цяла таблица и с проверка:
- Expand: новата таблица се добавя до старата. Нищо не се маха.
- Двоен запис: всяка нова поръчка се записва и в двете.
- Backfill: историята (поръчките отпреди двойния запис) се копира в новата.
- Помиряване (reconcile): двете се сравняват, докато разликите станат 0.
- Четенето минава на новата, старата спира да се пише.
- Contract: старата таблица се маха.
Това е expand–contract. До стъпка 6 всяка може да се върне: старата таблица още е там и е вярна.
Виж го
Двоен запис, backfill и помиряване
Двете таблици тук са Map, а не база, за да се види логиката:
function cents(text) { const [euros, rest] = text.split(","); const value = Number(euros) * 100 + Number(rest); if (!Number.isInteger(value)) throw new RangeError(text); return value;}
class Migration { // старата таблица (2016): сумата е текст с десетична запетая oldRows = new Map(); // новата таблица: сумата е в центове newRows = new Map();
// двоен запис: всяка нова поръчка отива и в двете place(id, total) { this.oldRows.set(id, total); this.newRows.set(id, cents(total)); }
// backfill: копира историята, без да пише върху новите редове backfill() { for (const [id, total] of this.oldRows) { try { if (!this.newRows.has(id)) this.newRows.set(id, cents(total)); } catch { console.log(`✗ backfill ${id}: "${total}"`); } } }
// помиряване: всеки стар ред трябва да е и в новата таблица reconcile() { return [...this.oldRows.keys()].filter((id) => !this.newRows.has(id)); }}const m = new Migration();// историята: поръчки отпреди миграцията, само в старата таблицаm.oldRows.set(1001, "35,00");m.oldRows.set(1002, "64,00");m.oldRows.set(1003, "23,00");m.oldRows.set(1004, "5O,00");// от днес: двоен записm.place(1011, "42,50");m.place(1012, "99,90");m.backfill();const missing = m.reconcile();console.log("старо: " + m.oldRows.size + ", ново: " + m.newRows.size + ", липсват в новото: " + "[" + missing.join(", ") + "]");console.log(missing.length === 0 ? "четенето може да мине на новото" : "още не: първо се оправя разликата");✗ backfill 1004: "5O,00"старо: 6, ново: 5, липсват в новото: [1004]още не: първо се оправя разликатаПоръчка 1004 от 2016 има в сумата буква O вместо нула. Старият модул живее с това, а превръщането в центове не може. Backfill не я крие: казва я на глас и продължава. Помиряването я хваща и казва „още не“. Някой ще поправи реда в старата таблица, backfill ще мине пак, и помиряването ще покаже 0.
Backfill може да се пуска колкото пъти трябва: копира само това, което липсва. Двойният запис може да се изпълни като две писания в една транзакция (урока за базите данни) или през outbox, за да не остане запис само в едната таблица.
Пробвай
Попълни
Backfill не трябва да пише върху ред, който вече е в новата таблица. Как?
for (const [id, total] of this.oldRows)
if (!this.newRows.(id)) this.newRows.set(id, cents(total));has пита дали ключът го има, и set се вика само ако го няма. Редът в новата таблица е дошъл от двойния запис, той е по-нов от копието на историята. Ако backfill пише върху него, връща назад промяна, направена днес.
Пробвай
Подреди стъпките
backfill(); // историята в новатаswitchReads(); // четенето от новатаcontract(); // махане на старатаdualWrite(); // всеки запис и в дветеexpand(); // новата таблица до старатаreconcile(); // сравнение, докато разликите са 0stopOldWrites(); // старата само за четене
Идеята
Точката без връщане
Всяка стъпка дотук има връщане: процентът се сваля, четенето се връща на старата таблица, двойният запис продължава. Две стъпки нямат: изключването на стария модул и махането на старата таблица. След тях връщането е възстановяване от архив.
Затова те са последни и се решават нарочно. Преди тях: седмици с 0 разлики при помиряването, никакъв код, който чете или пише старото, и архив на данните. Добре е планът, критерият за всяка стъпка и как се връща да са записани в ADR, преди да се почне: по средата на миграцията всеки иска да я свърши по-бързо.
Една миграция, спряна на половина, е най-лошото: две системи, два набора данни и двоен запис, които се поддържат завинаги. Планът трябва да стига до contract.
Идеята
Кога вреди
- Малка система с малко данни. Ако спирането за половин час през нощта е приемливо и данните се местят за минути, Router, сянка и помиряване са седмици работа без полза. Една миграция с план за връщане (архив преди нея) стига.
- Когато няма къде да се сложи Router. Ако старото се вика директно от двайсет места, първо трябва една граница пред него (фасада, шина), и чак тогава може да се души.
- Сянката не е безплатна. Новото смята всичко още веднъж. Ако то праща имейли или взема пари, в сянка това не бива да се случва: сянката трябва да е без странични ефекти.
- Безкрайно „почти“. Ако 100% никога не идва (последните 5% са най-трудни), остават две системи за поддръжка. Без срок и критерий strangler fig се превръща в два монолита вместо един.
По-точно, за любопитните
Името е от статия на Martin Fowler от 2004 г. (Strangler Fig Application). Expand–contract е описан и като parallel change.
Router в истински проект най-често е в gateway или load balancer пред двете системи: правило по път (/orders към новото) или по процент. Те се пускат и наблюдават като инфраструктура, а това е в курса по DevOps ZeroToOps. Процентът често е feature flag: настройка, която се сменя без ново пускане (LaunchDarkly, Unleash, OpenFeature).
Миграциите на схемата се пишат с инструменти като Flyway и Liquibase (Java), EF Core Migrations (.NET), Alembic (Python), Prisma или Knex (JavaScript). Те пускат стъпките по ред, но не решават кога: това е планът expand–contract.
Вместо двоен запис в кода понякога се ползва CDC (change data capture): инструмент като Debezium чете дневника на старата база и пренася всяка промяна в новата. Кодът не се пипа, но се добавя още една система за наблюдение.
Сравнението в сянка в голям мащаб се прави асинхронно: отговорите се записват и се сравняват после, за да не забавя новото купувача.
Упражнение
Помиряване по стойност
Реши задачата в редактора си.
Помиряването горе проверява само дали редът го има. Напиши reconcile, която сравнява две таблици със суми в центове и връща по ред за всяка разлика: ред само в старата, ред само в новата, или различна сума. Номерата са по ред.
Старата таблица: 1004 → 5000, 1011 → 4250, 1012 → 9900. Новата: 1011 → 4250, 1012 → 9990, 1013 → 1800.
1004: само в старото1012: старото 9900, новото 99901013: само в новоторазлики: 3Още задачи
- По купувач. Смени Router да избира по номера на купувача вместо по номера на поръчката. Добави поле за купувача и пусни две поръчки на един купувач при 50%.
- Връщане назад. Пусни плана 0, 10, 50, 10, 100 и провери, че при второто 10% са същите поръчки, като при първото.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Дизайн на API: договор, версии, повторени заявки · ADR: записани архитектурни решения · Транзакция през няколко услуги (saga) · Интеграция със стари и чужди системи
- Продължава в: Enterprise ландшафт и капабилитита · Твоята първа архитектурна задача
- Задачи по темата: Архитектът в работа