第1章:异步化改造——从2秒到200毫秒的蜕变

说实话,做风控系统最怕什么?

不是规则复杂,也不是模型不准。最怕的是——用户点了一下「提交」,然后盯着转圈圈等结果。2秒,在互联网时代,足够让用户关掉页面了。

我接手这个项目时,风控接口的P99响应时间是2.1秒。业务方天天抱怨,说「你们风控比银行还慢」。嗯,这话听着扎心,但确实是我们自己的问题。

1.1 同步调用的痛点

先看看原来的架构长什么样。说白了,就是一条线走到底:

用户请求 → 风控网关 → 规则引擎 → 模型服务 → 数据库 → 返回结果

每个环节都是同步阻塞的。规则引擎要等模型服务算完,模型服务要等数据库查完。我算过一笔账:

环节 耗时(ms) 说明
规则引擎 300-500 加载规则、匹配条件
模型服务 800-1200 特征计算、模型推理
数据库查询 200-400 历史数据、黑名单
其他开销 100-200 序列化、网络传输
总计 1400-2300 平均2秒左右

你看,模型服务占了将近一半的时间。但问题是——真的所有环节都需要同步等吗?

核心洞察:风控决策中,80%的请求只需要「通过/拒绝」两个结果。剩下的20%才需要详细的风险报告。但我们的系统对所有请求一视同仁,全都走完整链路。

1.2 异步化的思路

我当时的想法很简单:把「决策」和「记录」分开。

什么意思呢?

  • 同步路径:只做最核心的决策。规则引擎+轻量模型,200ms内给出结果。
  • 异步路径:把详细的特征计算、模型推理、数据落库扔到消息队列里,慢慢处理。

用户只需要等200ms拿到结果。至于后面的分析报告、风险归档,那是后台的事,用户不关心。

这里我画了一张改造后的架构图,你看一眼就明白了:

异步化风控架构 用户请求 同步决策(200ms) 规则引擎 + 轻量模型 返回结果 消息队列 异步处理 特征计算 / 模型推理 / 落库 数据库 / 归档 同步路径(200ms) 异步路径(后台处理) 异步消费者

1.3 消息队列选型

选型这事,我踩过坑。曾经用过RabbitMQ,结果业务量一上来,消息堆积到几百万,消费者根本来不及处理。

后来我总结了几个关键指标:

  • 吞吐量:风控系统日均处理千万级请求,单机TPS至少得5万+
  • 可靠性:消息丢了等于风控漏判,这是绝对不能接受的
  • 延迟:异步不代表无限延迟,后台处理最好在5秒内完成

最终我们选了RocketMQ。为什么?

  • 支持事务消息,保证最终一致性
  • 自带消息轨迹,排查问题方便
  • 社区活跃,遇到问题能找到人问

小技巧:消息队列的Topic设计要按业务维度拆分。比如「风控决策结果」「风控特征计算」「风控模型推理」各一个Topic。别把所有消息塞到一个Topic里,否则排查问题你会想哭。

1.4 代码实现要点

这里我贴一段核心代码,你看下消息生产和消费的写法:

// 生产者:同步决策后发送异步消息
public class RiskDecisionProducer {
    
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    
    public void sendAsyncTask(RiskContext context) {
        // 构造异步消息
        AsyncRiskMessage message = new AsyncRiskMessage();
        message.setRequestId(context.getRequestId());
        message.setUserId(context.getUserId());
        message.setFeatures(context.getBasicFeatures());
        message.setDecision(context.getDecision());
        
        // 发送到异步处理Topic
        rocketMQTemplate.asyncSend(
            "risk-async-process",
            message,
            new SendCallback() {
                @Override
                public void onSuccess(SendResult sendResult) {
                    log.info("异步消息发送成功, requestId: {}", context.getRequestId());
                }
                
                @Override
                public void onException(Throwable e) {
                    log.error("异步消息发送失败, requestId: {}", context.getRequestId(), e);
                    // 这里要告警,消息丢了是大问题
                }
            }
        );
    }
}

// 消费者:处理详细特征计算和模型推理
@RocketMQMessageListener(
    topic = "risk-async-process",
    consumerGroup = "risk-async-consumer"
)
public class AsyncRiskConsumer implements RocketMQListener<AsyncRiskMessage> {
    
    @Override
    public void onMessage(AsyncRiskMessage message) {
        try {
            // 1. 计算完整特征
            Map<String, Object> fullFeatures = featureService.computeFullFeatures(
                message.getUserId(), 
                message.getFeatures()
            );
            
            // 2. 执行完整模型推理
            RiskResult fullResult = modelService.fullInference(fullFeatures);
            
            // 3. 落库归档
            riskArchiveService.archive(message.getRequestId(), fullResult);
            
            log.info("异步处理完成, requestId: {}", message.getRequestId());
        } catch (Exception e) {
            log.error("异步处理异常, requestId: {}", message.getRequestId(), e);
            // 重试机制,最多重试3次
            throw e;
        }
    }
}

注意:消费者一定要做好幂等性处理。消息可能重复投递,同一个requestId被处理两次,会导致数据重复。我建议用数据库的唯一索引来防重。

1.5 效果与避坑

改造完成后,我们做了压测。结果很直观:

指标 改造前 改造后 提升
P99响应时间 2.1s 198ms 10.6倍
系统吞吐量 800 TPS 4500 TPS 5.6倍
服务器成本 20台 12台 节省40%

数字看着漂亮,但过程并不顺利。我踩过几个坑,分享给你:

  • 消息堆积:有一次双十一流量暴涨,消费者处理不过来,消息堆积到1000万。后来加了动态扩容策略,根据堆积量自动增加消费者实例。
  • 数据不一致:同步决策和异步结果偶尔对不上。原因是特征计算用了不同的数据源。解决方案是统一特征服务,同步和异步都用同一套API。
  • 监控缺失:刚开始没做消息延迟监控,出了问题半天才发现。后来加了Prometheus告警,消息延迟超过5秒就报警。

总结一下:异步化改造的核心不是技术,而是「取舍」。你要想清楚哪些环节可以异步,哪些必须同步。我的经验是——跟用户交互的路径必须同步,后台计算和归档全部异步。这样既保证了体验,又释放了系统压力。

嗯,这一章就聊到这儿。异步化改造看起来简单,但细节很多。下一章我们聊聊「规则引擎的冷热分离」,那个更有意思。


公众号:蓝海资料掘金营,微信deep3321