Design and Implementation of a Business Rate‑Limiting Component Using Redis Lua Scripts and Kotlin Annotations
This article explains how to design and implement a business-specific rate‑limiting component in Kotlin using Redis Lua scripts and Spring AOP annotations, covering the motivation, rule definitions, counting strategies, Lua script details, annotation creation, and a practical usage example.
1. Background
Why we need rate‑limiting: Our product heavily uses AI capabilities such as OCR and voice evaluation, which are costly in terms of money and resources, so the product layer requires limits on user usage.
Why we build our own component: Existing open‑source rate‑limiting solutions cannot satisfy the specific business requirements, so a custom implementation is necessary.
Other requirements: The limits must be adjustable in real time because most limit values are initially set as fixed numbers but often need to be changed later.
2. Idea
To build a simple business rate‑limiting component we need to implement the following work:
a. Rate‑limiting rules
Daily count (natural day)
Hourly count (natural hour)
Combined daily + hourly count with rollback logic
When the daily check passes but the hourly check fails, both counters must be rolled back; they cannot be counted separately.
b. Choice of counting method
MySQL + transaction : Persistent, traceable, but heavy and complex.
Redis + Lua : Simple to implement; Redis’s ability to execute Lua scripts satisfies transactional needs.
MySQL/Redis + distributed transaction : Requires locking, complex, but provides precise counting.
Because we do not need extremely precise technology and there is no persistence requirement, we choose Redis + Lua .
3. Specific Implementation
3.1 Rate‑limiting rule implementation
a. Daily / Hourly counting (shared Lua script)
The script increments a key if the current value is below the limit; otherwise it returns the current value without incrementing.
//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;Here KEYS[1] is the key for day or hour, ARGV[1] is the limit, and ARGV[2] is the expiration time.
b. Combined daily + hourly counting
This script handles both counters and performs rollback when one of the checks fails.
//lua script
local dayValue = 0;
local hourValue = 0;
local dayPass = true;
local hourPass = true;
local dayCurrentValue = redis.call('get', KEYS[1]);
if dayCurrentValue ~= false then
if tonumber(dayCurrentValue) < tonumber(ARGV[1]) then
dayValue = redis.call('INCR', KEYS[1]);
else
dayPass = false;
dayValue = tonumber(dayCurrentValue) + 1;
end
else
redis.call('set', KEYS[1], 1, 'px', ARGV[3]);
dayValue = 1;
end
local hourCurrentValue = redis.call('get', KEYS[2]);
if hourCurrentValue ~= false then
if tonumber(hourCurrentValue) < tonumber(ARGV[2]) then
hourValue = redis.call('INCR', KEYS[2]);
else
hourPass = false;
hourValue = tonumber(hourCurrentValue) + 1;
end
else
redis.call('set', KEYS[2], 1, 'px', ARGV[4]);
hourValue = 1;
end
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
local pair = {};
pair[1] = dayValue;
pair[2] = hourValue;
return pair;In this script KEYS[1] and KEYS[2] are the day and hour keys respectively; ARGV[1] and ARGV[2] are the day and hour limits; ARGV[3] and ARGV[4] are the expiration times.
3.2 Annotation implementation
a. Define @Detect annotation
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
annotation class Detect(
/**
* Event ID
*/
val eventId: String = "",
/**
* Content expression (SpEL)
*/
val contentSpel: String = ""
)b. Define the Aspect handler for @Detect
@Aspect
@Component
class DetectHandler {
private val logger = LoggerFactory.getLogger(javaClass)
@Autowired
private lateinit var detectManager: DetectManager
@Resource(name = "detectSpelExpressionParser")
private lateinit var spelExpressionParser: SpelExpressionParser
@Bean(name = ["detectSpelExpressionParser"])
fun detectSpelExpressionParser(): SpelExpressionParser {
return 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!")
}
// Parse SpEL expression
val expression = spelExpressionParser.parseExpression(detect.contentSpel)
val argMap = joinPoint.args.mapIndexed { index, any -> "arg${index+1}" to any }.toMap()
// Build evaluation context
val context = StandardEvaluationContext().apply {
if (argMap.isNotEmpty()) this.setVariables(argMap)
}
// Get content value
val content = expression.getValue(context)
// Perform rate‑limit check
detectManager.matchExceptionally(detect.eventId, content)
return joinPoint.proceed()
}
}4. Testing the Component
4.1 Usage with the annotation
After defining the annotation, a service method can be annotated as follows:
@Service
class OcrServiceImpl : OcrService {
@Autowired
private lateinit var detectManager: DetectManager
/**
* Submit OCR task with per‑user rate limiting
*/
@Detect(eventId = "ocr", contentSpel = "#arg1")
override fun submitOcrTask(userId: String, imageUrl: String): String {
// do ocr
}
}Debugging confirms that the annotation value is retrieved successfully and the SpEL expression is parsed correctly.
5. Reference
Original source: juejin.cn/post/7182774381448282172
6. Promotional Note (non‑technical)
The article ends with an invitation to join a backend‑focused technical community, encouraging developers and recruiters to share job referrals and industry discussions. This part is promotional and not related to the technical content.
Selected Java Interview Questions
A professional Java tech channel sharing common knowledge to help developers fill gaps. Follow us!
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.