Optimización de un monolito Django: De 30 segundos a 142 ms en informes
En un proyecto Django heredado, los informes de sucursal tardaban 30 segundos en cargar debido a un escaneo secuencial completo en la tabla de pedidos con 280k registros. EXPLAIN ANALYZE reveló:
Seq Scan on orders (cost=0.00..18420.00 rows=2841 width=847)
Filter: (branch_id = 42)
Rows Removed by Filter: 284100
Execution Time: 28340 ms
El ORM generaba más de 2800 consultas debido a N+1 en objetos relacionados. La lógica de negocio ubicada en controladores agravaba la carga.
Consulta original:
SELECT * FROM orders
JOIN order_items ON orders.id = order_items.order_id
JOIN products ON order_items.product_id = products.id
WHERE orders.branch_id = 42;
Eliminación de N+1
N+1 causaba un crecimiento exponencial de la carga. Antes:
orders = Order.objects.filter(branch_id=branch_id)
for order in orders:
items = order.order_items.all() # N+1
for item in items:
product = item.product # N+1
Después con prefetch:
orders = Order.objects.filter(
branch_id=branch_id
).select_related('customer').prefetch_related('order_items__product')
El recuento de consultas bajó a 3. select_related usa JOIN para ForeignKey, prefetch_related usa consultas IN para relaciones ManyToMany e inversas.
Indexación de tablas
Tras N+1, el escaneo secuencial persistía. Se añadieron índices sin tiempo de inactividad:
CREATE INDEX CONCURRENTLY idx_orders_branch_created ON orders(branch_id, created_at DESC);
CREATE INDEX CONCURRENTLY idx_products_search ON products USING GIN(to_tsvector('english', name));
Nuevo plan:
Index Scan using idx_orders_branch_created on orders
Index Cond: (branch_id = 42)
Execution Time: 142 ms
El tiempo de ejecución bajó de 28 segundos a 142 ms. CONCURRENTLY evita bloqueos de tabla en producción.
Recomendaciones clave de indexación:
- Compuesto en (filtro + ordenación)
- GIN para búsqueda de texto completo
- Siempre revisar EXPLAIN ANALYZE antes de añadir
Refactorización a DDD
La lógica de negocio en controladores creó deuda técnica. Se extrajeron Dominio y Casos de Uso independientemente del framework.
Ejemplo de Caso de Uso:
class GetBranchReportUseCase:
def __init__(self, repo: OrderRepository):
self._repo = repo
def execute(self, branch_id, period) -> BranchReport:
orders = self._repo.get_by_branch_and_period(branch_id, period)
return BranchReport.from_orders(orders)
La vista se volvió delgada:
class BranchReportView(APIView):
def get(self, request, branch_id):
use_case = GetBranchReportUseCase(DjangoOrderRepository())
report = use_case.execute(branch_id, DateRange.from_request(request))
return Response(BranchReportSerializer(report).data)
Los Casos de Uso se prueban con repositorios simulados sin Django y la base de datos. El tiempo de comercialización de nuevas funciones se redujo a la mitad.
Control de calidad del código
Se añadió:
- Mypy en modo estricto:
[mypy]
strict = true
disallow_untyped_defs = true
warn_return_any = true
- Pytest con cobertura ≥87%, bloqueando puerta de calidad en GitLab CI.
- MTTD disminuyó un 40% debido a detección temprana de errores.
Métricas de mejora
| Métrica | Antes | Después |
|--------|--------|-------|
| Tiempo de informe | 30 s | 1.5 s |
| CPU de BD | 80% | 32% |
| Consultas/Página | 2800+ | 3 |
| TTM de función | X | X/2 |
| MTTD | - | -40% |
Conclusiones clave
- EXPLAIN ANALYZE es el primer paso ante cualquier degradación de rendimiento
- N+1 mata bajo carga, prefetch_related/select_related son obligatorios
- Índices compuestos en (branch_id, created_at) dan una aceleración de 200x
- DDD aísla la lógica de negocio, acelerando desarrollo y pruebas
- Mypy + pytest + puerta CI reducen MTTD sin sobrecarga
— Editorial Team
Aún no hay comentarios.