Backend

岗位数据怕被爬,限流从固定窗口改成可恢复的额度

2304 字约 8 分钟

摘要Job Market Monitor 准备上线前,我想防止别人批量抓取岗位数据,又不想让正常求职者碰到限流。第一版是游客只看第一页加按分钟和小时计数的固定窗口,在我追问“正常用户到底会用多少”之后,改成了会随时间恢复的额度,只限制持续的高速读取。

Job Market Monitor 里积累了几万个岗位和每天的变化记录,这些数据要是被人批量抓走,项目就没有价值了。准备正式上线之前,我问助手:上线之后怎么防爬?

我自己的想法很简单,没有登录的用户看岗位列表时,只能看第一页,不能翻页,也不能改每页条数,一操作就跳到登录页。已登录的用户怎么限制,我还没想好。

第一版的游客预览和账号额度

助手先检查了现有的机制。系统已经有按身份组配置的权限、每页条数上限、接口限流和审计告警,但游客可以请求每页 100 条,岗位接口每分钟允许 60 次,详情和画像也是开放的。

它先指出了一个问题。只限制“下一页”是不够的,爬虫可以不断更换关键词、城市和公司,把每一次查询的第一页拼起来,所以除了限制翻页,还要限制累计获取的数据量。它同时强调,只要浏览器能看到的东西,就有可能被复制,我们能做的是提高批量获取的成本,并尽早发现异常,不能保证绝对爬不走。

第一版的方案是这样:

  • 游客只能看预览。 岗位列表固定第一页 20 条,翻页、改每页条数、看详情都会引导登录,登录后回到刚才的页面,筛选条件也还在。其他分析页只给固定范围的摘要,比如趋势只给最近 7 天。
  • 服务端必须拒绝越界请求。 不能只是把按钮隐藏,直接调接口、改页码参数,同样要被拦住。
  • 已登录账号按“频率加累计量”双重限制。 默认每 10 秒 24 次、每分钟 120 次、每小时 1,200 次,同时最多 6 个查询;另外统计 24 小时内获取了多少个不同的岗位、看了多少份详情。累计量先只记录不拦截,用“观察模式”看真实使用情况。
  • 规则放在后台配置。 不按“VIP”之类的身份组名称写死,管理员可以按身份组调整,也可以临时限制或放行某个账号。

这一版当天就做完部署了。后端测试和前端构建都通过,游客翻页引导登录、筛选保留的行为也在真实页面上验证过。

这样是不是太宽松了

部署完我继续想了一件事。每小时 1,200 次请求,看起来对一个人来说太多了。我让助手分析,一个正常使用的用户到底大概会用多少。

助手先说明现在没有普通用户的真实使用样本,下面的数字是按页面交互估算的场景,不是统计。它还提醒了一个容易误判的地方:一次点击不等于一次请求。打开一个页面,列表、筛选项、统计会同时发出好几个请求,不能把接口请求次数当成用户点击次数,否则会误伤。

使用方式每小时请求列表里出现的不同岗位打开的详情
普通浏览约 50 到 150 次约 200 到 800 个约 10 到 40 个
集中筛选和比较约 150 到 300 次约 800 到 2,000 个约 40 到 100 个

按这个估算,它建议把每分钟降到 60 次,每小时降到 400 次,24 小时的累计岗位数降到 3,000。同时它也指出了这个思路的漏洞:慢速爬取照样能过关。比如每 30 秒请求一页、每页 20 条,一个小时也能拿到约 2,400 条记录,请求频率看起来很低。

我换了一个目标

我回复助手:这种很慢的爬就不用管它,主要是防止高频地获取我的数据;要想办法让正常用户几乎不会触发限流,同时又能防住爬虫。

助手重新看了一遍,结论是方向要调整。

  • 之前建议的“每小时 400 次”这种硬上限不能用。认真用几个小时的求职者,完全可能碰到。
  • 慢速访问既然可以接受,累计小时数和每日岗位量就不用作为主要的拦截条件,只保留为观察和审计的统计。
  • 真正要限的是持续的高速读取,所以应该允许短时间快速操作,但持续不停地请求就要降速。

这正好对应一种叫“令牌桶”的做法:把额度想成一个桶,桶有容量,也在匀速地回水。

通俗比喻:水桶和水龙头

固定窗口像一张“每分钟最多 120 次”的票,到了下一分钟全部作废重发。用户在一分钟的开头用光了,就要干等到下一分钟;爬虫恰好卡在两个窗口的交界,可以连着用两份。

额度桶则像一个会慢慢回水的桶。用户打开页面、连续切换几个筛选、快速翻几页,可以一次舀走不少水;之后停下来读页面,桶就慢慢回满。爬虫不停地舀,桶很快见底,后面的请求就得等。

它符合人的节奏,操作一阵,阅读一阵。

助手给的起步参数如下,全部保留在后台可调。数字是待验证的起点,不是定论。

限制项允许短时集中使用持续速度
全部数据请求约 30 次平均约 60 次每分钟
岗位列表返回的条数约 500 条平均约 1,000 条每分钟
岗位详情约 12 次平均约 30 次每分钟

请求次数、返回条数和详情是三个独立的额度。一次请求拿 20 条和拿 100 条,获取数据的速度完全不同,所以不能只数次数;统计和筛选联动的请求,也不应该挤占详情的额度。对列表的额度,是先预留、返回后再按实际条数结算,避免并发的请求绕过限制。

只有在多个时间段内都持续超速,并且无视等待提示,才会逐级延长暂停时间。偶尔碰到一次,只会让人等几秒。

前端也要配合

单靠服务端收紧,正常用户偶尔碰到限流时体验会很差,所以前端同步做了几件事:

  • 合并重复的请求,连续切换筛选时只查询最后一次有效的条件,之前还没返回的请求直接取消。
  • 暂缓不必要的预加载,不让页面自己制造请求。
  • 遇到很短的限流,按服务端建议的等待时间自动重试一次;恢复成功就继续显示,还受限才提示。长时间的暂停不会自动重试。
  • 限流不会让用户退出登录,已有的列表和筛选条件都保留,页面里显示一条带等待时间的提示和“重试”入口,而不是弹窗。

计次类的功能和管理操作不会被自动重放,避免重复扣次数。

实现上的取舍

这套额度没有新建数据库表,复用了系统里原来就有的限流计数表,用账号和请求类型拼成独立的键,在表里存“额度什么时候回满”。规则调整后,已经欠下的额度会保留,不会因为改了参数而突然清零或放过。

验证

后端的专项测试覆盖了几种情况:额度随时间恢复、多个并发请求共用同一份额度、列表条数按实际返回量结算、持续超速后逐级升级暂停,还有一条很重要的,长时间正常浏览不会被拦截。完整测试和前端构建都通过。

发布后在真实页面上验证了游客的首屏、搜索和翻页引导。受限提示在约 1.1 秒后只自动重试了一次,较长的暂停不会重试。验收中还发现,游客在导航时页面会预取完整的分析数据,随后被权限检查拒绝。页面用起来没问题,但这些请求没有任何用途,也算在额度里,所以把游客的这类预取去掉了。

没有解决的部分

最后要说清楚,这套机制管的只是高速抓取。如果爬虫主动把速度降到和人一样慢,它是放行的,这也是我当时明确做出的取舍。

另外,这一阶段注册不需要邮箱验证,创建账号的成本很低,单个账号的额度很容易被大量账号绕过。人机验证需要接入外部服务,当时也没有做。CDN、WAF 和对源站的访问限制,因为还没有正式部署,也先放在了后面。这些都属于上线之前还要补上的东西。

评论