Günther S: Probleme mit selfmade Session-Handler (DB-basiert)

Hallo,

ich habe zwei Probleme mit meinem selbstgeschriebenen, datenbankbasierter (MySQL 5) PHP5-Session-Handler. In das Ganze involviert ist außerdem die PHP-eigene mysqli-Schnittstelle, welche sich eine MySQLi-Wrapper-Klasse zunutze macht. Bevor ich Genaueres schreibe, hier schonmal meine beiden Probleme:

  1. Die Garbage-Collector-Methode des Session-Handlers löscht alte Datenbank-Einträge erst nach 30 Stunden anstatt 30 Minuten (d.h. nach [eingestellte Zeit in Minuten] * 60). Das ist nicht nur Zufall: Über SHOW PROCESSLIST habe ich schon ein paar mal die Anfrage "... INTERVAL 1800 MINUTE ..." (30 * 60 = 1800) vom Garbage-Collector entdeckt. Wenn ich die Methode Session::_gc() aber "von Hand" aufrufe, steht im Datenbank-Query - wie erwünscht - "... INTERVAL 30 MINUTE ...".

  2. Sporadisch tritt die Fehlermeldung "Notice: Object of class mysqli_result could not be converted to int in [...]index.php on line 85" auf. In dieser Zeile steht nichts außer "session_start();". Das interessante ist, dass das wirklich nur sporadisch auftritt - bei ca. 98% aller Skriptaufrufe kommt diese Fehlermeldung nicht. Ich konnte bisher keinerlei Regelmäßigkeit hinter diesen Meldungen erkennen. Weder erscheint sie immer beim ersten Aufruf der Website, noch beim "manuellen" Ändern oder Weglassen/Hinzufügen der/einer Subdomain o.Ä.

Und nun einige Informationen zu dem Skript und den Session-Konfigurationsvariablen.

  
// Session-Konfigurationsvariablen laut phpinfo() (lokale und globale Werte sind jeweils identisch)  
session.auto_start Off  
session.bug_compat_42 Off  
session.bug_compat_warn On  
session.cache_expire 180  
session.cache_limiter nocache  
session.cookie_domain no value  
session.cookie_httponly Off  
session.cookie_lifetime 0  
session.cookie_path /  
session.cookie_secure Off  
session.entropy_file no value  
session.entropy_length 0  
session.gc_divisor 100  
session.gc_maxlifetime 1440  
session.gc_probability 0  
session.hash_bits_per_character 4  
session.hash_function 0  
session.name PHPSESSID  
session.referer_check no value  
session.save_handler files  
session.save_path /var/lib/php5  
session.serialize_handler php  
session.use_cookies On  
session.use_only_cookies Off  
session.use_trans_sid 0  

  
  
// Definition der unten eingebauten Konstanten (vor dem Einbinden + Aufruf der Session-Klasse)  
  
define('SESSION_LIFETIME',   30); // in minutes  
define('SESSION_GC_PROBABILITY', 1);  
define('SESSION_COOKIE_DOMAIN', ".meinedomain.de"); // hier steht natürlich im Original die echte Domain :-)  
  
  
  
  
// Session-Handler-Klasse  
  
ini_set('session.gc_maxlifetime', (int)SESSION_LIFETIME*60);  
ini_set('session.gc_probability', SESSION_GC_PROBABILITY);  
ini_set('session.gc_divisor', 100);  
ini_set('session.cookie_domain', SESSION_COOKIE_DOMAIN);  
  
$session = new Session();  
  
class Session  
{  
 public function __construct()  
 {  
  
  session_set_save_handler(array('Session', '_open'),  
        array('Session', '_close'),  
        array('Session', '_read'),  
        array('Session', '_write'),  
        array('Session', '_destroy'),  
        array('Session', '_gc'));  
 }  
  
 public function _open()  
 {  
  // DB-Verbindung besteht zu diesem Zeitpunkt bereits  
 }  
  
 public function _close()  
 {  
    // DB-Verbindung wird am Ende der Skriptlaufzeit geschlossen  
 }  
  
 public function _read($sess_id)  
 {  
  global $db;  
  
  $res = $db->query("SELECT sess_data FROM sessions WHERE sess_id = '" . mysqli_real_escape_string($sess_id) . "'");  
  
  $row = mysqli_fetch_assoc($res);  
  
  return array_pop($row);  
 }  
  
 public function _write($sess_id, $sess_data)  
 {  
  global $db;  
  
  if(!is_object($db)) $db = new Sqli(DBDSN);  
  
  return $db->query("REPLACE INTO sessions SET sess_id = '" . mysqli_real_escape_string($sess_id) . "', last_access = NOW(), sess_data = '" .mysqli_real_escape_string($sess_data) . "'");  
 }  
  
 public function _destroy($sess_id)  
 {  
  global $db;  
  
  return $db->query("DELETE FROM sessions WHERE sess_id = '" . mysqli_real_escape_string($sess_id) . "'");  
 }  
  
 public function _gc($sess_lifetime = SESSION_LIFETIME)  
 {  
  global $db;  
  
  $db->query("DELETE FROM sessions WHERE last_access < DATE_SUB(NOW(), INTERVAL " . $sess_lifetime . " MINUTE)");  
  
  return $db->query("OPTIMIZE TABLE ".DBPREFIX."sessions");  
 }  
}  
  

Hat jemand einen Denkanstoß für mich?

Gruß,
Günther

  1. Moin!

    1. Die Garbage-Collector-Methode des Session-Handlers löscht alte Datenbank-Einträge erst nach 30 Stunden anstatt 30 Minuten (d.h. nach [eingestellte Zeit in Minuten] * 60). Das ist nicht nur Zufall: Über SHOW PROCESSLIST habe ich schon ein paar mal die Anfrage "... INTERVAL 1800 MINUTE ..." (30 * 60 = 1800) vom Garbage-Collector entdeckt. Wenn ich die Methode Session::_gc() aber "von Hand" aufrufe, steht im Datenbank-Query - wie erwünscht - "... INTERVAL 30 MINUTE ...".

    Das ist normal.

    Die Garbage Collection wird im Prinzip bei jedem PHP-Request mit Session-Beteiligung aufgerufen, allerdings reduziert durch einen Zufallsfaktor. Standardeinstellung ist 1/100, also nur durchschnittlich jeder hunderste Aufruf eines PHP-Skripts mit Session-Funktion räumt alte Session-Daten auf.

    Bei unglücklicher Zufallsverteilung und geringer Zahl von Requests kann es also durchaus lange Zeit dauern, bis die Garbage Collection mal wieder gestartet wird.

    Das gilt auch dann, wenn du eigene Routinen installiert hast.

    1. Sporadisch tritt die Fehlermeldung "Notice: Object of class mysqli_result could not be converted to int in [...]index.php on line 85" auf.

    Deine Konstruktion ist in zwei Punkten kritikwürdig.

    1. Du stellst nirgendwo in deinem Session-Objekt sicher, dass es über eine funktionierende DB-Verbindung verfügt. Sinnvoll wäre, dem Objekt ein DB-Singleton zu übergeben, mit dem im Bedarfsfall die Verbindung hergestellt wird, andernfalls die bestehende Verbindung genutzt wird.

    2. Dein Escaping ist zwar vorhanden, nutzt aber dein DB-Objekt ebenfalls nicht. Dabei ist das Escaping abhängig von der gewählten Connection bzw. genauer gesagt der dort genutzten Zeichencodierung. $db->real_escape_string() wäre angesagt (sofern $db eine Instant von mysqli ist).

    - Sven Rautenberg

    --
    "Love your nation - respect the others."
    1. Hallo,

      Das ist normal.

      Die Garbage Collection wird im Prinzip bei jedem PHP-Request mit Session-Beteiligung aufgerufen, allerdings reduziert durch einen Zufallsfaktor. Standardeinstellung ist 1/100, also nur durchschnittlich jeder hunderste Aufruf eines PHP-Skripts mit Session-Funktion räumt alte Session-Daten auf.

      Das ist mir bekannt. Darum handelt es sich hier aber nicht. Ich habe die Anfrage (zufällig) in der MySQL-Prozessliste erwischt (ab und zu mal). Da stand jedes mal "DELETE FROM sessions WHERE last_access ... INTERVAL 1800 MINUTE" (anstatt ... INTERVAL 30 MINUTE). D.h. selbst wenn die gc_probability 100% wäre, würden nur alles, was älter ist als 30 Stunden gelöscht werden. Außerdem beobachte ich das schon eine ganze Weile: Der älteste Datensatz war meistens gut 30 Stunden alt (wie du schon sagst, je nach Zufallsverteilung auch mal etwas mehr, vielleicht 40 Stunden) - nie aber unter 30 Stunden. Das alleine könnte ich mir durch eine ungünstige Verteilung nicht mehr erklären ;). Also es ist fast so, als ob durch die Zeile

      ini_set('session.gc_maxlifetime', (int)SESSION_LIFETIME*60);

      der Wert der Konstanten SESSION_LIFETIME selbst mit 60 multipliziert wird (und dieser neue Wert dann eben wieder als Konstante gespeichert wird), so dass letztendlich nicht 30, sondern 30 * 60 = 1800 an den Garbage Collector übergeben wird. An so etwas liegt es aber auch nicht, da, wenn ich die Funktion Session::_gc() normal im Skript aufrufe (und wenn das nicht über den Session-Handler geschieht), die Anfrage richtig mit der Zahl 30 (und nicht 1800) ausgeführt wird.

      Hat irgendjemand eine Ahnung, wie so etwas sein kann?

      1. Sporadisch tritt die Fehlermeldung "Notice: Object of class mysqli_result could not be converted to int in [...]index.php on line 85" auf.

      Deine Konstruktion ist in zwei Punkten kritikwürdig.

      Da hast du Recht, danke für die Tipps. Die Fehlermeldung allerdings ist wohl nicht darauf zurückzuführen, oder irre ich? Woran könnte das sonst liegen?

      Gruß,
      Günther

  2. Hallo Günther,

    1. Sporadisch tritt die Fehlermeldung "Notice: Object of class mysqli_result could not be converted to int in [...]index.php on line 85" auf. In dieser Zeile steht nichts außer "session_start();".

    Speicherst Du irgendwo MySQL-Result-Objekte in der Session (auch indirekt innerhalb von anderen Objekten)? Wenn ja: Das funktioniert so nicht, da MySQL-Result-Objekte nicht serialisiert und erfolgreich wieder deserialisiert werden können.

    Wenn's das nicht ist und Du das Problem isolieren willst: Definiere Dir per set_error_handler einen eigenen Error-Handler. Sobald eben diese Notice kommt (Du kannst ja Dateiname und Zeile überprüfen und ansonsten nur die Meldung selbst wieder ausgeben) liest Du Dir aus $_REQUEST die Session-ID ein ($sessid = $_REQUEST[session_name ()]; - session_id () wird evtl. nicht funktionieren, da session_start () noch nicht abgeschlossen ist) und liest Dir manuell aus der DB die serialisierten Daten zu der zugehörigen Session aus und speicherst sie z.B. in einem Logfile oder gibst sie aus. Dann schaust Du Dir diese serialisierten Daten an und überlegst, woran das liegen könnte...

    Zu Deinem anderen Problem:

    define('SESSION_LIFETIME',   30); // in minutes
    [...]
    ini_set('session.gc_maxlifetime', (int)SESSION_LIFETIME*60);

    Ja, Du hast richtig gesehen, dass session.gc_maxlifetime in Sekunden angegeben wird, d.h. Du multiplizierst Deine Konstante SESSION_LIFETIME mit 60. Was Du aber nicht bedenkst, ist, dass

    public function _gc($sess_lifetime = SESSION_LIFETIME)

    die Methode _gc(), die Du an session_set_save_handler übergibst, weiterhin Sekunden erhält und damit

    $db->query("DELETE FROM sessions WHERE last_access < DATE_SUB(NOW(), INTERVAL " . $sess_lifetime . " MINUTE)");

    hier INTERVAL ... SECOND stehen müsste.

    (Und den Wert für Deinen Default-Parameter müsstet Du natürlich auch anpassen.)

    Viele Grüße,
    Christian

      1. Sporadisch tritt die Fehlermeldung "Notice: Object of class mysqli_result could not be converted to int in [...]index.php on line 85" auf. In dieser Zeile steht nichts außer "session_start();".

      Speicherst Du irgendwo MySQL-Result-Objekte in der Session (auch indirekt innerhalb von anderen Objekten)? Wenn ja: Das funktioniert so nicht, da MySQL-Result-Objekte nicht serialisiert und erfolgreich wieder deserialisiert werden können.

      Nein, tue ich nicht.

      Wenn's das nicht ist und Du das Problem isolieren willst: Definiere Dir per set_error_handler einen eigenen Error-Handler. Sobald eben diese Notice kommt (Du kannst ja Dateiname und Zeile überprüfen und ansonsten nur die Meldung selbst wieder ausgeben) liest Du Dir aus $_REQUEST die Session-ID ein ($sessid = $_REQUEST[session_name ()]; - session_id () wird evtl. nicht funktionieren, da session_start () noch nicht abgeschlossen ist) und liest Dir manuell aus der DB die serialisierten Daten zu der zugehörigen Session aus und speicherst sie z.B. in einem Logfile oder gibst sie aus. Dann schaust Du Dir diese serialisierten Daten an und überlegst, woran das liegen könnte...

      Das habe ich gemacht, bin aber leider nicht weitergekommen. Der Fehler tritt bei Leuten auf, die sich auf der Website einloggen, als auch bei nicht eingeloggten Besuchern; er tritt manchmal direkt beim ersten Hit auf (d.h. Session ist noch "leer"), manchmal später; manchmal liegt der letzte Klick des Benutzers nicht mal eine Sekunde zurück, manchmal mehrere Minuten.
      Die Datenkonstellation bei den eingeloggten Benutzern sieht auch nicht unüblich aus (bei nicht ausgeloggten Benutzern werden nur zwei, drei Daten gespeichert, an denen mir nicht im Traum einfiele, was daran diese Meldung auslösen könnte). Natürlich hat jeder andere Daten angegeben, aber wirklich aus der Reihe tanzte da bisher keiner der Datensätze.
      Kurzum: Ich gehe davon aus, dass die Fehlermeldung absolut unabhängig von den Benutzereingaben auftritt.
      Hast du noch andere Ideen, wie was das Problem verursachen könnte bzw. wie man es isolieren könnte?

      Was Du aber nicht bedenkst, ist, dass [...} die Methode _gc(), die Du an session_set_save_handler übergibst, weiterhin Sekunden erhält und damit [...] hier INTERVAL ... SECOND stehen müsste.

      Richtig, das war das Problem. Danke!

      Gruß,
      Günther

      1. Hallo Günther,

        Kurzum: Ich gehe davon aus, dass die Fehlermeldung absolut unabhängig von den Benutzereingaben auftritt.
        Hast du noch andere Ideen, wie was das Problem verursachen könnte bzw. wie man es isolieren könnte?

        Leider nein, das Problem sieht sehr obskur aus und ich bin leider absolut ratlos, woran das liegen könnte - es _dürfte_ in dem von Dir beschriebenen Fall auch gar nicht auftreten...

        Als einzigen Tipp, den ich Dir noch irgendwie geben könnte, wäre einen Stacktrace laufen zu lassen, wenn die Meldung auftritt. Du kannst zum einen in besagtem Error-Handler probieren, debug_print_backtrace() aufzurufen und so zu sehen, wo der Fehler _genau_ aufgerufen wird - wenn das keinerlei hilfreiche Informationen bietet, dann besteht ferner noch die Möglichkeit, XDebug (http://www.xdebug.org/) zu installieren und dort ausführlichere Stacktraces auch von PHP-Interna zu erhalten.

        Und wenn das immer noch nichts hilft, dann könntest Du mit einem C-Debugger wie »gdb« an die Sache rangehen (d.h. den kompletten PHP-Prozess (und wenn's ein Modul ist, den kompletten Webserver) debuggen) und dann dort nach dem Problem Ausschau halten. Wobei Du für den Fall einiges an Vorwissen bezüglich C und Debugging brauchst, um damit zurechtzukommen.

        Viele Grüße,
        Christian