Към съдържанието
wannacode

Смяна на жива система (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;
}
}
Стъпка по стъпка, с думи
  1. Router стои пред стария модул за поръчки (2016) и новия. Магазинът говори само с Router, а той решава кой отговаря.
  2. 0%: старото отговаря на всичките 10. Новото смята в сянка и се разминава в 4: 1002, 1005, 1007, 1009. Всички са над 50 €: забравило е безплатната доставка.
  3. Поправено и пак 0%: 0 разлики. Купувачите не са видели нито една грешна сума.
  4. 10%: поръчката с последна цифра 0 (1010) отива при новото. Една поръчка винаги отива на едно място, затова купувачът не скача между двете.
  5. 50%: 5 от 10 при новото. Ако то се държи зле, връщането е едно число: пак 10, и същите поръчки се връщат при старото.
  6. 100%: новото отговаря на всичко. Старото не получава нищо, но още работи и още има данните си.
  7. Изключването на старото е точката без връщане. Тя е последна, след седмици с 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, разлики 0
100%: новото 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 ? "новото" : "старото")}");
Отговор

Пробвай

Намери грешката

Колега пише по-кратък Router: „10% са 10%, нека е случайно“. Купувачите започват да пишат, че сумата в кошницата се мени при всяко презареждане. На кой ред е грешката?

Избери реда с грешката.

Идеята

Данните: expand–contract

Кодът се сменя с Router. Данните са по-трудни: новото иска нова таблица. В старата сумата е текст с десетична запетая ("64,00"), в новата е число в центове (6400). А докато трафикът се мести, и двете таблици трябва да са верни.

В урока за миграциите промяната на колона беше на стъпки: разширяване, преход, свиване. Тук е същото, но за цяла таблица и с проверка:

  1. Expand: новата таблица се добавя до старата. Нищо не се маха.
  2. Двоен запис: всяка нова поръчка се записва и в двете.
  3. Backfill: историята (поръчките отпреди двойния запис) се копира в новата.
  4. Помиряване (reconcile): двете се сравняват, докато разликите станат 0.
  5. Четенето минава на новата, старата спира да се пише.
  6. 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));

Пробвай

Подреди стъпките

  1. Backfill(); // историята в новата
  2. SwitchReads(); // четенето от новата
  3. Contract(); // махане на старата
  4. DualWrite(); // всеки запис и в двете
  5. Expand(); // новата таблица до старата
  6. Reconcile(); // сравнение, докато разликите са 0
  7. StopOldWrites(); // старата само за четене

Идеята

Точката без връщане

Всяка стъпка дотук има връщане: процентът се сваля, четенето се връща на старата таблица, двойният запис продължава. Две стъпки нямат: изключването на стария модул и махането на старата таблица. След тях връщането е възстановяване от архив.

Затова те са последни и се решават нарочно. Преди тях: седмици с 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, новото 9990
1013: само в новото
разлики: 3

Още задачи

  • По купувач. Смени Router да избира по номера на купувача вместо по номера на поръчката. Добави поле за купувача и пусни две поръчки на един купувач при 50%.
  • Връщане назад. Пусни плана 0, 10, 50, 10, 100 и провери, че при второто 10% са същите поръчки, като при първото.

Тест

Провери се

1. Какво прави strangler fig?
2. Защо новото първо работи в сянка?
3. Router праща към новото случайни 10% от заявките. Какъв е проблемът?
4. Защо backfill не пише върху редове, които вече са в новата таблица?
5. Кога се маха старата таблица (contract)?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·