1. 背景
我们系统是一个理财优惠券服务,负责优惠券和红包的全生命周期管理,包括发券、用券、过期、退款等环节。
某周五0点,运营反馈:一批本应在0点过期的用户券,在过期时间之后仍然显示为有效状态。
2. 过期机制详解
我们系统的过期逻辑采用了 Redis ZSET + 定时任务 + MQ 的三层架构,整体流程如下: 用户领券 → 过期时间写入Redis ZSET(score=过期时间戳) ↓ 定时任务扫描ZSET → ZRANGEBYSCORE取出score<当前时间的券 ↓ 发送过期消息到MQ ↓ 消费端收到消息 → 调用过期接口 → 变更券状态
为什么用Redis ZSET而不是扫表?
| 方案 | 优点 | 缺点 |
|---|---|---|
| 定时扫数据库 | 实现简单,不依赖Redis | 数据量大时全表扫描慢,且锁行影响正常读写 |
| Redis Key过期回调 | 原生支持 | 过期回调不可靠,大量key同时过期会阻塞Redis |
| Redis ZSET + 定时扫描 | ZRANGEBYSCORE时间复杂度O(logN),性能好;过期时间精确可控 | 需要额外维护ZSET数据,过期后需主动ZREM |
ZSET方案的核心优势在于:取到期券的操作是O(logN)的,不会因为券数量增长而变慢。
ZSET的数据生命周期
写入:领券时 ZADD expire_zset <过期时间戳> <券ID> 扫描:定时任务 ZRANGEBYSCORE expire_zset 0 <当前时间戳> 移除:过期处理完成后 ZREM expire_zset <券ID>
只存券ID,不存完整数据,控制ZSET的内存占用。具体的券信息在消费端根据ID去数据库查询。
为什么0点是高峰?
大部分营销活动的有效期都是按自然日设定的,用户领到的券过期时间大量集中在0点。定时任务在0点扫描ZSET时,瞬间取出一大批到期券,往MQ灌入大量过期消息,消费端在0点面临一个流量尖峰。
这次故障恰恰发生在这个流量尖峰时刻,红包系统也在0点同时处理过期逻辑,两个业务的过期高峰叠加,放大了数据库连接的压力。
3. 排查过程
3.1 确认定时任务是否执行
首先查看定时任务的执行日志,发现0点的定时任务正常触发了,也正确从ZSET中取出了到期券,并往MQ发送了过期消息。问题不在生产端,而在消费端。
3.2 查看消息消费情况
检查MQ消费端,发现过期消息出现了大量消费异常和积压。消费者在处理过期消息时抛出了异常,导致消息不断重试,新消息也在持续堆积。
3.3 追踪异常根因
查看消费端的异常日志,发现异常的调用链指向了红包过期接口——该接口返回了限流错误。
等等,我们处理的是优惠券过期,为什么会调用红包过期接口?
3.4 发现消息隔离问题
深入查看代码逻辑后发现问题所在:系统消费过期消息时,没有区分消息是红包类型还是优惠券类型,统一走同一条消费链路。 在这条链路中,红包过期和优惠券过期的处理是串行耦合的,红包过期接口的异常导致了整条消费链路的中断,优惠券的过期消息也随之无法被正常消费。
3.5 红包接口为什么被限流?
进一步追查红包过期接口被限流的原因,发现是数据库连接数超限导致的。
先看一组数字:
| 指标 | 扩容前 | 扩容后 |
|---|---|---|
| 红包机器数 | 30台 | 140台 |
| 单机最大连接数 | 9 | 9 |
| 单库连接数 | 30 × 9 = 270 | 140 × 9 = 1260 |
| 单MySQL实例连接数(2个库) | 270 × 2 = 540 | 1260 × 2 = 2520 |
| MySQL max_connections | 1600 | 1600 |
| 是否超限 | ✅ 540 < 1600 | ❌ 2520 > 1600,超出920个连接 |
红包系统近期从30台扩容到了140台,单机连接池配置最小4、最大9。乍一看每台9个连接不多,但乘上机器数和库数后,总连接数从540飙到2520,是最大连接数的1.575倍,数据库根本无法承载。
而该MySQL实例部署在虚拟机上,资源有限,无法直接调大max_connections。同事采取了应急措施,对红包过期接口做了限流。
4. 完整故障链路
graph TD
A[红包系统扩容 30台到140台] --> B[单库连接数 270到1260]
B --> C[MySQL实例连接数 540到2520]
C --> D[超出max_connections=1600]
D --> E[红包过期接口被限流]
E --> F[过期消息消费异常]
F --> G[消息积压和重试]
G --> H[优惠券过期消息无法消费]
H --> I[用户券该过期未过期]
J[MySQL虚拟机部署无法调大连接数] -.-> D
K[消费端未区分红包和优惠券] -.-> F
用一句话概括:红包系统扩容导致DB连接超限 → 限流止血 → 共享消费链路导致优惠券过期被连带阻塞。
5. 问题分析
这次故障暴露了系统设计中的两个核心问题。
5.1 消息消费缺乏业务隔离
红包和优惠券是两种不同的业务类型,但过期消息的消费没有做业务隔离,导致一个业务的故障直接扩散到了另一个业务。
// 问题代码示意:消费逻辑未区分业务类型
public void onMessage(List<Message> messages) {
for (Message message : messages) {
// 统一处理,未区分红包/优惠券 processExpire(message); // 红包接口异常 → 整体中断 }
}
}
理想的做法是按业务类型分Topic或分消费组:
方案一:分Topic coupon_expire_topic → CouponExpireListener redpacket_expire_topic → RedPacketExpireListener
方案二:同Topic分消费组(共享Topic但独立消费) expire_topic → CouponExpireConsumerGroup expire_topic → RedPacketExpireConsumerGroup
5.2 扩容评估不充分
红包系统从30台扩容到140台,看起来只跟应用层有关,但实际上每加一台机器,就多了一组数据库连接。当机器数翻了4.7倍,连接数也翻了4.7倍,但数据库的承载能力没有同步提升。
这个问题本质上是一个隐性依赖:应用层扩容时,数据库连接数也跟着线性增长,但这个依赖在扩容方案中没有被评估。
而且该MySQL实例部署在虚拟机上,资源有限,max_connections调不了,这意味着扩容方案从一开始就有一个硬性天花板,只是之前30台机器时没触碰到。
6. 修复方案
6.1 短期止血
- 对积压的消息进行手动补偿消费
- 对已过期但状态未变更的券批量执行过期状态修正
6.2 长期改进
| 改进项 | 具体方案 |
|---|---|
| 消息隔离 | 红包过期和优惠券过期拆分为独立Topic/消费组,互不影响 |
| 扩容规范 | 扩容前必须评估数据库连接数上限,形成扩容CheckList |
| 消费容错 | 消费端增加业务类型判断,红包接口异常时优惠券逻辑不受影响 |
| 告警完善 | 增加消息积压和消费异常的监控告警,缩短故障发现时间 |
| 连接池治理 | 评估是否需要缩小单实例连接池上限,或对红包系统做读写分离分散连接压力 |
6.3 扩容CheckList
这次故障后,我们沉淀了一份扩容前必查项:
扩容前必查项: □ 扩容后总实例数 × 单实例最大连接数 × 库数 = ?
□ 该值是否超过MySQL的max_connections?
□ 超过的话,能否调大max_connections?
□ 不能调大,能否减小单实例连接池?
□ 减小连接池后,单实例的吞吐是否够用?
□ 是否需要做读写分离/分库来分散连接压力?
7. 反思
这次故障给我最大的感触有两点:
第一,系统间的"隐性耦合"比"显性依赖"更危险。 显性依赖你知道它存在,会做容错和降级;但隐性耦合——比如红包和优惠券共享消费链路——在正常情况下一切正常,一旦一方出问题,另一方跟着倒下,而且排查时很难第一时间定位。
第二,扩容不是免费的。 每加一台机器,消耗的不只是CPU和内存,还有数据库连接、缓存连接、下游服务的吞吐余量。扩容方案如果只看应用层,不看上下游,就是在给自己埋雷。
好的系统设计,不是让所有功能都能正常运行,而是让一个功能出问题时,不会拖垮其他功能。
写在最后
今日话题:你有没有遇到过类似的坑?接手同事代码时,发现过哪些隐藏的bug?欢迎在评论区分享你的经历!
原文首发于公众号【经典小熊】,关注获取更多干货