← назад к разделу

Приложение меняется постоянно: добавили поле в заказ, завели новый статус, переименовали сущность. И почти каждое такое изменение упирается в один и тот же вопрос: что будет с данными и с кодом, которые про это изменение ещё не знают?

У этого вопроса есть системный ответ — пара понятий про совместимость и короткий список правил, как менять формат данных безопасно. Правила эти одни и те же для базы данных, для REST-запроса и для события в Kafka, хотя в каждом из этих каналов болят по-своему.

версия 2 записала строку — в ней появилось новое поле id status promo_code 1001NEWSPRING25 старый код читает строку в свою модель1001NEWпро promo_code он не знает так не надо: прочитал объект и сохранил его целиком1001PAID— пусто — поле потеряно так надо: обновил одно своё поле, UPDATE … SET status1001PAIDSPRING25 поле уцелело

Одна и та же строка проходит через две версии кода. Старый код не видит поля, которого нет в его модели, — и всё решает то, как он записывает строку обратно: перезапись объекта целиком стирает незнакомое поле молча, обновление одного своего поля его сохраняет.

Обязательно

Почему старая и новая версия всегда живут вместе

Кажется, что при выкате версия меняется мгновенно: была старая — стала новая. На деле нет. Сервис обновляют плавающим выкатом (rolling upgrade) — узлы заменяют по одному, чтобы не было простоя. Значит, несколько минут — а при раскатке на процент пользователей и несколько дней — в проде работают обе версии кода сразу. С клиентскими приложениями ещё хуже: мобильное приложение пользователь может не обновлять месяцами.

Отсюда простое следствие: данные, которые ходят между версиями, должны читаться в обе стороны. У этого есть два имени:

  • Обратная совместимость — новый код читает данные, записанные старым. Обычно несложно: тот, кто пишет новый код, помнит старый формат.
  • Прямая совместимость — старый код читает данные, записанные новым. Вот это сложнее: старый код должен спокойно проглотить то, чего он не понимает, — а переписать его уже нельзя, он уже работает.

И ещё факт, из-за которого тема серьёзная: данные живут дольше кода. Код целиком меняется за минуты, а запись пятилетней давности так и лежит в базе в формате пятилетней давности — никто не будет переписывать терабайты ради одного нового поля. Поэтому база в любой момент — это смесь форматов, записанных разными версиями кода.

обратная пишет старый данные v1 читает новый прямая пишет новый данные v2 читает старый

Строки различает только то, чья версия записала данные, а чья читает: сверху новый код над старой записью, снизу старый код над новой.

Три канала данных и их ловушки

База данных. Запись в базу — это как «письмо самому себе в будущее», и ей нужны обе совместимости сразу: новый код читает старые строки (обратная), а во время выката старый код читает строки, записанные новым (прямая). Здесь прячется самая коварная ловушка всей темы. Старый код читает запись с новым полем, что-то в ней меняет и сохраняет обратно целиком — и молча затирает то самое новое поле, о котором не знает. Данные теряются, а в логах — ни одной ошибки. Защита простая: обновлять только свои поля (UPDATE … SET status = …, а не «прочитал объект целиком → записал целиком»), а если всё же читаете-меняете-сохраняете документ, аккуратно сохраняйте в нём и незнакомые поля.

Вот эта потеря на маленькой программе: строка — набор полей, а версия 1 знает только два.

живой пример

import java.util.LinkedHashMap;
import java.util.Map;

public class SchemaEvolutionDemo {
    record OrderV1(String id, String status) {}

    public static void main(String[] args) {
        Map<String, String> row = new LinkedHashMap<>();
        row.put("id", "1001");
        row.put("status", "NEW");
        row.put("promo_code", "SPRING25");
        System.out.println("записала версия 2:  " + row);
        System.out.println("перезапись целиком: " + rewriteWhole(row));
        System.out.println("только своё поле:   " + updateStatusOnly(row));
    }

    static Map<String, String> rewriteWhole(Map<String, String> row) {
        OrderV1 order = new OrderV1(row.get("id"), row.get("status"));
        Map<String, String> back = new LinkedHashMap<>();
        back.put("id", order.id());
        back.put("status", "PAID");
        return back;
    }

    static Map<String, String> updateStatusOnly(Map<String, String> row) {
        Map<String, String> back = new LinkedHashMap<>(row);
        back.put("status", "PAID");
        return back;
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"fmt"
	"strings"
)

type field struct{ key, value string }

type row []field

func (r row) String() string {
	parts := make([]string, len(r))
	for i, f := range r {
		parts[i] = f.key + "=" + f.value
	}
	return "{" + strings.Join(parts, ", ") + "}"
}

func (r row) get(key string) string {
	for _, f := range r {
		if f.key == key {
			return f.value
		}
	}
	return ""
}

func (r row) with(key, value string) row {
	out := make(row, 0, len(r)+1)
	found := false
	for _, f := range r {
		if f.key == key {
			f.value, found = value, true
		}
		out = append(out, f)
	}
	if !found {
		out = append(out, field{key, value})
	}
	return out
}

type orderV1 struct{ id, status string }

func rewriteWhole(r row) row {
	order := orderV1{r.get("id"), r.get("status")}
	return row{{"id", order.id}, {"status", "PAID"}}
}

func updateStatusOnly(r row) row {
	return r.with("status", "PAID")
}

func main() {
	r := row{{"id", "1001"}, {"status", "NEW"}, {"promo_code", "SPRING25"}}
	fmt.Println("записала версия 2: ", r)
	fmt.Println("перезапись целиком:", rewriteWhole(r))
	fmt.Println("только своё поле:  ", updateStatusOnly(r))
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const show = (m) => '{' + [...m].map(([k, v]) => `${k}=${v}`).join(', ') + '}';

function rewriteWhole(row) {
  const order = { id: row.get('id'), status: row.get('status') };
  return new Map([['id', order.id], ['status', 'PAID']]);
}

function updateStatusOnly(row) {
  const back = new Map(row);
  back.set('status', 'PAID');
  return back;
}

const row = new Map([['id', '1001'], ['status', 'NEW'], ['promo_code', 'SPRING25']]);
console.log('записала версия 2:  ' + show(row));
console.log('перезапись целиком: ' + show(rewriteWhole(row)));
console.log('только своё поле:   ' + show(updateStatusOnly(row)));
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

from dataclasses import dataclass


@dataclass(frozen=True)
class OrderV1:
    id: str
    status: str


def show(row: dict) -> str:
    return "{" + ", ".join(f"{k}={v}" for k, v in row.items()) + "}"


def rewrite_whole(row: dict) -> dict:
    order = OrderV1(row["id"], row["status"])
    return {"id": order.id, "status": "PAID"}


def update_status_only(row: dict) -> dict:
    back = dict(row)
    back["status"] = "PAID"
    return back


row = {"id": "1001", "status": "NEW", "promo_code": "SPRING25"}
print("записала версия 2:  " + show(row))
print("перезапись целиком: " + show(rewrite_whole(row)))
print("только своё поле:   " + show(update_status_only(row)))
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Обе версии «работают правильно» — просто одна выбрасывает поле, которого не знает.

Синхронное API (REST, gRPC). Тут жить проще: обычно сначала обновляют серверы, потом клиентов. Поэтому запросам достаточно обратной совместимости, а ответам — прямой. На практике это два правила: «новый параметр запроса делаем только необязательным» и «новое поле в ответе клиент обязан игнорировать, а не падать на нём». Когда сохранить совместимость не получается — включают версионирование API, и старую версию приходится держать живой, пока не отвалится последний потребитель: заставить чужих клиентов обновиться вы не можете.

События (Kafka, RabbitMQ). Здесь совместимость важнее всего. Отправитель и получатель события — это разные сервисы разных команд, их версии неизбежно расходятся, а событие в топике переживает не один выкат получателей. Поэтому тут совместимость охраняют не дисциплиной, а инфраструктурой: Schema Registry проверяет каждую новую схему на совместимость с предыдущими и просто не даст отправителю зарегистрировать ломающее изменение. Одна тонкость, на которой обжигаются: по умолчанию реестр сверяет совместимость только в одну сторону — что новый потребитель прочитает старые события (режим BACKWARD). Переживёт ли старый потребитель новые события, он при этом не проверяет: за это отвечают режимы FORWARD и FULL. Событиям нужны обе стороны, поэтому FULL на топике выставляют руками. Отдельная грабля: получатель, который перекладывает событие в другой топик, обязан сохранять поля, которых не понимает, — иначе он превращается в тот же «старый код, затирающий новое».

Правила эволюции

Во всех форматах со схемой (Protobuf, Avro, JSON Schema) правила сводятся к короткому списку:

  • Новое поле — только необязательное или со значением по умолчанию. Если сделать новое поле обязательным, сломается обратная совместимость: новый код не сможет прочитать старые записи, где этого поля просто нет.
  • Идентификаторы полей трогать нельзя. В Protobuf у каждого поля есть номер (тег), и закодированные данные ссылаются на поля именно по номерам. Переиспользуешь освободившийся номер — и старые записи превращаются в мусор. Имя поля в Protobuf менять можно — двоичные данные ссылаются на номера, а не на имена, — а номер — никогда. Оговорка: если то же сообщение где-то отдают в виде JSON, имена там значимы, и переименование сломает и таких потребителей, и весь сгенерированный код. В Avro наоборот: поля сопоставляются по имени, и переименование без aliases ломает чтение старых данных.
  • Удалять можно только необязательные поля, и их номер после этого навсегда выходит из оборота — его нельзя выдать новому полю. Чтобы следующий разработчик не занял номер по незнанию, его запирают прямо в схеме: reserved 5;, а заодно и имя — reserved "promo_code";. После этого компилятор сам не даст их переиспользовать.
  • Менять тип — осторожно. Расширение (например, int32 → int64) новый код переживёт, а вот старый код молча обрежет слишком длинное значение. И это не единственная ловушка: int32 и sint32 кодируются по-разному, и на той стороне получится не то число; замена числа на строку или знакового типа на беззнаковый ломается так же тихо. Какие замены безопасны, у каждого формата описано в его документации — сверяйтесь с ней, а не с интуицией.

Следить за правилами руками не нужно — для этого и придумана схема: реестр или проверка контрактов в CI ловят ломающее изменение до выката.

JSON или бинарный формат со схемой

JSON победил как формат для интеграций: его читаешь глазами, и он работает везде. Но всё описанное выше болит у него сильнее, и болит в четырёх местах.

Схема неявная: что старый код сделает с новым полем, зависит от конкретной библиотеки. Числа больше 2⁵³ теряют точность в мире JavaScript, и Twitter из-за этого отдавал id твита сразу и строкой, и числом. Двоичные данные приходится гонять через Base64 с наценкой +33 % к объёму. И имена полей повторяются в каждой записи.

Бинарные форматы со схемой — Protobuf (номера полей, кодогенерация) и Avro (отдельно схема записи и схема чтения) — в разы компактнее и, главное, делают совместимость проверяемой: схема — это документация, которая не может протухнуть, и контракт, который CI умеет сверить автоматически. Практическая рамка: наружу, для чужих клиентов, — JSON и REST (совместимость держим дисциплиной и версионированием); между своими сервисами и в событиях — формат со схемой и реестром.

И отдельно предупреждение: встроенная в язык сериализация (java.io.Serializable и родня) не годится ни для чего долгоживущего — она привязана к языку, не даёт нормально эволюционировать формат и имеет известные дыры в безопасности при разборе недоверенных данных.

Что в базе стоит дорого, а что дёшево

Не все изменения схемы одинаковы, и разница измеряется в минутах простоя.

Дёшево: добавить колонку, допускающую пустоту (или с постоянным значением по умолчанию в современных версиях) — изменение только в описании таблицы, данные не трогаются. Добавить индекс без блокировки. Добавить ограничение с отложенной проверкой.

Дорого: сменить тип колонки (переписывается вся таблица), добавить колонку с вычисляемым значением по умолчанию, добавить обязательное ограничение с немедленной проверкой. На большой таблице это блокировка на минуты и часы.

Опасно, хотя и мгновенно: удалить или переименовать колонку. Таблица не переписывается, зато старый код, который читает эту колонку, падает сразу — то есть цена не в базе, а в совместимости.

И отдельная ловушка, из-за которой падает даже правильный код: SELECT *. Приложение, которое читает все колонки и раскладывает их по позициям или по именам, ломается при любом изменении набора — добавили колонку, поменяли порядок, удалили ненужную. Поэтому в коде, который живёт дольше одного релиза, колонки перечисляют явно; это же правило спасает от лишнего трафика и от чтения больших полей, которых никто не просил.

Версии событий и кто выключает старое

Версия в самом событии. Для потоков и очередей обратная совместимость держится не всегда — иногда смысл поля меняется так, что старое и новое не сводятся. Тогда версию выносят наружу: в имя типа события (OrderCreated.v2), в поле заголовка сообщения или в отдельную тему. Потребители подписываются на ту версию, которую понимают, а издатель какое-то время публикует обе. Это дороже совместимости внутри одной схемы, но честнее: никто не делает вид, что старая и новая модель — одно и то же.

Ленивая миграция. Данные живут дольше кода, и переписывать их все сразу не обязательно: читая документ или строку старого формата, код приводит её к новому и при следующей записи сохраняет уже в новом. Со временем старый формат вымывается сам. Условие одно: читающий код должен понимать оба формата, и эту поддержку нельзя убирать, пока в хранилище остаются старые записи. Приём подробно разобран в статье про моделирование документов.

Кто и когда убирает совместимость. Самая забытая часть работы: поддержка старого поля добавляется на «пару недель», а живёт годами, потому что никто не знает, читает ли его кто-нибудь. Чинится это измерением. Издатель считает, сколько сообщений какой версии он отправил; потребители докладывают, какую версию схемы используют (в реестре схем это видно из коробки); для базы считают, сколько строк осталось в старом формате (WHERE new_column IS NULL). Когда счётчик старой версии держится на нуле оговорённый срок — неделю, месяц, — поддержку удаляют, и это отдельная задача в плане, а не «когда-нибудь потом».

Где это применяется

Каждый инцидент вида «после выката посыпались ошибки разбора данных» или «у части пользователей пропало поле» — это сломанная совместимость. Проверочный вопрос перед изменением формата один: «что с этим сделает код, который ещё не обновился, и данные, которые уже записаны?» Если на оба вопроса ответ спокойный («прочитает» и «переживёт») — изменение безопасно.

Дополнительно: при первом чтении можно пропустить

Глубже: экспансия и сжатие для схемы базы данныхрасширенное

Канал «база данных» выше говорит о совместимости строк, но не о том, как менять саму таблицу, когда старая и новая версия приложения работают одновременно. Приём называется «расширить, потом сжать»: любое изменение раскладывают на шаги, каждый из которых совместим и с текущей, и с предыдущей версией кода.

Переименовать колонку addr в address одним ALTER TABLE ... RENAME нельзя: в момент выката одна версия ищет старое имя, другая новое, и одна из них падает. Шаги:

  1. Расширить. Добавить новую колонку, допускающую NULL: ALTER TABLE customer ADD COLUMN address text. В PostgreSQL это быстрая операция над метаданными, и даже DEFAULT с константой с версии 11 не переписывает таблицу.
  2. Писать в обе. Выкатить код, который пишет и в addr, и в address, а читает пока из старой. Обе версии приложения совместимы с этой схемой.
  3. Перелить. Заполнить address из addr для старых строк, пакетами по идентификатору (UPDATE ... WHERE id BETWEEN ? AND ? по десять тысяч строк с паузами), а не одним UPDATE на всю таблицу, который заблокирует строки на часы и раздует таблицу.
  4. Переключить чтение. Выкатить код, который читает из address; запись всё ещё в обе. Здесь сверяют, что значения совпадают: SELECT count(*) FROM customer WHERE addr IS DISTINCT FROM address.
  5. Сжать. Перестать писать в addr, через выкат удалить колонку: ALTER TABLE customer DROP COLUMN addr. Удаление тоже метаданные, место освободится позже.
расширить новая колонка NULL писать в обе читаем из старой перелить пакетами по id переключить читаем из новой сжать убрать старую

Пять шагов идут по порядку и разнесены по разным выкатам: на любом из них обе версии приложения работают со схемой без падений.

Между шагами проходят дни, и это нормально: экспансия и сжатие живут в разных выпусках, а миграция «на потом» это задача в трекере, а не память.

Про блокировки, которые здесь прячутся. ALTER TABLE берёт на таблицу самую строгую блокировку хотя бы на мгновение, и если перед ним стоит долгий SELECT, ALTER ждёт, а за ним встают все остальные запросы: таблица «зависла». Поэтому перед миграцией ставят SET lock_timeout = '3s' и повторяют попытку, а не ждут. NOT NULL на заполненную колонку добавляют не напрямую (полное сканирование под блокировкой), а через ADD CONSTRAINT ... CHECK (address IS NOT NULL) NOT VALID, потом VALIDATE CONSTRAINT без блокировки записи, и только затем SET NOT NULL, который в PostgreSQL 12 и новее использует уже проверенное ограничение. Индекс на большой таблице строят только CREATE INDEX CONCURRENTLY. Смена типа колонки это та же экспансия через новую колонку, а не ALTER COLUMN TYPE, который переписывает таблицу целиком под блокировкой. Инструмент миграций (Liquibase, Flyway) все эти шаги сохранит и применит по порядку, но разложить изменение на шаги за вас он не сможет.

Коротко

  • Выкат не мгновенный: узлы обновляют по одному, и какое-то время обе версии кода работают в проде одновременно.
  • Обратная совместимость — новый код читает старые данные, прямая — старый читает новые. Базе и событиям нужны обе.
  • Данные живут дольше кода: база всегда смесь форматов, терабайты ради одного поля никто не переписывает.
  • Тише всего теряет данные старый код, сохранивший запись целиком без незнакомого поля. Обновляйте только свои поля.
  • Новое поле — необязательное или со значением по умолчанию; номер поля в Protobuf не переиспользуют, в Avro не переименовывают поле без aliases.
  • Наружу — JSON и версионирование, между своими сервисами — схема и реестр, который отклонит ломающее изменение до выката; режим совместимости для событий ставят FULL, иначе проверится только одна сторона.
  • Схему базы меняют через экспансию и сжатие: добавить колонку, писать в обе, перелить пакетами, переключить чтение, удалить старую. Дёшево добавить колонку с пустотой, дорого сменить тип (переписывается таблица), а SELECT * делает ломким любой код.
  • Когда смысл поля меняется несовместимо, версию выносят наружу: в имя типа события или отдельную тему, и какое-то время публикуют обе.
  • Ленивая миграция переписывает записи по мере чтения, но читающий код обязан понимать оба формата, пока старые записи существуют.
  • Совместимость выключают по счётчику: доля старой версии у издателя, версии у потребителей, число строк в старом формате — и только после нуля в оговорённый срок.

Что почитать дальше