Оптимизация

Этот раздел я считаю самым важным, т.к. именно умение делать веб приложения с хорошей производительностью является отличительным признаком мастера от простого веб-разработчика. Основной принцип при оптимизации – сначала измерить и только потом менять. Не сделав замер, вы не сможете понять, улучшили вы производительность и на сколько.

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

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

Вот код, который вы можете для этого использовать:

public DateTime startTime = DateTime.Now;

protected void Application_BeginRequest()

{

       if (System.Web.Configuration.WebConfigurationManager.AppSettings["TracePagePerfomance"] == "1") {

                    startTime = DateTime.Now;

       }

}

protected void Application_EndRequest()

{

       if (!HttpContext.Current.Request.IsLocal && System.Web.Configuration.WebConfigurationManager.AppSettings["TracePagePerfomance"] == "1")

       {

                    var endTime = DateTime.Now;

                    var duration = (endTime - startTime).TotalSeconds;

                    if(duration>0.5){

                    CMS.BLL.Tracer.Write(HttpContext.Current.Request.RawUrl, duration.ToString(), "perf" + (int)HttpContext.Current.Response.StatusCode);

                    }

       }

}

Важный момент – используйте этот код только на некоторое время, иначе ваша база может очень быстро разрастись (либо увеличить порог до 1 сек – т.е. в базу будут записываться запросы с длиной более 1 секунды).

После того, как вы установили список медленных запросов, вы можете для каждого запроса расставить вызовы Trace.Warn и понять какие именно операторы вызывают наибольшие замедления. Про трассировку мы говорили ранее. Здесь можно только сказать, что вам надо просто расставлять метки вида [functionName.i] – т.е. в функции GetItems вы ставите такие точки GetItems1, GetItems2, …. Тем самым вы сразу по трассировке поймете какой участок самый медленный (по trace.axd). Таким нехитрым способом вы решите 1 задачу оптимизации – вы найдете те места, которые вызывают замедления.

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

В типовых ситуациях это может быть:

  • LINQ запрос
  • SQL запрос
  • Вызов API
  • Сложный цикл
  • Множественное сохранение
  • Экспорт в какой-то формат

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

В LINQ запросе это могут быть, например:

  • Тяжелые операции внутри Where (особенно когда идут отдельные подключения к базе). Это самая частая проблема – вызов тяжелых операций при большом количестве итераций.
  • Слишком рано использовался ToList (т.е. слишком много ненужных данных идет для извлечения)
  • Проблемы с сортировкой (для сортировочных полей лучше использовать индекс, особенно для дат).
  • Неоптимальное составление запроса (отрезайте сначала как можно больше в where, наименее вероятные (и самые простые) события ставьте вначале and)
  • Кешируйте тяжелые запросы.

Давайте в целом рассмотрим методы борьбы с долгими операциями:

  1. Отказаться от операции. Возможно, клиенту она не так нужна. Надо решить, что важнее: скорость работы или эта функция. К этому варианту прибегайте, когда остальные уже не дают ощутимого эффекта.
  2. Кеширование. Мы помещаем в кеш данные и берем потом их оттуда. Например, мы можем кешировать некоторые объекты базы данных. Тем самым мы только первый раз обращаемся к базе (относительно долгая операция), а затем используем данные кеша (быстрая операция). С кешированием есть 2 проблемы:
    • Если данные часто меняются, то кеш приходится часто очищать, и это может вызвать отрицательный эффект в плане оптимизации (т.е. данные чаще изменяются, чем используются).
    • Осторожно надо кешировать большие объекты. Они занимают память, поэтому дважды подумайте прежде, чем кешировать всю базу товаров к примеру. Храните в кеше минимально возможные данные.
  3. Оптимизация запроса к данным. Часто можно добиться нужно эффекта оптимизации за счет реструктуризации запросов. Вы изменяете LINQ или SQL запросы с замером производительности. Есть множество различных техник повышения производительности SQL запросов.

Оптимизация SQL запросов

Дорогие операции:

  • агрегатные функции (кешируйте их)
  • используйте короткие типы данных:
    • tinyint вместо int
    • varchar вместо nvarchaк (если только ascii).
  • большие поля (более 8 кб) храните в режиме off-row

EXEC sp_tableoption ‘mytable’, ‘large value types out of row’, ‘1’

  • возможно частые данные имеет смысл хранить отдельно от редких данных (разбить таблицы)
  • избегайте дубликатов в колонках
  • иногда лучше денормализовать таблицы, чтобы избегать joins (довольно медленная операция)
  • осторожнее с триггерами. делайте их короткими
  • вместо временных таблиц лучше использовать переменные типа таблица
  • full text search лучше чем like
  • лучше set base code, чем курсоры
  • http://blogs.msdn.com/b/sqlprogrammability/archive/2008/03/18/increase-your-sql-server-performance-by-replacing-cursors-withset-operations.aspx
  • берите с сервера только те данные, которые вам нужны (не используйте *). для больших полей возможно лучше использовать LEFT(longtext, 100)
  • в названии таблиц ставьте схему, не называйте процедуры с префикса sp_
  • используйте SET NOCOUNT ON для хранимых процедур (тогда не будет передавать число обработанных строк)
  • для объектов более 1 Мб используйте тип Filestream
  • по возможности не используйте функции в where
  • используйте union all вместо union (т.е. будет без удаления одинаковых строк)
  • используйте exist вместо count

IF EXISTS(SELECT * FROM dbo.Book)

print ‘Records found’

  • можно иногда совмещать update и select

Еще несколько советов по оптимизации SQL:

  1. избегайте distinct
  2. для оптимизации запроса используйте sql profiler
  3. попробуйте скомпилированные хранимые процедуры
  4. извлекайте только необходимые поля.
  5. используйте индексы для всех полей, которые участвуют в where (обычно это reference key)
  6. по возможности указывайте тип максимально точно (максимальный размер, not null, unique)
  7. для большой вставки данных используйте bulk insert с отключением некоторых функций https://www.simple-talk.com/sql/learn-sql-server/bulk-inserts-via-tsql-in-sql-server/
  8. в выражениях where ставьте наименее вероятные впереди при and и наиболее вероятные при or.

Для замеров времени запросов используйте:

Tell SQL Server to show I/O and timing details of each query we run:

SET STATISTICS IO ON
SET STATISTICS TIME ON

Also, before each query, flush the SQL Server memory cache:

CHECKPOINT
DBCC DROPCLEANBUFFERS

Также будет хорошим советом использовать отдельные хранимые процедуры для сложной бизнес-логики. Т.е. вы делаете операции не через C# и объекты, а в базе и в итоге выдаете результаты в виде каких-либо объектов. Согласен, что это немного неудобно для тех, кто привык работать с объектами EF и оперировать ими  через LINQ. Но здесь  также возможно приведение к этим объектам.  Более того, обычно вам нужна проекция  объектов (т.е. для вывода JSON через ajax), поэтому вы можете сразу доставлять в C# уже только нужные для этого данные. Попробуйте оценить на практике подобный подход для узких мест.

Индексы

Правила для индексов:

  1. ID в кластер из индекс – хорошая идея
  2. Не фокусируйтесь на одном запросе к таблице, рассматривайте индекс ко всем запросам.
  3. Ставьте индексы, исходя из ранее указанных запросов и Database Engine Tuning Advisor
  4. Ставьте индексы в порядке важности – JOIN, ORDER by, group by, where
  5. Для where не будут использоваться индексы, если в where используется функция или Like (только если в начале не стоит типа ‘word%’).
  6. Индекс хорошо работает для столбцов Unique, для функций типа Min, max
  7. Очень осторожно со столбцами большого размера – много места могут занимать и могут медленнее работать (для кластер индекса)
  8. Не используйте индекс:
  • когда много обновлений
  • в колонке много дубликатов

Поиск, где можно установить индексы:

select d.name AS DatabaseName, mid.*

from sys.dm_db_missing_index_details mid

join sys.databases d ON mid.database_id=d.database_id

Также можно использовать sql profiler и DB Tuning Advisor

Убрать лишние индексы:

SELECT d.name, t.name, i.name, ius.*

FROM sys.dm_db_index_usage_stats ius

JOIN sys.databases d ON d.database_id = ius.database_id

JOIN sys.tables t ON t.object_id = ius.object_id

JOIN sys.indexes i ON i.object_id = ius.object_id AND i.index_id =

ius.index_id

ORDER BY user_updates DESC

там где user_updates больше чем user_lookup – можно удалить индексы

Event log

Просматривайте периодически event log (applications and system) (или event viewer в Winsows Server 2012) на сервере. Там вы можете увидеть исключения, возникающие в веб приложениях, а также можно увидеть ошибки SQL и IIS – все эти моменты требуют самого тщательно рассмотрения. Также вы можете просмотреть аудит доступа к SQL Server. Очень часто его пытаются взломать методом  прямого перебора паролей к SA. Это означает, что вам надо во-первых заблокировать запить sa. Во-вторых, надо устанавливать сложные пароли для учетных записей SQL Server.

Также хороший вариант – ограничить внешний доступ по IP к SQL Server через брандмауэр (обычно этот доступ нужен только для локальной разработки).

Также вы можете искать медленные места с помощью специальных запросов SQL – они позволяют выявить самые затратные SQL запросы по памяти и по процессору:

  1. http://infostart.ru/public/308762/  (по загрузке памяти)
  2. http://www.sql.ru/blogs/decolores/946  (!)
  3. http://itdoc.com.ua/2009/03/optimizaciya-proizvoditelnosti-zaprosov-sql-server/
  4. http://www.mssqltips.com/sql-server-tip-category/9/performance-tuning/ (самое непонятное)
  5. http://axforum.info/forums/showthread.php?t=48342
  6. http://dba.stackexchange.com/questions/44533/high-cpu-usage-on-sql-server-slow-queries (загрузка процессов)

Запрос по загрузке процессора.

SELECT TOP (25)

       qs.sql_handle,

       qs.execution_count,

       qs.total_worker_time AS Total_CPU,

       total_CPU_inSeconds = --Converted from microseconds

       qs.total_worker_time/1000000,

       average_CPU_inSeconds = --Converted from microseconds

       (qs.total_worker_time/1000000) / qs.execution_count,

       qs.total_elapsed_time,

       total_elapsed_time_inSeconds = --Converted from microseconds

       qs.total_elapsed_time/1000000,

       st.text,

       qp.query_plan

FROM sys.dm_exec_query_stats AS qs

CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st

CROSS apply sys.dm_exec_query_plan (qs.plan_handle) AS qp

ORDER BY qs.total_worker_time DESC OPTION (RECOMPILE);

Запрос по поиску тяжелых запросов (по времени выполнения):

описание полей – http://www.sql.ru/blogs/decolores/946

set transaction isolation level read uncommitted
select 
       top 100
       creation_time,
       last_execution_time,
       execution_count,
       total_worker_time/1000 as CPU,
       convert(money, (total_worker_time))/(execution_count*1000)as [AvgCPUTime],
       qs.total_elapsed_time/1000 as TotDuration,
       convert(money, (qs.total_elapsed_time))/(execution_count*1000)as [AvgDur],
       total_logical_reads as [Reads],
       total_logical_writes as [Writes],
       total_logical_reads+total_logical_writes as [AggIO],
       convert(money, (total_logical_reads+total_logical_writes)/(execution_count + 0.0))as [AvgIO],
       case 
             when sql_handle IS NULL then ' '
             else(substring(st.text,(qs.statement_start_offset+2)/2,(
                    case
                           when qs.statement_end_offset =-1 then len(convert(nvarchar(MAX),st.text))*2      
                           else qs.statement_end_offset    
                    end - qs.statement_start_offset)/2  ))
       end as query_text,
       db_name(st.dbid)as database_name,
       object_schema_name(st.objectid, st.dbid)+'.'+object_name(st.objectid, st.dbid) as object_name
from sys.dm_exec_query_stats  qs
cross apply sys.dm_exec_sql_text(sql_handle) st
where total_logical_reads > 0
order by AvgDur desc

Запрос, который показывает какие запросы выдают очень много данных (строк).По хорошему каждый sql запрос должен выдавать только самые необходимые данные (т.е. до 500-700 строк за 1 запрос. Если больше – значит есть проблема с фильтрацией).

set transaction isolation level read uncommitted

select 

            top 100

            max_rows, 

            case 

                       when sql_handle IS NULL then ' '

                       else(substring(st.text,(qs.statement_start_offset+2)/2,(

                                   case

                                               when qs.statement_end_offset =-1 then len(convert(nvarchar(MAX),st.text))*2      

                                               else qs.statement_end_offset    

                                   end - qs.statement_start_offset)/2  ))

            end as query_text,

            creation_time,

            last_execution_time,

            execution_count,

            total_worker_time/1000 as CPU,

            convert(money, (total_worker_time))/(execution_count*1000)as [AvgCPUTime],

            qs.total_elapsed_time/1000 as TotDuration,

            convert(money, (qs.total_elapsed_time))/(execution_count*1000)as [AvgDur],    

            db_name(st.dbid)as database_name,

            object_schema_name(st.objectid, st.dbid)+'.'+object_name(st.objectid, st.dbid) as object_name

            

from sys.dm_exec_query_stats  qs

cross apply sys.dm_exec_sql_text(sql_handle) st

--where total_logical_reads > 0

order by max_rows desc

Оптимизация LINQ

Пару слов о методе ToList: по сути именно при его вызове по факту выполняется SQL запрос. Поэтому постарайтесь как можно больше данных отрезать до его вызова в операторе Where. После его вызова вы уже работаете с коллекцией объектов, а не с SQL запросом.

Cамая распространенная ошибка, которая влияет на быстродействие LINQ запросов – это так называемая ошибка n+1 запроса.

Мы используем EntityFramework, по умолчанию для загрузки связанных записей он использует lazy loading. Общий принцип – мы загружаем в объект, содержащийся в ObjectContext, запись из основной таблицы. В момент, когда мы обращаемся к свойству дочернего объекта, EntityFramework делает отдельный запрос в базу, загружает связанную запись и соответственно нужное свойство. В большинстве случаев это работает. В каком случае это плохо? Представим ситуацию:

1) Мы загружаем некоторую коллекцию объектов одной таблицы (db.entities.ToList())

2) Делаем обращение к дочерней записи каждого из объектов в цикле либо в операторе Select (db.entities.Select(e => e.childEntity.childProperty))

Представим, что мы загрузили 100 записей. Далее, для каждой из 100 записей мы делаем отдельный запрос в базу, чтобы загрузить childEntity. Итого считаем, запрос на список пусть будет 80 ms, запрос на каждую запись – 50 ms, итого время работы – 80 + 50 * 100

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

Для этого нужно за меньшее количество  запросов выбрать максимальное количество данных. В конкретном случае можно использовать оператор Include. В его параметрах надо указать, какие связанные записи мы должны загрузить вместе с запросом. Если это обычное свойство, то указать просто e.ChildEntity, если надо загрузить коллекцию, то e.ChildEnityt.Select(c => c.ChildCollection)

В данном случае запрос надо выполнить так: db.entities.Include(e => e.childEntity).ToList(). В этом случае время запроса немного вырастет, допустим будет 100 ms, зато остальные 100 запросов выполняться не будут.

Все это показывает MiniProfiler – он находит повторяющиеся запросы и  помечает их Duplicated, их видно если нажать на ссылку sql.

Есть простой способ проверить, что у вас не выполняются дополнительные запросы в базу для загрузки связанных свойств. У ObjectContext (LocalSqlServer) есть свойство db.Configuration.LazyLoadingEnabled. Если поставить его в false, то связанные записи не будут загружаться автоматически, а будут оставаться null. Но соответственно все записи, которые включены в Include, будут загружаться. Это можно использовать только для  проверки запросов! Если вдруг где-то связанный объект не добавлен в Include, при обращении к его свойству будет выдано NullReferenceException.

Еще момент – после использования Include надо обязательно проверять, не выросло ли время основного запроса настолько, что он стал выполняться дольше, нежели 100 мелких запросов. При сложном запросе это вполне может получиться.

Теперь по использованию хранимых процедур: да, использование хранимой процедуры уменьшает время запроса. Но при этом – EntityFramework не умеет мапить данные, полученных хранимой процедурой, на связанные объекты (но для этого мы можем использовать Dapper – он может мапить либо в объекты Entity, либо в динамические объекты). Т.е. даже если добавить Include и  дописать в теле процедуры inner join <…>, связанные объекты все равно будут подгружаться дополнительными запросами. Это может привести к потере преимущества.

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

Дополнительно:

  1. вы можете использовать внешние ключи до выполнения запроса (по сути до ToList) – тогда это будет часть одного запроса.
  2. Обрабатывать внешние ключи можно еще так:
var allTypes = mng.GetTypes();

var res = items.Select(x=> new { typeName = x.typeID!=null ? allTypes.FirstOrDefault(z=>z.id==x.typeID).name });

вместо

var res = items.Select(x=> new { typeName = x.types.name });

в этом случае не создаются лишние обращений к базе.

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

Важно: этот метод надо использовать аккуратно и только для небольших коллекций. При неверно использовании он довольно сильно нагружает процессор и может подвесить весь сайт. В самых критичных запросах используйте лучше Dapper + хранимые процедуры + dynamic объекты. Пример использования такого запроса – в документации по ark (для метода get items в as crud).  Dapper на сервере работает значительно быстрее чем локально – учитывайте это (т.е. проверяйте конечную оптимизацию именно на сервере).

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

Альтернативный вариант – это сначала грузим только основные данные, а вторым ajax запросом грузим дополнительные, более “медленные” данные.

Несколько советов по общей оптимизации ASP.NET:

  1. По возможности отключайте ViewState (мы в принципе не используем сейчас элементы с runat=server. Все через ajax)
  2. compilation debug = false – на рабочем сервере обязательно ставьте в web.config такой параметр.
  3. Поздно открывайте подключение к базе – рано закрывайте. Это относится больше для ADO.NET подключений.
  4. Для Disposable используй Using
  5. Управляйте через IsPostBack – также для нас малоактуально, т.к. не используем обычные postback
  6. Кеш данных и вывода.

Клиентская оптимизация.

Самое главное что вы должны попробовать – это Google PageSpeed.

Это плагин для Chrome  (или можно использовать через сайт https://developers.google.com/speed/pagespeed/insights/). Вы просто вводите адрес страницы и сервис выдает вам рекомендации что и как надо улучшить на сайте. Очень и очень полезная вещь.

Какие рекомендации по оптимизации клиентской части приложения:

  • сжатие трафика через IIS. Можно включить статическое и динамическое сжатие файлов в настройках IIS. Это уменьшает трафик, но немного увеличивает нагрузку на процессор сервера (особенно при динамическом). Статическое сжатие однозначно надо ставить. Динамическое – по ситуации.
  • картинки. Должны быть того размера, который реально используется на странице. Оптимизируйте картинки по качеству. Если у вас много однотипных картинок – то используйте спрайты (т.е. когда одна большая картинка содержит много разных картинок. Для работы с ними вы используете background-position).
  • загрузка JS, CSS и картинок. CSS всегда грузите вверху, JS грузите внизу. Минифицируйте файлы JS, CSS (с JS поаккуратнее, т.к. иногда они перестают работать из за этого). Объединяйте файлы CSS и отдельно файлы JS, чтобы как можно меньше файлов в целом грузить на сервер. Также есть такая техника как подгрузка файлов с разных поддоменов. Браузер обычно может грузить около 5 параллельных загрузок с 1 домена. Поэтому используйте разные домены, чтобы ускорить загрузку ресурсов на странице (стили, скрипты, картинки).
  • изучайте лог загрузки по F12 в Chrome на вкладке Network. Вы сможете понять, что именно задерживает загрузку страницы в целом.

Последнее что мы обсудим в плане оптимизации – это оптимизация JS. Для большинства приложений она не нужна. Но если вы разрабатываете приложение, где будут использоваться гигантские таблицы или большое количество разметки (свыше 1000 элементов), то обязательно следуйте этим правилам.

Оптимизация кода JS

  1. 1. Самая быстрая выборка
$('#id')

$("#id").find(".class1");

$(".class", parent)

псевдоселекторы вроде :visible – самые медленные

  1. для присвоения в DOM используй innerHTML а не $().html()
  2. складывать строки лучше через массив (arr.push(“string”))  и arr.join(“”)
  3. для обработчиков всегда лучше использовать deletage (вешать на body и максимально указать селектор элементов) – так меньше обработчиков создается.
  4. в итоге лучше все файлы JS соединять в один
  5. используй пост загрузку (т.е. прогружаем основные данные и затем в теневом режиме догружаем “медленные” данные)
  6. Для долгих задач используй setimeout по порциям
  7. кешируй элементы, а не делай каждый раз выборку элементов (особенно в цикле).

т.е. ставим перед циклом var cont = $(el).closest(‘table’) и используем этот cont далее.

  1. используй google cdn
  2. для цикла лучше использовать while с конца
  3. лучше this.id чем $(this).attr(“id”)
  4. используйте цепочки jQuery
  5. по минимуму надо использовать append, insertAfter,  и т.д. (Лучше сначала накопить данные в строке и потом одним махом все добавить).
  6. хранить данные лучше в .data(), а не в .html()
  7. $.data(“#el”, key, value) быстрее чем $(“#el”).data(key, value)
  8. for или while быстрее работают чем $.each
  9. выбор по селекторам лучше, чем циклы. Избегайте циклов.
  10. методы $.method() работают быстрее, чем $.fn.method()
  11. избегайте дублирования кода.
  12. используйте разные теги html5
  13. проверяйте объект перед вызовом его функций if(a) a.show();
  14. держите в своем арсенале: map(), slice(), stop(), queue(), dequeue(), prevAll(), pushStack() and inArray().
  15. для трассировки и оптимизации кода используйте функцию JS Trace (as.sys.trace). Она позволит понять сколько выполняется  по времени тот или иной участок кода.
  16. по возможности не выполняйте запрос ajax в запросе ajax. Если возможно – все сделайте в одном запросе. Т.е. старайтесь уменьшить количество запросов ajax.
  17. циклы – первые кандидаты на оптимизацию.

Ссылки:

http://www.slideshare.net/AddyOsmani/jquery-proven-performance-tips-tricks

http://jonraasch.com/blog/10-advanced-jquery-performance-tuning-tips-from-paul-irish

http://jsperf.com/for-vs-while21221

http://jonraasch.com/blog/10-advanced-jquery-performance-tuning-tips-from-paul-irish

Далее рассмотрим клиентский сервис.