mookie@dev:~/posts$ cat ef-core-any-vs-count.md
Any o Count: en tres de cuatro casos da igual, en el cuarto no
Una pregunta de sí o no no necesita contar, pero la diferencia real depende de los datos y del plan
«¿Hay algún pedido pendiente?» Hay una forma de escribirlo que se lee bien y funciona:
var hasPending = await db.Orders
.CountAsync(o => o.Status == "Pending") > 0;Y otra que dice lo que quieres saber:
var hasPending = await db.Orders
.AnyAsync(o => o.Status == "Pending");La primera cuenta todas las filas y después las compara con cero. La segunda pregunta si existe alguna.
El SQL de cada una
CountAsync se traduce a un conteo:
SELECT COUNT(*)
FROM "Orders" AS "o"
WHERE "o"."Status" = 'Pending'AnyAsync genera un EXISTS:
SELECT EXISTS (
SELECT 1
FROM "Orders" AS "o"
WHERE "o"."Status" = 'Pending')Con COUNT(*), la base de datos tiene que recorrer todas las filas que cumplen el filtro, aunque a ti te daría igual el resultado a partir de la primera. Con EXISTS puede parar en cuanto encuentra una.
Lo que midió la demo
Probé cuatro casos en la demo de la serie (ef-core-pills), con SQLite y la mediana de varias ejecuciones:
| Caso | Count() > 0 |
AnyAsync() |
|---|---|---|
20.000 pedidos, Status = 'Pending' |
menos de 1 ms | menos de 1 ms |
| Mismos pedidos, cliente 7 y estado (con índice) | menos de 1 ms | menos de 1 ms |
Mismos pedidos, Total > 5000 (nadie cumple) |
3 ms | 3 ms |
1.000.000 de envíos, Carrier = 'Carrier 3', sin índice |
40 ms | menos de 1 ms |
En tres de los cuatro casos no hay diferencia. Con 20.000 filas el conteo es instantáneo, con índice las dos consultas lo aprovechan, y cuando nadie cumple el filtro las dos tienen que mirar todas las filas, porque EXISTS también tiene que comprobar que no hay ninguna.
La diferencia aparece en el último caso: tabla grande, filtro sin índice y miles de filas que cumplen. Ahí COUNT(*) recorre el millón de filas y EXISTS se detiene en la primera coincidencia.
Entonces, ¿por qué usar Any?
Por tres motivos, y solo el tercero es de rendimiento:
- Dice lo que quieres.
Anyexpresa «¿existe?»;Count() > 0obliga a quien lee a deducir que solo te importa si el resultado es mayor que cero. - Es el caso en el que no te hace falta pensar. Con
Anyno dependes de que hoy la tabla sea pequeña o de que tu índice cubra el filtro. - Puede parar en la primera fila. Cuando hay muchas coincidencias y el filtro no usa un índice, es mucho más barato.
Otros motores pueden tener optimizaciones distintas para COUNT(*) > 0. No lo asumas: mira el plan de ejecución de tu base de datos (EXPLAIN en PostgreSQL y SQLite, el plan de ejecución real en SQL Server) para la consulta que te importa. Es lo que recomienda la documentación de EF Core para cualquier duda de rendimiento: encuentra la consulta lenta y examina su plan.
Cuándo sí quieres Count
Cuando necesitas el número. «Tiene 14 pedidos pendientes» es una pregunta distinta, y entonces CountAsync es lo correcto. Y si lo que quieres es mostrar el total en una página con paginación, ten presente que contar también cuesta, y que quizá no necesites mostrar el total exacto.
También existe All, que contesta «¿cumplen todos?» y se traduce a NOT EXISTS. Los tres operadores (Any, All, Count) responden preguntas diferentes, y elegir el que corresponde a la pregunta suele dar mejor SQL.
Cierre de la serie: la lista de comprobación
Las ocho píldoras comparten un hábito, que es mirar el SQL y contar comandos y filas. Antes de dar por buena una consulta de EF Core:
- ¿Cuántos comandos ejecuta por petición? (N+1)
- ¿Necesita seguimiento de cambios? (
AsNoTracking) - ¿Pide solo las columnas que usa? (
Select) - ¿Tiene límite o paginación con orden único? (
Take, keyset) - ¿El filtro está antes del materializador? (
Whereantes deToList) - ¿Carga más de una colección? (
AsSplitQuery, proyección) - ¿Guarda dentro de un bucle? (
SaveChangesúnico,ExecuteUpdate) - ¿Cuenta cuando solo necesita saber si existe? (
Any)
Ninguna se arregla de memoria: se ven en el log. El código con el que he sacado cada número está en ef-core-pills.