
Powstaje coraz więcej pluginów z użyciem MySQL, ale czy są to dobrze napisane pluginy? Na pewno nie wszystkie. O ile w większości przypadków do samych realizacji funkcji pluginu na serwerze nie można się przyczepić, o tyle komunikacja z bazą danych pozostawia wiele do życzenia. Pół biedy, gdy mamy serwer SQL na tej samej maszynie, albo na maszynie w jednej sieci... Albo gdy używamy SQLite. Ale większość serwerów posiada bazę SQL w zupełnie innej lokalizacji. Często serwer gry jest w Polsce, a SQL we Francji, Niemczech... Czym to owocuje?
Popatrzmy.. Sami wiemy, że serwery zagraniczne do grania mixów się nie nadają raczej, zbyt wysoki ping. Podobny ping do takiego serwera ma dowolny serwer gry postawiony w Polsce, potrafi się on wahać od 100 do 500 milisekund, czyli aż do pół sekundy! To już całkiem sporo jak na przetworzenie zapytania... O ile samo przetworzenie nie zajmuje dużo, przy dobrze skonfigurowanym serwerze nie jest to nawet 1 milisekunda, o tyle wysłanie go i odebranie wyniku już trochę trwa...
Popatrzmy na taki prosty przykład (przyjmijmy średni ping na 150 ms do zagranicznych serwerów), AmxBans, dowolna ich wersja... Gracz wchodzi na serwer, wykonywane są po kolei następujące zapytania:
- Sprawdzenie, czy gracz nie posiada bana.
- Jeśli ban został znaleziony:
- Jest aktywny: zwiększenie ilości kicków w danych o banie (dotyczy GmBans), dalsze zapytania niewykonywane
- Jeśli nie jest aktywny: przesunięcie go do archiwum
- Sprawdzenie, czy gracz nie został oflagowany
- Sprawdzenie, czy gracz nie posiadał wcześniej żadnych banów
- Jeśli używamy bazy sql zamiast users.ini - sprawdzenie, czy gracz nie jest adminem
Założyliśmy, że średni ping wynosi 150ms, czyli w uproszczeniu daje nam to 150ms na jedno zapytanie (w rzeczywistości trwa to jednak dłużej), więc 750ms przy pesymistycznej wersji będzie trwało ustalenie z kim mamy do czynienia, prawie sekunda! a graczy możemy mieć nawet 32, przy zmianie mapy te wszystkie zapytania się wykonują! W tym artykule pomogę Wam takich koszmarków unikać...
Projekt bazy danych.
Pierwszą rzeczą, którą musimy uczynić przy tworzeniu pluginu wykorzystującego bazę danych, jest odpowiedni projekt samej bazy danych. Najpierw musimy wiedzieć co zapisujemy. Czy chcemy stworzyć exp moda? Może jakieś statystyki dla graczy zapisywać? A może wszystkie serwery sieci zebrać w jednej bazie danych na potrzeby pluginu takiego, jak xRedirect?
Gdy to już wiemy, następna rzecz to to, co chcemy na dany temat w bazie umieścić. Przyjmijmy dla przykładu, że tworzymy plugin zliczający czas gry na naszym serwerze każdego gracza, który nań wejdzie. Dla uproszczenia przyjmijmy również, że serwer jest Steam Only (nie mam zamiaru igrać z prawem tutaj

Więc co musimy w bazie zapisać? Na pewno jego SteamID oraz czas gry na serwerze. Czyli mamy już 2 kolumny w bazie danych, dopiszemy jeszcze trzecią, id. Czym będzie ID? Identyfikatorem ułatwiającym nam operację na bazie danych

- `id` INT(11) NOT NULL auto_increment
- `sid` VARCHAR(24) NOT NULL
- `time` INT(11) NOT NULL
Mamy 3 kolumny, ale to jeszcze nie wszystko. Dodamy klucze! Czym są klucze? Dają nam one unikalność danych w kolumnie, szybkość wyszukiwania i definiują również jaka kolumna jest indeksem w tabeli. Dodamy 2 klucze, ponieważ ID ma być indeksem tabeli (PRIMARY KEY), a sid musi być koniecznie unikalne. Możemy to pominąć, ale zobaczycie jak to ułatwi później budowę zapytania

- PRIMARY KEY (`id`)
- UNIQUE KEY `sid` (`sid`)
`server` INT(11) NOT NULL
oraz zmodyfikujemy indeks `sid` na taki:
UNIQUE KEY `server_sid` (`server`, `sid`)
Jak ten indeks zadziała? Ano nie pozwoli on, aby w bazie znajdował się wpis z takim samym Steam ID oraz takim samym numerem serwera. Czyli gdy mamy 2 takie same Steam ID, ale inne serwery to wpisy te mogą istnieć, tak samo gdy 2 inne SteamID, a 2 takie same serwery.
Cała struktura tabeli, w języku SQL będzie wyglądała tak:
CREATE TABLE IF NOT EXISTS `godziny` ( `id` int(11) NOT NULL AUTO_INCREMENT, `server` int(11) NOT NULL, `sid` varchar(24) NOT NULL, `time` int(11) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `server_sid` (`server`,`sid`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_polish_ci;
Wstawianie i aktualizacja danych
Teraz przejdziemy do tego, co na serwerze. Pomyślmy co musimy zrobić w naszym przykładzie od strony serwera? Otóż wstawić nowy rekord w bazie z danymi gracza, lub go aktualizować jeśli już istnieje. Jak można to zrobić? Sprawdzić czy rekord istnieje, jeśli tak to zaktualizować, jeśli nie to wstawić nowy... czyli 2 zapytania dla każdego gracza by się musiały wykonać, ale czy muszą? Pomyślmy... A jakby zapytanie samo zdecydowało czy wstawić, czy zaktualizować? A da się tak? A da

Ale pozostaje jeszcze problem przy aktualizacji. Musimy odpowiednio zwiększyć starą wartość czasu, a nie wstawiać nową bezmyślnie, podmieniając starą... Możemy pobrać po prostu starą wartość, dodać i wpisać nową... NIE, tak nie zrobimy! Bo to też oznacza 2 zapytania (co prawda niekoniecznie w ciągu, jedno po drugim, ale dwa!).
Więc mamy do wyboru: Odpowiedni warunek używając SELECT, UPDATE oraz INSERT INTO w jednym zapytaniu... Długie ono niestety wyjdzie i ciężko jest je skonstruować. Drugim rozwiązaniem jest REPLACE INTO. Działa ono tak, że stary rekord usuwa, gdy taki istnieje, a następnie tworzy nowy. Tutaj niestety nie możemy się do starej wartości odwołać. Mamy jeszcze jakieś rozwiązania?
Tak, mamy. Dzięki odpowiednio skonstruowanym kluczom możemy po prostu użyć INSERT INTO. No nie tak po prostu, dopiszemy na końcu ON DUPLICATE KEY UPDATE, co spowoduje, że gdy zapytanie INSERT się nie powiedzie, z powodu istniejących już wartości w polach unikalnych, zostanie wykonane UPDATE. Niestety to UPDATE, jest z pewnych powodów trochę uproszczone w porównaniu do klasycznego.
Najpierw skonstruujmy samego INSERTa:
INSERT INTO `czasy` (`server`, `sid`, `time`) VALUES (%d, '%s', %d)
Jak widać proste zapytanie

INSERT INTO `czasy` (`server`, `sid`, `time`) VALUES (%d, '%s', %d) ON DUPLICATE KEY UPDATE `time` = `time`+%dOto i nasz update. Tak, tak się da, serwer SQL wtedy ładnie zwiększy `time` o wartość przez nas podaną. Jednakże napiszemy to troszkę inaczej.
INSERT INTO `czasy` (`server`, `sid`, `time`) VALUES (%d, '%s', %d) ON DUPLICATE KEY UPDATE `time` = `time`+VALUES(`time`)Ta funkcja spowoduje pobranie wartości, którą podaliśmy przy INSERT. Niby wychodzi troszkę większa ilość znaków, ale za chwilę zobaczycie dlaczego tak.
Odpowiednie umiejscowienie w kodzie pluginu
Teraz rzecz na której ludzie najczęściej się potykają. Kiedy wysyłać dane na serwer? Najprościej by było gdy gracz się rozłączy i pod koniec mapy, co sprowadza się do jednego: client_disconnect. A to jest błąd, duży błąd... Gdyż oznacza to przy pełnym 32 slotowym serwerze wysłanie 32 zapytań pod koniec mapy. Może i niedużo, ale jednak coś. Pytania nie są wysyłane na raz, ale po kolei. Nie mam tu na myśli, że zakończy się jedno, a wyśle drugie. Wysyłane są po kolei, a odbierane w kolejności wysłania. Więc jeśli np 15 zapytanie utknie, to reszta musi czekać. Jeśli odpowiedź z 7 utknie to znów reszta na odbiór musi czekać. Przydałoby się to upchnąć w plugin_end w jednym zbiorczym, np tak:
INSERT INTO `czasy` (`server`, `sid`, `time`) VALUES (%d, '%s', %d), (%d, '%s', %d), (%d, '%s', %d), (%d, '%s', %d) ON DUPLICATE KEY UPDATE `time` = `time`+VALUES(`time`)Wszak można wrzucić od razu wszystkich graczy w ten sposób (tu jest tylko 4). Ale hmm.. Najpierw się wykonują wszystkie client_disconnect, a dopiero później plugin_end. Więc trzeba jakoś to "przechwycić". Dodatkowym problemem jest to, że będzie potrzebna bardzo duża tablica, aby to wszystko zmieścić. Najpierw poradźmy sobie z 2 problemem, co nam częściowo rozwiąże 1. Zauważasz pewnie tutaj dlaczego wcześniej polecilem użyć tego "magicznego" VALUES. Otóż gdy dodajemy kilka rekordów to nie mamy już możliwości wpisania tam konkretnej wartości, a ta funkcja zadba o to, aby dla każdego wpisu była użyta wartość podana przy próbie jego zainsertowania

Otóż AMX ma to do siebie (jak duża część języków programowania), że lepiej przyjmuje duże tablice, gdy są one tablicami globalnymi, niż lokalnymi. Czyli zdefiniujmy sobie query na początku pliku sma, a nie w samej funkcji tej tablicy używającej. Wielkość query sobie trzeba policzyć. Tutaj mamy na pierwszą linię 53 znaki (wraz z enterem). Na lnii z danymi graczy mamy wstawione zmienne. trzeba policzyć jaką długość każda z nich zajmie. Znaczy liczyć nie trzeba, mamy to w bazie danych: 11, 24 i 11. W sumie jest to 46. do tego 2 przecinki, 2 nawiasy, 2 spacje (dla optymalizacji można je usunąć), 2 apostrofy, przecinek na końcu i enter. 10 znaków, czyli łącznie z tymi wstawionymi ze zmiennych mamy 56. Przemnóżmy to razy 32, wychodzi 1792. Teraz 2 ostatnie linie, mają one odopwiednio 24 i 31 znaków (łącznie z enterem i nullem na ich końcach). Sumujemy i wychodzi: 1900 (o, jaka równa liczba


Teraz problem numer 1. Jak go rozwiązać? W bardzo prosty sposób, w client_disconnect zamiast wykonywać zapytania, będziemy dopisywać do głównego naszego zapytania odpowiednie linie dla każdego "wychodzącego" gracza i wyślemy to zapytanie w plugin_end. Dodatkowo co jakiś czas (proponuję 60 sekund) będzie wykonywany task, który sprawdzi, czy jakiś gracz nie rozłączył się w trakcie trwania mapy i jeśli się rozłaczył (i dopisał do głównego zapytania) to to zapytanie wyślemy


Dodatkowo możemy w tasku przejechać się pętlą po wszystkich graczach na serwerze i też wysłać ich dane, dzięki czemu przy crashu nie stracą oni dorobku z całej mapy, ale tylko najwyżej z ostatniej minuty (jeśli co minutę task się wykonuje oczywiście).
Jest jeszcze jedna rzecz. Twórcy AMX, a ściślej biblioteki sqlx przestrzegają przed używaniem ThreadedQuery w plugin_end. Dlatego polecam użyć tutaj tzw trybu liniowego (synchronicznego). Nie spowoduje to "widocznego" laga na serwerze, gdyż gracze w tym momencie będą czekać na zmianę mapy, a czas trwania tej zmiany tak samo się przez to wydłuży, jak przy ThreadedQuery (serwer przed zmianą mapy czeka, aż wszystkie zapytania ThreadedQuery zostaną zakończone).
Druga część poradnika w #12 poście w tym temacie.