Acelerar un enrutador en Django 51 veces / Sudo Null IT News

La historia comenzó con un análisis del uso de recursos por parte de una aplicación que realiza proxy. Descubrimos que se necesita bastante tiempo para elegir una ruta (ruta) y decidimos acelerar este proceso. La optimización descrita en el artículo no requiere inversiones, esfuerzos o condiciones especiales, por lo que el código proporcionado se puede llevar a casa y utilizar sin ninguna intervención excesiva.

Enrutador

Cada vez que llega otra solicitud a la aplicación, ésta selecciona la URL de la solicitud (y a veces el verbo HTTP), el enrutador, que describe las reglas (rutas), e intenta encontrar una adecuada. Existen dos mecanismos de este tipo:

  1. variedad de rutas;

  2. Árbol de prefijos compacto (árbol de base/trie) de rutas, que se utiliza en fasthttp (no lo confunda con fastapi) y axum. Tiene algunas restricciones, en particular en el uso de expresiones regulares y no tiene la capacidad de indicar explícitamente prioridades (qué ruta intentar resolver primero), por lo tanto no es un reemplazo directo y no es adecuado en nuestro caso.

En el 99% de los frameworks web se utiliza el primer tipo: una matriz simple donde se escriben rutas y que usted conoce muy bien:

urlpatterns = (
	...
    path("marketplaces/<int:company_id>/status", MarketplacesStatusView.as_view(), name="marketplaces_status"),
    path("marketplaces/<int:company_id>/reports", MarketplacesReportsView.as_view(), name="marketplaces_reports"),
    path("marketplaces/reports/<int:report_id>", MarketplacesReportView.as_view(), name="marketplaces_report"),
    ...

Las rutas se pueden anidar unas dentro de otras (en Django – incluir), pero su algoritmo operativo es siempre el mismo:

  1. recorra la matriz de arriba a abajo;

  2. comparar URL con ruta;

  3. si lo encuentra, regálelo (en caso de anidar, baje a un nivel inferior).

En Django, esto funciona de la manera menos óptima: se crea una expresión regular para cada ruta, y cada solicitud pasa por todas las expresiones regulares en el peor de los casos (si ninguna ruta coincide), intentando hacer coincidir cada una. En un proyecto grande puede haber cientos de rutas. Y aunque las expresiones regulares en Python están escritas en C, siguen siendo lentas.

En 2017, Django introdujo una nueva forma de declarar rutas: path (el método con caracteres regulares pasó a llamarse de URL a re_path). Sin embargo, bajo el capó, Django todavía compila path durante la temporada regular, por lo que esto no da ninguna aceleración.

¿Qué hay para acelerar?

Los números antes de la optimización son los siguientes: en el grupo de ventas, resolución de URL /api/v5/jsonrpc tomó 180 μs (0,18 ms) con las siguientes entradas:

Puede parecer que no hay nada que acelerar, pero si observas de cerca todas las rutas, se pueden dividir en cuatro categorías:

  1. re_path con expresiones regulares complejas;

  2. path("hello/world", ...)donde la ruta es una constante y no contiene ninguna variable (<int:user_id>);

  3. path("hello/world/<int:user_id>/", ...)donde la ruta contiene al menos una variable, pero hay una cadena constante antes de la primera variable;

  4. path("<path:url>", ...)donde la ruta contiene al menos una variable y no hay una línea constante antes de la primera.

Casi no hay nada que puedas hacer con la primera y la última categoría, y el resto contiene un prefijo constante muy importante. En cada caso podemos utilizarlo:

  • Si la ruta es una constante en su totalidad, entonces lo más lógico sería comparar la URL recibida con esta cadena con una simple igualdad == (dicha optimización está disponible en aiohttp);

  • Si la ruta contiene una variable, entonces puede recordar el prefijo de la primera variable y compararlo con lo que URL.startswith(prefix).

Sin embargo, si la ruta contiene variables, inevitablemente tendremos que usar expresiones regulares para extraer estas variables de la URL. Y puede parecer que uno match regularmente “más barato” que la comparación en startswithy luego match temporada regular. Y esto es cierto, pero sólo lo es si consideramos una ruta aislada de todas las demás. Si hay varias rutas, entonces exactamente una ruta coincidirá con la URL, en la que Django detendrá la búsqueda y la devolverá. El resto de rutas probablemente no coincidirán con el prefijo, lo que significa que no se realizará ninguna verificación periódica. Esta optimización acelera el rechazo de rutas entre 3 y 6 veces:

In (8): %timeit url == x 
30.5 ns ± 0.0404 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)

In (12): %timeit url.startswith(x) 
80.3 ns ± 0.0754 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)

In (13): %timeit p.match(url)  # p=re.compile("hello/world/(?P<company_id>\d+)") 
196 ns ± 0.276 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

El código para registrar una nueva ruta, teniendo en cuenta el hecho de que las rutas en Django se pueden anidar, resultó así:

from collections.abc import Awaitable, Callable, Sequence
from typing import Any

from django.http import HttpResponseBase
from django.urls.conf import _path  # type: ignore(attr-defined)
from django.urls.resolvers import RoutePattern, URLPattern, URLResolver


class PrefixRoutePattern(RoutePattern):
    def __init__(self, route: str, name: str | None = None, is_endpoint: bool = False) -> None:
        # Ищем расстояние до первой переменной
        idx = route.find("<")

        # Если не нашли, то весь паттерн — константная строка и её можно
        # сравнивать с URL'ом на равенство целиком
        if idx == -1:
            self._prefix = route
            self._is_static = True
        # Если нашли, запоминаем префикс до первой переменной
        else:
            self._is_static = False
            self._prefix = route(:idx)
        # Роут может быть неоконечным, то есть, паттерн сам по себе является префиксом. Например, в случае
        # `path("users/", include(...))`
        self._is_endpoint = is_endpoint
        super().__init__(route, name, is_endpoint)

    def match(self, path: str) -> tuple(str, tuple(Any, ...), dict(str, Any)) | None:
        # Если паттерн — константная строка (в нём нет переменных), то:
        if self._is_static:
            # Если роут оконечный, то сравниваем на равенство строки
            if self._is_endpoint and path == self._prefix:
                # match отдаёт кортеж из трёх значений:
                # 1. Остаток URL'а
                # 2. Неименованные переменные
                # 3. Именованные переменные
                # Так как наш роут оконечный и не содержит переменных, то все значения пусты
                return "", (), {}
            # Если же роут содержит саброуты, то проверяется, что URL начинается с префикса
            elif not self._is_endpoint and path.startswith(self._prefix):
                return path(len(self._prefix) :), (), {}
        # Если в паттерне есть хоть одна переменная, то проверяется,
        # что URL начинается с префикса и если это так, матчинг передаётся дальше (в регулярку)
        else:
            if path.startswith(self._prefix):
                return super().match(path)
        return None


def make_pattern(route: str, name: str | None = None, is_endpoint: bool = False) -> PrefixRoutePattern | RoutePattern:
    # При регистрации роута проверяется, содержит ли паттерн переменные
    # и насколько первая переменная далеко от начала.
    # Если первая переменная очень близко к началу строки,
    # то префикс получится пустой или короткий, в котором не будет смысла,
    # поэтому используется стандартный RoutePattern
    idx = route.find("<")
    if idx == -1 or idx > 2:
        return PrefixRoutePattern(route, name, is_endpoint)
    else:
        return RoutePattern(route, name, is_endpoint)


def my_path(
    route: str,
    view: (
        Callable(..., HttpResponseBase | Awaitable(HttpResponseBase))
        | tuple(Sequence(URLResolver | URLPattern), str | None, str | None)
    ),
    kwargs: dict(str, Any) | None = None,
    name: str | None = None,
) -> URLResolver | URLPattern:
    return _path(route=route, view=view, kwargs=kwargs, name=name, Pattern=make_pattern)

En esta etapa, la resolución empezó a ser de 100 µs, 1,7 veces menos.

La siguiente optimización fue la trivial reorganización de las rutas calientes que no se superponen hacia arriba. Para nosotros, estos resultaron ser controladores jsonrpc.

Por ejemplo, dichas rutas se pueden intercambiar, ya que sus rangos de valores aceptables no se cruzan:

urls = (
    my_path("v3/jsonrpc", private_json_rpc_api.jsonrpc),
    my_path("v5/jsonrpc", private_json_rpc_api_v5.jsonrpc),
)

También puedes tener estos (int acepta solo números):

urls = (
    my_path("users/<int:user_id>", user_handler),
    my_path("users/me", me_handler),
)

Pero estos ya no se pueden cambiar:

urls = (
    my_path("users/me", me_handler),
    my_path("users/<str:user_id>", user_handler),
)

Después de plantear rutas “calientes”, la resolución comenzó a producirse en 12,4 µs. Esto es 0,0124 ms, lo que da una aceleración de 14,5 veces.

La última optimización fue adjuntar un caché LRU que almacena datos de uso frecuente en URLResolver usando un terrible parche manky:

from functools import lru_cache


def patch_resolver() -> None:
    from django.urls.resolvers import URLResolver

    orig_resolve = URLResolver.resolve
    # Кэш размером 16 позволяет кэшировать 16 самых часто используемых роутов и имеет смысл
    # только если часто используемые роуты не имеют динамических частей (<int:something> или регулярок)
    cached = lru_cache(maxsize=16)(orig_resolve)

    def dispatcher(self: URLResolver, path: str | Any) -> ResolverMatch:
        if isinstance(path, str):
            return cached(self, path)
        return orig_resolve(self, path)

    URLResolver.resolve = dispatcher

patch_resolver()

Es poco probable que este caché ayude a rutas con variables, pero funciona muy bien en rutas constantes, por ejemplo, controladores jsonrpc.

Después de esta resolución /api/v5/jsonrpc comenzó a suceder en 3,5 µs y obtenemos una aceleración final de 51 veces.

Línea de fondo

Utilizando un método astuto y shareware, aceleramos el flujo de cada solicitud en más de 150 microsegundos. Formalmente, esta es una cifra insignificante, pero es pura carga de CPU, y por cada 10.000 solicitudes se ahorra 1,5 segundos de tiempo de procesador, lo que supone diez eternidades para un ordenador. Es algo pequeño, pero bonito.

Algunos consejos sobre cómo usarlo

  1. Copiar código PrefixRoutePattern a cualquier lugar. Y reemplazar todo path en my_path. Son totalmente compatibles y reemplazables.

  2. Copie el código de parche del enrutador (patch_resolver) V. settings/__init__.py y llámalo allí.

  3. Eleve las rutas más calientes, sin olvidar los patrones de superposición.

  4. Reemplazar re_path en my_pathsi es posible, deshacerse de los personajes habituales.

  5. Reemplace los grupos de captura triviales (variables) en rutas con texto sin formato. Por ejemplo, /api/v<int:version>/jsonrpc/ Tiene sentido dividirlo en varias rutas separadas: /api/v1/jsonrpc/, /api/v2/jsonrpc/ etc.

  6. Ver una consulta en la base de datos durante 10 segundos y darse cuenta de que todo fue en vano.

  7. Llorar.

Publicaciones Similares

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *