Примечание
Для доступа к этой странице требуется авторизация. Вы можете попробовать войти или изменить каталоги.
Для доступа к этой странице требуется авторизация. Вы можете попробовать изменить каталоги.
Совокупный эффект большого количества запросов ввода-вывода может оказать значительное влияние на производительность и скорость реагирования.
Описание проблемы
Сетевые вызовы и другие операции ввода-вывода по сути являются медленными по сравнению с вычислительными задачами. Каждый запрос ввода-вывода обычно имеет значительные издержки, и совокупный эффект многочисленных операций ввода-вывода может замедлить работу системы. Ниже приведены некоторые распространенные причины болтливых операций ввода-вывода.
Чтение и запись отдельных записей в базу данных в виде отдельных запросов
В следующем примере выполняется считывание из базы данных с информацией о продуктах. Существует три таблицы, Product
и ProductSubcategory
ProductPriceListHistory
. Код извлекает все продукты в подкатегории, а также сведения о ценах, выполняя ряд запросов:
- Запросите подкатегорию из
ProductSubcategory
таблицы. - Найдите все продукты в этой подкатегории, запрашивая таблицу
Product
. - Для каждого продукта запросите данные о ценах из
ProductPriceListHistory
таблицы.
Приложение использует Entity Framework для запроса к базе данных. Полный пример см. здесь.
public async Task<IHttpActionResult> GetProductsInSubCategoryAsync(int subcategoryId)
{
using (var context = GetContext())
{
// Get product subcategory.
var productSubcategory = await context.ProductSubcategories
.Where(psc => psc.ProductSubcategoryId == subcategoryId)
.FirstOrDefaultAsync();
// Find products in that category.
productSubcategory.Product = await context.Products
.Where(p => subcategoryId == p.ProductSubcategoryId)
.ToListAsync();
// Find price history for each product.
foreach (var prod in productSubcategory.Product)
{
int productId = prod.ProductId;
var productListPriceHistory = await context.ProductListPriceHistory
.Where(pl => pl.ProductId == productId)
.ToListAsync();
prod.ProductListPriceHistory = productListPriceHistory;
}
return Ok(productSubcategory);
}
}
В этом примере проблема показана явно, но иногда O/RM может маскировать проблему, если он неявно извлекает дочерние записи по одной за раз. Это называется проблемой N+1.
Реализация одной логической операции в виде ряда HTTP-запросов
Это часто происходит, когда разработчики пытаются следовать объектно-ориентированной парадигме и обрабатывать удаленные объекты, как будто они были локальными объектами в памяти. Это может привести к слишком большому количеству сетевых обращений. Например, следующий веб-API предоставляет отдельные свойства объектов с помощью отдельных User
методов HTTP GET.
public class UserController : ApiController
{
[HttpGet]
[Route("users/{id:int}/username")]
public HttpResponseMessage GetUserName(int id)
{
...
}
[HttpGet]
[Route("users/{id:int}/gender")]
public HttpResponseMessage GetGender(int id)
{
...
}
[HttpGet]
[Route("users/{id:int}/dateofbirth")]
public HttpResponseMessage GetDateOfBirth(int id)
{
...
}
}
Хотя в этом подходе нет никаких технических ошибок, большинству клиентов, вероятно, потребуется получить несколько свойств для каждого User
, и код клиента будет выглядеть примерно так, как показано ниже.
HttpResponseMessage response = await client.GetAsync("users/1/username");
response.EnsureSuccessStatusCode();
var userName = await response.Content.ReadAsStringAsync();
response = await client.GetAsync("users/1/gender");
response.EnsureSuccessStatusCode();
var gender = await response.Content.ReadAsStringAsync();
response = await client.GetAsync("users/1/dateofbirth");
response.EnsureSuccessStatusCode();
var dob = await response.Content.ReadAsStringAsync();
Чтение и запись в файл на диске
Операции ввода-вывода файлов включают открытие файла и переход к соответствующей точке перед чтением или записью данных. После завершения операции файл может быть закрыт для сохранения ресурсов операционной системы. Приложение, которое постоянно считывает и записывает небольшие объемы информации в файл, создает значительные затраты на операции ввода-вывода. Небольшие запросы на запись также могут привести к фрагментации файлов, замедляя последующие операции ввода-вывода.
В следующем примере используется FileStream
для записи Customer
объекта в файл. Создание FileStream
открывает файл, а удаление его закрывает файл. (Оператор using
автоматически удаляет FileStream
объект.) Если приложение неоднократно вызывает этот метод по мере добавления новых клиентов, затраты на ввод-вывод могут быстро накапливаться.
private async Task SaveCustomerToFileAsync(Customer customer)
{
using (Stream fileStream = new FileStream(CustomersFileName, FileMode.Append))
{
BinaryFormatter formatter = new BinaryFormatter();
byte [] data = null;
using (MemoryStream memStream = new MemoryStream())
{
formatter.Serialize(memStream, customer);
data = memStream.ToArray();
}
await fileStream.WriteAsync(data, 0, data.Length);
}
}
Устранение проблемы
Уменьшите количество запросов ввода-вывода, упаковав данные в большие, меньше запросов.
Получение данных из базы данных в виде одного запроса вместо нескольких небольших запросов. Ниже приведена обновленная версия кода, которая извлекает сведения о продукте.
public async Task<IHttpActionResult> GetProductCategoryDetailsAsync(int subCategoryId)
{
using (var context = GetContext())
{
var subCategory = await context.ProductSubcategories
.Where(psc => psc.ProductSubcategoryId == subCategoryId)
.Include("Product.ProductListPriceHistory")
.FirstOrDefaultAsync();
if (subCategory == null)
return NotFound();
return Ok(subCategory);
}
}
Следуйте принципам проектирования REST для веб-API. Ниже приведена обновленная версия веб-API из предыдущего примера. Вместо отдельных методов GET для каждого свойства существует один метод GET, который возвращает User
. Это приводит к большему тексту ответа на запрос, но каждый клиент, скорее всего, сделает меньше вызовов API.
public class UserController : ApiController
{
[HttpGet]
[Route("users/{id:int}")]
public HttpResponseMessage GetUser(int id)
{
...
}
}
// Client code
HttpResponseMessage response = await client.GetAsync("users/1");
response.EnsureSuccessStatusCode();
var user = await response.Content.ReadAsStringAsync();
Для операций ввода-вывода файлов рекомендуется буферизуть данные в памяти, а затем записывать буферированные данные в файл в виде одной операции. Этот подход снижает затраты на многократное открытие и закрытие файла и помогает уменьшить фрагментацию файла на диске.
// Save a list of customer objects to a file
private async Task SaveCustomerListToFileAsync(List<Customer> customers)
{
using (Stream fileStream = new FileStream(CustomersFileName, FileMode.Append))
{
BinaryFormatter formatter = new BinaryFormatter();
foreach (var customer in customers)
{
byte[] data = null;
using (MemoryStream memStream = new MemoryStream())
{
formatter.Serialize(memStream, customer);
data = memStream.ToArray();
}
await fileStream.WriteAsync(data, 0, data.Length);
}
}
}
// In-memory buffer for customers.
List<Customer> customers = new List<Customers>();
// Create a new customer and add it to the buffer
var customer = new Customer(...);
customers.Add(customer);
// Add more customers to the list as they are created
...
// Save the contents of the list, writing all customers in a single operation
await SaveCustomerListToFileAsync(customers);
Соображения
Первые два примера делают меньше вызовов ввода-вывода, но каждый из них получает дополнительные сведения. Необходимо рассмотреть компромисс между этими двумя факторами. Правильный ответ зависит от фактических шаблонов использования. Например, в примере веб-API может оказаться, что клиенты часто нуждаются только в имени пользователя. В этом случае имеет смысл представить его в виде отдельного вызова API. Дополнительные сведения см. в антипаттерне избыточного извлечения.
При чтении данных не создавайте слишком большие запросы ввода-вывода. Приложение должно получать только сведения, которые, скорее всего, будут использоваться.
Иногда это помогает разделить информацию для объекта на два блока: данные, к которым часто обращаются, которые составляют большинство запросов, и данные, к которым обращаются реже, которые используются редко. Часто наиболее востребованные данные составляют относительно небольшую часть от общего объема данных для объекта, поэтому возвращение только этой части может значительно снизить затраты на ввод-вывод.
При написании данных избегайте блокировки ресурсов дольше, чем необходимо, чтобы снизить вероятность конфликтов во время длительной операции. Если операция записи охватывает несколько хранилищ данных, файлов или служб, то в конечном итоге применяется согласованный подход. См. руководство по согласованности данных.
Если вы буферизируете данные в памяти перед записью, данные уязвимы, если процесс завершается сбоем. Если скорость данных обычно имеет всплески или относительно разрежена, может быть безопаснее буферировать данные во внешней устойчивой очереди, такой как Центры событий.
Рассмотрите возможность кэширования данных, полученных из службы или базы данных. Это может помочь уменьшить объем операций ввода-вывода, избегая повторяющихся запросов для одних и того же данных. Дополнительные сведения см. в рекомендациях по кэшированию.
Как обнаружить проблему
Симптомы чатового ввода-вывода включают высокую задержку и низкую пропускную способность. Конечные пользователи, вероятно, будут сообщать об увеличенном времени отклика или сбоях, вызванных истечением времени ожидания служб, из-за повышенной конкуренции за ресурсы ввода-вывода.
Чтобы определить причины проблем, выполните следующие действия.
- Выполняйте мониторинг процессов рабочей системы для выявления операций с плохим временем отклика.
- Выполните нагрузочное тестирование каждой операции, определенной на предыдущем шаге.
- Во время нагрузочных тестов соберите данные телеметрии о запросах доступа к данным, выполняемых каждой операцией.
- Соберите подробную статистику для каждого запроса, отправленного в хранилище данных.
- Профилируйте приложение в тестовой среде, чтобы установить возможные узкие места ввода-вывода.
Найдите любой из этих симптомов:
- Большое количество небольших запросов ввода-вывода, выполненных в одном файле.
- Большое количество небольших сетевых запросов, сделанных экземпляром приложения к одному и тому же сервису.
- Большое количество небольших запросов, сделанных экземпляром приложения в одно и то же хранилище данных.
- Приложения и службы становятся привязанными к ввода-выводам.
Пример диагностики
В следующих разделах эти действия применяются к приведенному ранее примеру, который запрашивает базу данных.
Нагрузочное тестирование приложения
На этом графике показаны результаты нагрузочного тестирования. Медианное время отклика измеряется в десятках секунд на каждый запрос. На графике показана очень высокая задержка. При нагрузке системы 1000 пользователями пользователю, возможно, придется подождать почти минуту, чтобы увидеть результаты запроса.
Замечание
Приложение было развернуто в качестве веб-приложения службы приложений Azure с помощью базы данных SQL Azure. Нагрузочный тест использовал имитированную шаговую нагрузку с участием до 1000 одновременных пользователей. База данных была настроена с пулом подключений, поддерживающим до 1000 одновременных подключений, чтобы снизить вероятность того, что состязание по подключениям повлияет на результаты.
Мониторинг приложения
Пакет управления производительностью приложений (APM) можно использовать для отслеживания и анализа ключевых метрик, которые могут выявить чрезмерно частые операции ввода-вывода. Какие метрики важны, зависят от рабочей нагрузки ввода-вывода. В этом примере интересные запросы ввода-вывода были запросами к базе данных.
На следующем рисунке показаны результаты, созданные с помощью New Relic APM. Среднее время отклика базы данных достигло максимума в 5,6 секунды за запрос во время максимальной рабочей нагрузки. Система могла поддерживать в среднем 410 запросов в минуту на протяжении всего теста.
Сбор подробных сведений о доступе к данным
Более подробное анализ данных мониторинга показывает, что приложение выполняет три разных инструкции SQL SELECT. Эти запросы соответствуют запросам, созданным Entity Framework, для получения данных из таблиц ProductListPriceHistory
, Product
, и ProductSubcategory
. Кроме того, запрос, извлекаемый из ProductListPriceHistory
таблицы, является наиболее часто выполняемым оператором SELECT по порядку величины.
Оказывается, что метод, показанный GetProductsInSubCategoryAsync
ранее, выполняет 45 запросов SELECT. Каждый запрос приводит к тому, что приложение открывает новое подключение к SQL.
Замечание
На этом рисунке показаны сведения о трассировке для самого медленного экземпляра GetProductsInSubCategoryAsync
операции в нагрузочном тесте. В рабочей среде полезно изучить трассировки самых медленных экземпляров, чтобы узнать, существует ли шаблон, который предлагает проблему. Если вы просто посмотрите на средние значения, вы можете упустить проблемы, которые будут значительно усугубляться под нагрузкой.
На следующем рисунке показаны фактические инструкции SQL, которые были выданы. Запрос, который получает сведения о цене, выполняется для каждого отдельного продукта в подкатегории продукта. Использование соединения значительно уменьшит количество вызовов базы данных.
Если вы используете O/RM, например Entity Framework, трассировка запросов SQL может дать представление о том, как O/RM преобразует программные вызовы в инструкции SQL и указывает области, в которых может быть оптимизирован доступ к данным.
Реализация решения и проверка результата
Перезапись вызова Entity Framework привела к следующим результатам.
Этот нагрузочный тест был выполнен в том же развертывании, используя тот же профиль загрузки. На этот раз граф показывает гораздо меньшую задержку. Среднее время запроса при 1000 пользователях составляет от 5 до 6 секунд, что значительно уменьшилось с почти минуты.
На этот раз система поддерживала в среднем 3970 запросов в минуту по сравнению с 410 для предыдущего теста.
Трассировка SQL-запроса показывает, что все данные извлекаются в одном запросе SELECT. Хотя этот запрос значительно сложнее, он выполняется только один раз на операцию. И хотя сложные соединения могут стать дорогостоящими, реляционные системы баз данных оптимизированы для этого типа запроса.