Приложение меняется постоянно: добавили поле в заказ, завели новый статус, переименовали сущность. И почти каждое такое изменение упирается в один и тот же вопрос: что будет с данными и с кодом, которые про это изменение ещё не знают?
У этого вопроса есть системный ответ — пара понятий про совместимость и короткий список правил, как менять формат данных безопасно. Правила эти одни и те же для базы данных, для REST-запроса и для события в Kafka, хотя в каждом из этих каналов болят по-своему.
Одна и та же строка проходит через две версии кода. Старый код не видит поля, которого нет в его модели, — и всё решает то, как он записывает строку обратно: перезапись объекта целиком стирает незнакомое поле молча, обновление одного своего поля его сохраняет.
Почему старая и новая версия всегда живут вместе
Кажется, что при выкате версия меняется мгновенно: была старая — стала новая. На деле нет. Сервис обновляют плавающим выкатом (rolling upgrade) — узлы заменяют по одному, чтобы не было простоя. Значит, несколько минут — а при раскатке на процент пользователей и несколько дней — в проде работают обе версии кода сразу. С клиентскими приложениями ещё хуже: мобильное приложение пользователь может не обновлять месяцами.
Отсюда простое следствие: данные, которые ходят между версиями, должны читаться в обе стороны. У этого есть два имени:
- Обратная совместимость — новый код читает данные, записанные старым. Обычно несложно: тот, кто пишет новый код, помнит старый формат.
- Прямая совместимость — старый код читает данные, записанные новым. Вот это сложнее: старый код должен спокойно проглотить то, чего он не понимает, — а переписать его уже нельзя, он уже работает.
И ещё факт, из-за которого тема серьёзная: данные живут дольше кода. Код целиком меняется за минуты, а запись пятилетней давности так и лежит в базе в формате пятилетней давности — никто не будет переписывать терабайты ради одного нового поля. Поэтому база в любой момент — это смесь форматов, записанных разными версиями кода.
Строки различает только то, чья версия записала данные, а чья читает: сверху новый код над старой записью, снизу старый код над новой.
Три канала данных и их ловушки
База данных. Запись в базу — это как «письмо самому себе в будущее», и ей нужны обе совместимости сразу: новый код читает старые строки (обратная), а во время выката старый код читает строки, записанные новым (прямая). Здесь прячется самая коварная ловушка всей темы. Старый код читает запись с новым полем, что-то в ней меняет и сохраняет обратно целиком — и молча затирает то самое новое поле, о котором не знает. Данные теряются, а в логах — ни одной ошибки. Защита простая: обновлять только свои поля (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 нельзя: в момент выката одна версия ищет старое имя, другая новое, и одна из них падает. Шаги:
- Расширить. Добавить новую колонку, допускающую
NULL:ALTER TABLE customer ADD COLUMN address text. В PostgreSQL это быстрая операция над метаданными, и дажеDEFAULTс константой с версии 11 не переписывает таблицу. - Писать в обе. Выкатить код, который пишет и в
addr, и вaddress, а читает пока из старой. Обе версии приложения совместимы с этой схемой. - Перелить. Заполнить
addressизaddrдля старых строк, пакетами по идентификатору (UPDATE ... WHERE id BETWEEN ? AND ?по десять тысяч строк с паузами), а не однимUPDATEна всю таблицу, который заблокирует строки на часы и раздует таблицу. - Переключить чтение. Выкатить код, который читает из
address; запись всё ещё в обе. Здесь сверяют, что значения совпадают:SELECT count(*) FROM customer WHERE addr IS DISTINCT FROM address. - Сжать. Перестать писать в
addr, через выкат удалить колонку:ALTER TABLE customer DROP COLUMN addr. Удаление тоже метаданные, место освободится позже.
Пять шагов идут по порядку и разнесены по разным выкатам: на любом из них обе версии приложения работают со схемой без падений.
Между шагами проходят дни, и это нормально: экспансия и сжатие живут в разных выпусках, а миграция «на потом» это задача в трекере, а не память.
Про блокировки, которые здесь прячутся. 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 *делает ломким любой код. - Когда смысл поля меняется несовместимо, версию выносят наружу: в имя типа события или отдельную тему, и какое-то время публикуют обе.
- Ленивая миграция переписывает записи по мере чтения, но читающий код обязан понимать оба формата, пока старые записи существуют.
- Совместимость выключают по счётчику: доля старой версии у издателя, версии у потребителей, число строк в старом формате — и только после нуля в оговорённый срок.
Что почитать дальше
- Schema Registry в Kafka — инфраструктурная проверка совместимости событий.
- gRPC и protobuf — формат со схемой на практике.
- Версионирование REST API (Java, Go, Node, Python) — что делать, когда совместимость сохранить нельзя.
- Производные данные - почему производное пересобирают заново, а источник правды меняют осторожно.
- Миграции PostgreSQL без остановки сервиса - те же экспансия и сжатие, но в командах и блокировках.