WEb практикум. Web'cepbep
Скачать 4.76 Mb.
|
ГТ^5"ВЯТ^ЯЙд1е Chrome";v="88", ia-irrm nnn Загрузите мой сайт Flenov.info, введите что-то в поле поиска и откройте утилиты разработчика, затем перейдите на вкладку Network. Теперь введите что-то в строку поиска и нажмите кнопку Find (Найти). Находим самый первый запрос в левой панели и справа в разделе Request Headers ищем referer (рис 7.1). Как видите, тут указан мой сайт, потому что мы нажимали кнопку Find именно на той странице, которая указана в referer. Попробуйте прогуляться по сайту и посмотрите, как меняется referer. Обратите внимание, что он всегда указывает на ту страницу, с которой мы переходим на следующую. Таким образом, если форма будет располагаться на сайте хакера, то в Referer попадет его сайт. А что, если хакер направит форму на свой сайт и потом отправит запрос на сайт банка, подделав referer? Это сработает, и вы сможете подделать этот параметр, но как насчет cookies? Именно в них находится информация о сессии, и она говорит сайту, авторизован пользователь или нет. Если запрос будет отправляться сначала на сайт хакера, то браузер не предоставит необходимые cookies. Весь смысл в том, что запрос должен идти именно на сайт банка, чтобы ему передалась необходимая информация о текущем пользователе. Да, защита по Referer может сработать, и этот параметр можно проверять, но не стоит все же ему доверять. Слишком все нежно и звучит ненадежно. Есть мнение, что если хакер сможет украсть cookie, то ему останется только получить имя пользователя, и можно будет отправлять запрос с сервера напрямую и подделывать поле Referer. Но ведь если хакер украдет cookie, то он сможет перехватить и сессию на сервере и, возможно, просто загрузив сайт, уже будет авторизован от имени пользователя, и не нужно ничего строить дополнительно. Но допустим, что для смены пароля хакеру нужны cookies, чтобы отправить запрос от имени пользователя, имя пользователя и текущий пароль. Возможно, он украл cookie каким-то образом и, загрузив сайт, где-то увидел реальное имя пользователя, которое не так уж часто скрывается. Но вот текущий пароль хакер может не знать, и тут как раз поможет атака межсайтовых запросов. Создав поддельную форму для смены пароля, можно получить текущее значение и поменять его... Но если хакер смог получить текущий пароль, то зачем его менять? Теоретически можно уже и не менять, а просто украсть пароль, и это тоже вариант атаки. Но если смены не произойдет, то это вызовет подозрение у пользователя: он же будет думать, что уже установлен новый пароль, а его нет. В качестве дополнительной защиты от межсайтовых запросов можно реализовать что-то похожее на каптчу. Каждый раз, когда загружается форма, на сервере генерируется случайный код, который привязан к сессии и пользователю. Этот код добавляется к форме и отправляется со всеми данными на сервер. При защите от межсайтовой загрузки страниц предугадать подобный код будет невозможно. Именно так работает встроенная защита в Microsoft .NET. В .NET Core код защиты от межсайтовых атак может генерироваться автоматически. Для этого можно добавить сервис защиты Antiforgery в методе ConfigureServices класса Startup: services.AddAntiforgery(options => { options.FormFieldName = "AntiforgeryFieldname"; options.HeaderName = "X-CSRF-TOKEN-HEADERNAME"; options.SuppressXFrameOptionsHeader = false; }); После этого вы можете вручную контролировать создание защиты для каждой отдельной формы, на случай, если ее нужно отменить, указав в cshtml у формы свойство asp-antiforgery: В данном случае защита отключается указанием значения false. В .NET Framework можно также использовать специальный метод — расширение AntiForgeryToken: Выполнение этого кода приведет к тому, что .NET добавит к форме следующий невидимый параметр: Этот код заставит браузер добавить к форме код защиты и отправит его вместе с пользовательскими данными на сервер. Но это не значит, что сервер в реальности произведет проверку. Ему нужно сообщить, что для определенного метода необходимо произвести проверку, потому что там ожидается код безопасности. Например, следующий метод ChangePassword вызывается, когда пользователь отправляет данные на сервер методом post и в форме был указан код защиты, а значит, с помощью метаданных [ValidateAntiForgeryToken] мы просим фреймворк проверить этот код: [HttpPost] [ValidateAntiForgeryToken] public IActionResult ChangePassword(PasswordViewModel account) { } Защита от межсайтовых запросов возлагается на фреймворки, а не на сами языки программирования, так что если говорить о PHP, то это язык программирования и защита не является его задачей. В C# защита тоже не является частью языка, это часть фреймворка .NET. Главная идея в том, что на сервере мы сохраняем какой-то уникальный код, который привязан к сессии пользователя. Для этого можно сгенерировать 64 случайных байта и превратить их в строку из шестнадцатеричных значений, чтобы строка была короче (меньше передавать данных): $_SESSION['secure_token'] = bin2hex(random_bytes(64)); Теперь эту же строку нужно добавить к форме, и когда пользователь отправляет данные на сервер, нужно проверить, что строки совпадают. Но это если самому реализовывать защиту вручную на чистом PHP. Как я сказал, это вообще прерогатива фреймворков. Давайте посмотрим, что есть у Symfony для защиты от CSRF. Когда мы создаем форму, то нужно сделать невидимое поле (type="hidden") со значением ключа, которое генерируем методом csrf_token: Метод csrf_token в скобках получает какое-то имя, которое может указывать, для чего вы планируете использовать ключ. Просто на сайте может быть несколько различных форм, и будет хорошо просто разделить их между собой логически. Имя поля input может быть произвольным, я его назвал token, а вы можете выбрать любое имя, главное потом при проверке кода на сервере использовать его же. Теперь на сервере проверка токена может выглядеть так: public function changepassword(Request $request) { if ($this->isCsrfTokenValid('change-password', $request->request->get('token'))) { // ключ верный } } В принципе это и все — достаточно простая реализация простой защиты. Рассматривать все возможные фреймворки я не буду, потому что для PHP их существует... даже не знаю сколько, но точно уж больше, чем пальцев одной руки. Я же использую только Symfony, поэтому и рассказал вам о нем. Cross-origin — делим ресурсы Cross-origin Resource Sharing, или совместное использование ресурсов между разными источниками, — это технология, которая позволяет разделять ресурсы сайта с другими. С ее помощью можно указать, при обращении к каким ресурсам наш сайт сможет отдавать свои ресурсы. Например, если у нас сайт www.bankname.ru, хакер может создать свой сайт www.fakebank.ru и подключить ресурсы с нашего сайта. С помощью Cross-origin Resource Sharing мы можем сказать, можно ли отдавать свои ресурсы сайта www.bankname.ru, если пользователь обращается реально к домену www.fakebank.ru. Если не сделать защиты, то есть шанс, что хакер сможет обратиться к ресурсам банка и, возможно, подсмотреть какую-то информацию. Например, пользователь загружает сайт www.fakebank.ru с поддельной формой для изменения пароля, но на реальном сайте банка есть защита в виде какого-то секрета, который отображается на странице. Страница www.fakebank.ru может после загрузки отправить Ajax-запрос к реальному банку, чтобы в фоне загрузить реальную страницу и найти на ней секрет. Может ли банк защититься от такого запроса? Блокировать по Referer не получится, потому что пользователь может переходить на сайт банка с другого сайта легитимно. Например, по ссылке в поиске Google или с какого-то другого сайта. Это нормально, что пользователь переходит на ваш сайт с других. Плохо, когда он отправляет форму на сайт. Банк может сообщить браузерам, при обращении к каким доменам разрешено отгружать его ресурсы с помощью заголовка Access-Control-Allow-Origin, в котором могут быть домены. На PHP это делается с помощью метода header: header("Access-Control-Allow-Origin: www.bankname.ru "); ?> В .NET чуть больше шагов, но тоже не так сложно: using System.Web.Http.Cors; namespace Controllers { [EnableCors(origins: "www.bankname.ru", headers: "*", methods: "*")] public class MyController : ApiController { // Код метода } } За реализацию защиты Cross-origin Resource Sharing отвечают браузеры, потому что сервер не может знать — пользователь загружает сайт сам или загрузка инициирована сайтом с помощью AJAX-запроса. А вот браузеры знают, с чем именно они работают и могут сделать проверку, соответствует запрос требованиям сайта (банка) или есть нарушения. Но сейчас все браузеры уже реализуют требования к защите от межсайтовых запросов. Есть два параметра заголовков: Access-Control-Allow-Origin — определяет, какие домены могут обращаться к ресурсам сайта; Access-Control-Allow-Methods — показывает, какие типы запросов можно отправлять GET, POST, PUT и т. д. серверу для доступа к его ресурсам. Чаще всего необходим GET, поэтому стоит подумать над тем, чтобы добавить только его в список разрешенных. POST-запросы между сайтами тоже иногда бывают необходимы, но это встречается немного реже. Надеюсь, мне удалось вас убедить, что межсайтовое взаимодействие опасно и может привести к серьезным проблемам. К этому вопросу нужно подходить с полной ответственностью. ГЛАВА 8 DoS-атака на web-сайт Когда найти ошибку в сценариях web-сайта не удается, хакеры начинают прибегать к другим методам, в частности к DoS-атаке. Мы уже говорили о ней ранее (см.разд. 1.7), а сейчас рассмотрим более подробно: увидим, как с ее помощью можно сделать web-сервер недоступным или максимально затормозить его работу. В этом случае добросовестным пользователям не остается ресурсов сервера, и они не получают ответа. Поиск медленных страниц Когда хакер ищет цель для организации DoS-атаки, он выбирает те web-страницы, которые загружаются очень долго, что говорит об их плохой оптимизации, то есть чтобы web-сервер мог отобразить необходимые данные, ему приходится осуществить множество операций, сильно загружающих процессор. Во время работы над сайтом, который можно было отнести к электронной коммерции Sony в США, мне приходилось несколько раз сталкиваться с ситуацией, когда проблемы в производительности становились причиной значительного замедления серверов. Хакеры регулярно искали страницы, которые генерировались на сервере медленнее всего, и пытались атаковать их, поэтому приходилось быть очень внимательным к каждой странице. Из личного опыта могу сказать, что мне чаще всего приходилось сталкиваться с тремя ошибками, которые приводили к проблемам с производительностью страниц на сайте: доступ к базе данных — чаще всего в web самым слабым местом являются SQL- запросы, которые выполняются очень долго; переполнение ресурсов; неосвобождение ресурсов. В этой главе мы поговорим о тех проблемах, с которыми мне приходилось сталкиваться на практике, и как я в таких случаях оптимизировал код. Для тестирования производительности страниц я предпочитаю использовать JMeter — очень простая программа от Apache https://jmeter.apache.org/ — простая программа с открытым исходным кодом на Java. Оптимизация работы с СУБД На своем YouTube-канале я как-то наглядно показал влияние индексов на производительность запросов: https://www.youtube.com/watch?v=fAHtiulkQnA. Один правильный индекс способен значительно снизить нагрузку на сервер и позволит обрабатывать больше пользователей. С другой стороны, отсутствие индекса или плохой запрос способны стать причиной того, что сервер будет занят обработкой только SQL. Оптимизация SQL-запросов позволяет сэкономить мощности web-сервера и повысить его производительность, что уменьшит возможность положительного исхода проводимой против него DoS-атаки. Для доступа к данным используется язык запросов SQL, который стандартизирован много лет назад, но не потерял своей актуальности и по сей день. Стандарт будет использоваться еще долгое время, а вот возможности, которые он предоставляет, уже не могут обеспечить современных потребностей. В своей практике я использовал различные СУБД и не раз обжигался на том, что они по-разному могут обрабатывать даже SQL-запросы. Вроде бы все выполняется верно, но с небольшими отклонениями: например, СУБД может и не поддерживать чтение данных, которые записаны в базу данных, но еще не подтверждены. Это свойство связано с поддержкой различных уровней изоляции и настроек. Поэтому вы должны с самого начала писать сценарий именно под ту СУБД, с которой будет происходить работа. Нельзя писать код под MS SQL Server или MySQL, а потом просто перенести его под Oracle. Это совершенно разные системы, которые работают по-разному, поэтому вследствие такого переноса могут возникнуть проблемы не только с производительностью, но и верного выполнения запроса. У MySQL и SQL Server по-разному реализованы функции ограничения выборки, что часто используется при написании страничных запросов. При оптимизации приложений, работающих с СУБД, нужно действовать с двух сторон: оптимизировать саму СУБД и средства доступа к данным (SQL-запросы). Причем работать нужно сразу над обеими составляющими, потому что они взаимосвязаны: повышение производительности СУБД может негативно сказаться на производительности SQL-запроса. Данная книга не является руководством по СУБД, поэтому оптимизацию мы рассмотрим только в общих чертах. За более подробной информацией обращайтесь к специализированной литературе. Оптимизация SQL-запросов Некоторые программисты считают, что запросы работают одинаково в любой СУБД. Это большая ошибка. Действительно, существует стандарт SQL, и запросы, написанные на нем, будут восприняты в большинстве систем одинаково. Но только "восприняты", а их обработка может происходить совершенно по-разному. Максимальные проблемы во время переноса приложения могут принести расширения языка SQL. Так, например, в MS SQL Server используется Transact-SQL, а в Oracle — PL/SQL, и их операторы совершенно несовместимы. Вы должны заранее определиться с используемой СУБД, чтобы не столкнуться с возможными проблемами в будущем. Но даже если вы переведете синтаксис запросов с одного языка на другой, проблем по-прежнему останется очень много. Это связано с различными архитектурами оптимизаторов, разницей в блокировках и т. д. Если код программы при смене СУБД требует незначительных изменений, то запросы могут потребовать работы. Несмотря на большие различия между СУБД разных производителей, есть и общие стороны: например, большинство из них выполняет запросы следующим образом: разбор запроса; оптимизация; генерация плана выполнения; выполнение запроса. Это всего лишь общий план выполнения, а в каждой конкретной СУБД количество шагов может меняться. Но главное состоит в том, что перед выполнением происходит несколько шагов по подготовке, которые могут отнимать много времени. Для очень простого запроса с одним select разбор и генерация плана может занять миллисекунды, но для большого запроса с десятками SELECT и большим количеством join подготовительные этапы могут занимать секунду и даже более. После выполнения запроса использованный план будет сохранен в специальном буфере. При следующем запросе эти данные будут получены из буфера, и сразу же начнется его выполнение без лишних затрат на подготовку. Сервер сможет повторно использовать план выполнения, но только при определенных условиях. Посмотрим на два SQL-запроса: SELECT * FROM TableName WHERE ColumnName=10 и SELECT * FROM TableName WHERE ColumnName=20 Оба выбирают все данные из одной и той же таблицы. Только первый покажет строки, в которых колонка ColumnName содержит значение 10, а второй покажет строки, где эта же колонка содержит значение 20. На первый взгляд они очень похожи и должны выполняться по одному и тому же плану. Но это заметно только человеку, оптимизатор этого может не увидеть и, несмотря на их схожесть, будет производить все подготовительные шаги для каждого. Чтобы этого не было, нужно использовать переменные. Переменные в SQL схожи по назначению с переменными в PHP, но в зависимости от СУБД и драйвера могут оформляться разным способом. Поэтому я не буду делать никаких оформлений, чтобы не сбить вас с толку, а просто буду называть переменные именем paramx, где X — это любое число: SELECT * FROM TableName WHERE ColumnName=param1 Теперь достаточно только передать значение переменной param1. В этом случае SQL-запросы будут восприниматься оптимизатором как одинаковые и лишних затрат на проведение подготовительных этапов не будет. Получается, что параметры позволяют защититься не только от SQL-инъекции, но и повысить скорость выполнения сервером запросов. Используйте этот очень простой и эффективный метод. Буфер для хранения планов выполнения не бесконечен, поэтому в нем хранятся данные только о последних SQL-запросах (количество зависит от размера буфера). Если какой-то SQL-запрос выполняется достаточно часто, то в нем обязательно нужно использовать переменные, потому что это значительно повысит производительность. Попробуйте дважды выполнить один и тот же SQL-запрос и посмотреть на скорость выполнения. Вторичное выполнение будет намного быстрее, что может быть заметным даже на глаз. Если вы работаете с MS SQL Server, то в нем я рекомендую выполнить команду: SET STATISTICS TIME ON И после этого выполните SQL-запрос. В SQL Management Studio помимо результата запроса вы должны на вкладке Messages (рис. 8.1) увидеть еще и статистику выполнения, например такую: SQL Server parse and compile time: CPU time = 0 ms, elapsed time = 1 ms. SQL Server parse and compile time: CPU time = 0 ms, elapsed time = 1 ms. (0 row(s) affected) SQL Server Execution Times: CPU time = 0 ms, elapsed time = 0 ms. Рис. 8.1. Служебная информация выполнения запроса Если у вас сложная программа и очень много различных запросов, то все они не смогут поместиться в кеше. В этом случае можно воспользоваться хранимыми процедурами. Они оптимизируются на этапе компиляции. Если SQL-запрос выполняется не очень быстро, но очень редко, то можно не сильно обращать внимания на его оптимизацию. Да, это утверждение верно, но только не для web-серверов, где производительность имеет критическое значение всегда. Современные СУБД могут поддерживать вложенные SQL-запросы. В некоторых случаях программисты начинают ими злоупотреблять. При написании SQL- запросов старайтесь использовать минимальное количество операторов where, особенно вложенных в секцию select. Современные базы данных достаточно умные и в случае с простым запросом могут найти самый эффективный план выполнения, как бы вы ни писали свой запрос. Но когда запрос становится очень большим, то оптимизатор может начать ошибаться, поэтому запросы нужно писать максимально просто с минимальным количеством SELECT. Для повышения производительности иногда хорошо помогает вынос лишнего select в секцию from. Но иногда бывает и наоборот — быстрее будет выполняться SQL-запрос, в котором select вынесен из from в тело where. Это уже зависит от оптимизатора конкретной СУБД и конкретного случая. Но в целом лучше делать больше упор на join. Допустим, нам надо выбрать всех людей из базы данных, которые работают на предприятии в данный момент. Для всех работающих, указанных в колонке Status, ставится код, который можно получить из справочника состояний. Посмотрим на первый вариант SQL-запроса: SELECT * FROM tbPerson p WHERE p.idStatus= (SELECT idStatus FROM tbStatus WHERE Nam.e='Работает') Вам необязательно полностью понимать суть. Главное здесь в том, что в секции where выполняется вложенный SQL-запрос. Он будет генерироваться для каждой строки в таблице tbPerson, что может оказаться накладным (получается цикл, а цикл — враг производительности). При таком простом запросе оптимизатор SQL может выполнять запрос по-разному, но теоретически мы его просим делать именно подзапросы для каждого человека в таблице tbPerson, и есть вероятность, что сервер прочитает этот запрос именно так. Если есть СУБД, которая не умеет работать с вложениями, то это приводит к необходимости написания двух SQL-запросов. Первый будет получать статус: SELECT idStatus FROM tbStatus WHERE Name='Работает' А второй будет использовать его для выборки работников: SELECT * FROM tbPerson p WHERE p.idStatus=Полученный Статус Два идеально простых запроса, для которых база данных точно выберет самый лучший и быстрый план выполнения. Теперь посмотрим, как можно вынести select в секцию from. Это можно сделать так: SELECT * FROM tbPerson p, (SELECT [idStatus] FROM tbStatus WHERE Name='Работает') s WHERE p.idStatus=s.idStatus В этом случае будет выполнен SQL-запрос из секции from. А во время связывания результата с таблицей работников мы получим окончательный результат. Таким образом, вложение не будет выполняться для каждой строки и, соответственно, не будет цикла. Но этот способ работает только для простых вариантов. Представленные примеры слишком просты и могут выполняться за одно и то же время с точностью до секунды благодаря оптимизатору. Но при использовании более разветвленной структуры или сложного SQL-запроса можно сравнить время выполнения и выбрать наиболее предпочтительный вариант для определенной СУБД (напоминаю, что разные СУБД могут обрабатывать SQL-запросы по-разному). В большинстве же случаев каждый select отрицательно влияет на скорость работы, поэтому в предыдущем примере нужно избавиться от него: SELECT * FROM tbPerson p, tbStatus s WHERE p.idStatus=s.idStatus AND s.Name='Работает' Или если использовать JOIN-подход, этот запрос можно написать так: SELECT * FROM tbPerson p JOIN tbStatus s on p.idStatus=s.idStatus WHERE s.Name='Работает' В данном случае такое объединение является самым простым и напрашивается само собой. В более сложных примерах программисты очень часто не видят возможности решения задачи одним SQL-запросом, хотя такое решение может существовать. Есть мнение, что в таком запросе проверку на имя статуса также лучше перенести в JOIN: SELECT * FROM tbPerson p JOIN tbStatus s on p.idStatus=s.idStatus AND s.Name='Работает' Лично я ни разу не видел, чтобы такой трюк повлиял на производительность, потому что оптимизатор запросов и без этого изменения видит, что нужно найти только те статусы, которые равны 'Работает'. Возможно, бывают случаи, когда такой подход быстрее, но я с таким не сталкивался. Из личного опыта могу сказать, что следующий запрос с большой вероятностью выполнится абсолютно так же и за то же время: SELECT * FROM tbPerson p JOIN tbStatus s on 1=1 WHERE p.idStatus=s.idStatus AND s.Name='Работает' Здесь все связи указаны в разделе where, и на выполнение со стороны сервера это не влияет. Просто с точки зрения чистоты кода и простоты чтения так лучше не писать. Все связи должны быть после join on, а все фильтры должны быть в where: SELECT * FROM tbPerson p JOIN tbStatus s on p.idStatus=s.idStatus WHERE s.Name='Работает' Частая проблема производительности запросов — табличные переменные. — создаем временную таблицу из 5 случайных строк таблицы member declare @memberids table (id int); insert into @memberids select top 5 MemberlD from Member order by newid() — используем временную таблицу select * from Member m join @memberids ids on m.MemberID = ids.id В этом примере используется два SQL-запроса. В первом мы выбираем случайные 5 записей из таблицы Member и помещаем их в табличную переменную memberids. Второй запрос использует эту переменную, чтобы найти записи. В моей тестовой системе этот запрос выполнялся 1,690 миллисекунд: SQL Server Execution Times: CPU time = 0 ms, elapsed time = 1690 ms. Это очень долго для такого запроса, но проблема в том, что у меня тестовая база данных расположена на простой SD-карточке, которая не отличается высокой скоростью, и база данных относительно большая. Это сделано специально, чтобы тестировать запросы на производительность. К тому же у меня на ноутбуке всего 8 Гбайт оперативной памяти, хотя SQL Server для своей работы требует минимум 4 Гбайта, что можно с трудом выделить при наличии всего 8. Тот же самый результат можно получить выполнением одного select без табличных переменных: select * from Member m join (select top 5 MemberID as id from member order by newid() ) ids on m.MemberID = ids.id Этот запрос выполнялся у меня почти секунду: SQL Server Execution Times: CPU time = 219 ms, elapsed time = 886 ms. Разница не сильно большая, но все же есть, запрос без табличной переменной выполняется быстрее почти в два раза. А что, если мы поменяем количество случайных записей с 5 до 100. Производительность первого варианта с табличной переменной упадет до 7 секунд, а вот второй вариант у меня выполнился за 3 секунды. Если выполнить каждый из запросов, то они каждый раз могут показывать разную статистику, но четко видно, что с табличной переменной намного медленнее, и проблема не только в том, что тут нужно выполнять два select, но и в том, что используется переменная с таблицей, которую сервер SQL может не оптимизировать. Табличные переменные можно использовать только если в них очень мало данных, и то, очень аккуратно и постоянно следить за производительностью, которая может сильно сократиться. Не имеет значения, как создается табличная переменная — в SQL-запросе, как это сделано в моем примере, или если эта переменная передается запросу из кода. В любом случае основная нагрузка ложится именно на использование. Если выполнить запрос создания и заполнения переменной: declare @memberids table (id int); insert into @memberids select top 100 MemberlD from Member order by newid() у меня уходит на это 130 миллисекунд. Следующая потенциальная проблема SQL — приведение типов. Допустим, вы выполняете следующий запрос: SELECT * FROM Member m WHERE m.Memberid = '123132' Здесь я пытаюсь найти запись Member, где значение MemberId — строка с числом. Если поле MemberId является числом, а вы ищите по строке, то такой SQL выполнится корректно, но его производительность может быть невысокой. Сервер может отказаться от использования индекса и начать бежать по всем записям в таблице Member, конвертировать MemberID из числа в строку и только после этого производить сравнение. То же самое может произойти и если вы производите поиск по строке, но смешиваете Unicode и ANSI-строки: SELECT * FROM Member m WHERE m.FirstName = N'Misha' Если в таблице FirstName является просто однобайтовой строкой, то этот запрос может снова выполняться медленно, потому что мы сравниваем с Unicode-версией строки. Такое снова может привести к отказу от индекса и использованию конвертации для каждой строки в базе данных. Оптимизация базы данных Оптимизация должна начинаться еще на этапе проектирования базы данных. Очень часто программисты задают полям размер с достаточно большим запасом. Поначалу я и сам так поступал. Трудно предсказать, какого размера будут храниться данные, а если выбранного размера поля не хватит, то программа не сможет сохранить необходимую строку. В некоторых СУБД, если не указать размер поля для хранения строки, он принимает максимально возможное значение или 255. Это непростительное расточительство дискового пространства, если использовать тип данных ^ar, и создаваемая база данных становится неоправданно большой. А чем она больше, тем сложнее ее обработать, больше нужно читать с диска и требуется больше оперативной памяти для эффективного кеширования. Если же уменьшить размер, то СУБД сможет максимально быстро загрузить данные в память и произвести поиск без обращения к жесткому диску. Если база данных не помещается в памяти, то приходится загружать ее по частям, а в худшем случае — использовать файл подкачки, который находится на диске и работает в несколько раз медленнее оперативной памяти. Конечно же, можно увеличить объем оперативной памяти до размера базы, что позволит загрузить все данные в память и обрабатывать их там, что намного быстрее, но это не ускорит саму загрузку. Итак, чтобы ваша база данных была минимальной, вы должны использовать только необходимый размер полей. Например, для хранения номера телефона достаточно 10 символов char, и не надо использовать для этого 50. Для таблицы с 100 000 записей это будут лишние 4 Мбайта информации. А если полей с завышенным размером 10? В этом случае расход становится слишком большим. Если поле должно иметь размер более 100 символов, подумайте о том, чтобы использовать тип text или memo. Некоторым базам данных это действительно может помочь, потому что значения таких полей хранятся на отдельных страницах. Можно использовать везде тип данных varchar, который более эффективно использует дисковое пространство, и в этом есть свой смысл. В MS SQL Server для оптимизации можно попытаться использовать сжатие данных. В случае с простым жестким диском это может дать результат, потому что именно диск может быть самым слабым местом системы. В случае с SSD это уже не так заметно, но тоже можно добиться определенного результата. Итак, если использовать сжатие данных при хранении, то при обращении к жесткому диску придется читать меньше данных, в некоторых случаях в несколько раз меньше данных, потому что базы обычно очень хорошо поддаются сжатию, если содержат повторяющиеся данные. За один раз можно прочитать больше данных и потом распаковать их на быстром процессоре. В последних версиях SQL Server появилась возможность использовать оптимизированные для хранения в памяти таблицы. Такие таблицы обычно поднимаются сервером в оперативную память и хранятся при выполнении запросов, что может дать значительное повышение производительности. Одновременно с оптимизацией SQL-запросов вы должны оптимизировать и саму базу данных. Это достигается с помощью введения дополнительных индексов на поля, по которым часто происходит выборка. Индексы могут значительно ускорить поиск, но с ними нужно обращаться аккуратно, потому что слишком большое их количество может замедлить работу. Чаще всего замедление происходит во время добавления или удаления записей, что требует внесения изменений в большое количество индексов. Есть мнение, что раз база данных может медленнее производить вставку и обновление, то это плохо, и поэтому такие программисты очень редко создают индексы. Как часто вы меняете или добавляете данные? В большинстве приложений на 1 вставку приходится 100 и даже более операций поиска SELECT. Даже в такой ситуации выгоднее совсем немного затормозить вставку данных, но зато значительно повысить производительность поиска, который выполняется в 100 раз чаще. После внесения изменений вы должны протестировать систему на предмет производительности. Если скорость не увеличилась, то удалите индекс, чтобы он не отнимал лишние ресурсы, потому что добавление следующего индекса может не принести желаемого эффекта из-за присутствия предыдущих — не используемых, но отнимающих ресурсы. Если создать два схожих индекса, то база данных может в реальности использовать только один из них. Желательно проверять, что новые индексы реально используются базой данных. Еще одним способом повышения скорости работы запросов может быть денормализация данных. Что это такое? У вас может быть несколько связанных таблиц: например, в одной из них находится фамилия человека, а в другой — город проживания. Чтобы получить в одном SQL-запросе оба значения, нужно навести связь между этими таблицами, что может отрицательно сказаться на производительности. В таких случаях значения одной таблицы копируют в другую и связь становится ненужной. Конечно же, появляется и избыточность данных — в двух таблицах хранится одно и то же, но это повысит скорость обработки, и иногда очень значительно. Кроме избыточности, недостатком денормализации является и сложность поддержки данных. Если в одной таблице изменилось значение, то вы должны обновить соответствующие значения в другой таблице. Именно поэтому для денормализации используют только те поля, которые изменяются редко. Кроме того, если СУБД поддерживает триггеры, то задачу по обновлению таблиц можно переложить на нее. Деморализация очень часто приводит и к тому, что увеличивается и размер базы данных, и тогда приходится больше читать данных с диска, а, как мы уже говорили, очень часто именно этот компонент компьютеров/серверов является самым слабым звеном. Наиболее распространенной СУБД в web является MySQL. Для нее существует автоматический метод оптимизации: оператор optimize, способный повысить скорость работы с помощью выполнения профилактических действий, которые включают сортировку индексных страниц, обновление статистики, очистку удаленных строк и т. д. Оператор имеет следующий вид: OPTIMIZE TABLE имя В качестве параметра имя указывается имя таблицы, которая требует оптимизации. Подобные команды оптимизации есть и для других баз данных, в MS SQL Server эту же задачу выполняет sp_updatestats: sp_updatestats Member Если указать имя таблицы, то статистика обновится только для нее, как происходит в данном примере для таблицы Member. Если никаких параметров не указывать, то статистика будет обновлена для всей базы данных. По умолчанию базы данных неплохо поддерживают статистику в процессе работы с базой данных, но при массовом обновлении данных могут возникнуть все же проблемы. Современные СУБД для выбора правильного плана выполнения SQL-запроса используют статистику. Если она у вас не включена на автоматическое использование, то я рекомендую сделать это сейчас, и хотя бы раз в месяц можно производить профилактику и обновлять ее вручную. Как статистика может нам помочь? Допустим, у нас есть список работников литейного цеха. Примерно 90% этого списка (если не более) будут составлять мужчины, ведь литейное производство достаточно тяжелое для женщин. Теперь допустим, что нам нужно найти всех женщин из этого списка. Так как их мало, наиболее эффективным вариантом будет использование индекса, но если нужно найти мужчин, то его эффективность падает. Количество выбираемых записей слишком велико, и для каждой из них обходить дерево индекса будет лишними накладными расходами. Намного проще и быстрее просканировать всю таблицу, потому что достаточно по одному разу прочитать все листья нижнего уровня индекса без необходимости многоразового чтения всех уровней. Помимо статистики на скорость работы влияет и дефрагментация индексов. По мере работы с базой данных данные индексов могут оказаться разбросанными по диску и серверу придется читать информацию с разных сторон. Представьте себе книгу, в которой часть оглавления расположена в начале, а другая — в конце. Вам придется прыгать по двум оглавлениям в поисках нужной информации, и это замедлит поиск, даже несмотря на то, что само оглавление позволяет ускорить поиск нужной главы. То же самое касается и индекса в базе данных. Если часть индекса расположена физически в одном месте, а другая часть — в другом, то придется прыгать по двум местам. Но в реальной жизни данные могут быть разбросаны по сотни различных мест на диске, и это может замедлить поиск. Просто собрав все данные в одном месте последовательно на диске, можно ускорить работу. Обновление индекса также нужно сделать ежемесячной задачей профилактики базы данных. Как обновлять индексы — тут нужно смотреть конкретную базу данных, которую вы используете. В MS SQL Server это делается командой: ALTER INDEX REORGANIZE Выборка необходимых данных При работе с базами данных мы регулярно пишем SQL-запросы на выборку данных. Количество выбираемых данных может быть очень большим. Простой пример — поисковая система. Попробуйте на web-сайте Yahoo или Google запустить поиск по слову PHP. Мне поисковая система сообщила, что найдено около 690 000 000 записей, и при этом на обработку запроса понадобилось только 0,05 секунд. В реальности выборка таких данных даже на самом быстром компьютере будет происходить намного дольше, так откуда же такая скорость? Решение находится не в мощных компьютерах компании Yahoo или Google, я просто уверен, что все кроется в правильности написания SQL-запроса. Допустим, что каждая строка в базе данных Google занимает всего 100 байт. В реальности, конечно же, размер строки намного больше, но мы ограничимся таким маленьким числом, и даже его хватит, чтобы ужаснуться. Если умножить число 100 на количество строк в результате, то мы получим, что результат будет занимать 69 Гбайт. Даже если СУБД и используемый сценарий находятся на одном компьютере, получение таких данных отнимет не один десяток секунд. А если на разных? Даже при совершенно не занятом и самом мощном канале пересылка такого количества данных отнимет еще больше времени. Так как же это происходит? Дело в том, что отображать пользователю весь список результатов поиска слишком накладно, поэтому он разбивается на страницы, на каждой из которых отображается от 10 до 30 записей. Исходя из этого, получение результата можно разбить на два этапа: Определить общее количество записей, удовлетворяющих критериям поиска: SELECT Count(*) FROM таблица WHERE критерии_поиска Результатом SQL-запроса будет всего лишь одно число, для хранения которого хватит и 4 байтов. Такое число СУБД сможет мгновенно передать сценарию. Выбрать данные для формирования только одной страницы. На начальном этапе это первая страница, и нужно выбрать первые N записей. Если страница вторая, то выбираем записи от N + 1 до N + N и т. д. Это намного удобнее и быстрее по двум причинам: когда СУБД сканирует базу данных в поиске нужных записей и находит первые N строк, то прерывает поиск и возвращает результат клиенту. Дальнейшее сканирование бессмысленно, потому что клиенту больше записей пока не нужно; по сети передается только N х (размер строки данных), что намного меньше, чем размер строки, умноженный на 690 000 000. В случае использования самой распространенной СУБД MySQL для реализации всего вышесказанного нужно использовать оператор limit: SELECT * FROM таблица LIMIT Y, N где y — строка, начиная с которой нужно возвращать результат, а N — количество строк. Например, если необходимо получить строки, начиная с 10-й по 25-ю, нужно выполнить SQL-запрос: SELECT * FROM таблица LIMIT 9, 15 Если необходимо получить все строки, начиная с 50-й, то в качестве N нужно указать число -1: SELECT * FROM таблица LIMIT 50, -1 Всегда получайте от СУБД только самые необходимые данные. Даже запрос лишней колонки требует лишних затрат и ресурсов не только для web-сервера, но и для сетевого оборудования, и для клиента. Резюме Мы рассмотрели только основы оптимизации SQL-запросов, за более подробной информацией обращайтесь к специализированной литературе по используемой вами СУБД. Для MS SQL Server и его оптимизации могу порекомендовать мою книгу "Transact-SQL". Оптимизация кода Мы рассмотрели теорию оптимизации, поговорили о том, как можно ускорить работу СУБД, а теперь нам предстоит узнать, как же можно оптимизировать сам код программы. Когда вы нашли слабое место в системе и убедились, что для работы используется наиболее эффективный алгоритм, можно переходить к улучшению кода и PHP-инструкций. В этом разделе мы рассмотрим методы, которые могут значительно снизить время формирования web-страниц и уменьшить вероятность успешного проведения DoS- атаки. Несмотря на то, что мы будем рассматривать примеры на PHP, подобные алгоритмы можно реализовать и в других языках программирования. Кеширование вывода У PHP есть несколько интересных функций, с помощью которых можно включить буферизацию вывода и повысить скорость работы сценария — например, кеширование вывода. Для начала кеширования необходимо вызвать функцию ob_start, а в конце вызвать функцию ob_end_flush. Все операции вывода данных (например, print) между вызовами этих двух функций будут сохранять данные в буфере, а не направлять клиенту. Непосредственная отправка данных клиенту произойдет только после вызова функции ob_end_flush. Если функция ob_end_flush не будет вызвана, то данные будут направлены web-серверу по завершении выполнения сценария. Следующий пример показывает, как использовать функции кеширования: ob_start(); // Вывод данных ob_end_flush(); ?> Во время выполнения сценария вы можете контролировать состояние буфера. Для этого можно воспользоваться одной из двух функций: ob_get_contents — функция возвращает содержимое буфера; ob_ge t_l ength — функция возвращает размер выделенного буфера. Буферизацию можно ускорить еще больше, если включить сжатие данных. Для этого нужно выполнить функцию ob_start, а в качестве параметра передать строку ob_gzhandler: ob_start('ob_gzhandler'); // Вывод данных ?> В этом случае данные будут передаваться клиенту в сжатом виде. Если браузер клиента не поддерживает сжатия, то данные будут передаваться в открытом виде. Даже если 50% пользователей будут получать сжатые данные, вы сэкономите достаточно много трафика, а значит, и ресурсов. Для web-сервера это лишние расходы процессорного времени, ведь приходится выполнять лишние операции по сжатию. Зато сетевые каналы смогут обрабатывать большее количество запросов, а значит, и быстрее. Если ваш канал связи загружен более чем на 70%, необходимо подумать о том, чтобы включить кеширование. Кеширование web-страниц Если ваши сценарии для формирования web-страницы используют SQL-запросы к достаточно большой базе данных, и при этом изменения в ней происходят редко, то можно кешировать целые web-страницы. Как оценить, насколько редко меняется база данных? Для этого нужно сравнить частоту изменений с количеством обращений, и если между изменениями происходит более 100 обращений, то кеширование может реально помочь вашему web-сайту. Во время кеширования, при первом обращении после внесения изменений, данные web-страницы формируются с помощью сценария и после формирования сохраняются на жестком диске в специальном каталоге. При последующем обращении к этой же web-странице сценарий проверяет наличие кеша, и если он существует, то загружает его. Таким образом, не нужно будет заново формировать web- страницу, снизится количество обращений к базе данных и значительно уменьшится нагрузка. Для кеширования web-страниц у PHP нет готового и эффективного решения, да и не может быть, потому что это решение не может быть универсальным. Все приходится создавать самостоятельно, поэтому в данном разделе нам предстоит рассмотреть возможный вариант решения проблемы кеширования. Давайте подумаем, как объединить кеширование вывода (см. разд. 8.3.1) и кеширование web-страниц. Если объединить эти две технологии и немного подумать, то пример реализации кеширования web-страниц станет очевидным (листинг 8.1). Листинг 8.1. Кеширование web-страниц // Функция чтения кеша function ReadCache($CacheName) { if (file_exists("cache/$CacheName.htm")) { require("cache/$CacheName.htm"); print(" |