考虑一个场景,在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自身的延迟),请看下面一种情况:

nginx分流蜘蛛和真实用户流量 nginx 流控_lua



横轴是时间轴,用户的访问可能正好处在之间,第一个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上做限流),这样可以避免掉更多的请求到后端处理!