在一次常规的权益包过期批量退款中,我们的履约系统遭遇了预料之外的流量洪峰。短时间内涌入的1万多笔退款请求,让数据库CPU持续满载十分钟,部分请求失败。本文将完整回顾我们如何定位问题、实施紧急修复,并进行系统化加固的全过程。
1. 复盘"案发现场"
1.1. 业务背景
商城售卖了一批权益包的单,对于消费者未使用的在过期当天需要自动退款。这就好比你买了一张月底过期的"咖啡月卡"(权益包),但一直没去喝。公司很厚道,承诺:过期当天,自动退款!问题来了:如果10万个用户同时有这样的权益包,系统会在过期日瞬间发起10万笔退款。这就是我们面临的业务现实:定时、批量、高并发的"脉冲式"流量冲击。
1.2. 灾难时刻
时间线还原:
T+0s:商城侧开始推送过期权益包的退款申请。
T+10s:1万笔退款请求已抵达履约系统接口,平均QPS高达1000。
T+30s:数据库服务器CPU飙升至100%,并持续"僵死"。
T+5分钟:部分商城请求超时。
关键发现:
通过配置的监控告警,运维迅速协助锁定了一条"死亡慢SQL"。
sql
-- 示例sql
SELECT * FROM order
WHERE user_id = ?
AND status = 'pending'
AND package_type = ?
ORDER BY create_time DESC;
它的致命问题:
- 缺少关键索引,导致全表扫描。
- 在当时生产环境300w+单量的订单表中,单次查询就消耗数秒。
- 当1000QPS并发执行时,大量慢sql让CPU使用率飙到100%,部分web请求出现超时。
2. DBA的"黄金5分钟"
2.1. 第一原则:先恢复,再优化
面对线上事故,我们的应急响应流程:
- 快速定位:通过监控告警和数据库慢查询日志,快速定位到具体SQL。
- 紧急处理:DBA立即执行索引添加。
sql
-- 紧急添加的索引示例
ALTER TABLE refund_order
ADD INDEX idx_user_status_type (user_id, status, package_type);
增加索引后,CPU虽从100%下降,但仍处于60%+的高位,说明系统只是"退烧",并未"痊愈"。
3. 为什么我们的系统如此脆弱
3.1. 业务链路
在了解系统脆弱性之前先看看以下简化版的权益包退款业务链路,大致逻辑是先将商城报文存入中间表a,再通过定时任务消除平台差异后存入中间表b并通过MQ解耦生成退单,再由退单生成退款单。

3.2. 架构层面的"三道防线"全部失守
第一道防线(接入层):只有一扇"总门"
-
原有方案:全局网关限流5000 QPS。
-
问题:这个限流是针对所有接口的,而退款接口只是其中之一。当退款流量暴涨时,其他接口的额度也被占用,且无法阻止退款接口自身的内部风暴。
第二道防线(业务层):自己制造的"二次风暴"
-
原有流程:中间表a转换生成中间表b的Task,一次性查询出5000条待处理记录,然后分批并发处理。
-
放大效应:外部1000QPS的请求,进入系统后,又被这个Task放大成内部并发数据处理请求,进一步冲击数据库。
第三道防线(数据层/MQ消费):有枪无"准星"
- 原有方案:
RocketMQ消费者:单次拉取1000条消息。
框架层:全局共享消费线程池。
- 致命缺失:没有接口层面的限流,一旦消息堆积,所有线程都会疯狂消费同一个"退款"Topic,再次打满数据库。
4. 构建全链路"防波堤"
4.1. 接入层
在流量网关层设置接口限流(基于Resilience4j),防止系统过载,当服务调用失败率超过阈值时,自动熔断,防止故障扩散。
4.2. 业务层
调整中间表a转换生成中间表b的Task单次查询的单量,比如1min执行一次,单次能处理600单,则结合服务处理能力设置单次查询单量为600,避免一次捞取大量数据。
4.3. 数据层
4.3.1. 慢sql优化
通过调用链分析耗时发现慢sql是主要的耗时大头,因此通过增加/调整索引、数据归档、数据缓存、循环查代码逻辑改造等手段解决百万级大表查询慢的问题。
4.3.2. MQ消费限流
中间件层面和MQ消费者框架代码层面都只有全局限流设置,因此,利用项目中的Sentinel组件对MQ消费逻辑进行接口级限流。示例代码如下:
java
@Slf4j
@Service
@RocketMQMessageListener(consumerGroup = "refund-consumer-group", topic = "REFUND_TOPIC")
public class RefundMessageConsumer implements RocketMQListener<MessageExt> {
// 定义资源名,用于Sentinel限流
private static final String RESOURCE_NAME = "processRefundMessage";
@Override
@SentinelResource(
value = RESOURCE_NAME,
blockHandler = "handleFlowLimit", // 流控降级方法
blockHandlerClass = {RefundMessageConsumerBlockHandler.class}
)
public void onMessage(MessageExt message) {
// 核心退款业务逻辑
refundService.processRefund(parseMessage(message));
}
}
// 流控降级处理器
public class RefundMessageConsumerBlockHandler {
public static void handleFlowLimit(MessageExt message, BlockException ex) {
log.warn("触发消费限流,消息将延迟处理: {}", message.getMsgId());
// 可选:将消息重新投递或记录日志,稍后重试
throw new RuntimeException("系统繁忙,请稍后重试");
}
}
限流规则配置(通过Sentinel Dashboard动态设置):
-
QPS限流 :将
processRefundMessage资源的QPS阈值设置为与数据库处理能力匹配的值(如200)。 -
线程数限流:限制同时处理退款消息的线程数,防止线程池被打满。
下篇预告:
优化方案纸上谈兵?不!下一篇带你直击 《压测战场:如何用"真实数据"验证系统扛得住》 **,**看如何通过全链路压测,让优化效果"看得见、摸得着",并分享我们从这次事故中提炼出的高并发设计黄金法则。