昨天我們已經在 SpringBoot 專案內完成了 Redis Client 的基本配置,今天實際用固定窗口計數器來整合 Redis 分散式儲存限流計數的功能,並且建立一個測試用的 Dockerfile 跟新的 docker-compose.yaml 來在容器內運行起兩個實例,測試看看分散式的環境下,是否可以共享 Redis 的狀態、做到統一限流。
首先我們先新增一個用 Redis 實作的計數器,由於之前已經有一個用 ConcurrentHashMap 實作的計數器了,它們都會 implements RateLimiterStorage
在 Spring 容器的要求下需要在 @Component
為他們做顯式命名,不然到時限流 class 要注入時只會注入介面,不知道會使用到哪個計數器:
@Component("redisRateLimiterStorage")
@RequiredArgsConstructor
public class RedisRateLimiterStorage implements RateLimiterStorage {
private final RedisHelper redisHelper;
@Override
public long incrementAndSetExpire(String key, long expireSeconds) {
return redisHelper.incrementWithCustomTTL(RedisKey.RATE_LIMITER.getValue(), expireSeconds, key);
}
// 略...
}
在這之前我已經先做 RedisHelper
工具類新增一個方法以供使用:
@Component
@RequiredArgsConstructor
public class RedisHelper {
private final RedisTemplate<String, Object> redisTemplate;
public long incrementWithCustomTTL(String cacheName, long ttl, Object... keys) {
var key = getCacheKey(cacheName, keys);
var count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1) {
redisTemplate.expire(key, Duration.ofSeconds(ttl));
}
return count != null ? count : 0L;
}
private String getCacheKey(String cacheName, Object... args) {
if (args == null || args.length == 0) {
return cacheName;
}
return cacheName.concat("::").concat(StringUtils.join(args, ":"));
}
}
這邊要注意,如果要使用 @Qualifier("redisRateLimiterStorage")
指定要注入的實作類,就只能用建構子注入:
@Component
public class FixedWindowLimiter implements RateLimiterStrategy {
private final RateLimiterStorage storage;
public FixedWindowLimiter(@Qualifier("redisRateLimiterStorage") RateLimiterStorage storage) {
this.storage = storage;
}
@Override
public boolean isAllow(String key, RateLimiter rateLimiter) {
var currentCount = storage.incrementAndSetExpire(key, rateLimiter.window());
return currentCount <= rateLimiter.limit();
}
@Override
public Algorithm getAlgorithmType() {
return Algorithm.FIXED_WINDOW;
}
}
因為我們要使用 docker-compose 來啟動 docker-image,所以必須先把我們自己的服務 build 成 docker-image,為此需要寫一個 Dockerfile:
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
RUN chmod +x ./mvnw
RUN ./mvnw dependency:go-offline -B
COPY src ./src
RUN ./mvnw clean package -DskipTests
EXPOSE 8080
CMD ["java", "-jar", "target/playground-module-0.0.1-SNAPSHOT.jar"]
接著才能用 docker-compose 拉 image 來使用:
services:
app1:
build: ..
container_name: test-app1
environment:
- SPRING_PROFILES_ACTIVE=test
- REDIS_HOST=host.docker.internal
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD}
- SERVER_PORT=8080
ports:
- "8081:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
app2:
build: ..
container_name: test-app2
environment:
- SPRING_PROFILES_ACTIVE=test
- REDIS_HOST=host.docker.internal
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD}
- SERVER_PORT=8080
ports:
- "8082:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
如此一來就有兩個實例被運行起來了,分別是 8081 和 8082:
http://localhost:8081/rate/window/fixed
http://localhost:8082/rate/window/fixed
輪流 call 這兩隻 api 會發現它們是對同一個 Redis 裡面的資料結構做操作:
81 先 call → 計數 +1 = 1
82 再 call → 計數 +1 = 2
.
.
.
兩個合計訪問到第六次就會達到上限了,這樣就完成最基本的分散式限流架構。