Design and Implementation of a Business Rate‑Limiting Component Using Redis Lua Scripts and Custom Annotations in Kotlin
This article explains why a custom business risk‑control (rate‑limiting) component is needed for AI‑intensive services, compares open‑source solutions, and provides a complete backend implementation using Redis+Lua scripts, Kotlin code, Spring AOP annotations, and real‑time adjustable rules.
The author, a Java/Kotlin architect, introduces the need for a business‑level risk‑control component because AI services such as OCR and voice evaluation are costly, and the product requires per‑user usage limits.
1. Background
Why risk control? To restrict the number of times expensive AI capabilities are invoked.
Why build our own? Existing open‑source rate‑limiters do not satisfy the specific business requirements, especially the need for combined daily and hourly limits with rollback logic.
Other requirements include real‑time adjustable limits, as the initial thresholds often need to be tuned after deployment.
2. Design Idea
The component must implement three kinds of rules:
Natural‑day count
Natural‑hour count
Combined day‑and‑hour count (with rollback when one condition fails)
After evaluating implementation options (MySQL + transaction, Redis + Lua, distributed transactions), the author chooses Redis+Lua for its simplicity and sufficient accuracy.
3. Concrete Implementation
3.1 Rate‑limit rules
a. Day/Hour rules share a single Lua script because they differ only by the key used:
// lua script
local currentValue = redis.call('get', KEYS[1]);
if currentValue ~= false then
if tonumber(currentValue) < tonumber(ARGV[1]) then
return redis.call('INCR', KEYS[1])
else
return tonumber(currentValue) + 1
end;
else
redis.call('set', KEYS[1], 1, 'px', ARGV[2]);
return 1;
end;Arguments:
KEYS[1] : the Redis key for the day or hour counter
ARGV[1] : the maximum allowed count
ARGV[2] : expiration time (in milliseconds)
b. Combined day‑and‑hour rule requires a more complex script that updates both counters and rolls back the other when one limit is exceeded:
// lua script (simplified)
local dayValue = 0; local hourValue = 0;
local dayPass = true; local hourPass = true;
-- day logic
local dayCurrent = redis.call('get', KEYS[1]);
if dayCurrent ~= false then
if tonumber(dayCurrent) < tonumber(ARGV[1]) then
dayValue = redis.call('INCR', KEYS[1])
else
dayPass = false; dayValue = tonumber(dayCurrent) + 1;
end;
else
redis.call('set', KEYS[1], 1, 'px', ARGV[3]); dayValue = 1;
end;
-- hour logic (similar)
local hourCurrent = redis.call('get', KEYS[2]);
if hourCurrent ~= false then
if tonumber(hourCurrent) < tonumber(ARGV[2]) then
hourValue = redis.call('INCR', KEYS[2])
else
hourPass = false; hourValue = tonumber(hourCurrent) + 1;
end;
else
redis.call('set', KEYS[2], 1, 'px', ARGV[4]); hourValue = 1;
end;
-- rollback when one fails
if (not dayPass) and hourPass then
hourValue = redis.call('DECR', KEYS[2]);
end;
if dayPass and (not hourPass) then
dayValue = redis.call('DECR', KEYS[1]);
end;
return {dayValue, hourValue};Arguments:
KEYS[1] : day counter key
KEYS[2] : hour counter key
ARGV[1] : day limit
ARGV[2] : hour limit
ARGV[3] : day expiration
ARGV[4] : hour expiration
3.2 Annotation‑based usage
A custom @Detect annotation is defined to declare the event ID and a SpEL expression that extracts the content used for limiting:
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
annotation class Detect(
val eventId: String = "",
val contentSpel: String = ""
)The corresponding Aspect DetectHandler parses the SpEL expression, builds a context with method arguments (named arg1 , arg2 , …), obtains the content value, and calls detectManager.matchExceptionally(eventId, content) before proceeding with the original method.
@Aspect
@Component
class DetectHandler {
@Autowired private lateinit var detectManager: DetectManager
@Resource(name = "detectSpelExpressionParser")
private lateinit var spelExpressionParser: SpelExpressionParser
@Bean(name = ["detectSpelExpressionParser"])
fun detectSpelExpressionParser() = SpelExpressionParser()
@Around("@annotation(detect)")
fun operatorAnnotation(joinPoint: ProceedingJoinPoint, detect: Detect): Any? {
if (detect.eventId.isBlank() || detect.contentSpel.isBlank()) {
throw illegalArgumentExp("@Detect config is not available!")
}
val expression = spelExpressionParser.parseExpression(detect.contentSpel)
val argMap = joinPoint.args.mapIndexed { index, any -> "arg${index+1}" to any }.toMap()
val context = StandardEvaluationContext().apply { if (argMap.isNotEmpty()) setVariables(argMap) }
val content = expression.getValue(context)
detectManager.matchExceptionally(detect.eventId, content)
return joinPoint.proceed()
}
}3.3 Service usage example
After adding the annotation, the OCR service method becomes:
@Service
class OcrServiceImpl : OcrService {
@Autowired private lateinit var detectManager: DetectManager
@Detect(eventId = "ocr", contentSpel = "#arg1")
override fun submitOcrTask(userId: String, imageUrl: String): String {
// do ocr
}
}Debugging confirms that the annotation value is retrieved and the SpEL expression is evaluated correctly.
4. Conclusion
The article demonstrates a practical backend solution for business‑level rate limiting, combining Redis Lua scripts for atomic counters with a Spring‑AOP annotation to keep the business code clean and configurable.
Java Architect Essentials
Committed to sharing quality articles and tutorials to help Java programmers progress from junior to mid-level to senior architect. We curate high-quality learning resources, interview questions, videos, and projects from across the internet to help you systematically improve your Java architecture skills. Follow and reply '1024' to get Java programming resources. Learn together, grow together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.