Привет! Когда-то обновление версии сервиса было сложной задачей. Нужно было последовательно выполнить ряд действий, перезапустить инстансы, и не забыть предварительно забэкапить наши данные. Пользователям сервиса показывалось предупреждение, что сервис временно недоступен, и куча усилий тратилась на то, чтобы максимально снизить время этой недоступности.

Сейчас же у нас есть супер-мощные инструменты для управления сервисами. Они позволяют иметь несколько разных версий сервиса одновременно. Бизнес-пользователи могут управлять тем, какую версию видит та или иная группа пользователей. Обновление и откат версии сервиса могут происходить без прерывания работы пользователей. И когда появился такой мощный и красивый молоток, все начали хотеть забивать гвозди только им. Даже те, кому это, на самом-то деле, вообще не нужно. И тут есть проблема - наличие инструмента не означает автоматической готовности сервисов для того, чтобы этот инструмент с ними можно было использовать. И если обновлять сервисы старым способом было сложно, то новым это делать еще сложнее. Давайте посмотрим, почему это так, и как вообще получить этот ваш Zero Downtime.

Disclaimer

В статье рассматривается вопрос обновления базы данных при обновлении сервиса с точки зрения backend-разработки. Если вас интересует как быстро преобразовать десятки терабайт данных из одного формата в другой, то здесь на это не будет ответа.

Постановка задачи

У нас есть простой CRUD REST-сервис на Spring Boot'е. Он использует свою базу для хранения данных, с которой общается через Spring Data JDBC. Нам внезапно потребовалось немного поменять модель данных для поддержки новых фич. Сами новые фичи, наверное, потребуют изменения API, но это не входит в рамки задачи, нам надо просто поменять модель хранения данных. Наш сервис работает под постоянной нагрузкой и на чтение, и на запись. Мы хотим его обновить таким образом, чтобы логика работы с БД изменилась, но при этом пользователи бы не заметили простоя. Наша цель - ноль запросов, которые бы вернули ошибку в момент обновления.

Инструменты и окружение

  1. Сервис, написанный на Spring Boot. При работе с базой используем Spring Data JDBC;

  2. Liquibase для обновления базы;

  3. БД PostgreSQL 15;

  4. Kubernetes в качестве среды исполнения сервиса;

  5. JMeter для нагрузки.

Я использовал максимально простой вариант для всех этих инструментов. Postgres разворачивался в качестве Docker-контейнера на рабочей машине, без всяких кластеров. В качестве Kubernetes я использовал версию, поставляемую с Docker Desktop. Liquibase запускался как gradle task в основном проекте приложения.

Функционально сервис позволяет создавать, вычитывать и обновлять сообщения. Каждое сообщение имеет 5 основных атрибутов - ID, тема, текст, автор и признак, опубликовано оно или нет.

Модель данных
Модель данных

Сообщение создается опубликованным, но впоследствии его можно скрыть. Можно получать сообщения по ID, либо искать последние сообщения определенного автора (в этом случае есть возможность получить только опубликованные сообщения или вообще все).

openapi.json
{
  "openapi": "3.0.1",
  "info": {
    "title": "OpenAPI definition",
    "version": "v0"
  },
  "servers": [
    {
      "url": "http://localhost:8080",
      "description": "Generated server url"
    }
  ],
  "paths": {
    "/posts": {
      "get": {
        "tags": [
          "post-controller"
        ],
        "operationId": "findByAuthor",
        "parameters": [
          {
            "name": "author",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "publishedOnly",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": true
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PostDto"
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "post-controller"
        ],
        "operationId": "createPost",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreatePostRequestDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PostDto"
                }
              }
            }
          }
        }
      }
    },
    "/posts/{id}": {
      "get": {
        "tags": [
          "post-controller"
        ],
        "operationId": "findById",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PostDto"
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "post-controller"
        ],
        "operationId": "hidePost",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "CreatePostRequestDto": {
        "type": "object",
        "properties": {
          "author": {
            "type": "string"
          },
          "subject": {
            "type": "string"
          },
          "text": {
            "type": "string"
          }
        }
      },
      "PostDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64"
          },
          "author": {
            "type": "string"
          },
          "subject": {
            "type": "string"
          },
          "text": {
            "type": "string"
          },
          "published": {
            "type": "boolean"
          }
        }
      }
    }
  }
}

Сервис поднят в Kubernetes, работают 2 реплики.

Сценарий нагрузки JMeter представлен генераторами трех видов:

  • Публикация сообщений (5 потоков, 1000 случайных авторов);

  • Чтение сообщений случайного автора (50 потоков);

  • Скрытие сообщений (1 поток).

Когда я запускаю этот сервис и нагрузочный скрипт на своей машине, генерируется нагрузка в 90 tps на создание сообщений, 600 tps на чтение и 13 tps на обновление.

Не используйте такие типы тестов при нормальном НТ

Подобные сценарии не должны использоваться для настоящего нагрузочного тестирования. Здесь используется мало потоков, которые выполняют столько действий, сколько успеют, без какой‑то паузы. В результате такой профиль очень гуманен к сервису — сервис запнулся, 56 потоков встали в ожидание ответа и все. Более реалистичный сценарий — тысячи потоков с паузами между запросами и коротким временем ожидания ответа от сервиса. Если сервис запнулся на длительное время, то быстро растет очередь необработанных соединений. Как сервис поведет себя в этом случае - очень важный фактор поведения приложения под нагрузкой.

Настройки Spring Boot'а и Kubernetes deployment

Давайте для начала просто обновим версию приложения, не трогая его код - просто сделаем еще одну версию докер-образа и скажем сервису ее использовать. Исходное состояние: Spring Boot сервис с настройками по умолчанию, K8S deployment содержит только конфигурацию RollingUpdate (не задано никаких проб):

apiVersion: apps/v1
kind: Deployment
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    spec:
      containers:
        - name: zero-dt-app
          image: zero-dt-app:1.0.0
...

Запускаем нагрузку, ждем пару минут, обновляем версию приложения:

$ kubectl set image deployment/zero-dt-deployment zero-dt-app=zero-dt-app:1.0.1

Результат: куча ошибок (>4,000 org.apache.http.NoHttpResponseException и немного java.net.SocketException), а также подскочившие времена отклика (NoHttpResponseException в нашем случае - сервер отказал в соединении клиенту).

Времена отклика, мс (90-й персентиль)
Времена отклика, мс (90-й персентиль)

Собственно, именно ради этого мы вообще этот шаг делали - надо понять, какая минимальная конфигурация позволит сделать совместимое по данным обновление без прерывания сервиса. А потом уже переходить непосредственно к обновлению БД.

Итак, посмотрим на причины ошибок:

  • мы не объявили никаких проб (readiness, liveness, startup). K8S считает, что в этом случае под сразу готов к работе и направляет на него трафик;

  • мы не поддержали эти пробы на стороне сервиса. В Spring Boot для этого удобно использовать actuator;

  • K8S при остановке пода посылает SIGTERM (а потом, если под не уложился в таймаут, SIGKILL). Когда Spring Boot-приложение получает SIGTERM, оно сразу останавливается. Чтобы дать приложению завершить текущие запросы, нужно использовать параметр server.shutdown = graceful

Исправим эти проблемы:

  1. Добавим в Spring Boot-приложение зависимость spring-boot-starter-actuator. Для публикации health check-ручек больше ничего не требуется, но вы можете дополнительно настроить actuator, чтобы он, например, опубликовал другие свои endpoint'ы;

  2. Добавим в application.properties параметр server.shutdown = graceful;

  3. Добавим startup-пробу в deployment K8S.

apiVersion: apps/v1
kind: Deployment
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    spec:
      containers:
        - name: zero-dt-app
          image: zero-dt-app:1.0.1
          startupProbe:
            httpGet:
              path: "/actuator/health/readiness"
              port: 8080
            failureThreshold: 30
            periodSeconds: 2
...
А как с остальными пробами?

Liveness- и readiness-пробы полезно иметь в сервисе, но в нашем случае я их не добавлял, поскольку целью было определить минимально достаточную конфигурацию для корректного обновления. Да и, честно говоря, полезность этих проб здорово преувеличивается. Liveness-проба говорит Kubenetes'у, что под надо убить. В качестве примера часто приводится deadlock, при котором приложение особо ничего не может сделать для решения проблемы, и проще запустить новый инстанс. Но я ни разу не видел, чтобы это реально детектилось на стороне приложения.

С readiness-пробой то же самое. Это способ временно исключить под из маршрутизации, потому что мы считаем, что проблема пода временная и чуть позже сама разрешится. Но что это может быть за проблема, и почему мы считаем, что она связана именно с подом? Если у нас проблема с доступом к внешним ресурсам, то скорее всего, проблема на стороне внешних ресурсов (например, БД). Может, проблема и решится сама по себе, но надо понимать, что если проба фейлится, то K8S начинает перенаправлять трафик на другие поды. Другие поды, скорее всего, тоже не смогут достучаться до внешнего ресурса, только еще и получат кучу избыточного траффика, на который они не рассчитаны. Вам придется защищаться от этого добавляя rate limiter'ы и другие защитные механизмы, а в результате пользователь все равно не сможет воспользоваться сервисом.

Наверное, в 9 приложениях из 10 на Spring Boot'е весь смысл этих проб - вызвать хоть что-нибудь, чтобы убедиться, что приложение еще не словило OOM и не израсходовало все коннекты HTTP-сервера. Это решается одной довольно примитивной liveness-пробой. Readiness-пробы раньше активно использовались для определения, когда на под можно пускать трафик при старте, но сейчас для этого есть startup-пробы. В общем, пробы, конечно, мощный инструмент, но не стоит его переоценивать, его очень не всегда удается адекватно применить.

Теперь при старте пода мы будем каждые 2 секунды вызывать readiness-ручку, пока она не ответит (не больше минуты). После того, как startup-проба прошла, на под будет маршрутизироваться трафик.

Повторим эксперимент с обновлением версии:

$ kubectl apply -f deployment.yaml
$ kubectl set image deployment/zero-dt-deployment zero-dt-app=zero-dt-app:1.0.2

Уже почти хорошо, но org.apache.http.NoHttpResponseException почему-то все еще есть (теперь их десятки, а не тысячи). WTF?

После того, как мы добавили graceful shutdown в наш сервис, он честно дорабатывает запросы, которые к нему уже пришли, и не пускает новые. Как именно не пускает - зависит от контейнера сервлетов, который использует Spring Boot Web. По умолчанию, это Tomcat, и он просто не дает установить соединение - отсюда и ошибки. А вот Undertow должен отвечать HTTP 503. Но откуда вообще новые запросы берутся? Почему K8S продолжает их маршрутизировать на под, который сам же только что остановил? Ну, если коротко, то потому что Kubernetes так работает: вот, например. Или вот статья на Хабре, где все очень подробно расписано (на мой взгляд, даже слишком подробно, про удаление подов там в самом конце).

А зачем они так сделали (ИМХО)?

Если подумать, то это логично со стороны авторов Kubernetes'а - вы все равно не сможете остановить моментально трафик на под в случае, когда он умер незапланировано (решение кейса, когда контейнер незапланировано умер - одна из основных причин существование Kubernetes'а в принципе). Поэтому клиенты вашего сервиса все равно не могут завязываться на то, что 100% запросов будет обработано. А если на это нельзя завязаться в случае аварийной остановки пода, то зачем усложнять себе жизнь, реализуя дополнительные гарантии для плановой остановки? Уже есть механизм, который исключит под из маршрутизации, хоть и с некоторой задержкой, пусть он и работает.

Мы хотели, чтобы при обновлении сервиса мы не получили ни одной ошибки. В реальной жизни это абсолютно избыточное требование, но здесь у нас тепличные условия эксперимента, можно и немного усложнить себе задачу. Поэтому воспользуемся рекомендованным методом разрешения этой ситуации, который выглядит надежным как швейцарский банк (добавим для пода preStop и в нем сделаем sleep на несколько секунд).

А как это вообще должно помочь?

Kubernetes начнет удаление пода и пометит его как Terminating. Он выполнит preStop, а после его завершения пошлет сигнал SIGTERM. Но пока под выполняет sleep, информация о том, что этот под уже Terminating распространится по кластеру, и на него уже не будет маршрутизироваться трафик. Если кластер не успевает обработать эту информацию - увеличьте sleep, особенно это касается "настоящих" многонодных инсталляций.

По сути, этот шаг делает необязательной установку server.shutdown = graceful на уровне приложения (основной кейс, когда он все еще будет полезен - наличие очень длительных запросов).

apiVersion: apps/v1
kind: Deployment
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    spec:
      containers:
        - name: zero-dt-app
          image: zero-dt-app:1.0.2
          startupProbe:
            httpGet:
              path: "/actuator/health/readiness"
              port: 8080
            failureThreshold: 30
            periodSeconds: 2
          lifecycle:
            preStop:
              exec:
                command:
                  - sleep
                  - "10"
...

И еще раз повторяем обновление версии под нагрузкой:

$ kubectl apply -f deployment.yaml
$ kubectl set image deployment/zero-dt-deployment zero-dt-app=zero-dt-app:1.0.3

А вот теперь - нормально. org.apache.http.NoHttpResponseException полностью ушли, времена отклика немного подросли на момент переключения, но это ожидаемо.

Времена отклика, мс (90-й персентиль)
Времена отклика, мс (90-й персентиль)

Обновление БД

Теперь давайте перейдем к интересному - обновим БД. Допустим, мы решили, что теперь часть новых постов будут попадать на модерацию. Поэтому вместо флага published, мы решили использовать поле status, которое принимает 3 значения: PUBLISHED, UNPUBLISHED и MODERATION. С точки зрения внешнего API ничего поменяться не должно: посты в статусе MODERATION будут возвращаться с признаком published = false. Если для поста в статусе MODERATION вызывается метод скрытия сообщения, то статус меняется на UNPUBLISHED.

Обновленная модель данных
Обновленная модель данных

Основные трудности такого обновления:

  1. При обновлении у нас будет запущено 2 версии сервиса, старая не умеет работать с полем status, а новой уже не нужно поле published. При этом, в каждую версию сервиса могут попасть данные, созданные другой версией;

  2. Необходимо мигрировать старые данные в новый формат.

Чтобы решить эти проблемы, нужно каким-то образом обеспечить возможность работы как с новой, так и со старой версией данных. Это можно сделать разными способами, конкретный зависит от используемой БД, наполнения данными и компетенций вашей команды. После этого мы сможем безопасно перевести данные в новый формат, а затем поддержку старого формата можно прекратить.

Основные подходы работы с двумя моделями данных

Сделать все на стороне приложения

Общая схема обновления сервиса примерно такая:

  1. Добавьте в БД поле status (nullable). Сделайте поле published nullable. При выполнении любых таких действий проконсультируйтесь с DBA (если он у вас есть), и, при возможности, протестируйте это под нагрузкой до раскатки на прод. Причина - DDL-операции могут блокировать объект БД целиком, и почти всегда это и делают. Вопрос только в том, на какое время. Ответ очень зависит от вендора и версии вашей БД. Как правило, добавление нового поля без дефолтных значений и удаление constraint'ов безопасно (в том смысле, что выполняются быстро, поскольку требуют только обновления метаданных). Вам нужно убедиться, что ни одна такая операция не приведет к тому, что таблица на сотни миллионов записей будет действительно переписана, и на все время выполнения операции на таблице будет висеть эксклюзивная блокировка. Хорошая новость в том, что современные БД умеют это делать достаточно неплохо. Например, PostgreSQL сейчас не будет переписывать таблицу, даже если вы добавляете колонку со значением по умолчанию (правда за это приходится платить неочевидным поведением). Для ряда операций есть их версия, не блокирующая таблицу (например, создание индекса в том же PostgreSQL). Если все же сделать это невозможно, создайте в БД новую таблицу требуемой структуры. Но в этой развилке вам надо быть готовым к тому, что миграция будет проходить гораздо медленнее и потребует больше места на диске;

  2. Обновите приложение, добавив запись в новое поле status (v. 1.1.0). Приложение должно писать и в published, и в status. Читать оно будет из поля published. В случае, когда вы используете 2 таблицы, логика сложнее: новые записи вы вставляете в обе таблицы, читаете тоже из обеих таблиц, но при обновлении данных вы должны поддержать кейс, когда во второй таблицу еще нет соответствующей записи (для PostgreSQL подойдет insert ... on conflict do update). Параллельно с работой этой версии будет происходить миграция данных, поэтому позаботьтесь о безопасном конкурентном доступе: если получаете данные для последующего обновления, используйте блокировки;

  3. После того, как все инстансы версии 1.0.x были выключены, запустите миграцию данных. Это скрипт, скорее всего, работающий в пакетном режиме, который по значению поля published устанавливает поле status (для отдельной таблицы - создает новые записи). Также помните о том, что параллельно работает приложение (подумайте об использовании select ... for update skip locked или его аналогов);

  4. После завершения миграции, обновите приложение, чтобы оно читало данные из нового поля status (v. 1.1.1). Писать оно по-прежнему должно в два поля - status и published;

  5. Еще раз обновите приложение, чтобы оно писало только в поле status (v. 1.1.2). Вам нужно было разделить изменение записи и чтения на разные шаги, поскольку на момент обновления у вас запущены 2 версии приложения: прошлая и обновленная. После выполнения миграции, у вас стоит приложение версии 1.1.0, которое читает из поля published. Поэтому нужна версия 1.1.1, которая будет продолжать писать в поле published, чтобы запись, созданная обновленной версией приложения, смогла обработаться старой версией приложения 1.1.0, которая еще не успела удалиться;

  6. Опционально удалите ненужное поле published и сделайте status not null. Это действие названо опциональным, поскольку его действительно часто бывает проблемно выполнить без блокировки таблиц на длительное время. В этом случае, мусор в таблице может оказаться приемлемым. Чтобы он вам не мешал, переименуйте неиспользуемый столбец для большей наглядности - как правило, это быстрая операция. Сделать столбец not null сложнее, посмотрите, способна ли ваша БД на это. PostgreSQL удаляет колонки быстро (он только модифицирует метаданные), а сделать быстро поле not null, судя по всему, он способен с небольшим хаком.

    Но если вы пошли по пути создания отдельной таблицы, то здесь у вас никаких проблем не будет - старую таблицу можно просто удалить, а новая у вас уже была создана со всеми нужными constraint'ами. Единственное, у вас, скорее всего, будет не очень красивый naming.

Выше описан подход с дублированием логики по записи (в промежуточных версиях приложения мы пишем в 2 места). Возможен так же подход с дублированием по чтению (пишем всегда в одно поле, но читаем из 2. Если новое поле не null, берем его, иначе - старое). Подходы мало чем отличаются друг от друга. Дублирование по записи пишет немного больше данных, но дублирование по чтению может быть сложно реализовать, если в вашей модели null - корректное значение для нового поля.

Также схема предполагает последовательную установку трех версий приложения. Можно обойтись одной, но работающей в трех режимах при помощи feature toggling'а (реализация будет рассмотрена далее).

Триггеры

  1. Добавить поле status (nullable). Сделать поле published nullable. Повесить триггеры на вставку и обновление: они должны по полю published вычислять status, а по status - published;

  2. Выполнить миграцию данных. Можно воспользоваться созданными триггерами и пакетно выполнять что-то вроде

    UPDATE post SET published = published WHERE status IS NULL

    Нужно учитывать возможные конкурентные обновления со стороны сервиса;

  3. Установить версию приложения v. 1.1.0, которое работает только с полем status. В момент установки 1.0.x будет читать и писать поле published, но триггеры будут проставлять корректный status. Версия 1.1.0 уже будет писать в поле status, но триггер будет обновлять published, что позволит работать с такими записями версии 1.0.x;

  4. Удалить триггеры. Удалить поле published, сделать status not null (опционально).

Views

Одним из популярных способов маскировки изменений в БД для внешних клиентов является использование view (переименовываем таблицу в post_old, делаем таблицу post_new с новой структурой и делаем view post, который будет возвращать union из этих двух таблиц; миграция данных перегоняет записи из post_old в post_new). В нашей задаче такой подход вряд ли принесет пользу, поскольку приложение не сможет писать в такой view. А если мы заносим в приложение знание о том, что мы временно пишем не туда же, откуда читаем, то по сути получаем первый рассмотренный подход. Но теоретически у вас может быть сервис, который только читает данные, создаваемые, к примеру, ETL. В этом случае полезно помнить про такой подход.

Репликация

Еще один экзотический подход - отдельная БД для новой версии + продвинутая процедура репликации, что-то наподобие Oracle Golden Gate. Вы поднимаете реплику для новой версии приложения, настраиваете репликацию с преобразованием данных из старой БД в новую. Настраиваете еще одну репликацию с преобразованием из новой БД в старую. Обновляете приложение, новая версия смотрит на новую БД. Пока обновление раскатывается, новые данные реплицируются в старую базу и доступны для старой версии сервиса. Возможная трудность - автогенерация ключей. Тогда можно сначала на старой БД сделать шаг инкремента 2, чтобы генерились, например, только четные значения, а на новой - чтобы генерились только нечетные (сам сиквенс надо подвинуть на момент, когда на старой базе выставили инкремент 2). Конкретно для рассматриваемой задачи это неоправданно сложно, но миграции данных бывают разные. Подход дает быстрый способ проведения самой миграции и позволяет не держать в БД мусор. Кроме того, этот подход очень хорошо дополняется схемами blue-green deployment'а и canary releases, если кроме изменения модели данных вы сразу меняете API. Но при этом очень важно помнить, что вы теряете консистентность данных из-за того, что репликация у вас не синхронная.

В целом, этот подход сводится к тому, чтобы при помощи стороннего ПО поддерживать 2 копии данных разного формата. Его можно обобщить: необязательно использовать именно репликацию и отдельную БД - просто поддерживайте программно 2 копии данных с разной моделью, возможно, в одной базе при помощи любых инструментов. Но эти инструменты должны будут не терять изменения и обеспечивать низкую задержку. С большой долей вероятности начав писать свой такой инструмент вы заново изобретете уже существующий велосипед с журналом изменений, который используют почти все БД.

Сравнение подходов

Самый ограниченный подход - использование View, это подходит только в том случае, если ваш сервис не выполняет операции записи. Триггеры и Репликация довольно похожи друг на друга - оба позволяют выполнить нужные преобразования данных, не затрагивая логику основного сервиса. Но Триггеры обеспечивают консистентность данных, а Репликация - нет. Зато Репликацию можно использовать в базах, не поддерживающих Триггеры (читай - NoSQL), и в случаях, когда логика преобразования сложна для реализации на стороне БД (Репликацию вы реализовываете с помощью отдельного инструмента и можете использовать для этого что угодно). Реализация логики на стороне приложения требует минимальных изменений на стороне БД, но предполагает более серьезную доработку приложения и, возможно, установку нескольких версий. Консистентность данных в этом случае ограничена поддержкой транзакций между приложением и БД, то есть, как правило, обеспечивается.

В целом, моя рекомендация - если вам подходит несколько вариантов, используйте то, что лучше умеете делать. Триггеры выглядят самым простым решением, но вы должны понимать, как они повлияют на производительность БД, должны учесть, что изменения, совершаемые триггерами могут в свою очередь вызывать другие триггеры и т.д. В целом, перенос логики в БД накладывает на код БД такие же требования, как на код основного приложения - вы должны будете задумываться о том, как проводить рефакторинг БД, о тестах для процедур в БД и так далее. В моих командах количество доступных DBA колебалось от 0 до 0.5, а сам я не знаком с СУБД настолько, чтобы полагаться на логику, реализуемую на их стороне. Поэтому дальше мы рассмотрим реализацию первого подхода, когда поддержка обеих моделей данных осуществляется на стороне приложения.

Feature-toggling в Spring Boot

Мы хотим написать одно приложение, которое в зависимости от настроек, применяемых на лету, пишет и читает данные по-разному. Для этого мы используем Togglz, библиотеку feature-toggling'а для Java (так же можно взять FF4J). Для реализации логики нам потребуется 2 флага:

  • FEATURE_WRITE_POST_STATUS_ONLY - писать только в поле status. Если выключен - пишет и в поле published, и в поле status, если включен - только в поле status. По умолчанию выключен;

  • FEATURE_READ_POST_STATUS - читать поле статус. Если выключен - читает из published, если включен - из status. По умолчанию выключен.

Если посмотреть выше описание подхода с реализацией логики на стороне приложения, то случай, когда обе фичи выключены - v. 1.1.0, когда вторая включена - v. 1.1.1, когда обе включены - v. 1.1.2. FEATURE_WRITE_POST_STATUS_ONLY не должен включаться без включенного FEATURE_READ_POST_STATUS.

Фича-флаги можно использовать как условия для if'ов в логике работы приложения, они достаточно просто инъектятся в бины. Но мы будем использовать другой подход - будем подменять сами бины в зависимости от включения / выключения фичи. Поэтому сначала адаптируем архитектуру приложения:

Первоначальная диаграмма последовательности для сервиса
Первоначальная диаграмма последовательности для сервиса

Нам требуется сохранить API сервиса, и для реализации новой логики такая архитектура нам неудобна. Переложим ответственность за маппинг модели в DTO на сервисный слой и разделим сервис на операции чтения / записи. Тогда новая архитектура будет выглядеть как-то так:

Целевая диаграмма последовательности для сервиса
Целевая диаграмма последовательности для сервиса

Таким образом, нам нужно 2 реализации ReadOperations, каждая из которых ссылается на свой репозиторий и свой маппер, и 2 реализации WriteOperations, использующих те же самые репозитории и мапперы, что и ReadOperations. Такое разделение на операции чтения и записи хоть и выглядит некрасиво с точки зрения предметной области, но бывает полезно еще и для масштабирования приложения с репликами БД, работающими только на чтение - тогда у ReadOperations будет свой DataSource, настроенный на readonly-реплики.

Сделаем бины-операции RequestScope и будем создавать их через кастомную фабрику, которая создает нужный вариант бина в зависимости от состояния feature flag'а:

// Объединенный листинг основных компонентов, нужных для Togglz
@SpringBootApplication(scanBasePackages = {
  "codes.bespoke.brastak.snippets.zero", 
  "org.togglz.spring.boot.actuate"
})
@EnableJdbcRepositories
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
}

public enum ZeroDTFeatures implements Feature {
    @FeatureGroup("Add status field")
    @Label("Write new status field only")
    FEATURE_WRITE_POST_STATUS_ONLY,

    @FeatureGroup("Add status field")
    @Label("Read new status field")
    FEATURE_READ_POST_STATUS;
}

@Configuration
public class TogglzConfig {
    private static final int CACHE_TIMEOUT_MS = 5_000;
  
    @Bean
    public StateRepository stateRepository(DataSource dataSource) {
        return new CachingStateRepository(
            new JDBCStateRepository(dataSource),
            CACHE_TIMEOUT_MS
        );
    }

    @Bean
    public FeatureProvider featureProvider() {
        return new EnumBasedFeatureProvider(ZeroDTFeatures.class);
    }
}

@Configuration
public class OperationsFactory {
    @Bean
    @RequestScope
    public ReadOperations readOperations(ApplicationContext context, FeatureManager manager) {
        if (manager.isActive(ZeroDTFeatures.FEATURE_READ_POST_STATUS)) {
            return new ReadOperationsImpl(
                context.getBean(PostRepository.class),
                context.getBean(PostMapper.class)
            );
        } else {
            return new OldReadOperationsImpl(
                context.getBean(OldPostRepository.class),
                context.getBean(OldPostMapper.class)
            );
        }
    }

    @Bean
    @RequestScope
    public WriteOperations writeOperations(ApplicationContext context, FeatureManager manager) {
        if (manager.isActive(ZeroDTFeatures.FEATURE_WRITE_POST_STATUS_ONLY)) {
            return new WriteOperationsImpl(
                context.getBean(PostRepository.class),
                context.getBean(PostMapper.class)
            );
        } else {
            return new TempWriteOperationsImpl(
                context.getBean(TempPostRepository.class),
                context.getBean(TempPostMapper.class)
            );
        }
    }
}

Для конфигурирования Togglz мы объявляем enum с нашими флагами, создаем для него EnumBasedFeatureProvider, и говорим, что состояние флагов будет хранится в БД. Причем, чтобы на каждый запрос не было бы лишнего обращения к БД, мы используем CachingStateRepository с TTL 5 секунд. Создание бонов-операций вынесено в отдельный Configuration-класс. Он смотрит на состояние флага через FeatureManager и в зависимости от этого создает нужную версию бина. Переключение feature-флагов будет происходить через actuator-endpoint, который идет в комплекте с togglz-starter'ом (для этого надо явно включить его в application.properties: management.endpoints.web.exposure.include = health,togglz).

Для удобства используется соглашение по именованию классов. Приложение содержит сразу 3 набора логики: старый (с полем published), промежуточный (и с полем published, и с полем status) и целевой (с полем status). Сервисы, мапперы и репозитории из версии 1.0 переезжают в пакет old и обзаводятся префиксом Old. Сервисы, мапперы и репозитории для промежуточной версии создаются в пакете temp с префиксом Temp. Объекты целевой версии - в пакете target но без префикса - это позволит в версии 1.2 (или 1.1.Final) легко перенести их в исходный пакет без дополнительного переименования.

Liquibase и миграция данных

Теперь перейдем к скриптам обновления БД. И для обновления схемы, и для обновления данных, мы будем использовать Liquibase. Иногда используют подход, при котором в Liquibase хранят только DDL, тогда скрипты миграции запускаются отдельно. Я не вижу особых резонов выносить это. Бывают миграции данных, продиктованные бизнес-потребностями - импорт пользователей из сторонней системы, например. Такие скрипты действительно не должны попадать в Liquibase, потому что они актуальны для конкретного стенда, могут содержать чувствительные данные и т.п. Но в нашем случае выполняется техническая миграция, связанная с изменением типа данных. Еще могут быть ситуации, когда миграция данных запускается через какой-то CI/CD-инструмент, который не ожидает, что операция может идти часами. Но в целом, если мы можем обновлять базу из единого места, почему бы это не делать?

Также Spring Boot позволяет выполнять миграции вместе со стартом приложения, но я не рекомендую так делать. Хотя у этого подхода есть два плюса:

  • У вас есть всего один артефакт, который делает все что нужно;

  • Вы уверены в том, что ваше приложение запускается на той версии БД, с которой может работать.

Но также у него есть и минусы:

  • Проблемы с откатом версии;

  • Проблемы с длительными миграциями - пока все скрипты не отработают, ваше приложение не стартует;

  • Безопасность - приложению обычно не требуется выполнять DDL-операции, поэтому у технического пользователя приложения могут быть более слабые права, чем у пользователя, из-под которого выполняются DDL-скрипты;

  • Обновление БД требует новой версии приложения, даже если его код не поменялся;

  • Создается опасная иллюзия кумулятивных релизов: если у вас есть один артефакт, то вы начинаете думать, что любая новая версия может встать на любую старую. А это не всегда так. А вот если у вас есть отдельный артефакт на сервис, и отдельный - на БД, то вы по крайней мере зададите себе вопрос, а как их надо совместимо обновлять.

Нам нужно разбить обновление на 3 этапа, каждый из них должен стартовать в нужное время. Первый этап - добавление нового поля status и объявление published nullable. Второй этап - собственно выполнение миграции данных, его нужно начать, когда все инстансы сервиса будут обновлены на версию 1.1.0. После выполнения миграции, мы переключаем приложение на целевую логику работы (включаем 2 feature-флага). После того, как feature-флаги включены и подтянуты сервисами, можно выполнять 3-й этап миграции - удаление ненужной колонки и проставление not null для поля status. Для этого мы воспользуемся тэгами в liquibase-скриптах.

databaseChangeLog:
  - changeSet:
      id: "1.1.0"
      author: brastak
      changes:
        - addColumn:
            tableName: post
            columns:
              - column:
                  name: status
                  type: text
        - dropNotNullConstraint:
            tableName: post
            columnName: published
        - tagDatabase:
            tag: 1.1_BEFORE_MIGRATION
  - changeSet:
      id: "1.1.1"
      author: brastak
      runInTransaction: false
      changes:
        - sql:
            do '
              begin
                loop 
                  with cte as (
                    select id from post where published and status is null limit 250 for update skip locked
                  ) update post set status = ''PUBLISHED'' from cte where post.id = cte.id;
                  exit when not found;
                  commit;
                end loop;
              end
            '
        - sql:
            do '
              begin
                loop
                  with cte as (
                    select id from post where not published and status is null limit 250 for update skip locked
                  ) update post set status = ''UNPUBLISHED'' from cte where post.id = cte.id;
                  exit when not found;
                  commit;
                end loop;
              end
            '
        - sql:
            create index concurrently idx_post_status on post(status)
        - tagDatabase:
            tag: 1.1_AFTER_MIGRATION
      rollback:
        - dropIndex:
            indexName: idx_post_status
            tableName: post
  - changeSet:
      id: "1.1.2"
      author: brastak
      changes:
        - sql:
            alter table post add constraint post_status_not_null check (status is not null) not valid
        - sql:
            alter table post validate constraint post_status_not_null
        - sql:
            alter table post alter column status set not null
        - sql:
            alter table post drop constraint post_status_not_null
        - dropColumn:
            tableName: post
            columnName: published
        - tagDatabase:
            tag: '1.1'
      rollback:
        - addColumn:
            tableName: post
            columns:
              - column:
                  name: published
                  type: boolean
        - sql:
            update post set published = (status = 'PUBLISHED') where published is null
        - createIndex:
            indexName: idx_post_published
            tableName: post
            columns:
              - column:
                  name: published
        - dropNotNullConstraint:
            tableName: post
            columnName: status

Обновление сервиса и БД

Итак, теперь соберем все это в единое целое и проверим, как это все будет работать под нагрузкой.

  1. Запустим нагрузку JMeter на версии приложения 1.0.x и подождем, пока показатели стабилизируются (2-3 минуты);

  2. Обновим БД до состояния 1.1_BEFORE_MIGRATION:

    $ liquibase updateToTag 1.1_BEFORE_MIGRATION

  3. Обновим версию приложения. После этого, показатели нагрузки несколько просядут, подождем пару минут, пока они стабилизируются:

    $ kubectl set image deployment/zero-dt-deployment zero-dt-app=zero-dt-app:1.1.0

  4. Запустим миграцию данных, обновив БД до состояния 1.1_AFTER_MIGRATION:

    $ liquibase updateToTag 1.1_AFTER_MIGRATION

  5. Переключим приложение на чтение из поля status (актуальный адрес сервиса вам должен сказать ваш K8S):

    $ curl -X POST -d '{ "enabled": true }' -H "Content-Type: application/json" "localhost:32172/actuator/togglz/FEATURE_READ_POST_STATUS"

  6. Подождем, пока все инстансы подхватят новое значение флага (5 секунд). Переключим приложение на запись только в поле status:

    $ curl -X POST -d '{ "enabled": true }' -H "Content-Type: application/json" "localhost:32172/actuator/togglz/FEATURE_WRITE_POST_STATUS_ONLY"

  7. Подождем, пока все инстансы подхватят новое значение флага (5 секунд). Завершим обновление БД:

    $ liquibase update

В первом прогоне этого сценария после второго шага были выявлены немногочисленные ошибки при создании новой записи cached plan must not change result type . Это было довольно любопытно, поскольку мы вроде как вставляем данные, откуда там взялась ошибка, характерная для чтения? Причина в том, как Spring Data JDBC и драйвер PostgreSQL работают с автогенерацией ключей: результат эквивалентен дописыванию в конце запроса RETURNING *. Поэтому при добавлении / удалении / изменении типа столбцов случается такая ошибка при insert-запросах в уже открытых транзакциях. Что интересно, Spring Data JDBC содержит логику, которая может фактически дописывать RETURNING id, что решило бы эту проблему. Но это поведение определяется соответствующей настройкой диалекта БД, которая для PostgreSQL отключена. Для решения можно воспользоваться пользовательским запросом в репозитории:

@Repository
public interface PostRepository extends PagingAndSortingRepository<Post, Long>, CrudRepository<Post, Long> {
    @Query("insert into post (subject, text, author, published)" +
        " values (:#{#post.subject}, :#{#post.text}, :#{#post.author}, :#{#post.published})" +
        " returning id, subject, text, author, published")
    Post savePost(Post post);
}

После этой доработки повторяем тест. Вместо восстановления из бэкапа, можем воспользоваться скриптами отката до 1.0.x, заодно их и проверим (в описанном процессе отката под нагрузкой будут ошибки; если нужно откатить аккуратнее, то нужно делать больше промежуточных шагов по аналогии с процедурой обновления. Но такой откат на несколько версий назад - это не плановая процедура, прерывание сервиса на какое-то время обычно допустимо в этом случае):

  1. Отключим feature-флаги:

    $ curl -X POST -d '{ "enabled": false }' -H "Content-Type: application/json" "localhost:32172/actuator/togglz/FEATURE_WRITE_POST_STATUS_ONLY"

    $ curl -X POST -d '{ "enabled": false }' -H "Content-Type: application/json" "localhost:32172/actuator/togglz/FEATURE_READ_POST_STATUS"

  2. Откатим сервис:

    $ kubectl set image deployment/zero-dt-deployment zero-dt-app=zero-dt-app:1.0.x

  3. Откатим БД:

    $ liquibase rollback 1.0

Потом накатим исправленную версию приложения 1.0 и повторим выкатку версии 1.1 (с теми же исправлениями) под нагрузкой.

В результате обновление модели данных прошло успешно, нам удалось не получить ни одной ошибки. По влиянию обновления сервиса на клиентов можно сказать, что оно, конечно, есть, но весьма незначительное (по сути, единственное явное влияние видно в моменте переключения сервиса на новую версию - всплеск выше 250 мс в первой четверти графика). Максимальное время отклика за время прохождения теста составило 1800 мс.

Времена отклика, мс (99-й персентиль)
Времена отклика, мс (99-й персентиль)

Пару слов относительно разброса времен отклика, особенно по сравнению с предыдущими графиками - не надо их сравнивать. Первые 2 графика относятся к одному и тому же тесту, на схожем наполнении БД и при примерно равной длительности. Их корректно сопоставлять между собой. Последний график - совсем другой тест, на большем количестве данных, для графика берется 99-й персентиль и используется больший период для агрегации данных и т.п. В целом, графики приведены скорее для иллюстрации - когда на одной машине выполняется и сервис, и миграция, и скрипты нагрузки, доверия к абсолютным цифрам не должно быть.

Итоги

  1. Обновлять сервис с миграцией данных и нулевым даунтаймом можно, но сложно. Даже в простейших случаях изменения модели данных. Собственно, почти нигде эти сложности не стоят непродолжительной приостановки сервиса, особенно если останавливать сервис грамотно. Может быть, вам могут помочь canary releases? Тогда остановка будет затрагивать только часть пользователей за раз. Может быть, вам будет достаточно ограничить пользователям операции записи на время миграции? Если большинство пользователей только читает, то они вообще на заметят, что с сервисом что-то происходит. SLA по доступности 99.99% дает вам возможность ронять сервис на 1 минуту каждую неделю, за это время, как правило, можно успеть обновить таблицу в миллионы записей, у вас точно более жесткие условия? Асинхронные очереди и ретраеры в вашей архитектуре легко маскируют и более длительную недоступность сервиса для клиентов. Но если у вас база на сотни и тысячи гигабайт; данные, которые вы обновляете одинаково часто востребованы пользователями (вы не можете быстро обновить свежие данные, а потом постепенно докатить исторические); высокие требования по доступности сервиса - какие-то изложенные идеи могут оказаться полезными.

  2. Для связки K8S + Spring Boot + PostgreSQL + Liquibase основными источниками ошибок сервиса при обновлении могут быть:

    1. Некорректная работа сервиса из-за обращения к данным, которые еще не были смигрированы (очень сильное влияние);

    2. Скрипты миграции БД, которые эксклюзивно блокируют объекты (сильное влияние);

    3. Отсутствие проб в K8S (сильное влияние);

    4. Некорректная поддержка SIGTERM на стороне приложения (слабое влияние);

    5. Отсутствие Grace Period при остановке пода (слабое влияние);

    6. Наличие запросов вида select * в приложении (слабое влияние, при условии, что ваше приложение корректно обрабатывает изменившееся число столбцов).

  3. Для миграции данных при схеме Zero Downtime обязательно нужно выполнять нагрузочное тестирование.

Полезные ссылки и статьи

  1. Обновление базы данных и zero-downtime deployment - хорошее описание по обновлению модели данных;

  2. Как безопасно завершить работу пода в Kubernetes: разбираемся с graceful shutdown и zero downtime деплоймент - очень подробно про то, что происходит внутри K8S;

  3. Рефакторинг баз данных: эволюционное проектирование - неплохая книга по рефакторингу БД

Код проекта

Полный код доступен на GitHub

Комментарии (2)