Design a Universal, Rock‑Solid Risk‑Control System with Redis‑Lua and Kotlin Annotations
This article explains why a custom risk‑control component is needed for AI‑heavy services, outlines the requirement for real‑time adjustable limits, and walks through a Redis‑Lua based implementation of daily, hourly, and combined counters together with a Kotlin @Detect annotation for seamless integration.
Background
Our product relies heavily on AI capabilities such as OCR and voice evaluation, which are costly in terms of money and resources. To prevent abuse, we need a risk‑control (rate‑limiting) component that restricts the number of times a user can invoke these capabilities.
Why Build Our Own
Existing open‑source rate‑limiting libraries address generic scenarios and cannot satisfy our specific business requirements, which we refer to as “business risk control”. They lack real‑time adjustable limits and the combined daily‑hourly logic we need.
Requirements
Support real‑time adjustment of limits.
Count per natural day, per natural hour, and a combined day‑hour rule that rolls back the other counter when one condition fails.
Design Approach
We choose Redis + Lua scripts because they provide simple, atomic operations without the overhead of persistent databases. The scripts implement the three counting rules and handle the rollback logic.
Rule Implementation
Daily and hourly counters share a Lua script that increments the key only if the current value is below the configured limit.
//lua脚本
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 combined day‑hour rule uses two keys and four arguments (day limit, hour limit, day TTL, hour TTL). It increments both counters, then rolls back the other when one limit is exceeded.
//lua脚本
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;Invocation Method
A simple Kotlin component DetectManager calls the rule service and throws 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)
}
}
}Annotation‑Based Usage
Define a @Detect annotation to mark service methods. An Aspect intercepts the annotation, parses the SpEL expression, builds a context with method arguments (arg1, arg2, …), and invokes DetectManager.
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
annotation class Detect(
/** 事件id */
val eventId: String = "",
/** content的表达式 */
val contentSpel: String = ""
) @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(value = "@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()) this.setVariables(argMap)
}
//拿到结果
val content = expression.getValue(context)
detectManager.matchExceptionally(detect.eventId, content)
return joinPoint.proceed()
}
}Testing
Running the annotated OCR service shows that the annotation value is retrieved and the SpEL expression is evaluated correctly.
Annotation value obtained successfully.
Expression parsed successfully.
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.
Architect's Guide
Dedicated to sharing programmer-architect skills—Java backend, system, microservice, and distributed architectures—to help you become a senior architect.
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.
