Skip to content

上一篇《一次真实的服务器压测实战》的续章里,写接口的数据出现过一次反转:第一版脚本测出写接口 270 QPS、"快过所有读接口";修正方法重测后只剩 38 QPS,前后差了 7 倍。这篇把这次"数据打假"的完整过程单独成篇——从发现数字对不上,到翻源码定位防重逻辑,到设计实验验证拦截规律,最后修正压测方法拿到真实水位。压测数据里的"假绿"比压不动更危险:它不报错,只骗人

一、问题:三组对不上的数字

写接口压测(新增一条通知公告)跑完,把三份数据摆到一起,发现全对不上:

数据矛盾点
压测发出的写请求4034 个——
数据库实际落库只多出 5 条4000 多个请求去哪了?
压测工具报告errors = 0,全绿请求既没失败、又没生效?
同接口两档 QPS5 并发 134、10 并发 270线性扩展,看起来"很健康"

如果 4034 个请求都真的执行了 INSERT,数据库应该多出 4000 多条记录;只多 5 条,说明绝大多数请求根本没走到业务逻辑。但它们在压测工具眼里又全是"成功"的。两个事实只能有一个解释:有一层东西把请求拦下来了,而拦截响应在 HTTP 层面长得和成功响应一模一样。

二、排查:从对账到单发复现

排查分两步,每步都在收窄范围。

第一步:对账。用"请求数 vs 落库数"对账,确定请求是在进入业务处理之前被拦的——如果是业务处理了但数据没落库,那就是事务回滚问题,对账方向完全不同。

第二步:单发复现。用完全相同的参数连发两次请求,结果:

json
// 第 1 次:成功
{"code": 200, "msg": "操作成功"}

// 第 2 次(参数一个字节都没改):被拦
{"code": 500, "msg": "不允许重复提交,请稍候再试"}

注意两次的 HTTP 状态码都是 200,错误只藏在响应体的业务码里。定性完成:系统有防重复提交机制;它按"请求参数"判重;拦截返回 HTTP 200 + 业务码 500——而压测工具(autocannon、JMeter 的默认统计口径)只看 HTTP 状态码,所以 errors = 0。270 QPS 测的其实是"防重拦截"这条路径的速度,不是业务写入的速度。

三、源码定位:@RepeatSubmit 的完整链路

拿着报错文案"不允许重复提交"去翻源码,链路很快清楚了。

注解本身很简单,默认间隔 5 秒:

java
public @interface RepeatSubmit {
    /**
     * 间隔时间(ms),小于此时间视为重复提交
     */
    int interval() default 5000;
}

核心逻辑在切面里(简化后):

java
@Before("@annotation(repeatSubmit)")
public void doBefore(JoinPoint point, RepeatSubmit repeatSubmit) {
    long interval = repeatSubmit.timeUnit().toMillis(repeatSubmit.interval());

    String nowParams = argsArrayToString(point.getArgs());  // 参数序列化成 JSON
    String url = request.getRequestURI();
    String submitKey = request.getHeader(tokenName);          // 当前登录 token

    // 防重 key = url + md5(token + ":" + 参数JSON)
    String cacheRepeatKey = REPEAT_SUBMIT_KEY + url
            + SecureUtil.md5(submitKey + ":" + nowParams);

    // Redis SETNX:抢到占位就放行,抢不到就拦
    if (RedisUtils.setObjectIfAbsent(cacheRepeatKey, "", Duration.ofMillis(interval))) {
        KEY_CACHE.set(cacheRepeatKey);
    } else {
        throw new ServiceException("不允许重复提交,请稍候再试");
    }
}

配套的还有两个关键设计:

  • 业务成功后不删 key——保证 5 秒内无法重复提交(防重的本意);
  • 业务失败或抛异常后立即删 key——让用户改完错能马上重试(体验考虑)。

最后是"假绿"的临门一脚:ServiceException 被全局异常处理器接住,转成 R.fail(500) 正常返回——HTTP 状态码 200

java
@ExceptionHandler(ServiceException.class)
public R<Void> handleServiceException(ServiceException e) {
    return R.fail(e.getMessage());   // HTTP 200 + body 里的 code=500
}

整条链路画出来是这样的:

防重复提交请求链路与假绿成因

到这里,被过滤请求的规律已经能从源码直接推出来:同一个 token + 同一个 URL + 相同参数,5 秒窗口内只有第一个请求进业务,其余全部被拦。参数只要有一个字节不同,key 就不同,一个不拦。

四、控制变量实验:拦截规律实测

源码推演完,用一个三段式实验实测验证(控制变量:参数是否唯一、时间窗是否过期):

实验操作预期(源码推演)实测
同一参数 5 秒内连发 8 个1 个成功,7 个被拦1 成功 / 7 被拦 ✅
等 6 秒(窗口过期)后同参数重发放行放行 ✅
8 个参数各不相同的请求连发8 个全放行8/8 成功 ✅

被拦的 7 个请求响应完全一致:HTTP 200 + code: 500 + "不允许重复提交"。实验和源码完全对上,拦截规律实锤:

  • 被拦的不是"高频请求",是"同 token + 同参数的重复请求";
  • 只要参数有变化(哪怕只差一个时间戳),QPS 再高也一个不拦。

五、修正压测方法:为什么唯一参数才是正确姿势

修正前先想清楚一个问题:固定参数压测模拟的到底是什么场景?是"用户把同一张表单原样狂点提交"——这恰恰是防重校验设计出来要拦的病态行为。真实用户每次填的表单内容都不同(标题、内容、时间天然不同),所以每个请求用唯一参数不是"绕过校验",而是回到贴近真实用户行为的正确姿势——这也正是压测场景设计的第一原则。

修正过程中试了三代脚本,对比很能说明问题:

版本参数策略发出实际落库结论
v1所有请求共用 2 个固定标题40345(99.9% 被拦)测的是拦截路径,数据作废
v2autocannon 的 requests 模板轮换2943392(87% 被拦)部分失真,不能用
v3自写脚本,每请求标题带时间戳+序号17131713(0 被拦)可信

v2 值得多说一句:autocannon 支持在 requests 数组里放多个 body 轮换发送,看起来参数不同了,但多连接下模板会循环复用——5 秒窗口内同一个模板被不同连接重复发出,照样撞拦截。而且压测工具只统计 HTTP 状态码,被拦了多少根本数不出来。教训:涉及业务码判定的压测,要么工具支持响应体断言,要么自己写脚本逐条核对

v3 的自写脚本很朴素:原生 fetch + 固定并发数的 worker 循环,每个请求生成唯一标题,逐条核对响应体里的业务码,分别统计"业务成功 / 被拦截 / 网络失败"三类——十来行核心代码,换来的是完全可信的数据。

六、修正前后的数据对比

用 v3 方法重测(5/10/20 并发 × 15 秒),和 v1 的"假数据"放一起:

并发v1 拦截路径 QPS(假)v3 真实写入 QPS平均延迟P50P99
513437.6134ms123ms231ms
1027038.2263ms260ms447ms
20——38.4531ms552ms795ms

真实水位 ≈ 38 QPS,而且是一条教科书级的饱和曲线:并发翻 4 倍,QPS 纹丝不动,延迟恰好也翻 4 倍——请求都在排队,吞吐被写入链路的真实容量钉死

两个数字各测各的,都有意义,但别混着用:

  • 270 QPS(拦截路径):测的是"Redis SETNX 判重 + 抛异常返回"的速度——这条路径不碰数据库,当然快;
  • 38 QPS(真实写入):完整 INSERT 链路——参数校验、开启事务、ID 生成、防重占位、写库、写变更日志——这才是系统真实的写吞吐。

另外 38 QPS 这个数和工作台聚合接口(20 QPS)是同一档的,说明**"写一定比读慢"不成立,慢的从来是链路里的复杂度**——这条写入链路虽是单表,但事务 + 防重 + 日志的固定开销并不便宜。

七、结论:压测写接口的检查清单

这次的坑可以浓缩成一份写接口压测前的检查清单:

  1. 对账落库量:压完先数数据库,请求数 ≠ 业务成功数,对不上就是有拦截或回滚;
  2. 核对业务码:HTTP 200 ≠ 成功,逐条检查响应体里的业务码,别信工具默认的 errors 口径;
  3. 参数唯一化:每请求带时间戳/序号,模拟真实用户;固定参数测的是防重路径,不是业务路径;
  4. 分清两个指标:拦截路径 QPS(防重响应速度)和写入路径 QPS(业务吞吐)是两码事,各有价值但不能互相冒充;
  5. 留清理路径:标题带固定前缀(如【压测清理】),压完按前缀检索删除,测试数据零残留。

小结

这次排查最值的复盘的一点是:假绿不会报错,只会给出一个看起来合理的数字。270 QPS 线性扩展、errors=0,单看报告毫无破绽——直到和落库数对账才现了形。压测的可信度不取决于工具多专业,而取决于每个数字都能和系统的真实状态对上账

延伸阅读:防重、幂等这类"同一请求只执行一次"的问题,通用方案(数据库唯一键、Redis token、幂等表 + 状态机)在《接口幂等与防重提交》里有系统梳理;压测的主流程(场景设计、梯度纪律、带宽瓶颈定位)看前篇《一次真实的服务器压测实战》。


验证记录:文中拦截规律实验(同参数 8 发 1 成功 7 被拦、窗口过期放行、唯一参数 8/8 通过)与修正后三档压测(5/10/20 并发,0 拦截 0 失败,落库数与请求数完全对账)均为真实执行,测试数据已按前缀清理干净(全表回到压测前状态)。