Skip to content

Instantly share code, notes, and snippets.

@lyoshenka
Last active November 6, 2016 15:44
Show Gist options
  • Save lyoshenka/bee4afa9146ff794d233bad8d9b92e69 to your computer and use it in GitHub Desktop.
Save lyoshenka/bee4afa9146ff794d233bad8d9b92e69 to your computer and use it in GitHub Desktop.
Atomic, distributed locking with PHP and Redis

tl;dr

Use RedisLock::createAndLock() to create your locks. It will throw an exception if it cannot get the lock, unless you select another behavior. Choose the lock timeout carefully and release the lock when you're done. Don't forget to RedisLock::setRedis() before using the locks.

All Locks Must Be Released

Make sure you release any locks as soon as you're done with them. releaseAllCurrentlyHeldLocks() should be called at the end of a request or when your prorgam exits, but don't rely on that for normal functionality. Plus it's unclear if you get a lock and don't release it.

All Locks Have a Timeout

Redis does not have the ability to release locks when a client disconnects. So all locks must have a timout, and you have to explicitly say what timeout you want when you create the lock. For convenience, there's a DEFAULT_TIMEOUT constant in RedisLock. You can set the timeout really high if you want, but be very careful to properly release it when you're done (including if anything goes wrong).

Lock Behavior

If getting a lock fails, it will do one of three things. By default, it will throw a RedisLockFailException. It can also return false or block and try several times to get the lock, waiting longer and longer with each request. If it still cant get the lock (currently it tries 5 times), it will throw an exception. To choose which behavior you want, use the third parameter in create().

Don't Care About Distributed Locking?

Use this for simple file-based locking.

<?php
class RedisLockFailException extends Exception {}
class RedisLock
{
const DEFAULT_TIMEOUT = 20;
// what to do if we cannot get lock
const FAIL_EXCEPT = 'fail_except'; // throw exception
const FAIL_RETURN = 'fail_return'; // return false
const FAIL_BLOCK = 'fail_block'; // try again a few times, then throw exception
/** @var \Credis_Client */
protected static $redis;
// track all our locks so we can release them if there's an exception or if we forget
protected static $locks = [];
// lock name. if two locks share a name, only one will ever be locked at a time
protected $name;
// value is a unique value set when locking the lock.
// when we unlock, we send value to make sure the lock we're unlocking is this exact one
protected $value;
// what to do when a lock cannot be obtained
protected $failBehavior;
// how long before this lock expires. this is just in case we don't release the lock ourselves
// this is a failsafe. it should be set longer than you ever expect to need the lock for, but
// not so long that it freezes your app if the lock is never released
protected $timeout;
// does this lock need to be released. so we can release all locks without unneccesary redis queries
protected $needsRelease = false;
/**
* Set redis instance to use for communication with Redis server.
* You must call this before using any other function
*
* @param \Credis_Client $redis
*/
public static function setRedis(\Credis_Client $redis)
{
static::$redis = $redis;
}
protected static function ensureRedisIsSet()
{
if (!static::$redis)
{
throw new Exception('You must call setRedis() before using redis locks');
}
}
/**
********************** THIS IS PROBABLY THE METHOD YOU WANT *******************************
*
* Returns a lock object if the lock was successfully obtained. Store this object and call
* release() on it when you're ready to release the lock.
*
* @return RedisLock|bool
*/
public static function createAndLock($name, $timeout, $failBehavior = self::FAIL_EXCEPT)
{
$lock = static::create($name, $timeout, $failBehavior);
$return = $lock->lock();
return $return === true ? $lock : $return;
}
/**
* @return RedisLock
*/
public static function create($name, $timeout, $failBehavior = self::FAIL_EXCEPT)
{
$lock = new static($name, $timeout, $failBehavior);
static::$locks[] = $lock;
return $lock;
}
public function __construct($name, $timeout, $failBehavior)
{
if (!in_array($failBehavior, [static::FAIL_EXCEPT, static::FAIL_RETURN, static::FAIL_BLOCK]))
{
throw new DomainException('Unknown fail behavior: ' . $failBehavior);
}
if (!is_int($timeout))
{
throw new InvalidArgumentException('$timeout must be an integer.');
}
$this->name = $name;
$this->value = function_exists('random_bytes') ? base64_encode(random_bytes(15)) : uniqid();
$this->failBehavior = $failBehavior;
$this->timeout = (int) $timeout;
}
protected function key()
{
return 'redislock:' . $this->name;
}
/**
* Acquire lock
*
* @return bool True if lock acquired, false otherwise
* @trhows RedisLockFailException
*/
public function lock()
{
$maxTries = 5;
$numTries = 0;
static::ensureRedisIsSet();
while ($numTries < $maxTries)
{
$response = static::$redis->set($this->key(), $this->value, ['NX', 'EX' => $this->timeout]);
if ($response === true)
{
$this->needsRelease = true;
return true;
}
switch($this->failBehavior)
{
case static::FAIL_EXCEPT: throw new RedisLockFailException('Could not get lock "' . $this->name . '"');
case static::FAIL_RETURN: return false;
case static::FAIL_BLOCK: break; // do nothing, it's handled below
}
$numTries++;
if ($numTries < $maxTries)
{
sleep(pow(2, $numTries-1));
}
}
throw new RedisLockFailException('Could not get lock "' . $this->name . '" after ' . $numTries .' tries.');
}
public function release()
{
static::ensureRedisIsSet();
if ($this->needsRelease)
{
// script will unlock only if we're the ones who got the lock in the first place
// see Patterns section at http://redis.io/commands/set
$unlockScript = 'if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end';
static::$redis->eval($unlockScript, [$this->key()], [$this->value]);
$this->needsRelease = false;
}
}
/**
* DO NOT USE THIS METHOD FOR NORMAL LOCKING.
*
* createAndLock() returns a lock object. You should store that object and call its release()
* method when you're ready to release the lock.
*
* This should get called at the end of a request to clean up any leftover locks.
*/
public static function releaseAllCurrentlyHeldLocks()
{
static::ensureRedisIsSet();
foreach(static::$locks as $lock)
{
$lock->release();
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment