压测全绿的假象:0% 错误率的系统与 17.2% 成功率的业务

压测全绿的假象:0% 错误率的系统与 17.2% 成功率的业务

高并发票务系统中由压测引发的一场架构思考

前言

以前写过一个仿照大麦的票务系统作为实验室考核任务,当时用 JMeter 进行 1000 并发的压测,得到 0% 错误率后就满意离场了。隔了好久重新捡起这个项目想做点优化,没想到随手做的一次压测,又让我陷入了新的思考。这篇文章并不提供终极解决方案,主要是记录我个人的踩坑和推演过程。实际上我也不知道这个解法是否正确,说不定一年后我又发现业务逻辑里藏的惊天 Bug 了

一、最初的完美假象:MQ + Redis 防超卖架构

在这个项目中,我采用了非常经典的 Redis 缓存预热和 RabbitMQ 削峰架构:

  1. 用户发起抢票请求,抢票消息通过 MQ 异步发送,消费者从 MQ 中获取消息
  2. Redis 进行预扣减库存,然后将订单落库到 MySQL
  3. 如果抢票失败,那就 @Transactional 保证数据库数据一致性 + 手动添加回 Redis 缓存中的票数来完成回滚操作

理论上,这套架构应该能完美承接高并发流量;事实上,它也确实承接住了高并发流量。

二、JMeter 测试结果:0% 错误率下的异常

然而,当我看到压测报告时,我感到了一丝疑惑。在库存充足(10000 张)的情况下,系统表现出了完美的健壮性,也暴露了灾难性的业务问题:

  • 系统错误率:0%
  • 数据库超卖情况:0
  • MQ 积压:0
  • 成功下单数:仅 172 单?

如果是一年前,我并没有觉得这是一个问题,系统没崩,响应时间都在 200ms 内,看起来不是非常完美吗?

只不过这一次,我是从并发量 5 开始测的。当发现 5 人中仅有 1 人抢到票时,我意识到这不太对劲。明明库存里还有票,用户一抢居然就提示"已售空"。

所以 JMeter 看到的 0.00% Error 其实只是接口维度的假象。因为业务异常被捕获并 ACK 了,所以 JMeter 认为 HTTP 200 就是成功,而真正要看的核心指标是业务成功率。

三、架构设计缺陷

之后我思考了整个抢票流程,查看了控制台日志,发现 5 个人同时抢票时,由于乐观锁的存在,他们之中只有一个人成功修改了版本号,其他人在更新失败后就抛出了"已售空"的错误信息。总结下来,这个方案存在以下两个致命的缺陷:

缺陷一:数据库乐观锁成为并发瓶颈

为了防止超卖,我给数据库加上了乐观锁,这意味着同一时间只能有 1 个线程可以成功执行数据库的版本号修改。Redis 扣减成功后,所有被 MQ 消费的请求都带着相同的 currentVersion 并发更新数据库。而当多个请求同时读取到同一个版本号时,只会有第一个请求更新成功,其余的请求都会因为版本号冲突返回 dbSuccess = false。我的代码会在失败后直接回滚 Redis 并抛出"已售空",导致大量用户被误拦截。高并发优势完全失效,数据库同一行库存的更新被串行化,系统吞吐受限于单行更新能力。

缺陷二:业务提示误导用户

同时代码中对拿不到锁的请求,直接统一返回了"已售罄"。从用户角度来看,库存明明还有,但系统却告诉我没票了,这完全是业务语义的错误。在高并发场景下,拿不到锁应该返回"系统繁忙,请排队",而不是直接宣判没票。一个是系统问题,一个是业务结论,两者完全不能混为一谈。

四、代码复盘:为什么消息被"抛弃"了?

这里贴出当初写的消费者代码,很清楚地看到当初图方便的我直接把处理过的消息做了确认然后删除:

java 复制代码
try {
    boolean success = ticketGrabbingService.grabTicket(grabTicketRequest, message.getUserId());
    if (success) {
        redisTemplate.opsForValue().set(resultKey, "Processed", 30, TimeUnit.MINUTES);
    }

    channel.basicAck(deliveryTag, false);

    log.info("抢票处理完成: requestId={}", message.getRequestId());

} catch (BusinessException b) {
    // 不管成不成功我都直接删去 不做批量处理
    channel.basicAck(deliveryTag, false);
    redisTemplate.opsForValue().set(resultKey, "Processing failed", 30, TimeUnit.MINUTES);
    // 抛出业务异常
    throw b;
} catch (Exception e) {
    // 进入死信队列
    channel.basicNack(deliveryTag, false, false);
    log.error("处理抢票请求异常: requestId={}, 消息进入死信队列:", message.getRequestId(), e);
    redisTemplate.opsForValue().set(resultKey, "Processing failed", 30, TimeUnit.MINUTES);
    throw e;
}

也就是说,用户发送的抢票请求,就这样被乐观锁卡住,抛出错误信息后就被我丢弃了。更关键的是,业务异常也执行了 basicAck,消息已经确认,不会重试,也不会进入死信队列。

所以测试中 MQ 积压 0 并不代表健康,反而说明失败消息被直接 ack 掉了。再结合我设置的 MQ 动态并发范围 5~10 以及 172/1000 的成功率,一切都水落石出:这是低消费并发、单行库存竞争、失败即 ack 丢弃共同导致的结果。

五、结合旧策略的新思考:从 MQ -> Redis -> SQL 到 MQ -> Redis -> MQ -> SQL

方案一:重试机制

  • 思路: 一开始我很自然地想到加重试机制:第一次没抢到就等待 1s 再次操作,3 次重试失败后丢弃消息。
  • 问题: 但这样依旧无法保证业务正确性,单纯的重试只能提高总体业务成功率,却无法解决根本缺陷,反而还进一步压力了数据库。

然后我又想到了

方案二:串行消费

  • 思路: 让同一个票档的消息排成一队,一个处理完再处理下一个。这样确实不会乐观锁冲突,因为后一个消息读到的永远是前一个扣完后的最新版本。

  • 问题: 不过这样会迎来新的问题:如果一个票档有几千张票,抢票高峰时,所有请求都挤在同一个队列里,一个接一个扣减,吞吐量会被单消费者死死卡住。用户可能要排队很久,看着加载动画的圈圈转了一遍又一遍,完全没有考虑到用户的使用体验,是一个很差劲的设计。

于是我又想到了另一个方法

方案三:异步落库

  • 思路: 更准确地说,原架构其实已经是异步落库了。所以真正要解决的是:不要让所有请求并发更新同一行数据库库存。思路可以调整为在 Redis 预扣减后,再次通过 RabbitMQ 进行削峰,统一给用户返回"排队中",等到数据库真正完成库存扣减,再返回"抢票成功"。

  • 问题: 遗憾的是,这种解法也存在问题:如果两条消息同时抵达,两个消费者线程同时操作数据库,依旧会遇到和原来一样的问题

最终版本:Redis 准入

前文提到,旧架构的致命伤在于把数据库当成了抢票的最终场所,所有请求涌入数据库,靠乐观锁去决定谁赢,从而导致了大量的失败消息被丢弃。

既然 Redis 的预扣减本身就是原子性的,我决定把判定谁抢到了票完全交给 Redis。这样以 Redis 预扣减得到的用户为准,记录 user_id 然后通过 MQ 异步发送给数据库,这些用户再进行串行消费 + 重试机制,让数据库慢慢处理库存扣减消息,确保全部落库。同时重试多次失败的消息不再允许随意丢弃,而是进入死信队列交由人工处理。

因为 Redis 预扣减成功的数量是严格受限于库存的,因此进入 MQ2 串行消费的消息量是可预估且可控的,不会出现无限堆积导致队列卡死这样的情况。

这样虽然牺牲了一些落库的速度,但换来的是数据的绝对一致性和可靠性,我觉得这是非常划算的。

虽然还有一些令人头痛的问题,比如 Redis 服务崩掉了怎么办、MQ 消息丢了怎么办,虽然引入本地消息表可以解决很大一部分问题,但是具体怎么处理依旧是很值得讨论的一个话题,我会好好思考后重新写一篇博客。

六、总结与反思

这次压测给我上了很真实的一课:看似完美的 0 报错、0 超卖背后,可能隐藏着巨大的业务漏洞。在高并发场景下,0% 的错误率并不代表真的没有错误,要贴合真实业务去看。我想架构设计从来不是堆砌技术,而是要在真实场景下不断测试、反思和迭代。

不断学习,不断实践,不断思考,不断进步,我们共勉。

相关推荐
深入云栈1 小时前
Netty 4.2.x 源码深度解析 (十九):ChannelPool 连接池 —— 连接复用与资源管理
java·后端·架构
用户094248568031 小时前
第38章:从零构建JVM稳定性平台最小闭环
java·jvm
silvamux_1 小时前
Spring Boot 调用大模型 api 最小可跑教程
java·spring boot·后端
今年下半年1 小时前
【Spring Boot 】通过 OpenFeign 请求第三方接口
java·spring boot·后端·openfeign
三翼鸟数字化技术团队1 小时前
Springboot + WebFlux + Disruptor 实现数据转发
java·spring boot
范什么特西2 小时前
亲和路由(inspect)
java
她说可以呀2 小时前
Docker 镜像的两种创建方式
java·分布式·docker
霸道流氓气质2 小时前
AgentFlow 入门与实战:Task-Based 并发、EWMA 动态调度与四道门禁的 Java 生产级编排
java·开发语言
愿得一猪蹄2 小时前
# Java线程池实战,别再new Thread了
java