订单风险前置校验:把风险挡在门外

做交易系统这么多年,我有个很深的体会:风险控制,越早做越好。等订单进了核心交易链路再拦截,就像火都烧到厨房了你才去找灭火器——晚了。

今天聊的这三个校验,说白了就是三道门岗。订单还没进交易大厅,先在外面过一遍筛子。我习惯叫它「前置校验三件套」:金额阈值、频率限制、重复检测。

一、金额阈值校验:别让大单砸懵系统

金额阈值校验,名字听着挺唬人,其实逻辑很简单——超出设定范围的订单,直接拦下

我在项目中遇到过一件事:某次促销活动,系统突然涌入一批金额异常高的订单。正常客单价也就200块,结果来了几笔单笔50万的。后来一查,是某个渠道的接口参数传错了。你说这要是没拦住,直接进到清算环节,那麻烦就大了。

校验维度

  • 单笔金额上限:比如单笔不超过10万
  • 单笔金额下限:比如不低于0.01元(防止刷单)
  • 累计金额上限:比如单个用户当日累计不超过100万

核心原则:阈值不是拍脑袋定的,要结合业务场景和历史数据。我一般会拉过去90天的交易数据,取P99.9分位值作为上限参考。

代码示例

public class AmountThresholdValidator {
    
    // 配置化:从配置中心动态获取
    private static final double MAX_SINGLE_AMOUNT = 100000.00;
    private static final double MIN_SINGLE_AMOUNT = 0.01;
    
    public ValidationResult validate(Order order) {
        // 单笔上限校验
        if (order.getAmount() > MAX_SINGLE_AMOUNT) {
            return ValidationResult.fail("订单金额超过上限");
        }
        // 单笔下限制校验
        if (order.getAmount() < MIN_SINGLE_AMOUNT) {
            return ValidationResult.fail("订单金额低于下限");
        }
        return ValidationResult.success();
    }
}

小技巧:阈值别写死在代码里。用配置中心(比如Apollo、Nacos)动态下发,这样调整阈值不用重启服务。我曾经因为硬编码吃过亏,后来就学乖了。

二、订单频率限制:别让刷子把系统刷崩

频率限制,说白了就是控制单位时间内的请求次数。为什么需要这个?你想想看,正常用户谁会一秒下10单?

我见过最夸张的一次,某个用户用脚本在1秒内下了2000单。要不是频率限制拦住了,数据库连接池直接被打满,整个交易链路都得瘫痪。

常见的限流策略

策略 原理 适用场景
固定窗口 统计固定时间窗口内的请求数 简单场景,实现容易
滑动窗口 按时间粒度滑动统计 精度要求高的场景
令牌桶 匀速发放令牌,有令牌才能请求 突发流量场景
漏桶 固定速率处理请求 流量整形场景

我个人习惯用滑动窗口 + 令牌桶的组合。滑动窗口做第一道防线,令牌桶做第二道。为什么?滑动窗口能精确控制时间粒度,令牌桶能应对突发流量。两者互补,效果不错。

频率限制的维度

  • 用户维度:单个用户每秒/每分钟最多N单
  • IP维度:单个IP每秒最多N次请求
  • 设备维度:单个设备ID每分钟最多N单
  • 全局维度:系统整体每秒最多处理N单

注意:频率限制的阈值设置要留有余量。比如系统能扛1000 TPS,你限流设成800就行。别卡着上限设,万一有突发流量,系统容易被打穿。

三、订单重复性检测:别让同一笔订单被处理两次

重复订单,是交易系统里最头疼的问题之一。用户多点了一下「提交」,网络抖动导致重试,消息队列重复投递——这些都会导致同一笔订单被创建多次。

我记得有一次线上事故,用户下单买一台手机,结果系统给他创建了3个订单。用户投诉说「我就买一台,你们扣了我三台的钱」。嗯,那天的值班日志写得特别长。

幂等性设计

解决重复订单的核心思路是幂等性——同一笔请求,无论处理多少次,结果都一样。

实现方案

  1. 唯一键约束:用订单号或业务流水号作为数据库唯一索引
  2. 分布式锁:处理前先加锁,处理完释放
  3. 状态机校验:订单状态只能单向流转,已处理的订单不再处理

代码示例

public class DuplicateOrderDetector {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    public boolean isDuplicate(String orderNo) {
        // 使用Redis的SETNX命令实现分布式锁
        // key: order:duplicate:{orderNo}
        // value: 当前时间戳
        // 过期时间: 24小时
        String key = "order:duplicate:" + orderNo;
        Boolean result = redisTemplate.opsForValue()
            .setIfAbsent(key, String.valueOf(System.currentTimeMillis()), 
                         24, TimeUnit.HOURS);
        // 如果返回false,说明key已存在,订单重复
        return !Boolean.TRUE.equals(result);
    }
}

避坑指南:我曾经用数据库唯一索引做重复检测,结果高并发下频繁报主键冲突异常。后来改成Redis的SETNX,性能提升了一个数量级。记住:重复检测要放在缓存层做,别直接怼数据库

四、三者如何协同工作

这三个校验不是各自为战的。我一般会设计一个前置校验管道,按顺序执行:

  1. 先做金额阈值校验(计算量最小,最快)
  2. 再做重复性检测(需要查缓存,稍慢)
  3. 最后做频率限制(需要统计计数,最慢)

为什么这么排?你想想看,如果金额都不对,后面两个校验就没必要做了。这叫快速失败,能省不少资源。

五、核心逻辑流程图

下面这张图,是我自己画的前置校验流程。你看一眼就明白了:

订单风险前置校验流程图 订单到达 ① 金额阈值校验 单笔上限/下限/累计上限 通过? 拦截:金额异常 ② 订单重复性检测 Redis SETNX / 唯一键约束 重复? 拦截:重复订单 ③ 频率限制 滑动窗口+令牌桶

这张图里,三个校验是串行执行的。每个校验失败,订单直接进入「拦截」分支。只有三个都通过,订单才能进入后续的交易链路。

总结一下:金额阈值校验防大单、频率限制防刷单、重复检测防重单。这三道门岗守住了,交易系统的稳定性就有了基本保障。

嗯,前置校验这块,说白了就是把简单的事情做到极致。别小看这三个校验,很多线上事故都是因为「觉得太简单没做」导致的。我见过太多案例了,教训深刻啊。

最后说一句:这三个校验的日志一定要打全。哪天线上出问题了,查日志的时候你就知道这些日志有多重要了。我曾经因为少打了一行日志,排查问题花了整整一个通宵……


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