分布式锁
1、为什么需要分布式锁
首先我们通过下面的范例来说明分布式的来历。
假如我们需要做一个秒杀抢购的业务,操作流程主要是先获取库存,如果库存大于0就可以下单,然后库存数量减1,订单数量加1。正常情况下,肯定是没有任何问题的,在并发情况下很可能出现超卖问题,下面将使用C#代码编写的程序来模拟超卖现象。
c#
var minute = 30;// 秒杀对应的分钟
using (var client = new RedisClient("127.0.0.1", 6379))
{
// 设置库存数量为10
client.Set<int>("inventoryNum", 10);
// 设置订单数为0
client.Set<int>("orderNum", 0);
}
// 启动10个线程去抢购
Console.WriteLine($"在{minute}分0秒正式开启秒杀!");
var flag = true;
while (flag)
{ // 判断分钟数是否等于秒杀时间
if (DateTime.Now.Minute == minute)
{
flag = false;
// 模拟并发操作
Parallel.For(0, 30, (i) =>
{
int temp = i;
// 启动子线程
Task.Run(() =>
{
using (var client = new RedisClient("127.0.0.1",
6379))
{
var inventory = client.Get<int>
("inventoryNum");
if (inventory > 0)
{
// 库存减1
client.Set<int>("inventoryNum",
inventory - 1);
// 订单数量加1
var orderNum = client.Incr("orderNum");
Console.WriteLine($"抢购成功*****线程id:
{ Thread.CurrentThread.ManagedThreadId.
ToString("00")},库存:{inventory},
订单数量:{orderNum}");
}
else
{
Console.WriteLine("抢购失败");
}
}
});
});
}
}

上面代码的运行结果如图所示,出现了大量的超卖数据。本来只有10个库存,在并发操作下,下的订单数量明显超过了10个库存。要解决这个问题,我们想到的是在模拟多个线程的地方加锁。
改进后的代码如下:
c#
...
while (flag)
{
if (DateTime.Now.Minute == minute)
{
flag = false;
// 模拟并发操作
Parallel.For(0, 30, (i) =>
{
int temp = i;
// 启动子线程
Task.Run(() =>
{
lock ("不同业务不同资源对象")
{
// 业务操作,判断库存,减库存,加订单
...
}
});
});
}
}
上面代码的运行结果如图所示,使用程序锁后没有出现超卖问题。

但是当我们对当前业务进行分布式部署的时候(同一个业务在不同的进程或者不同的服务器中),同一时间多个客户端请求到不同的服务进程时,多个客户端同时进行秒杀抢购就会出现超卖情况,抢购结果如图所示。

同时启动三个进程,然后进行秒杀抢购时还是出现了超卖现象,这是因为在C#代码中的锁是依赖于当前应用程序进程的,因为锁中的对象在同一个进程中是同一个,在不同的进程中是多个对象,所以启动三个进程的时候实质上是三把锁,所以还是会出现超卖。这时,我们应该在多个进程中维护同一个对象,对同一个资源进行控制。当然,也可以选择关系型数据库(MySQL和SQLServer),通过给表中的数据加标记的方式(时间戳)来判断是否可以抢到锁。针对高并发的应用场景,关系型数据库存在IO瓶颈,分布式内存数据库Redis却是一个不错的选择。
2、非阻塞锁的实现和原理
实现非阻塞锁的相关代码如下:
bash
// 如果键不存在,则设置成功;如果键已经存在,则返回失败
SETNX datalock VALUE
// 设置键过期的时间
PEXPIRE datalock 1500
原理解析:首先尝试在Redis中创建一个字符串结构的缓存,该方法传入了键(为业务标识)和值(此处可为任意值),非阻塞锁后为传入的过期时间(timeout)。若Redis中没有键,则创建成功(抢到锁),然后立即返回true。若已经有键,则立即返回false(没有抢到锁)。以上过程为全局单线程原子操作,即整个过程为独占式操作。为了防止当前操作的Redis客户端宕机而出现锁一直不释放的可能,可以设置过期时间。当超过这个过期时间之后,键会被Redis服务自动清除。在正常情况下,当拿到锁,等待业务操作完成之后,当前客户端会删除当前键(释放锁)以供给其他客户端使用。
改进的代码如下:
c
var minute = 08;
using (var client = new RedisClient("127.0.0.1", 6379))
{
// 设置库存数量为10
client.Set<int>("inventoryNum", 10);
// 设置订单数为0
client.Set<int>("orderNum", 0);
}
// 启动10个线程去抢购
Console.WriteLine($"在{minute}分0秒正式开启秒杀!");
var flag = true;
while (flag)
{
if (DateTime.Now.Minute == minute)
{
flag = false;
// 模拟并发操作
Parallel.For(0, 30, (i) =>
{
int temp = i;
// 启动子线程
Task.Run(() =>
{
using (var client = new RedisClient("127.0.0.1",
6379))
{
// 设置锁过期时间
var timeout = TimeSpan.FromSeconds(10);
// 获取锁,如果key:DataLock不存在则返回true,
// 否则返回false
bool isLocked = client.Add<string>("DataLock"
+ "秒杀", "", timeout);// 如果拿到锁,
则可以进行业务处理,否则抢购失败
if (isLocked)
{
var inventory = client.Get<int>
("inventoryNum");
if (inventory > 0)
{
// 库存减1
client.Set<int>("inventoryNum",
inventory - 1);
// 订单数量加1
var orderNum =
client.Incr("orderNum");
Console.WriteLine($"抢购成功*****线程
id: { Thread.CurrentThread.
ManagedThreadId.ToString("00")},库存:
{inventory},订单数量:{orderNum}");
// 执行完成,删除键,释放锁,给客户端使用
client.Remove("DataLock" );
}
else
{
Console.WriteLine("抢购失败");
}
}
else
{
Console.WriteLine("没有抢到锁");
}
}
});
});
}
}
上面代码的执行结果如图13-4所示,使用非阻塞锁模拟多个客户端同时进行秒杀抢购也没有出现超卖情况。

在图中,库存没有因被同时抢购出现超卖,其原因是,同一时刻去抢锁的时候,锁已经被一个线程抢到,而且该线程的业务在短时间内没有执行完,造成了其他线程没有拿到锁而直接返回false了,这种没有抢到锁就立刻返回结果的锁称为非阻塞锁。
3、阻塞锁的实现和原理
在客户端拿锁却没有抢到锁的时候,可以重试多等待一会,等上一个客户端执行完业务并释放锁之后再一次去抢锁。如果在等待时间内抢到了,就返回true;如果没有抢到,就再返回false,这种锁就是阻塞锁。
实现代码如下:
c#
while (flag)
{
if (DateTime.Now.Minute == minute)
{
flag = false;
// 模拟并发操作
Parallel.For(0, 30, (i) =>
{
int temp = i;
// 启动子线程
Task.Run(() =>
{
// 此处应该使用try catch,防止重试抢不到锁而抛出异常
using (var client = new RedisClient("127.0.0.1",
6379))
{
// 这个时间代表两个意思,一个是锁过期时间,
// 另一个是抢不到锁需要等待的时间
var timeout = TimeSpan.FromSeconds(10);
// 阻塞方式抢锁
using (var datalock = client.AcquireLock
("BlockDataLock" + "", timeout))
{
// 此处省略拿到锁之后需要执行的业务代码
}
}
}
) ;
// Thread.Sleep(100);
});
}
}
上面代码的执行结果如图所示,使用阻塞锁模拟多个客户端同时进行秒杀而没有出现超卖情况。

需要注意的是,非阻塞锁是否可以支持到把所有库存卖完,这与业务执行时间和等待抢锁的时间有关,但是最后的结果肯定是订单数量小于等于库存数量。
接下来,我们解析一下AcquireLock阻塞锁的源代码。AcquireLock源代码如下:
c
using System;
using ServiceStack.Common;
using ServiceStack.Text;
namespace ServiceStack.Redis
{
public class RedisLock
: IDisposable
{
private readonly IRedisClient redisClient;
private readonly string key;
public RedisLock(IRedisClient redisClient, string key, TimeSpan?
timeOut)
{
this.redisClient = redisClient;
this.key = key;
// 等待超时(timeOut)时间,如果没有抢到就继续在此尝试抢锁
ExecUtils.RetryUntilTrue(
() =>
{
// Calculate a unix time for when the lock should expire
// 如果提供过期时间,默认就创建一个过期时间,比如一年
var realSpan = timeOut ?? new TimeSpan(365, 0, 0, 0);
// if nothing is passed in the timeout hold for a year
var expireTime = DateTime.UtcNow.Add(realSpan);
var lockString = (expireTime.ToUnixTimeMs() + 1)
.ToString();
// Try to set the lock, if it does not exist this will
// succeed and the lock is obtained
// 拿锁,如果键不存在,则直接写入,拿到锁
var nx = redisClient.SetValueIfNotExists(key,
lockString);
if (nx)
return true;
// If we've gotten here then a key for the lock is
// present. This could be because the lock is
// correctly acquired or it could be because a client
// that had acquired the lock crashed (or didn't release
// it properly).
// Therefore we need to get the value of the lock to
// see when it should expire
// 先监听当前这个字段的版本号,为了下一次使用
redisClient.Watch(key);
var lockExpireString = redisClient.Get<string>(key);
// 时间格式--
if (!long.TryParse(lockExpireString, out var
lockExpireTime))
{
// 取消监听
// since the client is scoped externally
redisClient.UnWatch();
return false;
}
// 锁没有失效
if (lockExpireTime > DateTime.UtcNow.ToUnixTimeMs())
{
// 取消监听
// since the client is scoped externally
redisClient.UnWatch();
return false;
}
// 锁过期
// we started the "Watch" above; this tx will succeed
// if the value has not moved
using (var trans = redisClient.CreateTransaction())
{
// 抢锁,带着版本号去写,如果版本号相同,则执行成功,
// 如果版本号不相同,则抢锁失败
trans.QueueCommand(r => r.Set(key, lockString));
return trans.Commit(); // returns false if
Transaction failed
}
},
timeOut
);
}
public void Dispose()
{
redisClient.Remove(key);
}
}
}
通过上面的源代码可以看出,客户端在Redis中创建一个字符串结构的缓存,该方法传入了键(业务标识)和value(为锁的过期时间timeout的时间戳)。如果Redis中没有键,则创建成功(抢到锁),然后立即返回。如果已经有键,则先监视,然后校验value中的时间戳是否已经超过当前时间。如果已超过,则尝试使用提交事务的方式覆盖新的时间戳,事务提交成功(抢到锁),然后立即返回。若未超过当前时间或事务提交失败(被别人抢到锁),则进入一个微循环,不断重试抢锁。传入的timeout还有一个作用,就是控制重试时间,重试超时后则抛出异常。using方法体执行完成之后或者直接显式调用dispose都会直接清除键。
需要注意的是,传入timeout有两个意思:一是成功加锁后锁的过期时间,二是未成功加锁后阻塞等待的时间。数据锁服务通过检查value中的时间戳来判断是否过期,并不是利用Redis在键上设置过期时间(expire time)来实现的。
4、Redlock原理
上面的两种锁存在两个明显的问题:第一个是单机问题,当单机宕机之后,当前所有依赖Redis锁的业务都变得不可用了,因为无法进行抢锁操作;第二个问题就是出现下面的状况,会出现锁失效。
当客户端1抢到了锁,然后锁过期时间是5秒,当客户端1执行业务时,延迟时间超过5秒,当客户端2去抢锁的时候发现前次锁的过期时间已到或者失效了,客户端2抢到锁。而后客户端1的业务执行完毕,删除锁,此时删除的锁其实是客户端2的锁,不是上次客户端1自己抢到的锁。接下来客户端3、客户端4都是这样的问题,进而造成多个客户端同时操作一份锁资源,如图所示。

4.1、什么是Redlock
Redis官方网站的文章提出了一种基于Redis实现分布式锁的方式------Redlock算法,此种方式比原先的单节点方法更安全,可以保证以下特性:
安全特性:互斥。在任何给定时刻,只有一个客户端可以持有锁。高可用性:无死锁。即使持有锁资源的客户端崩溃或分区了,也始终可以获得锁。容错性:容错能力。只要大多数Redis节点都处于运行状态,客户端就可以获取和释放锁。
4.2、单节点上实现Redlock
单节点上实现Redlock的命令如下:
bash
SET resource_name my_random_value NX PX 30000
该命令仅当键不存在时(NX保证)来设置值,设置过期时间为3000毫秒(PX保证),其中my_random_value的值必须是所有客户端和所有锁请求发生期间唯一的值,
释放锁的程序逻辑如下:
lua
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这个Lua脚本可以避免错误地释放掉另一个客户端创建的锁。如果只有del命令,那么当客户端1拿到锁lock1之后因为某些操作而阻塞了很长时间,而Redis端的lock1已经过期了并且已经被重新分配给客户端2,那么客户端1再去释放这把锁就会造成客户端2原本获得的锁被客户端1无故释放。现在为每个客户端都分配了一个唯一的string值就可以避免这个问题的发生。
4.3、Redlock算法
假设我们有N个Reids服务,分布在不同的机房以尽量保证可用性。客户端会进行如下操作来获取锁:
- 它以毫秒为单位获取当前时间。
- 它尝试在所有N个实例中依次使用各个实例中相同的键名和随机值来获取锁。在第2步中,在每个实例中设置锁时,客户端使用的超时时间(timeout)小于锁自动释放的时间。比如,自动释放的时间为10秒,超时时间可能在5到50毫秒之间,这样可以防止客户端长时间与处于故障状态的Redis节点进行通信:如果某个实例不可用,那么我们应尝试与下一个实例尽快进行通信。
- 客户端通过从当前时间中减去在步骤1中获得的时间戳来计算获取锁所花费的时间。当且仅当客户端能够在大多数实例(至少3个)中获取锁时,获取锁所花费的总时间小于锁有效时间则认为已获取锁。
- 如果获取了锁,则将其有效时间视为初始有效时间减去经过的时间,如步骤3中所计算的。
- 如果客户端由于某种原因(无法锁定N/2+1个实例或有效时间为负数)而未能获得该锁,就将尝试删除已经成功申请锁的节点的锁数据(申请成功锁的主节点上执行释放锁的操作,重置状态)。
1、失败重试
如果一个客户端申请锁失败了,那么它需要稍等一会再重试以避免出现多个客户端同时申请锁的情况,极端情况是一个客户端需要几乎同时向N个主节点发起锁申请。另外,如果客户端申请锁失败了,就需要尽快在它曾经申请到锁的主节点上执行unlock操作,便于其他客户端获得这把锁,避免这些锁要等到过期释放而造成时间的浪费。
2、释放锁
释放锁的操作很简单,就是依次释放所有节点上的锁。
3、可能出现的问题
使用Redis作为锁服务器需要满足高性能,因为许多用户在获取和释放锁的延迟以及每秒可能执行的获取或者释放锁操作等方面都需要高性能。所以,与N个Redis服务器进行通信以减少延迟的策略肯定是多路复用。对于能从崩溃中恢复的系统,还需要考虑持久化。
假设我们没有持久性地配置Redis,假如客户端已从5个实例(或服务节点)中的3个获取了锁,而客户端要获取锁的一个实例被重新启动了,之后我们要为这个实例再次锁定3个实例,而另一个客户端也可以再次锁定它,这就违反了锁的排他性的安全性。如果启用AOF持久化,那么情况将会大大改善。当出现了停电等不可控的情况呢?如果默认情况下将Redis配置为每秒在磁盘上执行fsync操作,则重启后键可能丢失了。从理论上讲,如果要保证在遇到任何形式的实例重新启动时保证锁的安全,那么我们需要在持久化设置中始终启用fsync=always。不过,这样一来将完全牺牲了性能。解决这个问题的方法是,当一个节点重启之后,我们规定在max TTL(实例崩溃时所有与锁有关的键存活的时间)期间它是不可用的,这样就不会干扰原本已经申请到的锁。
以C#实现的源代码如下:
c#
// 需要依赖于StackExchange.Redis
// lock 结构类型
public class Lock
{
public Lock(RedisKey resource, RedisValue val, TimeSpan validity)
{
this.resource = resource;
this.val = val ;
this.validity_time = validity;
}
private RedisKey resource;
private RedisValue val;
private TimeSpan validity_time;
public RedisKey Resource { get { return resource; } }
public RedisValue Value { get { return val; } }
public TimeSpan Validity { get { return validity_time; } }
}
// Redlock的实现
public class Redlock
{
// 连接多个节点的Redis服务
public Redlock(params IConnectionMultiplexer[] list)
{
foreach(var item in list)
this.redisMasterDictionary.Add(item.GetEndPoints().First().
ToString(),item);
}
// 默认重试次数
const int DefaultRetryCount = 3;
// 默认每次重试等待时间为200毫秒
readonly TimeSpan DefaultRetryDelay = new TimeSpan(0, 0, 0, 0, 200);
// 时钟驱动因子
const double ClockDriveFactor = 0.01;
// 节点的大多数
protected int Quorum { get { return (redisMasterDictionary.Count / 2)
+ 1; } }
// / <summary>
// /释放锁的脚本
// / </summary>
const String UnlockScript = @"
if redis.call(""get"",KEYS[1]) == ARGV[1] then
return redis.call(""del"",KEYS[1])
else
return 0
end";
// 客户端抢锁的唯一标识,全局唯一
protected static byte[] CreateUniqueLockId()
{
return Guid.NewGuid().ToByteArray();
}
protected Dictionary<String,IConnectionMultiplexer>
redisMasterDictionary = new Dictionary<string,
IConnectionMultiplexer>();
// 构造连接Redis的实例
protected bool LockInstance(string redisServer, string resource, byte[]
val, TimeSpan ttl)
{
bool succeeded;
try
{
var redis = this.redisMasterDictionary[redisServer];
// 写入键,如果键不存在就返回true,否则返回false
// 键为资源,值是客户端抢锁生成的唯一字符串(string),
// 并且设置了过期时间
succeeded = redis.GetDatabase().StringSet(resource, val, ttl,
When.NotExists);
}
catch (Exception)
{
succeeded = false;
}
return succeeded;
}
// 释放锁的方法执行释放锁的Lua脚本,在每一个服务器上都执行
protected void UnlockInstance(string redisServer, string resource,
byte[] val)
{
RedisKey[] key = { resource };
RedisValue[] values = { val };
var redis = redisMasterDictionary[redisServer];
redis.GetDatabase().ScriptEvaluate(
UnlockScript,
key,
values
);
}
// 抢锁,如果成功就返回true,否则返回失败
public bool Lock(RedisKey resource, TimeSpan ttl, out Lock lockObject)
{
// 生成抢锁前的唯一字符串
var val = CreateUniqueLockId();
Lock innerLock = null;
// 重试多次抢锁
bool successfull = retry(DefaultRetryCount, DefaultRetryDelay,
() =>
{
try
{
int n = 0;
var startTime = DateTime.Now;
// 抢锁
for_each_redis_registered(
redis =>
{
if (LockInstance(redis, resource, val, ttl)) n += 1;
}
);
/*
* Add 2 milliseconds to the drift to account for Redis expires
* precision, which is 1 millisecond, plus 1 millisecond min
drift
* for small TTLs.
*/
var drift = Convert.ToInt32((ttl.TotalMilliseconds *
ClockDriveFactor) + 2);
// 计算抢到锁所花费的时间和设置的过期时间
var validity_time = ttl - (DateTime.Now - startTime) - new
TimeSpan(0, 0, 0, 0, drift);
// 如果大部分节点抢到了锁,而且抢锁所花费的时间小于设置的锁过期时间
if (n >= Quorum&&validity_time.TotalMilliseconds > 0)
{
// 生成锁,返回true
innerLock = new Lock(resource, val, validity_time);
return true;
}
else
{
// 抢锁失败(要么大部分节点没有抢到锁,要么抢锁的时间超过了
// 设置的锁过期时间)
for_each_redis_registered(
redis =>
{
// 从所有节点执行删除锁的Lua脚本
UnlockInstance(redis, resource, val);
}
);
return false;
}
}
catch (Exception)
{ return false; }
});
lockObject = innerLock;
return successfull;
}
// 遍历所有节点,返回节点连接
protected void for_each_redis_registered(Action
<IConnectionMultiplexer> action)
{
foreach (var item in redisMasterDictionary)
{
action(item.Value);
}
}
// 遍历所有节点,返回节点地址信息
protected void for_each_redis_registered(Action<String> action)
{
foreach (var item in redisMasterDictionary)
{
action(item.Key);
}
}
// 重试方法
protected bool retry(int retryCount, TimeSpan retryDelay, Func<bool>
action)
{
int maxRetryDelay = (int)retryDelay.TotalMilliseconds;
Random rnd = new Random();
int currentRetry = 0;
while (currentRetry++ < retryCount)
{
if (action()) return true;
Thread.Sleep(rnd.Next(maxRetryDelay));
}
return false;
}
// 删除锁对象
public void Unlock(Lock lockObject)
{
for_each_redis_registered(redis =>
{
UnlockInstance(redis, lockObject.Resource,
lockObject.Value);
});
}
}
调用方式如下:
c#
// 基于3台Redis服务器声明分布式锁
var dlm = new Redlock(
ConnectionMultiplexer.Connect("127.0.0.1:6379"),
ConnectionMultiplexer.Connect("127.0.0.1:6380"),
ConnectionMultiplexer.Connect("127.0.0.1:6381")
);
// 声明锁对象
Lock lockObject;
// 尝试获取锁(使用resourceName作为锁标识符,有效期为10秒)
var locked = dlm.Lock(
resourceName,
new TimeSpan(0, 0, 10),
out lockObject
);
// 如果locked为true,则会填充lockObject并获取锁,
// 否则无法获取锁
If(locked )
{
// 此处进行业务处理
// 释放lockObject中包含的锁
dlm.Unlock(lockObject);
}