Nota
El acceso a esta página requiere autorización. Puede intentar iniciar sesión o cambiar directorios.
El acceso a esta página requiere autorización. Puede intentar cambiar los directorios.
El efecto acumulativo de un gran número de solicitudes de E/S puede tener un impacto significativo en el rendimiento y la capacidad de respuesta.
Descripción del problema
Las llamadas de red y otras operaciones de E/S son intrínsecamente lentas en comparación con las tareas de proceso. Cada solicitud de E/S suele tener una sobrecarga significativa y el efecto acumulativo de numerosas operaciones de E/S puede ralentizar el sistema. Estas son algunas causas comunes de Chatty I/O.
Lectura y escritura de registros individuales en una base de datos como solicitudes distintas
En el ejemplo siguiente, se extrae información de una base de datos de productos. Hay tres tablas, Product
, ProductSubcategory
y ProductPriceListHistory
. El código recupera todos los productos de una subcategoría, junto con la información de precios, ejecutando una serie de consultas:
- Consulte la subcategoría de la
ProductSubcategory
tabla. - Busque todos los productos de esa subcategoría consultando la
Product
tabla. - Para cada producto, consulte los datos de precios de la
ProductPriceListHistory
tabla.
La aplicación usa Entity Framework para consultar la base de datos.
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);
}
}
En este ejemplo se muestra el problema de forma explícita, pero, en ocasiones, si captura implícitamente los registros secundarios uno a uno, el asignador relacional de objetos puede enmascarar el problema. Esto se conoce como el problema "N+1".
Implementación de una sola operación lógica como una serie de solicitudes HTTP
Esto suele ocurrir cuando los desarrolladores intentan seguir un paradigma orientado a objetos y tratar objetos remotos como si fueran objetos locales en la memoria. Esto puede producir demasiado recorrido de ida y vuelta en la red. Por ejemplo, la siguiente API web expone las propiedades individuales de los objetos mediante métodos HTTP GET individuales.
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)
{
...
}
}
Aunque técnicamente no hay nada incorrecto con este enfoque, la mayoría de los clientes probablemente necesitarán obtener varias propiedades para cada User
, lo que da como resultado código de cliente como el siguiente.
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();
Lectura y escritura en un archivo en el disco
Las operaciones de E/S de archivos implican la apertura de un archivo y su desplazamiento al punto adecuado para leer o escribir datos. Una vez completada la operación, es posible que el archivo se cierre para guardar los recursos del sistema operativo. Una aplicación que lee y escribe continuamente pequeñas cantidades de información en un archivo generará una sobrecarga significativa de E/S. Las solicitudes de escritura pequeñas también pueden provocar fragmentación de archivos, lo que ralentiza aún más las operaciones de E/S posteriores.
En el siguiente ejemplo se usa un objeto FileStream
para escribir un objeto Customer
en un archivo. Al crear el FileStream
se abre el archivo, y al desecharlo se cierra el archivo. (La using
instrucción elimina automáticamente el FileStream
objeto). Si la aplicación llama a este método repetidamente a medida que se agregan nuevos clientes, la sobrecarga de E/S se puede acumular rápidamente.
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);
}
}
Cómo corregir el problema
Reduzca el número de solicitudes de E/S mediante el empaquetado de los datos en más grandes y menos solicitudes.
Capturar datos de una base de datos como una sola consulta, en lugar de varias consultas más pequeñas. Esta es una versión revisada del código que recupera la información del producto.
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);
}
}
Siga los principios de diseño de REST para las API web. Esta es una versión revisada de la API web del ejemplo anterior. En lugar de métodos GET independientes para cada propiedad, hay un único método GET que devuelve .User
Esto da como resultado un cuerpo de respuesta mayor por solicitud, pero es probable que cada cliente realice menos llamadas 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();
En el caso de la E/S de archivos, considere la posibilidad de almacenar en búfer los datos en memoria y, a continuación, escribir los datos almacenados en búfer en un archivo como una sola operación. Este enfoque reduce la sobrecarga de abrir y cerrar repetidamente el archivo y ayuda a reducir la fragmentación del archivo en el disco.
// 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);
Consideraciones
Los dos primeros ejemplos realizan menos llamadas de E/S, pero cada uno recupera más información. Debe tener en cuenta el equilibrio entre estos dos factores. La respuesta correcta dependerá de los patrones de uso reales. Por ejemplo, en el ejemplo de API web, podría resultar que los clientes a menudo solo necesitan el nombre de usuario. En ese caso, podría tener sentido exponerlo como una llamada API independiente. Para más información, consulte el antipatrón Extraneous Fetching.
Al leer datos, no realice solicitudes de E/S demasiado grandes. Una aplicación solo debe recuperar la información que es probable que use.
En ocasiones resulta útil para la partición de la información de un objeto en dos fragmentos, los datos de acceso frecuente en la mayoría de las solicitudes y los datos de acceso menos frecuente que casi no se usan. A menudo, los datos a los que se accede con más frecuencia son una parte relativamente pequeña de los datos totales de un objeto, por lo que devolver solo esa parte puede ahorrar una sobrecarga significativa de E/S.
Al escribir datos, evite bloquear los recursos durante más tiempo de lo necesario, para reducir las posibilidades de competencia durante una operación prolongada. Si una operación de escritura abarca varios almacenes de datos, archivos o servicios, adopte un enfoque finalmente coherente. Consulte Guía de coherencia de datos.
Si almacena los datos en búfer en memoria antes de escribir, estos son vulnerables si se bloquea el proceso. Si la velocidad de los datos normalmente tiene ráfagas o es relativamente dispersa, puede ser más seguro para almacenarlos en búfer en una cola durable externa como Event Hubs.
Considere la posibilidad de almacenar en caché los datos que recupera de un servicio o una base de datos. Esto puede ayudar a reducir el volumen de E/S evitando solicitudes repetidas para los mismos datos. Para obtener más información, consulte Procedimientos recomendados de almacenamiento en caché.
Cómo detectar el problema
Los síntomas de la E/S verbosa incluyen alta latencia y bajo rendimiento. Es probable que los usuarios finales notifiquen tiempos de respuesta extendidos o errores causados por el tiempo de espera de los servicios, debido a una mayor contención de los recursos de E/S.
Puede realizar los pasos siguientes para ayudar a identificar las causas de cualquier problema:
- Realice la supervisión de procesos del sistema de producción para identificar las operaciones con tiempos de respuesta deficientes.
- Realice pruebas de carga de cada operación identificada en el paso anterior.
- Durante las pruebas de carga, recopile datos de telemetría sobre las solicitudes de acceso a datos realizadas por cada operación.
- Recopile estadísticas detalladas para cada solicitud enviada a un almacén de datos.
- Genere perfiles de aplicación en el entorno de prueba para establecer dónde se pueden estar produciendo cuellos de botella de E/S.
Busque cualquiera de estos síntomas:
- Un gran número de pequeñas solicitudes de entrada/salida realizadas al mismo archivo.
- Un gran número de solicitudes de red pequeñas realizadas por una instancia de aplicación al mismo servicio.
- Un gran número de solicitudes pequeñas realizadas por una instancia de aplicación en el mismo almacén de datos.
- Aplicaciones y servicios dependientes de las operaciones de E/S.
Diagnóstico de ejemplo
En las secciones siguientes se aplican estos pasos al ejemplo mostrado anteriormente que consulta una base de datos.
Realizar una prueba de carga en la aplicación
En este gráfico se muestran los resultados de las pruebas de carga. El tiempo de respuesta medio se mide en decenas de segundos por solicitud. El gráfico muestra una latencia muy alta. Con una carga de 1000 usuarios, es posible que un usuario tenga que esperar casi un minuto para ver los resultados de una consulta.
Nota:
La aplicación se implementó como una aplicación web de Azure App Service mediante Azure SQL Database. La prueba de carga usó una carga de trabajo escalonada simulada de hasta 1000 usuarios simultáneos. La base de datos se configuró con un grupo de conexiones compatible con hasta 1000 conexiones simultáneas, para reducir la posibilidad de que la contención de las conexiones afectara a los resultados.
Supervisar la aplicación
Puede usar un paquete de gestión del rendimiento de aplicaciones (APM) para capturar y analizar las métricas clave que podrían identificar E/S verboso. Las métricas que son importantes dependerán de la carga de trabajo de E/S. En este ejemplo, las solicitudes de E/S interesantes eran las consultas de base de datos.
En la imagen siguiente se muestran los resultados generados con New Relic APM. El tiempo medio de respuesta de la base de datos alcanzó aproximadamente 5,6 segundos por solicitud durante la carga de trabajo máxima. El sistema pudo admitir un promedio de 410 solicitudes por minuto durante la prueba.
Recopilación de información detallada de acceso a datos
Profundizar en los datos de supervisión muestra que la aplicación ejecuta tres instrucciones SELECT de SQL diferentes. Estas corresponden a las solicitudes generadas por Entity Framework para capturar datos de las ProductListPriceHistory
tablas , Product
y ProductSubcategory
. Además, la consulta que recupera datos de la ProductListPriceHistory
tabla es, lejos, la instrucción SELECT ejecutada con más frecuencia, según un orden de magnitud.
Resulta que el GetProductsInSubCategoryAsync
método, mostrado anteriormente, realiza 45 consultas SELECT. Cada consulta hace que la aplicación abra una nueva conexión SQL.
Nota:
En esta imagen se muestra información de seguimiento de la instancia más lenta de la GetProductsInSubCategoryAsync
operación en la prueba de carga. En un entorno de producción, resulta útil examinar los seguimientos de las instancias más lentas para ver si hay un patrón que sugiere un problema. Si solo observa los valores promedio, puede pasar por alto problemas que empeorarán drásticamente bajo carga.
En la imagen siguiente se muestran las instrucciones SQL reales que se emitieron. La consulta que captura la información de precios se ejecuta para cada producto individual en la subcategoría del producto. El uso de una sentencia JOIN reduciría considerablemente el número de llamadas a la base de datos.
Si usa un O/RM, como Entity Framework, el seguimiento de las consultas SQL puede proporcionar información sobre cómo el O/RM traduce las llamadas mediante programación a instrucciones SQL e indicar áreas donde se puede optimizar el acceso a los datos.
Implementación de la solución y comprobación del resultado
Al volver a escribir la llamada a Entity Framework, se generaron los siguientes resultados.
Esta prueba de carga se realizó en la misma implementación, con el mismo perfil de carga. Esta vez, el gráfico muestra una latencia mucho menor. El tiempo medio de solicitud con 1000 usuarios está entre 5 y 6 segundos, reduciéndose de casi un minuto.
Esta vez el sistema admitía un promedio de 3970 solicitudes por minuto, en comparación con 410 para la prueba anterior.
El seguimiento de la instrucción SQL muestra que todos los datos se capturan en una sola instrucción SELECT. Aunque esta consulta es considerablemente más compleja, solo se realiza una vez por operación. Y aunque las combinaciones complejas pueden resultar costosas, los sistemas de bases de datos relacionales están optimizados para este tipo de consulta.