Статьи

Это выглядело как хорошая идея в то время: «start_request» PyMongo

Дорога

Дорога в ад вымощена благими намерениями.

Я расскажу вам историю о четырех прискорбных решениях, которые мы приняли, когда разрабатывали PyMongo, стандартный драйвер Python для MongoDB. Каждое из этих решений приводило к многим годам боли для сопровождающих PyMongo, Берни Хакетта и меня, и к многим путаницам для наших пользователей. Этой зимой я вырываю эти прискорбные проекты в рамках подготовки к PyMongo 3.0. Когда я их удаляю, я даю каждому горькую небольшую речь.

Сегодня я расскажу историю первого прискорбного решения: «запросы».


Начало

Все началось, когда MongoDB, Inc. был крошечным стартапом под названием 10gen. В самом начале Элиот Хоровиц и Дуайт Мерриман создавали платформу для хостинга приложений, немного похожую на Google App Engine, но с Javascript в качестве языка программирования и JSON-подобной базой данных для хранения. Клиенты не будут использовать базу данных напрямую. Это будет выставлено через чистый API.

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

> db.collection.insert({_id: 1})
> db.runCommand({getlasterror: 1})  // It worked.
{
    "ok" : 1,
    "err" : null
}
> db.collection.insert({_id: 1})
> db.runCommand({getlasterror: 1})
{
    "ok" : 1,
    "err" : "E11000 duplicate key error"
}

Необработанный протокол был аккуратно упакован за API, который обрабатывал сообщения об ошибках для вас. ( Элиот описывает историю протокола более подробно здесь. )

Когда 10gen вырос, мы поняли, что платформа приложений не взлетит. Настоящим продуктом был слой базы данных MongoDB. 10gen решил бросить платформу приложений и сосредоточиться на базе данных. Мы начали писать драйверы на нескольких языках, включая Python. Это было рождение PyMongo. Майк Дирольф начал писать его в январе 2009 года.

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

>>> # Obsolete code, don't use this!
>>> from pymongo import Connection
>>> c = Connection()
>>> collection = c.db.collection
>>> collection.insert({'_id': 1})
>>> collection.insert({'_id': 1})

Неподтвержденные записи не заботятся о задержке в сети, поэтому они могут насытить пропускную способность сети:

Неподтвержденная запись

С другой стороны, если вам нужны подтвержденные записи, вы можете спрашивать после каждой операции, была ли она успешной:

>>> # Also obsolete code. "safe" means "acknowledged".
>>> collection.insert({'_id': 1}, safe=True)
>>> collection.insert({'_id': 1}, safe=True)

Но вы бы заплатили за задержку:

Получить последнюю ошибку

Мы думали, что этот дизайн был великолепен! Вы, пользователь, можете выбирать, ждать ли подтверждения или «выстрелить и забыть». Мы приняли наше первое прискорбное решение: мы установили по умолчанию «выстрелить и забыть».

Изобретение start_request

Существует ряд проблем с настройкой по умолчанию, неподтвержденной. Очевидным является то, что вы не знаете, были ли успешными ваши записи. Но есть более тонкая проблема, проблема с последовательностью. После неподтвержденной записи вы не всегда можете сразу прочитать то, что написали. Скажем, у вас было два потока Python, выполняющих две функции и выполняющих неподтвержденные записи:

c = Connection()
collection_one = c.db.collection_one
collection_two = c.db.collection_two

def function_one():
    for i in range(100):
        collection_one.insert({'fieldname': i})

    print collection_one.count()

def function_two():
    for i in range(100):
        collection_two.insert({'fieldname': i})

    print collection_two.count()

threading.Thread(target=function_one).start()
threading.Thread(target=function_two).start()

Поскольку два потока выполняют параллельные операции, PyMongo открывает два сокета. Иногда один поток завершает отправку документов в сокет, проверяет сокет в пуле соединений и проверяет другой сокет из пула для выполнения «подсчета». Если это произойдет, сервер может не завершить чтение последних вставок из первого сокета, прежде чем он ответит на запрос «count» на другом сокете. Таким образом, количество составляет менее 100:

Неподтвержденные вкладыши

Если драйвер действительно подтвердил запись по умолчанию, он дождется подтверждения сервером вставок, прежде чем выполнит «подсчет», поэтому проблем с согласованностью нет.

Но значение по умолчанию было непризнанным, поэтому пользователи получали результаты, которые их удивляли. В январе 2009 года первоначальный автор PyMongo Майк Дирольф исправил эту проблему. Он написал пул соединений, который просто выделил сокет для потока. Пока поток всегда использует один и тот же сокет, не имеет значения, подтверждены или нет его записи:

Неподтвержденные вставки одного гнезда

Сервер не читает запрос «count» из сокета, пока не обработает все вставки, поэтому счетчик всегда корректен. (Предполагается, что вставки выполнены успешно.) Проблема решена!

Всякий раз, когда новый поток начинал общаться с MongoDB, PyMongo открывал для него новый сокет. Когда поток умер, его сокет был закрыт. Решение Майка было простым и делало то, что ожидали пользователи. И так началось пятилетнее путешествие PyMongo по дороге в ад.


Я не хочу, чтобы вы меня неправильно поняли: то, что делал Майк, в то время казалось хорошей идеей. Компания решила, что для всех драйверов MongoDB значение по умолчанию является неподтвержденным, но Майк по-прежнему хотел гарантировать согласованность чтения-записи-записи, если это возможно. Кроме того, драйвер Java уже связывал сокеты с потоками, поэтому Майк хотел, чтобы драйвер Python действовал аналогично.

Я могу представить Майка, сидящего за одним из столов в оригинальном офисе 10gen. Тогда на 10ген работало всего полдюжины человек или меньше. Это было задолго до моего времени. У них был угол офиса, в котором жили Джилт, ShopWiki и Panther Express, в старом здании из серого камня на 20-й улице в Манхэттене, рядом с библиотекой. В тот день было бы очень холодно, может быть, снег. Я вижу, как Майк сидит рядом с Элиотом, Дуайтом и их крошечной компанией. Он выбил драйвер Python для MongoDB, принимая одно быстрое решение за другим. Знал ли он, что устанавливает курс, который нельзя было исправить в течение пяти лет? Возможно нет.


Поэтому Майк решил, что PyMongo зарезервирует сокет для каждого потока. Но что, если поток говорит с MongoDB, а затем идет и делает что-то еще в течение длительного времени? PyMongo резервирует сокет для потока, который никто другой не может использовать. Поэтому в феврале Майк добавил метод end_request, чтобы позволить потоку освободить свой сокет. Он также добавил опцию «auto_start_request». Он был включен по умолчанию, но вы можете отключить его, если он вам не нужен. Если вы выполняете только подтвержденные записи или не сразу читаете свои собственные записи, вы можете отключить auto_start_request и получить более эффективный пул соединений.

В следующем году, в январе 2010 года, Майк упростил пул. В его новом коде «auto_start_request» больше нельзя было отключить. В его сообщении о фиксации утверждалось, что он сделал PyMongo «в 2 раза быстрее для простых тестов». Он написал,

Вызов Connection.end_request позволяет сокету возвращаться в пул и использоваться другими потоками вместо создания нового сокета. Разумное использование этого метода важно для приложений с большим количеством потоков или с длинными запущенными потоками, которые мало обращаются к PyMongo.

Берни Хакетт принял PyMongo через год после этого, и поскольку auto_start_request больше ничего не делал, Берни полностью удалил его в апреле 2011 года.

Подсказка о «разумном использовании end_request» была в документации PyMongo с прошлого года, но Берни подозревал, что пользователи не следуют инструкциям. Так же, как большинство людей не перерабатывают свои пластиковые бутылки, большинство разработчиков не называли end_request, так что хорошие розетки были потрачены впустую. Хуже того, поскольку потоки оставляли свои сокеты открытыми и зарезервированными в течение всего срока службы каждого потока, было обычным явлением развертывание приложения Python с тысячами и тысячами открытых соединений с MongoDB, даже если только несколько соединений выполняли какую-либо работу.

Поэтому, когда я пришел на работу к Берни в ноябре этого года, он поручил мне улучшить пул соединений PyMongo двумя способами. Во-первых, PyMongo должен снова позволить вам отключить «auto_start_request». Во-вторых, если поток умер, не вызвав end_request, PyMongo должен каким-то образом обнаружить, что поток умер, и вернуть свой сокет для пула, а не закрывать его.

Сделать «auto_start_request» необязательным снова было легко. Если вы выключили его, каждый поток просто возвращал свой сокет обратно в пул, когда не использовал его. Когда следующему потоку понадобился сокет, он извлек один, возможно, другой. Мы рекомендовали пользователям PyMongo делать «безопасные» записи (то есть подтвержденные записи) и отключать «auto_start_request». Это привело к улучшению отчетов об ошибках и гораздо более эффективному пулу соединений: выбор нормальный, но, увы, не значения по умолчанию. Мы не могли изменить значения по умолчанию, потому что мы должны были быть обратно совместимыми с прискорбными решениями, принятыми годами ранее.

Таким образом, восстановление «auto_start_request» было несложным делом. Однако обнаружить, что нить умерла, было адом.

Дорога в ад

Я хотел исправить PyMongo, чтобы он мог «восстанавливать» сокеты. Если для потока был зарезервирован сокет, и он забыл вызвать end_request до его смерти, PyMongo не должен просто закрывать сокет. Он должен проверить сокет обратно в пул соединений для использования в будущем. Моим первым решением было обернуть каждый сокет в объекте __del__методом:

class SocketInfo(object):
    def __init__(self, pool, sock):
        self.pool = pool
        self.sock = sock

    def __del__(self):
        self.pool.return_socket(self.sock)

Кусок пирога. Мы выпустили этот код в мае 2012 года, и он стал намного более эффективным. Тогда как предыдущая версия пула PyMongo часто закрывала и открывала сокеты:

PyMongo 2.1

PyMongo 2.2 исправил сокеты мертвых потоков для новых потоков, которым они нужны:

PyMongo 2.2

Я гордился своими достижениями. Тогда весь ад вырвался на свободу.

Худшая ошибка

Сразу после того, как мы выпустили мой код «восстановления сокетов» в PyMongo, пользователь сообщил, что в Python 2.6 и mod_wsgi 2.8 и с включенным «auto_start_request» (по умолчанию) его приложение пропускало соединение один раз каждые два запроса! Как только он пропустил несколько тысяч соединений, у него кончились файловые дескрипторы и он потерпел крах. Мне потребовалось 18 дней отчаянной отладки с Дэном Кростой на моей стороне, прежде чем я понял все это. Оказывается, в реализации Python для локальных потоков есть примерно три ошибки, которые были исправлены, когда Антуан Питру переписал локальные потоки для Python 2.7.1. Об одном из них сообщили, а о двух других никогда не было.

Я обнаружил незарегистрированную ошибку в функции C в интерпретаторе Python, который управляет локальными потоками. Получив доступ к локальному потоку из __del__метода, я вызвал рекурсивный вызов функции, для которой он не был предназначен. Это вызвало refleak каждый второй раз это произошло, оставляя открытые сокеты, никогда не может быть сборщиком мусора.

Эта ошибка в устаревшей версии Python, в свою очередь, взаимодействовала с устаревшей версией mod_wsgi, которая очищала состояние каждого потока Python после каждого HTTP-запроса. Таким образом, любой на Python 2.7 или mod_wsgi 3.x, или оба, не смог бы исправить ошибку. Но древние версии Python и mod_wsgi широко используются.

Я записал свой диагноз ошибки , повторно реализовал свой код восстановления сокета, чтобы избежать рекурсивного вызова, и выпустил исправление. Я написал разочарованную статью о том, как странны локальные потоки Python , и в начале следующего года я написал описание своего обходного пути .

На сегодняшний день ошибка моя худшая. Это одно из худших воздействий, конечно, его было сложнее диагностировать, и оно остается самым сложным для объяснения.

Этот последний пункт — ошибку трудно объяснить — имеет реальные издержки. Всем, кроме меня, очень трудно поддерживать пул соединений PyMongo. Любой, кто прикоснется к нему, рискует воссоздать ошибку. Конечно, мы проверяем на ошибку после каждого коммита: мы загружаем PyMongo с Apache и mod_wsgi на наш сервер Jenkins, чтобы предотвратить регресс этой ошибки. Но ни один сторонний участник вряд ли пойдет на такие усилия и не поймет, почему это необходимо.

Фабрика ошибок

Через год, в апреле 2013 года, я обнаружил еще одну утечку соединения . В отличие от ошибки 2012 года, эта утечка была редкой и трудно воспроизвести. Я не думаю, что кто-то пострадал от этого. К настоящему времени я был намного лучшим диагностом, и я слишком хорошо знал соответствующую часть CPython. У меня ушло меньше дня, чтобы определить, что в Python 2.6 присвоение локальному потоку не является потокобезопасным . Я добавил блокировку вокруг назначения и выпустил еще одно исправление для «start_request» в PyMongo.

За всю свою карьеру в MongoDB я регулярно находил и исправлял ошибки в «start_request». В 2012 году я обнаружил, что если один поток вызывает «start_request», другие потоки могут иногда ошибочно думать, что они тоже находятся в запросе . И когда реплика устанавливает первичные шаги вниз, все потоки в запросах выдают исключение перед повторным подключением .

В 2013 году участник Джастин Патрин попытался добавить функцию в наш пул соединений, но то, что должно было быть простым патчем, было загрязнено колючей проволокой в ​​»start_request». В его коде, если поток в запросе получил сетевую ошибку, он пропустил семафор . И только в прошлом месяце мне пришлось исправить небольшую ошибку в пуле соединений, связанную с «start_request» и mod_wsgi.

Привлекательная неприятность

В start_request есть еще одна вещь, которая почти так же плоха, как и ее сложность: его имя. Это привлекательная неприятность. Звучит так: «Мне нужно позвонить до того, как я начну запрос к MongoDB». Я часто вижу разработчиков, которые плохо знакомы с PyMongo, пишут такой код:

# Don't do this.
c.start_request()
doc = c.db.collection.find_one()
c.end_request()

Это абсолютно бессмысленно, трата усилий программиста и машины. Но название настолько расплывчато, а объяснение настолько сложное, что вы простите себя за мысль, что именно так вы должны использовать PyMongo.

Теперь я спрашиваю вас, какое решение было самым прискорбным? Была ли утилизация сокетов плохой функцией? Должны ли мы позволить PyMongo продолжать закрывать сокеты потоков, когда потоки умирают, вместо создания устройства Rube Goldberg для проверки этих сокетов обратно в пул? Или, может быть, худшая идея пришла много лет назад, когда Майк включил «auto_start_request» по умолчанию — возможно, все было бы хорошо, если бы он потребовал, чтобы пользователи вызывали «start_request» явно вместо этого. Может быть, он вообще не должен был реализовывать start_request. Скорее всего, основной причиной было решение, которое мы приняли до того, как Майк даже начал писать PyMongo: решение сделать неподтвержденным пишет значение по умолчанию.

Выкуп

MongoClient

В конце 2012 года, когда я был среди всех этих ошибок «start_request», у Элиота была идея, которая перевернула нас и показала нам путь из ада. Он придумал способ искупить наш первородный грех, грех заставить неподтвержденных записать дефолт. Видите, мы давно рекомендовали пользователям переопределить значения по умолчанию PyMongo, например:

>>> # Obsolete.
>>> c = Connection(safe=True, auto_start_request=False)

… но мы не могли сделать это новым значением по умолчанию, потому что это нарушило бы обратную совместимость. Элиот решил, что все водители должны ввести новый класс с правильными настройками по умолчанию. Скотт Эрнандес придумал хорошее имя для класса, которое еще ни один водитель не использовал: «MongoClient».

>>> # Modern code.
>>> c = MongoClient()

Пока мы работали над этим, мы осудили старые термины «безопасный / небезопасный» и ввели новую терминологию «написать письмо» . Пользователи могут выбрать новый класс, но мы не будем нарушать существующий код. Орфей сделал первый шаг своего пути домой из Аида.

Написать команды

В MongoDB 2.6, выпущенной этой весной, мы начали отменять даже более старое решение: старый протокол, который отправляет модификацию MongoDB, затем вызывает «getLastError», чтобы выяснить, удалось ли это. Новый протокол записи команд всегда ожидает ответа от сервера. Кроме того, это позволяет нам собирать сотни модификаций в одной команде и получать пакет ответов обратно. Изменение было прозрачным для пользователей, но мы вышли за рамки нашего первоначального протокола. Вам больше не нужно решать, хотите ли вы получать неподтвержденные записи с низкой задержкой или подтвержденные записи и платить за задержку. Теперь вы можете объединить свои операции, сделать подтвержденные записи и получить лучшее из обоих миров.

Sharding

Последний гвоздь — изменение шардинга MongoDB. Раньше считалось, что до тех пор, пока поток использовал одно и то же соединение с mongos для вторичного чтения, mongos продолжал использовать один и тот же вторичный объект в наборе реплик каждого шарда. Это должно было предотвратить «путешествие во времени»: если один вторичный объект в сегменте отстает, а другой нет, мы не хотели, чтобы поток вашего клиента считывал один раз из захваченного вторичного объекта, а затем один раз из лагового вторичного устройства, получая более раннее представление ваших данных.

Но этот дизайн сделал пул соединений Mongos гораздо менее эффективным. И мы не можем гарантировать идеальную монотонность, когда вы читаете из вторичного в любом случае. В MongoDB 2.6 мы изменили это поведение, чтобы mongos уравновешивал чтение каждого клиентского соединения между всеми вторичными серверами. Таким образом, последняя веская причина для клиентского потока всегда использовать одно и то же соединение устарела. Пришло время «start_request».

Удаление start_request

Этим утром я удалил «start_request» из кода PyMongo в ветке, которая станет PyMongo 3.0. Изменение удаляет около 300 строк. Самый веселый и самый рискованный Python, который я когда-либо писал, исчез. Код пула соединений снова выглядит нормальным. Еще раз, участник может отправить патчи для него, не открывая банку с червями. Программисты, начинающие с PyMongo, не будут соблазнены привлекательной неприятностью «start_request». И мое время не будет занимать случайные, срочные ошибки в пуле соединений PyMongo. Разрушение моей собственной работы еще никогда не было таким приятным, таким освобождающим.

Посмертный

Лампы дороги в ад плохо обозначены. Как мы можем узнать их в следующий раз?

Один принцип: не пытайтесь дать пользователям то, что они не могут иметь. Вы не можете объединить согласованность чтения-записи-записи с неподтвержденными записями. Наши попытки дать вам обе вещи были героическими, но глупыми. Мы думали, что будем щедры к вам, поддерживая очень сложный код, но, как говорит Zen of Python ,

Простое лучше, чем сложное.
Если реализацию сложно объяснить, это плохая идея.

Забавно писать сложный, трудный для объяснения код. Сейчас, конечно, гораздо веселее написать грубый код, чем задуматься о будущем и подождать, пока вы подумали о простом проекте, который выдержит испытание временем. Но в случае «start_request», лучший дизайн был там.

Здесь снова дзен Python поучителен. Он советует нам подождать, пока у нас не получится довольно хороший ответ, прежде чем мы начнем кодировать:

Сейчас лучше, чем никогда.
Хотя никогда зачастую лучше , чем прямо в настоящее время.

Но даже при том, что мы приняли прискорбное решение, мы в конечном итоге выправились. Новый протокол — команды записи — дает нам высокую пропускную способность и подтвержденные записи без нарушения обратной совместимости. И теперь, когда у нас есть новый протокол, мы можем удалить «start_request» в PyMongo 3.0. Дорога домой из ада окончена.


Оставайтесь с нами для следующего выпуска в статье «Это казалось хорошей идеей в то время»: PyMongo и Gevent.