Django ORM, gevent и грабли в зелени

в 12:28, , рубрики: django, gevent, postgresql, python, Песочница, метки: , , ,

Очень многие выбирают Django за его простоту. Код на Django прост и лаконичен, мы меньше думаем о костылях и больше о бизнес-логике.

Gevent тоже выбирают из-за того, что он простой, очень шустрый и не несёт за собой callback hell.

В голове возникает великолепная идея совместить две простые и удобные вещи вместе. Мы патчим Django и радуемся простоте, лаконичности и производительности, делаем множество запросов на другие сайты, создаём подпроцессы, в общем используем наш новый асинхронный Django по максимуму.
Но совместив их, мы незаметно для себя поставили несколько граблей на нашем пути.

Django ORM и пул соединений БД

Django создавался как фреймворк для синхронных приложений. Каждый процесс получает запрос от пользователя, полностью его обрабатывает, отправляет результат и только после этого он может начать обрабатывать другой запрос. Принцип работы прост, как пареная репа. Великолепная архитектура для создания блога или новостного сайта, но мы хотим большего.

Возьмём простой пример, который имитирует бурную деятельность с HTTP-запросами. Пусть это будет сервис для сокращения ссылок:

# testproject/__init__.py
__import__('gevent.monkey').monkey.patch_all()


# testproject/main/models.py
from django.db import models

class LinkModel(models.Model):
    url = models.URLField(max_length=256, db_index=True, unique=True)


# testproject/main/views.py
import urllib2, httplib
from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseRedirect
from .models import LinkModel

def check_url(url):
    request = urllib2.Request(url)
    request.get_method = lambda: 'HEAD'
    try:
        response = urllib2.urlopen(request)
    except (urllib2.URLError, httplib.HTTPException):
        return False
    response.close()
    return True

def remember(request):
    url = request.GET['url']
    try:
        link = LinkModel.objects.get(url=url)
    except LinkModel.DoesNotExist:
        if not check_url(url):
            return HttpResponse('Oops :(')
        link = LinkModel.objects.create(url=url)
    return HttpResponse('http://localhost:8000' + reverse(
            go_to, args=(str(link.id).encode('base64').strip(), )))

def go_to(request, code):
    obj = LinkModel.objects.get(id=code.decode('base64'))
    return HttpResponseRedirect(obj.url)

Без gevent этот код работал бы невероятно медленно и с трудом обслуживал бы два-три одновременных запроса, но с gevent всё летает.

Запускаем наш проект через uwsgi (который де-факто стал стандартом при деплое Python-сайтов:

uwsgi --http-socket 0.0.0.0:8000 --gevent 1000 -M -p 2 -w testproject.wsgi

Пробуем тестировать десять запросов на сокращение ссылки одновременно и радуемся: все запросы отрабатывают без ошибок за минимальное время.

Запускаем наш новый сервис, сидим и смотрим на его успешное развитие. Нагрузка растёт с 10 до 75 одновременных запросов, и ему такая нагрузка нипочём.

Вдруг в одну из ночей на почту приходит несколько тысяч писем с таким содержанием:

Traceback:
   ...
   > link = LinkModel.objects.get(url=url)
OperationalError: FATAL:  remaining connection slots are reserved for non-replication superuser connections

И это хорошо, если вы поставили локаль en_US.UTF-8 в postgresql.conf, потому что если вы использовали конфигурацию Ubuntu/Debian по умолчанию, то вы получите тысячу писем с сообщением вида:

OperationalError: ?????:  ?????????? ????? ??????????? ??????????????? ??? ??????????? ????????????????? (?? ??? ??????????)

Приложение создавало слишком много соединений с базой данных (по умолчанию — максимум 100 соединений), за что и было наказано.

Вот и самый первый подводный камень: в Django нет пула соединений с базой данных, потому что в синхронном коде он попросту не нужен. Один синхронный процесс Django не может обрабатывать запросы параллельно, он в любой момент времени обслуживает только один запрос, и поэтому у него не возникает надобности в создании более чем одного соединения с базой данных.

На самом деле...

На самом деле Django может работать в многопоточном режиме, в котором один процесс сможет обрабатывать несколько запросов. Именно такой сервер запускает команда manage.py runserver, при этом в документации сказано, что этот режим совершенно не подходит для боевого применения.

Решение одно: нам срочно нужен пул соединений с БД.

Реализаций пула для Django сравнительно немного, например django-db-pool и django-psycopg2-pool. Первый пул основан на psycopg2.TreadedConnectionPool, который бросает исключение при попытке взять соединение из пустого пула. Приложение будет вести себя так же, как и раньше, но при этом другие приложения смогут создать соединение с БД. Второй пул основан на gevent.Queue: при попытке взять соединение из пустого пула гринлет будет заблокирован до тех пор, пока другой гринлет не положит соединение в пул.
Скорее всего вы выберете второе решение как более логичное.

Запросы к БД внутри гринлетов

Мы уже пропатчили приложение с помощью gevent и нам мало синхронных вызовов, так почему бы не выжать максимум из гринлетов? Мы можем параллельно делать несколько HTTP-запросов или создавать подпроцессы. Возможно, нам захочется использовать БД в гринлетах:

def some_view(request):
    greenlets = [gevent.spawn(handler, i) for i in xrange(5)]
    gevent.joinall(greenlets)
    return HttpResponse("Done")


def handler(number):
    obj = MyModel.objects.get(id=number)
    obj.response = send_http_request_somewhere(obj.request)
    obj.save(update_fields=['response'])

Прошло несколько часов, и вдруг наше приложение полностью перестало работать: на любой запрос получаем 504 Gateway Timeout. Что на этот раз случилось? За разъяснением придётся почитать немного кода Django.

Все соединения хранит в себе django.db.connections, который является экземпляром класса django.db.utils.ConnectionHandler. Когда ORM готов сделать запрос, он запрашивает соединение с БД, вызвав connections['default']. ConnectionHandler.__getattr__ в свою очередь проверяет наличие соединения в ConnectionHandler._connections, и если там пусто, то он создаёт новое соединение.

Все открытые соединения обязательно нужно закрыть после использования. Этим занимается сигнал request_finished, который запускается в django.http.HttpResponseBase.close. Django закрывает соединения с БД в самый последний момент, когда к ним уже точно никто не обратится, что вполне логично.

Вся загвоздка именно в том, как ConnectionHandler хранит соединения с БД. Для этого он использует threading.local, который после манкипатчинга превращается в gevent.local.local. Будучи объявленным однажды, эта структура данных работает так, будто она уникальна в каждом гринлете. Контроллер some_view начал обрабатываться в одном гринлете, и в ConnectionHandler._connections уже есть соединение с БД. Мы создали несколько новых гринлетов, в котором ConnectionHandlers._connections оказалась пустой, и для этих гринлетов были взяты ещё соединения из пула. После того, как наши новые гринлеты пропали, пропало и содержимое их local(), соединения с БД безвозвратно утеряны и никто не вернёт их обратно в пул. Со временем пул опустошается полностью.

При разработке на Django+gevent всегда нужно помнить этот нюанс и в конце каждого гринлета закрывать соединения с БД, вызвав django.db.close_connection. Вызывать его нужно в том числе и при возникновении исключительной ситуации, для чего можно использовать небольшой декоратор-contextmanager.

Пример такого декоратора

class autoclose(object):
    def __init__(self, f=None):
        self.f = f

    def __call__(self, *args, **kwargs):
        with self:
            return self.f(*args, **kwargs)

    def __enter__(self):
        pass

    def __exit__(self, exc_type, exc_info, tb):
        from django.db import close_connection
        close_connection()
        return exc_type is None

Использовать эту обёртку нужно разумно: закрывать все соединения перед каждым переключением гринлетов (например перед urllib2.urlopen), а также следить, чтобы соединения не закрывались внутри незавершённой транзакции или цикла по итератору наподобие Model.objects.all().

Используем Django ORM отдельно от Django

Мы можем постичь такие же проблемы, если мы создадим аналог cron или Celery, который будет время от времени делать запросы к БД. То же самое нас ждёт если поднять Django с помощью gevent.WSGIServer и параллельно поднять какие-либо сервисы с другим протоколом, которые будут использовать Django ORM. Главное вовремя возвращать соединения в пул БД, тогда приложение будет работать стабильно и приносить вам радости.

Выводы

В этом посте было сказано об элементарных правилах, что нужно обязательно использовать пул соединений БД и что нужно возвращать соединения обратно в пул сразу после использования. Вы бы точно учитывали это, если бы использовали только gevent и psycopg2. Но Django ORM оперирует настолько высокоуровневыми абстракциями, что разработчик не имеет дела с соединениями БД, и со временем эти правила могут быть забыты и открыты заново.

Автор: lvo

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js