一、背景

1. 为什么要做风控?
这不得拜产品大佬所赐。目前我们业务里用到了不少 AI 能力,比如 OCR 识别、语音测评等等,这些能力要么费钱、要么费资源。所以产品层面也希望给用户的能力使用次数做一定限制,风控自然就成了刚需。
2. 为什么要自己写风控?
那么多开源的风控组件,为什么还要自己写呢?是不是想重复发明轮子呀。
要回答这个问题,得先弄清楚我们业务需要的风控(简称业务风控)和常见开源风控(简称普通风控)之间的区别:

因此,直接套用开源的普通风控,一般情况下是满足不了需求的。
3. 其它要求
支持实时调整限制。不少限制值在第一次设置时基本都是拍脑袋定的,后续调整的可能性很大,所以“可调整 + 实时生效”是必须的。
二、思路
要实现一个简单的业务风控组件,需要做哪些事情?
1. 风控规则的实现
a. 需要实现的规则:
- 自然日计数
- 自然小时计数
- 自然日 + 自然小时计数
自然日 + 自然小时计数不能简单地串联两个判断。因为如果自然日判定通过了,自然小时判定没通过,需要回退——自然日和自然小时都不能计入本次调用。
b. 计数方式的选择:
目前能想到的方案主要有:
- mysql + db 事务:持久化、记录可溯源,但实现麻烦,整体偏“重”
- redis+lua:实现简单,Redis 可执行 Lua 脚本的特性也能满足对“事务”的要求
- mysql/redis + 分布式事务:需要上锁,实现复杂,但能做到比较精确的计数,也就是真正等代码块执行成功之后再操作计数
目前没有很精确的计数要求,代价太大,也没有持久化需求,所以选用 redis+lua 即可。
2. 调用方式的实现
a. 常见做法
先定义一个通用入口:
// 简化版代码
@Component
class DetectManager {
fun matchExceptionally(eventId: String, content: String) {
// 调用规则匹配
val rt = ruleService.match(eventId, content)
if (!rt) {
throw BaseException(ErrorCode.OPERATION_TOO_FREQUENT)
}
}
}
在 Service 中调用该方法:
// 简化版代码
@Service
class OcrServiceImpl : OcrService {
@Autowired
private lateinit var detectManager: DetectManager
/**
* 提交 OCR 任务
* 需要根据用户 id 来做次数限制
*/
override fun submitOcrTask(userId: String, imageUrl: String): String {
detectManager.matchExceptionally("ocr", userId)
// do ocr
}
}
有没有更优雅一点的方法?用注解可能会更好一些(这个也比较有争议,这边先支持实现)。
因为传入的 content 跟业务关联,所以需要通过 SpEL 将参数组装成对应的 content。
三、具体实现
1. 风控计数规则实现
a. 自然日 / 自然小时
自然日和自然小时可以共用一套 Lua 脚本,因为它们只是 key 不同。脚本如下:
-- 自然日 / 自然小时计数脚本
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;
其中 KEYS[1] 是日/小时关联的 key,ARGV[1] 是上限值,ARGV[2] 是过期时间,返回值是当前计数值 +1 后的结果(如果已经达到上限,实际上不会计数)。
b. 自然日 + 自然小时
如前文提到的,两者结合并不是单纯拼凑,还要处理回退逻辑。
-- 自然日 + 自然小时组合计数脚本(含回退逻辑)
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;
其中 KEYS[1] 是天关联生成的 key,KEYS[2] 是小时关联生成的 key;ARGV[1] 是天的上限值,ARGV[2] 是小时的上限值;ARGV[3] 是天的过期时间,ARGV[4] 是小时的过期时间,返回值同上。
这里给的是比较粗糙的写法,主要想表达的是:进行两个条件判断时,只要其中一个不满足,另一个都需要回退。
a. 定义一个 @Detect 注解
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
annotation class Detect(
/**
* 事件 id
*/
val eventId: String = "",
/**
* content 的 SpEL 表达式
*/
val contentSpel: String = ""
)
其中 content 是需要经过表达式解析出来的,所以接收的是 String。
b. 定义 @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(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()
}
}
需要把参数放进上下文中,并命名成 arg1、arg2 ...
四、测试一下
1. 写法
使用注解之后的写法简洁多了:
// 简化版代码
@Service
class OcrServiceImpl : OcrService {
@Autowired
private lateinit var detectManager: DetectManager
/**
* 提交 OCR 任务
* 需要根据用户 id 来做次数限制
*/
@Detect(eventId = "ocr", contentSpel = "#arg1")
override fun submitOcrTask(userId: String, imageUrl: String): String {
// do ocr
}
}
2. Debug 看看

整个组件就是这么简单:计数交给 Redis + Lua,调用方式交给注解 + AOP,业务代码里几乎零侵入。如果大家还有别的风控实现思路,也欢迎到 云栈社区 一起交流。