一场“血案”
高可用和高可靠一直是web应用和api接口关注的目标。尤其对于api提供方,很可能面临着一个激增的流量影响服务的稳定性,导致机器的负载飙高的风险。如果流量持续上涨,并且不采取措施,可能会把机器打挂,并且雪崩,一台机器挂了之后,剩下的机器挂的更快。
秃毛哥在工作中就遇到一个坑,上线之后导致api提供方的负载逐步增加,并且没有停下来的迹象。为避免把机器打挂,对方把这个接口拒绝服务了,简单粗暴。大家可能会好奇为什么不回滚呢?其实场景是这样的:因为秃毛哥发现线上ES经常出现脏数据,这种情况往往是因为有人使用了线上ES配置进行开发或者测试(上古时期,现在没有了),在不知情的情况下,往ES写了线下数据。当然,这种情况是因为开发测试流程不规范导致的,但是总是有人喜欢直接使用线上配置进行调试开发,以图方便。导致秃毛哥经常需要跑脚本清理这些脏数据。考虑到在ES上加权限控制的成本并且的确有场景需要使用线上ES的情况,秃毛哥打算在代码逻辑上控制非线上机器的写ES权限。大概的逻辑就是在写ES时,判断当前机器的tag(用于判断机器的环境)与使用的ES配置。如果tag是线下机器并且ES配置是线上的,直接提示not allow。这样相当于在每次写ES时,都会做这个判断。问题出在获取机器tag的函数,秃毛哥调用的是其他项目组维护的代码,并且不在同一个代码仓库,而这个函数封装了对api接口的调用(居然没有加缓存!!😭)。如果写ES的操作不是很频繁还好,但是因为我们在代码中对数据库操作做了一些hook来保证ES与数据库的数据一致,并且还有一些兜底策略来保证数据一致性。因此,ES写操作非常频繁。而获取tag的服务明显是一个内部服务,一般的用途也只是在脚本启动时调用一次判断当前环境,显然抗压能力是很有限的。由于秃毛哥没有对该函数进行跟踪,误以为函数的工作方式是获取机器的环境变量。因此直到对方拒绝服务,不断收到报警之后才发现。其他的细节就不过多叙述了。
反思
反思本次经历,对方将接口直接拒绝服务的做法没有问题,毕竟是一种保护措施。其实可以有一种更温和的方式,就是对api进行限流。
api限流在以下几种方式可以提高接口的可靠性:
- 某一个用户可能占用过多流量,导致其他用户的服务受到影响。这个用户可能在使用脚本调用时,没有做频控,或者忽略了。更糟糕的情况就是他在恶意攻击你的接口,让你无法为其他用户提供服务,也就是Dos或者DDos。
- 某一个用户发送了过多低优先级的请求,导致某些高优先级或核心的服务受到影响。
- 系统出现问题,可能是基础服务的问题,此时接口无法承载平时的流量了,我们可以放弃某些低优先级的接口,确保核心服务的正常。
限流作为确保api接口可靠性最有效的方法之一,可以有多种策略。
- 最有效和常用的方法就是请求限流,就是说限制一个用户在每秒钟只能发送N个请求。
- 并发限流。限制用户在同一时间只能发出N个请求。接口调用方可能使用了一定规模的并发数来调用接口,并发规模超过了接口的承载能力。调用方对于失败的接口(可能因为没排上队而失败了)加入了retry策略。后果就是恶性循环,失败的调用一直在retry,新的调用也失败了,然后也在retry,并发数一直在堆积,最终雪崩。并发限流要求用户使用新的编程模型,避免这种情况。
- 基于使用量的限流。将流量划分为关键api和非关键api,限定每秒钟能够处理的请求数N。假设我们为关键api设定的访问量为20%,那么非关键api访问量超过80%*N的将被抛弃。
- 基于worker利用率的限流。还是将api进行评级,从高优先级到低优先级区分出几个类别,也可以按照http method进行划分。当worker繁忙时,从最低优先级的api开始抛弃,直到worker恢复正常在逐步恢复对不同优先级的响应。
实现
从api调用方和api提供方的角度来分别介绍一下如何使用python实现限流,并且会介绍一种在限流上经常使用的Token Bucket算法
。
api调用方限流
搜了一把发现python有一个叫ratelimit的module,实现很简单,直接贴源码。
from math import floor
import time
import sys
import threading
def rate_limited(period=1, every=1.0):
frequency = abs(every) / float(clamp(period))
def decorator(func):
last_called = [0.0]
lock = threading.RLock()
def wrapper(*args, **kargs):
with lock:
elapsed = time.time() - last_called[0]
left_to_wait = frequency - elapsed
if left_to_wait > 0:
time.sleep(left_to_wait)
last_called[0] = time.time()
return func(*args, **kargs)
return wrapper
return decorator
def clamp(value):
return max(1, min(sys.maxsize, floor(value)))
原理很简单,就是判断当前时间戳与上一次调用的时间戳的差值elapsed是否大于frequency,而这个frequency的计算就是拿时间除以调用次数得到的每隔多少时间可以调用一次。当elapsed大于frequency,说明程序在一段时间内没有调用过该函数了,因此可以直接调用;否则,sleep一段时间。
由于rate_limited实现为decorator的方式,因此使用上也很方便,如下:
from ratelimit import rate_limited
import requests
@rate_limited(1)
def call_api(self, url):
response = requests.get(url)
if response.status_code != 200:
raise ApiError('Cannot call API: {}'.format(response.status_code))
return response
看使用代码就知道了,这个ratelimit适用于api调用方,也就是在使用别人的接口时,这个module可以帮你做頻控,在一定程度上确保你不会打挂别人的机器。
api提供方限流
服务提供方的情况远比使用方复杂,想象一个接口A可能存在成千上万的调用方,我们想要分别限制每个调用方在1秒内,1分钟内,1小时内,甚至1天内能调用的次数。
Tocken Bucket
最容易想到的就是利用redis incr命令来做限流,在redis文档中有详细的介绍。简单来说,就是为每一个caller生成一个带ttl的key,每访问一次api就incr一次,直到超过一定的限度就禁止访问了。当key过期被redis自动删除之后,又恢复访问。这个算法实现起来很容易,而且能解决大部分问题,称得上简单实用。但是这个算法也存在一个问题。考虑一个场景,现在要限制接口A每分钟能被单个调用方访问100次。那用户就可以在某一分钟的开头发送1个请求,这一分钟的结尾发送99个请求,然后在下一分钟的开头发出100个请求。这样QPS峰值几乎是原来的两倍,可能被恶意攻击者利用。另外这种限流策略很不均匀,对用户也不够友好。正常的用户可能在前10s发送了90个请求,这时候即使过了40s,用户也只能在发送10个请求。理想情况的限流是均匀的。什么意思呢?就是说我们在前10s发送了90个请求,过了40s之后,我们能请求的数量应该是要大于10个的,因为我们的访问量是会恢复
的。把请求配额想象成一个桶,这个桶最多能放100滴的水。有一个水龙头在不停的往桶里面滴水,频率是0.6s一滴,1分钟刚好是100滴。也就是说,1分钟就能把一个空桶装满,桶满了之后就会溢出,所以最多只能装100滴。假设在第10s的时候桶里面的水只剩下10滴了,那40s之后还有多少滴水呢?答案是40/0.6+10约等于76。就是说,1分钟的前10s我们消耗了90个请求,40s之后,我们能在访问76次(期间没有消耗)。把水滴想象成一个令牌,当请求来了之后就去桶里面取一个令牌,只有获取到令牌的请求才能访问接口,这就是所谓的tocken bucket
算法了。
考虑一下实现问题,假设接口A想实现1s访问5次的限流,也就是每0.2s就往bucket里放入一个tocken。这个接口是一个比较重要的接口,因为它的调用方很多,可能达到了10k。每个调用方需要用一个bucket来存数据,这样就存在10k个bucket。如果用一个线程来做定时器,定时往bucket放tocken,那就需要10k个线程。很明显这种方式是不可能的,即使可以开销也太大了。tocken的计算方式其实不需要定时器就能完成,上面那段水龙头滴水的描述就已经用到了这个方法。首先令牌的放入速率v我们是知道的,因为这个可以通过桶的容量capacity和装满桶需要的时间expire计算出来,v=capacity/expire
。当用户U访问我们的接口时,我们只需要计算他上一次访问到当前访问的时间差需要补充多少个tocken,补充的token加上之前剩下的token(last_token)数不能超过桶的容量。最终我们得到下面的计算式子:
token = min(max_capacity, last_token+(cur_time - last_time)/v)
这样,我们只需要在每次访问到达的时候,才更新桶内的token。如果桶内有多余的token就让请求生效。好处也是可想而知的:
- 不需要维护定时器来更新每个桶的token
- 只在一个用户请求到来时更新token,节省了很多不必要的开销,也使得代码实现变得简单
现在来考虑桶的数据存储和数据结构问题。在所有请求到达接口之前都需要获取token。因此限流算法的实现不能成为api的瓶颈,在响应上要尽量快。本文决定使用redis来实现,既兼顾数据存储又兼顾速度。根据上文的描述,每个桶需要存储的数据有最后一次访问的时间戳和桶中剩余的token数量。每个用户到每个api接口上都应该有对应的桶,不能共用。综上,桶的数据结构决定用redis hash,每个bucket的key由二元组(caller, api)来构造。
KEY: (caller, api)
VALUE:
{
last_timestamp,
tokens
}
如下代码是基于python django框架实现的bucket token算法,分别实现为中间件和修饰器的形式。中间件使用白名单机制,可以对不同的api做一些简单的配置,目的在于为一些关键api提供最基本的保障。因此中间件的灵活性比较有限。为了获得更大的灵活性,提供一个修饰器函数,在需要限流的地方加上即可。对于访问过于频繁的请求,我们直接返回503(service unavailable),对于没有获取到caller的请求,则返回403(forbidden)。
# api_rate_limit/const.py
# coding: utf-8
import datetime
import time
from django.http import HttpResponse
# bucket key。 根据不同caller,不同api构造一个bucket
CALLER_KEY_TPL = 'API_RATE_LIMIT|{0}|{1}'
ONE_HOUR = 60 * 60
MICROSECOND = 1000000
BASE_TIME = int(time.mktime(datetime.datetime(2017, 1, 1).timetuple())) * MICROSECOND
RequestTooFrequentRsp = HttpResponse('Requests Are Too Frequent To Be '
'Temporarily Rejected Or Need Whitelist '
'Authorization', status=503)
MissingCallerRsp = HttpResponse('Missing caller identification to '
'access the api.', status=403)
# 中间件使用白名单机制。
rate_limit_conf = {
'core': {
'routes': {'/my_test/', '/my_test/haha/'},
'strategy': [
(5, 1, 1*MICROSECOND/5),
]
}
}
# lua实现hset脚本
lua_hset_script = '''
local i = 2
while(i<#ARGV) do
redis.call('hset', KEYS[1], ARGV[i], ARGV[i+1])
i = i+2
end
redis.call('expire', KEYS[1], ARGV[1])
return 1
'''
# api_rate_limit/api_rate_limit.py
# coding: utf-8
import time
import functools
from django.utils.deprecation import MiddlewareMixin
from vivi_data.common.api_rate_limit.const import (
RequestTooFrequentRsp,
CALLER_KEY_TPL,
lua_hset_script,
MissingCallerRsp,
ONE_HOUR,
BASE_TIME,
MICROSECOND,
rate_limit_conf
)
from vivi_data.common.redis_client import redis_cli as redis_cli
_lua_hset_script = redis_cli.register_script(lua_hset_script)
def _get_timestamp():
'''
获取当前时间减去固定值的时间戳。
'''
return long(time.time() * MICROSECOND - BASE_TIME)
def _get_caller(request):
'''
获取调用方标识。不带标识的时候使用ip
:param request: [obj] 请求对象
:return: [string]
'''
caller = None
if request.method == 'GET':
caller = request.GET.get('caller')
elif request.method == 'POST':
caller = request.POST.get('caller')
# 不带caller则使用ip
if not caller:
caller = request.META.get('REMOTE_ADDR')
return caller
def _lua_hset(keys, args, expire):
'''
使用lua脚本实现hset,保证写redis操作的原子性
:param keys: [string] redis key
:param args: [list] [h_key, h_value, h_key, h_value ...]
:param expire: key的过期时间
:return: 0/1
'''
global _lua_hset_script
args.insert(0, expire)
pipe = redis_cli.pipeline()
ret = [0]
try:
_lua_hset_script(keys, args, pipe)
ret = pipe.execute()
except Exception:
# need some alarm mechanism
pass
return ret[0]
class APIRateLimitMiddleware(MiddlewareMixin):
'''
api限流中间件。
支持HTTP POST和GET method
使用白名单策略,不带caller的请求则使用ip
'''
def process_request(self, request):
for _, rank_conf in rate_limit_conf.items():
if request.path in rank_conf['routes']:
for capacity, expire, supply_rate in rank_conf['strategy']:
rsp = _access(request, capacity, expire, supply_rate)
if rsp:
return rsp
def api_rate_limit(capacity=5, expire=1):
'''
中间件对所有接口做最基本的保证,缺乏灵活性。
提供修饰器的使用方法,可以使用在单独的接口上,更具灵活性
:param capacity: [int] expire时间内可调用次数
:param expire: [float] 单位是秒
'''
def api_rate_limit_wrapper(func):
@functools.wraps(func)
def api_rate_limit_func(*args, **kwargs):
request = args[0]
rsp = _access(request, capacity, expire)
if rsp:
return rsp
return func(*args, **kwargs)
return api_rate_limit_func
return api_rate_limit_wrapper
def _access(request, capacity, expire, supply_rate=None, redis_cli=redis_cli):
'''
api 限流
使用token bucket算法
:param request: [obj] 请求对象
:param capacity: [int] 在expire时间内能够发起的调用次数
:param supply_rate: [int] bucket补充速度
:param expire: [float] 时间长度
:param redis_cli: [obj] redis client
'''
caller = _get_caller(request)
if not caller:
return MissingCallerRsp
expire_microsecond = MICROSECOND * expire
if not supply_rate:
supply_rate = expire_microsecond/capacity
bucket_key = CALLER_KEY_TPL.format(caller, request.path)
cur_timestamp = _get_timestamp()
bucket = redis_cli.hgetall(bucket_key)
if not bucket:
tokens = capacity
else:
last_timestamp, tokens = \
long(bucket['last_timestamp']), int(bucket['tokens'])
# 计算tokens
tokens = min(capacity, tokens+(cur_timestamp-last_timestamp)/supply_rate)
if tokens >= 1:
tokens -= 1
_lua_hset(
[bucket_key],
['last_timestamp', cur_timestamp,
'tokens', tokens],
max(expire, ONE_HOUR)
)
return None
return RequestTooFrequentRsp
测试
分别使用单进程与多进程对api_rate_limit进行测试。由于中间件与独立函数基本上一致,我们只对独立函数进行测试。如下是我在服务器上起的api接口,同时也展示了如何使用api_rate_limit。接口很简单,只是在最开始的地方做了一个限流,限制请求以每秒钟5次的频率访问。
@api_rate_limit(capacity=5, expire=1)
def index(request):
return HttpResponse('success')
测试方法也比较简单,就是不停的向接口发请求,打印出访问成功的时间戳。多进程测试时,就是起几个进程,每个进程重复上面的步骤。只是在输出信息时做了一些区别,多进程还会输出每个进程的name。
# coding: utf-8
import datetime
import requests
import time
import multiprocessing
import signal
url = 'xxx.xxx.xxx....'
def single_process_test():
while True:
rsp = requests.get(url)
if rsp.status_code == 200:
print datetime.datetime.now()
def multi_process_test():
def _test():
while True:
rsp = requests.get(url)
if rsp.status_code == 200:
msg = '{0} {1}'.format(
datetime.datetime.now(),
multiprocessing.current_process().name,
)
print msg
def kill_workers():
for process in multiprocessing.active_children():
process.terminate()
import sys
sys.exit()
p_cnt = 4
p = []
for i in xrange(p_cnt):
_p = multiprocessing.Process(name='process_{0}'.format(i), target=_test)
_p.daemon = True
p.append(_p)
for i in xrange(p_cnt):
p[i].start()
signal.signal(signal.SIGHUP, kill_workers)
signal.signal(signal.SIGINT, kill_workers)
signal.signal(signal.SIGTERM, kill_workers)
for i in xrange(p_cnt):
p[i].join()
if __name__ == '__main__':
single_process_test()
# multi_process_test()
输出
只有一个进程在访问接口时,程序输出如下信息。基本上每秒钟的成功访问次数是5,这个结果是符合预期的。
2017-12-31 18:06:33.708904
2017-12-31 18:06:33.918038
2017-12-31 18:06:34.132355
2017-12-31 18:06:34.346779
2017-12-31 18:06:34.552447
2017-12-31 18:06:34.767989
2017-12-31 18:06:34.979178
2017-12-31 18:06:35.186383
2017-12-31 18:06:35.395457
2017-12-31 18:06:35.617200
2017-12-31 18:06:35.831757
2017-12-31 18:06:36.047009
2017-12-31 18:06:36.254655
2017-12-31 18:06:36.465700
2017-12-31 18:06:36.667374
2017-12-31 18:06:36.895062
2017-12-31 18:06:37.103030
2017-12-31 18:06:37.313911
2017-12-31 18:06:37.530973
2017-12-31 18:06:37.745396
2017-12-31 18:06:37.959736
2017-12-31 18:06:38.178208
2017-12-31 18:06:38.391540
2017-12-31 18:06:38.597252
2017-12-31 18:06:38.799587
2017-12-31 18:06:39.005146
2017-12-31 18:06:39.211123
2017-12-31 18:06:39.422840
2017-12-31 18:06:39.639014
2017-12-31 18:06:39.844864
2017-12-31 18:06:40.105797
2017-12-31 18:06:40.303648
2017-12-31 18:06:40.503719
2017-12-31 18:06:40.714770
2017-12-31 18:06:40.932519
2017-12-31 18:06:41.139138
2017-12-31 18:06:41.352076
2017-12-31 18:06:41.572667
2017-12-31 18:06:41.771777
多进程情况下,输出结果有点怪异。每秒钟的请求成功次数在4~7次之间。大家可能已经意识到了,存在资源竞速的情况。我们不是使用了lua脚本来保证hset的原子性了吗?用lua实现hset,只是保证了hset的原子性。但是不能保证bucket资源的原子性。主要原因是我们没有在hget和hset之间加锁。举一个可能出现的例子,当请求A到达时,这时候还没有属于A的bucket,所以没有获取到bucket。这时候请求B也来了,也没有获取到bucket。此时相当于A和B都认为自己是第一次请求接口,他们都认为自己有N个token,然后都从里面拿走一个,还剩下N-1个token。接着A去hest桶资源,设置token为N-1,然后B也去hset桶资源,把A的设置给覆盖了,也设置token为N-1。最终两次访问之后桶中还有N-1个token,而实际上应该是N-2。
2017-12-31 18:12:46.505145 process_2
2017-12-31 18:12:46.714228 process_3
2017-12-31 18:12:47.506608 process_2
2017-12-31 18:12:47.521368 process_3
2017-12-31 18:12:47.521637 process_0
2017-12-31 18:12:47.522321 process_1
2017-12-31 18:12:47.756011 process_3
2017-12-31 18:12:47.756525 process_2
2017-12-31 18:12:47.757567 process_1
2017-12-31 18:12:47.960467 process_0
2017-12-31 18:12:48.165730 process_3
2017-12-31 18:12:48.391574 process_1
2017-12-31 18:12:48.605674 process_2
2017-12-31 18:12:48.820007 process_0
2017-12-31 18:12:49.023727 process_3
2017-12-31 18:12:49.227993 process_3
2017-12-31 18:12:49.426867 process_1
2017-12-31 18:12:49.640145 process_2
2017-12-31 18:12:49.881380 process_0
2017-12-31 18:12:50.056172 process_2
2017-12-31 18:12:50.266562 process_2
2017-12-31 18:12:50.480573 process_2
2017-12-31 18:12:50.693158 process_1
2017-12-31 18:12:50.693021 process_3
2017-12-31 18:12:50.898402 process_2
2017-12-31 18:12:51.103031 process_1
2017-12-31 18:12:51.298433 process_0
2017-12-31 18:12:51.513614 process_3
2017-12-31 18:12:51.720962 process_0
2017-12-31 18:12:51.944542 process_1
2017-12-31 18:12:52.146480 process_2
2017-12-31 18:12:52.355181 process_0
2017-12-31 18:12:52.565183 process_1
2017-12-31 18:12:52.767692 process_3
2017-12-31 18:12:52.970029 process_2
2017-12-31 18:12:53.170410 process_1
2017-12-31 18:12:53.372768 process_2
2017-12-31 18:12:53.586224 process_2
2017-12-31 18:12:53.586956 process_1
2017-12-31 18:12:53.791121 process_3
2017-12-31 18:12:53.991593 process_3
2017-12-31 18:12:54.192782 process_1
本文的实现并不考虑加锁主要出于以下几个考虑:
- 复杂性。大部分企业的redis架构是分布式的。如果要加锁在我们的实现中需要实现一个redis proxy和一个分布式锁方案(当然也有开源的proxy)。一旦这么做之后,我们就需要考虑如何管理锁,如何确保数据一致性,如何容灾备份等一系列问题。
- 加锁意味着开销,而这个开销会产生在所有使用api_rate_limit的接口上。
- 当前的实现基本能满足需求。我们的目的是为了确保api调用保持在一个可控的量级,请求数在某个范围内波动都是可以允许的。
总结
- 本文实现的api限流适用于单redis集群,集群架构需要视情况做一定的修改。
- 如果想要严格控制请求数而加入锁机制,可以根据(caller, api)的方式来构造锁,原则就是越少进程争抢锁越好。
- 在使用api_rate_limit时不要对一个接口加入太多的策略。比如有人可能想对接口A实现一秒2次,一分钟100次,一小时7000次的限流。一个请求只有在同时获取到3个token时,才能访问。其实没有必要搞这么复杂,直接用补充速度最慢的策略就行了。比如之前的例子,补充速度分别是0.5s/token,0.6s/token,0.51s/token,补充速度最慢的是一分钟100次。所以直接使用这个策略就行了。
- 本实现只经过简单的测试。