Home > Design Patterns, How-To, Open Source > Memcache Session Handler With a MySQL Backup Plan

Memcache Session Handler With a MySQL Backup Plan

This post aims to demonstrate how to build a custom session handler using memcached and a mysql database. After you’ve gotten memcached installed and running (Memcached Wiki), we can continue. If you use Debian it’s easy:

  1. Install Memcached: sudo apt-get install memcached
  2. Start Memcached: sudo memcached -d -m 1024 -l 127.0.0.1 -p 11211.

    -d means to run it as a daemon (in the background);
    -m designates the amount of memory to use, we have specified 1024 (1GB);
    -l is the location or IP address, we use 127.0.0.1, localhost;
    -p is the port the memcached server runs on, we use 11211

So, first we set up the session table in the database. We do this because if memcached fails, we need to be able to read and write session data. We will write to the database every time, and only read from the database if memcached is inaccessible.

** Tip: we use a ‘mediumtext’ field to store session data. If you use blob or text, mysql will nuke any session that gets too large. http://dev.mysql.com/doc/refman/5.5/en/storage-requirements.html

create table session 
(
	id varchar(50) primary key ,
	data mediumtext null ,
	status enum('active','destroyed','gc','expired') default 'active' ,
	last_modified int not null default 0 ,
	datestamp int not null default 0
);
create index idx_session on session ( status , last_modified desc , datestamp desc );

After the MySQL table is set up, and memcached is running on the server, we can code the session handler: PHP Reference. This code uses DB::GetPDO() to get a PHP PDO Object to run database operations on. You will have to make alterations to those sections when implementing this into your system. To support the script, I have provided a simple DB class below, that has a PDO singleton instance.

The Session Handler

<?php
 
define('MEMCACHE_HOST', '127.0.0.1');
define('MEMCACHE_PORT', 11211);
define('SESSION_NAME', 'MY_SESS_NAME');
 
class Session 
{
	/* session timeout, in seconds - 1800 is 30min */
	const SESSION_TIMEOUT = 1800;
 
	private static $__memcache_connectable = true;
	private static $__memcache;
	private static $instance;
	private static $initialized = false;
	private static $expired = false;
 
	/**
	* retrieve the memcached object
	*
	* @return mixed bool or Memcache object
	*/
	private static function Memcache()
	{
		if ( self::$__memcache_connectable && !isset(self::$__memcache) )
		{
			$m = new Memcache;
			if ( !$m->connect(MEMCACHE_HOST, MEMCACHE_PORT) )
			{
				// if you have a custom error handler, uncomment this
				// trigger_error('Could not connect to memcache server.', E_USER_WARNING);
 
				self::$__memcache_connectable = false;
				return 0;
			}
			self::$__memcache = $m;
			self::$__memcache_connectable = true;
		}
		return self::$__memcache;
	}
 
	/**
	* get the instance to this class
	*
	*/
	public static function Singleton()
	{
		self::Initialize();
		return self::$instance;
	}
 
	/**
	* initialize the session - calls session_start() and closes any sessions open prior
	*
	*/
	public static function Initialize()
	{
		if ( !self::$initialized )
		{
			if ( session_id() != '' )
				session_write_close();
 
			self::$instance = new Session;
			session_set_save_handler( 
				array(self::$instance, 'open'),
				array(self::$instance, 'close'),
				array(self::$instance, 'read'),
				array(self::$instance, 'write'),
				array(self::$instance, 'destroy'),
				array(self::$instance, 'gc') 				// garbage collector
			);
			self::$initialized = true;
 
			// If you rely on using session_set_cookie_params() to set the session-lifetime, you may encounter a bug
			// this function only sets the lifetime of the cookie when the cookie is FIRST created
			// all subsequent requests which use the existing cookie do not have their expire time modified
			// as a result, sessions timeout pre-maturely (unless your lifetime is set to 0) - use setcookie instead (see below)
			// session_set_cookie_params(self::SESSION_TIMEOUT);
 
			// start the session
			session_name( SESSION_NAME );
			session_start();
 
			// make sure PHP tells the cookie to expire properly
			setcookie(SESSION_NAME, session_id(), time()+self::SESSION_TIMEOUT, '/');
		}
	}
 
	/**
	* private constructor - called from self::Singleton
	*
	*/
	private function __construct()
	{
		// you may not instantiate this class
	}
 
	/**
	* when the session object is unset or destroyed this is called to close the session and the memcached connection
	*
	*/
	public function __destruct()
	{
		/*
			As of PHP 5.0.5 the write  and close  handlers are called after object destruction and therefore cannot use objects or throw exceptions. The object destructors can however use sessions.
			It is possible to call session_write_close() from the destructor to solve this chicken and egg problem. 
		*/
		session_write_close();
		if ( is_object(self::$__memcache) )
			self::$__memcache->close();
	}
 
	/**
	* you may not clone the session object
	*
	*/
	public function __clone()
	{
		trigger_error('You cannot clone the Session object.', E_USER_ERROR);
	}
 
	/**
	* session handler callback 'open'
	*
	* @param string $save_path the path the session is located
	* @return bool
	*/
	public function open( $save_path , $session_name )
	{
		// expire sessions
		if ( rand(0,1) && ($pdo = DB::GetPDO()) )
			$pdo->Query('update _session set status = "expired" where status = "active" and last_modified <= '. (time() - self::SESSION_TIMEOUT));
 
		return true;
	}
 
	/**
	* session handler callback 'close'
	*
	*/
	public function close()
	{
		return true;
	}
 
	/**
	* session handler callback 'read' - reads data from memcached when possible and from the db otherwise - if objects have a __wakeup, this is where they get called from
	*
	* @param string $sessid the session id
	* @return mixed false or the unserialized data from the session
	*/
	public function read( $sessid )
	{
		// read from memcache when possible
		if ( $m = self::Memcache() )
		{
			if ( $data = $m->get($sessid) )
				return $data;
		}
 
		// read from DB when memcache is inaccessible or does not return anything
		if ( $pdo = DB::GetPDO() )
		{
			if ( $st = $pdo->query('select data , last_modified , status from _session where id = '. $pdo->quote($sessid) .' and status = "active"') )
			{
				if ( $st->RowCount() > 0 && ($data = $st->FetchAll(PDO::FETCH_OBJ)) )
				{
					if ( $data[0]->last_modified <= time() - self::SESSION_TIMEOUT )
					{
						/**
						* if the session id cookie is set properlly and expires properlly, we should never get here
						* 
						* if we do get here, we have an active session whos last modified is too old, so we need to mark it as expired
						* since this was a request with an old cookie id, we invalidate that cookie and tell self::write not to store its data 
						*/
						$pdo->query('update _session set status = "expired" where id = '. $pdo->quote($sessid));
 
						// expire the session id cookie
						setcookie(SESSION_NAME, 'expired', 1, '/');
 
						self::$expired = true;
						return false;
					}
					return $data[0]->data;
				}
			}
		} else
			trigger_error('Session::read could not fetch a PDO object; mysql session storage fails.', E_USER_ERROR);
 
		return false;
	}
 
	/**
	* session handler callback 'write' - writes data to memcached and to the db every time
	*
	* @param string $sessid the session id
	* @param mixed $data the data being written to the session in an unserialized format
	*/
	public function write( $sessid , $data )
	{
		// if this session is expired, do not write
		if ( self::$expired )
			return;
 
		// always write to memcache
		if ( $m = self::Memcache() )
			$m->set($sessid, $data, false, self::SESSION_TIMEOUT);
 
		// always write to DB
		if( $pdo = DB::GetPDO() )
		{
			$st = $pdo->query('update session set data = '. $pdo->quote($data) .' , status = "active" , last_modified = '. time() .' where id = '. $pdo->quote($sessid));
			if ( !$st || $st->rowCount() <= 0 )
				$pdo->query('insert into session ( id , data , datestamp , last_modified ) values ( '. $pdo->quote($sessid) .' , '. $pdo->quote($data) .' , '. time() .' , '. time() .' )');
		}
	}
 
	/**
	* session handler callback 'destroy' - removes the specified session from memcached and from the db
	*
	* @param string $sessid the session id
	*/
	public function destroy( $sessid )
	{
		// remove from memcache
		if ( $m = self::Memcache() )
			$m->delete($sessid, 0);
 
		// remove from db
		if ( $pdo = DB::GetPDO() )
			$pdo->query('update session set status = "destroyed" where id = '. $pdo->quote($sessid));
	}
 
	/**
	* session handler callback 'gc' - the garbage collector removes old sessions that have been expired for at least 60 seconds
	*
	* @param int $max_sess_lifetime the maximum session lifetime (timeout) in seconds that a session can exist
	*/
	public function gc( $max_sess_lifetime )
	{
		if ( $pdo = DB::GetPDO() )
			$pdo->query('update _session set status = "gc" where status in ("active","expired") and last_modified + '. ($max_sess_lifetime + 60) .' < '. time());
		else
			trigger_error('Session::gc could not fetch a pdo object; garbage collection fails.', E_USER_WARNING);
	}
}

View: Session Class

DB Class

<?php
 
define('DATABASE_DSN', 'mysql:dbname=database_name;host=127.0.0.1;');
define('DATABASE_USERNAME', 'user');
define('DATABASE_PASSWORD', 'password');
 
class DB
{
	private static $pdo;
 
	public function GetPDO()
	{
		if ( !is_object(self::$pdo) )
			self::$pdo = new PDO(DATABASE_DSN, DATABASE_USERNAME, DATABASE_PASSWORD);
 
		return self::$pdo;
	}
}

Usage Example

1
2
3
4
5
6
7
<?php
 
require_once 'Session.php';
Session::Initialize();
 
// continue to use sessions normally
$_SESSION['myvar'] = 'some data';
VN:F [1.9.17_1161]
Rating: 0.0/10 (0 votes cast)
VN:F [1.9.17_1161]
Rating: +2 (from 4 votes)
  1. molexus
    February 8th, 2010 at 16:14 | #1

    This is neat! This is in fact so neat i wanna have an orgasm. Using memcached or mysql as a session handler should speed up things a lot!

    I’ll be sure to use this code in the future!

    Thanks a lot :)

    VA:F [1.9.17_1161]
    Rating: +4 (from 4 votes)
  2. Astrandai
    February 15th, 2010 at 18:56 | #2

    If you would like to use the memcache server to store other things besides sessions what is the best approach? The class closes the connection to the memcache server, but when is this triggerd exactly?

    can you pull out the memcache connection from this class and store it inside a factory?

    Some advice would be nice

    VA:F [1.9.17_1161]
    Rating: +1 (from 3 votes)
  3. February 16th, 2010 at 16:40 | #3

    @Astrandai
    Thank you for taking the time to comment. To answer your first question, yes memcached can be used for more than just session data storage. Memcached stores data in RAM and is, therefore, very efficient as a caching mechanism. When utilizing memcached to store other data, the main thing you need to concern yourself with is making sure you have a unique / encrypted id which you would use to access data with memcached. Since anyone connected to the memcached server can access its data, it’s best to assign a key for each user that you intend on storing data for (the key in my example is the unique session id). If you are storing global data that should be accessible to everyone, then I suppose a key isn’t that much of a concern.

    Secondly, my SessionHandler class closes its connection to memcached when the last object is destroyed: http://us.php.net/__destruct. PHP calls __destruct for us, and that’s when it happens.

    Thrid, You can most definitely turn this into a Factory; by which, I’m assuming you mean that you want to use the same memcached connection to do various operations. What I provided was the singleton approach to this, but you can most definitely use different logic to store and retrieve different cached data. One example of that would be to use it for session handlers, another example would be for application-scope data that the application shares between sessions, another example is simple cached user data. I will write a Factory module (using namespaces), to follow up with this post, so I can demonstrate the behavior I have just described… keep it locked.

    VN:F [1.9.17_1161]
    Rating: 0 (from 2 votes)
  4. Astrandai
    February 17th, 2010 at 17:29 | #4

    @Anthony
    Thanks for the explination, (I should have checked the php site myself,) but I got a little confused reading different things about the session_set_save_handler.

    Also when I said “factory” I meant “registry” cause I’m using a registry class.

    I studied the example you gave, and adjusted it to suit my application (i’m currently working on). I commented out the memcached stuff to check if i could get it working with the database first. I will be trying to use the memcached stuff tomorrow.

    question: you mark the sessions in the database as deleted, but remove them from the memcached server. Why remove them from the memcached server? Isn’t it easier to set an expire time for let say like 1 hour, that way memcached will clean it for you. And you can just mark all the sessions in the database in one go, so no need to loop the result set retrieved in the “gc” function.

    VA:F [1.9.17_1161]
    Rating: +1 (from 3 votes)
  5. February 25th, 2010 at 23:41 | #5

    @Astrandai
    Regarding the session timeout, I had a couple reasons for doing it this way, but after evaluating it a bit more, I do concur with your assessment. I have made the appropriate changes in the class. Also note that the database schema has changed slightly. As always, thanks for your feedback.

    VN:F [1.9.17_1161]
    Rating: 0 (from 2 votes)
  6. jehidock
    February 19th, 2011 at 12:49 | #6

    Memcache session handler.. Great! :)

    VA:F [1.9.17_1161]
    Rating: 0 (from 2 votes)
  1. No trackbacks yet.