Заметки из Зазеркалья

02.12.2013

Оптимизация клиент-серверных приложений и особенности их разработки для использования в модели сервиса

Введение

На момент написания этой статьи, для того, чтобы прикладное решение работало в модели сервиса, надо чтобы в него была внедрена 1С:Библиотека стандартных подсистем 8.2 (БСП). Однако в настоящее время идёт активная работа по выделению части функционала для разработки прикладных решений в модели сервиса из БСП в отдельную библиотеку - 1С:Библиотека технологий сервиса (БТС). Это делается для того, чтобы обеспечить оперативность внедрения и обновления подсистем, необходимых для работы прикладных решений в модели сервиса. Но пока эта работа не закончена и поэтому в дальнейшем в этой статье мы везде будем использовать термин БСП.

Как именно в прикладном решении использовать БСП для целей работы в сервисе, описано в руководстве 1С:Технология разработки решений 1cFresh. Это общие технологические вопросы, которых мы не будем касаться в этой статье.

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

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

Работа с базой данных

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

Эффективные запросы

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

Запрос пишется для того, чтобы получить данные из базы, выборку. И, казалось бы, об этом не надо говорить, но выбирать нужно ровно те данные, которые требуются для решения задачи. Если по каким-то причинам оказывается, что запрос выбирает лишние данные, нужно от них избавляться и минимизировать объем выборки. На практике довольно часто встречается код, который сначала выбирает большое количество данных из базы, а потом, фактически, все их выкидывает, не использует. Это неэффективно. Если с базой работает только один пользователь, неэффективность такого запроса может быть незаметна. Но когда с базой работает много пользователей, маленькая неэффективность у каждого может складываться в большие проблемы у всех.

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

Если, например, запрос содержит 15 соединений, внутри которых есть операции выбора, выражения ИЛИ и т.д., то такой запрос заведомо не будет работать нормально, эффективно. СУБД просто не поймёт, что с этим запросом нужно делать. Явным признаком такой ситуации является то, что в скомпилированном плане запроса есть предупреждение timeout warning. Оно означает, что оптимизатору не хватило времени на поиск наилучшего плана запроса. С большой вероятностью такой запрос сложный и будет плохо выполняться. Правды ради нужно сказать, что не на всех поддерживаемых СУБД эта проблема проявляется одинаково сильно. Но поскольку заранее неизвестно, какую именно СУБД установит пользователь, нужно стремиться к тому, чтобы запросы одинаково эффективно работали на всех поддерживаемых СУБД.

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

Заботясь о понятности запроса, условия соединения таблиц всегда следует писать очень простыми. При этом если в этих условиях используются поля, которых нет в индексе, то такое соединение будет неэффективным.

Количество таблиц, участвующих в запросе, также может сильно повлиять на его эффективность. Если таблиц используется много, то СУБД не сможет построить для такого запроса эффективный план. Время, затрачиваемое на построение плана запроса, растёт практически экспоненциально количеству таблиц. В какой-то момент просто произойдёт тайм-аут на построение плана запроса, и СУБД выберет тот план, который она смогла сделать к этому времени.

Что значит "много таблиц"? Это может быть и 5-7 таблиц в одном запросе. Может быть и больше. Заранее сказать невозможно, но, безусловно, сокращение количества таблиц это один из путей оптимизации запроса, работающего неэффективно.

Эффективные планы запросов

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

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

С прикладной точки зрения может казаться, что запрос работает, и что такого ещё? Почему мы должны углубляться "в дебри" СУБД и интересоваться её планами? Но дело в том, что план выполнения одного и того же запроса может быть эффективным, а может быть и нет. Например, если у нас в таблице миллионы записей, то поиск одной записи из этого миллиона способом перебора - это очень неэффективно. СУБД будет работать медленно. Если при этом такой поиск осуществляется при соединении с другой таблицей (NESTED LOOPS), то это может быть уже миллион, помноженный на сколько-то. Такой план будет работать чудовищно долго.

Хорошо, скажете вы, СУБД может выбрать неэффективный план. Но ведь у нас нет возможности явно указать ей, какой план использовать? Да, явной возможности нет. Но зато мы знаем, на основе каких данных оптимизатор СУБД строит тот или иной план. На основе текста запроса, имеющихся индексов и статистики. А значит косвенно мы всё-таки можем повлиять на то, чтобы СУБД построила более эффективный план.

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

При этом нужно выполнять запрос на реальном количестве данных и при реальном состоянии статистики СУБД. Количество данных важно потому, что если, например, в тестовой таблице десять записей, выбрать из них две нужные не составляет труда. Но если в реальной таблице десять тысяч записей, то найти среди них две нужные - это уже непростая задача. Актуальность статистики СУБД, грубо говоря, показывает, насколько хорошо она знает, сколько данных находится в её таблицах. Если актуализировать статистику и выполнить запрос, план может получиться хорошим. Однако в реальной работе всё может быть иначе. Потому, например, что выполнению этого запроса предшествует массированный ввод данных, после которого статистика перестаёт быть актуальной. В результате чего СУБД выбирает менее эффективный, но более "простой" план.

Некоторые разработчики для каких-то простых запросов пытаются план угадывать. Но обычно это ни к чему хорошему не приводит. Угадать его не так то и просто.

Как посмотреть план? Можно настроить технологический журнал 1С:Предприятия и смотреть планы запросов в нём, независимо от используемой СУБД. Также планы запросов позволяет увидеть Центр управления производительностью (ЦУП) (начиная с версии 2.0.5). ЦУП это один из инструментов, входящих в состав продукта Корпоративный инструментальный пакет 8.

Кроме этого каждая СУБД имеет для этого собственные средства. Например, при разработке на Microsoft SQL Server можно использовать SQL Server Profiler.

Индексы

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

Вообще индекс нужен для ускорения выборки данных из таблицы. Индексы в таблицах 1С:Предприятие создаёт самостоятельно и автоматически, в момент реструктуризации базы данных. Разработчик не имеет возможности напрямую указать, в каких таблицах какие индексы должны быть созданы. Однако косвенно мы на это влиять можем, устанавливая у некоторых реквизитов признак индексирования в значение Индексировать или Индексировать с дополнительным упорядочиванием. Это не значит, что 1С:Предприятие построит составной индекс по всем полям, у которых мы установили это свойство. Или что индекс будет только по одному этому полю. Структура индекса будет довольно сложной и она будет зависеть от свойств того объекта конфигурации, реквизит которого мы проиндексировали. Те, кто интересуются подробностями, могут почитать статью Индексы таблиц базы данных. Посмотреть состав индексов "живой" базы данных можно с помощью функции встроенного языка ПолучитьСтруктуруХраненияБазыДанных(). Пример её использования можно посмотреть здесь.

Опыт показывает, что часто индексы, добавленные разработчиками, неэффективны. Потому что разработчик расставил их исходя из каких-то не очень понятных соображений, наверное, чтобы сделать быстрее. Но по факту оказывается, что прикладное решение использует эти индексы очень редко. Кроме этого чрезмерное "усердие" в расстановке индексов, это тоже плохо. Когда индексов много, велика вероятность, что СУБД не сможет выбрать среди них что-то подходящее, и план запроса от этого испортится.

Индекс может использоваться не так часто, как хотелось бы. Например, в запросе есть соединение с другой таблицей по каким-то полям. Или есть условие выборки по каким-то полям. Пусть это будут поля А, B и C. А индекс в таблице построен по полям B, C и D. Этот индекс использован не будет. Чтобы использовался этот индекс, отбирать нужно по полям B и C, или по полям B, C и D. То есть все требуемые для условия поля должны находиться в вершине индекса. В этом случае СУБД может использовать физическую операцию поиска по индексу (INDEX SEEK).

Если подходящего индекса в таблице нет, СУБД выберет операцию сканирования индекса (INDEX SCAN) или сканирования таблицы. В принципе, на наших таблицах сканирование таблицы почти невозможно, потому что во всех таблицах есть кластерный индекс. Но это не сильно меняет ситуацию. Нет ничего хорошего ни в сканировании кластерного индекса, ни в сканировании таблицы. В том случае, когда мы хотим выбрать из таблицы небольшое количество данных.

Если же мы хотим выбрать, например, 80% или даже 50% записей, то сканирование таблицы это, наверное, самый эффективный способ. Поэтому само по себе наличие в плане запроса операции SCAN ещё не означает, что план неэффективный. Главное соизмерять её с тем, что мы хотим выбрать.

Также СУБД может выбрать сканирование индекса в том случае, когда индекс по условиям не подходит к выборке, но все необходимые поля в нём есть. В такой ситуации у СУБД есть выбор: сканировать таблицу, или сканировать индекс. Индекс обычно меньше по размеру, чем таблица, поэтому СУБД решает, что проще просканировать его.

Ещё одна особенность использования индексов связана не с полями, участвующими в условии отбора, а с полями, которые мы хотим из таблицы выбрать. Допустим, у нас есть таблица справочника. Нас интересуют те записи, в которых один из реквизитов справочника имеет определённое значение. Но выбираем мы все реквизиты, которые есть у справочника. То есть почти все поля, которые есть в каждой записи. При таких условиях индекс почти гарантированно использован не будет. Потому что СУБД сначала должна будет найти нужные записи, а потом сделать на каждую из найденных записей LOOKUP в таблицу или в кластерный индекс, чтобы получить все остальные поля. СУБД будет это делать только в том случае, если в выборку попадёт небольшое количество записей. Если же в выборку попадёт 10% таблицы, она гарантированно сделает SCAN. Потому что она любит делать простые вещи.

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

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

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

Например, если мы хотим выбрать что-то из справочника по ссылке, селективность будет очень большой. Мы выберем единственную запись. С другой стороны, если у нас есть реквизит типа Булево, который мы проиндексировали с целью ускорить получение информации, то это будет очень неэффективно. Потому что Булево имеет всего два значения, и если они равномерно распределены в таблице, то половина таблицы будет со значениями Истина, а другая половина - со значениями Ложь. В результате СУБД не будет использовать такой индекс, потому что она увидит, что ничего полезного она от этого индекса не получит. Подобный индекс будет использоваться только в том случае, если мы хотим выбрать, например, значение Ложь, и записей с таким значением очень мало. Например, 99% это Истина, и 1% это Ложь.

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

Транзакции

Всю работу с данными СУБД выполняет в транзакциях. Транзакция это группа последовательных операций, которая может быть выполнена целиком и успешно, или не выполнена вообще. Другими словами невозможна ситуация, когда часть операций транзакции выполнена, а часть - нет. Наиболее распространённым набором требований к транзакциям является набор ACID (Atomicity, Consistency, Isolation, Durability). То есть транзакции должны быть атомарными, консистентными, независимыми и надёжными (долговечными, устойчивыми).

Зачем нужны транзакции? Самый простой пример это перемещение товаров с одного склада на другой. Мы должны списать товар с одного склада, и оприходовать его на другой склад. Если мы разорвём эту последовательность операций, выполним её не в транзакции, то может оказаться так, что товар с одного склада исчез, а на другом не появился. Или наоборот. Поэтому подобные последовательности операций должны выполняться в транзакциях.

В явном виде открывать транзакции во встроенном языке нужно не так уж и часто. Большая часть нужных обработчиков и так уже выполняется в транзакциях, которые платформа открывает самостоятельно. Например обработчики модуля объекта ПередЗаписью, ПриЗаписи, ПередУдалением. Но при желании можно транзакции открывать и явно. Для этого используются методы НачатьТранзакцию(), ЗафиксироватьТранзакцию(), ОтменитьТранзакцию(). Причём открыть внутри транзакции вторую транзакцию невозможно. При таком действии будет просто увеличиваться внутренний счётчик транзакций, но суть изменяться не будет. Поэтому если в транзакции любого "уровня вложенности" происходит исключение, или мы сами явно отменяем транзакцию, то отменяется вся транзакция, потому что она всего одна и есть.

Управляемые блокировки

С транзакциями связано понятие блокировок. Если мы читаем какие-то данные в транзакции, и эти данные могут меняться, то следует задуматься о том, что их, возможно, стоит блокировать. Если мы этого не сделаем, то конкурирующие транзакции могут изменить эти же данные параллельно с нами. В результате окажется, например, что единственная единица товара списана два раза. Что будет неправильно.

С другой стороны, если данные в транзакциях не меняются, или мы не предполагаем их частого изменения, или нам не нужно получать их в согласованном состоянии с другими данными, то такие данные можно не блокировать. Никакой необходимости в этом нет. В любом случае внутри транзакции мы не сможем прочитать "грязные данные", потому что уровень изоляции СУБД достаточно высокий и она этого сделать не даст. В худшем случае, если в момент нашего чтения эти данные кто-то долго изменял, мы получим тайм-аут на блокировке СУБД. Но ничего плохого в этом не видно. И если бы мы добавили в этом месте управляемую блокировку, то фактически ничего полезного не сделали бы, а только зря нагрузили менеджер блокировок 1С:Предприятия. То есть блокировку СУБД мы заменили бы управляемой блокировкой 1С:Предприятия, что само по себе не несёт никакой пользы.

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

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

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

Изменения в 8.3

Говоря о транзакциях нужно сделать небольшое замечание про платформу версии 8.3, Microsoft SQL Server и IBM DB2. До этой версии все чтения, выполняемые на этих СУБД вне транзакции, могли приводить к такому понятию как "грязное чтение". То есть мы имели шансы прочитать данные незафиксированных транзакций, и на практике это встречалось довольно часто. Например, все отчёты показывали данные непроведённых документов. В версии 8.3 при отключении режима совместимости "грязного чтения" уже не будет. Поэтому разделяемые блокировки можно будет ставить только в том случае, если одни и те же данные читаются несколько раз в рамках одной транзакции.

Использование длительных транзакций

Важное значение имеет длина транзакций. Тут сложно дать простую количественную рекомендацию, но в любом случае, когда мы делаем очень длинную транзакцию - это плохо. Потому что всё, что происходит в процессе транзакции, пишется в журнал транзакций СУБД. Большинство блокировок СУБД удерживает до конца запроса, но блокировки на изменение данных удерживаются до конца всей транзакции. В случае Microsoft SQL Server они удерживаются прямо в памяти сервера. А управляемые блокировки в любом случае удерживаются в памяти сервера 1С:Предприятия. Если используются версионные СУБД (PostgreSQL, Oracle Database) всё обстоит несколько иначе, но мы должны обеспечивать эффективную работу на всех поддерживаемых СУБД, поэтому нужно заботиться о разумном сокращении длительности транзакций.

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

Использование динамических списков

В версии платформы 8.2 появилась замечательная возможность писать произвольные запросы в динамических списках. И разработчики очень много пользуются этим в своих конфигурациях. Можно даже сказать, что злоупотребляют. Конечно, с одной стороны есть желание вывести в список как можно больше полезной информации, показать её пользователю. С другой стороны это желание приводит к тому, что появляются запросы, содержащие до 20 соединений с использованием вложенных запросов. Такой запрос, конечно, хорошо работать уже не будет. Почему он хорошо работать не будет? Потому что динамический список совсем не волшебный, и все запросы, которые он выполняет, при желании можно написать самостоятельно.

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

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

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

И последний вариант работы, когда нет ни динамического считывания, ни основной таблицы. В этом режиме список начинает фактически целиком читать таблицу, накапливая её в буфере. Понятно, что эффективно это работать не будет.

Поддержка различных СУБД

Ещё одно напоминание касается поддержки различных СУБД. Платформа поддерживает пять различных СУБД: файловая, Microsoft SQL Server, IBM DB2, PostgreSQL и Oracle Database. Соответственно и прикладные решения должны стремиться к этому, однако на практике один и тот же код не всегда одинаково хорошо работает на разных СУБД. В идеальном случае хотелось бы тестировать приложения на всех перечисленных СУБД. Но на практике это удаётся далеко не всегда. Поэтому очень желательно проверять работу прикладного решения хотя бы на файловой версии, потому что она сама по себе очень распространена, а также на одной, двух сторонних СУБД.

Поддержка веб-клиента

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

В веб-клиенте можно использовать расширение для работы с файлами. Но опыт показывает, что порой оно используется как безальтернативный выбор и это не очень корректно. Его нужно использовать только для повышения удобства работы с приложением, а не как единственно возможный вариант. В противном случае получается, что если у пользователя нет этого расширения, то он и функционалом прикладного решения воспользоваться не может. А у пользователя действительно может не быть этого расширения по разным причинам. Потому, что в половине браузеров, поддерживаемых платформой, таких расширений нет вообще. И эта ситуация улучшена только в версии 8.3. Потому, что даже если расширение существует, не все пользователи могут его себе установить. У кого-то нет прав устанавливать дополнительные компоненты в браузер. Кто-то просто не хочет его ставить по своим причинам.

Производительность интерфейса

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

Чем эту секунду замерять? У нас есть показатели производительности, можно пытаться использовать их. Ещё можно пытаться замерять время отклика с помощью дополнительного кода на встроенном языке. Это всё неправильно. В итоге мы получим совсем не то время, которое на практике видит пользователь. Особенно неправильным оно будет в веб-клиенте, в котором присутствует большое количество асинхронности. В результате время, когда начинает исполняться прикладной код (например, срабатывает обработчик ожидания), может сильно отличаться от того времени, когда форма реально прорисуется и ей можно будет начать пользоваться.

Поэтому для таких замеров предлагается брать секундомер, и время отклика измерять с его помощью.

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

Кроме не очень хорошего компьютера для измерений желательно устанавливать какой-нибудь браузер, который имеет довольно медленный JavaScript. Если проверять на "быстром" браузере, то многих проблем мы можем не увидеть. На самом деле от браузера зависит, наверное, половина всего быстродействия. Если взять медленный компьютер и поставить на него быстрый браузер, то он будет работать не хуже, чем быстрый компьютер и медленный браузер.

Также для получения более адекватных результатов желательно использовать режим имитации задержек при вызове сервера и режим низкой скорости соединения. Хотя если требуется очень хорошее тестирование, лучше всего брать модем и проверять всё в условиях реального мобильного GPRS. Потому что в нём всё тоже иногда работает хуже, чем должно.

Факторы, снижающие производительность веб-клиента

От чего зависит производительность интерфейса? Главное это клиент-серверные вызовы. Их количество нужно минимизировать. Какими средствами? Упаковывать несколько вызовов в один, по мере возможности. Эта рекомендация относится не только к веб-клиенту, но и к тонкому клиенту тоже.

На что ещё следует обращать внимание?

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

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

В-третьих, передача большого объёма данных. Это не очень критично, это важно только на низкой скорости соединения. Но, тем не менее, лучше об этом заботиться. Если есть какие-то эпизодически используемые данные, лучше их не помещать в данные формы. Потому что они могут быть и не нужны в каких-то сценариях использования. Лучше получать их с сервера при необходимости. Также можно их закэшировать в клиентском модуле с повторным использованием.

В-четвёртых, использование ключевого слова Знач при объявлении параметров процедур и функций. Дело в том, что при клиент-серверном взаимодействии это ключевое слово значит совсем не то, что при работе внутри одного компьютера, клиентского или серверного. Когда мы используем Знач при объявлении параметра серверной процедуры и вызываем её с клиента, это означает, что значение этого параметра обратно на клиент не приедет. Если же мы не устанавливаем Знач, а стандартно так и есть, то происходит следующее. Допустим, мы вызываем серверную процедуру и передаём в неё массив. Предположим, что на клиенте мы даже не собираемся потом этим массивом пользоваться. Он просто был параметром и на самом деле нам не нужен больше. Но когда серверный вызов закончится, массив будет упакован в XML или JSON (на веб-клиенте), и уедет обратно на клиент. Понятно, что это совсем неэффективно. Поэтому если вам не нужно возвращаемое значение, переданное через параметр, пишите ключевое слово Знач у таких параметров. Конечно, если параметр Булево, Знач можно сэкономить и не писать. Но по сути это нехорошо.

Длительные операции

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

Например, на сервисе 1cfresh через 75 секунд ожидания клиент будет закрыт. Потому что веб-сервер посчитает, что сервер 1С:Предприятия не отвечает, и ждать от него ответа не стоит. В результате клиент увидит ошибку, что приложение больше не работает. Для пользователей компьютеров Macintosh всё ещё хуже. Они используют стандартный браузер Safari, а в нём прямо в код браузера "вшит" тайм-аут 8 секунд. Если за 8 секунд серверный вызов не состоялся, то всё, приложение больше не работает. Ну и вообще, это не очень хорошо, когда мы делаем длительный серверный вызов и при этом программа "висит", а пользователь ничего делать не может.

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

Бережное использование ресурсов

Оперативная память

Одним из самых главных и ценных ресурсов является оперативная память. Мы с вами мало про это помним, особенно в современные времена. Когда в настольных компьютерах стоит по 16Гб памяти, непонятно, зачем нужно экономить память?

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

Поэтому когда мы пишем какие-то алгоритмы, нужно исходить из того, что оперативная память ограничена. Если объём данных, с которыми мы собираемся работать, сам по себе ничем не ограничен, его нужно ограничивать искусственно. Применять курсорные выборки, аналогичные тем, которые использует динамический список и т.д.

Особенно осторожно следует относиться к формированию больших структур данных в памяти. Например, во встроенном языке есть возможность обрабатывать файлы целиком. Текстовые документы в ТекстовыйДокумент, XML в ДокументDOM или HTML в ДокументHTML. Это неправильные способы работы с большими файлами, потому что в этом случае весь файл загружается в оперативную память, создаётся много служебной информации, а файл может быть очень большим. На практике эти способы нужны в редких случаях, когда необходим произвольный доступ к содержимому файла, к какой-то конкретной его части. Но в подавляющем большинстве случаев практические задачи заключаются в том, чтобы обработать весь файл. И для этого нужно использовать последовательную запись и последовательное чтение: ЧтениеXML, ЧтениеТекста, ЗаписьXML, ЗаписьТекста. Эти методы читают файлы порциями и расходуют память экономно.

Утечки памяти

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

Конечно в "живом" коде ровно такой пример не встречается, но к сожалению аналогичную конструкцию можно создать случайно. Например, если у нас есть объекты, внутри которых вложены другие объекты, и где-то там в глубине они ссылаются на самый верхний объект. В результате образуется циклическая ссылка.

Что произойдёт в описанной ситуации? Когда никаких внешних ссылок (в переменных, в реквизитах и пр.) на эти объекты не останется, объект всё равно не удалится, он останется в памяти. Потому что память устроена на счётчике ссылок, и когда мы обращаемся к объекту, то есть присваиваем ссылку на него, внутренний счётчик ссылок возрастает. Когда ссылка выходит из области видимости, или явно разрывается, счётчик ссылок уменьшается. Если счётчик ссылок никогда не выйдет в ноль, то память не освободится.

Понятно, что совет "не пишите такой код" довольно сложно выполнить. Поэтому нужно специально обращать внимание на эту особенность, когда вы создаёте какие-то структуры в памяти. Иначе потом это очень сложно диагностировать. Утечки, к сожалению, обнаруживаются не в отладке и не в тестировании, а в готовом продукте, когда на сервере кончается память.

Повторное использование возвращаемых значений

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

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

Другой момент. Если мы что-то помещаем в кэш, нужно быть уверенными в том, что мы будем потом к этому часто обращаться. Кеш не хранит данные вечно. В общем случае значение будет удалено из кэша через 20 минут после вычисления или через 6 минут после последнего использования. В зависимости от того, что наступит раньше. Кроме этого значение будет удалено при нехватке оперативной памяти в рабочем процессе сервера, при перезапуске рабочего процесса и при переключении клиента на другой рабочий процесс. Поэтому если мы "не успели" воспользоваться данными из кэша, значит ресурсы кэширования были потрачены зря.

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

Последняя особенность, связанная с этим механизмом, заключается в том, что кэш возвращает каждый раз не копию объекта, а ссылку на один и тот же объект в памяти. Очень легко допустить ошибку и случайно изменить этот объект после получения. Такой случай был на практике. В массив, который возвращала функция с повторным использованием, при каждом вызове дописывалось новое значение. В результате он очень быстро "распухал" при проведении документов. Поэтому в качестве возвращаемых значений очень желательно использовать значения, состояние которых изменить нельзя. Например, ФиксированныйМассив, ФиксированнаяСтруктура. Это поможет избежать подобных ошибок.

Работа с временными файлами

Следующий момент, имеющий отношение к ресурсам, это правильная работа с временными файлами. Для создания временных файлов следует использовать имена, полученные с помощью функции ПолучитьИмяВременногоФайла(). Файлы с такими именами платформа может удалить самостоятельно после завершения создавшего их процесса 1С:Предприятия. Понятно, что перезагрузка сервера или перезапуск рабочих процессов могут происходить редко, но в любом случае это лучше, чем если бы платформа никогда не подстраховывала разработчика и не удаляла временные файлы. Естественно, такой "сервис" совсем не отменяет того, что разработчик должен самостоятельно удалять временные файлы сразу по окончании использования. В противном случае они могут очень долго лежать на сервере, что, в конце концов, приведёт к исчерпанию дискового пространства и прекращению работы сервера.

Использование неразделённых данных

Отдельное внимание следует уделять неразделённым данным. Если у нас есть возможность какую-то часть данных вынести в неразделённые, то это позволяет разом получить несколько преимуществ. Однако делать это нужно осторожно и вдумчиво. Использование неразделённых данных повышает эффективность решения, но увеличивает его сложность. Потому что обслуживание неразделённых данных это сложная задача.

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

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

Очень часто есть возможность поделить данные на разделённые и неразделённые. Например, в очередной версии БСП таким образом оптимизировали варианты отчётов. Поставляемые варианты вынесены в неразделённые данные, а пользовательские варианты оставлены разделёнными. За счёт этого кардинально увеличилась скорость обновления. Потому что пропал "множитель" на область данных, и обновление стало работать гораздо быстрее. Однако, оптимизируя разделённые данные нужно помнить и о том, что данные, введённые пользователем, никак нельзя хранить в неразделённых данных. Потому что неразделённые данные могут быть доступны любым пользователям. Нужно сказать, что запись неразделённых данных в сеансе, в котором используются все разделители - очень опасная операция. В принципе сейчас это технически невозможно, потому что в БСП введено ограничение на запись неразделённых данных, но помнить об этом всё равно не мешает.

Оптимизация кода на встроенном языке

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

Минимизация времени обновления версии информационной базы

Важное значение имеет время обновления версии информационной базы. Потому что всё это время база недоступна для пользователей. Значительную часть работы здесь выполняет БСП, однако и разработчики практически в каждой версии дописывают свои подключаемые обработчики обновления.

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

Вообще, любые обработчики обновления ИБ, даже те, которые не "на звёздочке", надо оптимизировать максимально. Тут возможна следующая схема оптимизации. Создаётся неразделенный обработчик обновления, который сохраняет требуемые ему данные в неразделённых данных. При смене версии он анализирует, изменилось что-то, или нет. И если что-то изменилось, то запускает разделённые обработчики. А если нет, то ничего не делает. В принципе получается, что такой обработчик тоже при смене каждой версии выполняет какие-то действия, но это не так страшно, поскольку в сеансе, в котором разделители не используются, они выполняются только один раз. А не десять тысяч раз на базу.

Регламентные задания

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

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

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

Можно пытаться консолидировать регламентные задания, чтобы их было меньше. Например, так сделано в очереди заданий БСП для разделённых баз.

Вообще, для разделённых баз проблема с регламентными заданиями ещё более существенна, чем для "обычных". Вот самый простой пример. У нас есть регламентное задание "извлечение текста для полнотекстового индексирования". Оно имеет расписание "выполнять раз в 85 секунд". Оно проверяет, нет ли изменений, которые ему надо обработать. Если есть, то оно вытаскивает эти данные, запускает извлечение текста и помещает их обратно в базу. Большую часть времени оно ничего полезного не делает, потому что изменений никаких нет. Особенно в какой-нибудь базе типа 1С:Бухгалтерии, в которой не так много мест, куда можно положить текст. Но, тем не менее, это задание нужное. Вдруг какой-нибудь пользователь поместит вордовый файл в базу и его нужно будет проиндексировать.

Если бы задание изначально оставили разделённым, то вероятно сервер бы давно уже перестал работать. Раз в 85 секунд запускалось бы несколько тысяч сеансов и сервер на это время переставал бы работать. Допустим, мы переделали его просто на очередь заданий, как есть. И оно раз в 85 секунд, правда уже последовательно, а не одновременно, выполнялось во всех областях данных, которые были. Когда сняли замеры оказалось, что это задание, которое само по себе выполняется, допустим, за 100 миллисекунд, в сумме съедало больше 100 процентов одного ядра в сутки. Хотя вроде бы оно ничего не делало.

В БСП был заведён флаг изменений, который взводится при изменении любого объекта, который может потребовать полнотекстового индексирования. И есть одно единственное задание, которое раз в 85 секунд выполняется. Оно уже не заходит ни в какие области, оно просто смотрит на флаги. Если флаг в какой-то области взведён, она для неё планирует извлечение текста.

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

Ну и ещё одно напоминание, что создавать предопределённые разделённые регламентные задания нельзя. Это будет убийство сервера.

Работа в рамках архитектуры платформы

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

Ограничение на использование внешних ресурсов

Использование внешних ресурсов представляет потенциальную опасность. В версии 8.3 появилось такое понятие как профили безопасности кластера. Они назначаются прямо в кластере конкретной информационной базе. В результате все внешние ресурсы могут быть выключены. Это обращения к файловой системе сервера, запуск COM-объектов, использование внешних компонентов 1С:Предприятия, запуск внешних обработок и отчётов, запуск приложений, установленных на сервере и обращение к ресурсам Интернета.

Если конфигурации нужны какие-то внешние ресурсы, то это, во-первых, должно быть явно прописано в документации, а во-вторых, обращения к ним должны быть локализованы. Например, использование файловой системы. Нельзя просто так взять, и где-то на диске C: в папке Temp разместить свои файлы. А только в специальном разрешённом каталоге сервера. Раньше это встречалось на практике, когда, например, в технологическом журнале мы нашли какие-то непонятные файлы.

Работа с файловой системой сервера

Ещё один момент, который описан в стандартах, но про него всё время забывают. Кластер у нас называется кластером потому, что в нём может быть несколько серверов. А не по какой-то другой причине. И если у нас в кластере есть несколько серверов, как на 1cFresh, например, то пытаться сохранить файл между клиент-серверными вызовами не стоит. На следующем вызове вы, с высокой вероятностью, этот файл не найдёте. Потому что вызов может прийти на другой сервер.

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

Поддержка работы с часовыми поясами

И в конце небольшое напоминание про часовые пояса. Тут тоже давно существует стандарт. Текущую дату использовать не рекомендуется. Потому что функция ТеущаяДата(), вызванная на сервере, вернёт текущую дату сервера, расположенного, например, в Москве. Она обычно никакого отношения к клиенту, работающему, например, в Иркутске, не имеет. Текущая дата клиента тоже довольно сомнительная вещь, потому что у клиента на компьютере может быть неправильное время или вообще, неправильная дата.

Поэтому лучше использовать функцию ТекущаяДатаСеанса(). В сервисе это будет сеанс области данных.

Если этой логики недостаточно, то можно реализовать свою в прикладном решении. Мы пока на практике этого не видели. Но при желании часовой пояс сеанса можно переключать.

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

Теги: разработка 

Рассказать друзьям: