четверг, 25 февраля 2010 г.

Улучшение производительности LINQ запросов. Precompiled LINQ Queries.

Одна из возможностей LINQ to SQL и LINQ to Entities это транслятор запросов, которой из программного кода переводит запрос на язык, понимаемый источником, к которому вы посылаете запрос (если речь идет о БД). Процесс преобразования вашей цепочки вызовов, например, в T-SQL не дешевая операция. Каждый раз когда вы выполняете запрос, строится дерево выражений, операций, по которым ваш провайдер, в данном случае SQLProvider, будет транслировать запрос в T-SQL.

Если у вас есть веб сервисы, который дергаются на клиенте, то хочется чтоб они выполнялись быстро, чтоб продолжить выполнение каких-то операций на клиенте. А если у вас AJAX и Rich UI, то тем более хочется чтоб отклик был как можно быстрей. Конечно, это относится не только к сервисам, а и к приложению в целом.

Если у вас есть запросы, которые выполняются часто, например GetCustomerByName, GetPostsByAuthor и тп, и вы знаете что в запросе не будут добавляться фильтры то их можно оптимизировать. Под оптимизацией я понимаю кэширование процесса построения запроса, а именно скомпилировать запрос. Другими словами вы получаете хранимую процедуру в виде LINQ запроса в которую вы просто передаете параметры.

Рассмотри пример. У вас есть форма, в который вы ищете страну, чтоб показать её на карте или тп., запрос будет выглядеть примерно так:

using (DBContext db = new DBContext())
{
    var countries = (from country in db.Countries
                     where country.Name.ToLower() == key.ToLower()
                     select country).ToList()

    // do actions with result.
}

Компилируется запрос так:
CompiledQuery.Compile<DBContext, string, IQueryable<Country>>(
    (db, key) => (from country in db.Countries
                  where country.Name.ToLower() == key.ToLower()
                  select country));

Compile принмиает Func<>, можете передать контекст, дополнительные параметры и возвращаемый результат. Потом по коду можно использовать его так:

IQueryable<Country> countries = GetCountryByName.Invoke(db, txtCountry.Text);

Если у вас web приложение можно сохранить запрос в статическую переменную. В другом случае - "шило на мыло".

public Func<DBContext, string, IQueryable<Country>> GetCountryByName =
            CompiledQuery.Compile<DBContext, string, IQueryable<Country>>(
    (db, key) => (from country in db.Countries
                  where country.Name.ToLower() == key.ToLower()
                  select country));
Если у вас есть Repository классы, можно компилировать запрос при первом обращении. Для того чтоб код не "вонял", можно вынести запрос в отдельный класс Queries. При желании можно создать и подклассы, например Queries.Location.GetCountryByName:

public static IQueryable<Country> GetCountryByName(int name)
{
    if (Queries.Location.GetCountryByName == null)
    {
        Queries.Location.GetCountryByName = 
            CompiledQuery.Compile<DBContext, string, IQueryable<Country>>(
    (db, key) => (from country in db.Countries
                  where country.Name.ToLower() == key.ToLower()
                  select country));
    }

    return Queries.Location.GetCountryByName.Invoke(_context, name);
}


Если у вас в результате запроса возвращается сложный тип (анонимный), нужно создать его проекцию, например CountryDTO.

С LINQ to SQL есть проблемы: вы не можете догрузить смежные таблицы используя один DataContext. Например у вас есть выборка по постам блога и вы хотите подтянуть авторов и коментарии, это можно сделать через DataLoadOptions. Но LoadOptions можно задать только один раз для одного экземпляра DataContext, в другом случае получите ошибку. Если вы используете DataContext атомарно для запроса, то проблем нет. Но если у вас DataContext создаете на один HttpContext - то провал. Как вариант, конкретно для методов где используется компилированый запрос, вы можете использовать DataContext атомарно.

В LINQ to Entities эта проблема решена с помощью Include, где вы можете прям в запросе указать, какие таблицы вы хотите загрузить сразу, а не по требованию.

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

среда, 24 февраля 2010 г.

Как получить параметры строки запроса в веб сервисе

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

Допустим у вас есть следующий функционал: вы можете выбрать страницу и добавить на неё какие-то виджеты. Страница будет выглядеть так - Navigation.aspx?PageId=123. На странице будет js функция, которая дергает сервис для добавления выбранного виджета.

[WebService(Namespace = "http://tempuri.org/")]
[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
[ScriptService]
public class WidgetService : WebService
{
    [WebMethod]
    public int AddWidget(int widgetId)
    {
        NameValueCollection request = HttpUtility.ParseQueryString(this.Context.Request.UrlReferrer.Query);
        int pageId = int.Parse(request["PageId"]);

        int widgetInstanceId = AddWidgetInternal(widgetId, pageId);

        return widgetInstanceId;
    }
}

Решение находится в строке #8. Чтоб получить строку запроса, её можно получить у страницы, с которой был вызван сервис.

пятница, 22 января 2010 г.

Проблемы при подключении к удаленному MS SQL 2008 через SQLMS

Я поставил MS SQL 2008 Express (у меня стоит Vista) и столкнулся с проблемой, что не могу посмотреть список баз данных под любой учеткой, хоть под windows authentication, хоть под sql authentication на удаленном сервере, если подключаюсь через Management Studio. Я всегда получаю сообщение The server principal “%.*ls” is not able to access the database “%.*ls” under the current security context. Но если подключаюсь через Visual Studio, то все нормально.

Проблема не в настройке удаленного сервера, а вашего локального. На XP по умолчанию SQL Server устанавливается и запускается от учетки Network Service, которую, как оказалось, Microsoft не рекомендует использовать для запуска SQL Server под Vista. При установке эта опция стоит по умолчанию по старинке – Network Service (не понятно зачем?). Я cтавил SQL Server с дефолтными настройками. В этом и заключается вся проблема.

Решение:
  • Если у вас стоит Vista или 7, то устанавливается MS SQL 2008 под учеткой LocalSystem или Build In Domain User. Если вы уже установили сервер, тогда в Sql Server Configuration Manager меняем учетку от которой будет запускаться SQL Server сервис на Local System
  • Если у вас стоит XP - NetworkService.
Для более детальной информации: Настройка служб, доступных в программе установки SQL Server, Install SQL Server 2008 from the Command Prompt

среда, 2 декабря 2009 г.

SQL Поиск по шаблону. PATINDEX

Недавно коллега с соседней команды подкинули мне интересную задачу: у вас алфавитное меню A-Z, при клике на букву в подменю должны отобразится записи, которые начинаются на выбранную букву. Заполнением подменю занимается аяксовый сервис, который дергает хранимку, которая должна вернуть данные наичнающиеся с указанной буквы(такая была архитектура). Меню строится для продуктов. Ниже пример названий продуктов.

Как видите не все так сладко. Некоторые продукты не начинаются с буквы. Допустим нам нужны продукты которые начинаются на 'А'. Должны отобразится 128, 130-135, 137, 138, 140 и тд. Я привел только те, которые видны на картинке. В базе 300 000 продуктов, а подменю должно отображатся как подменю :)
Как решить задачу ?

Решение 1:
- Добавить колонку в таблицу, например [FirstLetter]. Вызвать один раз хранимку или серверный код, не важно, который заполнит это поле. Потому можно проиндексивать поле и выгребать данные как угодно и быстро. Или если товары импортируются, делать это при импорте.

Решение 2:
- Если "Решение 1" не подходит или нет возможности его реализовать, есть другой вариант. Можно выбрать один раз все имена продуктов на сервере, закешировать, поставить SqlCacheDependency если можно и нужно. Потом в сервисе можно в сохраненной выборке перебирать данные с помощью Regex, LINQ, искать первую букву, как угодно, возможностей много. Данное решение хорошее, но держать в кешэ 300 000 записей жирно та и требует изменений в существующей архитектуре - хочется сделать минимальные усилия с максимальным результатом исходя из того, что есть.

Решение 3:
- Хотелось бы воспользоваться оператором LIKE в котором можно задать примитивный шаблон типо регулярки, но как я не крутил с LIKE, у меня ничего не получилось, но я был на правильном пути. И тогда я вспомнил что есть встроенная функция PATINDEX, которая делает тоже самое, что и LIKE, только еще и возвращает индекс совпадения этого шаблона. По скольку нам нужно найти в названии первую букву и сравнить её с заданой, то это можно написать так (например для буквы 'A'):

SELECT [Name] AS 'Product Name'
FROM Product
WHERE SUBSTRING(LOWER([Name]), PATINDEX('%[a-z]%', LOWER([Name])), 1) = 'a'

В результате получим:


Данные выбираются меньше секунды. Задача решена как и хотелось, с минимальными усилиями и максимальным успехом.
А теперь как это работает: функция PATINDEX ищет первое совпадение по указанному шаблону (с синтаксисом шаблона можно ознакомится по ссылкам). Наш шаблон говорит "искать первую попавщуюся букву в вырежении". Нашли, получили индекс этой буквы. Вырезаем эту букву функцией SUBSTRING и сравниваем с заданной.
Успехов.

воскресенье, 8 ноября 2009 г.

LINQ Bulk Insert

Недавно я столкнулся с проблемой произовдительности вставки данных с помощью L2S. Представьте себе следующую ситуацию: у нас e-commerce сайт, в котором можно создавать продукты, категории и мапить продукты на категорию (ии). Остановимся на функционале, где можно мапить продукты на категорию. У вас есть форма для редактирования категории, на которой в таблице отображаются продукты. Таблица пейжирована. Также есть 2 чекбокса - check\ unckeck all и check\unckeck current page ну и кнопка Save.




Вы отмечаете check all нажимаете Save и жопа ждете пока сохраниться мап всех товаров, которые у вас есть на категорию. Не важно большой у вас ассортимент или нет, это будет выполнятся долго, т.к. будет дергаться INSERT INTO CategoryToProductMap столько раз, сколько у вас товаров. В моем случаей это было 10 000 раз. Потом я встретил еще чудо-код, который выполнял подобные действия 130 000 раз ! Вы представляете это !? А если хотя бы 2 человека попадут на эту страницу...

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

В MS SQL 2000 появилась команда BULK INSERT, которая возволяет загрузить данные из файла в SQL Server "пачкой". Внутри эта команда использует утилиту BCP, копирует данные из файла в указанную таблицу. В ADO.NET 2.0 появился класс SqlBulkCopy, который является обверткой над командой BULK INSERT. В его использовании нет ничего сложного. Вы указываете строку соединения, таблицу в которую хотите вставить данные и сами данные.
Это нам подходит! Надо бы сделать это гламурненько в стиле L2S.
Погуглив, я нашел пример как это делается в стиле L2S, но как по мне он сложноват как в исполнении так и в чтении. Если хотите, можете взять его, он рабочий, я проверил. Но! У меня получилось быстрей :). Почему - не знаю. В конце статьи я приведу примеры по скорости выполнения.
Для реализации своего примера я почерпнул пару штрихов из источника, поэтому желательно если вы с ним ознакомитесь, хотя и необязательно. Примеры я буду приводить на всем известной Northwind базе данных. В результате у нас получится слудующее:

IEnumerable<Customer> dataToInsert = GenerateCustomers(100000);
int batchSize = 5000;
context.Customers.BulkInsert(dataToInsert, batchSize);

Для того, чтобы сделать вставку, необходимо вызвать метод WriteToServer у класса SqlBulkCopy. У него есть несколько перегрузок:


мы будем использовать метод, который принимает IDataReader. У интерфейса IDataReader очень много методов, но нам необходимо только 4. Создадим класс реализующий этот интерфейс. Методы, которые нам нужны пометим как  abstract, остальные как virtual, они нам не нужны и я их не буду показывать в коде.

public abstract class SqlBulkCopyReader : IDataReader
{
     public abstract bool Read();
     public abstract object GetValue(int i);
     public abstract int GetOrdinal(string name);
     public abstract int FieldCount { get; }

     #region // Not required ...
     ....
     #endregion 
}

Отнаследуем класс от SqlBulkCopyReader:

public class LinqEntityDataReader<T> : SqlBulkCopyReader where T : class
{
     private DataTable _sourceTable;
     private readonly IEnumeratort<T> _enumerator;
     private DataRow _current;

     public LinqEntityDataReader(IEnumerable<T> source)
     {
           MapTableName();
           MapColumns();
           _enumerator = source.GetEnumerator();
     }

     public string DestenationTable 
     {
          get { return _sourceTable.TableName; }
     } 

     public IEnumerable<string> Columns
     {
          get
          {
               foreach (DataColumn column in _sourceTable.Columns)
               {
                    yield return column.ColumnName;
               }
           }
      }

      public override object GetValue(int columnIndex)
      {
           return _current[columnIndex];
      }

      public override int GetOrdinal(string name)
      {
           return _sourceTable.Columns[name].Ordinal;
      }

      public override bool Read()
      {
           bool next = _enumerator.MoveNext();

           if (next)
           {
                _current = _sourceTable.NewRow();

                foreach (DataColumn column in _sourceTable.Columns)
                {
                     _current[column] = typeof(T).GetProperty(column.ColumnName)
                           .GetValue(_enumerator.Current, null);
                }
           }

           return next;
     }

     public override int FieldCount
     {
          get { return _sourceTable.Columns.Count; }
     } 

     private void MapTableName()
     {
          Type entityType = typeof(T);

          TableAttribute destenationTable = entityType
              .GetCustomAttributes(typeof(TableAttribute), false)
              .Cast<TableAttribute>()
              .SingleOrDefault();

          if (destenationTable == null)
          {
               throw new ArgumentNullException("destenationTable");
          }

          _sourceTable = new DataTable(destenationTable.Name);
     }

     private void MapColumns()
     {
          Type entityType = typeof(T);
          int columnIndex = 0;

          foreach (PropertyInfo property in entityType.GetProperties())
          {
               ColumnAttribute column = property
                   .GetCustomAttributes(typeof(ColumnAttribute), false)
                   .Cast<columnattribute>()
                   .SingleOrDefault();
          }

          if (column == null)
          {
               return;
          }

          if (!column.IsVersion && !column.DbType.Contains("IDENTITY") && !column.IsDbGenerated)
          {
               _sourceTable.Columns.Add(column.Name ?? property.Name);
          }

          columnIndex++;
     }
}

Начнем с конструктора. Для начала мы должны знать куда нам вставлять данные. Это мы можем узнать у сущности из аттрибута Table, что и происходит в методе MapTableName. Затем нам нужно знать в какие колонки можно вставлять данные. Во вставке могу участвовать лубые колонки кроме IDENTITY, TIMESTAMP и тп. Это можно узнать у свойства сущности, которое помечено аттрибутом Column, что и происходит в методе MapColumns.
SqlBulkCopy начинает свою работу с получения количества колонок, которые участвую во вставке и если указан мапинг, то вызывается метод GetOrdinal, чтоб получить индекс каждой колонки по имени.
Затем SqlBulkCopy идет построчно вызывая метод Read, а потом вызывает GetValue для каждой колонки у текущей записи.
Мое "ноу-хау" - это получение значений колонок рефлексией, когда запрашивается очередная строка, и сохранение этих значений в DataRow. Во-первых это проще, во-вторых читабельней и понятней, а в-третьих - думаете рефлексия в методе Read будет все тормозить ? - Нет! Получилось только быстрей, на удивление (может кто-то скажет почему ?).

Ну и завершающий этап, сама обвертка над SqlBulkCopy в виде Extenstion Method для таблицы-сущности:

public static class TableExtension
{
 public static void BulkInsert<T>(this Table<T> entity, IEnumerable<T> data, int batchSize) 
  where T : class
 {
  LinqEntityDataReader<T> reader = new LinqEntityDataReader<T>(data);

  using (SqlBulkCopy sqlBulkCopy = new SqlBulkCopy(DBContext.ConnectionString))
  {
   foreach (string columnName in reader.Columns)
   {
    sqlBulkCopy.ColumnMappings.Add(columnName, columnName);
   }

   sqlBulkCopy.BatchSize = batchSize;
   sqlBulkCopy.DestinationTableName = reader.DestenationTable;
   sqlBulkCopy.WriteToServer(new LinqEntityDataReader<T>(data));
   sqlBulkCopy.Close();
  }
 }
}

Ну тут вроде ничего военного, все должно быть ясно. Ну и в результате:

IEnumerable<Customer> dataToInsert = GenerateCustomers(100000);
int batchSize = 5000;
context.Customers.BulkInsert(dataToInsert, batchSize);

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

public static IEnumerable<Customer> GenerateCustomers(int count)
{
 for (int i = 0; i < count; i++)
 {
  yield return new Customer
              {
           CustomerID = i.ToString("00000"), 
   CompanyName = "Company" + i, 
   ContactName = "Frederique Citeaux",
                        ContactTitle = "Marketing Manager",
   Address = "24, place Kleber",
   Phone = "(604) 555-4729",
   Region = "WA"
         };
 }
}

Вот что у меня получилось:

  • Мое решение ~ 8 секунд
  • Решение из пример ~ 11секунд
  • Решение из пример + динамический вызов GetValue, который ему посоветовали в коментах ~ 9.5 секунд
  • Стандартный InsertAllOnSubmit ~ 1 минута 23 секунды
Я не думаю, что это совпадение, все-таки 100 000 данных - это немало. Почему так, я еще не узнал.
В любой случае я получил, что хотел. В моем конкретном случае мапинг всех товаров на категорию (10 000 записей) теперь выполняется ~ 1.1 секунды. Думаю этот пример вас когда-то спасет.