Правильное подключение к БД: почему, зачем и как

729mspovue3sh_l6iwct-hi0-zk.png

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

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

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

Выполнять код буду на машине с процессором 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. Простейшее подключение

a7iyhqw3dkqmmh-utybqzdgtumc.png

Начнём с базового примера, в котором просто будем делать новое подключение тогда, когда оно нам понадобится. В этом коде будем в 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

eyoo3pgzayol35afucy-prjdntu.png

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

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 с обработкой ошибок

mwomamxxobglmriquc5knitapua.png

Подобные неприятности происходят от того, что на подключение не поставлен 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, как и было задумано.


Заключение

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

© Habrahabr.ru