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


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


Статья рассчитана на начинающих и всех, кто интересуется этой темой.


Выполнять код буду на машине с процессором Intel E5 2650 v2 и 64 Гб оперативной памяти. На другом железе результаты будут другими.


В качестве примера для подключения будем использовать Redis — он хранит данные в оперативной памяти и благодаря этому имеет колоссальную производительность, даже на сервере начального уровня от 100 тысяч get/set запросов в секунду, подробнее можно почитать здесь.


Для корректной работы нам будут нужны:



Репозиторий с исходным кодом на GitHub.


Redis развернём в Docker-контейнере без каких-либо настроек, то есть из коробки.


docker-compose.yml:


version: '3.7'

services:
    redis:
        image: redis:latest
        container_name: redis
        restart: unless-stopped
        ports:
            - "6379:6379"

Шаг 1. Простейшее подключение



Начнём с базового примера, в котором просто будем делать новое подключение тогда, когда оно нам понадобится. В этом коде будем в 16 потоков писать и читать в наш Redis, заодно замерим время. Посмотрим, будет ли всё работать как надо.


simple_get_set.py:


# simple connect
import time
from multiprocessing import Pool
from redis.client import Redis

REQUESTS_COUNT = 20000

def test(i):
    client = Redis(host='0.0.0.0', port=6379)
    client.set(i, i)
    assert client.get(i) != i, 'wrong save'

start_time = time.time()

with Pool(16) as p:
    p.map(test, range(REQUESTS_COUNT))

sec = time.time() - start_time
print(f'time {sec:0.2f} seconds')

На выходе получаем: time 3.56 seconds, всё отработало быстро, без ошибок. Если посмотреть в redis-cli, то можно убедиться с помощью команды DBSIZE, что записалось ровно 20 тыс. ключей. Действительно, вроде всё отработало как надо, быстро и надёжно. Тут можно задать вопрос, а что будет, если увеличить количество запросов? Чтобы это проверить, очистим Redis командой FLUSHALL в redis-cli, поменяем значение переменной REQUESTS_COUNT на 100000 и запустим скрипт опять.


Мы, конечно, ожидаем, что всё отработает как надо, однако не тут-то было. В результате получаем на выходе: redis.exceptions.ConnectionError: Error while reading from 0.0.0.0:6379 : (104, 'Connection reset by peer'). Хост просто не может обработать такое количество новых подключений и закрывает их с ошибкой со своей стороны. Количество ключей, которые скрипт успел сохранить — 28224 штуки. Уже не так всё круто, как было. После того, как скрипт вылетел с ошибкой, Redis висел около одной минуты (!), то есть я даже не смог сразу выполнить в redis-cli команду DBSIZE, чтобы понять, сколько удалось записать.


Именно так и получилось, как я говорил: вроде всё работает, не о чем волноваться. Ну что париться, ведь всё сохраняется быстро и надёжно, ровно до тех пор, пока проект не масштабируется, вот тогда и начинаются проблемы.


Шаг 2. Подключение через Singleton



Что ж, раз такое дело, попробуем доработать наш скрипт.


singleton_get_set.py:


# simple singleton connect
import time
from multiprocessing import Pool
from redis.client import Redis

REQUESTS_COUNT = 100000

class Singleton:
    _instance = None
    @staticmethod
    def get_connection():
        if not Singleton._instance:
            Singleton._instance = Redis(host='0.0.0.0', port=6379)
        return Singleton._instance

def test(i):
    client = Singleton.get_connection()
    client.set(i, i)
    assert client.get(i) != i, 'wrong save'

start_time = time.time()

with Pool(16) as p:
    p.map(test, range(REQUESTS_COUNT))

sec = time.time() - start_time
print(f'time {sec:0.2f} seconds')

Паттерн Singleton, в сущности, просто использует одно подключение, то есть можно было бы написать, например, так:


client = Redis(host='0.0.0.0', port=6379)
def test(i):
    client.set(i, i)
    assert client.get(i) != i, 'wrong save'

Эффект был бы таким же, однако в реальном проекте так не получится сделать, нужно будет использовать класс, это ясно. Вышеприведённый код просто иллюстрирует саму идею.


Как всегда, очистим Redis и запустим наш скрипт сразу на 100 тыс. запросов. Заодно и проверим, так ли быстро всё отработает, как было написано выше — про колоссальную производительность даже на сервере начального уровня. На выходе получаем time 4.75 seconds, меньше пяти секунд на 100 тыс. запросов. Впечатляет. Также проверим, что у нас всё правильно записалось, и действительно, в redis-cli лежит ровно 100 тыс. ключей. Неплохо, но можно лучше.


Дальше речь уже идёт не про скорость работы, а про надёжность исполнения. Предположим, работает наше приложение. Работает себе и работает, никому не мешает. Но, как это обычно бывает, неожиданно на стороне Redis происходит какая-нибудь ошибка. Вот вдруг так случилось, что же будет в этом случае? Проверим. Для этого я запущу алгоритм и вместе с этим поставлю на паузу Redis Docker-контейнер с помощью команды docker-compose pause.


Что же мы получим на выходе? А ничего мы не получим, наши потоки просто зависли (!). Должно быть, самая ненавистная ошибка, когда у тебя приложение работает в несколько десятков потоков и один из них подвис. Из-за этого весь проект висит, и поди разбери, что там не работает.


Шаг 3. Подключение через Singleton с обработкой ошибок



Подобные неприятности происходят от того, что на подключение не поставлен timeout. То есть соединение будет вечно висеть и ждать ответа от сервера. Решить эту проблему можно, просто поставив нужный timeout на подключение. Тогда, если в течение указанного времени не будет ответа, приложение завершится с ошибкой. Это лучше, чем оно будет бесконечно висеть.


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


singleton_handle_errors_get_set.py:


# singleton with handle errors connect
import time
from multiprocessing import Pool
from redis.client import Redis
from redis.backoff import ExponentialBackoff
from redis.retry import Retry
from redis.exceptions import (
    BusyLoadingError,
    ConnectionError,
    TimeoutError
)

REQUESTS_COUNT = 100000
RETRY = 3
TIMEOUT = 2

class Singleton:
    _instance = None
    @staticmethod
    def get_connection():
        if not Singleton._instance:
            Singleton._instance = Redis(
                host='0.0.0.0', 
                port=6379, 
                socket_timeout=TIMEOUT,
                retry=Retry(ExponentialBackoff(), RETRY), 
                retry_on_error=[BusyLoadingError, ConnectionError, TimeoutError]
            )
        return Singleton._instance

def test(i):
    client = Singleton.get_connection()
    client.set(i, i)
    assert client.get(i) != i, 'wrong save'

start_time = time.time()

with Pool(16) as p:
    p.map(test, range(REQUESTS_COUNT))

sec = time.time() - start_time
print(f'time {sec:0.2f} seconds')

Не буду вдаваться в подробности настройки, всё же это руководство не про настройку Redis, подробнее почитайте здесь. Вкратце можно описать:


  • socket_timeout — время в секундах до повторного запроса, если нет ответа;
  • ExponentialBackoff — алгоритм задержки при отправке повторных запросов, чтобы случайно не завалить сервер, если отправить их всем скопом, подробнее здесь;
  • BusyLoadingError, ConnectionError, TimeoutError — ошибки, при которых будет повторный запрос.

И так повторяем ещё раз, запускаем скрипт и ставим во время его работы Redis Docker-контейнер на паузу. На этот раз вместо того, чтобы зависнуть, скрипт упал с ошибкой: redis.exceptions.TimeoutError: Timeout reading from socket, как и было задумано.


Заключение


Как было показано, даже самый простой способ подключения способен работать на простом, ненагруженном приложении без ошибок и проблем. Однако не стоит специально ограничивать скорость и надёжность работы. При повышении числа запросов, скорее всего, начнутся — вернее даже будет сказать, что точно будут возникать — ошибки, которые придётся искать и исправлять. Уж лучше сразу написать нормально, тем более, что это не очень-то и сложно.

Комментарии (13)


  1. KrasPvP
    25.05.2023 04:58
    +2

    Спасибо за статью. Интересно и заставляет задуматься, как мы проектируем наши приложения.
    Хочу еще добавить, что полезно изучать тулзы и экосистему используемых БД. Например, у PostgreSQL есть Pgbouncer, который будет держать пулл коннектов и т.п.
    А также, в крупных компаниях делают иногда учебные аварийные отключения сервисов (скопом), баз данных и т.п., что тоже помогает найти уйму мест, где сервисы ведут себя не по задуманному образу.


  1. TT13
    25.05.2023 04:58
    +2

    В первом скрипте у вас REQUESTS_COUNT = 200000, а в описании 20000


    1. DimaFromMai Автор
      25.05.2023 04:58

      Опечатка, сейчас поправлю, спасибо.


  1. PrinceKorwin
    25.05.2023 04:58
    +4

    А почему не рассмотрели вариант с Connection Pool? При чем оба: 1) pool внутренний и 2) pool внешний


    1. DimaFromMai Автор
      25.05.2023 04:58

      Не стал сильно углубляться, хотел скорее на базовом сделать акцент, но идея хорошая, возможно в будущем напишу и про это.


      1. PrinceKorwin
        25.05.2023 04:58
        +8

        Вот это и странно. Самый популярный и правильный для большинства случаев сценарий и не рассмотрели :)


        1. el_kex
          25.05.2023 04:58
          +1

          Поддержу. Ведь тот же тест через Singleton может быть нерелевантен, если приложение запускается в несколько инстансов. И тогда пул коннектов, который можно шарить между процессами, становится прямо таки важным аспектом реализации.


          1. DimaFromMai Автор
            25.05.2023 04:58
            +1

            Да, верно подмечено, в общем это тема для следующей статьи. Хотя, если я правильно вас понял коннектов будет столько, сколько запущено инстансов, т.е. не такое уж большое число. Хотя да, тест по факту не будет релевантным если шарить пул коннектов, выигрыш в таком случае будет какой?


            1. el_kex
              25.05.2023 04:58
              +1

              В целом, ответ на вопрос есть в комментарии @acordell

              Если запросов не очень большое количество, то и выигрыш призрачен. Но вот если говорить о большом объёме инстансов, то уйма ресурсов будет уходить на инициализацию соединения. То есть, мы не запускаем, скажем 100 инстансов, в каждом из которых пилим N потоков через Singleton - ситуация тут, когда, скажем, через fastCGI в Python летит 100*N запросов на получение данных.


  1. acordell
    25.05.2023 04:58
    +3

    Спасибо, но как-то все закончилось на самом интересном месте. Есть куча клиентов к базам, которые нифига не потокобезопасные. Более того, их как раз абсолютное большинство, на сколько мне известно. И вот так вот просто взять и разделить конекшен уже не выйдет. При этом в рамках одного потока тоже будет не совсем культурно поднимать соединение на каждый чих. Потому есть потребность в пуле, который рулит соединениями более мудро. Делит их по потокам и прикрывает, когда уже не нужны и проч.


    1. DimaFromMai Автор
      25.05.2023 04:58

      Хорошее замечание, спасибо. Клиент redis-py действительно по дефолту использует пул коннектов и является потокобезопасным из коробки.


  1. DimaFromMai Автор
    25.05.2023 04:58

    Результаты тестирования скорости на 20000 get/set запросов:


    • Простой (идёт в тексте первым): time 3.56 seconds
    • Singleton (второй и третий варианты): time 0.97 seconds

    Ну, тут даже пояснять нечего.


  1. 0Bannon
    25.05.2023 04:58
    +1

    Ещё можно не создавать класс метод get_connection, а проверить синглтон в __new__ .