考虑一个场景,在1s的时间内,同一个ip只允许访问某个系统3次,都有哪些实现方式?
1,基于Nginx实现
Nginx使用ngx_http_limit_zone_module 模块实现,其可以按照定义的键限定每个键值的连接数。特别的,可以设定单一 IP 来源的连接数。其优点在于避免了大流量传递到后端系统,消耗后端资源。但是,由于nginx.conf不能热加载,使用这种方式只能配置一些静态的参数,无法进行动态参数配置,还有就是如果流控有一定的业务逻辑,这种方式就不大适合了。
2,nginx+lua
有了lua脚本,nginx可以执行一些带有业务逻辑的处理,也能够动态配置一些参数,这种方式在基于web访问的分布式系统场景中,可以很好起到流控作用。但是如果系统调用方式不是web的,即不通过nginx代理,比如后端系统之间的tcp连接调用,这种方式就不大适合了。还有就是有些逻辑是nginx运维层处理不了的。下面着重考虑后端系统的流控实现。
3,基于redis实现(以下场景为了简单测试,没有使用连接池,实际使用中应该使用连接池)
针对这个场景,很容易想到利用redis的incr指令,做一个计数器:
public static boolean isAllowed(String key,int limit,int duration){
Jedis jedis = new Jedis("127.0.0.1", 6379);
long value = jedis.incr(key);
if(value==1l){
jedis.expire(key, duration);
}else if(value>limit){
System.out.println("current thread "+ Thread.currentThread().getName()+ " is denied,key:"+key);
return false;
}
return true;
}
调用函数时,key=ip,limit=3,duration=1,即1s内一个ip只允许访问一次,经测试,可以得到正确的结果。仔细分析一下上面的代码,是有风险的,比如在调用expire方法时,客户端挂了,这样会导致一个ip只能访问一次了,disuster!!!那如何解决这个问题呢?
仔细分析上面的代码,问题出在客户端上,如果客户端挂了,服务端没法执行expire,那有没有办法让这些操作一块都在服务端执行呢,这样不就解决问题了。答案是有的,从2.6之后,redis都支持嵌入lua脚本执行,根据这个特性,我们更改下上面的代码:
public static boolean isAllowedByLua(String key, int limit, int duration){
//基于lua
Jedis jedis = new Jedis("127.0.0.1", 6379);
String luaString = "local current"+"\n"
+"current = redis.call('incr',KEYS[1])"+"\n"
+"if tonumber(current) == 1 then" +"\n"
+" redis.call('expire',KEYS[1],tonumber(ARGV[2]))"+"\n"
+"else"+"\n"
+" if tonumber(current) > tonumber(ARGV[1]) then"+"\n"
+" return 'false'"+"\n"
+" end"+"\n"
+"end"+"\n"
+"return 'true'";
Object object = jedis.eval(luaString, 1, key,limit+"",duration+"");
boolean result = Boolean.valueOf((String)object);
if(!result){
System.out.println("current thread "+ Thread.currentThread().getName()+ " is denied,key:"+key);
return false;
}
return result;
}
经过测试,上面的代码执行正确。但是仔细分析上面的代码,相当于每次调用的时候都会把luaString传到服务端,这会影响性能,有没有办法把这个脚本保存在服务端,调用的时候不再传递这个脚本?答案是有的,对上面的代码进行改进:
public static boolean isAllowedByLua(String key, int limit, int duration){
//基于lua
Jedis jedis = new Jedis("127.0.0.1", 6379);
if(null == LUA_STRING_SHA){
LUA_STRING_SHA = jedis.scriptLoad(LUA_STRING);
}
Object object = jedis.evalsha(LUA_STRING_SHA,1,key,limit+"",duration+"");
boolean result = Boolean.valueOf((String)object);
if(!result){
System.out.println("current thread "+ Thread.currentThread().getName()+ " is denied,key:"+key);
return false;
}
return result;
}
我们定义一个LUA_STRING_SHA的变量,保存了LUA_STRING脚本在服务端的sha1校验和,这样就避免了每次都要传输脚本!
再思考一下,上面的代码需要支持第三方脚本lua的执行,在一些redis低版本中是不支持的,那有没有办法不使用lua脚本,也能实现上面的功能呢?请看下面:
public static boolean isAllowed2(String key,int limit,int duration){
Jedis jedis = new Jedis("127.0.0.1", 6379);
String str = jedis.get(key);
if(null == str){
Long expireDate = new DateTime().plusSeconds(duration).toDate().getTime();
Transaction transaction = jedis.multi();
Response<Long> res = transaction.incr(key);
Response<Long> expireResponse = transaction.expireAt(key, expireDate);
transaction.exec();
if(res.get()>limit || expireResponse.get()==0l){
System.out.println("current thread "+ Thread.currentThread().getName()+ " is denied,key:"+key);
return false;
}else{
return true;
}
}else{
long v = jedis.incr(key);
if(v > limit){
System.out.println("current thread "+ Thread.currentThread().getName()+ " is denied,key:"+key);
return false;
}else{
return true;
}
}
}
当获取到key值为空时,我们在redis事务中执行自增和过期指令,这样可以保证自增后设置了过期时间.但是这里有个问题,并发访问的情况下这个过期时间不是那么精确,这个得视具体业务场景是否能够忍受,个人认为如果能够忍受1s左右的延迟,上面的代码还是可以使用的。查阅了有关文档,redis的过期时间也不是那么完全精确,在redis 2.6之前这个延迟在1s以内,2.6以后把这个延迟时间控制在了1毫秒左右。
再思考一下,排除redis自身的延迟,我们有办法做到高精度的流控么?上面的代码问题在于可能同时有多个线程设置这个过期时间,那有办法只允许第一个到达的线程设置过期时间吗?看下面代码:
public static boolean isAllowed3(String key,int limit,int duration){
Jedis jedis = new Jedis("127.0.0.1", 6379);
String str = jedis.get(key);
if(null == str){
String resultString = jedis.set(key, "0","NX","EX",duration);
if("OK".equalsIgnoreCase(resultString)){
long value = jedis.incr(key);
if(value > limit){
System.out.println("current thread "+ Thread.currentThread().getName()+ " is denied,key:"+key);
return false;
}else{
return true;
}
}else{
long v = jedis.incr(key);
if(v > limit){
System.out.println("current thread "+ Thread.currentThread().getName()+ " is denied,key:"+key);
return false;
}else{
return true;
}
}
}else{
long v = jedis.incr(key);
if(v > limit){
System.out.println("current thread "+ Thread.currentThread().getName()+ " is denied,key:"+key);
return false;
}else{
return true;
}
}
}
使用set指令,当第一个线程到达的时候获取到锁,此时设置过期时间,第二个线程set时发现key已经有了,直接执行自增指令。
4,基于memcache实现
基于memcache的实现思路与redis类似,不同的是在指令的使用上,这里就不再贴代码了。
以上的流控是基于用户第一次访问开始计时的1s,如果是基于时间维度,那么以上的代码流控都不是那么精确了(排除redis自身的延迟),请看下面一种情况:
横轴是时间轴,用户的访问可能正好处在之间,第一个1s结束之前到第二个1s开始后那会儿,其实这个时候是不可以访问的,使用上面的代码无法拦截这种情况,还是以redis为例子,对上面的代码进行改造:
public static boolean isAllowedByList(String key,int limit,int duration){
Jedis jedis = new Jedis("127.0.0.1", 6379);
long currentTime = System.currentTimeMillis();
long len = jedis.lpush(key,String.valueOf(currentTime));
if(len > limit) {
jedis.ltrim(key, 0, limit);
List<String> result = jedis.lrange(key,limit,limit);//获取最后一个元素的值
long time = Long.valueOf(result.get(0));
if((currentTime-time)/1000 > duration){//比较时间
return true;
}else {
System.out.println("current thread " + Thread.currentThread().getName() + " is denied,key:" + key);
return false;
}
}else{
return true;
}
}
利用lpush命令构造一个列表(队列),这个队列的长度为limit+1,当队列中的元素个数不大于limit时,我们认为可以通过。当队列中的元素个数大于limit时,我们取队列中的最后一个元素,比较其时间与当前时间的差值来判断是否可以通过。
再思考一下,上面的代码真的能做到那么精确吗?上面的代码都是客户端操作,不可避免的会产生于服务器之间的时间差值,所以不可能是那么的精确,那么有没有办法再提高下精确度?有的,让这段逻辑运行在服务器端不就行了,这时又需要借助于lua脚本了,这里就不再贴代码了,依葫芦画瓢可以写出对应的代码!
再思考一下,比如1s内来个5次请求,有个突发情况是请求都集中在第一毫秒内,后面的999ms都不会有请求,这样可能对系统产生抖动,那有没有办法使得请求平滑的被处理呢?比如每秒访问5次,是否可以做到每隔200ms访问一次呢?可以,一个简单的实现是控制前后两次访问的时间间隔,需借助lua,看下面的代码:
String smooth_lua = "local result"+"\n"
+"result = redis.call('get',KEYS[1])"+"\n"
+"if result==false then"+"\n"
+" redis.call('set',KEYS[1],tostring(tonumber(ARGV[1])+tonumber(ARGV[2])))"+"\n"
+" redis.call('expire',KEYS[1],24*60*60)"+"\n"
+" return ARGV[1]"+"\n"
+"else"+"\n"
+" if(tonumber(ARGV[1]) > tonumber(result)) then"+"\n"
+" redis.call('set',KEYS[1],tostring(tonumber(ARGV[1])+tonumber(ARGV[2])))"+"\n"
+" return ARGV[1]"+"\n"
+" else"+"\n"
+" redis.call('set',KEYS[1],tostring(tonumber(result)+tonumber(ARGV[2])))"+"\n"
+" return result"+"\n"
+" end"+"\n"
+"end"+"\n";
Object object = jedis.eval(smooth_lua,1,key,String.valueOf(currentTime),String.valueOf(step));//实际应使用evalsha更好
double time = Double.valueOf((String)object);
while(currentTime<time){
currentTime = System.nanoTime();
}
System.out.println(Thread.currentThread().getName()+" accessed,time:"+System.currentTimeMillis());//显示当前时间*/
这里的duration我们假设为1,limit=5,即1s访问5次,我们将1s分割程5份,步长step=200ms(这里为了提高精确度,可以使用nanoTime),每次进来时都设置下一次可以访问的时间,然后把本次获取到可访问时间返回。如果当前时间小于可访问时间,进入等待。如果当前时间超过了本次访问时间,那说明间隔已经大于了200ms,本次可以访问,同时以本次的currentTime为基准,将currentTime+step写入下一次可以访问的时间。
再考虑一个问题,通常情况下,使用单一的redis主从结构就能够满足上面的需求,但是如果我们的访问量真的巨大呢,大到单一的redis无法支撑(比如内存超过限制,redis连接数超限制),这时我们就需要考虑多引入几个主从节点了,这里有个问题就是有可能我们的主从节点是动态加入的(比如监测到访问量陡增),也有可能我们动态的移除某个主从节点(比如资源有限,将该节点挪用它处,移除一个节点也能满足需求),还有一个问题就是如何将这些请求按照一定的规则落到各个redis服务端上(比如均摊,比如有些机器性能一般,这时就需要一个权重的考虑)。还有就是如何快速根据key知道从哪个服务端获取值呢?基于这些问题,下面我们挨个问题给出思路:
1,节点如何动态增加或者删除?这时就需要借助于zk的服务发现功能了,redis server启动好后向zk进行注册,客户端通过读取zk就能知道有哪些redis server可用(为了提高性能,客户端缓存这些节点)
2,后面的问题都可以通过一致性hash来解决,关于一致性hash,见参考文章,这里先给出简易的例子:
private static String[] servers = {"127.0.0.1:6679","127.0.0.1:6680"};//这里先写死 结合zk servers的大小可能是动态的
private static final int VIRTUAL_NODES = 8; // for test
private static List<String> realNodes = new LinkedList<String>();//真实节点
//key---hash,value--虚拟节点
private static SortedMap<Long, String> virtualNodes = new TreeMap<Long, String>();
static{
//初始化
for(int i=0; i<servers.length; i++)
realNodes.add(servers[i]);
for (String str : realNodes){
for(int i=0; i<VIRTUAL_NODES; i++){
String virtualNodeName = str + "-V" + String.valueOf(i);
long hash = getHash(virtualNodeName);
System.out.println("虚拟节点[" + virtualNodeName + "]被添加, hash值为" + hash);
virtualNodes.put(hash, virtualNodeName);
}
}
}
public static void isAllowedByLua(String key,int limit,int duration){
String server = getServer(key);
String addr = server.split(":")[0];
int port = Integer.valueOf(server.split(":")[1]);
Jedis jedis = new Jedis(addr, port);
//处理逻辑....
}
private static String getServer(String key){
long hash = getHash(key);
SortedMap<Long, String> subMap = virtualNodes.tailMap(hash);//顺时针找大于hash的map
String virtualNode;
if(subMap.isEmpty()){
//没有就从第一个开始
Long i = virtualNodes.firstKey();
virtualNode = virtualNodes.get(i);
}else{
//第一个Key就是
Long i = subMap.firstKey();
virtualNode = subMap.get(i);
}
if(virtualNode!=null && virtualNode.trim()!=""){
return virtualNode.substring(0, virtualNode.indexOf("-"));
}
return null;
}
private static Long getHash(String key) {
ByteBuffer buf = ByteBuffer.wrap(key.getBytes());
int seed = 0x1234ABCD;
ByteOrder byteOrder = buf.order();
buf.order(ByteOrder.LITTLE_ENDIAN);
long m = 0xc6a4a7935bd1e995L;
int r = 47;
long h = seed ^ (buf.remaining() * m);
long k;
while (buf.remaining() >= 8) {
k = buf.getLong();
k *= m;
k ^= k >>> r;
k *= m;
h ^= k;
h *= m;
}
if (buf.remaining() > 0) {
ByteBuffer finish = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN);
finish.put(buf).rewind();
h ^= finish.getLong();
h *= m;
}
h ^= h >>> r;
h *= m;
h ^= h >>> r;
buf.order(byteOrder);
if(h < 0l){
h =-h;
}
return h;
}
一致性hash算法来源于网上,见参考文章
实际使用中,有现成的方案如codis,redis cluster等集群方案可以解决上述问题,具体可以google之。
以上都是在整个分布式系统上进行限流,接下来我们看下单机上的限流。
1,限制连接数(总量)
我们的web服务器大都使用tomcat,以tomcat为例子,配置其几个参数
maxThreads="500":最大处理请求的线程数
maxConnections:连接数达到最大值后,系统会继续接收连接但不会超过acceptCount的值
acceptCount="100":连接排队队列,如果超过了100就拒绝连接
2,单个接口访问的限流(总量)
public class LimitMain {
private static AtomicLong atomicLong = new AtomicLong(0);
private static final long LIMIT = 3L;
public static void main(String[] args) {
for(int i=0;i<10;i++){
Thread thread = new Thread("thread-"+i){
@Override
public void run() {
try {
if(atomicLong.incrementAndGet() > LIMIT){
System.out.println(Thread.currentThread().getName()+" has been refused");
}
//for test
Thread.sleep(1000);
} catch (Exception e) {
// TODO: handle exception
}finally{
atomicLong.decrementAndGet();
}
}
};
thread.start();
}
}
}
利用atomicLong进行计数,请求进入时先加1计数,如果超过限制,拒绝请求。当执行结束时记得计数减1.
3,限制某个接口或者某个资源在某个时间窗口的请求
比如限制接口每秒/分等的请求数,我们可以利用一个Map<key,AtomicLong>来实现计数,但是需要自己处理key过期等问题,因为有现成的解决方案,我们使用现成的解决方案。一种方式是使用ehcache,其底层实现是一个ConcurrentHashMap,它为我们实现了key过期等功能。
public class EhcacheMain {
private static final int LIMIT = 3;
private static final int DURATION = 1;
public static void main(final String[] args) {
CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder().build(true);
CacheConfiguration<Long,AtomicLong> cacheConfiguration = CacheConfigurationBuilder.newCacheConfigurationBuilder(
Long.class,AtomicLong.class,ResourcePoolsBuilder.heap(100))
.withExpiry(Expirations.timeToLiveExpiration(Duration.of(DURATION, TimeUnit.SECONDS))).build();
final Cache<Long, AtomicLong> myCache = cacheManager.createCache("flow_controller",cacheConfiguration);
for(int i=0;i<10;i++){
Thread thread = new Thread("thread-"+i){
@Override
public void run() {
long current = System.currentTimeMillis()/1000;
AtomicLong atomicLong = myCache.get(current);
if(null == atomicLong){
atomicLong = myCache.putIfAbsent(current,new AtomicLong(0));
if(null == atomicLong){
atomicLong = myCache.get(current);
}
}
if(atomicLong.incrementAndGet() > LIMIT){
System.out.println(Thread.currentThread().getName()+" has been refused");
}else{
//业务处理
}
}
};
thread.start();
}
}
}
另一种实现方式是采用guava cache,其思路与ehcache差不多,这里就不再贴代码了。
另一种实现方式是guava提供了一个现成的限流工具RateLimiter,它使用的是一种叫令牌桶的算法。
public class RateLimiterMain {
public static void main(String[] args){
final RateLimiter rateLimiter = RateLimiter.create(5);//1s五次访问
for(int i=0;i<10;i++){
Thread thread = new Thread("thread-"+i){
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+" request wait:"+rateLimiter.acquire());
}
};
thread.start();
}
}
}
========================================output====================================
thread-0 request wait:0.0
thread-2 request wait:0.19685
thread-5 request wait:0.396445
thread-1 request wait:0.59623
thread-3 request wait:0.795725
thread-7 request wait:0.994648
thread-9 request wait:1.194246
thread-4 request wait:1.393599
thread-6 request wait:1.59331
thread-8 request wait:1.793009
可以看出,这种方式与上面的不通过之处在于将请求进行了平滑处理,针对一秒五次访问这种情况,将其处理为每个200ms左右进行一次访问,可以有效防止突发的请求都集中在某一刻。
最后总结一下,对于系统限流来说,限流操作越放在前端执行越好(比如nginx上做限流),这样可以避免掉更多的请求到后端处理!