想象一下这个场景:“双十一”大促结束,后台有2000万张设置了当晚24:00为有效期的优惠券。你设计的系统在凌晨0点执行了一条简单的SQL:
UPDATE coupon SET status = 'EXPIRED' WHERE expire_time <= NOW()
结果可能是灾难性的:数据库连接池瞬间被打满,CPU飙升至100%,应用响应超时,甚至可能引发雪崩效应。这不仅是一个面试中常见的高并发场景设计题,更是许多线上系统真实踩过的坑。
本文将深入剖析这个问题,并为你提供三种经过生产验证的解决方案,让你在处理海量数据过期时,能够从容应对。
问题根源:为何一条UPDATE会拖垮系统?
直接执行全量更新的SQL看似简单,但背后隐藏着几个致命缺陷:
- 行锁竞争:大量更新操作需要竞争行锁,极易导致锁等待超时,甚至死锁。
- 大事务风险:单条SQL更新上千万行,会生成巨大的回滚段(Undo Log),导致主从复制延迟飙升,严重时可拖垮整个数据库实例。
- IO瓶颈:全表扫描加上大量随机写操作,会瞬间打满磁盘IO,影响其他正常业务。
因此,核心思路是“削峰填谷”,将集中式的瞬时压力分摊开。下面介绍三种主流方案。
方案一:分批定时任务 —— 化整为零,稳扎稳打
核心思想:将2000万条数据拆分成多个小批次(如每批1000条),循环执行更新。配合分布式锁,确保在集群环境下任务不会被重复执行。
实现流程:
- 定时任务触发,尝试获取分布式锁。
- 获取锁成功后,查询待过期数据的总量,并计算总批次数。
- 进入循环:每次查询一批数据的ID,执行批量更新,提交事务,并记录当前处理到的偏移量(用于断点续传)。
- 任务意外中断后,重启时可从上次记录的偏移量继续执行。
代码示例(Spring Boot + MyBatis-Plus):
@Component
public class CouponExpireScheduler {
@Autowired
private CouponMapper couponMapper;
@Autowired
private RedissonClient redissonClient;
private static final int BATCH_SIZE = 1000; // 每批处理1000条
private static final String LOCK_KEY = "coupon:expire:lock";
@Scheduled(cron = "0 0 0 * * ?") // 每天0点执行
public void expireCoupons() {
RLock lock = redissonClient.getLock(LOCK_KEY);
try {
// 尝试加锁,等待5秒,锁有效期30秒
if (lock.tryLock(5, 30, TimeUnit.SECONDS)) {
// 获取当前最大ID(假设ID自增)
Long maxId = couponMapper.selectMaxId();
Long currentId = 0L;
while (currentId < maxId) {
List<Long> ids = couponMapper.selectExpiredIds(currentId, BATCH_SIZE);
if (ids.isEmpty()) {
break;
}
// 关键:批量更新,使用IN条件,避免循环单条更新
couponMapper.batchUpdateStatus(ids, CouponStatus.EXPIRED);
currentId = ids.get(ids.size() - 1); // 更新起始ID
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock != null && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
batchUpdateStatus 方法对应生成 UPDATE coupon SET status = ? WHERE id IN (...) 的SQL,这是实现高效批量更新的关键。
方案评价:
- 优点:实现简单,可控性强,支持断点续传。
- 缺点:需要轮询数据库,占用连接;数据量巨大时,整体执行时间较长。
- 适用场景:对实时性要求不高,允许在数小时甚至一天内完成状态更新的非核心业务数据。
方案二:惰性删除 + 定期清理 —— 借鉴Redis的智慧
核心思想:不主动去更新过期状态,而是在每次查询时进行判断。若发现数据已过期,则触发一个异步任务去更新其状态。同时,搭配一个低优先级的后台定时任务,定期清理那些“漏网之鱼”。
实现流程:
- 用户查询优惠券时,应用层代码判断
expire_time 是否已过当前时间。
- 若已过期,则触发异步操作(如发送一条消息到消息队列),并给用户返回“已失效”的结果。
- 异步消费者收到消息后,执行单条状态更新。
- 每日凌晨执行一个兜底的定时任务,扫描并批量修复所有过期但状态未更新的数据。
代码示例(查询时触发异步更新):
@Service
public class CouponService {
@Autowired
private CouponMapper couponMapper;
@Autowired
private RabbitTemplate rabbitTemplate;
public CouponDTO getCouponById(Long id) {
Coupon coupon = couponMapper.selectById(id);
if (coupon != null && coupon.getExpireTime().isBefore(LocalDateTime.now())) {
// 关键:如果已过期,发送异步消息更新状态
rabbitTemplate.convertAndSend("coupon.expire", id);
return null; // 返回空,表示优惠券无效
}
return convertToDTO(coupon);
}
}
// 消费者处理
@Component
public class CouponExpireConsumer {
@Autowired
private CouponMapper couponMapper;
@RabbitListener(queues = "coupon.expire")
public void handleExpire(Long id) {
couponMapper.updateStatus(id, CouponStatus.EXPIRED);
}
}
生活化类比:这就像超市不会在每天打烊后全盘清查所有商品,而是等到顾客拿着商品到收银台结账时,收银系统发现商品过期再通知理货员下架。这样保证了购物流程顺畅,又避免了每天闭店时的大规模清点。
方案评价:
- 优点:读操作路径几乎无额外开销,数据库压力极小。
- 缺点:存在短暂的数据不一致窗口(已过期但状态未及时更新),过期数据会持续占用存储空间。
- 适用场景:读多写少,对短暂不一致有容忍度的业务,如用户端查询个人优惠券列表。
方案三:基于消息队列的延迟处理 —— 精确控制,削峰填谷
核心思想:在数据创建时(如发放优惠券),就根据其过期时间,向消息队列发送一条延迟消息。消息在准确的过期时刻才会被消费者拉取,进而触发状态更新。
实现流程:
- 创建数据时,计算其过期时间与当前时间的差值,作为消息的延迟时间。
- 将数据ID(或一批ID)作为消息体,发送一条延迟消息到队列(如RocketMQ的定时消息,或RabbitMQ的TTL+死信队列)。
- 消息到达延迟时间后被消费者消费。
- 消费者解析出数据ID,执行状态更新。
代码示例(发送延迟消息 - RocketMQ):
// 发放优惠券时
public void issueCoupon(Coupon coupon) {
couponMapper.insert(coupon);
// 计算延迟时间,单位毫秒
long delay = coupon.getExpireTime().getTime() - System.currentTimeMillis();
if (delay > 0) {
// 发送延迟消息
rocketMQTemplate.syncSend("coupon-expire-topic",
MessageBuilder.withPayload(coupon.getId()).build(),
3000, // 超时时间
delay); // 延迟发送
}
}
// 消费者
@RocketMQMessageListener(topic = "coupon-expire-topic", consumerGroup = "coupon-consumer")
public class CouponExpireListener implements RocketMQListener<Long> {
@Autowired
private CouponMapper couponMapper;
@Override
public void onMessage(Long id) {
couponMapper.updateStatus(id, CouponStatus.EXPIRED);
}
}
注意:如果数据量极其庞大,为每个个体都发送一条消息会给MQ带来压力。可以优化为按批次聚合,例如将同一分钟内过期的数据ID集合成一条消息发送。
方案评价:
- 优点:过期时间控制精确,对数据库无轮询压力,消息队列天然支持流量削峰。
- 缺点:系统复杂度增加,需处理消息丢失、重复消费等可靠性问题。
- 适用场景:对过期实时性要求高,且状态变更需要触发其他下游业务(如发送过期通知)的场景。
方案对比与选型指南
| 方案 |
实时性 |
数据库压力 |
系统复杂度 |
适用场景 |
| 分批定时任务 |
低 |
可控(分批) |
低 |
非核心数据,可接受延迟更新 |
| 惰性删除+定期清理 |
高(读时判断) |
极低 |
中 |
读多写少,容忍短暂不一致 |
| 消息队列延迟处理 |
高 |
可控(异步) |
高 |
实时性要求高,需精确触发下游动作 |
如何选择?
- 追求简单稳定:数据量不是特别大,且能接受延迟,选方案一。
- 读多写少,想最大程度保护数据库:优先考虑方案二。
- 需要精确触发,且有下游依赖:方案三是最佳选择。
实战经验与避坑要点
曾经有一个电商项目在促销后,因采用全量更新导致数据库短暂瘫痪。后续我们重构为“按月分表 + 惰性删除”的策略:用户查询时异步更新状态,凌晨再跑一个轻量级任务查漏补缺,彻底解决了问题。
无论选择哪种方案,都要牢记两个关键点:
- 幂等性:消息消费或任务重试时必须保证幂等,例如在更新前先检查当前状态。
- 监控与告警:对定时任务的执行进度、消息队列的堆积情况设置监控,一旦异常能够及时告警。
面试常见问题与回答思路
- 问:定时任务执行到一半宕机了怎么办?
答:实现断点续传。在任务中记录已处理数据的最大ID或偏移量,并持久化到数据库或Redis。任务重启后,从该点继续执行,避免数据丢失或重复处理。
- 问:消息重复消费导致状态被多次更新怎么办?
答:消费端实现幂等逻辑。例如,在更新前先查询当前状态,如果已经是目标状态则直接跳过;或者利用数据库的乐观锁机制(版本号)。
- 问:惰性删除会导致每次查询都扫全表吗?
答:不会。查询语句中会带上 WHERE expire_time > NOW() 这样的条件,数据库通过索引可以快速定位到未过期的有效数据,过期数据不会被包含在查询结果集中。
总结
处理海量数据过期,本质上是平衡实时性、资源消耗和系统复杂度。三种方案各有优劣,没有绝对的最佳,只有最适合当前场景的选择。
- 分批处理,拒绝大事务:这是处理海量数据操作的第一原则。
- 异步削峰,解耦核心链路:将非实时必要的操作异步化,能极大提升主流程的稳定性。
- 惰性思维,按需处理:很多时候,“不主动处理”比“积极处理”更能降低系统负载。
希望本文的剖析和方案对比能为你带来启发。如果你在Spring Boot项目中有类似的数据处理需求,不妨根据实际情况尝试改造。欢迎在云栈社区交流你在实践中遇到的更多挑战和解决方案。