一次线上优惠券过期失效的排查实录:从消息积压看系统隔离的重要性

0 阅读7分钟

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台
单机最大连接数99
单库连接数30 × 9 = 270140 × 9 = 1260
单MySQL实例连接数(2个库)270 × 2 = 5401260 × 2 = 2520
MySQL max_connections16001600
是否超限✅ 540 < 16002520 > 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 短期止血

  1. 对积压的消息进行手动补偿消费
  2. 对已过期但状态未变更的券批量执行过期状态修正

6.2 长期改进

改进项具体方案
消息隔离红包过期和优惠券过期拆分为独立Topic/消费组,互不影响
扩容规范扩容前必须评估数据库连接数上限,形成扩容CheckList
消费容错消费端增加业务类型判断,红包接口异常时优惠券逻辑不受影响
告警完善增加消息积压和消费异常的监控告警,缩短故障发现时间
连接池治理评估是否需要缩小单实例连接池上限,或对红包系统做读写分离分散连接压力

6.3 扩容CheckList

这次故障后,我们沉淀了一份扩容前必查项:

扩容前必查项: □ 扩容后总实例数 × 单实例最大连接数 × 库数 = ?

□ 该值是否超过MySQL的max_connections?

□ 超过的话,能否调大max_connections?

□ 不能调大,能否减小单实例连接池?

□ 减小连接池后,单实例的吞吐是否够用?

□ 是否需要做读写分离/分库来分散连接压力?

7. 反思

这次故障给我最大的感触有两点:

第一,系统间的"隐性耦合"比"显性依赖"更危险。 显性依赖你知道它存在,会做容错和降级;但隐性耦合——比如红包和优惠券共享消费链路——在正常情况下一切正常,一旦一方出问题,另一方跟着倒下,而且排查时很难第一时间定位。

第二,扩容不是免费的。 每加一台机器,消耗的不只是CPU和内存,还有数据库连接、缓存连接、下游服务的吞吐余量。扩容方案如果只看应用层,不看上下游,就是在给自己埋雷。

好的系统设计,不是让所有功能都能正常运行,而是让一个功能出问题时,不会拖垮其他功能。

写在最后

今日话题:你有没有遇到过类似的坑?接手同事代码时,发现过哪些隐藏的bug?欢迎在评论区分享你的经历!

原文首发于公众号【经典小熊】,关注获取更多干货 image.png