分布式锁
分布式锁是一种用于在分布式系统中实现互斥访问的机制,它可以确保在多个节点、或进程同时访问共享资源。如果没有适当的锁机制,就可能导致数据不一致或并发冲突的问题。
分布式锁需要的介质
- 需要一个多个微服务节点都能访问的存储介质,需要能保存锁信息。
- 该工具上锁时要能保证原子操作,能处理并发,且能对结果进行感知。
- 节点具有强一致性,不论几个节点,客户端最终结果一致。
- 老生常谈的高可用,高性能。
分布式锁需要实现的功能
- 锁的基本要求:锁最大的要求就是互斥和可重入。
- 不同的对象不能拿到同一个锁,同一个对象可以再次访问该锁。
- 需要避免死锁:死锁四大条件里面,只要破坏一个就可以避免死锁。
互斥条件
:资源是排他的,一个资源一次只能被一个对象获取。请求与保持条件
:当前对象持有这个资源的是时候会请求其他的资源,并且不会放弃持有当前资源。不可剥夺条件
:不可以强行从一个对象手中剥夺资源。循环等待条件
:当前对象需要去等待其他对象的资源,其他对象也要等待当前对象的资源。解决方案
: 超时退出最简单。
- 锁对象独占:能拿到锁,能校验锁,也能解除锁,保证锁的独占性。
- 尝试获取时间 / 超时自动释放:一个是尝试获取锁时,多久超时失败。一个是拿到锁后,多久自动释放。
- 高并发,高可用:除了锁介质需要满足这些,实现锁的方案上也有满足。
分布式锁的特点
- 互斥性:在任何时刻,只有一个客户端能够持有锁。这是分布式锁最基本的要求,确保了数据的一致性和安全性。
- 锁超时释放:锁应该有一个超时时间,在超时后自动释放,以防止客户端因异常情况无法释放锁而造成死锁。
- 可重入性:同一个客户端在持有锁的情况下可以重复获取锁,防止客户端因再次请求锁而陷入死锁。
- 公平性:锁的获取应该尽可能公平,避免某些客户端长时间等待或饿死。
- 容错性:在分布式系统中,网络或节点故障是常见问题。一个健壮的分布式锁应该能够在部分故障的情况下仍然保持其功能。
- 高可用性和高性能:分布式锁应该能够在高并发和高负载的情况下稳定运行,且性能损失尽可能小。具有较低的延迟和高吞吐量,以确保对共享资源的访问不会成为系统性能的瓶颈。
- 支持多种锁模式:如共享锁(读锁)和排他锁(写锁),以适应不同的业务场景。
- 锁的状态同步:在分布式环境中,锁的状态需要在不同的节点间同步,确保一致性。
- 锁的粒度控制:可以根据需要设置锁的粒度,如对整个资源加锁或只对资源的一部分加锁。
- 锁的阻塞和非阻塞获取:支持客户端选择阻塞或非阻塞方式获取锁,以满足不同的业务需求。
- 安全性:分布式锁应具备一定的安全性措施,如防止恶意客户端获取或释放锁。
- 可靠性(Reliability):分布式锁应该能够在各种异常情况下保持正确的行为,包括:网络分区、节点故障、客户端崩溃等情况。即使某个客户端持有锁的过程中发生异常,也应该确保锁最终能够被释放,以避免死锁等问题。
分布式锁的解决方案
Redis:高性能简单的分布式锁方案
- 本身的
单线程
方式保证了操作的并发性。 - 通过
EVAL 命令
可以保证操作的原子性。 - 虽然
没有达到强一致
,但是多节点时可以保证最终一致性。
基于 ZooKeeper 或 Etcd 实现
ZooKeeper或Etcd实现分布式锁的优势在于:
1、两者都是分布式的,天然具备高可用能力。
2、都有临时节点的能力,能够很好支持锁超时释放等机制的实现。
3、基于顺序节点、Revision 机制等,更方便实现可重入、公平性等特性。
4、基于Watch、Revision 机制更容易避免分布式锁争抢中的「惊群效应」问题(抢占分布式锁时被频繁唤醒和重新休眠,造成浪费)。解决方案为:分布式锁释放后,只唤醒满足条件的下一个节点。
ZK实现分布式锁的基本原理是:以某个资源为目录,然后这个目录下面的节点就是需要获取锁的客户端,未获取到锁的客户端注册需要注册Watcher到上一个客户端。如下图所示:
临时节点
和事件监听
机制,创建临时有序节点,判断是否是最小从而获取到锁。- 通过事件监听等待锁的释放。
- 解锁则删除节点。
MySQL:性能较低
- MySQL的事务机制和锁机制足够满足上述的基本需求。
- 阻塞等机制我们可以借助MySQL的事务和锁机制(select...for update)。
- 性能较低,不适合高并发场景,实现比较繁琐,很少使用MySQL去实现分布式锁。
Redis与ZK/Etcd的方案主要异同点:
- 性能:Redis基于内存,读写性能高,适合高并发。ZK/Etcd相对弱一些。
- 运维成本:Redis更常用、是基础组件,运维也更简单。ZK/Etcd都是分布式系统,运维相对复杂一些。
- 易用性:都有较成熟的客户端封装,差别不大。
- 高可用:均支持,Redis采用Redlock方案,ZK/Etcd本身就是高可用的。
以上的几种实现方式里面,用的最多的还是
Redis
。
- ZK 需要
额外的部署
,有些项目并没有使用 ZK 的场景 。ZK 在性能上比 Redis 要差。
- 对一致性的要求
没想象那么高
,小概率事件
,Redis 基本上可以满足。- 非要强一致,
Redis 也有替代的方案
, 比如 RedissonRedLock。- 在锁的处理上,数据库算是
性能最差
的,占用资源最多。- 通常用上分布式锁的时候,系统已经比较大了,这个时候大概率已经
分库分表
,增加了复杂度。- 对于一些复杂的功能,数据库实现不了(解锁,判断锁)。
- 用数据库做分布式锁还不如让它作为
乐观锁。
分布式锁的使用场景
Martin Kleppmann是英国剑桥大学的分布式系统的研究员,之前和Redis之父 Antirez 进行过关于RedLock(红锁)是否安全有过激烈讨论。正如Martin所说,我们使用分布式锁一般有两个场景:正确性和效率。
1、保证数据正确性,比如抢红包、秒杀下单等场景,需要保障不会出现超卖等问题。
因为红包、秒杀商品等场景下,只能被先到达的人才能抢到,所以要顺序执行库存扣减等操作,这样就需要限制同一时间只能有一个线程或进程对资源进行访问和修改。分布式锁可以确保在多个副本部署服务或高并发的情况下,同一时间只有一个线程或进程能够执行相应的业务代码,从而避免数据不一致的问题。
2、避免重复执行某些操作,浪费资源。比如多个客户端可能都执行发送短信通知,但是需要保证这个通知只被发送一次。这些操作可能是非幂等性的,即执行多次会产生不同的结果。为了避免重复执行这些操作,可以使用分布式锁来确保同一时间只有一个客户端能够执行该操作。比如,业务逻辑可能包括以下步骤:
(1)先获取分布式锁;
(2)获取到锁后,先查询是否已发送短信通知,
(3)之后如果查询到未发送状态后才发送,
(4)发送成功后更新发送状态到数据库中。
这样只有第一次执行该逻辑时才能成功发送,此处应用分布式锁能避免因并发操作导致的重复发送问题。
类比单个服务中,操作全局共享变量会先加锁避免并发修改资源造成错误,分布式锁是用于分布式环境下避免并发修改资源导致破坏数据正确性,用于控制某个资源在同一时刻只能被一个应用所使用。如下图所示:
分布式锁的业务场景
场景一 :限制资源写入
资源访问限制是一个很宽泛的领域,来细化一下就是 API 的访问
,数据库的访问
等等场景都可以通过分布式锁来控制。而往业务场景去偏移,包括超卖问题
,重复消费问题
,等等也都在分布式锁的解决范围之内。同时可以在一定程度上避免数据库级别的锁竞争问题。避免同时的数据写入和修改。
场景二 : 限制资源的产生
这种最常见的场景在于缓存过期的问题上,当并发到来的时候,如果缓存服务器即将过期,可能会基于缓存的特性限制缓存的重复读取和写入
。 避免查询重复的数据
。再就例如分布式ID的场景下,也会通过分布式锁类似的方式,来获取一个粗粒度的 ID 范围,用于后续ID的细分。
场景三 : 限制触发的频率
这种体现在 Job 定时任务的执行上。不过如果使用的是类似于 XXL-JOB 这类外部的 Job 组件,可能这个特性就用不上。但是如果是单个服务内置的 Job 组件,微服务之间没有互相通信,那么就需要分布式锁来限制任务触发的频率
。对应的还包括 API 的访问频率
,也可以在分布式锁的基础上进行扩展(主要就是要求原子性的计数)。
场景四 : 维护资源的一致性
由于分布式场景的特性,可能在单机上面被视为原子对象的资源,在分布式场景下就变成了多个资源。分布式锁并不能改变这种状态,但是可以增强一致性 ,维护他们的统一状态
。常见的场景包括分布式事务。
分布式锁的实现思路
关于锁的实现要点
- 要实现锁的等待,首先要有个明确的等待时间,然后在业务代码里面等待(比如自旋,Java的锁)。
- 锁的主键:一般情况下实现的时候都是通过 类 + 方法 + 参数 + 值
- 锁的重入:使用 redisson 的情况下 ,它是通过线程ID来实现的重入(如果同一个应用线程相同,就可能存在问题)。
分布式锁的实现方式
- Zookeeper 有提供完整功能的第三方包,例如 Curator。
- Redis 使用更加简单。
基于 Redis 的方案:
- 基于 LUA 脚本自定义分布式锁。
- 基于 redisson 的分布式锁 (其实本质上还是 LUA 脚本)。
解锁原理
// 如果锁不存在,则直接返回if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil;
end;// 若锁存在,且唯一标识(线程ID)匹配:则先将锁重入计数减1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then // 如果锁的持有数还是大于 0 ,则不可以删除锁,只是设置时间redis.call('pexpire', KEYS[1], ARGV[2]); return 0;
elseredis.call('del', KEYS[1]); // 否则则直接删除锁,锁释放redis.call('publish', KEYS[2], ARGV[1]); // 广播锁释放消息,唤醒等待的线程return 1;
end; return nil;
分布式锁过期的业务处理
首先,在设置过期时间时要结合业务场景去考虑,尽量设置一个比较合理的值,就是理论上正常处理的话,在这个过期时间内是一定能处理完毕的。接着考虑对这个问题进行兜底设计。关于这个问题,目前常见的解决方法有两种:
-
守护线程“续命”:额外起一个线程,定期检查线程是否还持有锁,如果有则延长过期时间。Redisson 里面就实现了这个方案,使用“看门狗”定期检查(每1/3的锁时间检查1次),如果线程还持有锁,则刷新过期时间。
-
超时回滚:当解锁时发现锁已经被其他线程获取了,说明此时我们执行的操作已经是“不安全”的了,此时需要进行回滚,并返回失败。
同时,需要进行告警,人为介入验证数据的正确性,然后找出超时原因,是否需要对超时时间进行优化等等。
守护线程“续命”
Redisson 的 RLock 对象会自动处理锁的续期。当一个线程获取了锁,Redisson 会在后台启动一个定时任务(看门狗),用于在锁即将过期时自动续期。 详细流程步骤:
获取锁
:当调用 lock.lock() 时,Redisson 会尝试在 Redis 中创建一个具有过期时间的锁。锁的自动续期
:Redisson 会启动一个后台线程(看门狗),它会在锁的过期时间的一半时检查锁是否仍然被当前线程持有。续期锁
:如果锁仍然被持有,看门狗会延长锁的过期时间。这确保了即使业务逻辑执行时间较长,锁也不会过期。执行业务逻辑
:在锁的保护下,执行业务逻辑。释放锁
:当业务逻辑执行完毕后,调用 lock.unlock() 释放锁。如果当前线程是最后一个持有锁的线程,Redisson 会从 Redis 中删除锁。异常处理
:如果在执行业务逻辑时发生异常,finally 块中的 unlock() 调用确保了锁能够被释放,防止死锁。看门狗线程终止
:一旦锁被释放,看门狗线程会停止续期操作,并结束。
通过这种方式,Redisson 提供了一个简单而强大的机制来处理分布式锁的自动续期,从而减少了锁过期导致的问题。
超时回滚
使用超时回滚机制处理 Redis 分布式锁过期的情况,是指当一个线程因为执行时间过长导致持有的分布式锁过期,而其他线程又获取了同一把锁时,原线程需要能够检测到这一情况并执行业务逻辑的回滚操作。无论业务逻辑是否成功执行,都需要在 finally 块中释放锁,以避免死锁。在释放锁之后,如果业务逻辑执行失败,可能需要通知用户或者记录日志,以便进一步处理。
常见的 Redis 锁实现
基于SETNX + EXPIRE命令
SETNX是"SET if Not eXists"的缩写,命令格式:SETNX key value。
- 获取锁:使用
SETNX
命令尝试设置唯一锁标识符。返回1表示成功创建锁,返回0表示锁已被占用。 - 设置过期时间:成功获取锁后,用
EXPIRE
命令为锁设定超时时间,防止客户端崩溃导致锁无法释放。 - 释放锁:完成任务后,使用
DEL
命令删除锁标识符,释放锁。
需要注意的是,在获取锁后,执行业务逻辑时应设定合理的超时时间,以避免锁被长时间占用。这种方式实现的锁存在一定的缺陷,当 Redis 服务器故障或者出现网络分区时,可能会导致锁无法正常释放,从而导致死锁的问题。SETNX 和 EXPIRE 是两个命令,不是原子操作,如果执行完 SETNX 后宕机,这个锁就会一直存在。
先拿setnx来争抢锁,抢到之后,再用expire给锁加一个过期时间防止锁忘记了释放。如果在setnx之后执行expire之前进程意外crash或者要重启维护了,那会怎么样?
set指令有非常复杂的参数,这个应该是可以同时把setnx和expire合成一条指令来使用。
基于 SET 扩展命令
命令格式:SET key value [EX seconds | PX milliseconds] [NX]。
- 获取锁并设置过期时间:使用 SET $lock_key $val EX $second NX 命令保证加锁原子性,并为锁设置过期时间。NX 表示只有在锁不存在的情况下才设置锁。
- 释放锁:完成任务后,使用
DEL
命令删除锁标识符,释放锁。
缺陷:一个客户端可能会误删除别的客户端的锁。比如,客户端A获得分布式锁,之后执行业务操作过久,导致分布式锁已经过期了。此时客户端B成功获得分布式锁,之后A完成业务操作,就把客户端B的锁操作删除了。
基于 SET 命令 + LUA脚本
基于SET扩展命令 + SET 随机 value 以便删除时校验 + LUA脚本保证删除时对比数据和DEL操作的原子性。
1、获取锁并设置过期时间
使用 SET $lock_key $unique_val EX $second NX 命令保证加锁原子性,并为锁设置过期时间。
多个客户端使用同一个lock_key,但是各个客户端应该使用自己唯一的unique_val,以便删除的时候进行校验,防止自己的锁被别的客户端误操作删除。
2、释放锁
完成任务后,获取当前lock_key的 value 与 unique_val 是否相同,相同则使用DEL
命令删除锁标识符,释放锁。为保证释放锁为原子操作,需使用lua脚本完成这两步操作。
RedLock 实现锁
RedLock 锁它是由Redis的作者Salvatore Sanfilippo提出的,旨在提供一个可靠的分布式锁方案。
以下是RedLock 算法的基本步骤:
- 获取当前时间戳:所有Redis实例使用相同的时间源,例如:NTP获取当前时间戳。
- 尝试在多个Redis实例上获取锁:在每个Redis实例上尝试使用SET命令获取锁,设置一个带有唯一标识符的键,设置的键名应该是全局唯一的,以避免与其他锁冲突。
- 计算获取锁所花费的时间:计算从第一步获取时间戳到成功获取锁所花费的时间,记为
elapsed_time
。 - 判断锁是否获取成功:如果获取锁的时间
elapsed_time
小于设定的锁超时时间,并且大多数(例如大于一半)的Redis实例成功获取了锁,那么认为锁获取成功。 - 释放锁:在所有成功获取锁的Redis实例上执行释放锁的操作,使用DEL命令删除对应的键。
关于Redlock还有这么一段趣事:
Redis 作者把这个方案一经提出,就马上受到业界著名的分布式系统专家Martin的质疑。Martin指出了分布式系统的三类异常场景(NPC):
N:Network Delay,网络延迟
P:Process Pause,进程暂停(GC)
C:Clock Drift,时钟漂移
Martin 用一个进程暂停(GC)的例子,指出了 Redlock 安全性问题,由于GC问题会导致分布式锁的正确性出现问题,如下图所示:
之后Redis 作者毫不客气地进行了回怼,Redis 作者同意对方关于时钟跳跃对Redlock的影响,但认为通过运维手段是可以避免的。Redlock中有超时时间判断的机制,可以有效避免NPC问题,但是如果Redlock 步骤3(成功拿到锁)之后发生GC,不止是 Redlock 有问题,其它分布式锁服务同样也有问题,所以不在讨论范畴内。
Redisson 对分布式锁的改进
首先,Redis能做分布式锁,主要是因为是单线程执行的,所以如果能在一个指令里面操作完获取以及设置锁的话,就不会有并发问题。
比如setnx指令,以及新版的set支持相关的参数。当然也可以用lua脚本来保证多个指令的原子性。
但是基于简单的一条指令去做锁的话,是一个不可重入锁,另外一个锁过期时间不好把控,可能会出现业务没有执行完锁过期。
所以redisson主要解决这2个问题,当然还有一些比如读写锁、联锁等等。
可重入的话基于lua+hash去实现可重入锁。然后时间把控,如果你不知道锁多久过期,redisson会基于时间轮+递归来时间锁续期。也就是我们说的看门狗。
时间轮+递归实现续期
时间轮就是hashWheelTimer,是一个netty包下的类,它主要实现的功能是延时执行任务。
它的实现逻辑是会启动一个线程去轮询数组,然后任务根据延时多久添加到对应数组,如果轮询到了的话,通过多线程去执行相关的任务。
看门狗,就是用hashWheelTimer 去根据设置的看门狗时间/3 去延时判断key是否存在,如果存在,就续期,并且递归,做到只要key还在就一直续期。如果key不存在,就不再递归。
Redisson 联锁
Redisson 的联锁(Redisson MultiLock)是 Redisson 分布式锁的一种实现,用于在多个 Redis 节点上同时加锁。它允许多个资源(例如不同的Redis键)同时被锁定,只有当所有的资源都成功锁定时,联锁才会认为锁定成功。这种机制非常适合于需要同时锁定多个资源以执行某些操作的场景。
联锁的目的是因为redis是属于AP模式的中间件,会存在数据丢失,那么锁就会失效。
所以联锁主要做的一件事情就是尽可能的去保证数据不丢失,加锁会加在不同的独立集群机器。当满足一半成功就成功。其实主要思想就是把鸡蛋分散到不同的篮子。降低风险。只要不是超过一半的失败,就是成功的。
实现Redis分布式锁的高可用
首先想到的还是Redis的单点故障问题,如果Redis挂了,分布式锁就不能正常工作,因此可以引入Redis主从模式。
但是,如果在Redis集群的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。但是此时分布式锁也已经失效了。
怎么解决这个问题呢?为此,Redis 的作者提出一种解决方案,就是经常听到的 Redlock(红锁)。为此,需要部署5个单独的Redis,Redlock的实现步骤如下:
- 向5个Redis master节点请求加锁。
- 根据设置的超时时间来判断(加锁的总耗时要小于锁设置的过期时间),是不是要跳过该master节点。
- 如果大于等于3个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功。
- 释放锁,向全部节点发起释放锁请求。
Redis 脑裂问题
Redis 脑裂问题是指,在 Redis 哨兵模式或集群模式中,由于网络原因,导致主节点(Master)与哨兵(Sentinel)和从节点(Slave)的通讯中断,此时哨兵就会误以为主节点已宕机,就会在从节点中选举出一个新的主节点,此时 Redis 的集群中就出现了两个主节点的问题,就是 Redis 脑裂问题。
脑裂问题影响
Redis 脑裂问题会导致数据丢失。
而最后一步,当旧的 Master 变为 Slave 之后,它的执行流程如下:
- Slave(旧 Master)会向 Master(新)申请全量数据。
- Master 会通过 bgsave 的方式生成当前 RDB 快照,并将 RDB 发送给 Slave。
- Slave 拿到 RDB 之后,先进行 flush 清空当前数据(此时第四步旧客户端给他的发送的数据就丢失了)。
- 之后再加载 RDB 数据,初始化自己当前的数据。
从以上过程中可以看出,在执行到第三步的时候,原客户端在旧 Master 写入的数据就丢失了,这就是数据丢失的问题。
脑裂问题解决方法
脑裂问题只需要在旧 Master 恢复网络之后,切换身份为 Slave 期间,不接收客户端的数据写入即可,那怎么解决这个问题呢?
Redis 为我们提供了以下两个配置,通过以下两个配置可以尽可能的避免数据丢失的问题:
- min-slaves-to-write:与主节点通信的从节点数量必须大于等于该值主节点,否则主节点拒绝写入。
- min-slaves-max-lag:主节点与从节点通信的 ACK 消息延迟必须小于该值,否则主节点拒绝写入。
这两个配置项必须同时满足,不然主节点拒绝写入。
在假故障期间满足 min-slaves-to-write 和 min-slaves-max-lag 的要求,那么主节点就会被禁止写入,脑裂造成的数据丢失情况自然也就解决了。
设置了参数之后,Redis 脑裂问题能完全被解决吗?Zookeeper 是如何解决脑裂问题的?
设置了
min-slaves-to-write
和min-slaves-max-lag
参数后,Redis 的脑裂问题可以得到一定程度的缓解,但并不能完全解决。原因如下:
网络分区:当网络发生分区时,主节点可能会与部分从节点失去连接,但仍然能够接收客户端的写入请求。如果剩余的从节点数量满足
min-slaves-to-write
的要求,主节点将继续处理写入操作,而这些写入可能不会同步到被网络分区隔离的从节点,从而导致数据不一致。假故障:如果主节点或从节点发生临时故障(如进程暂停、网络延迟等),在故障恢复之前,主节点可能会拒绝写入,这会影响系统的可用性。
配置复杂性:正确配置这两个参数需要深入理解系统的性能和网络状况,配置不当可能会导致不必要的写入拒绝或数据不一致。
Zookeeper 也会面临脑裂问题,但它通过以下机制来解决:
Zab 协议:Zookeeper 使用 Zab(Zookeeper Atomic Broadcast)协议来保证分布式系统的一致性。该协议确保了即使在发生网络分区时,也只有一个领导节点能够处理写操作。
过半机制:Zookeeper 集群中的每个操作都需要得到超过半数节点的同意才能执行。这确保了在网络分区时,至少有一半的节点与领导节点相连,从而避免了脑裂问题。
领导选举:当领导节点发生故障或网络分区时,Zookeeper 集群会自动进行领导选举,选举出一个新的领导节点来处理写操作。
通过这些机制,Zookeeper 在保证一致性的同时,也提高了系统的可用性和容错性。
限流算法与实现原理
高并发系统有三大特征:限流、缓存和熔断。
限流是指在各种应用场景中,通过技术和策略手段对数据流量、请求频率或资源消耗进行有计划的限制,以避免系统负载过高、性能下降甚至崩溃的情况发生。限流的目标在于维护系统的稳定性和可用性,并确保服务质量。
使用限流有以下几个好处:
- 保护系统稳定性:过多的并发请求可能导致服务器内存耗尽、CPU 使用率饱和,从而引发系统响应慢、无法正常服务的问题。
- 防止资源滥用:确保有限的服务资源被合理公平地分配给所有用户,防止个别用户或恶意程序过度消耗资源。
- 优化用户体验:对于网站和应用程序而言,如果任由高并发导致响应速度变慢,会影响所有用户的正常使用体验。
- 保障安全:在网络层面,限流有助于防范 DoS/DDoS 攻击,降低系统遭受恶意攻击的风险。
- 运维成本控制:合理的限流措施可以帮助企业减少不必要的硬件投入,节省运营成本。
限流常见算法
计数器算法:将时间周期划分为固定大小的窗口(如每分钟、每小时),并在每个窗口内统计请求的数量。当窗口内的请求数达到预设的阈值时,后续请求将被限制。时间窗口结束后,计数器清零。
- 优点:实现简单,易于理解。
- 缺点:在窗口切换时刻可能会有突刺流量问题,即在窗口结束时会有短暂的大量请求被允许通过。
滑动窗口算法:改进了计算器算法(固定窗口算法)的突刺(在短时间内突然出现的流量高峰或数据量激增的现象)问题,将时间窗口划分为多个小的时间段(桶),每个小时间段有自己的计数器。随着时间流逝,窗口像滑块一样平移,过期的小时间段的计数会被丢弃,新时间段加入计数。所有小时间段的计数之和不能超过设定的阈值。
- 优点:更平滑地处理流量,避免了突刺问题。
- 缺点:实现相对复杂,需要维护多个计数器。
漏桶算法:想象一个固定容量的桶,水(请求)以恒定速率流入桶中,同时桶底部有小孔让水以恒定速率流出。当桶满时,新来的水(请求)会被丢弃。此算法主要用来平滑网络流量,防止瞬时流量过大。
- 优点:可以平滑突发流量,保证下游系统的稳定。
- 缺点:无法处理突发流量高峰,多余的请求会被直接丢弃。
令牌桶算法:与漏桶相反,有一个固定速率填充令牌的桶,令牌代表请求许可。当请求到达时,需要从桶中取出一个令牌,如果桶中有令牌则允许请求通过,否则拒绝。桶的容量是有限的,多余的令牌会被丢弃。
- 优点:既能平滑流量,又能处理一定程度的突发流量(因为令牌可以累积)。
- 缺点:需要精确控制令牌生成速度,实现较漏桶复杂。
使用Redis实现限流
使用 Redis 也可以实现简单的限流,它的常见限流方法有以下几种实现:
- 基于计数器和过期时间实现的计数器算法:使用一个计数器存储当前请求量(每次使用 incr 方法相加),并设置一个过期时间,计数器在一定时间内自动清零。计数器未到达限流值就可以继续运行,反之则不能继续运行。
- 基于有序集合(ZSet)实现的滑动窗口算法:将请求都存入到 ZSet 集合中,在分数(score)中存储当前请求时间。然后再使用 ZSet 提供的 range 方法轻易的获取到 2 个时间戳内的所有请求,通过获取的请求数和限流数进行比较并判断,从而实现限流。
- 基于列表(List)实现的令牌桶算法:在程序中使用定时任务给 Redis 中的 List 添加令牌,程序通过 List 提供的 leftPop 来获取令牌,得到令牌继续执行,否则就是限流不能继续运行。
计数器算法
- 使用 Redis 的计数器保存当前请求的数量。
- 设置一个过期时间,使得计数器在一定时间内自动清零。
- 每次收到请求时,检查计数器当前值,如果未达到限流阈值,则增加计数器的值,否则拒绝请求。
滑动窗口算法
- 使用有序集合(ZSet)来存储每个时间窗口内的请求时间戳,成员(member)表示请求的唯一标识,分数(score)表示请求的时间戳。
- 每次收到请求时,将请求的时间戳作为成员,当前时间戳作为分数加入到有序集合中。
- 根据有序集合的时间范围和滑动窗口的设置,判断当前时间窗口内的请求数量是否超过限流阈值。
具体实现代码如下:
public class RedisSlidingWindowRateLimiter {private static final String ZSET_KEY = "request_timestamps";private static final int WINDOW_SIZE = 60; // 时间窗口大小(单位:秒)private static final int REQUEST_LIMIT = 100; // 限流阈值public boolean allowRequest() {Jedis jedis = new Jedis("localhost");long currentTimestamp = System.currentTimeMillis() / 1000;// 添加当前请求的时间戳到有序集合jedis.zadd(ZSET_KEY, currentTimestamp, String.valueOf(currentTimestamp));// 移除过期的请求时间戳,保持时间窗口内的请求long start = currentTimestamp - WINDOW_SIZE;long end = currentTimestamp;jedis.zremrangeByScore(ZSET_KEY, 0, start);// 查询当前时间窗口内的请求数量Set<Tuple> requestTimestamps = jedis.zrangeByScoreWithScores(ZSET_KEY, start, end);long requestCount = requestTimestamps.size();jedis.close();// 判断请求数量是否超过限流阈值return requestCount <= REQUEST_LIMIT;}
}
令牌桶算法
① 添加令牌
在 Spring Boot 项目中,通过定时任务给 Redis 中的 List 每秒中添加一个令牌(当然也可以通过修改定时任务的执行时间来控制令牌的发放速度),具体实现代码如下:
redisTemplate.opsForList().rightPush("limit_list",UUID.randomUUID().toString());
② 获取令牌
令牌的获取代码如下:
Object result = redisTemplate.opsForList().leftPop("limit_list");
在上述代码中,每次访问 allowRequest() 方法时,会尝试从 Redis 中获取一个令牌,如果拿到令牌了,那就说明没超出限制,可以继续执行,反之则不能执行。
使用 Redis 实现限流有什么优缺点?为什么微服务中不会使用 Redis 实现限流?
优点
- 高性能:基于内存的键值存储系统,读写速度非常快,适合用于需要高性能的限流场景。
- 分布式环境友好:支持分布式部署,可以在多个服务器之间共享计数器和状态。
- 灵活的限流策略:数据结构多,可以实现各种复杂的限流策略,如固定窗口、滑动窗口等。
- 原子操作:如 INCR、DECR 等,可以保证在并发场景下的数据一致性。
- 易于集成:Redis 在多种编程语言中都有客户端库,易于和各种应用系统集成。
缺点
- 依赖外部系统:依赖于 Redis 服务器,如果服务器出现问题,会影响限流功能的正常运行。
- 网络延迟:虽然 Redis 性能高,但网络请求比本地计算要慢,可能会引入额外的延迟。
- 资源消耗:虽然 Redis 是基于内存的,但在大规模和高并发的场景下,Redis 仍然可能消耗大量的内存和 CPU 资源。
至于为什么微服务中不会使用 Redis 实现限流,这个说法可能有些绝对。实际上,Redis 在微服务架构中经常被用于限流,尤其是在需要跨服务限流或全局限流的场景下。然而,微服务架构中不使用 Redis 进行限流的原因可能包括:
- 避免单点故障:微服务架构强调去中心化和容错性,过度依赖外部系统(如 Redis)可能会引入单点故障的风险。
- 简化架构:有些微服务架构可能会选择更简单的限流方案,如使用本地计数器或基于时间的令牌桶算法,以减少对外部系统的依赖。
- 性能考虑:对于一些对性能要求极高的微服务,可能会选择在服务内部实现限流逻辑,以避免网络通信的开销。