Проблема
Не секрет, что тема перехода в 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:
-
-
Устанавливается как плагин на PostgreSQL и делает его совместимым с T-SQL синтаксисом, заставляя клиентское приложение думать, что оно работает с MS SQL.
-
В то же время, данное решение рассматривалось лишь как средство для быстрого перехода на PostgreSQL, которое имеет некоторые ограничения в совместимости с СУБД.
-
Babelfish пока что не поддерживает пространственные типы данных GEOMETRY и GEOGRAPHY.
-
-
-
Утилита генерации 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 => p.Id); builder.Property(p => p.Id).ValueGeneratedOnAdd(); builder.Property(o => o.Name) .IsRequired() .HasMaxLength(100) .HasColumnType("NVARCHAR(100)"); builder.Property(o => o.Abbreviation) .IsRequired() .HasMaxLength(15) .HasColumnType("NVARCHAR(15)"); builder.HasOne(p => p.Region) .WithMany(p => p.Settlements) .HasForeignKey(p => 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/
Добавить комментарий