一文带你彻底理清 Redisson RateLimiter tryAcquire 分布式限流的底层原理 ...

打印 上一主题 下一主题

主题 1830|帖子 1830|积分 5490

1.一个简朴的 Redisson 限流必要用到哪些方法?         

下面是一个最简朴的 Redisson 限流实践:
  1. @Test
  2. void test1(){
  3.        RRateLimiter rateLimiter = redissonClient.getRateLimiter("limit:user:1");
  4.        rateLimiter.trySetRate(RateType.OVERALL,3,2, RateIntervalUnit.SECONDS);
  5.        System.out.println(rateLimiter.tryAcquire(1));
  6.        System.out.println(rateLimiter.tryAcquire(3));
  7. }
复制代码
注意,tryAcquire()等同于 tryAcquire(1)

 这个实践的含义是对用户1(user:1)举行限流,大致分为三个流程:
===  getRateLimiter 获取限流器对象
==>  trySetRate 设置对于同一个名称的限流器(limit:user:1), 2秒 内最多允许 3 次请求(正确一点说,是3个允许证,一个请求可以获取多个允许证)
==>  tryAcquire (num) 代表,实验获取 num 个允许证,成功获取返回 true,代表通过限流
具体来讲,执行上面的 测试方法,两秒内两次 tryAcquire,由于两秒内 1+3 = 4 > 3,因此第二次 tryAcquire 会失败。
信赖来了解原理的读者大多都了解上面三个 API 的用法,那么现在的问题是:这三个 API 毕竟干了些什么?

2. getRateLimiter 和 trySetRate 干了什么?

2.1 getRateLimiter 没有对 Redis 举行任何操作,只是初始化限流器对象,并保存你传入的 name 参数

追下源码

追进构造器

可以看到,实际上就是用 name 举行了 RateLimiter 对象的初始化,而看不到任何 Lua 脚本

2.2 trySetRate 实际操作 Redis,将限流参数设置到 Redis hash 结构中

为了直观理解 trySetRate 如何操作 Redis ,可以利用 Redis 可视化客户端(我是用的是 RESP)来查察 Redis 中 key 的变革
在上文提到的 Test 测试方法中打断点运行,我们会发现,在执行 getRateLimiter 后,革新 RESP,key 没有任何变革,状态如下图

而当我们打断点执行完 trySetRate 的一行之后,革新 RESP,就可以看到 db1 中多了一个 key

这个 key 有点眼熟?
  1. @Test
  2. void test1(){
  3.        RRateLimiter rateLimiter = redissonClient.getRateLimiter("limit:user:1");
  4.        rateLimiter.trySetRate(RateType.OVERALL,3,2, RateIntervalUnit.SECONDS);
  5.        System.out.println(rateLimiter.tryAcquire(1));
  6.        System.out.println(rateLimiter.tryAcquire(3));
  7. }
复制代码
仔细一看,就是之前你在 getRateLimiter 里传入的 name 参数,rateLimiter 对象中保存了这个 name 参数,并在 trySetRate 时将该参数作为 key ,set 到 Redis 中
那么这个 key 的内容是什么呢?

可以看到,就是一个 hash 结构,将我们 trySetRate 时传入的 rate, rateInterval,type 等,作为 field - value 设置到了其中!interval 单元为毫秒。
通过可视化,我们了解了 trySetRate 的大致行为,接下来我们追入 Lua 脚本源码


看到绿色字符串的时候,我们知道,这波稳了(狗头)。显然,和上面说的一样,不过细节是 Lua 脚本接纳 hsetnx,nx 保证了只有在不存在该 field 的环境下才能设置,以是你在代码里重复 trySetRate 是不会报错的,不过如许不太规范(我们下面会讲)

3. tryAcquire (long permits) 执行前后 Redis 状态对比

个人的学习经验是,学习某项技术不能一开始就去看源码,也不能一开始就问 AI,我们起首必要通过可视化工具让自己对代码执行的效果建立起一个直观的理解,之后再去看源码比力合适。以是这里就先给各人展示下 tryAcquire 后 Redis 的变革,建立起一个基础理解后再去看 Lua 脚本。
起首,在 trySetRate 将指定限流配置设置到 Redis 中后,接下来我们 tryAcquire 获取允许证,看看 Redis 中发生了什么。
这是我们在 tryAcquire 之前的 Redis 的状态

接下来 debug 继承,执行 tryAcquire(1),可以看到执行完成之后,db1多出了两个key!这两个key的全名是:{limit:user:1}:permits,{limit:user:1}:value,这两个 key 观察一下,发现就是在原来的限流 key 上拼接了后缀形成新的 key


3.1 tryAcquire 后限流key (limit:user:1) 没有任何变革,可以将它理解为只读的配置 key 

我们这时去观察原来的 limit:user:1 的限流key,发现 hash 结构中 的值没有任何变革!这表明这个 key 只负责存储我们指定的限流配置,而不会在 tryAcquire 时去举行写操作等改变 field 的值

那么我既然通过了限流,总有一些值必要改变吧,是哪里触发了写操作呢?这就要说到刚刚多出来的两个 key : {limit:user:1}:permits,{limit:user:1}:value ,我们去看一下它们现在是什么值

3.2 开端理解限流 key 拼接 “value”、限流 key 拼接 "permits" 所产生的两个key


起首我们看 {limit:user:1}:value 这个 key,发现它为 2。2和前面有什么关联?对了,设置的 rate 为 3,而我们 tryAcquire(1)获取一个允许证,这不就剩下了 2 吗?看到这里你应该已经对 RateLimiter 的原理有了一些开端的猜测,联想到,trySetRate 后初始就有 3 个允许证,和设置的 rate 相对应;tryAcquire 会使允许证扣减,而 {limit:user:1}:value 则代表扣减后剩下的允许证数量。接下来在 Lua 脚本的源码中,你会更清晰地理解它和限流的 interval 有什么关系,到底是什么过程。
拼接 "value" 的 key 说完了,拼接"permits"的 key 呢?
可以看到 {limit:user:1}:permits 是一个 ZSET 范例,也就是 SortedSet。 value 不知道在写什么,score 里又是什么玩意?好,我们接下来开始看 Lua 脚本源码


4. tryAcquire(long permits) Lua 脚本源码详细剖析

4.1 理解 Lua 脚本中的 KEYS,ARGV 数组的各元素的含义

追进 tryAcquire

追进 tryAcquireAsync,可以发现到了 Lua 脚本源码部分

这里为了方便展示,我去除了 + 号等,得到了如下代码:
  1. return this.commandExecutor.evalWriteAsync(this.getName(), LongCodec.INSTANCE, command,
  2. "
  3. local rate = redis.call('hget', KEYS[1], 'rate');
  4. local interval = redis.call('hget', KEYS[1], 'interval');
  5. local type = redis.call('hget', KEYS[1], 'type');
  6. assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized')
  7. local valueName = KEYS[2];
  8. local permitsName = KEYS[4];
  9. if type == '1' then
  10.     valueName = KEYS[3];
  11.     permitsName = KEYS[5];
  12. end;
  13. local currentValue = redis.call('get', valueName);
  14. if currentValue ~= false then
  15.     local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
  16.     local released = 0;
  17.     for i, v in ipairs(expiredValues) do
  18.         local random, permits = struct.unpack('fI', v);
  19.         released = released + permits;
  20.     end;
  21.     if released > 0 then
  22.         redis.call('zrem', permitsName, unpack(expiredValues));
  23.         currentValue = tonumber(currentValue) + released;
  24.         redis.call('set', valueName, currentValue);
  25.     end;
  26.     if tonumber(currentValue) < tonumber(ARGV[1]) then
  27.         local nearest = redis.call('zrangebyscore', permitsName, '(' .. (tonumber(ARGV[2]) - interval), tonumber(ARGV[2]), 'withscores', 'limit', 0, 1);
  28.         local random, permits = struct.unpack('fI', nearest[1]);
  29.         return tonumber(nearest[2]) - (tonumber(ARGV[2]) - interval);
  30.     else
  31.         redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1]));
  32.         redis.call('decrby', valueName, ARGV[1]);
  33.         return nil;
  34.     end;
  35. else
  36.     assert(tonumber(rate) >= tonumber(ARGV[1]), 'Requested permits amount could not exceed defined rate');
  37.     redis.call('set', valueName, rate);
  38.     redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1]));
  39.     redis.call('decrby', valueName, ARGV[1]);
  40.     return nil;
  41. end;
  42. ",
  43. Arrays.asList(this.getName(), this.getValueName(), this.getClientValueName(), this.getPermitsName(), this.getClientPermitsName()), new Object[]{value, System.currentTimeMillis(), ThreadLocalRandom.current().nextLong()});
复制代码
要看懂这个 Lua 脚本,开始必要做的事是:理解 KEYS 和 ARGV 的各元素分别代表什么。
起首要知道,这段代码开头的 evalWrite 会将这段代码末端的
Arrays.asList(this.getName(), this.getValueName(), this.getClientValueName(), this.getPermitsName(), this.getClientPermitsName()) 作为 Lua 脚本的 KEYS ,
将 new Object[]{value, System.currentTimeMillis(), ThreadLocalRandom.current().nextLong()}    作为 Lua 脚本的  ARGV,Lua 脚本中的索引从1开始。
那么 KEYS 大小为5,其中看到第三个参数 this.getClientValueName() 和第五个参数 this.getClientPermitsName() 带有 Client ,这主要运用于单客户端限流,而我们之前在 trySetRate 中已经指定过 RateType.OVERALL 全体限流,因此这两个参数我们不消管;那么第一,第二,第四个参数分别代表什么?this.getName,getValueName,getPermitsName,看到这些单词,是不是认识起来了?
getName 获取到的就是 getRateLimiter() 里传入的 name 参数,代表限流器配置key “limit:user:1”,也就是说, KEYS[1] = "limit:user:1";
getValueName 获取到的就是之前看到的 {limit:user:1}:value ,                                                     也就是说, KEYS[2] = "{limit:user:1}:value";
getPermitsName 获取到的就是之前看到的 {limit:user:1}:permits,                                                也就是说,  KEYS[4] = "{limit:user:1}:permits"
ARGV 就比力容易理解了
ARGV[1] = value,也就是你 tryAcquire(permits)的 permits 值;
ARGV[2] = 当前毫秒时间;
ARGV[3] 则是一个随机数

4.2 Lua 脚本分段解读

起首看下 Lua 脚本第一段,显然就是读取到了 KEYS[1],也就是限流器配置 key 里的各项配置 rate,interval,type;然后将 KEYS[2],KEYS[4] ,也就是别的两个 key 赋值给 valueName 和 permitsName,type == 1 代表客户端限流而非全局限流,不思量
  1. local rate = redis.call('hget', KEYS[1], 'rate');
  2. local interval = redis.call('hget', KEYS[1], 'interval');
  3. local type = redis.call('hget', KEYS[1], 'type');
  4. assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized')
  5. local valueName = KEYS[2];
  6. local permitsName = KEYS[4];
  7. if type == '1' then
  8.     valueName = KEYS[3];
  9.     permitsName = KEYS[5];
  10. end;
复制代码
接着看 Lua 脚本第二段,也就是从 redis.call('get', valueName) 开始的这一段。
起首回顾我们上面提到的示例,在 trySetRate 后,第一次 tryAcquire 使 Redis 中多出了两个 key,我们起首到源码中看一下这多出来的两个 key 走的是什么逻辑。
redis.call('get', valueName) 获取 {limit:user:1}:value 对应的值,可是上面可视化也看到,我们 trySetRate 后是没有{limit:user:1}:value 这个 key 的,因此第一次 tryAcquire,这里 Lua脚本中得到的 currentValue 值为 null;
可以看到下面的 if currentValue ~= false (也就是 != null) 做了currentValue 是否为空的判定,由于此时 currentValue 为空,以是应该走 else 逻辑

else 逻辑干了什么?
  1. else
  2.     assert(tonumber(rate) >= tonumber(ARGV[1]), 'Requested permits amount could not exceed defined rate');
  3.     redis.call('set', valueName, rate);
  4.     redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1]));
  5.     redis.call('decrby', valueName, ARGV[1]);
  6.     return nil;
  7. end;
复制代码
起首,第一行 assert 判定 限流器 rate 参数是否大于即是 ARGV[1], ARGV[1] 即 tryAcquire 传入的 permits 。假如 rate < 传入的 permits,就返回 'Request permits mount could not exceed defined rate',即 '请求的允许数不能超过限流器配置的 rate',这个也好理解,你两秒钟限3次,(限3个允许),那你一次请求4个允许显然是不行的。
接下来的三行,关键点来了!
redis.call('set', valueName, rate): 
这代表假如是 trySetRate 后第一次 tryAcquire,则会去 Redis 中设置 String 范例的 {limit:user:1}:value,初始化值为限流器配置的 rate;
redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1]));
zadd 这一段接纳的是 Redis sortedSet 数据结构的 zadd key score value 下令将当前毫秒时间戳 (ARGV[2]) 作为 score,并将随机数(ARGV [3] )、permits (ARGV[1])打包作为 value,存入新建的key {limit:user:1}:permits 中

这下你是否可以理解之前看到的这个图了呢?
score的这一长串就是当前毫秒时间戳,而 value 则是 struct.pack,压缩(打包) permits 和随机数后得到的一个值
而最后一行 decrby  valueName  ARGV[1]比力简朴,既然你现在 {limit:user:1}:value 初始值为 rate,而且 assert 判定了 rate >= permits,那么这个请求获取允许证肯定是成功的;获取完后从 {limit:user:1}:value 中 扣减 permits(ARGV[1]) 个允许,然后返回 nil;
那么我们可以好好思考一下了:为什么 要有 zadd 的那一行代码,将这个成功获取到允许证的请求的 时间戳 和 打包值 存储下来?
拿我们的例子来看,rate 为3,rateInterval为2000(2秒),你想,假如按照现在提到的逻辑,每一次 tryAcquire(permits) 获取允许证都从 {limit:user:1}:value 中 扣减 permits 个允许,但是{limit:user:1}:value 初始化值就是 rate = 3, 你如许获取允许证,总有一天会获取完的,岂非我们建了一个限流器,从建立开始到世界扑灭都只能提供3个允许证吗?那也太搞笑了。
为了实现一个正常运作的限流器,关键点就在于 回收 老请求的允许证,将回收的允许证数加到{limit:user:1}:value 的 key 中,{limit:user:1}:value 有减有加才能确保限流器一直正常工作;而为了回收老请求的允许证,我们必要记录每一个成功获取允许证的请求,以是用到了 zadd 记录到 SortedSet 。
我们上面说,要回收老请求。那么什么可以界说为"老"请求 ? 这下我也亢奋起来了,写了这么久,终于写到了RateLimiter最核心的点:
1)回收:第一次 tryAcquire 后,每当一个请求实验  tryAcquire,Redisson 就会回收“当前时间戳减去  rateInterval”前的所有请求记录,并将它们的 permits 加回到 {limit:user:1}:value上;
2)判定:然后再判定现在的{limit:user:1}:value 的允许数是否 >= 当前请求的 permits:
==> 假如是,分析可以给这个请求 permits 个允许,则从 {limit:user:1}:value 扣减对应数量的允许,并记录这个获取允许成功的请求,也就是通过 zadd, 将这个请求对应的时间戳和打包值存储到 {limit:user:1}:permits 中;
==> 假如不是,分析给不了这个请求  permits 个允许,当前请求获取允许证失败;这个时候不会记录该请求到{limit:user:1}:permits,也不会扣减允许证。返回给客户端一个毫秒值时间,代表还必要等候多久可以再次实验获取。
这也就是说,老请求指的是 rateInterval 前的请求,每次有客户端实验获取允许证,都会去回收 rateInterval 前的请求。
上面这么说,可能有点抽象,通过下面的图来辅助理解

4.3 多个请求实验获取同一限流器允许证,流程可视化图




是否能理解呢?具体来说:
1.每一个请求来获取允许证,都会先实验回收 2000 毫秒前的请求
==> 假如SortedSet 中没有2000ms前的请求记录(没有请求,或者已经被回收过) , 就什么都不做;
==> 假如SortedSet 中有2000ms前的请求记录,则回收请求;回收请求的允许证数将加回到 {limit:user:1}:value,并将回收请求移除出 SortedSet
2.实验回收请求后,判定当前 {limit:user:1}:value 中允许证个数是否 >= permits,假如是,则获取允许证成功,记录请求,扣减允许(对应限流放行);假如不是,获取允许证失败,不记录请求(对应限流拒绝)

理解上面的流程后,你会发现一个风趣的特性:
{limit:user:1}:value (当前可用允许证数量)再怎么变革,也不会超过 rate=3 —— 因为第一次 tryAcquire 时,指定了 {limit:user:1}:value 初始值为 rate,而后续我们没有主动增加 {limit:user:1}:value 的操作,一切允许证的增加都靠回收,可以理解成开始的 3 个 允许反复回收利用。

4.4.理解流程可视化图后,回看 Lua 脚本源码

我们之前另有一段 Lua 脚本没有读过,这段是最复杂的,但是在理解上面的流程可视化图后,你会发现,无非就是先回收,再判定!
  1. local currentValue = redis.call('get', valueName);
  2. if currentValue ~= false then
  3.     local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
  4.     local released = 0;
  5.     for i, v in ipairs(expiredValues) do
  6.         local random, permits = struct.unpack('fI', v);
  7.         released = released + permits;
  8.     end;
  9.     if released > 0 then
  10.         redis.call('zrem', permitsName, unpack(expiredValues));
  11.         currentValue = tonumber(currentValue) + released;
  12.         redis.call('set', valueName, currentValue);
  13.     end;
  14.     if tonumber(currentValue) < tonumber(ARGV[1]) then
  15.         local nearest = redis.call('zrangebyscore', permitsName, '(' .. (tonumber(ARGV[2]) - interval), tonumber(ARGV[2]), 'withscores', 'limit', 0, 1);
  16.         local random, permits = struct.unpack('fI', nearest[1]);
  17.         return tonumber(nearest[2]) - (tonumber(ARGV[2]) - interval);
  18.     else
  19.         redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1]));
  20.         redis.call('decrby', valueName, ARGV[1]);
  21.         return nil;
  22.     end;
复制代码
具体来说:
1.起首通过 zrangebyscore 获取 SortedSet 中 score 小于(ARGV[2] - interval )的记录,ARGV[2] 是当前时间戳,也就是获取时间戳在当前时间戳 interval ms 前的所有记录;
2.for 循环盘算被回收的请求的允许证总数;
3.假如有请求被回收(released > 0), 那么 zrem 移除 SortedSet 中的对应记录,回加 released,即 ‘开释允许证数‘ 到 currentValue,并 set 更新 {limit:user:1}:value;
4.在 {limit:user:1}:value 被更新(或稳定)后,判定 currentValue 与 ARGV[1](permits) 的大小:
假如currentValue < permits,分析当前的允许证数不敷它获取;那么,基于重试机制的思量,再次 zrangebyscore 获取 "当前时间戳减去 rateInterval 到 当前时间戳" 区间内的最早的请求,返回一个时间,代表这个最早的请求另有多久被回收;
假如currentValue >= permits,分析当前允许证数可以满足它获取,那么,zadd 记录请求,decrby 扣减 {limit:user:1}:value 允许证数量。
以上就是整个 tryAcquire Lua脚本的解读。

一些细节问题:
currentValue < permits环境的详细解读?
  1. local nearest = redis.call('zrangebyscore', permitsName, '(' .. (tonumber(ARGV[2]) - interval), tonumber(ARGV[2]), 'withscores', 'limit', 0, 1);
  2. local random, permits = struct.unpack('fI', nearest[1]);
  3. return tonumber(nearest[2]) - (tonumber(ARGV[2]) - interval);
复制代码
上面说的可能不敷详细。起首,重试机制指的是获取允许失败后不是立刻失败,而是过一段时间再实验。那么什么时候再实验?总不能无脑的重复实验,很影响性能。我们上面提到,允许证的增加只靠回收。那么是不是等到下一个请求被回收,允许证增加后,再让请求重试,会比力公道呢?
以是,这里的 nearest 指的就是下一个将被回收的请求。zrangebyscore 下令中 的范围 (tonumber(ARGV[2] - interval), tonumber(ARGV[2]),代表 "当前时间戳减去 rateInterval 到 当前时间戳" 区间; limit 0,1 代表获取第一条。由于这个 nearest 在 (tonumber(ARGV[2]) - interval), tonumber(ARGV[2]) 区间内,以是它不会现在就被回收,而是要等到一段时间后才被回收。等到多久之后被回收?
起首,nearest 是 SortedSet 中的一条记录,nearest[1] 代表 pack 的谁人值,nearest[2] 代表它获取允许证时的时间戳。

现在拿上面谁人例子举例,请求A是nearest,它对应的时间戳是nearest[2],当前回收了ARGV[2] - interval 之前的请求,那么,是不是等到黑色双箭头的时间过去,请求 A 就将被回收,允许证对应增加?那么 Redis 就返回 (nearest[2]) - (tonumber(ARGV[2]) - interval) ,就是在告诉发出请求C的客户端:你过这段时间再来,到时候就能回收请求A,说不定就有足够的允许了!

 

5.总结:

5.1 概括 Redisson RateLimiter 的工作流程

getRateLimiter 获取限流器
trySetRate 用 hsetnx 下令向 redis 的 限流器名称的 key 中写入限流配置 rate , rateInterval, type
tryAcquire 实验获取允许证
tryAcquire 底层 Lua 脚本的逻辑是:起首判定是否是第一次来,假如是建立限流 key 后第一次来,那么会创建 限流 key + value, 限流 key + permits 这两个新的 key ,+value的 key 是 String结构, 用于存储剩余的允许证个数,初始值为 rate;+ permits 的key 是 sortedSet 结构,用于存储获取允许证成功的历史请求,并记录它们获取允许证的个数,以 获取允许证的毫秒时间戳作为 SortedSet 的 score。
接着 第二次开始,起首用 zrangebyscore 下令获取 当前时间 rateInterval 毫秒之前的历史请求,将它们移除出 SortedSet,并将其允许证回收,回加到 value 的 key;然后判定剩余允许证个数是否 >= 当前请求实验获取的允许证个数,假如满足,那么扣减允许证,zadd 记录到 SortedSet 并返回 true; 否则,返回必要等候的时间,即,现在 SortedSet 中最早的历史请求将过多久被回收。

5.2 RateLimiter 接纳滑动窗口实现了限流

概括竣工作流程后,我们应该思考:它到底如何实现的限流?实在也好理解,RateLimiter 接纳的,相称于是将每一个实验获取允许证的请求的当前时间戳作为滑动窗口的末尾。它回收了滑动窗口头部之前的所有允许证,使得当前长度为 rateInterval 的滑动窗口,最多允许 rate 个允许证(因为允许证上限只有 rate 个)。假如当前请求会导致滑动窗口允许证个数多于 rate 个,就拒绝,反之通过限流。
实在网上有不少人说 RateLimiter 是令牌桶,但是我以为并不是。令牌桶不会严酷限制某个时间区段内的允许证个数,从而更灵活;而显然这里的 RateLimiter 严酷限制了长度为 rateInterval 窗口内的允许证数量,以是我说是滑动窗口。不过,这点也没那么紧张,见仁见智吧。

5.3 理解源码后,我们配置 RateLimiter 能做出哪些优化?

Rate不要设置太大。从源码中你也看出了,RateLimiter 记录了所有的获取允许证成功的记录,以是假如你设置的Rate值过大,在Redis中存储的信息 (permitsName对应的zset) 也就越多,每次执行那段lua脚本的性能也就越差,这对Redis实例也是一种压力。假如想设置较大的限流阈值,倾向于小Rate+小时间窗口的方式。比如在允许的环境下,不要设置 30秒 允许 90 个允许,而应该设置1 秒允许 3 个允许,后者这种设置方式请求也会更均匀一些。(这一段引用自华为云社区详解Redisson分布式限流的实现原理-云社区-华为云)

尾声:
这一篇文章实在是呕心沥血,写了好久,花了很大力气,但最后照旧感觉把这个事讲清晰了,我自己对源码的理解也更清晰了!网上同样也有其他源码解读,但是我认为没有到达看一遍就能看懂的程度,以是希望能做一些对社区真正有所贡献的事。假如以为文章的结构、叙述顺序等有可以优化的地方,欢迎指出,也是督促我优化文章、之后写的更易懂的动力,谢谢~



免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

河曲智叟

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表