限流的目的是通過對併發訪問/請求進行限速或者一個時間視窗內的的請求進行限速來保護系統,一旦達到限制速率則可以拒絕服務。
前幾天在DD的公眾號,看了一篇關於使用 瓜娃 實現單應用限流的方案,參考《redis in action》 實現了一個jedis版本的,都屬於業務層次限制。 實際場景中常用的限流策略:
- Nginx接入層限流
按照一定的規則如帳號、IP、系統呼叫邏輯等在Nginx層面做限流 - 業務應用系統限流
通過業務程式碼控制流量這個流量可以被稱為訊號量,可以理解成是一種鎖,它可以限制一項資源最多能同時被多少程式訪問。
程式碼實現
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 |
import redis.clients.jedis.Jedis; import redis.clients.jedis.Transaction; import redis.clients.jedis.ZParams; import java.util.List; import java.util.UUID; /** * email wangiegie@gmail.com * @data 2017-08 */ public class RedisRateLimiter { private static final String BUCKET = "BUCKET"; private static final String BUCKET_COUNT = "BUCKET_COUNT"; private static final String BUCKET_MONITOR = "BUCKET_MONITOR"; static String acquireTokenFromBucket( Jedis jedis, int limit, long timeout) { String identifier = UUID.randomUUID().toString(); long now = System.currentTimeMillis(); Transaction transaction = jedis.multi(); //刪除訊號量 transaction.zremrangeByScore(BUCKET_MONITOR.getBytes(), "-inf".getBytes(), String.valueOf(now - timeout).getBytes()); ZParams params = new ZParams(); params.weightsByDouble(1.0,0.0); transaction.zinterstore(BUCKET, params, BUCKET, BUCKET_MONITOR); //計數器自增 transaction.incr(BUCKET_COUNT); List<Object> results = transaction.exec(); long counter = (Long) results.get(results.size() - 1); transaction = jedis.multi(); transaction.zadd(BUCKET_MONITOR, now, identifier); transaction.zadd(BUCKET, counter, identifier); transaction.zrank(BUCKET, identifier); results = transaction.exec(); //獲取排名,判斷請求是否取得了訊號量 long rank = (Long) results.get(results.size() - 1); if (rank < limit) { return identifier; } else {//沒有獲取到訊號量,清理之前放入redis 中垃圾資料 transaction = jedis.multi(); transaction.zrem(BUCKET_MONITOR, identifier); transaction.zrem(BUCKET, identifier); transaction.exec(); } return null; } } |
呼叫
1 2 3 4 5 6 7 8 9 10 11 12 13 |
測試介面呼叫 @GetMapping("/") public void index(HttpServletResponse response) throws IOException { Jedis jedis = jedisPool.getResource(); String token = RedisRateLimiter.acquireTokenFromBucket(jedis, LIMIT, TIMEOUT); if (token == null) { response.sendError(500); }else{ //TODO 你的業務邏輯 } jedisPool.returnResource(jedis); } |
優化
使用攔截器 + 註解優化程式碼
攔截器
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 |
@Configuration static class WebMvcConfigurer extends WebMvcConfigurerAdapter { private Logger logger = LoggerFactory.getLogger(WebMvcConfigurer.class); @Autowired private JedisPool jedisPool; public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new HandlerInterceptorAdapter() { public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HandlerMethod handlerMethod = (HandlerMethod) handler; Method method = handlerMethod.getMethod(); RateLimiter rateLimiter = method.getAnnotation(RateLimiter.class); if (rateLimiter != null){ int limit = rateLimiter.limit(); int timeout = rateLimiter.timeout(); Jedis jedis = jedisPool.getResource(); String token = RedisRateLimiter.acquireTokenFromBucket(jedis, limit, timeout); if (token == null) { response.sendError(500); return false; } logger.debug("token -> {}",token); jedis.close(); } return true; } }).addPathPatterns("/*"); } } |
定義註解
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
/** * email wangiegie@gmail.com * @data 2017-08 * 限流注解 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface RateLimiter { int limit() default 5; int timeout() default 1000; } |
使用
1 2 3 4 5 |
@RateLimiter(limit = 2, timeout = 5000) @GetMapping("/test") public void test() { } |
併發測試
工具:apache-jmeter-3.2
說明: 沒有獲取到訊號量的介面返回500,status是紅色,獲取到訊號量的介面返回200,status是綠色。
當限制請求訊號量為2,併發5個執行緒:
當限制請求訊號量為5,併發10個執行緒:
資料
總結
- 對於訊號量的操作,使用事務操作。
- 不要使用時間戳作為訊號量的排序分數,因為在分散式環境中,各個節點的時間差的原因,會出現不公平訊號量的現象。
- 可以使用把這塊程式碼抽成@rateLimiter註解,然後再方法上使用就會很方便啦
- 不同介面的流控,可以參考原始碼的裡面RedisRateLimiterPlus,無非是每個介面生成一個監控引數
- 原始碼http://git.oschina.net/boding1/pig-cloud