Update the 2020-12-22
See [Redis] how to use Redis to implement a general purpose frequency limiter. The implementation inside is more elegant, illustrated with a graph, and the time complexity of the frequency limit inside is O(1).
preface
In the project, we often encountered the need for frequency limit, such as SMS captcha can only be sent once per minute, 10 times per day. We can do this using Redis.
Basic implementation principle
Let’s say I can do six requests in 24 hours.
Fixed time refresh is, let’s say 24pm or 6am refresh times, so that by the time we get to the time, the number of times the user can request a refresh is 6.
A fixed delay refresh is calculated from the point at which the user’s first request is started, with a total of 6 refreshes after 24 hours of delay.
The range refresh is calculated from the point when the user’s first request starts. If the user requests 6 times in the 24 hours, and the request time is [0, 6, 7, 8, 15, 20], then the number of available requests is 0 at the 20th hour, and the user stops sending the request. 24 hours when set to 1 (because the distance from the current 24 hours before sending the request) for the first time, after 6 hours (30 hours) set to 2 (because the distance from the current 24 hours before sending the second request), 1 hour before (hours) 31 refresh for 3 (because the distance from the current 24 hours before sending the request for the third time).
If the number of requests available is 1, then a fixed delay refresh and a range refresh have the same effect.
parameter
- Key Requires the frequency limiting key
- Frequency frequency
- Time Frequency limit time
Fixed point in time refresh principle
For example, refresh at 24pm every night, we can calculate how much time is left between the current time and 24pm as the timeout of Redis key, and then judge whether the value of Redis key is greater than or equal to the frequency (null value is treated as 0), if so, reject. Otherwise, use INCR to increase the value of the key and set the expiration time.
Frequency limiting principle of range refresh
Our implementation principle is that when a user makes a request, he/she needs to apply for a token. The token expires. As long as the number of tokens reaches the limit, the user’s request will be rejected.
We use the Set structure of Redis to store tokens. When the number of tokens is less than the specified number, we can issue tokens. However, Redis does not support the expiration of elements in a Set, and we must make the token expire periodically. Therefore, every time we store the token into a Set, we will add the token as a key to the String structure of Redis and Set the expiration time. In this case, you only need to check whether there is a key that is the same as the token in the Set. If the token is expired, run the SREM command to clear it. Of course, the premise of this is that the token must be guaranteed to be unique, otherwise incorrect results will occur.
Here we also Set the expiration time of the Set, so there is no need to manually clear the expired Set.
This article main class structure diagram
FrequencyLimiter
Is the core interface, belowFixedPointRefreshFrequencyLimiter
,RangeRefreshFrequencyLimiter
,RepeatableFrequencyLimiter
Are all concrete implementation classes,FrequencyLimiterManager
I’m just putting them together.FrequencyLimitAspect
useFrequencyLimiter
The implementation class to implement annotations using frequency limits.
Range refresh frequency limit implementation
Implementation method 1: Java + Redis
Here, the Redis command is directly invoked to implement the UUID to ensure that the token is unique. However, this method is not an atomic operation, and it is possible to obtain more tokens than frequency in concurrent operations.
/** * query whether a key isAllowed to operate ** for example, the SMS verification code service is used isAllowed("15333333333", 10, 1, timeunit.days); For example, the SMS verification code service is used in isAllowed("15333333333", 1, 1, TimeUnit.MINUTES "). Verification code * * can be sent only once a minute@paramThe format of the key should be {service name}:{specific business name}:{unique identifier}, for example, SMS :auth-code:15333333333 *@paramFrequency frequency *@paramTime Frequency limiting time x@paramUnit Time unit *@returnWhether to allow */
@Override
public Boolean isAllowed(String key, Long frequency, Long time, TimeUnit unit) {
// Get the tokens corresponding to the key
// smembers(key)
Set<String> tokens = redisTemplate.opsForSet().members(key);
// Delete all expired tokens
// srem(token)
int expiredTokensCount = 0;
for (String token : tokens) {
if (redisTemplate.opsForValue().get(token) == null) { redisTemplate.opsForSet().remove(key, token); expiredTokensCount++; }}// If the number of unexpired tokens is greater than or equal to frequency, return false
int unexpiredTokensCount = tokens.size() - expiredTokensCount;
if (unexpiredTokensCount >= frequency) {
return false;
}
// Create a unique token, add the token to the redis set, and set the expiration time
// set(token, "", time, unit)
// sadd(key, token)
// expire(key, time, unit)
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(token, "", time, unit);
redisTemplate.opsForSet().add(key, token);
redisTemplate.expire(key, time, unit);
return true;
}
Copy the code
Implementation method 2: Lua script
Since the implementation only uses the Redis command, we can ensure atomicity through the Lua script. The UUID is implemented by the INCR command of Redis.
--[[KEYS[1] Required key ARGV[1] frequency ARGV[2] Frequency limit time --]]
-- Get tokens for the key
local tokens = redis.call('SMEMBERS', KEYS[1])
-- Delete all expired tokens
local expiredTokensCount = 0
for i = 1, #tokens do
if not redis.call('GET', tokens[i]) then
redis.call('SREM', KEYS[1], tokens[i])
expiredTokensCount = expiredTokensCount + 1
end
end
If the number of unexpired tokens is greater than or equal to frequency, return false
local unexpiredTokensCount = #tokens - expiredTokensCount;
if unexpiredTokensCount >= tonumber(ARGV[1]) then
return false
end
Create a unique token, add the token to the redis set, and set the expiration time
local token = redis.call('INCR'.'frequency-limit:token:increment-id')
redis.call('SET', token, ' '.'PX', ARGV[2])
redis.call('SADD', KEYS[1], token)
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return true
Copy the code
Use this script in Spring Boot
Define the Bean of the script
/** * Frequency limiting Lua script classpath path */
private static final String FREQUENCY_LIMIT_LUA_SCRIPT_CLASS_PATH = "/redis/lua/FrequencyLimit.lua";
/** * Redis script Bean */
@Bean("frequencyLimitRedisScript")
public RedisScript<Boolean> frequencyLimitRedisScript(a) {
DefaultRedisScript<Boolean> frequencyLimitRedisScript = new DefaultRedisScript<>();
frequencyLimitRedisScript.setResultType(Boolean.class);
frequencyLimitRedisScript.setScriptSource(new ResourceScriptSource(
new ClassPathResource(FREQUENCY_LIMIT_LUA_SCRIPT_CLASS_PATH)));
return frequencyLimitRedisScript;
}
Copy the code
Execute the script using the RedisTemplate
@Override
public Boolean isAllowed(String key, Long frequency, Long time, TimeUnit unit) {
return redisTemplate.execute(frequencyLimitRedisScript, Collections.singletonList(key), frequency.toString(),
String.valueOf(TimeoutUtils.toMillis(time, unit)));
}
Copy the code
Fixed time point refresh implementation
Implementation is relatively simple, because the expiration time is set, so when the time point is reached, Redis will automatically clear the key, thus achieving a fixed time point refresh.
--[[KEYS[1] Need to limit key ARGV[1] frequency ARGV[2] expiration time --]]
-- Reject the request directly if the frequency is 0
if tonumber(ARGV[1= =])0 then
return false
end
-- Obtain the number of tokens corresponding to the key
local tokenNumbers = redis.call('GET', KEYS[1])
If the corresponding key exists and the number is greater than or equal to frequency, return false
if tokenNumbers and tonumber(tokenNumbers) >= tonumber(ARGV[1]) then
return false
end
-- Increase the number of tokens and set the expiration time
redis.call('INCR', KEYS[1])
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return true
Copy the code
Extension 1: Encapsulated as a frequency limiter
It is still troublesome to use the Lua script directly, which needs to be combined with the RedisTemplate, and to refresh the frequency limit at a fixed time point, we need to process the expiration time by ourselves, so we encapsulated them as frequency limiter for easy use.
Range refresh frequency limiter
The RANGE_REFRESH_FREQUENCY_LIMIT_REDIS_KEY_PREFIX prefix has been added to prevent conflicts with other Redis keys in the project.
/** * Range refresh frequency limiter **@author xhsf
* @create 2020/12/18 15:41
*/
public class RangeRefreshFrequencyLimiter implements FrequencyLimiter{
/** * Range refresh frequency limit Redis Key prefix */
private static final String RANGE_REFRESH_FREQUENCY_LIMIT_REDIS_KEY_PREFIX = "frequency-limit:range-fresh:";
/** * Range refresh frequency limit Lua script */
private static final String RANGE_REFRESH_FREQUENCY_LIMIT_LUA =
Tokens \n" -- obtain tokens\n +
"local tokens = redis.call('SMEMBERS', KEYS[1])\n" +
"\n" +
"-- Delete all expired tokens \n" +
"local expiredTokensCount = 0\n" +
"for i = 1, #tokens do\n" +
" if not redis.call('GET', tokens[i]) then\n" +
" redis.call('SREM', KEYS[1], tokens[i])\n" +
" expiredTokensCount = expiredTokensCount + 1\n" +
" end\n" +
"end\n" +
"\n" +
"-- If the number of unexpired tokens is greater than or equal to frequency, return false\n" +
"local unexpiredTokensCount = #tokens - expiredTokensCount; \n" +
"if unexpiredTokensCount >= tonumber(ARGV[1]) then\n" +
" return false\n" +
"end\n" +
"\n" +
"-- generate a unique token, add the token to the redis set corresponding to the key, and set the expiration time \n" +
"local token = '" + RANGE_REFRESH_FREQUENCY_LIMIT_REDIS_KEY_PREFIX + "'.. redis.call('INCR', 'frequency-limit:token:increment-id')\n" +
"redis.call('SET', token, '', 'PX', ARGV[2])\n" +
"redis.call('SADD', KEYS[1], token)\n" +
"redis.call('PEXPIRE', KEYS[1], ARGV[2])\n" +
"return true";
/** * StringRedisTemplate */
private final StringRedisTemplate stringRedisTemplate;
/** * Range refresh frequency limit script */
private final RedisScript<Boolean> rangeRefreshFrequencyLimitRedisScript;
public RangeRefreshFrequencyLimiter(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
// Range refresh frequency limit script
DefaultRedisScript<Boolean> rangeRefreshFrequencyLimitRedisScript = new DefaultRedisScript<>();
rangeRefreshFrequencyLimitRedisScript.setResultType(Boolean.class);
rangeRefreshFrequencyLimitRedisScript.setScriptText(RANGE_REFRESH_FREQUENCY_LIMIT_LUA);
this.rangeRefreshFrequencyLimitRedisScript = rangeRefreshFrequencyLimitRedisScript;
}
** For example, the SMS verification code service is used in isAllowed("15333333333", 10, 1, timeunit.days); For example, the SMS verification code service is used in isAllowed("15333333333", 1, 1, TimeUnit.MINUTES "). Verification code * * can be sent only once a minute@paramThe format of the key should be {service name}:{specific business name}:{unique identifier}, for example, SMS :auth-code:15333333333 *@paramFrequency frequency *@paramTime Frequency limiting time x@paramUnit Time unit *@returnWhether to allow */
public boolean isAllowed(String key, long frequency, long time, TimeUnit unit) {
key = RANGE_REFRESH_FREQUENCY_LIMIT_REDIS_KEY_PREFIX + key;
returnstringRedisTemplate.execute(rangeRefreshFrequencyLimitRedisScript, Collections.singletonList(key), String.valueOf(frequency), String.valueOf(TimeoutUtils.toMillis(time, unit))); }}Copy the code
Refresh the frequency limiter at a fixed time point
The Redis key prefix is added here and the point in time is represented by cron expression, so you only need to write cron expression to use it.
Cron expressions can be explained in detail by referring to the article cron expressions and the time of cron expressions calculated according to CronSequenceGenerator.
/** * Fixed time point refresh frequency limiter **@author xhsf
* @create 2020/12/18 15:41
*/
public class FixedPointRefreshFrequencyLimiter implements FrequencyLimiter{
/** * Fixed time point refresh frequency limit Redis Key prefix */
private static final String FIXED_POINT_REFRESH_FREQUENCY_LIMIT_REDIS_KEY_PREFIX =
"frequency-limit:fixed-point-refresh:";
/** * Fixed time point refresh frequency limit lua script */
private static final String FIXED_POINT_REFRESH_FREQUENCY_LIMIT_LUA =
"-- Reject the request directly if frequency is 0 \n" +
"if tonumber(ARGV[1]) == 0 then\n" +
" return false\n" +
"end\n" +
"\n" +
"-- get the number of tokens for the key \n" +
"local tokenNumbers = redis.call('GET', KEYS[1])\n" +
"\n" +
"-- If the corresponding key exists and the number is greater than or equal to frequency, return false\n" +
"if tokenNumbers and tonumber(tokenNumbers) >= tonumber(ARGV[1]) then\n" +
" return false\n" +
"end\n" +
"\n" +
"-- Increase the number of tokens and set the expiration time \n" +
"redis.call('INCR', KEYS[1])\n" +
"redis.call('PEXPIRE', KEYS[1], ARGV[2])\n" +
"return true";
/** * StringRedisTemplate */
private final StringRedisTemplate stringRedisTemplate;
/** * Cache the CronSequenceGenerator for cron expressions */
private final Map<String, CronSequenceGenerator> cronSequenceGeneratorMap = new HashMap<>();
/** * Fixed time point refresh frequency limit script */
private final RedisScript<Boolean> fixedPointRefreshFrequencyLimitRedisScript;
public FixedPointRefreshFrequencyLimiter(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
// The frequency limiting script is refreshed at a fixed time point
DefaultRedisScript<Boolean> fixedPointRefreshFrequencyLimitRedisScript = new DefaultRedisScript<>();
fixedPointRefreshFrequencyLimitRedisScript.setResultType(Boolean.class);
fixedPointRefreshFrequencyLimitRedisScript.setScriptText(FIXED_POINT_REFRESH_FREQUENCY_LIMIT_LUA);
this.fixedPointRefreshFrequencyLimitRedisScript = fixedPointRefreshFrequencyLimitRedisScript;
}
** For example, the SMS verification code service is used in isAllowed("15333333333", 10, 1, timeunit.days); For example, the SMS verification code service is used in isAllowed("15333333333", 1, 1, TimeUnit.MINUTES "). Verification code * * can be sent only once a minute@paramThe format of the key should be {service name}:{specific business name}:{unique identifier}, for example, SMS :auth-code:15333333333 *@paramFrequency frequency *@paramCron Cron expression *@returnWhether to allow */
public boolean isAllowed(String key, long frequency, String cron) {
CronSequenceGenerator cronSequenceGenerator = cronSequenceGeneratorMap.getOrDefault(
cron, cronSequenceGeneratorMap.put(cron, new CronSequenceGenerator(cron)));
Date now = new Date();
Date next = cronSequenceGenerator.next(now);
long timeout = next.getTime() - now.getTime();
key = FIXED_POINT_REFRESH_FREQUENCY_LIMIT_REDIS_KEY_PREFIX + key;
returnstringRedisTemplate.execute(fixedPointRefreshFrequencyLimitRedisScript, Collections.singletonList(key), String.valueOf(frequency), String.valueOf(timeout)); }}Copy the code
Use FrequencyLimiterManager to manage multiple frequencylimiters
/** * Frequency limiter manager **@author xhsf
* @create 2020/12/18 15:41
*/
public class FrequencyLimiterManager implements FrequencyLimiter {
private final FixedPointRefreshFrequencyLimiter fixedPointRefreshFrequencyLimiter;
private final RangeRefreshFrequencyLimiter rangeRefreshFrequencyLimiter;
public FrequencyLimiterManager(StringRedisTemplate stringRedisTemplate) {
this.fixedPointRefreshFrequencyLimiter = new FixedPointRefreshFrequencyLimiter(stringRedisTemplate);
this.rangeRefreshFrequencyLimiter = new RangeRefreshFrequencyLimiter(stringRedisTemplate);
}
** For example, the SMS verification code service is used in isAllowed("15333333333", 10, 1, timeunit.days); For example, the SMS verification code service is used in isAllowed("15333333333", 1, 1, TimeUnit.MINUTES "). Verification code * * can be sent only once a minute@paramThe format of the key should be {service name}:{specific business name}:{unique identifier}, for example, SMS :auth-code:15333333333 *@paramFrequency frequency *@paramTime Frequency limiting time x@paramUnit Time unit *@returnWhether to allow */
public boolean isAllowed(String key, long frequency, long time, TimeUnit unit) {
return rangeRefreshFrequencyLimiter.isAllowed(key, frequency, time, unit);
}
** For example, the SMS verification code service is used in isAllowed("15333333333", 10, 1, timeunit.days); For example, the SMS verification code service is used in isAllowed("15333333333", 1, 1, TimeUnit.MINUTES "). Verification code * * can be sent only once a minute@paramThe format of the key should be {service name}:{specific business name}:{unique identifier}, for example, SMS :auth-code:15333333333 *@paramFrequency frequency *@paramCron Cron expression *@returnWhether to allow */
public boolean isAllowed(String key, long frequency, String cron) {
returnfixedPointRefreshFrequencyLimiter.isAllowed(key, frequency, cron); }}Copy the code
Use the sample
// Can only send 10 times per day, every night 00:00:00 refresh
frequencyLimiter.isAllowed("sms:auth-code:15333333333".10."0 0 * * *");
// Send only once a minute
frequencyLimiter.isAllowed("sms:auth-code:15333333333".1.1, TimeUnit.MINUTES);
Copy the code
Extension 2: Encapsulated as annotations
Using annotations can reduce intrusion into business code and improve ease of use. Here, EL expressions are implemented and multiple annotations are supported simultaneously, satisfying most of the requirements.
EL expressions and parsers
Refer to the article SpEL for a brief analysis of spring-expression implementation principles that you are interested in.
Annotations FrequencyLimit
/** * Description: frequency limit note, by {@linkFrequencyLimitAspect} Implements * *@author xhsf
* @createThe 2020-12-18 then * /
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Repeatable(FrequencyLimits.class)
public @interface FrequencyLimit {
/** * limit the frequency of key, support EL expression, such as #{#user.phone} *@see#parameters() Support placeholder padding * such as value =" user:{0}:phone", parameters="#{#user.id}" will be converted to value =" user:#{#user.id}:phone" */
String value(a);
/** * Fill in the placeholder parameter */
String[] parameters() default {};
/** * cron expression * 1. Cron has 7 digits, but the last digit is the year (1970-2099), which can be left blank, so we can write 6 digits in order: ** second (0-59) * minute (0-59) * hour (0-23) * day (month) (0-31) But you need to consider the days of your month) * Month (0~11) * Week (1~7 1=SUN, MON, TUE, WED, THU, FRI, SAT) * * Some special symbols of cron * (*) asterisk: Every second, every minute, every day, every month, every year Question mark: * The question mark can only appear in the date and the week, indicating that the value of this position is uncertain. It is executed every day at 3 o 'clock. Therefore, we do not need to pay attention to the position of the sixth week, * is the uncertain value. Also: Date and week are mutually exclusive elements, and no value is specified by the question mark. For example, January 10, such as Monday, * if the location of the week is another Tuesday, there is a conflict. * (-) minus: * indicates a range, such as "10-12" in the hour field, from 10 to 12, that is, 10,11,12 * (,) comma: * indicates a list value, such as "1,2,4" in the week field, indicating Monday, Tuesday, and Thursday * (/) slash: for example: X /y, x is the starting value, y is the step size, such as in the first (second) 0/15 is, start at 0 seconds, every 15 seconds, and end at 0, 15, 30, 45, 60 * and: *\/y, which is the same as 0/y * *@see org.springframework.scheduling.support.CronSequenceGenerator
*/
String cron(a) default "";
/** * frequency, default 0 */
long frequency(a) default 0;
/** * Frequency limit time, default 0 */
long time(a) default 0;
/** * Time unit. The default value is seconds */
TimeUnit unit(a) default TimeUnit.SECONDS;
/** * Error message when the token fails to be obtained. EL expression */ is supported
String errorMessage(a) default "Too many request.";
}
Copy the code
Annotations FrequencyLimits
/** * Description: Frequency limit annotation array **@author xhsf
* @createThe 2020-12-18 then * /
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface FrequencyLimits {
/** * limit the frequency of the annotation array */
FrequencyLimit[] value();
}
Copy the code
FrequencyLimitAspect aspect implementation class
/** * Description: Frequency limited section, with {@linkFrequencyLimit} allows convenient use of FrequencyLimit * *@author xhsf
* @create2020/12/18 as * /
@Aspect
public class FrequencyLimitAspect {
private final FrequencyLimiter frequencyLimiter;
/** * EL expression parser */
private static final ExpressionParser expressionParser = new SpelExpressionParser();
public FrequencyLimitAspect(FrequencyLimiter frequencyLimiter) {
this.frequencyLimiter = frequencyLimiter;
}
Limit the frequency of requests **@errorCodeTooManyRequests: too frequent * *@param joinPoint ProceedingJoinPoint
* @return Object
*/
@Around("@annotation(FrequencyLimits) || @annotation(FrequencyLimit)")
public Object handler(ProceedingJoinPoint joinPoint) throws Throwable {
FrequencyLimit[] frequencyLimits = getFrequencyLimits(joinPoint);
for (FrequencyLimit frequencyLimit : frequencyLimits) {
boolean isAllowed = isAllowed(joinPoint, frequencyLimit);
if(! isAllowed) { String errorMessageExpression = frequencyLimit.errorMessage(); String errorMessage = getExpressionValue(errorMessageExpression, joinPoint);returnResult.fail(ErrorCodeEnum.TOO_MANY_REQUESTS, errorMessage); }}// Execute the business logic
return joinPoint.proceed();
}
/** whether to allow **@param joinPoint ProceedingJoinPoint
* @param frequencyLimit FrequencyLimit
* @returnWhether to allow */
private boolean isAllowed(ProceedingJoinPoint joinPoint, FrequencyLimit frequencyLimit) {
/ / get keys
String key = getKey(joinPoint, frequencyLimit);
// Whether to allow
if (frequencyLimit.cron().equals("")) {
return frequencyLimiter.isAllowed(
key, frequencyLimit.frequency(), frequencyLimit.time(),frequencyLimit.unit());
}
return frequencyLimiter.isAllowed(key, frequencyLimit.frequency(), frequencyLimit.cron());
}
/** * Get the list of frequency limit annotations **@param joinPoint ProceedingJoinPoint
* @return FrequencyLimit[]
*/
private FrequencyLimit[] getFrequencyLimits(ProceedingJoinPoint joinPoint) {
Method method;
try {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
method = joinPoint.getTarget()
.getClass()
.getMethod(methodSignature.getName(), methodSignature.getParameterTypes());
return method.getAnnotationsByType(FrequencyLimit.class);
} catch (NoSuchMethodException ignored) {
}
return new FrequencyLimit[]{};
}
/** * Get the key for limiting frequency **@param joinPoint ProceedingJoinPoint
* @param frequencyLimit FrequencyLimit
* @returnThe limit of the frequency key * /
private String getKey(ProceedingJoinPoint joinPoint, FrequencyLimit frequencyLimit) {
// Get the key expression pattern
String keyExpressionPattern = frequencyLimit.value();
// Get the parameter
Object[] parameters = frequencyLimit.parameters();
// Construct key expressions
String keyExpression = keyExpressionPattern;
// Fill in the parameters, if any
if (parameters.length > 0) {
keyExpression = MessageFormat.format(keyExpressionPattern, parameters);
}
/ / get keys
return getExpressionValue(keyExpression, joinPoint);
}
/** * Get the value of the expression **@paramExpression Expression *@param joinPoint ProceedingJoinPoint
* @return value
*/
private String getExpressionValue(String expression, ProceedingJoinPoint joinPoint) {
// Get the Map of the method parameters
String[] parameterNames = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
Object[] parameterValues = joinPoint.getArgs();
Map<String, Object> parameterMap = new HashMap<>();
for (int i = 0; i < parameterNames.length; i++) {
parameterMap.put(parameterNames[i], parameterValues[i]);
}
// Parse the EL expression
return getExpressionValue(expression, parameterMap);
}
/** * Get the value of the EL expression **@paramElExpression EL Expression *@paramParameterMap Parameter name - Value Map *@returnThe value of the expression */
private String getExpressionValue(String elExpression, Map<String, Object> parameterMap) {
Expression expression = expressionParser.parseExpression(elExpression, new TemplateParserContext());
EvaluationContext context = new StandardEvaluationContext();
for (Map.Entry<String, Object> entry : parameterMap.entrySet()) {
context.setVariable(entry.getKey(), entry.getValue());
}
returnexpression.getValue(context, String.class); }}Copy the code
Example of using annotations
Limit the email verification code to 1 times per minute, 5 times per hour, and refresh every hour at 0 minutes 0 seconds.
@FrequencyLimit(value = "email:auth-code:{0}", parameters = "#{#createAndSendEmailAuthCodePO.email}", frequency = 1, time = 60)
@FrequencyLimit(value = "email:auth-code:{0}", parameters = "#{#createAndSendEmailAuthCodePO.email}", frequency = 5, cron = "0 0 0/1 * * ?" )
@Override
public Result<Void> createAndSendEmailAuthCode(CreateAndSendEmailAuthCodePO createAndSendEmailAuthCodePO) {
// Business code
}
Copy the code
Remember to register sections
/** **@param stringRedisTemplate StringRedisTemplate
* @return FrequencyLimiter
*/
@Bean
public FrequencyLimiter frequencyLimiter(StringRedisTemplate stringRedisTemplate) {
return new FrequencyLimiter(stringRedisTemplate);
}
/** * Frequency limiting section **@param frequencyLimiter FrequencyLimiter
* @return FrequencyLimitAspect
*/
@Bean
public FrequencyLimitAspect frequencyLimitAspect(FrequencyLimiter frequencyLimiter) {
return new FrequencyLimitAspect(frequencyLimiter);
}
Copy the code
Extension 3: Problems with multiple constraints
If you use a multiple limiter to limit the frequency, say 1 time per minute, 10 times a day, there is a problem: if the first limit is passed and the second limit is not passed, the first limit will not be automatically released according to the above implementation. The first limit in this case is only 1 minute which is fine, the user only has to wait 1 minute. But if the limit is one hour, then the user must wait an hour before he can try again for the second limit.
Therefore, in the case of multiple constraints, if a constraint does not pass, we need to release the previously passed constraint.
The solution
We can keep a record of each previous limit passed, and then release the passed limit if one of the limits is not passed.
Use Lua scripts to solve the problem
While the Lua script is complex, it is basically a combination of the previous two Lua scripts and adding cleanup code in case of failure. In this case, tokenMap is used to record the allowed key: token key pair. If the key: token key pair recorded in tokenMap is not allowed, the key: token key pair recorded in tokenMap is released.
ARGV[#KEYS * 2 + I] ARGV[#KEYS * 2 + I] ARGV[#KEYS * 2 + I] ARGV[#KEYS * 2 + I] ARGV[#KEYS * 2 + I]
-- Record the successfully obtained key: token key pair
local tokenMap = {}
-- loop to obtain each token
for i = 1, #KEYS do
if ARGV[#KEYS * 2 + i] == 'FIXED_POINT_REFRESH' then
-- Reject the request directly if the frequency is 0
if tonumber(ARGV[i]) == 0 then
break
end
-- Obtain the number of tokens corresponding to the key
local tokenNumbers = redis.call('GET', KEYS[i])
-- If the corresponding key exists and the number of keys is greater than or equal to frequency, directly break
if tokenNumbers and tonumber(tokenNumbers) >= tonumber(ARGV[i]) then
break
end
-- Increase the number of tokens and set the expiration time
redis.call('INCR', KEYS[i])
redis.call('PEXPIRE', KEYS[i], ARGV[#KEYS + i])
tokenMap[KEYS[i]] = ' '
else
-- Get tokens for the key
local tokens = redis.call('SMEMBERS', KEYS[i])
-- Delete all expired tokens
local expiredTokensCount = 0
for j = 1, #tokens do
if not redis.call('GET', tokens[j]) then
redis.call('SREM', KEYS[i], tokens[j])
expiredTokensCount = expiredTokensCount + 1
end
end
-- If the number of unexpired tokens is greater than or equal to frequency, break the tokens directly
local unexpiredTokensCount = #tokens - expiredTokensCount
if unexpiredTokensCount >= tonumber(ARGV[i]) then
break
end
Create a unique token, add the token to the redis set, and set the expiration time
local token = redis.call('INCR'.'frequency-limit:token:increment-id')
redis.call('SET', token, ' '.'PX', ARGV[#KEYS + i])
redis.call('SADD', KEYS[i], token)
redis.call('PEXPIRE', KEYS[i], ARGV[#KEYS + i])
tokenMap[KEYS[i]] = token
end
end
-- Gets the size of the tokenMap
local tokenMapSize = 0
for key, token in pairs(tokenMap) do
tokenMapSize = tokenMapSize + 1
end
- Check whether all tokens are obtained successfully. If no, release the obtained tokens
if tokenMapSize < #KEYS then
for key, token in pairs(tokenMap) do
if token == ' ' then
redis.call('INCRBY', key, - 1)
else
redis.call('SREM', key, token)
redis.call('DEL', token)
end
end
return false
end
return true
Copy the code
Encapsulated as a frequency limiter
The Lua script above is particularly cumbersome to use directly, so we encapsulate it as a frequency limiter. Similarly, we will prefix every key generated in Redis to prevent collisions.
/** * Can get multiple key frequency limiter **@author xhsf
* @create 2020/12/18 15:41
*/
public class RepeatableFrequencyLimiter implements FrequencyLimiter {
/** * Fixed time point refresh frequency limit Redis Key prefix */
private static final String FIXED_POINT_REFRESH_FREQUENCY_LIMIT_REDIS_KEY_PREFIX =
"frequency-limit:fixed-point-refresh:";
/** * Range refresh frequency limit Redis Key prefix */
private static final String RANGE_REFRESH_FREQUENCY_LIMIT_REDIS_KEY_PREFIX = "frequency-limit:range-fresh:";
/** * Lua script that can get multiple keys at the same time */
private static final String REPEATABLE_FREQUENCY_LIMIT_LUA =
"--[[\n" +
"KEYS[I] need frequency limited key\n" +
"ARGV [I] frequency \ n" +
"ARGV[#KEYS + I] frequency limit time \n" +
"ARGV[#KEYS * 2 + I] limited frequency type \n" +
"--]]\n" +
"\n" +
"-- record successfully obtained key: token value pair \n" +
"local tokenMap = {}\n" +
"\n" +
"-- loop to obtain each token\n" +
"for i = 1, #KEYS do\n" +
" if ARGV[#KEYS * 2 + i] == 'FIXED_POINT_REFRESH' then\n" +
"-- Reject the request directly if frequency is 0 \n" +
" if tonumber(ARGV[i]) == 0 then\n" +
" break\n" +
" end\n" +
"\n" +
"-- get the number of tokens for the key \n" +
" local tokenNumbers = redis.call('GET', KEYS[i])\n" +
"\n" +
"-- break\n if the corresponding key exists and the number of keys is greater than or equal to frequency" +
" if tokenNumbers and tonumber(tokenNumbers) >= tonumber(ARGV[i]) then\n" +
" break\n" +
" end\n" +
"\n" +
"-- Increase the number of tokens and set the expiration time \n" +
" redis.call('INCR', KEYS[i])\n" +
" redis.call('PEXPIRE', KEYS[i], ARGV[#KEYS + i])\n" +
" tokenMap[KEYS[i]] = ''\n" +
" else\n" +
Tokens \n" -- obtain tokens\n +
" local tokens = redis.call('SMEMBERS', KEYS[i])\n" +
"\n" +
"-- Delete all expired tokens \n" +
" local expiredTokensCount = 0\n" +
" for j = 1, #tokens do\n" +
" if not redis.call('GET', tokens[j]) then\n" +
" redis.call('SREM', KEYS[i], tokens[j])\n" +
" expiredTokensCount = expiredTokensCount + 1\n" +
" end\n" +
" end\n" +
"\n" +
"-- Break \n if the number of unexpired tokens is greater than or equal to frequency" +
" local unexpiredTokensCount = #tokens - expiredTokensCount\n" +
" if unexpiredTokensCount >= tonumber(ARGV[i]) then\n" +
" break\n" +
" end\n" +
"\n" +
"-- generate a unique token, add the token to the redis set corresponding to the key, and set the expiration time \n" +
" local token = '" + RANGE_REFRESH_FREQUENCY_LIMIT_REDIS_KEY_PREFIX + "'.. redis.call('INCR', 'frequency-limit:token:increment-id')\n" +
" redis.call('SET', token, '', 'PX', ARGV[#KEYS + i])\n" +
" redis.call('SADD', KEYS[i], token)\n" +
" redis.call('PEXPIRE', KEYS[i], ARGV[#KEYS + i])\n" +
" tokenMap[KEYS[i]] = token\n" +
" end\n" +
"end\n" +
"\n" +
"-- get tokenMap size \n" +
"local tokenMapSize = 0\n" +
"for key, token in pairs(tokenMap) do\n" +
"\ttokenMapSize = tokenMapSize + 1\n" +
"end\n" +
"\n" +
"-- determine whether all tokens have been obtained successfully. If not, release the obtained tokens \n" +
"if tokenMapSize < #KEYS then\n" +
" for key, token in pairs(tokenMap) do\n" +
" if token == '' then\n" +
" redis.call('INCRBY', key, -1)\n" +
" else\n" +
" redis.call('SREM', key, token)\n" +
" redis.call('DEL', token)\n" +
" end\n" +
" end\n" +
" return false\n" +
"end\n" +
"\n" +
"return true";
/** * StringRedisTemplate */
private final StringRedisTemplate stringRedisTemplate;
/** * Get multiple key frequency limiting script */
private final RedisScript<Boolean> repeatableFrequencyLimitRedisScript;
public RepeatableFrequencyLimiter(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
// A frequency limiting script that can get multiple keys
DefaultRedisScript<Boolean> repeatableFrequencyLimitRedisScript = new DefaultRedisScript<>();
repeatableFrequencyLimitRedisScript.setResultType(Boolean.class);
repeatableFrequencyLimitRedisScript.setScriptText(REPEATABLE_FREQUENCY_LIMIT_LUA);
this.repeatableFrequencyLimitRedisScript = repeatableFrequencyLimitRedisScript;
}
/** * select tokens ** from tokens **@paramFrequencyLimiterTypes Frequency limiting type *@paramKeys requires a frequency limiting key *@paramFrequencies frequency *@paramTimeouts Expiration time *@returnWhether to allow */
public boolean isAllowed(FrequencyLimiterType[] frequencyLimiterTypes, List<String> keys, long[] frequencies,
long[] timeouts) {
String[] args = new String[frequencyLimiterTypes.length * 3];
for (int i = 0; i < frequencies.length; i++) {
args[i] = String.valueOf(frequencies[i]);
}
for (int i = 0; i < timeouts.length; i++) {
args[frequencyLimiterTypes.length + i] = String.valueOf(timeouts[i]);
}
for (int i = 0; i < frequencyLimiterTypes.length; i++) {
args[frequencyLimiterTypes.length * 2 + i] = frequencyLimiterTypes[i].name();
}
for (int i = 0; i < keys.size(); i++) {
if (frequencyLimiterTypes[i] == FrequencyLimiterType.FIXED_POINT_REFRESH) {
keys.set(i, FIXED_POINT_REFRESH_FREQUENCY_LIMIT_REDIS_KEY_PREFIX + timeouts[i] + ":" + keys.get(i));
} else {
keys.set(i, RANGE_REFRESH_FREQUENCY_LIMIT_REDIS_KEY_PREFIX + timeouts[i] + ":"+ keys.get(i)); }}returnstringRedisTemplate.execute(repeatableFrequencyLimitRedisScript, keys, args); }}Copy the code
Encapsulate into sections
Since the parameters of the above frequency limiter are oriented to the Redis Lua script, they are quite tedious to use compared to the previous two, so it is better to use sections again to reduce the complexity. The implementation of the aspect here is similar to the above, except that the parameters of the FrequencyLimit array are converted, encapsulated as an array, and then the frequency limiter’s isAllowed() method is called.
/** * Description: Frequency limited section, with {@linkFrequencyLimit} allows convenient use of FrequencyLimit * *@author xhsf
* @create2020/12/18 as * /
@Aspect
public class FrequencyLimitAspect {
/** * frequency limiter */
private final FrequencyLimiter frequencyLimiter;
/** * EL expression parser */
private static final ExpressionParser expressionParser = new SpelExpressionParser();
/** * Cache the CronSequenceGenerator for cron expressions */
private final Map<String, CronSequenceGenerator> cronSequenceGeneratorMap = new HashMap<>();
public FrequencyLimitAspect(FrequencyLimiter frequencyLimiter) {
this.frequencyLimiter = frequencyLimiter;
}
Limit the frequency of requests **@errorCodeTooManyRequests: too frequent * *@param joinPoint ProceedingJoinPoint
* @return Object
*/
@Around("@annotation(FrequencyLimits) || @annotation(FrequencyLimit)")
public Object handler(ProceedingJoinPoint joinPoint) throws Throwable {
FrequencyLimit[] frequencyLimits = getFrequencyLimits(joinPoint);
if(! isAllowed(joinPoint, frequencyLimits)) { String errorMessageExpression = frequencyLimits[0].errorMessage();
String errorMessage = getExpressionValue(errorMessageExpression, joinPoint);
return Result.fail(ErrorCodeEnum.TOO_MANY_REQUESTS, errorMessage);
}
// Execute the business logic
return joinPoint.proceed();
}
/** whether to allow **@param joinPoint ProceedingJoinPoint
* @param frequencyLimits FrequencyLimit[]
* @returnWhether to allow */
private boolean isAllowed(ProceedingJoinPoint joinPoint, FrequencyLimit[] frequencyLimits) {
FrequencyLimiterType[] frequencyLimiterTypes = new FrequencyLimiterType[frequencyLimits.length];
List<String> keys = new ArrayList<>(frequencyLimits.length);
long[] frequencies = new long[frequencyLimits.length];
long[] timeouts = new long[frequencyLimits.length];
for (int i = 0; i < frequencyLimits.length; i++) {
if (frequencyLimits[i].cron().equals("")) {
frequencyLimiterTypes[i] = FrequencyLimiterType.RANGE_REFRESH;
timeouts[i] = TimeoutUtils.toMillis(frequencyLimits[i].time(), frequencyLimits[i].unit());
} else {
frequencyLimiterTypes[i] = FrequencyLimiterType.FIXED_POINT_REFRESH;
String cron = frequencyLimits[i].cron();
CronSequenceGenerator cronSequenceGenerator = cronSequenceGeneratorMap.getOrDefault(
cron, cronSequenceGeneratorMap.put(cron, new CronSequenceGenerator(cron)));
Date now = new Date();
Date next = cronSequenceGenerator.next(now);
timeouts[i] = next.getTime() - now.getTime();
}
keys.add(getKey(joinPoint, frequencyLimits[i]));
frequencies[i] = frequencyLimits[i].frequency();
}
return frequencyLimiter.isAllowed(frequencyLimiterTypes, keys, frequencies, timeouts);
}
/** * Get the list of frequency limit annotations **@param joinPoint ProceedingJoinPoint
* @return FrequencyLimit[]
*/
private FrequencyLimit[] getFrequencyLimits(ProceedingJoinPoint joinPoint) {
Method method;
try {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
method = joinPoint.getTarget()
.getClass()
.getMethod(methodSignature.getName(), methodSignature.getParameterTypes());
return method.getAnnotationsByType(FrequencyLimit.class);
} catch (NoSuchMethodException ignored) {
}
return new FrequencyLimit[]{};
}
/** * Get the key for limiting frequency **@param joinPoint ProceedingJoinPoint
* @param frequencyLimit FrequencyLimit
* @returnThe limit of the frequency key * /
private String getKey(ProceedingJoinPoint joinPoint, FrequencyLimit frequencyLimit) {
// Get the key expression pattern
String keyExpressionPattern = frequencyLimit.value();
// Get the parameter
Object[] parameters = frequencyLimit.parameters();
// Construct key expressions
String keyExpression = keyExpressionPattern;
// Fill in the parameters, if any
if (parameters.length > 0) {
keyExpression = MessageFormat.format(keyExpressionPattern, parameters);
}
/ / get keys
return getExpressionValue(keyExpression, joinPoint);
}
/** * Get the value of the expression **@paramExpression Expression *@param joinPoint ProceedingJoinPoint
* @return value
*/
private String getExpressionValue(String expression, ProceedingJoinPoint joinPoint) {
// Get the Map of the method parameters
String[] parameterNames = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
Object[] parameterValues = joinPoint.getArgs();
Map<String, Object> parameterMap = new HashMap<>();
for (int i = 0; i < parameterNames.length; i++) {
parameterMap.put(parameterNames[i], parameterValues[i]);
}
// Parse the EL expression
return getExpressionValue(expression, parameterMap);
}
/** * Get the value of the EL expression **@paramElExpression EL Expression *@paramParameterMap Parameter name - Value Map *@returnThe value of the expression */
private String getExpressionValue(String elExpression, Map<String, Object> parameterMap) {
Expression expression = expressionParser.parseExpression(elExpression, new TemplateParserContext());
EvaluationContext context = new StandardEvaluationContext();
for (Map.Entry<String, Object> entry : parameterMap.entrySet()) {
context.setVariable(entry.getKey(), entry.getValue());
}
returnexpression.getValue(context, String.class); }}Copy the code
Use the sample
Exactly the same as before.
@FrequencyLimit(value = "email:auth-code:{0}", parameters = "#{#createAndSendEmailAuthCodePO.email}", frequency = 1, time = 60)
@FrequencyLimit(value = "email:auth-code:{0}", parameters = "#{#createAndSendEmailAuthCodePO.email}", frequency = 5, cron = "0 0 0/1 * * ?" )
@Override
public Result<Void> createAndSendEmailAuthCode(CreateAndSendEmailAuthCodePO createAndSendEmailAuthCodePO) {
// Business code
}
Copy the code
Extension 4: Return failed key
Another problem with the above implementation is that it is not known which key the token failed to obtain. We can tell the Lua script to return the subscript for failure and -1 for success, and the Java code can use the subscript to get the specific frequency limiting operation that failed.
Lua code implementation
I just changed the return value to tokenMapSize and -1.
ARGV[#KEYS * 2 + I] ARGV[#KEYS * 2 + I] ARGV[#KEYS * 2 + I] ARGV[#KEYS * 2 + I] ARGV[#KEYS * 2 + I]
-- Record the successfully obtained key: token key pair
local tokenMap = {}
-- loop to obtain each token
for i = 1, #KEYS do
if ARGV[#KEYS * 2 + i] == 'FIXED_POINT_REFRESH' then
-- Reject the request directly if the frequency is 0
if tonumber(ARGV[i]) == 0 then
break
end
-- Obtain the number of tokens corresponding to the key
local tokenNumbers = redis.call('GET', KEYS[i])
-- If the corresponding key exists and the number of keys is greater than or equal to frequency, directly break
if tokenNumbers and tonumber(tokenNumbers) >= tonumber(ARGV[i]) then
break
end
-- Increase the number of tokens and set the expiration time
redis.call('INCR', KEYS[i])
redis.call('PEXPIRE', KEYS[i], ARGV[#KEYS + i])
tokenMap[KEYS[i]] = ' '
else
-- Get tokens for the key
local tokens = redis.call('SMEMBERS', KEYS[i])
-- Delete all expired tokens
local expiredTokensCount = 0
for j = 1, #tokens do
if not redis.call('GET', tokens[j]) then
redis.call('SREM', KEYS[i], tokens[j])
expiredTokensCount = expiredTokensCount + 1
end
end
-- If the number of unexpired tokens is greater than or equal to frequency, break the tokens directly
local unexpiredTokensCount = #tokens - expiredTokensCount
if unexpiredTokensCount >= tonumber(ARGV[i]) then
break
end
Create a unique token, add the token to the redis set, and set the expiration time
local token = redis.call('INCR'.'frequency-limit:token:increment-id')
redis.call('SET', token, ' '.'PX', ARGV[#KEYS + i])
redis.call('SADD', KEYS[i], token)
redis.call('PEXPIRE', KEYS[i], ARGV[#KEYS + i])
tokenMap[KEYS[i]] = token
end
end
-- Gets the size of the tokenMap
local tokenMapSize = 0
for key, token in pairs(tokenMap) do
tokenMapSize = tokenMapSize + 1
end
- Check whether all tokens are obtained successfully. If no, release the obtained tokens
if tokenMapSize < #KEYS then
for key, token in pairs(tokenMap) do
if token == ' ' then
redis.call('INCRBY', key, - 1)
else
redis.call('SREM', key, token)
redis.call('DEL', token)
end
end
return tokenMapSize
end
return - 1
Copy the code
Time complexity analysis
Range refresh frequency limiter
Since the SMEMBERS command needs to be executed every time, the time complexity is O(N), where N is the number of elements in the Set, and N = frequency in the worst case. Therefore, if the frequency is large, the speed of the scheme may be slow, which is suitable for the case where the frequency is not very large.
Refresh the frequency limiter at a fixed time point
The time complexity is O(1), only GET, INCR, PEXPIRE are used.