пятница, 28 августа 2015 г.

Сказ о том как мы выливались до 3х часов ночи

Однажды у нас появилась относительно несложная (на вид) задача - отображать прогресс перевода документа не по количеству переведённых сегментов (относительно небольшие куски на которые разбивается документ для более удобной обработки), а по количеству слов в этих сегментах. Идея здравая, информация насчёт слов у нас вроде бы и так была. В целом казалось что сделать всё проще простого.
Однако реальность внесла свои коррективы. Сразу же выяснились 2 вещи:
1. Старая статистика хранилась в уже агрегированном виде в таблице документов и обновлялась при каждом изменении сегмента (что увеличивает количество запросов при обновлении, потенциально опасно и ухудшает и так не слишком читаемый код внутри персистентности, но это лирика)
2. Количество слов у сегмента у нас было только в C# коде (высчитывалось по примитивному алгоритму) - в базе эти данные не хранились, только общий агрегат.

Так что мы поняли что придётся всё же писать количество слов для каждого сегмента + обновлять кусок, который считает статистику для документа.
Это был один из первых звоночков, когда я стал понимать что так дальше жить нельзя - вся логика работы с базой была размазана по немалому количеству хранимых процедур, причем искать зависимости в базе - это совсем не find usages внутри студии. При этом даже статистика вытаскивалась как минимум двумя разными хранимками (мы с этим отлавливали баги - в одном месте были одни значения, а в другом - другие).
Но в общем и целом кое-как была таки дополнена логика на сервере, на клиенте, написана миграция базы переписывающая штук 5 хранимых процедур и вьюшку, а так же добавляющая недостающую колонку. Ну и следующей миграцией (накатываемой сразу же после первой) был написан SQL скрипт, который с помощью несложной функции считающей количество слов (аналог string.Split('  ').Count() в нашем коде) проходил по всем сегментам и прописывал количество слов в них.
После довольно немалого (как для такой несложной фичи) периода стабилизации на тестовом сервере мы таки решили выливаться в продакшн. По грубым прикидкам (банально поленились полезть и посмотреть, положившись на память) на продакшене было раза в 3-4 больше данных чем на тестовом сервере. Учитывая что скрипт на тестовом сервере шел порядка 5-10 минут, мы решили что в целом ничего ужасного нет и мы вполне можем подождать полчасика.

И вот наступает день Х. Мы ждём пока выльется другая команда, у них это затягивается до 10-11 вечера (как обычно, впрочем). После чего начинаем свою выливку - быстренько выливаем код и запускаем миграцию. Первая миграция (со структурой базы) проходит быстро, а вот вторая как-то затягивается - 30 минут, 40... В итоге примерно к концу часа, миграция падает с довольно невнятным сообщением об ошибке. Ладно, пробуем прогнать её еще раз и попутно пытаемся разобраться в чем там было дело. Толком непонятно, но к концу следующего часа миграция опять падает. Становится понятно что это не случайная ошибка, а какой-то систематический просчёт у нас в миграции.
Хуже того, Sql сервер начинает как-то уж очень плохо шевелиться, причем не только на нашей базе. Делать нечего, вызваниеваем одного из админов. Он сообщает что раздел диска выделенный под TempDB забит под завязку. После чего добавляет туда места, рестартует сервер и вроде бы всё начинает нормально работать. Уже около половины второго ночи. Мы выдыхаем чуть спокойнее и решаем что нам необязательно "вот прямо сейчас" иметь все данные обновлёнными и поэтому достаточно будет если мы смигрируем документы только за последние несколько месяцев, а остальное отложим на завтра. Собственно, это мы и сделали - в ручном режиме (запустив скрипт напрямую) прогоняем миграцию только части данных, быстренько прогоняем тесты и разъезжаемся по домам.
Утром я быстренько набросал консольное приложеньице, которое мигрирует сегменты подокументно (т.е. пачками 10-10к штук) и уже в рабочее время в течении дня прогнал миграцию, на всякий случай попросив админов мониторить нагрузку на сервер (там всё оказалось нормально).
Т.е. в общем и целом - особо ужасного конечно ничего не случилось - вряд ли много пользователей страдали от того что наш сервис плохо работал в районе полуночи, никаких данных мы не потеряли и к утру большинство сценариев даже отрабатывали как надо.
Но для нас это было неприятно и нетипично - крайне редко выливки у нас длились дольше чем до 20:00, при том что начинались не раньше чем в 18:30, а 90% времени тратилось на регрессивное тестирование автотестом + немного руками.

Так что мы сделали кое-какие выводы:
1. Иногда размер (базы) таки имеет значение. То что довольно легко проходит на тестовых данных, может застрять на реальных объёмах.
2. Всегда стоит внимательнее посмотреть - насколько отличаются размеры тестовых данных и насколько может деградировать производительность с ростом их количества. И если миграция на тестовом сервере проходит больше 3-5 минут, то это уже повод для беспокойства, особенно если при увеличении количества записей время растёт нелинейно (проще всего это проверить запустив миграцию сперва на части тестовых данных, а потом на всех).
3. Слона лучше есть по частям - не стоило запихивать такие масштабные изменения в один запрос. Именно это привело к распуханию TempDB. Так что следующие масштабные миграции мы решили делать в фоне и подокументно, т.е. относительно небольшими порциями. Кстати, мне стоило послушать Лёху, который предлагал это изначально ;)
4. Не совсем относящийся к миграции пункт - слой работы с базой был крайне плохоподдерживаемым. И мы затратили уйму сил чтобы всё это заработало и заработало без багов. И это на довольно простой задаче. Так что зачастую косвенные затраты на внесение новых фич в плохо поддерживаемых участках кода могут в совокупности довольно быстро перевесить затраты на нормальное переписывание. Надо будет не забыть про этот аргумент в следующий раз, когда буду выбивать время на рефакторинг кода.

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

среда, 26 августа 2015 г.

О Persistence ignorance и проблеме контроля изменений в слое работы с базой

О Persistence ignorance и проблеме контроля изменений в слое работы с базой.
В огромном количестве умных книжек по доменным моделям постоянно твердят о т.н. “Persistence ignorance” - утверждении о том, что доменная модель должна строиться без оглядки на базу данных. Мысль конечно правильая, вот только в качестве реализации почему-то всегда предлагают сделать ORM маппинги для доменной модели и делать вид что она при этом никак с персистентностью не связана, “оно само” работает с помощью ORM.
Однако даже в простейших “книжных” примерах или в демонстрационных видео видно что уже сразу начинаются всяческие уступки для того чтобы ORM заработала - virtual свойства, proteсted конструкторы без параметров (для того чтобы была возможность заполнить поля при вычитке из базы), использование обратных ссылок на родителей в дочерних объектах (public ParentClass Parent {get; set;}) для того чтобы включить инверсное сохранение дочерних объектов так как оно работает в СУБД (проблема one-to-many) и т.д. и т.п. Т.е. делается множество относительно мелких, но всё же вполне заметных допущений и “подпиливаний напильником” для того чтобы суметь втиснуть доменную модель в рамки ORM и работы с базой. Причем допущения эти в целом довольно неявные и неочевидные, но без них персистентность перестаёт работать.
И это еще в хорошем случае если доменная модель и структура базы совпадают между собой. Когда же начинают натягивать что-то не совпадающее 1 в 1, то в ход идёт тяжелая артиллерия - всяческие ухищрения со стороны маппингов и настроек ORM. В этом случае персистентность становится неимоверно хрупкой. И без знания этих деталей сделать изменения в базе или доменной модели становится очень опасно - могут возникать ошибки в абсолютно неочевидных местах.
Поэтому, лично моё мнение что в таком виде Persistence ignorance - это миф, слишком уж разные миры реляционных баз данных и объектов (и коллекций) в памяти.

Что же делать в этом случае? Перестать притворяться!
Т.е. разделить условный слой персистентности на две части:
Часть работы напрямую с базой: объекты-модели максимально точно воспроизводящие строение базы, их маппинги (или напрямую аннотации в классах), контекст работы с базой. Всё это имеет модификаторы internal и снаружи не доступно.
Часть, свободная от персистентности - уже “чистые” классы, которые будут нашим контрактом во внешний мир + публичные статические методы, которые создают, удаляют и обновляют записи в СУБД. Именно они манипулируют нашей первой частью в том виде, в котором нам это нужно.

Т.е. всё это выглядит примерно таким образом:
Сперва мы создаём “образ” нашей СУБД в виде всем понятных и известных классов и маппингов, не пытаясь притворяться что “ложки-базы нет” - это именно что модель сугубо для общения с базой, с публичными сеттерами и прочими вещами не свойственными для хорошей доменной модели. Там же мы создаём internal перегрузку контекста работы с базой (сессия в терминах NHibernate), чем сразу говорим “работа с базой дальше этой сборки не пойдёт”, заодно избавимся от соблазна протянуть Entity/NHibernate через всё приложение.

После этого можно приступать к созданию классов доступных уже за пределами проекта, не завязанного напрямую с базой, не имеющих маппингов, конструкторов без параметров (а то и вовсе не имеющих публичных конструкторов, только private/internal, если нам не надо создавать объекты без сохранения их в базу). 
Лично я создавал вообще read-only объекты, модификация которых была возможна только “через кассу базу” .

Т.е. выглядело это приблизительно так:
public class Document
{
 private readonly Guid id;
 private readonly string title;
 private readonly string fileName;

 internal Document(Guid id, string title, string fileName)
 {
  this.id = id;
  this.title = title;
  this.fileName = fileName;
 }
 
 public Guid Id {get { return id; } }
 public string Title {get { return title; } }
 public string FileName {get { return fileName; } }

 public static Document GetDocument(Guid id)
 {
     using (var context = DbContext.CreateInternal())
     {
         var entity = context.Documents.Find(id);
  
         return ConvertFromEntity(entity); 
     }
  }

  internal static Document ConvertFromEntity(Entities.Document entity)
  {
      return new Document(entity.Id, entity.Title, entity.FileName);
  }
}

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

Отдельно стоит упомянуть проблему поддержания всех моделей (при подходе по типу CQRS, где модель для чтения и модель для записи - это две разных модели) в консистентном состоянии. Думаю, для многих не редкость ситуация, когда для того чтобы добавить колонку в таблицу, необходимо не только добавить колонку в таблицу и поле в модель, но еще и добавить её в несколько view и хранимых процедур в базе. Причем узнать какие именно это хранимые процедуры или вьюшки довольно непросто - в Sql Management Studio есть конечно view dependencies, но работает оно мягко говоря совсем не так удобно как Find Usages в студии, не говоря уже о том что это модальное окошко. В итоге, вносить изменения становится намного труднее, а отлавливать баги - куда чаще.

В случае с вышеприведённым подходом, есть возможность использовать одну и ту же инфраструктуру для работы с базой как для чтения, так и для записи, ведь всё равно она лишь повторяет схему базы.
К тому же, по моему опыту - большая часть view - моделей обычно состоят из плоских доменных моделей + каких-либо дополнительных данных (агрегатные данные типа сумм, количества и т.д. + какие-либо дочерние коллекции). Соответственно, никто особо не запрещает использовать либо наследование (хотя его не очень люблю и на свежую голову решил что необходимости в нём тут абсолютно нет) либо композицию и использовать те же самые CreateFromEntity. Что во-первых даёт полный контроль с т.з. системы типов и конструкторов, а во-вторых уменьшает количество точек где надо производить изменения до минимума - в таком случае для того чтобы добавить колонку в базе необходимо будет: добавить собственно колонку, добавить поле в entity для работы с базой, добавить поле во внешний объект и его конструктор, а также в CreateFromEntity (который, кстати, тоже может быть private/internal конструктором).

Т.е. если нам понадобился документ вместе с его содержимым, то мы создаём 


public class DocumentWithEntries
{
 private readonly Document document;
 private readonly IEnumerable<DocumentEntry> entries;

 private static DocumentWithEntries(Document document, IEnumerable<DocumentEntry> entries)
 {
  this.document = document;
  this.entries = entries;
 }

 public Document Document { get { return document; } }
 public IEnumerable<DocumentEntry> {get { return entries; } }

 public static DocumentWithEntries GetDocumentWithEntries(guid id)
 {
  using(var context = DbContext.CreateInternal())
  {
   var documentEntity = context
                           .Documents
                               .Include(x => x.Entries)
                            .FirstOrDefault(x => x.Id == id);
   return ConvertFromEntity(documentEntity, documentEntity.Entries);
  }
 }
 
 private static DocumentWithEntries ConvertFromEntity(Entities.Document document, 
IEnumerable<Entities.DocumentEntry> entryEntities)
 {
  var document = Document.ConvertFromEntity(document);
  var entries = entryEntities.Select(x => DocumentEntry.ConvertFromEntity(x));
  
  return new DocumentWithEntries(document, entries);
 }
}

Т.е. если вдруг понадобится добавить поле в Document, то сперва добавляем его в Document и соответственно его конструктор (ведь поле у нас readonly, иного способа его инициализировать нет). А дальше - у нас перестаёт билдиться ConvertFromEntity, т.к. внутри в вызове конструктора не хватает параметра, вслед за ним тянется Entities.Document (надо же откуда-то брать данные?) и всё! Останется лишь добавить колонку в базу, что в общем-то довольно сложно забыть. Всякие DocumentWithEntries, DocumentWithStatistics и всё что нам еще придёт в голову будут автоматически иметь нужное нам поле. При этом Entity позволяет получать довольно удобные агрегирующие функции прямо из Linq интерфейса путём трансляции этих запросов в базу. Т.е. в 90% случаев скорее всего вполне можно обойтись без Views/Stored Procedures и ручного написания SQL кода.

В качестве вывода хотел бы еще раз подчеркнуть что не стоит пытаться прятать голову в песок и утверждать что мы имеем Persistence Ignorance, потому что мы сумели кое-как натянуть ORM поверх доменных объектов. На мой взгляд лучше не пытаться притворяться и разделить эту часть на две - проекцию базы данных и нормальные доменные сущности, которые уже будут действительно POCO, без прикрученных сбоку маппингов, конструкторов без параметров и т.д. Заодно, поверх этой же проекции можно сделать и View - модели для чтения. Либо смешав их с доменными (т.к. они практически всегда строятся поверх уже имеющихся там данных), либо вынеся в отдельную friendly сборку. Это поможет иметь куда более удобный контроль над изменениями в структуре базы, практически ничего не останется на “это надо запомнить”, всё будет контроллироваться с помощью параметризированных конструкторов и системы типов.

UPD. Тут мне заняли хороший вопрос - а что происходит с этой моделью в дальнейшем и как будет выглядеть модификация данных?
В общем, первоначальным соблазном было сказать что на выходе мы и имеем доменную модель. Но тут возникает вопрос - а что мы хотим получить от доменной модели и что в таком случае будет ею являться?
Если она должна "делать зашибись", включая обновления базы, то наверное можно назвать её и таким образом. Но мне не нравится эта идея, потому как тогда эта доменная модель будет  намертво привязана к персистентности и зачастую смешивать собственную логику с логикой персистентности (во всяком случае соблазн будет крайне велик).
Так что я всё же склоняюсь к тому что это никакая не доменная модель, а просто довольно четко оформленная модель персистентности. А уже потом из неё можно сделать что угодно - и доменную модель и модель для отображения данных и чёрта в ступе. Т.е. опять приходится не лениться и делать разделение между моделями :(
Соответственно, изменения для такой модели тоже могут быть минималистичными с учетом необходимых юзкейсов. Например, если необходимо изменить текст в элементе документа, то вовсе не надо принимать в качестве модели весь элемент документа, достаточно просто получить его Id и новый текст.
public static DocumentEntry UpdateEntryText(Guid id, string newText)
{
    using(var context = DbContext.CreateInternal())
    {
        var entryEntity = context.DocumentEntries.First(x => x.Id == id);
        
        entryEntity.Text = newText;
        context.Save();

        return ConvertFromEntity(entryEntity);
    }
}

Кстати, если вдруг будет необходимость комбинировать разные операции внутри одного более глобального метода, то можно создавать 2 версии метода - internal перегрузку, которая будет принимать контекст и выполнять все действия в нём и public версию, которая будет создавать контекст и вызывать internal часть, которая всё и сделает. Соответственно, если нужно скомбинировать какие-то действия, то будут вызываться internal методы получающие контекст извне. И только внешний public метод будет создавать транзакции и управлять ими.