Към съдържанието
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 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;
}
}
Стъпка по стъпка, с думи
  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 разлики и с архив на данните му.

Виж го

Стъпка по стъпка

С поправеното ново, през целия план:

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, разлики 0
100%: новото 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 ? "новото" : "старото"));
Отговор

Пробвай

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

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

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

Идеята

Данните: expand–contract

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

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

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

Пробвай

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

  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 за достъпно обучение по програмиране ·