如何在今晚零点,让1000万张优惠券在同一瞬间准时失效,同时保证系统平稳运行、用户无感知?这看似简单的需求背后,隐藏着对高并发架构设计的深刻考验。
电商大促活动结束后,如何处理海量优惠券的集中过期,是很多技术团队都曾面临过的挑战。我们来算一笔账:假设你有1000万张优惠券需要在今晚零点准时过期。一个简单的 UPDATE coupon SET status = 'expired' WHERE expire_time <= NOW() AND status = 'active' 语句,直接命中数据库会发生什么?
假设你的数据库每秒能处理5000次更新(这已经是性能不错的配置了):
- 处理1000万张优惠券需要:10,000,000 / 5000 = 2000秒 ≈ 33分钟
这意味着从零点开始,你的数据库将承受持续半小时的高压,期间所有相关的优惠券查询、使用操作都可能被阻塞或延迟,用户体验会急剧下降。更糟的是,如果过期逻辑还涉及其他连带操作(如返还预算、发送到期通知),情况会更加复杂。
所以,核心挑战可归结为三点:
- 数据库压力:如何避免单次大批量操作压垮数据库?
- 执行时效:如何确保在可接受的时间窗口(如几分钟甚至秒级)内完成任务?
- 系统影响:如何让整个过程对线上的正常交易和查询做到基本无感知?
下面这个对比图,直观展示了从简单粗暴到逐步优化的四种核心方案思路:

方案一:简单分批更新(基础版)
这是最直接、最容易理解的改进方案。核心思想是“化整为零”:将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) { /* 处理异常 */ }
}
}
}
}
方案评价:
- 优点:实现简单,能有效分散数据库压力,避免长事务。
- 缺点:
- 扫表压力:
SELECT ... LIMIT 分页查询在偏移量很大时(深分页)会越来越慢。
- 时效性:总耗时依然较长,1000万/5000=2000批,即使每批0.1秒,也需200秒以上。
- 精确时间:无法做到在“零点整”这个精确瞬间全部过期,因为处理本身需要时间。
适用场景:过期时间要求不严格(如允许半小时内完成),系统压力不大的情况。
方案二:基于时间片的滚动分批(进阶版)
为了解决方案一的深分页问题,并更好地控制进度,我们可以引入时间片(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范围,实现并行处理。
方案三:消息队列异步驱动(解耦版)
当过期逻辑非常复杂,不只是更新状态,还涉及发通知、更新统计、返还权益等多步骤时,方案一和二的同步处理模型就会显得笨重。这时,可以引入消息队列(MQ)进行异步解耦。
核心架构:
- 过期触发器:一个轻量级定时任务,在零点时,快速扫描出所有已到期的优惠券ID(只读操作,压力小),并将其作为消息体发送到消息队列(如RocketMQ、Kafka)。
- 消费者集群:部署多个消费者,并行地从消息队列中拉取优惠券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. 记录日志或发送事件
}
}
方案优势:
- 彻底解耦:触发与消费分离,互不影响。
- 弹性伸缩:通过增加消费者实例,可以水平扩展处理能力。
- 流量削峰:消息队列本身起到缓冲作用,消费端可以匀速消费,保护下游数据库。
- 高可用:即使个别消费者失败,消息也不会丢失,可以重试或由其他消费者处理。
新挑战:
- 消息顺序:优惠券过期一般无需严格顺序,但需要注意重复消费问题(消费者需实现幂等性)。
- 积压监控:需监控消息积压情况,确保消费速度能跟上。
方案四:被动过期 + 主动巡检(优雅版)
以上都是“主动推”的模式。我们还可以换个思路,采用“被动拉”的模式,这也是很多大型互联网公司采用的更优雅的方案。
核心思想:不追求在过期时间点“立即”更新数据,而是让业务逻辑在“使用”时实时判断是否过期。
具体实现:
- 被动过期:
// 用户使用优惠券时的校验逻辑
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("优惠券无效或已过期");
}
- 主动巡检(兜底):
- 由于可能有些优惠券永远不被访问,状态会一直停留在“ACTIVE”。
- 需要一个低频率(如每天一次)的巡检任务,清理那些“
expire_time已过,但status还是ACTIVE”的“僵尸”优惠券。这个任务压力很小,因为大部分优惠券已在被动访问时被更新。
方案优势:
- 零点零压力:在过期临界点,数据库没有任何批量操作。
- 按需计算:只有被用到的优惠券才会触发状态更新,资源利用率高。
- 实现简单:业务逻辑清晰。
适用场景:读多写少的场景。如果优惠券在过期后完全不被访问,则巡检任务会承担最终清理工作。
实战融合:组合拳才是王道
在实际生产环境中,我们往往会根据具体情况,打出“组合拳”。例如:
融合方案:“被动过期为主 + 消息队列异步巡检为辅”
- 核心业务路径(如下单用券)采用 方案四 的被动过期校验,确保实时性和用户体验。
- 设立一个低频定时任务(如凌晨2点业务低峰期),采用 方案三 的消息队列驱动方式,对全天到期未处理的优惠券进行一次兜底巡检和清理。这个任务可以慢慢跑,对系统无压力。
- 对于运营需要立即生效的批量过期(如提前下架活动),可以采用 方案二 的游标分批,快速、可控地完成任务。
这个融合方案的整体流程与数据状态变迁,可以通过以下流程图来清晰把握:

监控与保障:
- 设置看板:监控过期优惠券的数量变化趋势、消息队列积压情况、数据库更新QPS。
- 配置告警:当巡检任务处理时间异常变长,或“僵尸券”数量累积超过阈值时告警。
- 保证幂等:无论是异步更新还是消息消费,更新状态前先判断当前状态,避免重复操作。
总结
面对“1000万优惠券同时过期”这类海量数据定时处理问题,我们经历了从简单到复杂的思维升级:
- 直接更新是灾难,它会引发数据库长事务和锁表风险,必须避免。
- 分批处理是基础,通过化整为零、游标扫描,能有效缓解数据库压力,是许多场景下可靠的选择。
- 消息队列是解耦器,它将触发与消费分离,提供了弹性伸缩和流量削峰的能力,适合复杂、链式的过期逻辑。
- 被动过期是优雅之道,它将计算成本分摊到每次请求中,实现了“按需过期”,在读写比较高的场景下是最佳选择。
技术选型的本质是权衡。没有最好的方案,只有最合适的方案。作为架构设计者,我们需要根据业务的数据规模、过期时效要求、系统当前负载、团队运维能力等因素,灵活选择或组合这些模式。下次当你再面临类似“海量数据批量处理”的挑战时,不妨从这几个维度思考:能否异步?能否分片?能否延迟?能否并行? 想清楚这些问题,解决方案的轮廓自然会在你脑中浮现。
记住,好的架构不是设计出来的,而是在不断的权衡和演进中生长出来的。如果想深入了解更多的后端架构实践和社区讨论,欢迎来云栈社区交流分享。