Миграция микросервиса с геоданными с 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:
Устанавливается как плагин на 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 в комментарии.