如何在今晚零点,让1000万张优惠券在同一瞬间准时失效,同时保证系统平稳运行、用户无感知?这看似简单的需求背后,隐藏着对高并发架构设计的深刻考验。
大家好,我是苏三。
电商大促活动结束后,如何处理海量优惠券的集中过期,是很多技术团队都曾面临过的挑战。
今天,我就跟大家一起聊聊这个话题,希望对你会有所帮助。
最近想快速提升项目实战能力,或者最近找工作的小伙伴,都可以看看下面👇🏻的这个链接:
有些小伙伴在工作中可能觉得:“不就是更新数据库吗?写个定时任务,在过期时间跑个UPDATE语句不就行了?”
这么想就把问题简单化了。
我们来算一笔账:
假设你有1000万张优惠券需要在今晚零点准时过期。一个简单的UPDATE coupon SET status = 'expired' WHERE expire_time <= NOW() AND status = 'active'语句,直接命中数据库会发生什么?
假设你的数据库每秒能处理5000次更新(这已经是性能不错的配置了):
这意味着从零点开始,你的数据库将承受持续半小时的高压,期间所有相关的优惠券查询、使用操作都可能被阻塞或延迟,用户体验会急剧下降。
更糟的是,如果过期逻辑还涉及其他连带操作(如返还预算、发送到期通知),情况会更加复杂。
所以,核心挑战可归结为三点:
下面这个对比图,直观展示了从简单粗暴到逐步优化的四种核心方案思路:

图片
这是最直接、最容易理解的改进方案。核心思想是“化整为零”:将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 分页查询在偏移量很大时(深分页)会越来越慢。适用场景:过期时间要求不严格(如允许半小时内完成),系统压力不大的情况。
为了解决方案一的深分页问题,并更好地控制进度,我们可以引入时间片(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 游标可以记录断点,任务意外停止后可以从中断处恢复。当过期逻辑非常复杂,不只是更新状态,还涉及发通知、更新统计、返还权益等多步骤时,方案一和二的同步处理模型就会显得笨重。这时,可以引入消息队列(MQ)进行异步解耦。
核心架构:
// 触发器:负责发送消息
@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. 记录日志或发送事件
}
}方案优势:
新挑战:
以上都是“主动推”的模式。我们还可以换个思路,采用“被动拉”的模式,这也是很多大型互联网公司采用的更优雅的方案。
核心思想:不追求在过期时间点“立即”更新数据,而是让业务逻辑在“使用”时实时判断是否过期。
具体实现:
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. 主动巡检(兜底):
expire_time已过,但status还是ACTIVE”的“僵尸”优惠券。这个任务压力很小,因为大部分优惠券已在被动访问时被更新。方案优势:
适用场景:读多写少的场景。如果优惠券在过期后完全不被访问,则巡检任务会承担最终清理工作。
在实际生产环境中,我们往往会根据具体情况,打出“组合拳”。例如:
融合方案:“被动过期为主 + 消息队列异步巡检为辅”
这个融合方案的整体流程与数据状态变迁,可以通过以下流程图来清晰把握:

图片
监控与保障:
面对“1000万优惠券同时过期”这类海量数据定时处理问题,我们经历了从简单到复杂的思维升级:
技术选型的本质是权衡。
没有最好的方案,只有最合适的方案。
作为架构师,我们需要根据业务的数据规模、过期时效要求、系统当前负载、团队运维能力等因素,灵活选择或组合这些模式。
下次当你再面临类似“海量数据批量处理”的挑战时,不妨从这几个维度思考:能否异步?能否分片?能否延迟?能否并行? 想清楚这些问题,解决方案的轮廓自然会在你脑中浮现。
记住,好的架构不是设计出来的,而是在不断的权衡和演进中生长出来的。