找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3492

积分

0

好友

480

主题
发表于 17 小时前 | 查看: 3| 回复: 0

想象一下这个场景:“双十一”大促结束,后台有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条),循环执行更新。配合分布式锁,确保在集群环境下任务不会被重复执行。

实现流程

  1. 定时任务触发,尝试获取分布式锁。
  2. 获取锁成功后,查询待过期数据的总量,并计算总批次数。
  3. 进入循环:每次查询一批数据的ID,执行批量更新,提交事务,并记录当前处理到的偏移量(用于断点续传)。
  4. 任务意外中断后,重启时可从上次记录的偏移量继续执行。

代码示例(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的智慧

核心思想:不主动去更新过期状态,而是在每次查询时进行判断。若发现数据已过期,则触发一个异步任务去更新其状态。同时,搭配一个低优先级的后台定时任务,定期清理那些“漏网之鱼”。

实现流程

  1. 用户查询优惠券时,应用层代码判断 expire_time 是否已过当前时间。
  2. 若已过期,则触发异步操作(如发送一条消息到消息队列),并给用户返回“已失效”的结果。
  3. 异步消费者收到消息后,执行单条状态更新。
  4. 每日凌晨执行一个兜底的定时任务,扫描并批量修复所有过期但状态未更新的数据。

代码示例(查询时触发异步更新)

@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);
    }
}

生活化类比:这就像超市不会在每天打烊后全盘清查所有商品,而是等到顾客拿着商品到收银台结账时,收银系统发现商品过期再通知理货员下架。这样保证了购物流程顺畅,又避免了每天闭店时的大规模清点。

方案评价

  • 优点:读操作路径几乎无额外开销,数据库压力极小。
  • 缺点:存在短暂的数据不一致窗口(已过期但状态未及时更新),过期数据会持续占用存储空间。
  • 适用场景:读多写少,对短暂不一致有容忍度的业务,如用户端查询个人优惠券列表。

方案三:基于消息队列的延迟处理 —— 精确控制,削峰填谷

核心思想:在数据创建时(如发放优惠券),就根据其过期时间,向消息队列发送一条延迟消息。消息在准确的过期时刻才会被消费者拉取,进而触发状态更新。

实现流程

  1. 创建数据时,计算其过期时间与当前时间的差值,作为消息的延迟时间。
  2. 将数据ID(或一批ID)作为消息体,发送一条延迟消息到队列(如RocketMQ的定时消息,或RabbitMQ的TTL+死信队列)。
  3. 消息到达延迟时间后被消费者消费。
  4. 消费者解析出数据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集合成一条消息发送。

方案评价

  • 优点:过期时间控制精确,对数据库无轮询压力,消息队列天然支持流量削峰。
  • 缺点:系统复杂度增加,需处理消息丢失、重复消费等可靠性问题。
  • 适用场景:对过期实时性要求高,且状态变更需要触发其他下游业务(如发送过期通知)的场景。

方案对比与选型指南

方案 实时性 数据库压力 系统复杂度 适用场景
分批定时任务 可控(分批) 非核心数据,可接受延迟更新
惰性删除+定期清理 高(读时判断) 极低 读多写少,容忍短暂不一致
消息队列延迟处理 可控(异步) 实时性要求高,需精确触发下游动作

如何选择?

  • 追求简单稳定:数据量不是特别大,且能接受延迟,选方案一
  • 读多写少,想最大程度保护数据库:优先考虑方案二
  • 需要精确触发,且有下游依赖方案三是最佳选择。

实战经验与避坑要点

曾经有一个电商项目在促销后,因采用全量更新导致数据库短暂瘫痪。后续我们重构为“按月分表 + 惰性删除”的策略:用户查询时异步更新状态,凌晨再跑一个轻量级任务查漏补缺,彻底解决了问题。

无论选择哪种方案,都要牢记两个关键点:

  1. 幂等性:消息消费或任务重试时必须保证幂等,例如在更新前先检查当前状态。
  2. 监控与告警:对定时任务的执行进度、消息队列的堆积情况设置监控,一旦异常能够及时告警。

面试常见问题与回答思路

  • :定时任务执行到一半宕机了怎么办?
    :实现断点续传。在任务中记录已处理数据的最大ID或偏移量,并持久化到数据库或Redis。任务重启后,从该点继续执行,避免数据丢失或重复处理。
  • :消息重复消费导致状态被多次更新怎么办?
    :消费端实现幂等逻辑。例如,在更新前先查询当前状态,如果已经是目标状态则直接跳过;或者利用数据库的乐观锁机制(版本号)。
  • :惰性删除会导致每次查询都扫全表吗?
    :不会。查询语句中会带上 WHERE expire_time > NOW() 这样的条件,数据库通过索引可以快速定位到未过期的有效数据,过期数据不会被包含在查询结果集中。

总结

处理海量数据过期,本质上是平衡实时性、资源消耗和系统复杂度。三种方案各有优劣,没有绝对的最佳,只有最适合当前场景的选择。

  1. 分批处理,拒绝大事务:这是处理海量数据操作的第一原则。
  2. 异步削峰,解耦核心链路:将非实时必要的操作异步化,能极大提升主流程的稳定性。
  3. 惰性思维,按需处理:很多时候,“不主动处理”比“积极处理”更能降低系统负载。

希望本文的剖析和方案对比能为你带来启发。如果你在Spring Boot项目中有类似的数据处理需求,不妨根据实际情况尝试改造。欢迎在云栈社区交流你在实践中遇到的更多挑战和解决方案。




上一篇:从Docker到GitOps:实战指南教你如何构建基础设施即代码(IaC)技术栈
下一篇:CUDA Warp Vote原语解析:掌握并行条件判断与归约的核心技术
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-23 19:59 , Processed in 0.423585 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表