PHP Logo

We just finished migrating our PHP Sessions from the default PHP session save location (file) to a distributed system (Redis) and I wanted to share our experience.

As always, I’ve made every effort to test this but mistakes happen and we take no responsibility for any problems this may cause. Make sure you manually test this on your application BEFORE you deploy it to your live server. The risk of data loss/annoyed customers is very real with this process. I ran over the process several times before deploying the results.

Default Configuration

By default, PHP stores it’s session information to disk. For most applications this is fine because you only have one server. Problems occurs when you try to add a second or even third server. Storing the session information to disk can cause people to be randomly logged out of your site as they are switched between servers. Moving the session data to a system where all the servers can access the information allows the user to seamlessly switch between servers.

Picture of PHPinfo displaying the default session information

Why Redis

When we started looking at options for this change several options came up.

We discarded MySQL early because although we already have it setup there are some performance problems with storing sessions in a database. We’re not sure at what level these become a problem but we didn’t want to tempt fate. :-)

We tried using memcached for the session storage but we found it difficult to reconfigure the settings when we added/removed nodes. Memcache also doesn’t persistent to disk so if a node goes down it starts with a blank slate and can cause people to loose data.

Redis was a good choice because it saves data to disk every so often (depending on your settings) and if a node crashes it will automatically recreate it’s data from the master node. Redis allows you to set an expiration for the keys so PHP doesn’t have to do garbage collection. We’re also already using Redis for Resque so that make it an easy choice. :-)

I should point out that there are other options to solve this problem (glusterfs to replicate disk based data, other in memory solutions, etc) so this is not an exhaustive list.

The Plan

The goal in this process is to not log anyone out. You could easily make this change by forcing all of your users to log back in but we wanted to provide the least number of problems for our customers. I’ve accidentally logged everyone out before (accidental rm in the wrong directory) and it caused a huge number of calls to our support line because people lost information they were entering into a form.

To this end, we’re going to slowly transition people from disk to in memory storage using the following steps:

  1. Save to disk and Redis but read from disk
  2. Perform bulk migrate
  3. Read just from Redis
  4. Save just to Redis

New Session Handler

In order to create your own session handler, PHP requires you create a class that implements the SessionHandlerInterface. Below is an implementation for this class and if you look at the SessionHandlerInterface PHP documentation you’ll actually see that most of this file is just a copy/paste of their example. Several of the functions (open, close, gc) don’t actually do anything in the Redis case but they need to return true;.

We’re also using the Credis Library to make working with Redis easier.

<?php
//Filename RedisFileSessionHandler.php
class RedisFileSessionHandler implements SessionHandlerInterface
{
    private $savePath;
    private $redisConnection;

    public function __construct($redisConnection)
    {
        $this->redisConnection = $redisConnection;
    }

    public function open($savePath, $sessionName)
    {
        $this->savePath = $savePath;
        if (!is_dir($this->savePath)) {
            mkdir($this->savePath, 0777);
        }

        return true;
    }

    public function close()
    {
        return true;
    }

    public function read($id)
    {
        $redisData = $this->redisConnection->get("sess_$id");

        // uncomment this to read data from redis.  It's nice to have this here for testing
        // return $redisData;

        $fileData = (string)@file_get_contents("$this->savePath/sess_$id");

        return $fileData;
    }

    public function write($id, $data)
    {
        $this->redisConnection->set("sess_$id", $data);
        // values expire after 30 days (or whatever you want it to be)
        $this->redisConnection->expire("sess_$id", 60 * 60 * 24 * 30);

        return file_put_contents("$this->savePath/sess_$id", $data) === false ? false : true;
    }

    public function destroy($id)
    {
        $this->redisConnection->delete("sess_{$id}");
        
        $file = "$this->savePath/sess_$id";
        if (file_exists($file)) {
            unlink($file);
        }

        return true;
    }

    public function gc($maxlifetime)
    {
        return true;
    }
}

In your PHP code you need to tell PHP to use the new session handler.

<?php
require_once('RedisFileSessionHandler.php');
$redisConnection = new Credis_Client('localhost');
$sessionHandler = new RedisFileSessionHandler($redisConnection);
session_set_save_handler($sessionHandler, true);

We also setup Redis Sentinel so if a node goes down we have automatic fail over. Credis is nice enough to provide an easy way to interact with Redis Sentinel as if it were a single server so we can use the same RedisFileSessionHandler class and just pass the connection to the cluster.

<?php
require_once('RedisFileSessionHandler.php');
$sentinel = new Credis_Sentinel(new Credis_Client('localhost', 26379));
$cluster = $sentinel->getCluster('mymaster');
$sessionHandler = new RedisFileSessionHandler($cluster);
session_set_save_handler($sessionHandler, true);

Testing and Switch Off Disk

Now that we have our application writing to both Redis and disk we can test it to make sure it doesn’t cause any performance problems. I recommend using New Relic for this and I would highly recommend you wait at least a day to make sure nothing horrible happens. While we were testing we found a spot in some older code that was serializing a class into the session and then it was never unserialized. The serialized version of the class was hundreds of megabytes for some users so we removed the call to the serialization and it was easily fixed.

Bulk Migrate

So now that we have our application writing to Redis it’s time to migrate our user’s data over. There are two ways we can do this. The first is to allow it to happen organically. If you expire sessions after a short period of time this might be an excellent way to go. However, if you can’t wait that long you’ll need to run a migration script:

<?php
$redisConnection = new Credis_Client('localhost');

foreach (glob("/path/sess_*") as $file) {
    $key = str_replace($file, "/path/", '');

    $data = file_get_contents($file);

    $redisConnection->set($key, $data);
    // values expire after 30 days (or whatever you want it to be)
    $redisConnection->expire($key, 60 * 60 * 24 * 30);
}

Switch Over

Now that our user’s sessions have been migrated we can switch over to reading from Redis:

<?php
class RedisFileSessionHandler implements SessionHandlerInterface
{
    public function read($id)
    {
        $redisData = $this->redisConnection->get("sess_$id");
        return (string)$redisData;
    }
}

Finally, we’re going to make a small change to the write function so it doesn’t save to disk anymore.

<?php
class RedisFileSessionHandler implements SessionHandlerInterface
{
    public function write($id, $data)
    {
        $this->redisConnection->set("sess_$id", $data);
        // values expire after 30 days (or whatever you want it to be)
        $this->redisConnection->expire("sess_$id", 60 * 60 * 24 * 30);

        return true;
    }
}

Conclusion

This process worked really well for us and was easy to implement once we figured everything out. Let us know in the comments if this has helped you.

Pinterest facebook

Like this post? Don't forget to follow us on Twitter and Facebook for updates.