为什么需要锁:
redis写入时不带锁定功能,为防止多个进程同时进行一个操作,出现意想不到的结果。例如换领优惠券,如果用户同一时间并发提交换领码,在没有加锁限制的情况下,用户则可以使用同一个换领码同时兑换到多张优惠券。
锁的实现要注意的事项:
- 互斥,在任何时刻,对于同一条数据,只有一台应用可以获取到分布式锁;
- 不能发生死锁,一台服务器挂了,程序没有执行完,但是redis中的锁却永久存在了,那么已加锁未执行完的数据,就永远得不到处理了,直到人工发现,或者监控发现;
- 高可用性,可以保证程序的正常加锁,正常解锁;
- 加锁解锁必须由同一台服务器进行,不能出现你加的锁,别人给你解锁了。
Redis锁的重要命令:
SETNX ,(SET IF NOT EXISTS),可以理解为如果不存在则插入;
GETSET,利用了GETSET命令同时获取和赋值的特性,在此期间其他进程无法修改锁的值。
实现原理:
- 在进程请求执行操作前进行判断,加锁是否成功,加锁成功允许执行下步操作;
- 如果不成功,则判断锁的值(时间戳)是否大于当前时间,如果大于当前时间,则获取锁失败不允许执行下步操作;
- 如果锁的值(时间戳)小于当前时间,并且GETSET命令获取到的锁的旧值依然小于当前时间,则获取锁成功允许执行下步操作;
- 如果锁的值(时间戳)小于当前时间,并且GETSET命令获取到的锁的旧值大于当前时间,则获取锁失败不允许执行下步操作;
分布式锁实现方法一
以下是Redis实现分布式锁的完整PHP代码:
get($key);
//判断缓存中是否有数据
if(empty($result))
{
$status = TRUE;
while ($status)
{
//设置锁值为当前时间戳 + 有效期
$lockValue = time() + $lockExpire;
/**
* 创建锁
* 试图以$lockKey为key创建一个缓存,value值为当前时间戳
* 由于setnx()函数只有在不存在当前key的缓存时才会创建成功
* 所以,用此函数就可以判断当前执行的操作是否已经有其他进程在执行了
* @var [type]
*/
$lock = $redis->setnx($lockKey, $lockValue);
/**
* 满足两个条件中的一个即可进行操作
* 1、上面一步创建锁成功;
* 2、 1)判断锁的值(时间戳)是否小于当前时间 $redis->get()
* 2)同时给锁设置新值成功 $redis->getset()
*/
if(!empty($lock) || ($redis->get($lockKey) < time() && $redis->getSet($lockKey, $lockValue) < time() ))
{
//给锁设置生存时间
$redis->expire($lockKey, $lockExpire);
//******************************
//此处执行插入、更新缓存操作...
//******************************
//以上程序走完删除锁
//检测锁是否过期,过期锁没必要删除
if($redis->ttl($lockKey))
$redis->del($lockKey);
$status = FALSE;
}else{
/**
* 如果存在有效锁这里做相应处理
* 等待当前操作完成再执行此次请求
* 直接返回
*/
sleep(2);//等待2秒后再尝试执行操作
}
}
}
上面实例的解析:
- 进程1获得锁后操作超时/崩溃/删除锁失败
- 进程2检测到锁已存在,但获取锁的值对比当前时间发现锁已过期,
- 进程2通过GETSET命令重新给锁赋予新的值,并获取到的锁的旧值,再次对比锁的旧值与当前时间,如果锁的旧值依然小于当前时间的话,这时进程2就可以忽略进程1余留下的废锁进行下步操作了。
- 进程2完成下步操作后返回前应该删除锁,但在删除锁时可以先检测锁是否还未过期,未过期才做删除操作,已过期的就没必要在去删除锁了,因为很有可能其他进程检测到锁过期时已经去获取锁了。
- 这里要说明的是,如果有其他进程在进程2之前获取到锁,那么进程2将获取锁失败,但是进程2在用GETSET获取锁的旧值时也赋予了锁新的值,改写了其他进程赋予锁的超时值。看到这大家可能会有疑问了,进程2没获取到锁怎么能改变锁的值呢?是的,进程2改变了锁的原有值,但这一点小小的时间误差带来的影响是可以忽略。
如果还不是很明白建议:查看原文,原文说的非常清晰,
https://www.cnblogs.com/wenxiong/p/3954174.html
分布式锁实现方法二
php集群的Redis分布式实例:
class RedLock
{
private $retryDelay;
private $retryCount;
private $clockDriftFactor = 0.01;
private $quorum;
private $servers = array();
private $instances = array();
function __construct(array $servers, $retryDelay = 200, $retryCount = 3){
$this->servers = $servers;
$this->retryDelay = $retryDelay;
$this->retryCount = $retryCount;
$this->quorum = min(count($servers), (count($servers) / 2 + 1));
}
public function lock($resource, $ttl){
$this->initInstances();
$token = uniqid();
$retry = $this->retryCount;
do {
$n = 0;
$startTime = microtime(true) * 1000;
foreach ($this->instances as $instance) {
if ($this->lockInstance($instance, $resource, $token, $ttl)) {
$n++;
}
}
# Add 2 milliseconds to the drift to account for Redis expires
# precision, which is 1 millisecond, plus 1 millisecond min drift
# for small TTLs.
$drift = ($ttl * $this->clockDriftFactor) + 2;
$validityTime = $ttl - (microtime(true) * 1000 - $startTime) - $drift;
if ($n >= $this->quorum && $validityTime > 0) {
return [
'validity' => $validityTime,
'resource' => $resource,
'token' => $token,
];
} else {
foreach ($this->instances as $instance) {
$this->unlockInstance($instance, $resource, $token);
}
}
// Wait a random delay before to retry
$delay = mt_rand(floor($this->retryDelay / 2), $this->retryDelay);
usleep($delay * 1000);
$retry--;
} while ($retry > 0);
return false;
}
public function unlock(array $lock){
$this->initInstances();
$resource = $lock['resource'];
$token = $lock['token'];
foreach ($this->instances as $instance) {
$this->unlockInstance($instance, $resource, $token);
}
}
private function initInstances(){
if (empty($this->instances)) {
foreach ($this->servers as $server) {
list($host, $port, $timeout) = $server;
$redis = new \Redis();
$redis->connect($host, $port, $timeout);
$this->instances[] = $redis;
}
}
}
private function lockInstance($instance, $resource, $token, $ttl){
return $instance->set($resource, $token, ['NX', 'PX' => $ttl]);
}
private function unlockInstance($instance, $resource, $token){
$script = '
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
';
return $instance->eval($script, [$resource, $token], 1);
}
}
调用方法:
注意:下面的集群的服务器地址,要写自己存在的,如果不存在要注释掉;否则报错
$servers = [
['127.0.0.1', 6379, 0.01],
['127.0.0.1', 6380, 0.01],
//['127.0.0.1', 6399, 0.01],;
];
$redLock = new RedLock($servers);
while (true) {
$lock = $redLock->lock('test', 10000);
if ($lock) {
print_r($lock);
//return false;这里在测试时候,可以打开
} else {
print "Lock not acquired\n";
}
}
实现分布式锁用到的Redis命令介绍:
setnx(key, value)
将key的值设为value,当且仅当key不存在。
若给定的key已经存在,则SETNX不做任何动作。
SETNX是”SET if Not eXists”(如果不存在,则SET)的简写。
返回值:
设置成功,返回1。
设置失败,返回0。
get(key)
返回key所关联的字符串值。
如果key不存在则返回特殊值nil。
假如key储存的值不是字符串类型,返回一个错误,因为GET只能用于处理字符串值。
返回值:
key的值。
如果key不存在,返回nil。
getset(key, value)
将给定key的值设为value,并返回key的旧值。
当key存在但不是字符串类型时,返回一个错误。
返回值:
返回给定key的旧值(old value)。
当key没有旧值时,返回nil。
expire(key, seconds)
为给定key设置生存时间。
当key过期时,它会被自动删除。
在Redis中,带有生存时间的key被称作“易失的”(volatile)。
在低于2.1.3版本的Redis中,已存在的生存时间不可覆盖。
从2.1.3版本开始,key的生存时间可以被更新,也可以被PERSIST命令移除。(详情参见 http://redis.io/topics/expire)。
返回值:
设置成功返回1。
当key不存在或者不能为key设置生存时间时(比如在低于2.1.3中你尝试更新key的生存时间),返回0。
ttl(key)
返回给定key的剩余生存时间(time to live)(以秒为单位)。
返回值:
key的剩余生存时间(以秒为单位)。
当key不存在或没有设置生存时间时,返回-1 。
del(key)
移除给定的一个或多个key。
返回值:
被移除key的数量。
使用分布式锁 实现秒杀的实例有:
主要针对并发情况下,通过redis的分布式锁和队列的方式进行处理的代码
Queue:{商品ID}: 数据类型是有序集合(zset),成员是用户ID,score是用户入队的时间戳
Lock:Queue:{商品ID}: 数据类型是字符串(string),存储的是该锁的过期时间
goods:{商品ID}:stock: 存储的是商品的库存数量
简单介绍demo代码中的实现思路:
将当前秒杀的商品id作为一个队列名称
$queue_name = “Queue:{商品ID}”;
对$queue_name进行加锁
- 通过setnx(满足原子性)实现加锁 :$redis->setnx("Lock:Queue:{商品ID}", $expire time)
加锁成功,给该锁设置一个过期时间,主要是为了防止死锁
如果加锁失败,通过设置休眠时间,进行循环请求
加锁成功后,判断队列中的成员数是否超过指定的大小
$count = $this->redis->zCard("Queue:{$name}");
if($count >= $this->redis->get("goods:{$name}:stock")) {
$this->lockModel->unlock("Queue:$name");
return '超过指定集合数量';
}
判断用户ID是否存在队列中,如不存在则加入队列(score:存入的是当前时间戳 )
if (false === $this->redis->zScore("Queue:$name", $user_id)) {
$this->redis->zAdd("Queue:$name", $score, $user_id);
}
入队成功,进行解锁$redis->del("Lock:Queue:{商品ID}");提示用户抢购成功。成功的用户会跳转到确认购买的页面,点击确认后才会生成订单、出队等后续操作
ps: 针对多个账号,一次性发送多个请求可以通过ip的访问频率的限制来预防
demo代码的下载地址:https://github.com/Ritajiajia/redis_test/tree/master/test/seckill
参考文章有:
https://www.cnblogs.com/wenxiong/p/3954174.html //setnx 实现
https://www.jianshu.com/p/934ddfed599b //lua脚本实现
http://blog.jobbole.com/95156/ 锁秒杀实现实例