分布式锁是redis非常强大的功能,而且许多许多的公司都是采用redis的分布式锁,所以看好这一章,无论对你工作,还是面试都是有极大好处的。
分布式锁也像一般锁一样可以“首先获取锁,然后执行操作,最后释放锁”动作,但这种锁既不是给同一个进程中的多个线程使用,也不是给同一台机器上的多个进程使用,而是由不通机器上的不通Redis客户端进行获取和释放的。何时使用以及是否使用WATCH或者锁取决于给定的应用程序:有的应用不需要使用锁就可以正确地运行,而有的应用只需要使用少量的锁,还有的应用需要在每个步骤都使用锁,不一而足。
我们没有直接使用操作系统级别的锁,编程语言级别的锁,或者其他各式各样的锁,而是选择了花费大量时间去使用Redis构建锁,这其中一个原因和范围有关:为了对Redis存储的数据进行排他性访问,客户端需要访问一个锁,这个锁必须定义一个可以让所有客户端都看得见的范围之内,而这个范围就是Redis本身,因此我们需要把锁构建在Redis里面。另一方面,虽然Redis提供的SETEX命令确实具有基本的加锁功能,但它的功能并完整,并且也不具备分布式锁常见的一些高级特性,所以我们还是需要自己动手来构建分布式锁。
这一节将会说明“为什么使用WATCH命令来监视被频繁访问的键可能会引起性能问题”,还会展示构建一个锁的详细步骤,并最终在某些情况下使用锁去代替WATCH命令。
1.锁的重要性
之前章节展示的游戏交易市场,我们使用了WACTH,MULTI,EXEC的乐观锁进行的处理,那么乐观锁的性能随着人数的提升有何改变呢?下面我们做了一个数据统计:
上表的模拟记过显示,随着负载不断增加,系统完成一次交易所需的重试次数从最初的3次上升到了250次,与此同时,完成一次交易所需的等待时间也从最初的少于10ms上升到了500ms。这个模拟示例完美地展示了为什么WATCH、MULTI和EXEC组成的事务并不具有可扩展性,原因在与使用乐观锁的方式会反复失败重试,在并发量特别多的时候会严重加大重试次数。为了解决这个问题,并以可扩展的方式来处理市场交易,我们将使用锁来保证市场在任一时刻只能上架或者销售一件商品。
简易锁
我们最先说下第一版的锁实现,这个锁非常简单,并且在一些情况下可能会无法正常运作。我们在刚开始构建锁的时候,并不会立即处理那些可能会导致锁无法正常运作的问题,而是先构建出可以运行的锁获取操作和锁释放过程,等到证明了使用锁的确可以提升性能之后,才会回过头一个接一个地解决那些引发锁故障的问题。
因为客户端即使在使用锁的过程中也可能会因为这样或那样的原因而下线,所以为了防止客户端在取得锁之后崩溃,并导致锁一直处于“已被获取的状态”,最终版的锁实现将带有超时限制特性:如果获得锁的过程未能在指定的时限内完成操作,那么锁将自动释放。
虽然很多Redis用户都对锁(lock),加锁(locking)及超时锁(lock timeouts)有所了解,但遗憾的是,大部分使用redis实现的锁只是基本上正确,它们发生故障的时间和方式通常难以预料。下面列出了一些导致锁出现不正确行为的原因,以及锁在不正确运行时的症状。
1.持有锁的进程因为操作时间过长而导致锁被自动释放,但进程本身并不知晓这一点,甚至还可能会错误地释放调了其他进程持有的锁。
2.一个持有锁并打算执行长时间操作的进程已经崩溃,但其他想要获取锁的进程不知道哪个进程持有者锁,也无法检测出持有锁的进程已经崩溃,只能白白浪费时间等待锁被释放。
3.在一个进程持有的锁过期之后,其他多个进程同时尝试去获取锁,并且都获得了锁。
4.上面提到的第一种情况和第三种情况同时 出现,导致有多个进程获得了锁,而 每个进程都以为自己是唯一一个获得锁的进程。
因为Redis在最新的硬件上可以每秒执行100000个操作,而在高端的硬件上甚至可以每秒执行将近225000个操作,所以尽管上面提到的问题出现的几率只有万分之一,但这些问题在高负载的情况下还是有可能会出现,因此,让锁正确地运作起来仍然是一件相当中药店额事情。
使用Redis构建锁
使用Redis构建一个基本正确的锁非常简单,如果在实现锁时能够对用到的操作多加留心的话,那么使用Redis构建一个完全正确的锁也并不是一件非常困难的事情。本节接下来要介绍的是锁实现的第1个版本,这个版本的锁要做的事就是正确地实现基本的加锁功能,而之后将会介绍如何处理过期的锁以及因为持有者崩溃而无法释放的锁。
为了对数据进行排他性访问,程序首先要做的就是获取锁。SETNX命令天生就适合用来实现锁的获取功能,这个命令只会在键不存在的情况下为键设置值,而锁要做的就是将一个随机生成的128位UUID设置为键的值,并使用这个值来防止锁被其他进程取得。
如果程序在尝试获取锁的时候失败,那么它将不断进行重试,直到成功取得锁或者超过给定的时限位置,如下所示:
/**
* 还是游戏商品交易市场的例子,这次使用redis锁的方式进行
* @param jedis
* @param buyerId
* @param itemId
* @param sellerId
* @return
*/
private boolean PurchaseItemWithLock(Jedis jedis, String buyerId, String itemId, String sellerId) {
String buyer = "user:" + buyerId;
String seller = "user:" + sellerId;
String item = itemId + "." + sellerId;
String inventory = "inventory:" + buyerId;
String locked = acquireLock(jedis, "market");
if (locked == null) {
return false;
}
Pipeline pipe = jedis.pipelined();
try{
pipe.zscore("market:", item);
pipe.hget(buyer, "funds");
List<Object> pipeResults = pipe.syncAndReturnAll();
Object priceObj = pipeResults.get(0);
if (priceObj == null) {
return false;
}
double price = (double) priceObj;
int funds = Integer.parseInt((String) pipeResults.get(1));
pipe.hincrBy(seller, "funds", (long) price);
pipe.hincrBy(buyer, "funds", (long) price);
pipe.sadd(inventory, itemId);
pipe.zrem("market:", item);
pipe.sync();
return true;
}finally {
//释放锁
releaseLock(jedis, "market", locked);
}
}
//redis加锁代码
private String acquireLock(Jedis jedis,String lockName) {
String identifier = UUID.randomUUID().toString();
long end = System.currentTimeMillis() + 10000;
while (System.currentTimeMillis() < end) {
//若是成功设置值,返回值为1
if (jedis.setnx("lock:" + lockName, identifier)==1) {
return identifier;
}
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return null;
}
//redis释放锁代码
private boolean releaseLock(Jedis jedis, String lockName, String identifier) {
lockName = "lock:" + lockName;
//这个无限循环是针对后面的超时限制性质的锁加的
while (true) {
jedis.watch(lockName);
String lockValue = jedis.get(lockName);
if (lockValue != null && lockValue == identifier) {
Transaction trans = jedis.multi();
trans.del(lockName);
List<Object> results = trans.exec();
if (results == null) {
continue;
}
}
jedis.unwatch();
break;
}
return true;
}
下面是用锁代替watch实现后的,相同情况对比,如下:
与之前的WATCH相比,锁实现的上架商品数量虽然有所减少,但是在买入商品时却不需要进行重试,并且上架商品数量和买入商品数量之间的比率,也跟卖家数量和买家数量之间的比率接近。目前来说,不通上架和买入进程之间的竞争限制了商品买卖操作性能的进一步提升。而接下来的细粒度锁将解决这个问题。
细粒度锁
细粒度锁的操作与简单锁基本一样,可能就是在锁的key值上,稍微做一些变化。这里不做过多介绍,下面两行代码示例一下:
jedis.hsetnx(market, lock+"itemA.13", uuid);
jedis.setnx(lock + market + item, uuid);
带有超时限制特性的锁
前面提到过,目前的锁实现在持有者崩溃的时候不会自动被释放,这将导致锁一直处于已被获取的状态。为了解决这个问题,为了解决这个问题,我们将为锁加上超时功能。
为了给锁加上超时限制,程序将在取得锁之后,调用Expire命令来为锁 设置过期时间,使得Redis可以自动删除超时的锁。为了确保锁在客户端已经崩溃(客户端在执行介于SETNX和EXPIRE之间的时候崩溃是最糟糕的)的情况下仍然能够自动被释放,客户端会在尝试获取锁失败之后,检查锁的超时时间,并为未设置超时时间的锁设置超时时间。因此锁总会带有超时时间,并最终因为超时而自动被释放,使得客户端可以继续尝试获取已被释放的锁。
需要注意的一点是,因为多个客户端在同一时间内设置的超时时间基本上都是相同的,所以即使有多个客户端同时为同一个锁设置超时时间,锁的超时时间也不会产生太大变化。
//redis加锁代码,设置超时时间
private String acquireLock(Jedis jedis,String lockName) {
String identifier = UUID.randomUUID().toString();
long end = System.currentTimeMillis() + 10000;
while (System.currentTimeMillis() < end) {
//若是成功设置值,返回值为1
if (jedis.setnx("lock:" + lockName, identifier)==1) {
jedis.expire("lock:" + lockName, 10000);
return identifier;
//如果没有设值成功,并且所取key没有设置过期时间,为其设置过期时间
} else if (jedis.ttl("lock:" + lockName) == -1) {
jedis.expire("lock:" + lockName, 10000);
}
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return null;
}