Смяна на жива система (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 IOrders{ int Total(Order order);}// старият модул (2016)class Legacy : IOrders{ public int Total(Order order) => order.Subtotal >= 50 ? order.Subtotal : order.Subtotal + 5;}static class Shop{ public const int FreeFrom = 50, Delivery = 5;}class Fresh : IOrders{ public int Total(Order order) { var free = order.Subtotal >= Shop.FreeFrom; return order.Subtotal + (free ? 0 : Shop.Delivery); }}Legacy и Fresh изпълняват един интерфейс IOrders, затова магазинът не знае кой от двата му отговаря. Router също е такъв модул:
class Router(IOrders legacy, IOrders fresh, int percent) : IOrders{ public int ByLegacy { get; private set; } public int ByFresh { get; private set; } public List<int> Mismatches { get; } = [];
public int Total(Order order) { // една и съща поръчка винаги отива на едно място if (order.Id % 10 * 10 < percent) { ByFresh++; return fresh.Total(order); } ByLegacy++; var answer = legacy.Total(order); // в сянка: новото смята, но не отговаря if (fresh.Total(order) != answer) Mismatches.Add(order.Id); return answer; }}Поръчката отива при новото, ако последната ѝ цифра по 10 е под процента: при 10% само цифрата 0, при 50% цифрите от 0 до 4. Когато отговаря старото, новото смята същата поръчка в сянка: резултатът му само се сравнява, купувачът не го вижда.
Виж го
В сянка
Първата версия на новото:
// първата версия на новото: забравена безплатна доставкаclass FreshDraft : IOrders{ public int Total(Order order) => order.Subtotal + Shop.Delivery;}Десет поръчки, при 0%:
Order[] orders = [ new(1001, 30), new(1002, 64), new(1003, 18), new(1004, 45), new(1005, 120), new(1006, 9), new(1007, 50), new(1008, 22), new(1009, 75), new(1010, 40),];var router = new Router(new Legacy(), new FreshDraft(), 0);foreach (var o in orders) router.Total(o);Console.WriteLine($"0%: старото {router.ByLegacy}, новото {router.ByFresh}");Console.WriteLine($"разлики: [{string.Join(", ", router.Mismatches)}]");0%: старото 10, новото 0разлики: [1002, 1005, 1007, 1009]Всичките 10 са отговорени от старото, и всички суми са верни. А списъкът казва, че новото се разминава при 4 поръчки, всичките от 50 € нагоре: забравило е безплатната доставка. Грешката е намерена върху истинските поръчки, без нито един купувач да я види. Поправката е Fresh от горе.
class Router(IOrders legacy, IOrders fresh, int percent) : IOrders{ public int ByLegacy { get; private set; } public int ByFresh { get; private set; } public List<int> Mismatches { get; } = [];
public int Total(Order order) { // една и съща поръчка винаги отива на едно място if (order.Id % 10 * 10 < percent) { ByFresh++; return fresh.Total(order); } ByLegacy++; var 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 разлики и с архив на данните му.
Виж го
Стъпка по стъпка
С поправеното ново, през целия план:
Order[] orders = [ new(1001, 30), new(1002, 64), new(1003, 18), new(1004, 45), new(1005, 120), new(1006, 9), new(1007, 50), new(1008, 22), new(1009, 75), new(1010, 40),];var legacy = new Legacy();var fresh = new Fresh();foreach (var percent in new[] { 0, 10, 50, 100 }){ var router = new Router(legacy, fresh, percent); foreach (var o in orders) router.Total(o); Console.WriteLine($"{percent,3}%: новото {router.ByFresh}, старото {router.ByLegacy}, разлики {router.Mismatches.Count}");} 0%: новото 0, старото 10, разлики 0 10%: новото 1, старото 9, разлики 0 50%: новото 5, старото 5, разлики 0100%: новото 10, старото 0, разлики 0Разликите остават 0 и на 10%, и на 50%. В истински магазин между две стъпки минават дни: всяка стъпка се гледа, докато грешките, времето за отговор и оплакванията не покажат, че е спокойно.
Изборът е по номера на поръчката, а не случаен. Така една поръчка винаги отива на едно място, и ако процентът се свали от 50 на 10, същите поръчки се връщат при старото. В истински проект изборът е по нещо, което държи купувача на едно място: номера на купувача или на кошницата.
Процентът е настройка (флаг), а не код: в истински проект се сменя без ново пускане, затова и връщането е за секунди.
Пробвай
Познай изхода
int percent = 30;foreach (var id in new[] { 1007, 1012, 1020 }) Console.WriteLine($"{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 и помиряване
Двете таблици тук са SortedDictionary (подредени по номер), а не база, за да се види логиката:
class Migration{ // старата таблица (2016): сумата е текст с десетична запетая public SortedDictionary<int, string> OldRows { get; } = []; // новата таблица: сумата е в центове public SortedDictionary<int, long> NewRows { get; } = [];
static long Cents(string text) { var p = text.Split(','); return long.Parse(p[0]) * 100 + long.Parse(p[1]); }
// двоен запис: всяка нова поръчка отива и в двете public void Place(int id, string total) { OldRows[id] = total; NewRows[id] = Cents(total); }
// backfill: копира историята, без да пише върху новите редове public void Backfill() { foreach (var (id, total) in OldRows) { try { NewRows.TryAdd(id, Cents(total)); } catch (FormatException) { Console.WriteLine($"✗ backfill {id}: \"{total}\""); } } }
// помиряване: всеки стар ред трябва да е и в новата таблица public List<int> Reconcile() => OldRows.Keys.Where(id => !NewRows.ContainsKey(id)).ToList();}var m = new Migration();// историята: поръчки отпреди миграцията, само в старата таблицаm.OldRows[1001] = "35,00";m.OldRows[1002] = "64,00";m.OldRows[1003] = "23,00";m.OldRows[1004] = "5O,00";// от днес: двоен записm.Place(1011, "42,50");m.Place(1012, "99,90");m.Backfill();var missing = m.Reconcile();Console.WriteLine($"старо: {m.OldRows.Count}, ново: {m.NewRows.Count}, липсват в новото: [{string.Join(", ", missing)}]");Console.WriteLine(missing.Count == 0 ? "четенето може да мине на новото" : "още не: първо се оправя разликата");✗ backfill 1004: "5O,00"старо: 6, ново: 5, липсват в новото: [1004]още не: първо се оправя разликатаПоръчка 1004 от 2016 има в сумата буква O вместо нула. Старият модул живее с това, а превръщането в центове не може. Backfill не я крие: казва я на глас и продължава. Помиряването я хваща и казва „още не“. Някой ще поправи реда в старата таблица, backfill ще мине пак, и помиряването ще покаже 0.
Backfill може да се пуска колкото пъти трябва: копира само това, което липсва. Двойният запис може да се изпълни като две писания в една транзакция (урока за базите данни) или през outbox, за да не остане запис само в едната таблица.
Пробвай
Попълни
Backfill не трябва да пише върху ред, който вече е в новата таблица. Как?
foreach (var (id, total) in OldRows)
NewRows.(id, Cents(total));TryAdd записва само ако ключът липсва и връща false, ако го има. Редът в новата таблица е дошъл от двойния запис, той е по-нов от копието на историята. Ако 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 ландшафт и капабилитита · Твоята първа архитектурна задача
- Задачи по темата: Архитектът в работа