Implementing Business Rate Limiting with Redis, Lua, and Kotlin Annotations
This article explains why a custom business rate‑limiting component is needed, outlines the required rules, chooses Redis + Lua for counting, and provides complete Kotlin/Spring code—including a Detect annotation, Lua scripts, and usage examples—to enforce daily, hourly, and combined limits with real‑time adjustments.
The author, a senior architect, introduces a business risk‑control (rate‑limiting) feature needed for AI‑heavy services such as OCR and voice evaluation, which consume significant resources and therefore require usage limits per user.
Background
Why risk control? To restrict costly AI calls and protect resources.
Why build a custom solution? Existing open‑source rate‑limiters cannot satisfy the specific business requirements, such as combined daily and hourly limits with rollback logic.
Other requirements include real‑time adjustable limits.
Design Idea
The component will implement three rule types:
Natural‑day count
Natural‑hour count
Combined day + hour count with rollback
After evaluating options (MySQL + transaction, Redis + Lua, distributed transactions), the author selects redis+lua for its simplicity and sufficient precision.
Rule Implementation
Lua scripts are used for counting. The first script handles simple daily or hourly limits:
-- 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;The second script handles the combined day + hour rule with rollback:
-- lua script for day+hour with rollback
local dayValue = 0;
local hourValue = 0;
local dayPass = true;
local hourPass = true;
-- day logic
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;
-- hour logic (similar)
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;
-- rollback handling
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};Invocation Method
A generic entry point DetectManager.matchExceptionally(eventId, content) is defined to invoke the rule matching and throw an exception when the limit is exceeded.
@Component
class DetectManager {
fun matchExceptionally(eventId: String, content: String) {
val rt = ruleService.match(eventId, content)
if (!rt) {
throw BaseException(ErrorCode.OPERATION_TOO_FREQUENT)
}
}
}Service methods call this manager before performing the actual business logic.
Annotation‑Based Approach
An annotation @Detect is introduced to declare the event ID and a SpEL expression for the content, allowing declarative rate‑limit enforcement.
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
annotation class Detect(
val eventId: String = "",
val contentSpel: String = ""
)The corresponding aspect parses the SpEL expression, builds a context with method arguments ( arg1, arg2, …), obtains the content, and calls DetectManager.matchExceptionally.
@Aspect
@Component
class DetectHandler {
@Autowired
private lateinit var detectManager: DetectManager
@Resource(name = "detectSpelExpressionParser")
private lateinit var spelExpressionParser: 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()
}
}Usage example in an OCR service:
@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
return "result"
}
}Testing
After applying the annotation, the system successfully retrieves the annotation value, parses the SpEL expression, and enforces the limit.
Conclusion
The article provides a complete, practical guide to building a customizable business rate‑limiting component using Redis + Lua for counting and Kotlin/Spring annotations for declarative usage, satisfying real‑time adjustability and combined day‑hour constraints.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Top Architect
Top Architect focuses on sharing practical architecture knowledge, covering enterprise, system, website, large‑scale distributed, and high‑availability architectures, plus architecture adjustments using internet technologies. We welcome idea‑driven, sharing‑oriented architects to exchange and learn 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.
