Доброго времени суток, уважаемые посетители CSDevs 
Сегодня речь пойдёт о довольно редкой, но серьезной проблеме, которая может сильно испортить игровой опыт от Вашего сервера (проекта). Данная проблема связана с базами данных. Заранее развеем все опасения. Если Вы не пользуетесь дешёвыми базами данных, то скорее всего Вы с этой проблемой не столкнётесь. Итак, приступим...
Простой плагин-пример для работы с БД:
Sourcemod предоставляет довольно простой API для взаимодействия с базой данных, который чаще всего используют как указано далее:
Мы выполняем попытку подключения к базе данных, если что-то не получается, мы объявляем о своей неработоспособности. Так делают если не все, то очень и очень многие. После успешного подключения к базе мы можем выполнять различные операции, например хранить время последнего входа и никнейм игрока:
Что же здесь не так?
В данном случае мы не учитываем то, что соединение может быть разорвано по разным причинам. Вы скажите, что оно может восстановиться и это будет правдой, но что, если оно не восстановится? На самом деле это довольно редкий случай, но всё в этой жизни возможно...
В случае потери подключения к базе данных SourceMod вернёт ошибку драйвера "Lost connection" и заботливо будет пытаться восстановить соединение при каждом следующем запросе к этой базе данных. Каждый такой запрос, если до базы достучаться не удается, будет возвращаться ошибкой подключения:
Стоит ли на это реагировать?
Однозначно, да. Как уже говорилось, драйвер mysql при потере соединения возвращает ошибку "Lost connection", далее SourceMod будет пытаться восстановить подключение и возвращать уже ошибку подключения, если таковая есть.
Можно отказаться от заботы SourceMod'а и самостоятельно реагировать на подобные события, но это может потребовать кардинальных изменений в логике плагина:
Теперь, при потери соединения, плагин не будет отправлять запросы. Каждый игрок имеет свое значение "загруженности", которое отражает всего 3 возможных состояния:
Но стоит отметить, что мы здесь программируем, основываясь на деталях реализации, то есть, данный код может сломаться при изменении работы SourceMod'а с базами данных.
Код усложнился, не спорим. Напомним, что мы делаем довольно простую вещь: храним в базе имя и время подключения игрока. Чтобы описать задачу плагина, мне понадобилось 8 слов на русском и сотня строк кода. Для такого простого плагина слишком дорого расписывать такую огромную логику. Может, вызывать SetFailState при потере соединения? При смене карты SourceMod бережно поднимет нас:
Здесь мы избавились от необходимости усложнять код, который выполняет всё то же самое: работает с базой, не работает без базы, восстанавливает соединение при потере, просто с небольшой задержкой. Забегая наперёд, стоит отметить, что это крайне плохая идея, идём далее...
Можем ли мы сказать, что написали правильный код для работы с БД?
И да и нет. На самом деле, всё несколько сложнее. По крайней мере, мы написали код, который должен быть правильным или крайне близким к правильному.
Я описал процесс работы с базой со стороны разработчика плагина. Однако, насколько бы мы не были белыми и пушистыми, SourceMod не без греха, и наш код, на самом деле, всё еще подвержен проблеме. Мы не зря затронули именно неблокирующий API, ведь он должен предоставлять функции для работы с базой, которые не блокируют главный поток сервера, благодаря чему сервер не зависает. Однако есть кое-что, что может зависнуть. Давайте взглянем, что происходит при потере соединения от лица администратора сервера (проекта).
Что происходит в нашей наивной версии плагина, которая не учитывает потерю соединения с базой?
Наш плагин отправляет запрос на каждого игрока при подключении. При смене карты все игроки "выходят" и "заходят" обратно, вызывая
Всё бы ладно, если бы эти задачи выполнялись параллельно или хотя бы не мешали запросам других баз данных или других плагинов. Звучит, будто так и должно быть, а вот и нет.
SourceMod для наших неблокирующих запросов использует очередь для выполнения задач. Для выполнения блокирующих задач. Для выполнения в одном потоке. Это и есть тот самый грех SourceMod.
Проще говоря, запросы выполняются последовательно и друг за другом, никакого параллельного выполнения. А значит, что во время тех самых 30 секунд попытки подключения к базе ни один плагин не сможет получить доступ к любой базе данных, даже SQLite, которая, вроде бы, тут ни причем, мы ведь к MySQL подключаемся. Немного поднять глаза на текст выше и уже оказывается, что, запрос такой не один, и на N плагинов, отправляющий запрос на каждого игрока при подключении, при смене карты, при M игроков онлайн, очередь запросов блокируется на 30*M*N секунд.
Звучит не очень, ведь идёт речь далеко не о 5 или 10 минутах блокировки, а это может крайне негативно сказаться на серверах с WCS или RPG модами (которые могут быть подключены вообще к другой базе или к SQLite), ведь геймплей практически полностью зависит от данных в базе, как и смысл игры.
Что же происходит в нашей "продвинутой" версии плагина?
Проблема также может всплыть, но единожды и гораздо реже. Ведь при потере соединения мы просто не отправляем запросы, а значит, ситуация, когда мы генерируем много долгих запросов, произойдёт лишь тогда, когда база данных упадёт перед сменой карты, а это далеко не 100% времени работы сервера. Но мы всё еще, при попытке переподключиться, блокируем очередь задач, блокируя любой доступ к базам данных на полминуты. Это решается небольшим изменением в файле databases.cfg:
Теперь мы блокируем очередь не на 30 секунд, а на 8. Этого должно быть достаточно, чтобы успеть соединиться с базой данных и не так сильно ударить по работоспособности сервера при невозможности подключения. Но всё-равно сопровождается блокировкой очереди запросов.
Заключение (выводы)
Рассмотрев все беды, происходящие во время внезапных падений базы данных, стоит отметить, что для игрового сервера крайне важно иметь стабильное подключение к стабильной базе данных. Ведь невозможно переписать все имеющиеся плагины, чтобы учесть данный "грех" SourceMod'а, да и дорого делать настолько "правильный" код. Гораздо проще потратить лишние несколько зеленых на качественную базу данных.
Не роняйте и не теряйте базу. Хорошая база данных, может быть, даже дорогая, сэкономит Вам ресурсов.
Сегодня речь пойдёт о довольно редкой, но серьезной проблеме, которая может сильно испортить игровой опыт от Вашего сервера (проекта). Данная проблема связана с базами данных. Заранее развеем все опасения. Если Вы не пользуетесь дешёвыми базами данных, то скорее всего Вы с этой проблемой не столкнётесь. Итак, приступим...
Простой плагин-пример для работы с БД:
Sourcemod предоставляет довольно простой API для взаимодействия с базой данных, который чаще всего используют как указано далее:
C-like:
Database g_hDatabase;
public void OnPluginStart()
{
// SQL_TConnect(OnDatabaseConnected, "database");
Database.Connect(OnDatabaseConnected, "database");
}
public void OnDatabaseConnected(Database hDatabase, const char[] szError, any data)
{
if(!hDatabase)
{
SetFailState("Could not connect to database: %s", szError);
}
g_hDatabase = hDatabase;
}
Мы выполняем попытку подключения к базе данных, если что-то не получается, мы объявляем о своей неработоспособности. Так делают если не все, то очень и очень многие. После успешного подключения к базе мы можем выполнять различные операции, например хранить время последнего входа и никнейм игрока:
C-like:
public void OnDatabaseConnected(Database hDatabase, const char[] szError, any data)
{
if(!hDatabase)
{
SetFailState("Could not connect to database: %s", szError);
}
g_hDatabase = hDatabase;
char szAuth[32];
for(int i = 1; i <= MaxClients; ++i)
{
if(IsClientConnected(i) && IsClientAuthorized(i))
{
GetClientAuthId(i, AuthId_Steam2, szAuth, sizeof szAuth);
OnClientAuthorized(iClient, szAuth);
}
}
}
// НЕ OnClientPostAdminCheck, НЕ OnClientPutInServer, НЕТ!
public void OnClientAuthorized(int iClient, const char[] szAuth)
{
if(!g_hDatabase || IsFakeClient(iClient)) return;
char szName[64];
char szQuery[256];
// Отбросим поддержку SQLite и MySQL ниже версии 8.0
// Также предположим, что таблица с соответствующими колонками уже существует
g_hDatabase.Format(szQuery, sizeof szQuery, "REPLACE INTO players (`steamid`, `name`, `last_connection`) VALUES ('%s', '%s', CURRENT_TIMESTAMP())", szAuth, szName);
g_hDatabase.Query(Database_OnClientInserted, szQuery);
}
public void Database_OnClientInserted(Database hDatabase, DBResultSet hResult, const char[] szError, any data)
{
if(!hResult)
{
// Произошла ошибка, мы должны об этом сообщить
LogError("Database_OnClientInserted: %s", szError);
}
}
Что же здесь не так?
В данном случае мы не учитываем то, что соединение может быть разорвано по разным причинам. Вы скажите, что оно может восстановиться и это будет правдой, но что, если оно не восстановится? На самом деле это довольно редкий случай, но всё в этой жизни возможно...
Стоит ли на это реагировать?
Однозначно, да. Как уже говорилось, драйвер mysql при потере соединения возвращает ошибку "Lost connection", далее SourceMod будет пытаться восстановить подключение и возвращать уже ошибку подключения, если таковая есть.
Можно отказаться от заботы SourceMod'а и самостоятельно реагировать на подобные события, но это может потребовать кардинальных изменений в логике плагина:
C-like:
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_hDatabase.Query(Database_OnClientInserted, szQuery, GetClientUserId(iClient));
// Также помечаем, что в данный момент запрос за этого клиента уже отправлен и ожидается ответ
g_iClientLoadState[iClient] = LoadState_Waiting;
}
public void Database_OnConnected(Database hDatabase, const char[] szError, any data)
{
char szAuth[32];
for(int i = 1; i <= MaxClients; ++i)
{
// Добавим проверку на LoadState_Unloaded
if(IsClientConnected(i) && IsClientAuthorized(i) && g_iClientLoadState[i] == LoadState_Unloaded)
{
GetClientAuthId(i, AuthId_Steam2, szAuth, sizeof szAuth);
OnClientAuthorized(iClient, szAuth);
}
}
}
public void Database_OnClientInserted(Database hDatabase, 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_hDatabase = 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, szAuth, sizeof szAuth);
OnClientAuthorized(iClient, szAuth);
}
}
else g_iClientLoadState[iClient] = LoadState_Loaded;
}
}
public Action Timer_ReconnectToDatabase(Handle timer)
{
Database.Connect(OnDatabaseConnected, "database");
}
Теперь, при потери соединения, плагин не будет отправлять запросы. Каждый игрок имеет свое значение "загруженности", которое отражает всего 3 возможных состояния:
- Не загружен, то есть, игрок на сервере, но запрос не отправлен.
- Не загружен, но запрос отправлен, мы ждём ответ.
- Загружен. Действий не требуется.
Но стоит отметить, что мы здесь программируем, основываясь на деталях реализации, то есть, данный код может сломаться при изменении работы SourceMod'а с базами данных.
Код усложнился, не спорим. Напомним, что мы делаем довольно простую вещь: храним в базе имя и время подключения игрока. Чтобы описать задачу плагина, мне понадобилось 8 слов на русском и сотня строк кода. Для такого простого плагина слишком дорого расписывать такую огромную логику. Может, вызывать SetFailState при потере соединения? При смене карты SourceMod бережно поднимет нас:
C-like:
public void Database_OnClientInserted(Database hDatabase, DBResultSet hResult, const char[] szError, any data)
{
if(!hResult)
{
if(StrContains(szError, "Lost connection") != -1)
{
SetFailState( ... );
}
LogError("Database_OnClientInserted: %s", szError);
}
}
Здесь мы избавились от необходимости усложнять код, который выполняет всё то же самое: работает с базой, не работает без базы, восстанавливает соединение при потере, просто с небольшой задержкой. Забегая наперёд, стоит отметить, что это крайне плохая идея, идём далее...
Можем ли мы сказать, что написали правильный код для работы с БД?
И да и нет. На самом деле, всё несколько сложнее. По крайней мере, мы написали код, который должен быть правильным или крайне близким к правильному.
Я описал процесс работы с базой со стороны разработчика плагина. Однако, насколько бы мы не были белыми и пушистыми, SourceMod не без греха, и наш код, на самом деле, всё еще подвержен проблеме. Мы не зря затронули именно неблокирующий API, ведь он должен предоставлять функции для работы с базой, которые не блокируют главный поток сервера, благодаря чему сервер не зависает. Однако есть кое-что, что может зависнуть. Давайте взглянем, что происходит при потере соединения от лица администратора сервера (проекта).
Что происходит в нашей наивной версии плагина, которая не учитывает потерю соединения с базой?
Наш плагин отправляет запрос на каждого игрока при подключении. При смене карты все игроки "выходят" и "заходят" обратно, вызывая
OnClientAuthorized заново... идеальный момент, чтобы отправить запрос в базу. Каждый запрос производит попытку подключения, поскольку API неблокирующее, наши плагины, на самом деле, создают "задачи" для работы с БД. И этих задач может быть много. Каждая попытка подключения длится 30 секунд (значение по умолчанию). У нас появляется слишком много долгих задач.Всё бы ладно, если бы эти задачи выполнялись параллельно или хотя бы не мешали запросам других баз данных или других плагинов. Звучит, будто так и должно быть, а вот и нет.
SourceMod для наших неблокирующих запросов использует очередь для выполнения задач. Для выполнения блокирующих задач. Для выполнения в одном потоке. Это и есть тот самый грех SourceMod.
Проще говоря, запросы выполняются последовательно и друг за другом, никакого параллельного выполнения. А значит, что во время тех самых 30 секунд попытки подключения к базе ни один плагин не сможет получить доступ к любой базе данных, даже SQLite, которая, вроде бы, тут ни причем, мы ведь к MySQL подключаемся. Немного поднять глаза на текст выше и уже оказывается, что, запрос такой не один, и на N плагинов, отправляющий запрос на каждого игрока при подключении, при смене карты, при M игроков онлайн, очередь запросов блокируется на 30*M*N секунд.
Звучит не очень, ведь идёт речь далеко не о 5 или 10 минутах блокировки, а это может крайне негативно сказаться на серверах с WCS или RPG модами (которые могут быть подключены вообще к другой базе или к SQLite), ведь геймплей практически полностью зависит от данных в базе, как и смысл игры.
Что же происходит в нашей "продвинутой" версии плагина?
Проблема также может всплыть, но единожды и гораздо реже. Ведь при потере соединения мы просто не отправляем запросы, а значит, ситуация, когда мы генерируем много долгих запросов, произойдёт лишь тогда, когда база данных упадёт перед сменой карты, а это далеко не 100% времени работы сервера. Но мы всё еще, при попытке переподключиться, блокируем очередь задач, блокируя любой доступ к базам данных на полминуты. Это решается небольшим изменением в файле databases.cfg:
Code:
"database"
{
...
"timeout" "8"
}
Заключение (выводы)
Рассмотрев все беды, происходящие во время внезапных падений базы данных, стоит отметить, что для игрового сервера крайне важно иметь стабильное подключение к стабильной базе данных. Ведь невозможно переписать все имеющиеся плагины, чтобы учесть данный "грех" SourceMod'а, да и дорого делать настолько "правильный" код. Гораздо проще потратить лишние несколько зеленых на качественную базу данных.
Не роняйте и не теряйте базу. Хорошая база данных, может быть, даже дорогая, сэкономит Вам ресурсов.