基于nginx的频率控制方案思考和实践

时间:2023-12-13 19:32:38

基于nginx的频率控制方案思考

标签: 频率控制 nginx


背景

nginx其实有自带的limit_req和limit_conn模块,不过它们需要在配置文件中进行配置才能发挥作用,每次有频控策略的增删都需要直接改动配置文件,再让nginx重新加载配置文件。由于以配置文件的形式来管理导致整个流程不够灵活,因此它的实用性其实并不强,而且这也不适合大量的差异化的配置策略,不然配置文件更难维护了。基于此,下面展开了基于nginx的频率控制方案思考。本文不是最终的系统设计文档,很多地方都显得比较啰嗦和发散。

正文

上面简单谈了使用nginx自带的限流模块的局限性,下面谈谈我想要达到的目标。

目标

1 配置方面

  支持组合频率控制,如对具体的ip地址,userid,参数值的组合项进行频率控制。在这里我打算只对固定元素进行频率控制,如对ip 1.2.3.4进行频率控制,或者对ip 为1.2.3.4且userid为u1024的请求进行频率控制,在这里不考虑以集合作为参数,如以ip为key做频率控制(1是因为这会涉及到元素的淘汰,2是因为在实际业务需求中往往都是对指定调用来源做频控)

2 控制方面

  可即时更改,即时生效,控制灵活,毕竟nginx自带的频率控制模块在使用上最大的缺陷就是不够灵活,导致实用性不强。

整体设计

  针对上面的两点目标,再简单看一下整体设计

  1开发人员在配置页面上配置相关限制策略,通过配置cgi将其写入DB

  2 agent服务定期从DB中拉取全量最新的配置内容,根据各机器权重,计算出本机的频率控制速率,处理后存储于共享内存中

  3 接入层编写nginx模块,在Pre_Access阶段定期更新配置内容,同时获取请求信息,根据配置内容和请求信息进行频率控制

  

  整体结构如下

基于nginx的频率控制方案思考和实践

结构非常简单,其中同步agent直连db取配置这里在nginx接入层机器较少时是可以接受的。不过如果是为了长远考虑,可以在DB到nginx接入层之间加一层频率配置服务,由频率配置服务定期扫描db处理配置信息,同时每次配置cgi写db时也可通知频率配置服务有更新,而agent则直接请求频率配置服务即可。另外也只有在加上了频率配置服务才能更好的做到即时生效,因为agent可以以更短的时间间隔向频率控制服务轮询,是否有配置变更。

详细设计

1 配置模块设计

配置系统较为简单,可采用配置页面+配置cgi+mysql的模式,主要作用为将用户的配置项落地db,此处直接略过。

2 限流算法选取

采用漏桶算法或者令牌桶算法,两种算法各有优劣

漏桶算法模型

此处引用一下网上的图

基于nginx的频率控制方案思考和实践

算法逻辑

1 根据配置的速率,按固定速率处理请求

2 当有请求到达时,计算桶内当前水量

3 若当前水量超过桶容量,请求被限制,直接返回错误(503)

4 若当前水量小于桶容量,请求根据当前水量进行休眠,休眠结束后处理请求

算法特性

流量稳定,均匀

令牌桶算法模型

此处引用一下网上的图

基于nginx的频率控制方案思考和实践

算法逻辑

1 根据配置的速率,按固定速度往桶中添加令牌,若桶内令牌数已满,则放弃

2 请求到达时从桶中获取一个令牌,获取失败则返回错误(503)

3 获取成功则放行请求

算法特性

允许并发,其中最高并发数为令牌桶容量

Nginx限流模块参考

Nginx的limit_req模块采取的限流算法是漏桶算法,但是做了一些改进,允许一定程度的并发(允许的并发大小由用户配置,当未配置并发时,则采用的就是原生的漏桶算法了)。

3 共享内存设计

1 全局设计

共享内存一共分为2块,分别是配置块,统计块,分别存储配置信息和请求统计信息。其中配置块又均分为A/B配置块用于实现RCU

配置块的表头结构如下(单位为byte)

flag segment padding seq bitset
4 1 3 4 128

1 flag

初始化flag,标识该块内存是否被初始化

2 segment

当nginx worker需要更新配置内容时进行读取,指向当前应该读哪片内存

3 padding

填充字节以及作为保留字节,用于保证seq 4字节对齐

4 seq

为总的配置操作流水,所有的nginx worker每次处理请求时都会读取该值,判断跟本地保存的配置内容的seq是否相等,如果相等的话,则是最新配置,无需更新,如果不相等,则触发配置更新逻辑

5 bitset

128字节,共1024bit,每个bit表示对应偏移的统计slot(用于统计请求信息,下文会提到)是否已被使用,因此当前系统的设计最多可配置1024条策略(此处采用bit并不是为了节省内存占用,而是为了加速寻找到未被使用的统计slot,以8字节进行操作时,每次可判断64个bit的状态)

2 A/B内存块设计

由agent负责创建和管理,分为 A/B两块区域,通过RCU来避免agent与nginx worker的读写冲突。对于每条配置策略,agent都将分配一个统计slot用于统计请求信息。每块区域的结构如下

magic size item
2 2 ...

1 magic

固定值,检测内存是否出现异常

2 size

表示后面有多少条策略item

3 item

策略item的内容

3策略item

即用户配置的具体限流策略

3 策略item

即用户配置的具体限流策略

magic version length padding s_id seq offset rate size key value ...
2 2 2 2 4 2 2 4 2 ... ... ...

1 magic

固定值,用于检测当前的策略item是否出现异常,当读到错误值时,说明后续内容可能已经错乱,将跳出策略的加载逻辑,并进行错误上报

2 version

版本号,为后续功能扩展考虑

3 length

length为该条策略的总长度

4 padding

padding为填充和保留字段

5 s_id

策略id

6 seq

seq为该条策略的操作流水号,对配置更新时,大部分配置内容都是无需更新的,通过判断seq是否相等完成配置内容的增量更新

7 offset

用于定位对应的统计slot,初始化时将配置内容按s_id从小到大写入内存中,offset从0开始自增,策略更新时,不会影响offset值。(该字段的值与上面的bitset有关联)

8 rate

配置速率

9 size

维度数量,即有几对key/value组合

10 key

Key表示限流维度,如为ip,userid或者业务参数等等,其存储结构为length+value的组合,表示具体的限流纬度

11 value

同key的设计,表示具体的值

4 统计表

根据配置内存块的策略item的offset值以及统计共享内存的初始地址,可以确定每条策略对应的统计slot

统计slot设计

magic lock padding current burst timestamp
2 1 1 4 8

1 magic

固定值,用于检测当前的slot是否出现异常,当出现异常时,将初始化该块slot

2 lock

用于自旋锁上锁使用

3 padding

保留及填充区域,用于保证字节对齐

4 current burst

上一次被处理后桶中遗留的水量,或者桶内当前的令牌数

5 timestamp

统计周期时间戳

字节对齐,共16byte

5 初始化及更新

初始化

共享内存的初始化工作由agent来做。

这里再回顾一下配置块表头的结构

flag segment padding seq bitset
4 1 3 4 128

初始化流程如下:

agent:

1 获取或者创建共享内存

2 如果是获取成功,则判断头4个字节是否为初始化完成flag,如果是的话则直接跳过初始化流程,否则进入4

3 如果是新创建的共享内存,则清0头4个字节

4 根据从db中读取到的数据初始化共享内存

5 将共享内存的头4个字节赋值为初始化完成flag

nginx worker:

1 获取共享内存地址失败,则直接放行请求

2 判断头4个字节是否为初始化完成flag,不是的话直接放行请求

2 将配置load进进程内存

6 配置策略存储

在各进程初始化完毕之后,开始加载共享内存中的配置策略,策略的加载按顺序读取共享内存中的信息即可,配置信息的存储结构如下

typedef strcut {
  uint32_t s_id;
  uint16_t seq;
  uint16_t offset;
  uint32_t rate;
  uint32_t burst;
  uint8_t *slot;
  map<string,string> limits;
} LimitConf;
7 配置策略查找

当请求进来时,依次遍历所有限制策略,对匹配的策略执行限流算法相关的逻辑,直至请求被限制,或请求通过所有策略为止,流程如下

1 遍历限流策略

2 策略是否匹配,不匹配则返回1,匹配则进入3

3 记录下该条命中策略的offset,在统计更新逻辑中将被使用到

4 遍历结束

5 对命中策略的统计slot信息进行更新

假设我们有N条策略,每条策略平均有M个限制维度,那么遍历算法下的时间复杂度为O(MN),当策略变得很多时,策略的查找将会成为性能消耗的大头,如何解决呢?策略数量小的时候,无需解决。当数量较多时,可以有如下的方法:

1 可以以某些参数为索引,比如,在我的需求中,频率控制的粒度可以细化到接口,那可以以接口名作为索引,从而减少无效的匹配

2 每次请求进来时,我们都进行一次策略的遍历,很明显这是在做重复的事情,我们是否可以把每个请求中属于限流维度的信息提取出来,组成一个key,做一个缓存?

2这一点在当前的设计下可行性不强,因为我们的限流纬度包括ip,userid这种扩散性太强的维度。既然如此,我们可以把ip,userid这类参数单独提取出来,将除去ip,userid后的维度信息提取出来,组成一个key做一下缓存,再去单独匹配各个策略的ip和userid。不过考虑到系统的后续运维,一旦ip,userid多起来,还是会对性能产生较大的影响,转而一想,对于ip,userid一类的参数我们经常需要的是黑名单的功能,我们可以将ip与userid拿出来,设计一个黑名单模块,这样就消除了这种威胁。这里到底该如何处理还是需要针对实际情况来进行取舍,直接拉黑应该是不能满足所有的情况的。接下来我分别讨论下黑名单的设计和对于处理ip,userid之类参数的一点思考。

黑名单模块的设计在下面会单独提出来。现在先分析一下为什么有ip,userid类型参数后匹配会慢,主要原因是由于这类参数的取值扩散性太强,无法组成缓存用的key,导致只能先取到满足其它维度信息策略集合(设为集合A)后的一个一个策略去遍历。但是反过来想,如果我们有了某ip下能命中的策略集合(设为集合B),那只要取A,B的交集就能得到最终命中的策略了,从而避免了一个一个策略去做字符串匹配(当然ip和userid也可以转换为int加速匹配)。

集合匹配

我们在初始化配置策略时,同时初始化策略里面每个ip和userid(如果有对ip或userid做限制的话)命中的策略集合,集合的存储结构如下

typedef struct{
int max_offset;
uint64_t elts[20];//最多存放1280条策略
} StrategyColl;

elts存储该集合拥有的元素,由于每条策略都有自己的offset值(该值由0开始递增),因此elts对应的第offset个bit为1即表示对应的策略在此集合中

max_offset表示该集合中的所有策略中的最大的offset值

集合求交集的伪代码如下:

i = 0;
offset = (max(A.max_offset,B.max_offset)>>6)+1
while(i < offset){
C.elts[i] = A.elts[i]&B.elts[i];
i++;
}
C.max_offset = max_bit(C.elts,i);

得到的集合C即匹配的策略集合。

8多进程统计

由于nginx是多进程模型,而对于同一个限制策略我们都是在同一块共享内存上进行操作(读/写),这样会带来一个问题:如何解决读写冲突。

冲突的内存有如下位置:

1 配置策略

配置策略的冲突来源为多个nginx worker进程的读与agent的写。由于是单写多读的模式,采用RCU的模式可以完美解决该问题,将内存分为A/B两块区域,由变量X(1 byte)的值来决定使用哪块内存,如X=1使用A区域,X =2使用B区域。

2 统计slot

对于slot的统计由于我们需要同时更新水量(4字节)和时间戳(8字节),并且一个请求可能同时命中多条策略,因此需要使用锁来保证互斥。

统计逻辑

在这里我们以漏桶算法(令牌痛算法也类似)为例。

1 获取当前时间戳(cur_time)

2 使用自旋锁上锁

3 遍历命中的策略

4 对每一个策略,根据上一次请求被处理的时间戳(last_time),以及配置的速率rate,和上一次遗留的水量(pre_burst),计算当前的水量(cur_burst),根据当前水量和桶的最大容量MAX_BURST决定是否需要限流,伪代码如下

cur_burst =pre_burst  -  (cur_time - last_time) * rate + 1;
cur_burst = cur_burst > 0 ? cur_burst : 0;

5 如果需要限流,释放锁,直接返回错误

6 不需要限流,则继续遍历

7 遍历结束后,对所有的命中策略进行更新,同时记录需要休眠的最大时间

8 释放锁

9 根据需要休眠的最大时间开始休眠,休眠结束后放行请求。

选取自旋锁的原因

自旋锁实现原理伪代码如下

while(!cas(p,0,1))
  cpu_pause();

其中cas为原子操作,如果p指向的地址中的值为0,则将其设置为1。返回true或者false,cpu_pause()并不是要让该进程放弃cpu,进入阻塞或者就绪状态,而是让CPU以一种节能的方式空转。可通过下面的pause指令实现

__asm__ ("pause" )

实际上使用时我们对于cpu_pause()的使用还会进行优化,用于减少激烈的cas操作,另外为了避免有进程上锁后core了,导致其它进程死循环,我们会对循环次数加上限制,最后的伪代码如下

cnt = 1;
while(!cas(p,0,1) && cnt <= MAX_LOOP){
  for(i = 1; i <= cnt;i++ )
   cpu_pause();
  cnt=cnt<<1;
}

解锁只需要

cas(p,1,0);

由于我们上锁后的逻辑都属于计算型,且计算量较小,此处使用自旋锁的效率是很高的

风险

这里存在死锁的风险,一旦有进程上锁后,还没来得及释放,nginx重启了,那就没法再上锁了。由于共享内存的初始化是由agent做的,但是agent很难感知nginx是否重启(当然也是可以做到的,但是由agent去感知nginx重启以及找到死锁也是非常繁琐的一件事),因此需要有一种死锁识别和恢复策略,一种简单的策略是这样的:

1 在A/B块共享内存的信息头里扩展一个2字节的fail_cnt变量,每次有进程成功获取到slot锁都将对fail_cnt清0。

2 每次有进程在对该内存上锁失败后对fail_cnt进行value = fetch_and_add(p,1)的原子操作, 通过判断value的值,比如当value >= MAX_FAIL_CNT之后(另外我们可以在value>=MAX_FAIL_CNT时将value值进行上报,以此来监控是否有死锁出现),就认为此时出现了死锁,从而实现了死锁的识别。

识别之后,自然还需要对死锁进行恢复,恢复逻辑中可以采用文件锁,获取到文件锁的认为成功获取到了slot锁,进而清零fial_cnt变量,一切如初。

采用文件锁的原因:如果继续对一个新的变量使用自旋锁,那么由于自旋锁还是存在可能造成死锁的风险,导致这成为了一个死结。而文件锁在进程重启或者core时会自动释放掉。

当然,其实还有一种更优的方案,我们可以通过原子操作对fail_cnt进行清0,伪代码如下

update = true;
do{
  fail_cnt = *pt_fail_cnt;
  if(fail_cnt < MAX_FAIL_CNT){
   update = false;
   break;
  }
}while( !cas(pt_fail_cnt,fail_cnt,0) );

通过判断update值可知道是否是本进程清0成功,清0成功的认为获取到了锁。

如何无锁

在上述的频率控制方案中我们不得不使用自旋锁来实现互斥,原因有2点:

1 上述方案中一个请求可能命中多条策略,为了实现统计的精准度我们对共享内存进行了加锁

2 上述方案下多个进程都需要对burst(4byte) 和 timestamp(8byte)进行读和写,没法做到一次内存访问,但这两片内存的数据是有逻辑关联的,一荣俱荣,一损俱损,因此对它们的更新也需要做到原子性。

因此如果想做到无锁,需要针对以上两点做出如下两点对策

1 牺牲掉命中多条策略情况时的统计精准度

2 将上面对burst和timestamp的两次读/写操作合并为一次原子读/写操作

在实际的频率控制需求上,可以允许少量的误差,因此牺牲掉一定的精准度以换取更高的性能,在这里是可以接受的。

先设想这样一种情况,对于任何一个请求它都只会命中一条限制策略。在这种情形下,命中策略的查找能做到O(1),同时能轻松的实现无锁。

方案如下:

新的统计slot的结构如下

magic padding timestamp count
2 6 4 4

共16个字节,这里不再采用漏桶算法,而是采用分时统计算法,当然相应的配置策略结构那边也需要调整,比如不再需要burst变量来存储桶容量了,另外还需要扩展一个字段unit,表示统计时间粒度,在这里我们以秒来讨论。

1 count

统计变量

2 padding

填充字节,用于保证count,timestamp整体8字节对齐,timestamp 4字节对齐

3 timestamp

保存最新的时间周期,秒级。

更新逻辑

所有的更新操作都需要判断当前的时间戳(以下简称进程时间戳)与共享内存中的时间戳(以下简称共享时间戳)

当共享时间戳>=进程时间戳时,共享时间戳不需要更新

当共享时间戳< 进程时间戳时,count以及timestamp都需要更新。

更新时,count清0,同时更新时间戳,由于低32位就足够存储秒为单位的时间戳,以及机器字节序为小端模式,因此cas原子操作8字节(以uint64_t类型表示)直接赋值时间戳即可

伪代码如下

  uint64_t cur_time = time(NULL);
  uint64_t *pt_mix = p+offsetof(struct,timestamp);
  uint64_t shm_mix;
  uint32_t shm_time;
  do{
  shm_mix = *pt_mix;
  shm_time = shm_mix & 0xFFFFFFFF;
  if(shm_time >= cur_time)
   break;
  }while(!cas(pt_mix ,shm_mix,cur_time )//更新失败,则继续循环
以此保证最新的时间开始时,统计量也将被清0。

以一个实例来说明该方案的运转。

当请求到来时,我们获取当前的时间戳(秒为单位),对共享时间戳执行判断和更新逻辑。

更新时统一采用原子操作,伪代码如下

origi_value = fetch_and_add(pt,1);

通过origi_value可以知道请求是当前时间周期内的第多少个请求,再根据配置的速率决定是否限流。

现在回到之前的一个请求可能命中多条策略的情况。依然采用上述的无锁方案。更新逻辑将变成如下:

1 遍历命中的策略

2 对命中策略执行更新逻辑,用于保证统计新周期开始时,该策略的count会被清0

3 读取当前策略的count来判断是否会超过限流策略,如果会超过则直接返回503

4 遍历结束

5 再次遍历命中策略,对各条命中策略的统计变量count原子加1

误差分析

该方案的误差主要有如下2点:

1 由于一个请求可能命中多条策略,因此方案一中对统计变量count的限流逻辑判断和自增分离开了,即有可能连续的两个请求,上个请求通过后,还没来得及自增统计变量,或者只自增完部分统计变量,进程被切走,这时候落在另一个worker进程的下一个请求到达时,使用的还是上次的统计变量。

2 来自时间周期交界处的误差。设想一种极端情况,一个请求在某一秒的999ms时开始进行限流处理,当它更新完时间戳之后,进程被切走了,再切回来时shm_time已经变成了下一秒(这说明count变量也被清0过一次了),这会使本应属于上一周期的请求被统计到当前周期了,同理,当统计周期连续时,本周期的请求也可能被统计到下一周期。不过这个误差是可以定量分析的,其最大误差即系统的nginx work进程数-1。

隐患

隐患来自于分时统计算法本身的不稳定性,在漏桶算法下,流量将均匀的下传,这就像水坝一样,先对流量进行围堵,再通过闸门进行泄洪。而分时统计算法下,流量是可能在一个极小的时间片段中集中下传,容易对下层服务造成冲击(特别是一些涉及到访问db的服务)。

黑名单

讲到这里,内容其实已经快结束了,不过上面有提过将ip,userid一类扩散性极强的参数提取出来实现一个黑名单那模块,让我们来看看如何高效实现这一点。

黑名单内容只存放在共享内存,采用多阶hash(其中最后一阶采用线性探测来处理冲突,用于兜底)存储userid的hash值,ip地址由于本身可转化为一个uint32_t值,为避免误杀,因此不进行hash,直接使用。

表头结构如下

magic step_num max_slot current_num seq
2 2 4 4 4

1 magic

固定值,检测异常

2 max_slot

多阶hash的最大阶的slot数

3 step_num

多阶hash的阶数

4 current_num

当前的黑名单数量

5 seq

当前的公共seq值,每个slot也会有一个seq值,slot的seq值大于等于公共seq值才是有效的slot

黑名单slot设计

hash_value seq
4 4

1 hash_value

Hash值

2 seq

当前slot的seq,当slot的seq大于等于公共seq时,才是有效的

黑名单更新

黑名单更新操作由agent来做,更新时将从db中将全量黑名单数据取出来,先将hash值全部计算出来,找到对应的slot将其seq值置为公共seq值+1,当slot冲突时,判断冲突slot的seq值是否大于头部的seq值,若小于等于的话直接替换,否则在下一阶继续寻找。若最后一阶依然冲突,则开始进行线性探测,若最后无slot可用,则上报错误。更新完毕后,公共seq值+1。此种更新方案下,可保证多阶hash不会出现断层,即在多阶hash表中不会有无效的策略位于有效策略前面(hash值取模后相同时将会产生slot冲突,保证了位于多阶hash前面的阶数的一定是最新的要被使用的策略)。

黑名单查找

黑名单查找工作由nginx worker来做,分别取出用户端ip,guid值计算hash值,经hash后,看slot中的hash值是否一致,再看对应的slot是否有效,当有效时拒绝掉请求。共享内存还未初始化时,将跳过黑名单查找过程,直接放行。

最后

以上为对固定元素进行频率控制时的方案思考。全文较长,对碰到的一些技术问题均进行了分析和解决,部分地方进行了发散,最终频率控制系统该怎么实现还是需要根据实际情况来进行选取,在这里只是将我所想到的一些情况分享出来。

为什么最后的无锁方案中采用了分时统计算法而不是漏桶算法?

主要原因:在这里一旦采用漏桶算法后,性能将变得无法预期。

一般而言漏桶算法的时间精确度至少会为ms,这是因为一般而言我们的配置速率都是以s为单位,漏桶算法采用的时间精确度至少要小它一个数量级才行,否则无法对其进行精确划分,均匀控制流量。因此timestamp必须占用6个字节才行,下面是使用上述无锁方案,同时采用漏桶算法的伪代码。

  uint64_t cur_time = time_ms(NULL);
  uint64_t *pt_mix = p+offsetof(struct,timestamp);
  uint64_t shm_mix;
  Uint64_t cur_mix;
  uint64_t shm_time;
  Uint64_t shm_burst;
  uint64_t cur_burst;
  do{
   shm_mix = *pt_mix;
   shm_time = shm_mix & 0xFFFFFFFFFFFF;//低48位
   shm_burst = shm_mix >> 48;
   cur_burst =shm_burst - (cur_time - shm_time) * rate + 1;
   cur_burst = cur_burst > 0 ? cur_burst : 0;
   if(cur_burst > MAX_BURST)
   fail();//请求结束,该请求被拒绝
   cur_mix = cur_time + (cur_burst << 48)
  }while(!cas(pt_mix ,shm_mix,cur_mix )//更新失败,则继续循环

  由于shm_time很可能会大于cur_time(持续的几次更新失败之后,就可能出现这种情况了),这时必须再次获取当前时间戳,否则算出来的cur_burst的误差会比较大。所以真正的伪代码如下:

  uint64_t cur_time = time_ms(NULL);
  uint64_t *pt_mix = p+offsetof(struct,timestamp);
  uint64_t shm_mix;
  uint64_t cur_mix;
  uint64_t shm_time;
  uint64_t shm_burst;
  uint64_t cur_burst;
  do{
   shm_mix = *pt_mix;
   shm_time = shm_mix & 0xFFFFFFFFFFFF;//低48位
   shm_burst = shm_mix >> 48;
   if(shm_time > cur_time)
   cur_time = time_ms(NULL);
   cur_burst =shm_burst - (cur_time - shm_time) * rate + 1;
   cur_burst = cur_burst > 0 ? cur_burst : 0;
   if(cur_burst > MAX_BURST){
   fail();//请求结束,该请求被拒绝
   return;//可以返回了
   }
   cur_mix = cur_time + (cur_burst << 48)
  }while(!cas(pt_mix ,shm_mix,cur_mix )//更新失败,则继续循环

  不同于分时统计算法当shm_time > cur_time时,它能跳出循环,即当别的进程更新成功时,本进程也能跳出循环,所有进程的目标都是让shm_time保持最新,它在性能方面的最差表现是当所有的nginx进程都进入新的统计周期时间戳后,必定会有进程更新成功,因为此时count变量不会再有变化,而所有worker的时间戳又都是一致的。

  在采用漏桶算法时,当别的进程更新成功时,还将影响本进程的更新逻辑(需要重新获取时间戳),同时这里的cas操作成功的难度也非常高,在实现时自旋锁时,自旋锁的cas只有两种值,非0即1,而这里有无数种值,在性能方面的表现将变得很难预期,因此在无锁方案中放弃了漏桶算法。