单结点
https://mp.weixin.qq.com/s/qJK61ew0kCExvXrqb7-RSg
多结点
https://blog.csdn.net/asd051377305/article/details/108384490
简介
一般来说,在对数据进行“加锁”时,程序首先需要通过获取(acquire)锁来得到对数据进行排他性访问的能力,然后才能对数据执行一系列操作,最后还要将锁释放(release)给其他程序。对于能够被多个线程访问的共享内存数据结构(shared-memory data structure)来说,这种“先获取锁,然后执行操作,最后释放锁”的动作非常常见。Redis使用WATCH命令来代替对数据进行加锁,因为WATCH只会在数据被其他客户端抢先修改了的情况下通知执行了这个命令的客户端,而不会阻止其他客户端对数据进行修改,所以这个命令被称为乐观锁(optimistic locking)。
分布式锁也有类似的“首先获取锁,然后执行操作,最后释放锁”动作,但这种锁既不是给同一个进程中的多个线程使用,也不是给同一台机器上的多个进程使用,而是由不同机器上的不同Redis客户端进行获取和释放的。何时使用以及是否使用WATCH或者锁取决于给定的应用程序:有的应用不需要使用锁就可以正确地运行,而有的应用只需要使用少量的锁,还有的应用需要在每个步骤都使用锁,不一而足。
我们没有直接使用操作系统级别的锁、编程语言级别的锁,或者其他各式各样的锁,而是选择了花费大量时间去使用Redis构建锁,这其中一个原因和范围(score)有关:为了对Redis存储的数据进行排他性访问,客户端需要访问一个锁,这个锁必须定义在一个可以让所有客户端都看得见的范围之内,而这个范围就是Redis本身,因此我们需要把锁构建在Redis里面。另一方面,虽然Redis提供的SETEX命令确实具有基本的加锁功能,但它的功能并不完整,并且也不具备分布式锁常见的一些高级特性,所以我们还是需要自己动手来构建分布式锁。
虽然很多Redis用户都对锁(lock)、加锁(locking)及锁超时(lock timeouts)有所了解,但遗憾的是,大部分使用Redis实现的锁只是基本上正确,它们发生故障的时间和方式通常难以预料。
下面列出了一些导致锁出现不正确行为的原因,以及锁在不正确运行时的症状:
- 持有锁的进程因为操作时间过长而导致锁被自动释放,但进程本身并不知晓这一点,甚至还可能会错误地释放掉了其他进程持有的锁。
- 一个持有锁并打算执行长时间操作的进程已经崩溃,但其他想要获取锁的进程不知道哪个进程持有着锁,也无法检测出持有锁的进程已经崩溃,只能白白地浪费时间等待锁被释放。
- 在一个进程持有的锁过期之后,其他多个进程同时尝试去获取锁,并且都获得了锁。
- 上面提到的第一种情况和第三种情况同时出现,导致有多个进程获得了锁,而每个进程都以为自己是唯一一个获得锁的进程。
简易锁
这个锁非常简单,并且在一些情况下可能会无法正常运作。我们在刚开始构建锁的时候,并不会立即处理那些可能会导致锁无法正常运作的问题,而是先构建出可以运行的锁获取操作和锁释放过程,等到证明了使用锁的确可以提升性能之后,才会回过头去一个接一个地解决那些引发锁故障的问题。
因为客户端即使在使用锁的过程中也可能会因为这样或那样的原因而下线,所以为了防止客户端在取得锁之后崩溃,并导致锁一直处于“已被获取”的状态,最终版的锁实现将带有超时限制特性:如果获得锁的进程未能在指定的时限内完成操作,那么锁将自动被释放。
为了对数据进行排他性访问,程序首先要做的就是获取锁。SETNX命令天生就适合用来实现锁的获取功能,这个命令只会在键不存在的情况下为键设置值,而锁要做的就是将一个随机生成的128位UUID设置为键的值,并使用这个值来防止锁被其他进程取得。
如果程序在尝试获取锁的时候失败,那么它将不断地进行重试,直到成功地取得锁或者超过给定的时限为止。
acquire_lock()函数的行为和前面描述的一样:它会使用SETNX命令,尝试在代表锁的键不存在的情况下,为键设置一个值,以此来获取锁;在获取锁失败的时候,函数会在给定的时限内进行重试,直到成功获取锁或者超过给定的时限为止(默认的重试时限为10秒)。
release_lock()函数展示了锁释放操作的实现代码:函数首先使用WATCH命令监视代表锁的键,接着检查键目前的值是否和加锁时设置的值相同,并在确认值没有变化之后删除该键(这个检查还可以防止程序错误地释放同一个锁多次)。
需要注意的一点是,对于目前的锁实现来说,release_lock()函数包含的无限循环只会在极少数情况下用到——函数之所以包含这个无限循环,主要是因为之后介绍的锁实现会支持超时限制特性,而如果用户不小心地混合使用了两个版本的锁,就可能会引起解锁事务失败,并导致上锁时间被不必要地延长。尽管这种情况并不常见,但为了保证解锁操作在各种情况下都能够正确地执行,我们还是选择在一开始就把这个无限循环添加到release_lock()函数里面。
/*** 简易锁锁名前缀*/private static final String EASY_TO_LOCK_PREFIX = "EASY_TO_LOCK:";/*** 简易锁-获取锁*/public String acquireLock(Jedis conn,String lockName) {return acquireLock(conn,lockName,10000L);}/*** 简易锁-获取锁* @param conn jedis连接对象* @param lockName 锁名* @param timeOut 重试时间* @return 加锁成功的标识符*/public String acquireLock(Jedis conn,String lockName,Long timeOut){// 获取标识符String identifier = UUID.randomUUID().toString();long end = System.currentTimeMillis() + timeOut;while (System.currentTimeMillis() < end){if (conn.setnx(EASY_TO_LOCK_PREFIX+lockName, identifier) == 1){return identifier;}}try {Thread.sleep(1000);} catch (InterruptedException e) {Thread.currentThread().interrupt();}return null;}/*** 简易锁-释放锁* @param conn jedis连接* @param lockName 锁名* @param identifier 标识符* @return*/public Boolean releaseLock(Jedis conn,String lockName,String identifier){String lock = EASY_TO_LOCK_PREFIX + lockName;while (true) {conn.watch(lock);// 检查进程是否仍然持有锁if (identifier.equals(conn.get(lock))) {// 释放锁Transaction transaction = conn.multi();transaction.del(lock);List<Object> results = transaction.exec();if (results == null){continue;}return true;}// 其它客户端修改了锁,重试conn.unwatch();break;}// 进程已经失去了锁return false;}
细粒度锁
针对业务上,是对全部还是部分操作/数据进行加锁,具体问题具体分析。
在一些情况下,判断应该锁住整个结构还是应该锁住结构中的一小部分是一件非常简单的事情。比如:我们要监视的关键数据为市场中的一件商品,而一件商品只是整个市场中的一小部分数据,所以只锁住一件商品的做法无疑是正确的。但是,在需要锁住的一小部分数据有不止一份的时候,又或者需要锁住结构的多个部分的时候,判断应该对小部分数据进行加锁还是应该直接锁住整个结构就会变得困难起来。除此之外,使用多个细粒度锁也有引发死锁的危险,一不小心就会导致程序无法正常运行。
带有超时限制特性的锁
简易锁实现在持有者崩溃的时候不会自动被释放,这将导致锁一直处于已被获取的状态。为了解决这个问题,在下面,将为锁加上超时功能。
为了给锁加上超时限制特性,程序将在取得锁之后,调用EXPIRE命令来为锁设置过期时间,使得Redis可以自动删除超时的锁。为了确保锁在客户端已经崩溃(客户端在执行介于SETNX和EXPIRE之间的时候崩溃是最糟糕的)的情况下仍然能够自动被释放,客户端会在尝试获取锁失败之后,检查锁的超时时间,并为未设置超时时间的锁设置超时时间。因此锁总会带有超时时间,并最终因为超时而自动被释放,使得其他客户端可以继续尝试获取已被释放的锁。
需要注意的一点是,因为多个客户端在同一时间内设置的超时时间基本上都是相同的,所以即使有多个客户端同时为同一个锁设置超时时间,锁的超时时间也不会产生太大变化。
在其他数据库里面,加锁通常是一个自动执行的基本操作。而Redis的WATCH、MULTI和EXEC,就像之前所说的那样,只是一个乐观锁——这种锁只会在数据被其他客户端抢先修改了的情况下,通知加锁的客户端,让它撤销对数据的修改,而不会真正地把数据锁住。通过在客户端上面实现一个真正的锁,程序可以为用户带来更好的性能、更熟悉的编程概念、更简单易用的API,等等。但是与此同时,也请记住Redis并不会主动使用这个自制的锁,我们必须自己使用这个锁来代替WATCH,或者同时使用锁和WATCH协同进行工作,从而保证数据的正确与一致。
/*** 带超时锁限制前缀*/private static final String TIMEOUT_LOCK_PREFIEX = "timeout_lock:";/*** 带超时限制锁** @param conn jedis连接* @param lockName 锁名* @param acquireTimeout 重试时间* @param lockTimeout 锁的有限时间* @return*/public String acquireLockWithTimeout(Jedis conn, String lockName, Long acquireTimeout, long lockTimeout) {// 标识符String identifier = UUID.randomUUID().toString();String lockKey = TIMEOUT_LOCK_PREFIEX + lockName;long lockExpire = lockTimeout / 1000;long end = System.currentTimeMillis() + acquireTimeout;while (System.currentTimeMillis() < end) {// 获取锁并设置过期时间if (conn.setnx(lockKey, identifier) == 1) {conn.expire(lockKey, lockExpire);return identifier;}// 检查过期时间,并在有需要时对其进行更新// 如果key存在并且没有设置过期时间(永久有效),返回 -1if (conn.ttl(lockKey) == -1) {conn.expire(lockKey, lockExpire);}try {Thread.sleep(1000);} catch (InterruptedException e) {Thread.currentThread().interrupt();}}// null indicates that the lock was not acquiredreturn null;}// 解锁-同简易锁解锁代码
计数信号量
计数信号量是一种锁,它可以让用户限制一项资源最多能够同时被多少个进程访问,通常用于限定能够同时使用的资源数量。你可以把我们在前一节创建的锁看作是只能被一个进程访问的信号量。
计数信号量和其他种类的锁一样,都需要被获取和释放。客户端首先需要获取信号量,然后执行操作,最后释放信号量。计数信号量和其他锁的区别在于,当客户端获取锁失败的时候,客户端通常会选择进行等待;而当客户端获取计数信号量失败的时候,客户端通常会选择立即返回失败结果。举个例子,假设我们最多只允许5个进程同时获取信号量,那么当有第6个进程尝试去获取信号量的时候,我们希望这个获取操作可以尽早地失败,并向客户端返回“本资源目前正处于繁忙状态”之类的信息。
构建基本的计数信号量
构建计数信号量时要考虑的事情和构建其他类型的锁时要考虑的事情大部分都是相同的,比如判断是哪个客户端取得了锁,如何处理客户端在获得锁之后崩溃的情况,以及如何处理锁超时的问题。实际上,如果我们不考虑信号量超时的问题,也不考虑信号量的持有者在未释放信号量的情况下崩溃的问题,那么有好几种不同的方法可以非常方便地构建出一个信号量实现。遗憾的是,从长远来看,这些简单方便的方法构建出来的信号量都不太实用,因此我们将通过持续改进的方式来提供一个功能完整的计数信号量。
使用Redis来实现超时限制特性通常有两种方法可选。一种是像之前构建分布式锁那样,使用EXPIRE命令,而另一种则是使用有序集合。为了将多个信号量持有者的信息都存储到同一个结构里面,这次我们将使用有序集合来构建计数信号量。
说得更具体一点,程序将为每个尝试获取信号量的进程生成一个唯一标识符,并将这个标识符用作有序集合的成员,而成员对应的分值则是进程尝试获取信号量时的Unix时间戳。
所需的数据结构
- zset
- semaphore:remote

进程在尝试获取信号量时会生成一个标识符,并使用当前时间戳作为分值,将标识符添加到有序集合里面。接着进程会检查自己的标识符在有序集合中的排名。如果排名低于可获取的信号量总数(成员的排名从0开始计算),那么表示进程成功地取得了信号量。反之,则表示进程未能取得信号量,它必须从有序集合里面移除自己的标识符。为了处理过期的信号量,程序在将标识符添加到有序集合之前,会先清理有序集合中所有时间戳大于超时数值(timeout number value)的标识符。
acquire_semaphore()函数所做的就和前面介绍的一样:生成标识符,清除所有过期的信号量,将新的标识符添加到有序集合里面,检查新添加的标识符在有序集合中的排名。没有什么让人出乎意料的地方。
信号量释放操作非常简单:程序只需要从有序集合里面移除指定的标识符就可以了。
这个基本的信号量实现非常好用,它不仅简单,而且运行速度也飞快。但这个信号量实现也存在一些问题:它在获取信号量的时候,会假设每个进程访问到的系统时间都是相同的,而这一假设在多主机环境下可能并不成立。举个例子,对于系统A和B来说,如果A的系统时间要比B的系统时间快10毫秒,那么当A取得了最后一个信号量的时候,B只需要在10毫秒内尝试获取信号量,就可以在A不知情的情况下,“偷走”A已经取得的信号量。对于一部分应用程序来说这并不是一个大问题,但对于另外一部分应用程序来说却并不是如此。
每当锁或者信号量因为系统时钟的细微不同而导致锁的获取结果出现剧烈变化时,这个锁或者信号量就是不公平的(unfair)。不公平的锁和信号量可能会导致客户端永远也无法取得它原本应该得到的锁或信号量。接下来的一节将介绍解决这个问题的方法。
/*** 获取计数信号量锁* @param conn jedis连接* @param semname 锁名* @param limit 限制个数* @param timeout 过期时间* @return*/public String acquireSemaphore(Jedis conn, String semname, Long limit, Long timeout) {String identifier = UUID.randomUUID().toString();long now = System.currentTimeMillis();// 清理过期的信号量持有者Transaction transaction = conn.multi();transaction.zremrangeByScore(semname, "-inf", now - timeout + "");// 尝试获取信号量transaction.zadd(semname, now, identifier);transaction.zrank(semname, identifier);// 检查是否成功取得了信号量List<Object> results = transaction.exec();if ((Long) results.get(results.size() - 1) < limit) {return identifier;}// 获取信号量失败,删除之前添加的标识符conn.zrem(semname, identifier);return null;}/*** 释放信号量锁* @param conn* @param semname* @param identifier* @return*/public Boolean releaseSemaphore(Jedis conn, String semname, String identifier) {// 如果信号量已经被正确地释放,那么返回 True,返回 False 则表示该信号量已经因为过期而被删除了return conn.zrem(semname, identifier) == 1;}
公平信号量
当各个系统的系统时间并不完全相同的时候,前面介绍的基本信号量就会出现问题:系统时钟较慢的系统上运行的客户端,将能够偷走系统时钟较快的系统上运行的客户端已经取得的信号量,导致信号量变得不公平。我们需要减少不正确的系统时间对信号量获取操作带来的影响,使得只要各个系统的系统时间相差不超过1秒,就不会引起信号量被偷或者信号量提早过期。
为了尽可能地减少系统时间不一致带来的问题,我们需要给信号量实现添加一个计数器以及一个有序集合。其中,计数器通过持续地执行自增操作,创建出一种类似于计时器(timer)的机制,确保最先对计数器执行自增操作的客户端能够获得信号量。另外,为了满足“最先对计数器执行自增操作的客户端能够获得信号量”这一要求,程序会将计数器生成的值用作分值,存储到一个“信号量拥有者”有序集合里面,然后通过检查客户端生成的标识符在有序集合中的排名来判断客户端是否取得了信号量。
所需的数据结构
- zset
- semaphore:remote:owner
- string
- semaphore:remote:counter

公平信号量和之前介绍的基本信号量一样,都是通过从系统时间有序集合里面移除过期元素的方式来清理过期信号量的。另外,公平信号量实现还会通过ZINTERSTORE命令以及该命令的WEIGHTS参数,将信号量的超时时间传递给新的信号量拥有者有序集合。
程序首先通过从超时有序集合里面移除过期元素的方式来移除超时的信号量,接着对超时有序集合和信号量拥有者有序集合执行交集计算,并将计算结果保存到信号量拥有者有序集合里面,覆盖有序集合中原有的数据。之后,程序会对计数器执行自增操作,并将计数器生成的值添加到信号量拥有者有序集合里面;与此同时,程序还会将当前的系统时间添加到超时有序集合里面。在完成以上操作之后,程序会检查当前客户端添加的标识符在信号量拥有者有序集合中的排名是否足够低,如果是的话就表示客户端成功取得了信号量。相反地,如果客户端未能取得信号量,那么程序将从信号量拥有者有序集合以及超时有序集合里面移除与该客户端相关的元素。
acquire_fair_semaphore()函数和之前的acquire_semaphore()函数有一些不同的地方。它首先清除已经超时的信号量,接着更新信号量拥有者有序集合并获取计数器生成的新ID值,之后,函数会将客户端的当前时间戳添加到过期时间有序集合里面,并将计数器生成的ID值添加到信号量拥有者有序集合里面,这样就可以检查标识符在有序集合里面的排名是否足够低了。
图6-8展示了ID为8372的进程在1326437039.100这个时间尝试获取信号量时执行的一系列操作,其中信号量的最大数量为5
公平信号量的释放操作几乎和基础信号量的释放操作一样简单,它们之间的唯一区别在于:公平信号量的释放操作需要同时从信号量拥有者有序集合以及超时有序集合里面删除当前客户端的标识符。
因为信号量获取操作的其中一个步骤,就是对信号量拥有者有序集合进行更新,移除那些不再存在于超时有序集合中的标识符,所以,如果我们想要稍微偷懒一下的话,也可以在释放信号量的时候,只移除超时有序集合里面的客户端标识符,而不对信号量拥有者有序集合执行相同的操作。但是只从超时有序集合里面移除标识符可能会引发这样一个问题:当一个客户端执行acquire_fair_semaphore()函数,对信号量拥有者有序集合进行了更新,并正准备将自己的标识符添加到超时有序集合和信号量拥有者有序集合之际,如果有另一个客户端执行信号量释放函数,并将该客户端自己的标识符从超时有序集合中移除的话,这将导致原本能够成功执行的信号量获取操作变为执行失败。虽然这个问题出现的几率很低,但它还是有可能会出现,因此,为了确保程序在不同情况下都能产生正确的行为,信号量释放函数仍然会同时从两个有序集合里面移除客户端标识符。
尽管这个信号量实现并不要求所有主机都拥有相同的系统时间,但各个主机在系统时间上的差距仍然需要控制在一两秒之内,从而避免信号量过早释放或者太晚释放。
/*** 存储信号量信息的有序集合*/private static final String SEMAPHORE_REMOTE_ZSET = "semaphore:remote";/*** 公平锁-信号量拥有者集合(semaphore:remote:owner)*/private static final String FAIR_SEMAPHORE_ZSET_OWNER = ":owner";/*** 公平锁-计数器(semaphore:remote:counter)*/private static final String FAIR_SEMAPHORE_INT_COUNTER = ":counter";/*** 获取公平锁** @param conn jedis连接* @param semname 锁名前缀(semaphore:remote)* @param limit 限制个数* @param timeout 超时时间* @return*/public String acquireFairSemaphore(Jedis conn, String semname, int limit, long timeout) {// 标识符String identifier = UUID.randomUUID().toString();String czset = semname + FAIR_SEMAPHORE_ZSET_OWNER;String ctr = semname + FAIR_SEMAPHORE_INT_COUNTER;long now = System.currentTimeMillis();Transaction trans = conn.multi();// 删除超时的信号量trans.zremrangeByLex(czset.getBytes(), "-inf".getBytes(), String.valueOf(now - timeout).getBytes());// 设置乘法因子ZParams zParams = new ZParams();zParams.weights(1, 0);// 取两个集合(信号量拥有者和信号量信息)的交集,并将数值进行乘法处理,最后将结果放到 czset 中trans.zinterstore(czset, zParams, czset, semname);// 对计数器执行自增操作,并获取计数器在执行自增操作之后的值trans.incr(ctr);List<Object> results = trans.exec();log.info("resultes = " + results.toString());int counter = ((Long) results.get(results.size() - 1)).intValue();log.info("counter = " + counter);trans = conn.multi();// 添加信号量信息trans.zadd(semname, now, identifier);// 添加信号量拥有者信息trans.zadd(czset, counter, identifier);// 返回当前 identifier 的排名trans.zrank(czset, identifier);results = trans.exec();counter = ((Long) results.get(results.size() - 1)).intValue();// 通过检查排名来判断客户端是否取得了信号量if (counter < limit) {return identifier;}trans = conn.multi();// 客户端未能取得信号量,清理无用数据trans.zrem(semname, identifier);trans.zrem(czset, identifier);trans.exec();return "";}/*** 释放公平锁** @param conn jedis连接* @param semname 锁名* @param identifier 标识符* @return*/public boolean releaseFairSemaphore(Jedis conn, String semname, String identifier) {Transaction trans = conn.multi();// 信号量信息trans.zrem(semname, identifier);// 信号量拥有者trans.zrem(semname + FAIR_SEMAPHORE_ZSET_OWNER, identifier);List<Object> results = trans.exec();return (Long) results.get(results.size() - 1) == 1;}
刷新信号量
只要客户端持有的信号量没有因为过期而被删除,refresh_fair_semaphore()函数就可以对信号量的超时时间进行刷新。另一方面,如果客户端持有的信号量已经因为超时而被删除,那么函数将释放信号量,并将信号量已经丢失的信息告知调用者。在长时间使用信号量的时候,我们必须以足够频繁的频率对信号量进行刷新,防止它因为过期而丢失。
/*** 刷新信号量** @param conn jedis连接* @param semname 锁名* @param identifier 标识符* @return*/public Boolean refreshFairSemaphore(Jedis conn, String semname, String identifier) {// 更新客户端持有的信号量long now = System.currentTimeMillis();if (conn.zadd(semname, now, identifier) > 0) {releaseFairSemaphore(conn, semname, identifier);// 告知调用者,客户端已经失去了信号量return false;}// 客户端仍然持有信号量return true;}
消除竞争条件
竞争条件可能会导致操作重试或者数据出错,而解决竞争条件并不容易。不巧的是,前面介绍的信号量实现也带有可能会导致操作不正确的竞争条件。
比如说,当两个进程A和B都在尝试获取剩余的一个信号量时,即使A首先对计数器执行了自增操作,但只要B能够抢先将自己的标识符添加到有序集合里,并检查标识符在有序集合中的排名,那么B就可以成功地取得信号量。之后当A也将自己的标识符添加到有序集合里,并检查标识符在有序集合中的排名时,A将“偷走”B已经取得的信号量,而B只有在尝试释放信号量或者尝试刷新信号量的时候才会察觉这一点。
将系统时钟用作获取锁的手段提高了这类竞争条件出现的可能性,导致信号量持有者的数量比预期的还要多,多出的信号量数量与各个系统时钟之间的差异有关——差异越大,出现额外信号量持有者的可能性也就越大。虽然引入计数器和信号量拥有者有序集合可以移除系统时钟这一不确定因素,并降低竞争条件出现的几率,但由于执行信号量获取操作需要客户端和服务器进行多次通信,所以竞争条件还是有可能会发生。
为了消除信号量实现中所有可能出现的竞争条件,构建一个正确的计数器信号量实现,我们需要用到前面在6.2.5节中构建的带有超时功能的分布式锁。总的来说,当程序想要获取信号量的时候,它会先尝试获取一个带有短暂超时时间的锁。如果程序成功取得了锁,那么它就会接着执行正常的信号量获取操作。如果程序未能取得锁,那么信号量获取操作也宣告失败。
/*** 消除竞争条件*/public String acquireSemaphoreWithLock(Jedis conn, String semname, int limit, Long timeout) {String identifier = acquireLock(conn, semname, timeout);if (identifier != null) {try {return acquireFairSemaphore(conn, semname, limit, timeout);} finally {releaseLock(conn, semname, identifier);}}return null;}
因为令我们困扰至今的竞争条件竟然只需要使用一个锁就可以轻而易举地解决掉,但这种事在使用Redis的时候并不少见:相同或者相似的问题通常会有几种不同的解决方法,而每种解决方法都有各自的优点和缺点。以下是之前介绍过的各个信号量实现的优缺点。
- 如果你对于使用系统时钟没有意见,也不需要对信号量进行刷新,并且能够接受信号量的数量偶尔超过限制,那么可以使用我们给出的第一个信号量实现。
- 如果你只信任差距在一两秒之间的系统时钟,但仍然能够接受信号量的数量偶尔超过限制,那么可以使用第二个信号量实现。
- 如果你希望信号量一直都具有正确的行为,那么可以使用带锁的信号量实现来保证正确性。
我们拥有了几个不同的信号量实现,而它们遵守信号量限制的程度也各不相同。一般来说,使用最新也最严格遵守限制的实现是最好的,这不仅因为最新的实现是唯一真正正确的实现,更关键的是,如果我们因为图一时之快而使用了带有错误的简陋实现,最终可能会因为使用了太多资源而导致得不偿失。
这一节介绍了如何使用信号量来限制同时可运行的API调用数量。除此之外,信号量通常还用于限制针对数据库的并发请求数量,从而降低执行单个查询所需的时间。另外,当需要使用多个客户端来下载同一个服务器上的多个网页,而服务器的robots.txt却声明该服务器最多只允许同时下载(比如说)3个页面时,也可以使用信号量来防止客户端给服务器带来太大负担。
