Миграция микросервиса с геоданными с MS SQL на PostgreSQL

от автора

Проблема

Не секрет, что тема перехода в IT-сфере на технологии, не требующие дорогостоящего лицензирования становится всё более актуальной. В то же время, очевидно и стремление компаний попасть в Реестр отечественного ПО, чтобы получить разного рода преференции для себя и своих сотрудников.

С точки зрения обновления технологического стека, мы воспользовались методическими рекомендациями Реестра отечественного ПО и приняли решение о переводе наших проектов на технологии со свободными лицензиями, в частности .NET 6 и PostgreSQL. Это открыло нам путь как к оптимизации производительности приложений и уменьшению расходов на лицензирование, так и добавлению решений компании в реестр.

В данной статье предлагаю рассмотреть путь по миграции географического микросервиса с MS SQL на PostgreSQL с фокусом на пространственные (spatial) типы данных.

Вопрос стоимости лицензий и непосредственного сравнения MS SQL vs PostgreSQL опустим, т.к. эта тема весьма хорошо раскрыта в DotNext-докладе моего коллеги, Станислава Флусова (@stus). Рекомендую к просмотру!

Пути миграции

Поскольку типы данных и встроенные функции в MS SQL и PostgreSQL зачастую имеют свои особенности, нельзя просто так взять и накатить бэкап/скрипт одной СУБД на другую.

Задача осложнялась еще и необходимостью перевести 150 ГиБ пространственных данных (типы GEOGRAPHY и GEOMETRY из MS SQL) в PostgreSQL.

На рынке уже есть готовые решения, позволяющие передавать схему или данные из MS SQL в PostgreSQL:

  • Babelfish от AWS

    • Устанавливается как плагин на PostgreSQL и делает его совместимым с T-SQL синтаксисом, заставляя клиентское приложение думать, что оно работает с MS SQL.

    • В то же время, данное решение рассматривалось лишь как средство для быстрого перехода на PostgreSQL, которое имеет некоторые ограничения в совместимости с СУБД.

    • Babelfish пока что не поддерживает пространственные типы данных GEOMETRY и GEOGRAPHY.

  • AWS Schema Conversion tool

    • Утилита генерации SQL-скриптов со схемой БД для заданной СУБД на основе другой СУБД.

    • Мигрирует данные через специальные data extraction agents.

    • Миграция данных рассчитана на облако AWS.

  • Некоторое количество вариаций утилит по, непосредственно, импорту-экспорту пространственных данных. Например, ogr2ogr.

    • Утилита специализируется исключительно на пространственных данных и пришлось бы разделить процессы выгрузки их и других типов данных, что усложнило бы разработку.

    • Часть процесса выглядела бы так: MS SQL → файл с геоданными → PostgreSQL.

    • Довольно тернистый путь, как показалось.

Рассмотрев различные варианты утилит и инструментов по миграции схем и данных, взвесив их плюсы и минусы, все же, решили обратиться к технологии, которую уже использовали в проекте, Entity Framework, и написать свой велосипед мигратор.

О проекте Geography

Данный микросервис был написан еще в 2016 году, основывается на .NET, использует MS SQL для хранения данных и Entity Framework в качестве ORM.

Некоторое время назад было принято решение адаптировать данный проект под требования Реестра отечественного ПО, в том числе — обновить версии .NET, EF Core и, наконец, перевести хранение данных в PostgreSQL.

Современные версии .NET предлагают не только свободную лицензию MIT, но также и существенный рост производительности (на эту тему есть целый цикл статей) вместе с новыми возможностями для разработки. В то же время, PostgreSQL предлагает версионность данных «из коробки», которая эффективно решает проблему блокировок уровня манипуляций с данными (DML, при условии штатного функционирования сервера), бесплатное High Availability, вплоть до кластера, не говоря уже о read only и логических репликах. В добавок, получаем полную совместимость со стеком Linux и развитое сообщество. Да и кто же не любит новые технологии? Таков путь.

Стоит отметить, что и Entity Framework Core уже достаточно зрелая ORM-технология.

Составные части решения на момент начала перехода к PosgtreSQL:

  • Api — ASP .NET web API

  • Api.Client — Клиент API

  • Business Logic Services — Слой бизнес-логики

  • EF.Sql — EF Core контекст БД, миграции схемы и данных БД, репозитории

  • Migration utility — консольная утилита для миграций схемы и данных. Используется для CI/CD.

  • Проекты с тестами API, сервисов, интеграционными тестами

Технологический стек

  • .NET Core 3.1

  • MS SQL Server 2016

  • Entity Framework Core 5

    • Все сущности конфигурируются через Data Annotations.

  • ASP .NET Core 3.1

  • Autofac в качестве DI-контейнера

Рефакторинг проекта

Для начала, из проекта контекста БД было выделено несколько слоёв:

  • Cущности EF в виде POCO-классов, без каких-либо ссылок на EF и без аннотаций/атрибутов, описывающих ограничения, ключи и связи между сущностями.

  • Интерфейсы

    • Для контекста EF.

    • Для конфигурирования контекста EF.

    • Для чтения/записи в БД, UnitOfWork — часть унифицированного подхода компании к работе с Data Access Layer

      Интерфейсы чтения/записи в БД. Фрагмент библиотеки Fortis.Common.DAL
      // Copyright © 2022 ООО "Фортис", ГК "Монополия" namespace Fortis.Common.DataAccessLayer.Contracts;  /// <summary> /// Предоставляет запрос на получение записей из хранилища /// Абстрагирован от конкретной СУБД и ORM /// </summary> public interface IDbReader { /// <summary> /// Предоставляет запрос на получение записей из хранилища /// </summary> IQueryable<TEntity> Read<TEntity>() where TEntity : class, IDbEntity; }  /// <summary> /// Интерфейс создания и модификации записей в хранилище /// Абстрагирован от конкретной СУБД и ORM /// </summary> public interface IDbWriter { /// <summary>Добавить новую запись</summary> void Add<TEntity>(TEntity entity) where TEntity : class, IDbEntity;  /// <summary>Изменить запись</summary> void Update<TEntity>(TEntity entity) where TEntity : class, IDbEntity;  /// <summary>Удалить запись</summary> void Delete<TEntity>(TEntity entity) where TEntity : class, IDbEntity; }  /// <summary>Сохранение изменений контекста</summary> public interface IDbUnitOfWork { /// <summary>Асинхронно сохраняет все изменения контекста в Db</summary> /// <returns>Число записей о состоянии, записанных в базу данных</returns> Task<int> SaveChangesAsync(CancellationToken cancellationToken);  /// <summary>Создает новую транзакцию</summary> IDbTransaction InTransaction(); }

      Реализация интерфейсов
      // Copyright © 2022 ООО "Фортис", ГК "Монополия" using System.Linq;
 using Fortis.Common.DataAccessLayer.Contracts;
 using Fortis.GeographyEF.Abstract;
 using Microsoft.EntityFrameworkCore;  namespace Fortis.GeographyEF;  partial class GeographyDbContext : IMsSqlGeographyDbReader, IMsSqlGeographyDbWriter, IDbUnitOfWork { void IDbWriter.Add<TEntity>(TEntity entity) => Entry(entity).State = EntityState.Added;  void IDbWriter.Update<TEntity>(TEntity entity) => base.Entry(entity).State = EntityState.Modified;  void IDbWriter.Delete<TEntity>(TEntity entity) => base.Entry(entity).State = EntityState.Deleted;  IQueryable<TEntity> IDbReader.Read<TEntity>() => base.Set<TEntity>() .AsNoTracking() .AsQueryable();  Task<int> IDbUnitOfWork.SaveChangesWithoutAuditAsync(CancellationToken cancellationToken) => SaveChangesAsync(cancellationToken);  Task<int> IDbUnitOfWork.SaveChangesAsync(CancellationToken cancellationToken) => SaveChangesWithDetachAsync(cancellationToken);  IDbUnitOfWork.IDbTransaction InTransaction() { var internalTransaction = Database.BeginTransaction(); return new GeographyDbTransaction(internalTransaction); }  /// <summary> /// Метод сохраняет изменения с Detach модифицированных сущностей /// </summary> internal async Task<int> SaveChangesWithDetachAsync(CancellationToken cancellationToken) { var count = await base.SaveChangesAsync(cancellationToken); foreach (var entry in base.ChangeTracker.Entries().ToArray()) { entry.State = EntityState.Detached; }  return count; } }

  • Сам DbContext для MS SQL, реализующий описанные выше интерфейсы, а также его миграции и Autofac-модуль, регистрирующий контекст в DI контейнере и подключаемый в web API приложении.

  • Конфигурация контекста для MS SQL на основе Fluent API Configuration

    • Для каждой из таблиц написан отдельный класс конфигурации, реализующий интерфейс IEntityTypeConfiguration<TEnity>.

      Пример конфигурирования сущности
      // Copyright © 2022 ООО "Фортис", ГК "Монополия" namespace Fortis.GeographyEF.Entities.Configurations.MsSql; public class SettlementConfiguration : IEntityTypeConfiguration<Settlement> {     public void Configure(EntityTypeBuilder<Settlement> builder)     {     builder.ToTable("Settlements");         builder.HasKey(p =&gt; p.Id);         builder.Property(p =&gt; p.Id).ValueGeneratedOnAdd();                   builder.Property(o =&gt; o.Name)             .IsRequired()             .HasMaxLength(100)             .HasColumnType("NVARCHAR(100)");         builder.Property(o =&gt; o.Abbreviation)             .IsRequired()             .HasMaxLength(15)             .HasColumnType("NVARCHAR(15)");              builder.HasOne(p =&gt; p.Region)             .WithMany(p =&gt; p.Settlements)             .HasForeignKey(p =&gt; p.RegionId)             .OnDelete(DeleteBehavior.Cascade);     } }

    • Подключение такого рода конфигураций довольно просто осуществляется в контексте БД.

      Пример подключения конфигураций сущностей
      // Copyright © 2022 ООО "Фортис", ГК "Монополия" namespace Fortis.GeographyEF; internal sealed class GeographyDbContext: DbContext, IGeographyDbContext {     //...     protected override void OnModelCreating(ModelBuilder builder)     {         base.OnModelCreating(builder);              builder.ApplyConfigurationsFromAssembly(typeof(SettlementConfiguration).Assembly);     } }

  • Слой репозиториев, зависящих не от конкретного контекста, а от абстракции (интерфейса).

Таким образом, получилась более красивая и расширяемая организация слоя данных приложения и открывшая путь к абстрагированию от реализации Entity Framework под конкретную СУБД.

Составные части решения после рефакторинга

Подключаем PostgreSQL

На данном этапе в приложение было добавлено еще два проекта, подобные реализованным ранее для MS SQL:

  • DbContext для PostgreSQL, миграции схемы и Autofac-модуль.

    • Инициирующую миграцию создали тут же.

  • Конфигурация контекста PostgreSQL на основе Fluent API Configuration.

Составные части решения после подключения PostgreSQL

Также, для web api приложения, в рамках Startup-класса, было добавлено чтение новой переменной конфигурации из IConfiguration, в зависимости от значения которой подключается нужный нам Autofac-модуль контекста БД. 

Т.е. то, какая СУБД будет использована, определяется на уровне конфигурации самого исполняемого приложения. 

Не обошлось и без дополнительной настройки самой PostgreSQL — было необходимо установить расширение PostGIS, включающее поддержку пространственных типов данных в СУБД.

Миграция данных и стратегия переключение на PostgreSQL

Схема деплоя сервиса Geography использует консольное приложение-мигратор для миграции схемы БД. Было решено расширить его конфигурацию возможностью выбрать текущую активную СУБД (MS SQL или PostgreSQL), а также добавить поддержку аргумента командной строки для запуска процесса миграции данных.

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

Для передачи всех 150ГиБ данных (несколько таблиц по ~10млн записей, но основной объем — это значения в полях с пространственными типами данных), было решено пойти по пути обхода каждой таблицы из EF-контекста MS SQL и записи в контекст PostgreSQL.

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

Пример реализации алгоритма топологической сортировки
// Copyright © 2022 ООО "Фортис", ГК "Монополия" using System; using System.Collections.Generic; using System.Reflection; using NetTopologySuite.Geometries;  namespace Fortis.Geography.Migrate.DbSync;  internal sealed class TopSortService : ITopSortService { private static readonly HashSet<Type> excludedTypes = new HashSet<Type> { typeof(string), // don't analyze string properties typeof(Geometry), // and Geometry types typeof(LineString), };  public IEnumerable<Type> Sort(IEnumerable<Type> typesList) { var graph = new Dictionary<Type, List<Type>>(); foreach (var vertex in typesList) { var edges = new List<Type>(); graph.Add(vertex, edges); foreach (var propertyInfo in vertex.GetProperties(BindingFlags.Public | BindingFlags.Instance)) { var edge = propertyInfo.PropertyType; if (!edge.IsValueType && // don't analyze primitive types !edge.IsGenericType && // and generic types !excludedTypes.Contains(edge)    ) { edges.Add(edge); } } }  var result = new List<Type>(); var visited = new HashSet<Type>(); foreach (var vertex in graph.Keys) { Dfs(vertex, graph, visited, result); }  return result; }  private static void Dfs( Type vertex, Dictionary<Type, List<Type>> graph, HashSet<Type> visited, List<Type> result) { if (!visited.Add(vertex)) { return; }  foreach (var edge in graph[vertex]) { Dfs(edge, graph, visited, result); }  result.Add(vertex); } }

Далее, был написан сам код мигратора.

Пример реализации мигратора
// Copyright © 2022 ООО "Фортис", ГК "Монополия" using System; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Fortis.Common.DataAccessLayer.Entity.Contracts; using Fortis.Common.Logging.Contracts; using Fortis.GeographyEF.Abstract; using Fortis.GeographyEF.Common; using Fortis.GeographyEF.PostgreSql.Abstract; using Microsoft.EntityFrameworkCore;  namespace Fortis.Geography.Migrate.DbSync;  internal sealed class DbSynchronizationService : IDbSynchronizationService { private static readonly MethodInfo synchronizeEntityMethodInfo = typeof(DbSynchronizationService).GetMethod(nameof(SynchronizeEntities));  private readonly ITopSortService topSortService; private readonly IMsSqlGeographyDbReader msReader; private readonly IGeographyEntitiesResolver entitiesResolver; private readonly IPgGeographyDbWriter pgWriter; private readonly IPgGeographyDbUnitOfWork pgUnitOfWork; private readonly IFortisLogger logger;  public DbSynchronizationService( ITopSortService topSortService, IMsSqlGeographyDbReader msReader, IGeographyEntitiesResolver entitiesResolver, IPgGeographyDbWriter pgWriter, IPgGeographyDbUnitOfWork pgUnitOfWork, IFortisLogManager logManager) { logger = logManager.GetLogger<DbSynchronizationService>();  this.topSortService = topSortService; this.msReader = msReader; this.entitiesResolver = entitiesResolver; this.pgWriter = pgWriter; this.pgUnitOfWork = pgUnitOfWork; }  async Task IDbSynchronizationService.Synchronize(CancellationToken cancellationToken) { var dbTypes = entitiesResolver.GetClrTypes(); var sortedDbTypes = topSortService.Sort(dbTypes); foreach (var dbType in sortedDbTypes) { await (Task)synchronizeEntityMethodInfo .MakeGenericMethod(dbType) .Invoke(this, new object[] {cancellationToken}); } }  // don't change modifier, because we use reflection public async Task SynchronizeEntities<T>(CancellationToken token) where T : class, IDbEntity, ICreatedAtEntity, IEntityWithId { const int batchSize = 4024;  var baseMsQuery = msReader.Read<T>();  var recordsTotal = await baseMsQuery.CountAsync(token); var recordsProcessed = 0;  logger.Info($"Synchronizing {typeof(T).Name}. Total records: {recordsTotal}");  try { while (recordsProcessed < recordsTotal) { var srcList = await baseMsQuery .Skip(recordsProcessed) .Take(batchSize) .ToListAsync(token);  foreach (var entity in srcList) { pgWriter.Add(entity); }  await pgUnitOfWork.SaveChangesAsync(token);  recordsProcessed += srcList.Count; } } catch (Exception e) { logger.Error($"{typeof(T).Name} synchronization failed. Processed records: {recordsProcessed}.", e); throw; }  logger.Info($"{typeof(T).Name} has been synchronized."); } }

В класс мигратора инжектируются не непосредственно контексты БД, но абстракции над ними (для чтения, операций записи и UnitOfWork). Стоит и уточнить назначение IGeographyEntitiesResolver — это интерфейс, предоставляющий список CLR типов, ассоциированных с контекстом.

Интерфейс IGeographyEntitiesResolver и его реализация
// Copyright © 2022 ООО "Фортис", ГК "Монополия" public interface IGeographyEntitiesResolver { /// <summary> /// Вернуть все ассоциированные с контекстом БД CLR-типы /// </summary> IReadOnlyCollection<Type> GetClrTypes(); }  partial class PgGeographyContext : IGeographyEntitiesResolver { private static readonly Assembly entitiesAssembly = typeof(Location).Assembly;  IReadOnlyCollection<Type> IGeographyEntitiesResolver.GetClrTypes() => Model.GetEntityTypes() .Select(o => o.ClrType) .Where(o => o.Assembly == entitiesAssembly) .ToList(); }

Отмечу сразу, что данная реализация мигратора не является панацеей и может отличаться в зависимости от особенностей ваших БД. Мигратор из примера запускался для статической, отдельной, копии БД продуктивного окружения. 

Само собой, что с момента окончания первой миграции данных в PostgreSQL, БД продуктивного окружения гарантированно будет отличаться по количеству строк и необходимо реализовать механизм компенсации расхождений.

Задача облегчалась тем, что бизнес-логика сервиса Geography подразумевает только добавление новых данных по городам, точкам интереса и маршрутам. Изменение данных — только через отдельные EF-миграции данных.

Таким образом, было достаточно догрузить несколько тысяч недостающих записей на момент релиза с окончательным переходом к PostgreSQL — для этого в код мигратора были внесены изменения с поддержкой определения последней выгруженной строки для каждой таблицы и переноса только новых строк. В процессе деплоя был вызван мигратор и данные доактуализированы за небольшое время.

Да, были возможны и альтернативные варианты стратегий компенсации расхождений данных:

  • Реализация одновременной записи в обе СУБД через репозитории.

  • Отслеживание изменений на уровне триггеров в таблицах и уведомление об этом через реализацию Outbox-паттерна.

  • и т.д.

Все варианты имеют свои плюсы и минусы и был выбран наиболее эффективный для нашей конкретной ситуации.

Итоги

Сервис Geography был успешно мигрирован на новую СУБД без потерь, сопутствующий рефакторинг улучшил структуру решения, проблем с производительностью запросов не наблюдается.

Поскольку подход миграции данных с использованием EF и топологической сортировкой показал свою состоятельность, планируется применять его, с некоторыми доработками, и в других проектах.

Надеюсь, что описанное выше поможет вам в переходе с MS SQL на PostgreSQL. Если остались вопросы, welcome в комментарии.


ссылка на оригинал статьи https://habr.com/ru/post/701060/