Anmelden

browsergames tutorials

Der Weg zum eigenen Browsergame - Die Datenbank

Ein oft diskutiertes Thema ist die performante Datenbeschaffung mittels einer Datenbank. Was gilt es hier zu beachten und welche Datenbank kann den Anforderungen eines Browsergames genügen?

MySql

Wenn man mit PHP arbeitet, bietet sich die Nutzung der kostenlosen Datenbank MySql an. Die Tage, in denen MySql als langsam und nicht konkurrenzfähig gegenüber größerer Datenbanksysteme galt, sind gezählt. Spätestens mit Einführung der MySqli-Erweiterung in PHP 5 hat sich einiges getan. Im Gegensatz zur Mysql-Erweiterung ist in MySqli ein einheitlicher objektorientierter Datenbankzugriff möglich. Zudem hat sich die Zugriffsgeschwindigkeit erhöht, was die Sache noch viel interessanter macht.

InnoDB

In einem Browsergame werden viele Benutzeraktionen erwartet. Dies führt zu vielen Lese- und Schreibzugriffen auf die Tabellen. Wegen der hohen Zugriffsfrequenz, kann es auch zu Überschneidungen zwischen zwei Datenbankanfragen kommen, die dazu führen, dass die Daten durcheinander geraten oder sogar verloren gehen.

Jeder, der schon ein paar Browsergames gespielt hat, kennt das Problem. Plötzlich verschwinden Einheiten oder der Händler kommt niemals wieder. Solche Bugs sind meist auf ein schlechtes Datenmanagement, bzw. auf schlecht geplante Datenbankzugriffe zurückzuführen.

Es gibt eine Engine, die für unsere Anforderungen ideal geeignet ist - InnoDB!
InnoDB unterstützt Transaktionen, was die Datensicherheit erhöht, und kommt mit vielen Lese- und Schreibzugriffen sehr gut zurecht.

Zwischenstand

  • Für unser Browsergame entscheiden wir uns für MySql als Datenbank.
  • Innerhalb dieser Datenbank legen wir unsere Tabelle mit der InnoDB-Engine an.
  • Für den Zugriff auf diese Tabellen mittels PHP, verwenden wir die MySqli-Erweiterung.

Datenbankzugriff mittels PHP

Um mit PHP bequem auf die Datenbank zugreifen zu können, erstellen wir ein Datenbankobjekt über das wir jegliche Zugriffe abhandeln.

class MyDB
{
  private $connection;
 
  public function connect($host, $database, $username, $password)
  {
    @$this->connection = new mysqli($host, $username, $password, $database);
    if (mysqli_connect_errno()) throw new Exception(__METHOD__.'::'.mysqli_connect_error());
  }
  public function close()
  {
    if (!$this->connection) return false;
    $this->connection->close();
  }
}
 

Das Objekt, sowie die aktive Verbindung zur Datenbank, erzeugen wir nun mit folgendem Code:

$db = new MyDB();
try
{
  $db->connect('localhost', 'user', 'password', 'database-name');
}
catch(Exception $e)
{
  die($e->getMessage());
};
 

Sollte es Probleme bei der Verbindungsherstellung zur Datenbank geben, springt das Programm in den Catch-Block, gibt die Fehlermeldung aus und bricht das Skript ab.

Bei einer erfolgreichen Verbindung können wir jetzt mit dem lustigen Datenaustausch beginnen, würde uns nicht die passende Methode dazu fehlen. Wir ergänzen also unsere MyDB-Klasse um folgende Methode.

  ...
 
  public function query($sql, $return='affected', $result_mode=MYSQLI_USE_RESULT)
  {
    if (!$this->connection) throw new Exception('Connection missing');
    $data = array();
    if ($result = $this->connection->query($sql, $result_mode)) 
    {
      if ($return=='affected'){ $data = $this->connection->affected_rows; }
      elseif ($return=='num'){ $data = $result->num_rows; }
      elseif ($return=='id'){ $data = $this->connection->insert_id; }
      elseif ($return=='assoc'){ while ($row = $result->fetch_assoc()) $data[] = $row; }
      elseif ($return=='numeric'){ while ($row = $result->fetch_assoc()) $row = $result->fetch_array(MYSQLI_NUM); }
      elseif ($return=='fields'){ while ($row = $result->fetch_fields()) $data[] = $row; }
      if (is_object($result)) $result->close();
    }
    else
    {
      throw new Exception(__METHOD__.'::'.$this->connection->error.'::'.$sql);
    };
    return $data;
  }
 
  ...

Jetzt können wir auf unsere Datenbank zugreifen und die benötigten Spieldaten laden oder schreiben.

try
{
  $all_user_data = $db->query("SELECT * FROM user", 'assoc');
  if (!empty($all_user_data)) foreach($all_user_data as $dataset)
  {
    echo 'ID:'.$dataset['id'].' | ';
    echo 'NICKNAME:'.$dataset['nickname'].' | ';
    echo 'PASSWORD:'.$dataset['password'];
  };
  $next_id = count($all_user_data) + 1;
  $db->query("INSERT INTO user (id, nickname, password) VALUES ({$next_id}, 'Testuser', 'test123')");
  $db->query("UPDATE user SET password = 'sCv54!j' WHERE id = ".$next_id);
}
catch(Exception $e)
{
  echo $e->getMessage();
};
$db->close();

Mit dem oberen Skript lassen sich also alle Benutzer aus der Datenbank laden und ausgeben. Zudem wird mit jedem Aufruf immer ein neuer Testuser mit der ID (Gesamtanzahl+1) angelegt und direkt bearbeitet. Nach einem genaueren Sinn dieses Skripts muss hier nicht weiter gesucht werden. Es soll vielmehr ein Problem aufzeigen, mit dem wir bei mehreren gleichzeitigen Zugriffen durch verschiedenen Benutzer auf ein und das selbe Skript, zu kämpfen haben.

Gehen wir mal davon aus, dass zwei Spieler gleichzeitig spielen, Spieler A und Spieler B. Das sollte auch nicht allzu selten vorkommen. Nun klicken Beide fast zeitgleich auf einen Link und das obere Skript wird für beide Spieler ausgelöst. Spieler A hat eine Nanosekunde früher geklickt als Spieler B und seine Skriptinstanz liest die Tabelle "user" zuerst aus. Im Anschluss wird die Tabelle "user" von der Skriptinstanz des Spielers B ausgelesen. Währenddessen ist Spieler A schon bei dem INSERT angelangt und trägt einen neuen Datensatz ein. Dieser neue Datensatz kommt jetzt in der Datenmenge von Spieler B nicht vor, da er sein SELECT ja bereits durchgeführt hat. Nun kommt die Stelle an der das Skript für Spieler B mit einer Fehlermeldung abbricht - und zwar genau bei dem Versuch den nächsten Datensatz einzufügen. Zu diesem Zeitpunkt wurde bereits ein Datensatz mit dieser ID eingefügt, und zwar von der Skriptinstanz des Spielers A, dessen Skript ohne Fehler durchläuft. Spieler B meldet einen Bug. Und das zurecht.

Wie lassen sich solche Skriptinstanz-Überschneidungen vermeiden?

Das Zauberwort lautet hier "Transactions".
Mittels Transaktionen lassen sich SQL-Abfragen in Blöcken zusammenfassen die entweder gesamtheitlich ausgeführt werden oder gar nicht.

Ergänzen wir also unsere MyDB-Klasse um folgende Methoden:

  ...
 
  public function startTransaction($isolation_level="SERIALIZABLE")
  {
    if (!$this->connection) return false;
    $isolation_level = strtoupper($isolation_level);
    $ok = $this->query("SET TRANSACTION ISOLATION LEVEL {$isolation_level};", "bool");
    $ok = ($ok && $this->query("SET AUTOCOMMIT=0;", "bool"));
    return ($ok && $this->query("START TRANSACTION;", "bool"));
  }
  public function commit()
  {
    if (!$this->connection) return false;
    if (!$this->query("COMMIT;", "bool")) return false;
    $this->query("SET AUTOCOMMIT=1;", "bool");
    return true;
  }
  public function rollback()
  {
    if (!$this->connection) return false;
    if (!$this->query("ROLLBACK;", "bool")) return false;
    $this->query("SET AUTOCOMMIT=1;");
    return true;
  }  
 
  ...

Als Isolationsebene verwenden wir als Standard SERIALIZABLE. Dadurch werden Dirty-Reads, Non-Repeatable-Reads und Phantom-Reads verhindert. Was genau es damit auf sich hat sollte jeder für sich einmal nachlesen. SERIALIZABLE mag die langsamste Isolationsebene sein, bietet aber die höchste Datenstabilität und ist somit absolut notwendig wenn wichtige Daten ausgelesen, verarbeitet und wieder in der Datenbank abgespeichert werden. Andere Isolationsebenen sind "Read Uncommitted", "Read Committed" und "Repeatable Read". Bei weniger wichtigen Daten sind diese Ebenen durch die schnellere Ausführungsgeschwindigkeit eine interessante Alternative. Das sollte man aber von Fall zu Fall entscheiden.

Mit diesen neuen Möglichkeiten lässt sich das Skript von vorhin sicher gegen parallele Ausführung machen und oben beschriebene Probleme vermeiden. Für Spieler B gibt's dann auch keinen Bug mehr, da seine Skriptinstanz so lange wartet bis das Skript für Spieler A durchgelaufen ist.

$db->startTransaction();
try
{
  $all_user_data = $db->query("SELECT * FROM user", 'assoc');
  if (!empty($all_user_data)) foreach($all_user_data as $dataset)
  {
    echo 'ID:'.$dataset['id'].' | ';
    echo 'NICKNAME:'.$dataset['nickname'].' | ';
    echo 'PASSWORD:'.$dataset['password'];
  };
  $next_id = count($all_user_data) + 1;
  $db->query("INSERT INTO user (id, nickname, password) VALUES ({$next_id}, 'Testuser', 'test123')");
  $db->query("UPDATE user SET password = 'sCv54!j' WHERE id = ".$next_id);
  $db->commit();
}
catch(Exception $e)
{
  $db->rollback();
  echo $e->getMessage();
};
$db->close();

Schlusswort

Ich fasse noch einmal zusammen:

  • MySql Datenbanken sind mittlerweile sehr schnell und hervorragend geeignet für ein Browsergame.
  • Die Tabellen sollten mit der InnoDB-Engine laufen, da man hier Transaktionen verwenden kann.
  • Eine Transaktion bündelt mehrere SQL-Abfragen zu einem Block der entweder ganz oder gar nicht ausgeführt wird, und sichert diesen Block gegen dazwischenfunkende Datenbankzugriffe.
  • Die MySqli-Erweiterung von PHP ist hervorragend geeignet für einen schnellen, und objektorientierten Zugriff auf die Datenbank.

Hier noch mal die gesamte MyDB-Klasse:

class MyDB
{
  private $connection;
 
  public function connect($host, $database, $username, $password)
  {
    @$this->connection = new mysqli($host, $username, $password, $database);
    if (mysqli_connect_errno()) throw new Exception(__METHOD__.'::'.mysqli_connect_error());
  }
  public function close()
  {
    if (!$this->connection) return false;
    $this->connection->close();
  }
  public function query($sql, $return='affected', $result_mode=MYSQLI_USE_RESULT)
  {
    if (!$this->connection) throw new Exception('Connection missing');
    $data = array();
    if ($result = $this->connection->query($sql, $result_mode)) 
    {
      if ($return=='affected'){ $data = $this->connection->affected_rows; }
      elseif ($return=='num'){ $data = $result->num_rows; }
      elseif ($return=='id'){ $data = $this->connection->insert_id; }
      elseif ($return=='assoc'){ while ($row = $result->fetch_assoc()) $data[] = $row; }
      elseif ($return=='numeric'){ while ($row = $result->fetch_assoc()) $row = $result->fetch_array(MYSQLI_NUM); }
      elseif ($return=='fields'){ while ($row = $result->fetch_fields()) $data[] = $row; }
      if (is_object($result)) $result->close();
    }
    else
    {
      throw new Exception(__METHOD__.'::'.$this->connection->error.'::'.$sql);
    };
    return $data;
  }
  public function startTransaction($isolation_level="SERIALIZABLE")
  {
    if (!$this->connection) return false;
    $isolation_level = strtoupper($isolation_level);
    $ok = $this->query("SET TRANSACTION ISOLATION LEVEL {$isolation_level};", "bool");
    $ok = ($ok && $this->query("SET AUTOCOMMIT=0;", "bool"));
    return ($ok && $this->query("START TRANSACTION;", "bool"));
  }
  public function commit()
  {
    if (!$this->connection) return false;
    if (!$this->query("COMMIT;", "bool")) return false;
    $this->query("SET AUTOCOMMIT=1;", "bool");
    return true;
  }
  public function rollback()
  {
    if (!$this->connection) return false;
    if (!$this->query("ROLLBACK;", "bool")) return false;
    $this->query("SET AUTOCOMMIT=1;");
    return true;
  }  
 
}
 

Glory Wars - Fantasy Rollenspiel

Glory Wars Browsergame

Glory Wars

Glory Wars - ein Fantasy-Rollenspiel

Weiterlesen...

Tagoria - Das Fantasy-Rollenspiel

Tagoria Fantasy-Rollenspiel

Tagoria - Fantasy-Rollenspiel

Sei ein furchloser Krieger im Fantasy-Rollenspiel Tagoria.

Weiterlesen...

Goodgame Café Browsergame

Cafe Browsergame

Das Café Browsergame

Ein Café einrichten und organisieren im Browserspiel von Goodgame.

Weiterlesen...

Der Weg zum eigenen Browsergame - Die Karte

Der Weg zum eigenen Browsergame

Thema: Die Karte

Weiterlesen...

Der Weg zum eigenen Browsergame

browsergames tutorial

Tipps und Tricks zur Erstellung eines Browsergames.

Insider verraten wie es geht.

Weiterlesen...