Стратегии поддержания долгоживущих соединений с СУБД MySQL в Python и фреймворке Tornado

С точки зрения СУБД, соединение с клиентским приложением – ресурс, которым надо тщательно управлять для аккуратного расходования системных ресурсов. Практически все СУБД устанавливают таймауты неактивности соединения, после достижения которых данное соединени разрывается в одностороннем порядке. Обычно, приложения узнают о данном факте уже после того, как соединение разорвано. В случае с MySQL, клиент получает сообщение The MySQL server has gone away (error 2006). В статье мы рассмотрим подходы, которые позволяют приложениям c долгоживущими соединениям, поддерживать их столько, сколько требуется. Примеры будут приводиться для стандартного интерфейса подключения к СУБД MySQL – mysql.connector. Язык примеров – Python3, в качестве прикладного примера будет использоваться микросервис, реализованный на фреймворке Tornado.

Зачем держать соединение открытым

Итак, этот вопрос является важным, можно с него и начать. Каждое открытие и закрытие соединения занимает время и несет накладные расходы, как на стороне сервера так и на стороне клиента. Особенно заметно этот фактор начинает влиять, если сервер и клиент находятся в географически разнесенных сетях с существенной задержкой прохождения пакетов (RTT > 100 мс+).

К примеру, для одного из наших приложений, у которого сервер СУБД находится в РФ, а сервер приложения в США, время установления соединения составляет 4 секунды. Очевидно, что наивный подход с открытием соединения по требованию не представляется разумным для микросервиса, ответы от которого ожидаются максимально быстро по протоколу HTTP.

Наивный подход с таймаутами

Наивный подход, который заключается в установке таймаутов на стороне клиента и сервера может быть применен довольно редко, например, в случае корпоративной внутренней сети с безопасными приложениями и ограниченным количеством пользователей, как правило в двухуровневой архитектуре (2 tier architecture).

Другой пример данного подхода – приложения, которые просто редко обращаются к СУБД, что требует корректировки значения по-умолчанию, установленного для таймаута соединения.

За таймаут соединения отвечают три переменные, которые можно установить как глобальные переменные:

SET GLOBAL connect_timeout=3600
SET GLOBAL wait_timeout=3600
SET GLOBAL interactive_timeout=3600

или как настроечные переменные:

[mysqld]
connect_timeout=3600
wait_timeout=3600
interactive_timeout=3600

Значения этих переменных:

  • connection_timeout – максимальное время ожидания сервером пакета connect для вновь открытого соединения;
  • interactive_timeout – максимальное время ожидания сервером на интерактивном соединении;
  • wait_timeout – максимальное время ожидания сервером активности на соединении.

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

Вызов MySQL ping перед первым запросом и повторное соединение

Данный подход довольно универсален и должен применяться в большинстве случаев.


connection = None

def init_db():
    return = mysql.connector.connect(
        mysql_host=db_host, 
        mysql_user=db_user, 
        mysql_password=db_passwd, 
        mysql_db_name=db_dbase, 
        mysql_port=db_port)

def get_cursor():
    try:
        connection.ping(reconnect=True, attempts=3, delay=5)
    except mysql.connector.Error as err:
        # reconnect your cursor as you did in __init__ or wherever    
        connection = init_db()
    return connection.cursor()


connection = init_db()

...

# перед серией запросов
cursor = get_cursor()

# запросы

Периодический вызов ping (Tornado)

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

Необходимо заметить, что соединения с MySQL не потокобезопасны, поэтому нельзя непосредственно вызывать ping из дополнительного потока. Для решения этой проблемы мы реализуем дополнительный API (/keepalive), который будем периодически вызывать из дополнительного потока.

Сначала рассмотрим дополнительный поток. Реализация типовая:

import logging
import threading
import socket
import os

from time import sleep
from http.client import HTTPConnection


class Watchdog(threading.Thread):
    def __init__(self, ip, port, url, timeout, pause):
        threading.Thread.__init__(self)
        self.ip = ip
        self.port = port
        self.url = url
        self.timeout = timeout
        self.pause = pause

    def run(self):
        while True:
            logging.debug("Sleeping between KeepAlives for %d seconds" % self.pause)
            sleep(self.pause)
            try:
                logging.debug("Calling for KeepAlive [%s:%d, %s]" % (self.ip, self.port, self.url))

                host, port = self.ip, self.port
                ip, port = socket.getaddrinfo(host, port)[0][-1]

                conn = HTTPConnection(ip, port, timeout=self.timeout)
                conn.request('GET', self.url)
                resp = conn.getresponse()
                data = resp.read()
                logging.debug(data)
            except socket.timeout:
                logging.debug("Keepalive timeout happened. Stop whole app.")
                os._exit(1)

В реализации важно отметить только способ соединения с сервером через IP-адрес и порт. Только этот способ обеспечивает корректную обработку таймаута ожидания с генерацией исключения.

В Tornado реализуем обработчик для KeepAlive:

import logging

import tornado.web
from libs.commons import Commons

class KeepaliveHandler(tornado.web.RequestHandler):
    def get(self):
        logging.debug("Checking the state of connections")
        try:
            Commons.connection.ping(reconnect=True, attempts=3, delay=5)
        except mysql.connector.Error as err:
            Commons.connection = init_db()
        self.write('OK')

В теле сервиса запустим наш поток:


app = tornado.web.Application([
        ('/keepalive', KeepaliveHandler),
    ])

tornado.ioloop.IOLoop.current().start()

watchdog = Watchdog(ip='127.0.0.1', port=Commons.server_port, url='/keepalive', pause=60, timeout=60)
watchdog.start()

app.run(Commons.server_port)
watchdog.join()

Теперь, каждую минуту будет проверяться подключение к БД, а в случае разрыва выполняться переподключение и обеспечиваться высокая готовность соединения. В том случае, если поток watchdog не сможет дождаться активации соединения, он завершает весь сервис, чтобы внешняя система управления могла его заново запустить (например, docker run --restart=always).

Заключение

Мы рассмотрели три способа обработки таймаутов соединения клиента с СУБД MySQL, которые, будучи применены вместе позволяют приложениям поддерживать соединения с СУБД в течение неограниченного времени.

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

Если вам понравилась статья, поделитесь ей с друзьями.