⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 苏三说技术 「苏三」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

前言

如何在今晚零点,让1000万张优惠券在同一瞬间准时失效,同时保证系统平稳运行、用户无感知?这看似简单的需求背后,隐藏着对高并发架构设计的深刻考验。

电商大促活动结束后,如何处理海量优惠券的集中过期,是很多技术团队都曾面临过的挑战。

今天,我就跟大家一起聊聊这个话题,希望对你会有所帮助。

01 问题背后的技术挑战:为什么这很难?

有些小伙伴在工作中可能觉得:“不就是更新数据库吗?写个定时任务,在过期时间跑个UPDATE语句不就行了?”

这么想就把问题简单化了。

我们来算一笔账:

假设你有1000万张优惠券需要在今晚零点准时过期。一个简单的UPDATE coupon SET status = 'expired' WHERE expire_time <= NOW() AND status = 'active'语句,直接命中数据库会发生什么?

假设你的数据库每秒能处理5000次更新(这已经是性能不错的配置了):

  • 处理1000万张优惠券需要:10,000,000 / 5000 = 2000秒 ≈ 33分钟

这意味着从零点开始,你的数据库将承受持续半小时的高压,期间所有相关的优惠券查询、使用操作都可能被阻塞或延迟,用户体验会急剧下降。

更糟的是,如果过期逻辑还涉及其他连带操作(如返还预算、发送到期通知),情况会更加复杂。

所以,核心挑战可归结为三点:

  1. 数据库压力:如何避免单次大批量操作压垮数据库?
  2. 执行时效:如何确保在可接受的时间窗口(如几分钟甚至秒级)内完成任务?
  3. 系统影响:如何让整个过程对线上的正常交易和查询做到基本无感知?

下面这个对比图,直观展示了从简单粗暴到逐步优化的四种核心方案思路:

02 方案一:简单分批更新(基础版)

这是最直接、最容易理解的改进方案。核心思想是“化整为零”:将1000万条记录分成多个小批次(Batch),比如每批5000条,分批提交更新。

@Service
public class BasicBatchExpireService {
@Autowired
private JdbcTemplate jdbcTemplate;

public void expireCouponsBatch() {
int totalUpdated = 0;
int batchSize = 5000; // 每批处理量
boolean hasMore = true;

while (hasMore) {
// 使用分页思想,每次选取一批未过期的优惠券ID
String sql = "SELECT id FROM coupon WHERE status = 'ACTIVE' " +
"AND expire_time <= NOW() LIMIT ?";
List<Long> couponIds = jdbcTemplate.queryForList(sql, Long.class, batchSize);

if (couponIds.isEmpty()) {
hasMore = false;
} else {
// 批量更新状态
String updateSql = "UPDATE coupon SET status = 'EXPIRED' WHERE id IN (?)";
// 注意:实际中需根据ORM框架或数据库支持来构造IN语句
// 这里使用MyBatis等框架的批量操作更佳
int[] updateCounts = jdbcTemplate.batchUpdate(updateSql,
couponIds.stream().map(id -> new Object[]{id}).collect(Collectors.toList()));

totalUpdated += couponIds.size();
System.out.println("已过期处理: " + totalUpdated + " 张");

// 每批处理后短暂休眠,让数据库喘口气
try { Thread.sleep(100); } catch (InterruptedException e) { /* 处理异常 */ }
}
}
}
}

方案评价:

  • 优点:实现简单,能有效分散数据库压力,避免长事务。

  • 缺点:

    1. \1. 扫表压力:SELECT ... LIMIT 分页查询在偏移量很大时(深分页)会越来越慢。
  1. \2. 时效性:总耗时依然较长,1000万/5000=2000批,即使每批0.1秒,也需200秒以上。
  2. \3. 精确时间:无法做到在“零点整”这个精确瞬间全部过期,因为处理本身需要时间。

适用场景:过期时间要求不严格(如允许半小时内完成),系统压力不大的情况。

03 方案二:基于时间片的滚动分批(进阶版)

为了解决方案一的深分页问题,并更好地控制进度,我们可以引入时间片(Time Slice) 和游标(Cursor) 的概念。不再使用LIMIT offset, size,而是基于优惠券的创建时间或ID等有序字段进行分段。

@Service  
public class TimeSliceExpireService {
@Autowired
private JdbcTemplate jdbcTemplate;

public void expireCouponsByTimeSlice() {
Long lastMaxId = 0L; // 或使用最后处理时间
int batchSize = 5000;
boolean hasMore = true;

while (hasMore) {
// 关键:使用id > ? 替代 LIMIT offset,利用索引避免深分页
String querySql = "SELECT id FROM coupon WHERE id > ? AND status = 'ACTIVE' " +
"AND expire_time <= NOW() ORDER BY id ASC LIMIT ?";
List<Long> couponIds = jdbcTemplate.queryForList(
querySql, Long.class, lastMaxId, batchSize);

if (couponIds.isEmpty()) {
hasMore = false;
} else {
// 批量更新(此处简写,实际应用PreparedStatement批量操作)
expireBatch(couponIds);

lastMaxId = couponIds.get(couponIds.size() - 1); // 移动游标
System.out.println("进度游标移至 ID: " + lastMaxId);

// 更动态的休眠:根据处理时间调整,实现“匀速”处理
// 或者引入更复杂的流控机制
}
}
}

private void expireBatch(List<Long> ids) {
// 具体的批量更新逻辑,可使用MyBatis-Plus的updateBatchById等
}
}

改进点:

  • 性能提升:WHERE id > ? 利用主键索引,性能远高于LIMIT offset。
  • 可监控:lastMaxId 游标可以记录断点,任务意外停止后可以从中断处恢复。
  • 可扩展:可以拆分成多个子任务,每个子任务处理一个连续ID范围,实现并行处理。

04 方案三:消息队列异步驱动(解耦版)

当过期逻辑非常复杂,不只是更新状态,还涉及发通知、更新统计、返还权益等多步骤时,方案一和二的同步处理模型就会显得笨重。这时,可以引入消息队列(MQ)进行异步解耦。

核心架构:

  1. 过期触发器:一个轻量级定时任务,在零点时,快速扫描出所有已到期的优惠券ID(只读操作,压力小),并将其作为消息体发送到消息队列(如RocketMQ、Kafka)。
  2. 消费者集群:部署多个消费者,并行地从消息队列中拉取优惠券ID,执行各自的过期业务逻辑。
// 触发器:负责发送消息
@Component
public class CouponExpireTrigger {
@Autowired
private RocketMQTemplate rocketMQTemplate;

@Scheduled(cron = "0 0 0 * * ?") // 每天零点执行
public void triggerExpire() {
Long lastMaxId = 0L;
int batchSize = 10000;

while (true) {
List<Long> expireIds = findExpiredIds(lastMaxId, batchSize);
if (expireIds.isEmpty()) break;

// 将一批ID发送到消息队列
rocketMQTemplate.syncSend("COUPON_EXPIRE_TOPIC", expireIds);

lastMaxId = expireIds.get(expireIds.size() - 1);
}
System.out.println("过期ID发送完毕,开始异步处理。");
}
}

// 消费者:负责处理具体过期逻辑
@Component
@RocketMQMessageListener(topic = "COUPON_EXPIRE_TOPIC", consumerGroup = "coupon-expire-group")
public class CouponExpireConsumer implements RocketMQListener<List<Long>> {

@Override
public void onMessage(List<Long> couponIds) {
// 在这里执行复杂的过期逻辑:更新状态、发通知、更新用户权益等
for (Long id : couponIds) {
processSingleCoupon(id);
}
}

private void processSingleCoupon(Long couponId) {
// 1. 更新优惠券状态为过期 (原子操作,使用乐观锁避免重复处理)
// 2. 如果更新成功,进行后续操作
// 3. 记录日志或发送事件
}
}

方案优势:

  • 彻底解耦:触发与消费分离,互不影响。
  • 弹性伸缩:通过增加消费者实例,可以水平扩展处理能力。
  • 流量削峰:消息队列本身起到缓冲作用,消费端可以匀速消费,保护下游数据库。
  • 高可用:即使个别消费者失败,消息也不会丢失,可以重试或由其他消费者处理。

新挑战:

  • 消息顺序:优惠券过期一般无需严格顺序,但需要注意重复消费问题(消费者需实现幂等性)。
  • 积压监控:需监控消息积压情况,确保消费速度能跟上。

05 方案四:被动过期 + 主动巡检(优雅版)

以上都是“主动推”的模式。我们还可以换个思路,采用“被动拉”的模式,这也是很多大型互联网公司采用的更优雅的方案。

核心思想:不追求在过期时间点“立即”更新数据,而是让业务逻辑在“使用”时实时判断是否过期。

具体实现:

  1. 被动过期:

    // 用户使用优惠券时的校验逻辑
    public Coupon validateCoupon(Long userId, Long couponId) {
    Coupon coupon = couponMapper.selectById(couponId);
    // 关键判断:状态为“活跃” AND (过期时间为空 OR 过期时间 > 当前时间)
    if (coupon.getStatus() == CouponStatus.ACTIVE
    && (coupon.getExpireTime() == null
    || coupon.getExpireTime().after(new Date()))) {
    return coupon; // 有效
    }
    // 如果发现已过期(根据expire_time判断),可以异步触发一个状态更新
    if (coupon.getExpireTime() != null && coupon.getExpireTime().before(new Date())) {
    // 异步调用,将状态改为过期,避免阻塞主流程
    asyncUpdateCouponStatus(couponId, CouponStatus.EXPIRED);
    }
    throw new BusinessException("优惠券无效或已过期");
    }
  2. 主动巡检(兜底):

  • 由于可能有些优惠券永远不被访问,状态会一直停留在“ACTIVE”。
  • 需要一个低频率(如每天一次)的巡检任务,清理那些“expire_time已过,但status还是ACTIVE”的“僵尸”优惠券。这个任务压力很小,因为大部分优惠券已在被动访问时被更新。

方案优势:

  • 零点零压力:在过期临界点,数据库没有任何批量操作。
  • 按需计算:只有被用到的优惠券才会触发状态更新,资源利用率高。
  • 实现简单:业务逻辑清晰。

适用场景:读多写少的场景。如果优惠券在过期后完全不被访问,则巡检任务会承担最终清理工作。

06 实战融合:组合拳才是王道

在实际生产环境中,我们往往会根据具体情况,打出“组合拳”。例如:

融合方案:“被动过期为主 + 消息队列异步巡检为辅”

  1. 核心业务路径(如下单用券)采用 方案四 的被动过期校验,确保实时性和用户体验。
  2. 设立一个低频定时任务(如凌晨2点业务低峰期),采用 方案三 的消息队列驱动方式,对全天到期未处理的优惠券进行一次兜底巡检和清理。这个任务可以慢慢跑,对系统无压力。
  3. 对于运营需要立即生效的批量过期(如提前下架活动),可以采用 方案二 的游标分批,快速、可控地完成任务。

这个融合方案的整体流程与数据状态变迁,可以通过以下流程图来清晰把握:

监控与保障:

  • 设置看板:监控过期优惠券的数量变化趋势、消息队列积压情况、数据库更新QPS。
  • 配置告警:当巡检任务处理时间异常变长,或“僵尸券”数量累积超过阈值时告警。
  • 保证幂等:无论是异步更新还是消息消费,更新状态前先判断当前状态,避免重复操作。

07 总结

面对“1000万优惠券同时过期”这类海量数据定时处理问题,我们经历了从简单到复杂的思维升级:

  1. 直接更新是灾难,它会引发数据库长事务和锁表风险,必须避免。
  2. 分批处理是基础,通过化整为零、游标扫描,能有效缓解数据库压力,是许多场景下可靠的选择。
  3. 消息队列是解耦器,它将触发与消费分离,提供了弹性伸缩和流量削峰的能力,适合复杂、链式的过期逻辑。
  4. 被动过期是优雅之道,它将计算成本分摊到每次请求中,实现了“按需过期”,在读写比较高的场景下是最佳选择。

技术选型的本质是权衡。

没有最好的方案,只有最合适的方案。

作为架构师,我们需要根据业务的数据规模、过期时效要求、系统当前负载、团队运维能力等因素,灵活选择或组合这些模式。

下次当你再面临类似“海量数据批量处理”的挑战时,不妨从这几个维度思考:能否异步?能否分片?能否延迟?能否并行? 想清楚这些问题,解决方案的轮廓自然会在你脑中浮现。

记住,好的架构不是设计出来的,而是在不断的权衡和演进中生长出来的。

文章目录
  1. 1. 前言
  2. 2. 01 问题背后的技术挑战:为什么这很难?
  3. 3. 02 方案一:简单分批更新(基础版)
  4. 4. 03 方案二:基于时间片的滚动分批(进阶版)
  5. 5. 04 方案三:消息队列异步驱动(解耦版)
  6. 6. 05 方案四:被动过期 + 主动巡检(优雅版)
  7. 7. 06 实战融合:组合拳才是王道
  8. 8. 07 总结