爬虫被限流时,服务器返回 429。限流有几种常见的实现,从客户端看行为各不相同。这篇在本机用四种自己实现的限流器,量一量边界突发、Retry-After 的取整、重试方式和自适应调速的实际表现。
固定窗口:被接受 40/90,按客户端的每一秒 [20, 10, 10];前 12 个被接受的请求的时刻 [0.0, 0.03, 0.07, 0.11, 0.14, 0.17, 0.2, 0.23, 0.27, 0.3, 0.33, 0.37] 滑动窗口日志:被接受 30/90,按客户端的每一秒 [10, 10, 10];前 12 个被接受的请求的时刻 [0.0, 0.04, 0.07, 0.1, 0.14, 0.17, 0.2, 0.23, 0.27, 0.3, 1.03, 1.07] 滑动窗口计数:被接受 34/90,按客户端的每一秒 [16, 9, 9];前 12 个被接受的请求的时刻 [0.0, 0.04, 0.07, 0.1, 0.14, 0.17, 0.21, 0.24, 0.27, 0.3, 0.4, 0.54] 令牌桶:被接受 39/90,按客户端的每一秒 [19, 10, 10];前 12 个被接受的请求的时刻 [0.0, 0.04, 0.07, 0.1, 0.14, 0.17, 0.2, 0.24, 0.27, 0.3, 0.34, 0.37]
固定窗口按整点切窗口,实现最简单;滑动窗口日志记录每个请求的时间,任何 1 秒内都不超过限额;滑动窗口计数用上一个窗口的加权估算;令牌桶允许攒满桶后的突发,之后按速率放行。从客户端匀速发 30 个/秒、限额 10 个/秒,3 秒内被接受的总数分别是 40、30、34、39。
固定窗口:每次(前 10 个被接受, 后 10 个被接受, 总跨度秒)[(10, 10, 0.155), (10, 10, 0.152), (10, 10, 0.161), (10, 10, 0.159), (10, 10, 0.15)];两批合计被接受 [20, 20, 20, 20, 20] 滑动窗口日志:每次(前 10 个被接受, 后 10 个被接受, 总跨度秒)[(10, 0, 0.15), (10, 0, 0.16), (10, 0, 0.155), (10, 0, 0.156), (10, 0, 0.152)];两批合计被接受 [10, 10, 10, 10, 10] 滑动窗口计数:每次(前 10 个被接受, 后 10 个被接受, 总跨度秒)[(10, 0, 0.152), (10, 0, 0.158), (10, 0, 0.155), (10, 0, 0.156), (10, 0, 0.15)];两批合计被接受 [10, 10, 10, 10, 10] 令牌桶:每次(前 10 个被接受, 后 10 个被接受, 总跨度秒)[(10, 1, 0.15), (10, 1, 0.152), (10, 1, 0.155), (10, 1, 0.156), (10, 1, 0.151)];两批合计被接受 [11, 11, 11, 11, 11]
在窗口边界附近,固定窗口两个窗口的额度可以连着用:0.15 秒内放过了 20 个,是限额的两倍。滑动窗口放过 10 个,令牌桶放过 11 个。如果你在帮服务器设计限流,这是固定窗口的缺点;如果你在写爬虫,别指望靠边界多拿额度——服务器的实现你并不知道。
固定窗口:整秒 Retry-After:用时 2.02 秒,发出 27 个请求,其中 429 有 2 个,实际速率 12.4 个/秒;精确毫秒:用时 1.90 秒,发出 27 个请求,其中 429 有 2 个,实际速率 13.1 个/秒 滑动窗口日志:整秒 Retry-After:用时 2.01 秒,发出 27 个请求,其中 429 有 2 个,实际速率 12.4 个/秒;精确毫秒:用时 2.01 秒,发出 27 个请求,其中 429 有 2 个,实际速率 12.4 个/秒 滑动窗口计数:整秒 Retry-After:用时 4.04 秒,发出 29 个请求,其中 429 有 4 个,实际速率 6.2 个/秒;精确毫秒:用时 2.58 秒,发出 35 个请求,其中 429 有 10 个,实际速率 9.7 个/秒 令牌桶:整秒 Retry-After:用时 2.02 秒,发出 27 个请求,其中 429 有 2 个,实际速率 12.4 个/秒;精确毫秒:用时 1.50 秒,发出 40 个请求,其中 429 有 15 个,实际速率 16.6 个/秒
按 RFC,Retry-After 是整秒数。服务器把 0.1 秒向上取整成 1 秒,守规矩的客户端就多等了 0.9 秒(滑动窗口计数下 4.04 秒对 2.58 秒)。精确的等待值更快,但发出的请求更多。服务器有 Retry-After 就应该遵守(见第 31 篇)。
429 后立刻重试:用时 5.50 秒,发出 15791 个请求(429 有 15731 个),服务器收到 15791 个 429 后等 Retry-After(毫秒):用时 5.50 秒,发出 115 个请求(429 有 55 个),服务器收到 115 个 匀速 100 ms 一个:用时 5.90 秒,发出 60 个请求(429 有 0 个),服务器收到 60 个
429 后立刻重试没有任何节制:实验里取 60 个页面发出了 15791 个请求,对服务器是一次小规模的压力,而且用时并没有更短。按速率发,或者按 Retry-After 等待,请求数少两个数量级,用时一样。
每个线程(被接受, 被拒绝):[(12, 1868), (10, 1854), (7, 1880), (10, 1849)];被接受合计 39(3 秒内理论上最多 10 + 3×10 = 40),被拒绝合计 7451
限流通常按客户端标识(IP、账号等)计算,同一个标识下的多个线程共用一个额度。4 个线程加起来仍是约 39 个,每个线程只拿到 7 到 12 个。线程多了不会让总量变多,只会让 429 更多。
固定窗口:每秒调整后的速率 [3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 6.0];总共发出 77 个,429 有 1 个(1%);后 6 秒平均每秒被接受 8.3 个 滑动窗口日志:每秒调整后的速率 [3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 5.5, 6.5];总共发出 71 个,429 有 1 个(1%);后 6 秒平均每秒被接受 7.3 个 滑动窗口计数:每秒调整后的速率 [3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 5.0, 6.0, 7.0];总共发出 69 个,429 有 1 个(1%);后 6 秒平均每秒被接受 7.0 个 令牌桶:每秒调整后的速率 [3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0];总共发出 86 个,429 有 0 个(0%);后 6 秒平均每秒被接受 10.0 个
不知道限额时可以用 AIMD(加性增、乘性减):没遇到 429 就慢慢加速,遇到就减半。实验里窗口类限流在越过限额的那一刻就拒绝,减半之后平均速率偏低(7 到 8.3 个/秒);令牌桶因为桶里有余量,速率到 13 个/秒都没被拒绝。实验只跑了 12 秒,没看到完整的锯齿。