Comment implémenter correctement une session persister personnalisée en PHP + MySQL?
j'essaie d'implémenter une session persister personnalisée en PHP + MySQL. La plupart des choses sont triviales - créez votre table de base de données, faites vos fonctions de lecture/écriture, appelez session_set_save_hander()
, etc. Il y a même plusieurs tutoriels qui proposent des implémentations échantillon pour vous. Mais d'une manière ou d'une autre tous ces tutoriels ont commodément négligé un petit détail sur les persisters session - verrouillage . Et maintenant c'est où le vrai plaisir commence!
I examiné la mise en œuvre de session_mysql PECL extension de PHP. Qui utilise les fonctions de MySQL get_lock()
et release_lock()
. Semble beau, mais je n'aime pas la façon dont il le fait. La serrure est acquise dans la fonction read , et libérée dans la fonction write . Mais que faire si la fonction d'écriture n'est jamais appelé? Que se passe-t-il si le script tombe en panne, mais que la connexion MySQL reste ouverte (à cause de la mise en commun ou autre)? Ou si le script entre dans une impasse mortelle?
je viens de a un problème où un script ouvre une session et a ensuite essayé de flock()
un fichier sur un partage NFS, tandis que l'autre ordinateur (qui a accueilli le fichier) a été également faire la même chose. Le résultat était que l'appel flock()
par rapport au NFS bloquait le script pendant environ 30 secondes à chaque appel. Et c'était dans une boucle de 20 itérations! Depuis que C'était une opération externe, le script de PHP les temps morts ne s'appliquaient pas, et la session était verrouillée pendant plus de 10 minutes chaque fois que ce script était accédé. Et, par chance, c'est le script qui a été interrogé par une boîte aux lettres AJAX toutes les 5 secondes... Les principaux clou du spectacle.
j'ai déjà quelques idées sur la façon de le mettre en œuvre d'une meilleure façon, mais je voudrais vraiment entendre ce que d'autres personnes suggèrent. Je n'ai pas eu beaucoup d'expérience avec PHP pour savoir ce que les cas subtils de bord apparaissent dans l'ombre qui pourrait un jour mettre en péril l'ensemble de la chose.
ajouté:
OK, apparemment personne n'a rien à suggérer. OK, alors voici mon idée. J'aimerais un avis sur ce qui pourrait mal tourner.
- créer une table de session avec moteur de stockage InnoDB. Cela devrait assurer un verrouillage approprié des lignes, même dans les scénarios groupés. Le tableau doit avoir les colonnes ID , Données , Lastaccesstime N' , LockTime , LockID . J'Omets les types de données ici parce qu'ils suivent tout à fait directement les données qui doivent être stockées dans eux. Le ID sera L'ID de la session PHP. Data contiendra bien sûr les données de session. LastAccessTime sera un timestamp qui sera être mis à jour sur chaque opération de lecture/écriture et sera utilisé par le GC pour supprimer les anciennes sessions. LockTime sera un timestamp de la dernière serrure qui a été acquise sur la session, et LockID sera un guide de la serrure.
- lorsque l'opération read est demandée, les mesures suivantes seront prises::
- Execute
INSERT IGNORE INTO sessions (id, data, lastaccesstime, locktime, lockid) values ($sessid, null, now(), null, null);
- cela créera la ligne de session si elle n'est pas là, mais ne rien faire si elle est déjà présente; - génère un id de verrouillage aléatoire dans la variable $guid;
- Execute
UPDATE sessions SET (lastaccesstime, locktime, lockid) values (now(), now(), $guid) where id=$sessid and (lockid is null or locktime < date_add(now(), INTERVAL -30 seconds));
- il s'agit d'une opération atomique qui obtiendra soit une serrure sur la ligne de session (si elle n'est pas verrouillée ou si la serrure est Expirée), soit ne fera rien. - vérifier avec
mysql_affected_rows()
si la serrure a été obtenue ou non. Si elle a été obtenue aller de l'avant. Si ce n'est pas le cas, réessayez l'opération toutes les 0,5 seconde. Si dans 40 en secondes la serrure n'est toujours pas obtenue, lancer une exception.
- Execute
- Lorsqu'une opération write est demandée, exécutez
UPDATE sessions SET (lastaccesstime, data, locktime, lockid) values (now(), $data, null, null) where id=$sessid and lockid=$guid;
il s'agit d'une autre opération atomique qui mettra à jour la ligne de session avec les nouvelles données et supprimera la serrure si elle a encore la serrure, mais ne fera rien si la serrure a déjà été retirée. - lorsqu'une opération
gc
est demandée, il suffit de supprimer toutes les lignes aveclastaccesstime
trop vieux.
est-ce que quelqu'un peut voir des défauts avec ça?
4 réponses
je voulais juste ajouter (et vous le savez peut-être déjà) que le stockage de session par défaut de PHP (qui utilise des fichiers) verrouille les fichiers de sessions. De toute évidence, l'utilisation de fichiers pour les sessions présente de nombreux défauts, ce qui explique probablement pourquoi vous recherchez une solution de base de données.
Ok. La réponse va être un peu long donc patience! 1) Tout ce que je vais écrire est basé sur les expériences que j'ai faites ces derniers jours. Il peut y avoir des boutons/réglages/fonctionnement interne dont je ne suis pas au courant. Si vous repérez des erreurs/ ou ne sont pas d'accord alors s'il vous plaît crier!
2) première clarification-quand les données de SESSION sont lues et écrites
les données de session vont être lues exactement une fois, même si vous avez plusieurs $ _SESSION lit dans votre script. La lecture à partir de la session est a sur une base par script. En outre, le fetch de données se produit sur la base de la session_id et non des clés.
2) deuxième clarification-écriture toujours appelée à la fin du SCRIPT
A) l'option write to session save_set_handler est toujours activée, même pour les scripts qui ne font que "lire" depuis la session et n'écrivent jamais. B) L'écriture n'est déclenché une fois, à la fin du script ou si vous l'avez explicitement appel session_write_close. Encore une fois, l'écriture est basée sur session_id et non sur les clés
3) Troisième Clarification: pourquoi nous avons besoin de verrouillage
- C'est quoi cette histoire?
- avons-nous vraiment besoin de serrures sur la session?
- avons-nous vraiment Besoin d'un Gros Lock d'emballage de LECTURE + ÉCRITURE
pour expliquer le tapage
Script1
- 1: $x = S_SESSION ["X"];
- 2: sommeil (20);
- 3: Si ($x == 1) {
- 4: //faire quelque chose
- 5: $ _SESSION ["X"] = 3 ;
- 6:}
- 4: exit;
Script 2
- 1: $x = $_SESSION ["X"];
- 2: Si ($x == 1 ) { $_SESSION ["X"] = 2;}
- 3: exit ;
l'incohérence est que le script 1 fait quelque chose basé sur une variable de session (ligne:3) valeur qui a changé par un autre script alors que script-1 était déjà en cours d'exécution. Il s'agit là d'un exemple sommaire, mais qui illustre bien ce point. Le fait que vous prenez des décisions basées sur quelque chose qui n'est plus VRAI.
lorsque vous utilisez la session PHP par défaut (Request Level locking), script2 bloquera ligne 1 parce qu'il ne peut pas lire dans le fichier que script 1 a commencé à lire à la ligne 1. Ainsi les requêtes aux données de session sont sérialisées. Lorsque script2 lit une valeur, il est garanti pour lire la nouvelle valeur.
Clarification 4: la synchronisation des sessions PHP est différente de la synchronisation des variables
beaucoup de gens parlent de la synchronisation des sessions PHP comme si c'était comme une synchronisation variable, l'emplacement d'écriture en mémoire se produit dès que vous écrasez la valeur variable et la prochaine lecture dans n'importe quel script récupérera la nouvelle valeur. Comme nous le voyons dans la CLARIFICATION n ° 1 - Ce n'est pas vrai. Le script utilise les valeurs lues au début du script tout au long du script et même si un autre script a changé les valeurs, le script en cours d'exécution ne connaîtra pas de nouvelles valeurs avant le prochain rafraîchissement. Il s'agit d'un point très important .
aussi, gardez à l'esprit que les valeurs dans la session change même avec PHP big verrouillage. Dire des choses comme" script qui finit en premier va écraser la valeur " n'est pas très précis. Changement de valeur n'est pas mauvais, ce que nous sommes après est l'incohérence, à savoir, il ne devrait pas changer à mon insu.
CLARIFICATION 5: avons-nous vraiment besoin D'une grande serrure?
maintenant, avons-nous vraiment besoin D'une grande serrure (niveau de requête)? La réponse, comme dans le cas de DB isolation, est que cela dépend de la façon dont vous voulez faire les choses. Avec la mise en œuvre par défaut de $ _SESSION, IMHO, seule la grande serrure a du sens. Si je vais à l'utilisation de la valeur que j'ai lu au début tout au long de mon script, alors seule la grande serrure a du sens. Si je change l'implémentation $_SESSION en valeur" always "fetch " fresh", alors vous n'avez pas besoin de Big LOCK.
supposons que nous implémentions un schéma de vérification des données de session comme la vérification des objets. Maintenant, script 2 write va réussir parce que script-1 n'est pas encore venu pour écrire point. script-2 écrit au magasin de session et incréments version par 1. Maintenant, quand le script 1 essaie d'écrire à la session, il échouera (ligne:5) - Je ne pense pas que ce soit souhaitable, bien que faisable.
===================================
à Partir de (1) et (2), il s'ensuit que, peu importe la complexité de votre script, avec X lit et Y écrit à la session,
- les méthodes du gestionnaire de session read () et write () ne sont appelées qu'une seule fois
- et ils sont toujours appelés
maintenant, il y a des gestionnaires de session PHP personnalisés sur le net qui essaient de faire une "variable" -niveau de verrouillage etc. Je suis encore à essayer de comprendre certains d'entre eux. Cependant, je ne suis pas en faveur de complexes.
en supposant que les scripts PHP avec $_SESSION sont censés servir des pages web et sont traités en milli-secondes, Je ne pense pas que la complexité supplémentaire en vaille la peine. comme Peter Zaitsev le mentionne ici , un select pour update avec commit après write devrait faire l'affaire.
ici j'inclus le code que j'ai écrit pour mettre en œuvre le verrouillage. Ce serait bien de le tester avec des scripts de "simulation de course". Je crois que cela devrait fonctionner. Il n'y a pas beaucoup d'implémentations correctes que j'ai trouvées sur net. Ce serait bien si vous pouviez pointer les erreurs. J'ai fait ça avec une simple mysqli.
<?php
namespace com\indigloo\core {
use \com\indigloo\Configuration as Config;
use \com\indigloo\Logger as Logger;
/*
* @todo - examine row level locking between read() and write()
*
*/
class MySQLSession {
private $mysqli ;
function __construct() {
}
function open($path,$name) {
$this->mysqli = new \mysqli(Config::getInstance()->get_value("mysql.host"),
Config::getInstance()->get_value("mysql.user"),
Config::getInstance()->get_value("mysql.password"),
Config::getInstance()->get_value("mysql.database"));
if (mysqli_connect_errno ()) {
trigger_error(mysqli_connect_error(), E_USER_ERROR);
exit(1);
}
//remove old sessions
$this->gc(1440);
return TRUE ;
}
function close() {
$this->mysqli->close();
$this->mysqli = null;
return TRUE ;
}
function read($sessionId) {
Logger::getInstance()->info("reading session data from DB");
//start Tx
$this->mysqli->query("START TRANSACTION");
$sql = " select data from sc_php_session where session_id = '%s' for update ";
$sessionId = $this->mysqli->real_escape_string($sessionId);
$sql = sprintf($sql,$sessionId);
$result = $this->mysqli->query($sql);
$data = '' ;
if ($result) {
$record = $result->fetch_array(MYSQLI_ASSOC);
$data = $record['data'];
}
$result->free();
return $data ;
}
function write($sessionId,$data) {
$sessionId = $this->mysqli->real_escape_string($sessionId);
$data = $this->mysqli->real_escape_string($data);
$sql = "REPLACE INTO sc_php_session(session_id,data,updated_on) VALUES('%s', '%s', now())" ;
$sql = sprintf($sql,$sessionId, $data);
$stmt = $this->mysqli->prepare($sql);
if ($stmt) {
$stmt->execute();
$stmt->close();
} else {
trigger_error($this->mysqli->error, E_USER_ERROR);
}
//end Tx
$this->mysqli->query("COMMIT");
Logger::getInstance()->info("wrote session data to DB");
}
function destroy($sessionId) {
$sessionId = $this->mysqli->real_escape_string($sessionId);
$sql = "DELETE FROM sc_php_session WHERE session_id = '%s' ";
$sql = sprintf($sql,$sessionId);
$stmt = $this->mysqli->prepare($sql);
if ($stmt) {
$stmt->execute();
$stmt->close();
} else {
trigger_error($this->mysqli->error, E_USER_ERROR);
}
}
/*
* @param $age - number in seconds set by session.gc_maxlifetime value
* default is 1440 or 24 mins.
*
*/
function gc($age) {
$sql = "DELETE FROM sc_php_session WHERE updated_on < (now() - INTERVAL %d SECOND) ";
$sql = sprintf($sql,$age);
$stmt = $this->mysqli->prepare($sql);
if ($stmt) {
$stmt->execute();
$stmt->close();
} else {
trigger_error($this->mysqli->error, E_USER_ERROR);
}
}
}
}
?>
pour enregistrer le Gestionnaire de session objet,
$sessionHandler = new \com\indigloo\core\MySQLSession();
session_set_save_handler(array($sessionHandler,"open"),
array($sessionHandler,"close"),
array($sessionHandler,"read"),
array($sessionHandler,"write"),
array($sessionHandler,"destroy"),
array($sessionHandler,"gc"));
ini_set('session_use_cookies',1);
//Defaults to 1 (enabled) since PHP 5.3.0
//no passing of sessionID in URL
ini_set('session.use_only_cookies',1);
// the following prevents unexpected effects
// when using objects as save handlers
// @see http://php.net/manual/en/function.session-set-save-handler.php
register_shutdown_function('session_write_close');
session_start();
Voici une autre version réalisée avec AOP. Celui-ci vérifie l'existence de sessionId et fait la mise à jour ou L'insertion. J'ai aussi supprimé la fonction gc d'open() car elle déclenche inutilement une requête SQL sur chaque chargement de page. Le nettoyage de session périmé peut facilement être fait via un script cron. Ceci devrait être la version à utiliser si vous êtes sur PHP 5.x. Faites-moi savoir si vous trouvez des insectes!
=========================================
namespace com\indigloo\core {
use \com\indigloo\Configuration as Config;
use \com\indigloo\mysql\PDOWrapper;
use \com\indigloo\Logger as Logger;
/*
* custom session handler to store PHP session data into mysql DB
* we use a -select for update- row leve lock
*
*/
class MySQLSession {
private $dbh ;
function __construct() {
}
function open($path,$name) {
$this->dbh = PDOWrapper::getHandle();
return TRUE ;
}
function close() {
$this->dbh = null;
return TRUE ;
}
function read($sessionId) {
//start Tx
$this->dbh->beginTransaction();
$sql = " select data from sc_php_session where session_id = :session_id for update ";
$stmt = $this->dbh->prepare($sql);
$stmt->bindParam(":session_id",$sessionId, \PDO::PARAM_STR);
$stmt->execute();
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
$data = '' ;
if($result) {
$data = $result['data'];
}
return $data ;
}
function write($sessionId,$data) {
$sql = " select count(session_id) as total from sc_php_session where session_id = :session_id" ;
$stmt = $this->dbh->prepare($sql);
$stmt->bindParam(":session_id",$sessionId, \PDO::PARAM_STR);
$stmt->execute();
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
$total = $result['total'];
if($total > 0) {
//existing session
$sql2 = " update sc_php_session set data = :data, updated_on = now() where session_id = :session_id" ;
} else {
$sql2 = "insert INTO sc_php_session(session_id,data,updated_on) VALUES(:session_id, :data, now())" ;
}
$stmt2 = $this->dbh->prepare($sql2);
$stmt2->bindParam(":session_id",$sessionId, \PDO::PARAM_STR);
$stmt2->bindParam(":data",$data, \PDO::PARAM_STR);
$stmt2->execute();
//end Tx
$this->dbh->commit();
}
/*
* destroy is called via session_destroy
* However it is better to clear the stale sessions via a CRON script
*/
function destroy($sessionId) {
$sql = "DELETE FROM sc_php_session WHERE session_id = :session_id ";
$stmt = $this->dbh->prepare($sql);
$stmt->bindParam(":session_id",$sessionId, \PDO::PARAM_STR);
$stmt->execute();
}
/*
* @param $age - number in seconds set by session.gc_maxlifetime value
* default is 1440 or 24 mins.
*
*/
function gc($age) {
$sql = "DELETE FROM sc_php_session WHERE updated_on < (now() - INTERVAL :age SECOND) ";
$stmt = $this->dbh->prepare($sql);
$stmt->bindParam(":age",$age, \PDO::PARAM_INT);
$stmt->execute();
}
}
}
?>
Vérifiez avec mysql_affected_rows() si la serrure a été obtenue ou non. Si elle a été obtenue aller de l'avant. Si ce n'est pas le cas, réessayez l'opération toutes les 0,5 seconde. Si en 40 secondes la serrure n'est toujours pas obtenue, lancer une exception.
je vois un problème dans le blocage de l'exécution du script avec cette vérification continue d'une serrure. Vous suggérez que PHP s'exécute pendant 40 secondes à la recherche de cette serrure à chaque fois que la session est initialisée (si je lis que correctement.)
recommandation
si vous avez un environnement en grappe, je recommande fortement memcached . Il supporte une relation serveur/client pour que toutes les instances groupées puissent se reporter au serveur memcached. Il n'a pas de problèmes de verrouillage dont vous avez peur, et est très rapide. Citation de leur page:
quelle que soit la base de données que vous utilisez (MS-SQL, Oracle, Postgres, MySQL-InnoDB,etc..), il y a beaucoup de frais généraux dans l'implémentation des propriétés acides dans un RDBMS, surtout quand les disques sont impliqués, ce qui signifie que les requêtes vont être bloquées. Pour les bases de données qui ne sont pas compatibles avec L'acide (comme MySQL-MyISAM), ce overhead n'existe pas, mais les threads de lecture bloquent les threads d'écriture. memcached bloque jamais.
Sinon, si vous êtes toujours engagé dans un magasin de session RDBMS (et inquiet ce verrouillage va devenir un problème), vous pouvez essayer une sorte de sharding basé sur un identifiant de session collant (saisir à la courte paille ici. Ne rien savoir d'autre de votre architecture, c'est aussi spécifique que je peux l'être.
ma question est pourquoi verrouiller à tous? Pourquoi ne pas laisser la dernière écriture réussir? Vous ne devriez pas utiliser les données de session comme cache, donc les Écritures ont tendance à être peu fréquentes, et en pratique ne se piétinent jamais.