为 Web 服务加上访问频率限制

暴露在公网上的 Web 服务,特别是 API 服务,很可能会突然遇到大规模的访问,无论这是有意的攻击,还是程序员无意之中写出了 bug,都会给服务端带来巨大的访问负载,从而影响到正常用户的访问。解决这个问题最简(jian)单(lou)的方法便是对单个用户的访问频率加以限制。

如何给用户加以限制并且尽量不影响用户的体验呢?我们来看看 GitHub 是怎么处理的。

1
2
3
4
5
# 正常访问
HTTP/1.1 200 OK
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Reset: 1520337201
1
2
3
4
5
6
7
8
9
10
11
# 超出限制
HTTP/1.1 403 Forbidden
Date: Tue, 20 Aug 2013 14:50:41 GMT
Status: 403 Forbidden
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1377013266
{
"message": "API rate limit exceeded for xxx.xxx.xxx.xxx. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)",
"documentation_url": "https://developer.github.com/v3/#rate-limiting"
}

GitHub 在收到客户端发起的访问请求后,在返回给客户端的响应消息首部中包含了访问频率限制信息。

头部名称 描述
X-RateLimit-Limit 单位时间内的访问次数上限
X-RateLimit-Remaining 单位时间内的剩余访问次数
X-RateLimit-Reset 访问次数重置的时间

GitHub 的做法对用户来说十分友好,用户在收到访问频率限制信息后可以合理的安排自己的访问,X-RateLimit-Reset 可以让用户知道在超出上限后什么时候可以发起下一次请求,从而避免无谓的请求,减少双方的负担。除了在响应消息头部中表明访问频率限制信息的,还有在消息体中表明,甚至在 API 文档中直接表明的,我个人认为应该优先在消息首部中表明访问频率限制信息,我就想知道有多少程序员是不仔细看 API 文档直接开始写代码的😅。

当用户超出访问频率限制之后,GitHub 返回了一个 403 状态码,现在我们的应该返回 429 TOO MANY REQUESTS 状态码,429 状态码在 2012 年 4 月发布的 RFC 6585 中定义,GitHub 还在使用 403 可能是历史遗留问题。不管怎样,我们应该尽可能的使用 429。

我们在对用户进行访问频率限制的时候应该仔细考虑怎样才能尽可能的满足正常用户的需求,比如那些使用频率高的请求我们可以将限制放宽松一些,对数据库有负担的请求我们可以限制的更严格一些。

为了限制用户的访问频率,需要记录每个用户在单位时间内访问了多少次,我们简单的用字典来记录也行,用 Redis 之类的 KV 数据库来记录也可以。下面我们看看在 Flask 中是怎么实现一个简单的能限制访问频率的 API 服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# 代码参考自 http://flask.pocoo.org/snippets/70/
import time
from functools import update_wrapper

from flask import Flask, jsonify, abort, request, g
from redis import Redis

api = Flask(__name__)
redis = Redis()

class RateLimit(object):
expiration_window = 10

def __init__(self, key_prefix, limit, per, send_x_headers):
self.reset = (int(time.time()) // per) * per + per
self.key = key_prefix + str(self.reset)
self.limit = limit
self.per = per
self.send_x_headers = send_x_headers
p = redis.pipeline()
p.incr(self.key)
p.expireat(self.key, self.reset + self.expiration_window)
self.current = min(p.execute()[0], limit)

remaining = property(lambda x: x.limit - x.current)
over_limit = property(lambda x: x.current >= x.limit)

def get_view_rate_limit():
return getattr(g, '_view_rate_limit', None)

def on_over_limit(limit):
abort(429)

def ratelimit(limit, per=300, send_x_headers=True,
over_limit=on_over_limit,
scope_func=lambda: request.remote_addr,
key_func=lambda: request.endpoint):
def decorator(f):
def rate_limited(*args, **kwargs):
key = 'rate-limit/%s/%s/' % (key_func(), scope_func())
rlimit = RateLimit(key, limit, per, send_x_headers)
g._view_rate_limit = rlimit
if over_limit is not None and rlimit.over_limit:
return over_limit(rlimit)
return f(*args, **kwargs)
return update_wrapper(rate_limited, f)
return decorator

@api.after_request
def inject_x_rate_headers(response):
limit = get_view_rate_limit()
if limit and limit.send_x_headers:
h = response.headers
h.add('X-RateLimit-Remaining', str(limit.remaining))
h.add('X-RateLimit-Limit', str(limit.limit))
h.add('X-RateLimit-Reset', str(limit.reset))
return response

@api.route("/")
@ratelimit(limit=100, per=60 * 2)
def index():
return jsonify({"path": "index"})

@api.route("/hello")
@ratelimit(limit=5, per=60 * 5)
def hello():
return jsonify({"path": "hello"})

@api.errorhandler(429)
def page_not_found(e):
message = "API rate limit exceeded for {remote_addr}. Check out the documentation for more details.".\
format(remote_addr=request.remote_addr)
res = {
"message": message,
"documentation_url": "https://example.com/rate-limiting"
}
return jsonify(res), 429


if __name__ == '__main__':
api.run(debug=True)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 正常访问
> curl -i http://127.0.0.1:5000/hello
HTTP/1.0 200 OK
Content-Type: application/json
X-RateLimit-Remaining: 3
X-RateLimit-Limit: 5
X-RateLimit-Reset: 1520339400
Content-Length: 165
Server: Werkzeug/0.14.1 Python/3.6.4
Date: Tue, 06 Mar 2018 12:28:40 GMT

{
"path": "hello"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 超出限制
> curl -i http://127.0.0.1:5000/hello
HTTP/1.0 429 TOO MANY REQUESTS
Content-Type: application/json
X-RateLimit-Remaining: 0
X-RateLimit-Limit: 5
X-RateLimit-Reset: 1520339400
Content-Length: 165
Server: Werkzeug/0.14.1 Python/3.6.4
Date: Tue, 06 Mar 2018 12:28:42 GMT

{
"documentation_url": "https://example.com/rate-limiting",
"message": "API rate limit exceeded for 127.0.0.1. Check out the documentation for more details."
}