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

Недавно я столкнулся с одной довольно редкой, но серьезной проблемой, которая может сильно испортить игровой опыт от вашего сервера, которую я опишу здесь и которая, естественно, связана с базами данных.
Могу заранее развеять ваши опасения: скорее всего, вы с этой проблемой не столкнётесь, если, конечно, не жонглируете дешевыми базами данных.
Здесь не будет рассматриваться блокирующее API, это отдельная тема, но не подверженная проблеме поста.
Для тех, кто имеет какой-никакой опыт в работе с базой данных, предлагаю перейти сразу к "2. Что здесь не так?".


1. Простенький плагин для работы с базой данных

SourceMod предоставляет довольно простой API для работы с базой данных, который чаще всего используется как-то так:
C-подобный:
Database g_hDB = null;

public void OnPluginStart()
{
    Database.Connect(Database_OnConnected, "database");
    // или SQL_TConnect(Database_OnConnected, "database");
}

public void Database_OnConnected(Database hDB, const char[] szError, any data)
{
    if(!hDB)
    {
        SetFailState("Could not connect to database: %s", szError);
    }

    g_hDB = hDB;
}
Мы пытаемся подключиться к базе и, если подключение не удалось, объявляем о своей неработоспособности.
Такое практически в каждом плагине, вы это используете, я это использую, R1KO это использует.

После подключения может происходить какая-либо логика, она может быть довольно простой, например, хранение времени последнего захода и имени игрока в базе:
C-подобный:
public void Database_OnConnected(Database hDB, const char[] szError, any data)
{
    if(!hDB)
    {
        SetFailState("Could not connect to database: %s", szError);
    }

    g_hDB = hDB;

    char szSteamID[32];
    for(int i = 1; i <= MaxClients; ++i)
    {
        if(IsClientConnected(i) && IsClientAuthorized(i))
        {
            GetClientAuthId(i, AuthId_Steam2, szSteamID, sizeof szSteamID);
            OnClientAuthorized(iClient, szSteamID);
        }
    }
}

// НЕ OnClientPostAdminCheck, НЕ OnClientPutInServer, НЕТ!
public void OnClientAuthorized(int iClient, const char[] szSteamID)
{
    if(!g_hDB || IsFakeClient(iClient))    return;

    char szName[64];
    char szQuery[256];

    // Отбросим поддержку SQLite и MySQL ниже версии 8.0
    // Также предположим, что таблица с соответствующими колонками уже существует
    g_hDB.Format(szQuery, sizeof szQuery, "REPLACE INTO players (steamid, name, last_connection) VALUES ('%s', '%s', CURRENT_TIMESTAMP())", szSteamID, szName);
    g_hDB.Query(Database_OnClientInserted, szQuery);
}

public void Database_OnClientInserted(Database hDB, DBResultSet hResult, const char[] szError, any data)
{
    if(!hResult)
    {
        // Произошла ошибка, мы должны об этом сообщить
        LogError("Database_OnClientInserted: %s", szError);
    }
}
В данном примере мы храним в базе актуальную информацию об игроках, а именно: имя по SteamID и время последнего подключения на сервер.
Этот пример довольно простой, в большинстве случаев работает и кажется довольно безобидным, ведь здесь, вроде бы, нечему ломаться. Мы все пишем подобное.

2. Что здесь не так?

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

Это база данных shared хостинга, который использовал мой знакомый до сегодняшнего дня.
Хостинг заблокировал IP адрес сервера "в целях безопасности", потому что последний попытался подключится целых 12 раз подряд (12 серверов, как никак).
Собственно, это послужило причиной написания этого поста.

В случае потери базы данных, SourceMod вернёт ошибку драйвера "Lost connection" и заботливо будет пытаться восстановить соединение при каждом следующем запросе к этой базе данных. Каждый такой запрос, если до базы достучаться не удается, будет возвращаться ошибкой подключения:
1666464446789.png

3. Можно ли на это реагировать?

Да. Как уже говорилось, драйвер mysql при потере соединения возвращает ошибку "Lost connection", далее SourceMod будет пытаться восстановить подключение и возвращать уже ошибку подключения, если таковая есть.
Можно отказаться от заботы SourceMod'а и самостоятельно реагировать на подобные события, но это может потребовать кардинальных изменений в логике плагина:
C-подобный:
enum LoadState
{
    LoadState_Unloaded = 0, // не загружен, требуется отправить запрос в базу
    LoadState_Waiting,        // запрос отправлен, ожидается ответ
    LoadState_Loaded        // игрок загружен, действия не требуются
}

// Введём состояние "загруженности" игрока
LoadState g_iClientLoadState[MAXPLAYERS + 1] = { LoadState_Unloaded, ... };

public void OnClientDisconnect(int iClient)
{
    g_iClientLoadState[iClient] = LoadState_Unloaded;
}

public void OnClientAuthorized(int iClient, const char[] szSteamID)
{
    ...
    // Теперь мы передаём UserID клиента, чтобы в будущем пометить его как "загруженного"
    g_hDB.Query(Database_OnClientInserted, szQuery, GetClientUserId(iClient));
    // Также помечаем, что в данный момент запрос за этого клиента уже отправлен и ожидается ответ
    g_iClientLoadState[iClient] = LoadState_Waiting;
}

public void Database_OnConnected(Database hDB, const char[] szError, any data)
{
    ...
    for(int i = 1; i <= MaxClients; ++i)
    {
        // Добавим проверку на LoadState_Unloaded
        if(IsClientConnected(i) && IsClientAuthorized(i) && g_iClientLoadState[i] == LoadState_Unloaded)
        {
            GetClientAuthId(i, AuthId_Steam2, szSteamID, sizeof szSteamID);
            OnClientAuthorized(iClient, szSteamID);
        }
    }
}

public void Database_OnClientInserted(Database hDB, DBResultSet hResult, const char[] szError, any data)
{
    int iClient = GetClientOfUserId(data);
    bool bConnectionLost = false;

    if(!hResult)
    {
        if(StrContains(szError, "Lost connection") != -1)
        {
            bConnectionLost = true;
            // Помечаем, что соединения с базой данных на данный момент нет
            g_hDB = null;

            // Через время вновь пытаемся подключиться к базе данных
            CreateTimer(30.0, Timer_ReconnectToDatabase);
        }
        else if(StrContains(szError, "Can't connect to MySQL server on") != -1)    bConnectionLost = true;
        else LogError("Database_OnClientInserted: %s", szError);
    }

    if(iClient)
    {
        if(!hResult)
        {
            // Помечаем игрока как незагруженного
            g_iClientLoadState[iClient] = LoadState_Unloaded;
     
            // Пытаемся переотправить запрос, если ошибка была вызвана потерей подключения и оно к этому моменту уже восстановлено
            if(bConnectionLost)
            {
                GetClientAuthId(i, AuthId_Steam2, szSteamID, sizeof szSteamID);
                OnClientAuthorized(iClient, szSteamID);
            }
        }
        else g_iClientLoadState[iClient] = LoadState_Loaded;
    }
}

public Action Timer_ReconnectToDatabase(Handle timer)
{
    Database.Connect(Database_OnConnected, "database");
}
Готово. Теперь, при потери соединения, плагин не будет отправлять запросы. Каждый игрок имеет свое значение "загруженности", которое отражает всего 3 возможных состояния:
1. Не загружен, то есть, игрок на сервере, но запрос не отправлен.
2. Не загружен, но запрос отправлен, мы ждём ответ.
3. Загружен. Действий не требуется.
При этом, при восстановлении соединения, запросы не будут отправляться для тех, чьи предыдущие запросы еще не были обработаны, благодаря чему одного и того же игрока не обработаем дважды.
Но стоит отметить, что мы здесь программируем, основываясь на деталях реализации, то есть, данный код может сломаться при изменении работы SourceMod'а с базами данных.

Код усложнился. Напомним, что мы делаем довольно простую вещь: храним в базе имя и время подключения игрока. Чтобы описать задачу плагина, мне понадобилось 8 слов на русском... сотня строк кода.
Для такого простого плагина слишком дорого расписывать такую огромную логику. Может, вызывать SetFailState при потере соединения? При смене карты SourceMod бережно поднимет нас:
C-подобный:
public void Database_OnClientInserted(Database hDB, DBResultSet hResult, const char[] szError, any data)
{
    if(!hResult)
    {
        if(StrContains(szError, "Lost connection") != -1)
        {
            SetFailState( ... );
        }
        LogError("Database_OnClientInserted: %s", szError);
    }
}
Здесь мы избавились от необходимости усложнять код, который выполняет всё то же самое: работает с базой, не работает без базы, восстанавливает соединение при потере, просто с небольшой задержкой.
Забегая вперёд, стоит отметить, что это крайне плохая идея. А почему - расскажу позже.

4. Можем ли мы сказать, что написали правильный код для работы с базой данных?

И да и нет. На самом деле, всё несколько сложнее. По крайней мере, мы написали код, который должен быть правильным или крайне близким к правильному.
Я описал процесс работы с базой со стороны разработчика плагина. Однако, насколько бы мы не были белыми и пушистыми, SourceMod не без греха, и наш код, на самом деле, всё еще подвержен проблеме.
Я не зря затронул именно неблокирующий API, ведь он должен предоставлять функции для работы с базой, которые не блокируют главный поток сервера, благодаря чему сервер не зависает. Однако есть кое-что, что может зависнуть.
Пора нам взглянуть на то, что происходит при потере соединения, как администратор, а не как разработчик.

Что происходит в нашей наивной версии плагина, которая не учитывает потерю соединения с базой?
Наш плагин отправляет запрос на каждого игрока при подключении. При смене карты все игроки "выходят" и "заходят" обратно, вызывая OnClientAuthorized заново... идеальный момент, чтобы отправить запрос в базу.
Каждый запрос производит попытку подключения. Т.к. API неблокирующее, наши плагины, на самом деле, создают "задачи" для работы с базой. И этих задач может быть много.
Каждая попытка подключения длится 30 секунд (значение по умолчанию). У нас появляется слишком много долгих задач.

Всё бы ладно, если бы эти задачи выполнялись параллельно или хотя бы не мешали запросам других баз данных или других плагинов.... звучит, будто так и должно быть? А вот и нет.
SourceMod для наших неблокирующих запросов использует очередь для выполнения задач. Для выполнения блокирующих задач. Для выполнения в одном потоке. Это и есть тот самый грех SourceMod.
Проще говоря, запросы выполняются последовательно и друг за другом, никакого параллельного выполнения. А значит... во время тех самых 30 секунд попытки подключения к базе ни один плагин не сможет получить доступ к любой базе данных, даже SQLite, которая, вроде бы, тут ни причем, мы ведь к MySQL подключаемся. Немного поднять глаза на текст выше и уже оказывается, что, запрос такой не один, и на N плагинов, отправляющий запрос на каждого игрока при подключении, при смене карты, при M игроков онлайн, очередь запросов блокируется на 30*M*N секунд.
Звучит не очень, ведь идёт речь далеко не о 5 или 10 минутах блокировки, а это может крайне негативно сказаться на серверах с WCS или RPG модами (которые могут быть подключены вообще к другой базе или к SQLite), ведь геймплей практически полностью зависит от данных в базе, как и смысл игры.

Что же происходит в нашей "продвинутой" версии плагина?
Проблема также может всплыть, но единожды и гораздо реже. Ведь при потере соединения мы просто не отправляем запросы, а значит, ситуация, когда мы генерируем много долгих запросов, произойдёт лишь тогда, когда база данных упадёт перед сменой карты, а это далеко не 100% времени работы сервера. Но мы всё еще, при попытке переподключиться, блокируем очередь задач, блокируя любой доступ к базам данных на полминуты. Это решается небольшим изменением в файле databases.cfg:
C-подобный:
"database"
{
    ...
    "timeout"    "8"
}
Теперь мы блокируем очередь не на 30 секунд, а на 8. Этого должно быть достаточно, чтобы успеть соединиться с базой данных и не так сильно ударить по работоспособности сервера при невозможности подключения. Но всё-равно сопровождается блокировкой очереди запросов.

Блокировки... костыли... а что же с последним вариантом? Мы просто выключаем плагин при потере соединения, так что всё должно быть хорошо!
А вот и нет: SourceMod, при выгрузке плагина, начинает выполнять все запросы плагина в главном потоке, блокируя сервер, из-за чего сервер убивает сервер (себя)

Заключение

Рассмотрев все беды, происходящие во время внезапных падений базы данных, стоит отметить, что для игрового сервера крайне важно иметь стабильное подключение к стабильной базе данных.
Ведь невозможно переписать все имеющиеся плагины, чтобы учесть данную "фичу" SourceMod'а, да и дорого делать настолько "правильный" код. Гораздо проще потратить лишние 2-5 сотен рублей на качественную базу данных.

Ну и совсем коротко:
Не роняйте и не теряйте базу. Хорошая база данных, может быть, даже дорогая, сэкономит вам ресурсов. Ждём фикса SourceMod'а.