25 限速器

限速器

目的:

  • 防止黑客的攻击
  • 防止对资源的访问超过服务器的承载能力
  • 防止在爬虫项目中被服务器封杀

在爬虫项目中,保持合适的速率也有利于我们稳定地爬取数据。大多数限速的机制是令牌桶算法(Token Bucket)来完成的。

令牌桶原理

令牌桶算法的原理很简单,我们可以想象这样一个场景,你去海底捞吃饭,里面只有 10 个座位,我们可以将这 10 个座位看作是桶的容量。现在,由于座位已经满了,服务员就帮我们叫了个号,我们随即进入到了等待的状态。

一桌客人吃完之后,下一位并不能马上就座,因为服务员还需要收拾饭桌。由于服务员的数量有限,因此即便很多桌客人同时吃完,也不能立即释放出所有的座位。如果每 5 分钟收拾好一桌,那么“1 桌 /5 分钟”就叫做令牌放入桶中的速率。轮到我们就餐时,我们占据了一个座位,也就是占据了一个令牌,这时我们就可以开吃了。

通过上面简化的案例能够看到,令牌桶算法通过控制桶的容量和令牌放入桶中的速率,保证了系统能在最大的处理容量下正常工作

在 Go 中,我们可以使用官方的限速器实现:golang.org/x/time/rate,它提供了一些简单好用的 API。

// 速率,代表每秒钟放入到桶中的令牌个数
type Limit float64

// 第一个参数传递的是 Limit 速率,第二个参数 b 表示桶的数量
func NewLimiter(r Limit, b int) *Limiter

// 参数是两个令牌之间的时间间隔,它会转化为对应的 Limit 速率。
func Every(interval time.Duration) Limit

// Wait是WaitN(ctx, 1)的简化形式, 参数 ctx 可以设置超时退出的时间,它可以避免协程一直陷在堵塞状态中。
func (lim *Limiter) Wait(ctx context.Context) (err error)
// WaitN阻塞当前直到lim允许n个事件的发生。当没有可用或足够的事件时,将阻塞等待,推荐实际程序中使用这个方法。
func (lim *Limiter) WaitN(ctx context.Context, n int) (err error)

// Allow是函数AllowN(time.Now(), 1)的简化函数
func (lim *Limiter) Allow() bool
// AllowN标识在时间now的时候,n个事件是否可以同时发生(也意思就是now的时候是否可以从令牌桶中取n个token)。
// 适合在超出频率的时候丢弃或跳过事件的场景。
func (lim *Limiter) AllowN(now time.Time, n int) bool

// ReserveN(time.Now(), 1)的简化形式
func (lim *Limiter) Reserve() *Reservation
// 用于标识调用者需要等多久才能等到n个事件发生(意思就是等多久令牌桶中至少含有n个token)。
// Wait/WaitN和Allow/AllowN其实就是基于其之上实现的,通过sleep等待时间和直接返回状态。
// 如果想对事件发生的频率和等待处理逻辑更加精细的话就可以使用它。
func (lim *Limiter) ReserveN(now time.Time, n int) *Reservation

func (lim *Limiter) Burst() int
// 改变Token桶大小
func (lim *Limiter) SetBurst(newBurst int)
func (lim *Limiter) SetBurstAt(t time.Time, newBurst int)

func (lim *Limiter) Limit() Limit
// 改变放入Token的速率
func (lim *Limiter) SetLimit(newLimit Limit)
func (lim *Limiter) SetLimitAt(t time.Time, newLimit Limit)

// 返回现在可用的令牌数。
func (lim *Limiter) Tokens() float64
func (lim *Limiter) TokensAt(t time.Time) float64

示例

package main

import (
  "context"
  "fmt"
  "golang.org/x/time/rate"
  "time"
)

func main() {
  //生成了一个限速器,其中桶的最大容量为 2,rate.Limit(1) 表示每隔 1 秒钟向桶中放入 1 个令牌。
  limit := rate.NewLimiter(rate.Limit(1), 2)
  for {
    if err := limit.Wait(context.Background()); err == nil {
      fmt.Println(time.Now().Format("2006-01-02 15:04:05"))
    }
  }
}
// output
2023-04-17 21:03:18
2023-04-17 21:03:18
2023-04-17 21:03:19
2023-04-17 21:03:20
// 前两次打印是在同一秒,这是因为桶中一开始有两个令牌可以用。之后,每一次打印都需要间隔一秒,因为每隔一秒钟才会往桶中填充一个令牌

使用 rate.Every 来生成 Limit 速率

package main

import (
	"context"
	"fmt"
	"golang.org/x/time/rate"
	"time"
)

func main() {
	// 每 500 毫秒放入一个令牌,换算过来就是每秒钟放入 2 个令牌。
	limit := rate.NewLimiter(rate.Every(500*time.Millisecond), 2)
	for {
		if err := limit.Wait(context.Background()); err == nil {
			fmt.Println(time.Now().Format("2006-01-02 15:04:05"))
		}
	}
}

多条件限速器

有时候我们还会有一些更复杂的需求,例如有多层限速器的需求(细粒度限速器限制每秒的请求,粗粒度限速器限制每分钟、每小时或每天的请求)。

假设我们的爬虫项目希望每分钟只能够访问 10 次目标网站,但是只有每分钟的限制是不够的。因为这样我们可能会一秒钟直接访问 10 次,这样服务器就能直接检测出我们是爬虫机器人了。所以,我们还需要控制一下瞬时的请求量,例如每秒钟访问的频率不超过 0.5 次。这里我也借鉴了《Concurrency in Go》中多层限速器的设计,在新的 Limiter 包中将限速器抽象为了 RateLimiter 接口,golang.org/x/time/rate实现的 Limiter 自动就实现了该接口:

package limiter

type RateLimiter interface {
  Wait(context.Context) error
  Limit() rate.Limit
}

多层限速器对应的 multiLimiter 如下:

type multiLimiter struct {
  limiters []RateLimiter
}

// 聚合多个 RateLimiter,并将速率由小到大排序。
func MultiLimiter(limiters ...RateLimiter) *multiLimiter {
  byLimit := func(i, j int) bool {
    return limiters[i].Limit() < limiters[j].Limit()
  }
  sort.Slice(limiters, byLimit)
  return &multiLimiter{limiters: limiters}
}

//Wait 方法会循环遍历多层限速器 multiLimiter 中所有的限速器并索要令牌,只有当所有的限速器规则都满足后,才会正常执行后续的操作。
func (l *multiLimiter) Wait(ctx context.Context) error {
  for _, l := range l.limiters {
    if err := l.Wait(ctx); err != nil {
      return err
    }
  }
  return nil
}

func (l *multiLimiter) Limit() rate.Limit {
  return l.limiters[0].Limit()
}

生成多层限速器并把它放入爬虫任务 Task 中,每一个爬虫任务可能有不同的限速。这里生成速率的函数用 Per 进行了封装,例如 limiter.Per(20, 1*time.Minute) 代表速率是每 1 分钟补充 20 个令牌。


func main(){
  //2秒钟1个
  secondLimit := rate.NewLimiter(limiter.Per(1, 2*time.Second), 1)
  //60秒20个
  minuteLimit := rate.NewLimiter(limiter.Per(20, 1*time.Minute), 20)
  multiLimiter := limiter.MultiLimiter(secondLimit, minuteLimit)

  seeds := make([]*collect.Task, 0, 1000)
  seeds = append(seeds, &collect.Task{
    Property: collect.Property{
      Name: "douban_book_list",
    },
    Fetcher: f,
    Storage: storage,
    Limit:   multiLimiter,
  })
}

func Per(eventCount int, duration time.Duration) rate.Limit {
  return rate.Every(duration / time.Duration(eventCount))
}

到这里,我们就不再需要在爬取数据时固定休眠了,只要使用限速器来控制速度的就可以了。

随机休眠

不过,如果使用多层限速器,我们访问服务器的频率会过于稳定。为了模拟人类的行为,我们还可以在限速器的基础上,增加随机休眠。

如下所示,假设设置的 r.Task.WaitTime 为 2 秒,我们这里使用了随机数,获取 0-2000 毫秒之间的任何一个整数作为休眠时间。

func (r *Request) Fetch() ([]byte, error) {
  if err := r.Task.Limit.Wait(context.Background()); err != nil {
    return nil, err
  }
  // 随机休眠,模拟人类行为
  sleeptime := rand.Int63n(r.Task.WaitTime * 1000)
  time.Sleep(time.Duration(sleeptime) * time.Millisecond)
  return r.Task.Fetcher.Get(r)
}

「此文章为4月Day9学习笔记,内容来源于极客时间《Go分布式爬虫实战》,强烈推荐该课程!/推荐该课程」

04-17 23:54