Entity Framework (EF) — это удобный фреймворк для работы .NET-приложения с базой данных. По сути, это такая удобная абстракция над БД, которая сама пишет за разработчика оптимальные (ну, почти) SQL-запросы прямо из высокоуровневых LINQ-конструкций. Одной из киллер-фич фреймворка является возможность относительно легко сменить СУБД приложения на какую-нибудь другую. Предположим, разочаровались вы в MySQL или, наоборот, хотите сменить MSSQL на что-то менее дорогое — пожалуйста, EF как абстракция над СУБД в теории может это предоставить, так сказать, by design.

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

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

  • Я пишу про «небесшовный» переход. Бесшовный переход предполагает, что приложение умеет ходить одновременно в две БД, а плюс EF как раз в том, чтобы одну реализацию абстракции подменить на другую. То есть придётся или писать много кода для поддержки двух реализаций одновременно, или пересаживаться на новую БД «быстро, решительно». Рассказываю, как это быстро и решительно сделать наименее болезненно и с возможностью отката в моменте.

  • Приложение должно использовать «чистый» EF, то есть никаких низкоуровневых заигрываний с SQL, только LINQ-конструкции. Если вы используете какие-то внешние отчёты, например, через Redash, то это придётся переписывать руками, и статья этот вопрос не освещает.

  • Я описываю именно переезд с базы Microsoft SQL на MySQL и именно в таком направлении. Предположу, что с другими СУБД эта статья тоже будет более-менее применима, но индивидуальные нюансы каждой БД и другой тулинг могут серьёзно поменять детали процесса.

Общий план действий

План перехода состоит из следующих этапов:

  1. Учим приложение работать с новой базой, подключаем новый коннектор и разбираемся с тонкостями настройки.

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

  3. Готовим скрипты переноса данных из одной БД в другую.

  4. Релизим, получаем фидбэк, фиксим проблемы.

  5. Пункты 2, 3 и 4 повторяем для всех частей приложения.

Учим приложение работать с новой базой

Тут всё очень просто. Я подключил NuGet-пакет с MySQL-коннектором, поменял инициализацию DbContext'а в Startup.cs, чтобы он использовал новый коннектор.

Было

services.AddDbContextPool<TDbContext>(
    options => options.UseSqlServer(dbConnection));

Стало

services.AddDbContextPool<TDbContext>(
    options => options.UseMySql(mysqlConnection));

Ещё немного поменял настройки маппинга сущности на таблицу в БД, убрав из маппинга schema, т.к. В MySQL должна быть одна база (schema) на всё.

Всё чуть сложнее, если вы храните в базе GUID. У MySQL нет специального типа данных под GUID, но она может хранить его как binary(16), или вообще строкой. Управляется это настройкой подключения, а конкретно опцией GUID Format. У меня после перехода на MySQL изменилась сортировка по GUID, потому что, скорее всего, я не совсем правильно выбрал тип хранения, так что если вам по каким-то причинам важно сохранить сортировку по GUID, то отнеситесь к этой настройке внимательно.

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

Миграция данных

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

Так как сразу предполагается, что данные придётся «хачить», то лучше полагайтесь на консольные утилиты — с ними гораздо проще влиять на ввод-вывод и делать на основе этого скрипты. Если таблиц в базе много, то написать скрипты просто критически важно. Для работы с Microsoft SQL я использовал утилиту bcp из стандартной обвязки сервера, но также её можно поставить и отдельно. А для MySQL я нашёл также официальную утилиту mysqlsh.

Пайплайн переливки данных выглядит следующим образом: данные дампятся на рабочую машину через bcp в некий csv-like формат, а затем этот файл заливается через mysqlsh на целевую базу MySQL. Для mysqlsh при этом пишется довольно объёмная команда для импорта, по сути являющаяся скриптом, потому что может содержать различные условия и даже некоторую логику.

Скриптинг для mysqlsh

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

Итак, mysqlsh имеет внутри себя целых три оболочки: для классического SQL, а также JS и Python. SQL нам трогать не надо, я через него разве что таблички чистил после неудачной переливки, а вот JS-интерпретатор в вопросах импорта данных оказался очень полезным инструментом. В нём содержится несколько встроенных специальных команд, конкретно под наши нужды применяется utils.importTable, синтаксис которого вот такой:

util.importTable('path/to/file', {
            schema: 'schemaname',
            table: 'tablename',
            fieldsTerminatedBy: '|-|',
            linesTerminatedBy: '\n-/-\n',
            columns: [1,2,3],
            decodeColumns: {
                "Id": "UUID_TO_BIN(@1)",
                "ColumnName": "@2",
                "AnotherColumnName": "@3",
            }
          });

С названием схемы и таблицы всё должно быть очевидно: fieldsTerminatedBy — это делимитер между столбцами, linesTerminatedBy — соответственно, делимитер между строками. Каждое значение в columns — это название колонки в целевой таблице, в которую будет импортирован столбец из файла согласно порядку, указанному в columns.

Но почему там цифры? А цифра — это что-то типа названия переменной, чтобы можно было уже в секции decodeColumns как-то переопределить значение. В приведённом примере первая колонка заменяется на переменную 1 (число может быть произвольным, но это обязательно должно быть число), а в секции decodeColumns уже указывается, что к значению из первой колонки применяется функция UUID_TO_BIN,  и полученный результат вставляется в колонку Id.

Делимитеры

Итак, bcp дампит данные во что-то типа csv. Но csv-формат предполагает, что значения разделены запятой, а каждое значение обрамлено в кавычки. То есть кавычки в рамках чистого csv — это небезопасный символ, который надо экранировать, если он встретится в данных. Но bcp экранировать не умеет (или я не нашёл как), поэтому от кавычек следует отказаться, а значения разделять некой последовательностью символов, которая в ваших данных встретиться не может.

У меня данные в базе частично представляли из себя текст статей прямо вместе с HTML-разметкой, потому специальных символов в данных, прямо скажем, хватало. В итоге я остановился на разделителе |-| — выглядит понятно и нигде в моих данных не встречается. Для разделения строк использовал конструкцию \n-/-\n, где \n — это перевод каретки. Скрипт для дампа выглядит как-то так:

bcp schemaname.tablename out /path/to/file -S server -U user -P password -d dbname -c -t '|-|' -r '\n-/-\n'

NULL

Bcp работает с NULL немного не так, как это делает SQL. NULL в файле выгрузки превращается в пустое значение, а пустая строка превращается в специальный символ Nul (он же 0x0). А вот mysqlsh работает как SQL, то есть пустое значение интерпретирует как пустую строку, а символ Nul — в общем-то, как символ Nul. Потому для правильной заливки дампа пришлось написать оверрайд для каждой колонки со строковым типом данных:

decodeColumns: {
            "ColumnName": "IF(@1='@','',IF(@1='',NULL,@1))"
}

К сожалению, mysqlsh никак не хотел принимать конструкцию вида IF(@1=0x0,'',...), поэтому пришлось воспользоваться старым добрым sed и заменить в дампе символ 0x0 на нормально воспринимаемую mysqlsh собачку (@).

sed -i 's/\x0/@/g' /path/to/file

Потом оказалось, что похожая проблема есть у всех nullable типов данных, потому что вместо ожидаемого базой NULL приходит пустая строка и ломает импорт. Для этого пришлось написать ещё пару оверрайдов, но с ними код скрипта стал совершенно нечитаемым:

decodeColumns: {
    "StringColumn": "IF(@1='@','',IF(@1='',NULL,@1))",
    "NullableUUID": "UUID_TO_BIN(IF(@2 = '',NULL,@2))",
    "AnotherNullable": "IF(@3='',NULL,@3)",
}

Тут я решил, что не зря же эта оболочка, по сути, интерпретатор JS, а значит, можно всё красиво обернуть в функции:

function string(param){return `IF(${param}='@','',IF(${param}='',NULL,${param}))`}
function uuid_nullable(param){return `UUID_TO_BIN(IF(${param} = '',NULL,${param}))`}
function nullable(param){return `IF(${param}='',NULL,${param})`}

...

decodeColumns: {
    "StringColumn": string("@1"),
    "NullableUUID": uuid_nullable("@2"),
    "AnotherNullable": nullable("@3"),
}

Так ошибиться при составлении скрипта стало намного сложнее.

Тип rowversion

Кроме GUID мне попался ещё один тип данных, которого нет в MySQL — это rowversion. EF при составлении миграции для создания таблицы в MySQL предложил использовать для этого тип timestamp. Вот только проблема в том, что rowversion со временем никак не связан, а потому напрямую в timestamp никак не транслируется.

К счастью, особой функциональной нагрузки этот тип не несёт, он просто не позволяет перезаписать данные, если за время твоих изменений они были модифицированы. Так что rowversion можно сконвертировать в произвольный timestamp, не переживая, что он может быть сильно в прошлом или сильно в будущем, потому что коду не важно, «когда» этот timestamp, главное, чтобы он оставался тем же самым во время чтения и во время обновления. Потому я сделал ещё одну функцию для оверрайда:

function row_version(param){return `FROM_UNIXTIME(IF(${param} = '',NULL,${param}))`}

Итого

Чтобы перелить данные для одной таблицы, нужно:

  1. Сделать скрипт для создания дампа конкретной таблицы через bcp.

  2. Прогнать дамп через sed, чтобы заменить символ 0x0.

  3. Сделать скрипт импорта для mysqlsh:

    1. взять шаблон, вставить в него имя базы, таблицы и файла дампа, а также проставить делимитеры;

    2. вставить в массив columns значения по количеству столбцов в таблице;

    3. для каждой колонки сделать оверрайд, выбрав нужную функцию. Если оверрайд не нужен, то написать "Column": "@{номер столбца}";

    4. не перепутать нумерацию колонок и оверрайдов.

  4. Протестировать.

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

Релиз

Теперь, когда код написан, а скрипты переливки проверены, нужно выкатить изменения на прод. Вот так выглядит алгоритм упомянутого выше «небесшовного» релиза:

  1. Тёмной ночью выкладывается новая версия приложения, которая работает с MySQL. Таблицы создаются мигратором прямо в рамках этого деплоя и на момент выкладки абсолютно пусты. С момента окончания деплоя можно считать, что данные находятся в некотором зафиксированном состоянии, потому что доступа к старой базе у приложения больше нет, а в новой пока нет данных.

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

  3. Данные заливаются в новую базу. Они уже подготовлены и заливка протестирована — всё должно пройти без ошибок.

  4. Если всё же возникает ошибка, которую не удаётся починить на ходу, то система просто откатывается на предыдущее состояние, где она работала со старой базой. Можно спокойно провести работу над ошибками и попробовать снова следующей ночью.

Обновлять приложение, особенно если оно большое, лучше небольшими частями. Я выкатывал за раз по одному DbContext'у, причём начинал с тех, где меньше всего цена ошибки. О некоторых ошибках может стать известно через несколько дней, потому лучше не спешить.

Выводы

Как я уже говорил в начале статьи, не ко всем приложениям, использующим EF, применим такой алгоритм перехода на другую БД. База должна быть небольшой, нагрузка — такой, чтобы можно было позволить себе 10-15 минутный даунтайм, причём несколько раз в рамках всего перехода. Но зато при соблюдении этих условий переезд на другую БД становится вполне тривиальной задачей.

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

Удалось ли подтвердить наличие у EF киллер-фичи по смене БД? Я считаю, что удалось. Переезд выполнялся ровно одним разработчиком, и за две недели было перенесено 90% приложения (потом внезапно наступил отпуск и итоговый результат по времени уже не выглядит так красиво). При этом в приложении изменилось какое-то смехотворное число строк кода, и ни строки не изменилось в бизнес-логике. Думаю, что разработчики EF могут по праву гордиться своим продуктом. А если бы существовал cross-DB тулинг для переливки данных, можно было бы менять БД как перчатки. :)

Полезные ссылки

Настройка MySQL-коннектора

Документация по bcp

Документация по mysqlsh

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


  1. shashurup
    26.07.2022 15:25
    +5

    Вот мне интересно, как думают люди которые пишут про "низкоуровневый SQL".
    В реальности SQL, как правило сильно выше уровнем чем тот код, который его использует. SQL реально абстрагирует клиентов от того как хранилище в конкретной БД реализовано. А вот клиентский код, как правило, "прибит гвоздями" к представлению данных в памяти процесса.

    LINQ, пожалуй, первая попытка подтянуть средства современных языков до уровня SQL. Остальные же ОРМ предлагают нам вместо относительно универсального SQL, привязанное к конкретному языку кривое подмножество этого самого SQL. Ну и кучу разных веселых проблем в качестве бонуса :(


    1. boldMahoney
      26.07.2022 16:03
      +1

      Код SQL выше уровнем, чем клиентский код его использующий? Это как? Поясните.


      1. shashurup
        26.07.2022 16:18

        Организацию данных в памяти приходится учитывать при реализации алгоритмов их обработки. То есть, мы на клиенте сразу решаем, что, например, идем по списку сотрудников, подтягиваем для каждого "расшифровку" статуса из рядом лежащей мапы и название отдела из еще одной (заранее сформированной) мапы. При этом может получится так, что в некоторых случаях (например, в случае фильрации по отделу) быстрее будет не перебирать всех сотрудников а иметь дополнительную мапу сотрудников по отделам и вытаскивать сотрудников сразу из нее - а мы уже прибили гвоздями конкретный алгоритм, завязанный на нашу структуру данных в памяти.

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


        1. boldMahoney
          26.07.2022 17:12

          Ну так это к SQL коду непосредственно отношения не имеет. Вы здесь описываете частный случай денормализации данных, т.е. создать рядом мапу(ы) из нормализованных полей данных для ускорения поиска по частому конкретному запросу. Это скорее к теории отношений БД: как их правильно составлять и проектировать размещение данных. Никто ведь не заставляет клиентский код прибивать гвоздями к этим мапам: их можно агрегировать в соответствующие вьюхи и использовать через ORM как обычные таблицы. Вот SQL код во вьюхах и пусть оптимизирует сама БД, она и статистику соберет по запросам. И у вас выходит, что в клиентском коде зашито знание как эти данные хранятся в БД. А клиентскому коду по-хорошему знать об этих приседаниях не надо.

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

          Каждый раз приходя на очередной проект и видя развесистый EF спрашиваю местных: в чем сила брат? в чем преимущество использования здесь ORM'а? И обычно первым же ответом идет чтобы легче переехать на другую СУБД "если что". Причем это самое "если что" не уточняется и не случалось чуть ли не десятилетиями. Для меня же это в первую очередь продуманная инфраструктура, удобный генератор миграций, валидация моделей классов, встроенное кеширование и прочие плюшки оптимизаций запросов. Поэтому при всех недостатках ORM'а все же выступлю за его использование. Однако учитывая текущую ситуацию все же раз в пару десятков лет это самое "если что" стреляет. Вот и у нас на горизонте замаячила задача переезда с MS SQL на что-то опенсорсное.


          1. shashurup
            26.07.2022 17:56
            +1

            Не совсем так. Я имел ввиду то, как клиент работает с данными у себя в памяти. Клиенту приходится значительно больше внимания уделять тому как данные у него в памяти размещены, какие ему понадобятся вспомогательные стурктуры и т.д. А уж переключить алгоритм выборки в зависимости от условий запроса он никак не может - это всё перекладывается на разработчика, а это, чаще всего, слишком сложно для реализации в конкретном случае.

            Именно с этой точки зрения SQL более высокоуровневый - он скрывает сильно больше деталей реализации и избавляет клиентов от большого кол-ва рутины.

            На счет встроенной миграции - соглашусь. В мире БД с этим есть проблемы.
            На счет кэширования - скорее нет. Пусть лучше будет больше кэша в самой БД чем entity level кэш в ОРМ со всем прилагаемыми проблемами. Просто потому, что проблем с инвалидацией кэшэй в БД я не припоминаю, а вот в ОРМ - вполне себе.


            1. shashurup
              26.07.2022 18:02

              На счет валидации - в целом, фича хорошая - статической типизацией нужно пользоваться. Только, ИМХО, реализовывать это нужно было бы немного по-другому. Но тут стреляет другая проблема мэйнстримовых ЯП, мало кто дает выполнить какой-то свой код на этапе "компиляции" чтобы проверить, что все поля запроса на месте и типы данных у них совместимы с кодом.


  1. tukreb
    26.07.2022 16:17
    +3

    А почему выбрали MySQL, после MSSQL, а не PostgreSQL? Там и проблем с типами не было бы, и аналог rowversion есть https://www.postgresql.org/docs/current/ddl-system-columns.html . По мне так MySQL выглядит больше как даунгрейд СУБД в этом плане, если конечно вы осознано выбирали MySQL под очень узкий тип задач.


    1. Rasteniy Автор
      26.07.2022 18:04
      +2

      Mysql неосознанно выбран более десятка лет назад и используется почти во всех наших проектах, так что теперь мы слишком хорошо знаем, как его готовить, чтобы имело смысл переходить на постгрес.


      1. iamkisly
        26.07.2022 20:32
        +3

        Почему я представляю это вот так? В целом это очень странно..


        1. Sergeant101
          27.07.2022 06:30
          +1

          Почему на рободьяволе реклама пиццы?


          1. iamkisly
            27.07.2022 14:05

            Как бы этот логотип более узнаваем, чем лого dodo engineering.. и по цветам лучше подходит.


        1. Rasteniy Автор
          28.07.2022 18:52

          Хочется заметить, что в 2011 году мускуль и постгря были совершенно другими базами, и никто не мог предсказать траекторию их развития. У постгрес так вообще тогда были серьёзные проблемы с репликацией, что объективно отпугивало от её использования. А картинка классная, спасибо)


  1. Dansoid
    26.07.2022 17:24

    Нужно было взять для перезаливки данных расширение linq2db.EntityFrameworkCore - там легко делается пайплайн из исходной таблицы и вставка в таргет. Вставка присходит быстро и не нужно дополнительных телодвижений.

    Приблизительный пример как это могло быть:

    using var sourceContext = new MyDbContext(sourceOptions);
    using var targetContext = new MyDbContext(targetOptions);
    
    targetContext.BulkCopy(sourceContext.SomeTable.AsNoTracking().AsEnumerable());
    targetContext.BulkCopy(sourceContext.SomeOtherTable.AsNoTracking().AsEnumerable());
    ... // и тд.
    

    Можно сделать обобщенные фукции которые могут заливать инкрементарно или перезапускать заливку если данные в таблице есть, а количество не совпадает.

    Также ничего не мешает сериализировать классы в файлы и загружать их если источник недоступен.


  1. imlex
    27.07.2022 09:16

    На заметку - есть ещё такая удобная визуальная тулза для переноса данных из одной бд в другую - Pentaho Data Integration (вроде бы она же называется Kettle, а сам исполняемый файл Spoon). Вот ссылка на community edition - https://sourceforge.net/projects/pentaho/files/Data Integration/7.1/

    В своё время очень помогла при миграции с MS SQL и с Oracle на Postgres. Впрочем она написана на Java и поддерживает любые JDBC драйвера.


  1. KReal
    27.07.2022 15:19

    Я перевозил один проект с on-premise MS SQL на AWS MySQL. Использовался Linq2Sql (тогда EF ещё не было). Нашёл два коннектора, опен-сорс и от devart. Опенсорсный не завёлся, девартовский сразу заработал. Данные перенёс сравнительно легко, а вот процедурки пришлось переписывать. Причём отчасти и логику, потому что оптимизации в мускле работали как-то иначе. Но в целом управился за пару-тройку дней.