上一篇《一次真实的服务器压测实战》的续章里,写接口的数据出现过一次反转:第一版脚本测出写接口 270 QPS、"快过所有读接口";修正方法重测后只剩 38 QPS,前后差了 7 倍。这篇把这次"数据打假"的完整过程单独成篇——从发现数字对不上,到翻源码定位防重逻辑,到设计实验验证拦截规律,最后修正压测方法拿到真实水位。压测数据里的"假绿"比压不动更危险:它不报错,只骗人。
一、问题:三组对不上的数字
写接口压测(新增一条通知公告)跑完,把三份数据摆到一起,发现全对不上:
| 数据 | 值 | 矛盾点 |
|---|---|---|
| 压测发出的写请求 | 4034 个 | —— |
| 数据库实际落库 | 只多出 5 条 | 4000 多个请求去哪了? |
| 压测工具报告 | errors = 0,全绿 | 请求既没失败、又没生效? |
| 同接口两档 QPS | 5 并发 134、10 并发 270 | 线性扩展,看起来"很健康" |
如果 4034 个请求都真的执行了 INSERT,数据库应该多出 4000 多条记录;只多 5 条,说明绝大多数请求根本没走到业务逻辑。但它们在压测工具眼里又全是"成功"的。两个事实只能有一个解释:有一层东西把请求拦下来了,而拦截响应在 HTTP 层面长得和成功响应一模一样。
二、排查:从对账到单发复现
排查分两步,每步都在收窄范围。
第一步:对账。用"请求数 vs 落库数"对账,确定请求是在进入业务处理之前被拦的——如果是业务处理了但数据没落库,那就是事务回滚问题,对账方向完全不同。
第二步:单发复现。用完全相同的参数连发两次请求,结果:
// 第 1 次:成功
{"code": 200, "msg": "操作成功"}
// 第 2 次(参数一个字节都没改):被拦
{"code": 500, "msg": "不允许重复提交,请稍候再试"}注意两次的 HTTP 状态码都是 200,错误只藏在响应体的业务码里。定性完成:系统有防重复提交机制;它按"请求参数"判重;拦截返回 HTTP 200 + 业务码 500——而压测工具(autocannon、JMeter 的默认统计口径)只看 HTTP 状态码,所以 errors = 0。270 QPS 测的其实是"防重拦截"这条路径的速度,不是业务写入的速度。
三、源码定位:@RepeatSubmit 的完整链路
拿着报错文案"不允许重复提交"去翻源码,链路很快清楚了。
注解本身很简单,默认间隔 5 秒:
public @interface RepeatSubmit {
/**
* 间隔时间(ms),小于此时间视为重复提交
*/
int interval() default 5000;
}核心逻辑在切面里(简化后):
@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:
@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 个固定标题 | 4034 | 5(99.9% 被拦) | 测的是拦截路径,数据作废 |
| v2 | autocannon 的 requests 模板轮换 | 2943 | 392(87% 被拦) | 部分失真,不能用 |
| v3 | 自写脚本,每请求标题带时间戳+序号 | 1713 | 1713(0 被拦) | 可信 |
v2 值得多说一句:autocannon 支持在 requests 数组里放多个 body 轮换发送,看起来参数不同了,但多连接下模板会循环复用——5 秒窗口内同一个模板被不同连接重复发出,照样撞拦截。而且压测工具只统计 HTTP 状态码,被拦了多少根本数不出来。教训:涉及业务码判定的压测,要么工具支持响应体断言,要么自己写脚本逐条核对。
v3 的自写脚本很朴素:原生 fetch + 固定并发数的 worker 循环,每个请求生成唯一标题,逐条核对响应体里的业务码,分别统计"业务成功 / 被拦截 / 网络失败"三类——十来行核心代码,换来的是完全可信的数据。
六、修正前后的数据对比
用 v3 方法重测(5/10/20 并发 × 15 秒),和 v1 的"假数据"放一起:
| 并发 | v1 拦截路径 QPS(假) | v3 真实写入 QPS | 平均延迟 | P50 | P99 |
|---|---|---|---|---|---|
| 5 | 134 | 37.6 | 134ms | 123ms | 231ms |
| 10 | 270 | 38.2 | 263ms | 260ms | 447ms |
| 20 | —— | 38.4 | 531ms | 552ms | 795ms |
真实水位 ≈ 38 QPS,而且是一条教科书级的饱和曲线:并发翻 4 倍,QPS 纹丝不动,延迟恰好也翻 4 倍——请求都在排队,吞吐被写入链路的真实容量钉死。
两个数字各测各的,都有意义,但别混着用:
- 270 QPS(拦截路径):测的是"Redis SETNX 判重 + 抛异常返回"的速度——这条路径不碰数据库,当然快;
- 38 QPS(真实写入):完整 INSERT 链路——参数校验、开启事务、ID 生成、防重占位、写库、写变更日志——这才是系统真实的写吞吐。
另外 38 QPS 这个数和工作台聚合接口(20 QPS)是同一档的,说明**"写一定比读慢"不成立,慢的从来是链路里的复杂度**——这条写入链路虽是单表,但事务 + 防重 + 日志的固定开销并不便宜。
七、结论:压测写接口的检查清单
这次的坑可以浓缩成一份写接口压测前的检查清单:
- 对账落库量:压完先数数据库,请求数 ≠ 业务成功数,对不上就是有拦截或回滚;
- 核对业务码:HTTP 200 ≠ 成功,逐条检查响应体里的业务码,别信工具默认的 errors 口径;
- 参数唯一化:每请求带时间戳/序号,模拟真实用户;固定参数测的是防重路径,不是业务路径;
- 分清两个指标:拦截路径 QPS(防重响应速度)和写入路径 QPS(业务吞吐)是两码事,各有价值但不能互相冒充;
- 留清理路径:标题带固定前缀(如【压测清理】),压完按前缀检索删除,测试数据零残留。
小结
这次排查最值的复盘的一点是:假绿不会报错,只会给出一个看起来合理的数字。270 QPS 线性扩展、errors=0,单看报告毫无破绽——直到和落库数对账才现了形。压测的可信度不取决于工具多专业,而取决于每个数字都能和系统的真实状态对上账。
延伸阅读:防重、幂等这类"同一请求只执行一次"的问题,通用方案(数据库唯一键、Redis token、幂等表 + 状态机)在《接口幂等与防重提交》里有系统梳理;压测的主流程(场景设计、梯度纪律、带宽瓶颈定位)看前篇《一次真实的服务器压测实战》。
验证记录:文中拦截规律实验(同参数 8 发 1 成功 7 被拦、窗口过期放行、唯一参数 8/8 通过)与修正后三档压测(5/10/20 并发,0 拦截 0 失败,落库数与请求数完全对账)均为真实执行,测试数据已按前缀清理干净(全表回到压测前状态)。
