Смяна на жива система (strangler fig, expand–contract)
Накратко: Strangler fig сменя стара система на стъпки, като пред нея стои рутер, който мести трафика към новата, а expand–contract сменя данните с двоен запис, backfill и помиряване.
Проблем
Голямото превключване
Модулът за поръчки е от 2016. Той смята сумата: под 50 € се плащат 5 € доставка, от 50 € нагоре доставката е безплатна. Екипът пише нов модул, с нов код и нова таблица. Планът е: в събота вечер се спира сайтът, старото се изключва, новото се пуска.
Помисли какво става, ако новото има грешка, която тестовете не са хванали. В понеделник купувачите от три държави виждат грешни суми. Връщането към старото вече е трудно: от събота новото пише поръчки в новата таблица, а старото не ги познава. Изборът е между лош и по-лош: да се поправя на живо, или да се връща назад и да се губят поръчки.
Проблемът не е в новия код. Проблемът е, че превключването е една голяма стъпка без връщане.
Идеята
Strangler fig: новото расте около старото
Името е на растение: смокинята удушвач покълва върху друго дърво, пуска корени надолу около него и с години го заменя. Дървото изчезва, а формата остава.
Така се сменя и система. Пред старото се слага посредник (тук Router), с който говори останалият магазин. В началото той праща всичко на старото. После новото поема малка част, после по-голяма, накрая всичко. Старото се изключва последно, когато вече не получава нищо. Това е strangler fig.
Всяка стъпка е малка и има връщане: ако новото се държи зле, процентът се сваля. Router е същото място като anti-corruption layer: граница, зад която може да се сменя каквото е отзад, без останалите да разберат.
Виж го
Router в код
Двата модула отговарят на един и същ въпрос: колко струва поръчката.
record Order(int id, int subtotal) {}
interface Orders { int total(Order order);}// старият модул (2016)class Legacy implements Orders { public int total(Order order) { int s = order.subtotal(); return s >= 50 ? s : s + 5; }}static final int FREE_FROM = 50, DELIVERY = 5;class Fresh implements Orders { public int total(Order order) { boolean free = order.subtotal() >= FREE_FROM; return order.subtotal() + (free ? 0 : DELIVERY); }}Legacy и Fresh изпълняват един интерфейс Orders, затова магазинът не знае кой от двата му отговаря. Router също е такъв модул:
class Router implements Orders { private final Orders legacy, fresh; private final int percent; int byLegacy = 0, byFresh = 0; final List<Integer> mismatches = new ArrayList<>();
Router(Orders legacy, Orders fresh, int percent) { this.legacy = legacy; this.fresh = fresh; this.percent = percent; }
public int total(Order order) { // една и съща поръчка винаги отива на едно място if (order.id() % 10 * 10 < percent) { byFresh++; return fresh.total(order); } byLegacy++; int answer = legacy.total(order); // в сянка: новото смята, но не отговаря if (fresh.total(order) != answer) mismatches.add(order.id()); return answer; }}Поръчката отива при новото, ако последната ѝ цифра по 10 е под процента: при 10% само цифрата 0, при 50% цифрите от 0 до 4. Когато отговаря старото, новото смята същата поръчка в сянка: резултатът му само се сравнява, купувачът не го вижда.
Виж го
В сянка
Първата версия на новото:
// първата версия на новото: забравена безплатна доставкаclass FreshDraft implements Orders { public int total(Order order) { return order.subtotal() + DELIVERY; }}Десет поръчки, при 0%:
var orders = List.of( new Order(1001, 30), new Order(1002, 64), new Order(1003, 18), new Order(1004, 45), new Order(1005, 120), new Order(1006, 9), new Order(1007, 50), new Order(1008, 22), new Order(1009, 75), new Order(1010, 40));var router = new Router(new Legacy(), new FreshDraft(), 0);for (Order o : orders) router.total(o);System.out.println("0%: старото " + router.byLegacy + ", новото " + router.byFresh);System.out.println("разлики: " + router.mismatches);0%: старото 10, новото 0разлики: [1002, 1005, 1007, 1009]Всичките 10 са отговорени от старото, и всички суми са верни. А списъкът казва, че новото се разминава при 4 поръчки, всичките от 50 € нагоре: забравило е безплатната доставка. Грешката е намерена върху истинските поръчки, без нито един купувач да я види. Поправката е Fresh от горе.
class Router implements Orders { private final Orders legacy, fresh; private final int percent; int byLegacy = 0, byFresh = 0; final List<Integer> mismatches = new ArrayList<>();
Router(Orders legacy, Orders fresh, int percent) { this.legacy = legacy; this.fresh = fresh; this.percent = percent; }
public int total(Order order) { // една и съща поръчка винаги отива на едно място if (order.id() % 10 * 10 < percent) { byFresh++; return fresh.total(order); } byLegacy++; int answer = legacy.total(order); // в сянка: новото смята, но не отговаря if (fresh.total(order) != answer) mismatches.add(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 разлики и с архив на данните му.
Виж го
Стъпка по стъпка
С поправеното ново, през целия план:
var orders = List.of( new Order(1001, 30), new Order(1002, 64), new Order(1003, 18), new Order(1004, 45), new Order(1005, 120), new Order(1006, 9), new Order(1007, 50), new Order(1008, 22), new Order(1009, 75), new Order(1010, 40));var legacy = new Legacy();var fresh = new Fresh();for (int percent : new int[] {0, 10, 50, 100}) { var router = new Router(legacy, fresh, percent); for (Order o : orders) router.total(o); System.out.printf("%3d%%: новото %d, старото %d, разлики %d%n", percent, router.byFresh, router.byLegacy, router.mismatches.size());} 0%: новото 0, старото 10, разлики 0 10%: новото 1, старото 9, разлики 0 50%: новото 5, старото 5, разлики 0100%: новото 10, старото 0, разлики 0Разликите остават 0 и на 10%, и на 50%. В истински магазин между две стъпки минават дни: всяка стъпка се гледа, докато грешките, времето за отговор и оплакванията не покажат, че е спокойно.
Изборът е по номера на поръчката, а не случаен. Така една поръчка винаги отива на едно място, и ако процентът се свали от 50 на 10, същите поръчки се връщат при старото. В истински проект изборът е по нещо, което държи купувача на едно място: номера на купувача или на кошницата.
Процентът е настройка (флаг), а не код: в истински проект се сменя без ново пускане, затова и връщането е за секунди.
Пробвай
Познай изхода
int percent = 30;for (int id : new int[] {1007, 1012, 1020}) System.out.println(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 и помиряване
Двете таблици тук са TreeMap (подредени по номер), а не база, за да се види логиката:
class Migration { // старата таблица (2016): сумата е текст с десетична запетая final Map<Integer, String> oldRows = new TreeMap<>(); // новата таблица: сумата е в центове final Map<Integer, Long> newRows = new TreeMap<>();
static long cents(String text) { String[] p = text.split(","); return Long.parseLong(p[0]) * 100 + Long.parseLong(p[1]); }
// двоен запис: всяка нова поръчка отива и в двете void place(int id, String total) { oldRows.put(id, total); newRows.put(id, cents(total)); }
// backfill: копира историята, без да пише върху новите редове void backfill() { for (var row : oldRows.entrySet()) { try { newRows.putIfAbsent(row.getKey(), cents(row.getValue())); } catch (NumberFormatException e) { System.out.println("✗ backfill " + row.getKey() + ": \"" + row.getValue() + "\""); } } }
// помиряване: всеки стар ред трябва да е и в новата таблица List<Integer> reconcile() { List<Integer> missing = new ArrayList<>(); for (int id : oldRows.keySet()) if (!newRows.containsKey(id)) missing.add(id); return missing; }}var m = new Migration();// историята: поръчки отпреди миграцията, само в старата таблицаm.oldRows.put(1001, "35,00");m.oldRows.put(1002, "64,00");m.oldRows.put(1003, "23,00");m.oldRows.put(1004, "5O,00");// от днес: двоен записm.place(1011, "42,50");m.place(1012, "99,90");m.backfill();var missing = m.reconcile();System.out.println("старо: " + m.oldRows.size() + ", ново: " + m.newRows.size() + ", липсват в новото: " + missing);System.out.println(missing.isEmpty() ? "четенето може да мине на новото" : "още не: първо се оправя разликата");✗ backfill 1004: "5O,00"старо: 6, ново: 5, липсват в новото: [1004]още не: първо се оправя разликатаПоръчка 1004 от 2016 има в сумата буква O вместо нула. Старият модул живее с това, а превръщането в центове не може. Backfill не я крие: казва я на глас и продължава. Помиряването я хваща и казва „още не“. Някой ще поправи реда в старата таблица, backfill ще мине пак, и помиряването ще покаже 0.
Backfill може да се пуска колкото пъти трябва: копира само това, което липсва. Двойният запис може да се изпълни като две писания в една транзакция (урока за базите данни) или през outbox, за да не остане запис само в едната таблица.
Пробвай
Попълни
Backfill не трябва да пише върху ред, който вече е в новата таблица. Как?
for (var row : oldRows.entrySet())
newRows.(row.getKey(), cents(row.getValue()));putIfAbsent записва само ако ключът липсва. Редът в новата таблица е дошъл от двойния запис, той е по-нов от копието на историята. Ако 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 ландшафт и капабилитита · Твоята първа архитектурна задача
- Задачи по темата: Архитектът в работа