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

1951

积分

0

好友

314

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

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

电商大促活动结束后,如何处理海量优惠券的集中过期,是很多技术团队都曾面临过的挑战。我们来算一笔账:假设你有1000万张优惠券需要在今晚零点准时过期。一个简单的 UPDATE coupon SET status = 'expired' WHERE expire_time <= NOW() AND status = 'active' 语句,直接命中数据库会发生什么?

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

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

这意味着从零点开始,你的数据库将承受持续半小时的高压,期间所有相关的优惠券查询、使用操作都可能被阻塞或延迟,用户体验会急剧下降。更糟的是,如果过期逻辑还涉及其他连带操作(如返还预算、发送到期通知),情况会更加复杂。

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

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

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

优惠券过期四大方案对比流程图:从直接全量更新到被动过期+主动巡检

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

这是最直接、最容易理解的改进方案。核心思想是“化整为零”:将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. 扫表压力SELECT ... LIMIT 分页查询在偏移量很大时(深分页)会越来越慢。
    2. 时效性:总耗时依然较长,1000万/5000=2000批,即使每批0.1秒,也需200秒以上。
    3. 精确时间:无法做到在“零点整”这个精确瞬间全部过期,因为处理本身需要时间。

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

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

为了解决方案一的深分页问题,并更好地控制进度,我们可以引入时间片(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)进行异步解耦

核心架构

  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. 记录日志或发送事件
    }
}

方案优势

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

新挑战

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

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

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

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

具体实现

  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”的“僵尸”优惠券。这个任务压力很小,因为大部分优惠券已在被动访问时被更新。

方案优势

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

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

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

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

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

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

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

优惠券被动过期与主动巡检状态流转图

监控与保障

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

总结

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

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

技术选型的本质是权衡。没有最好的方案,只有最合适的方案。作为架构设计者,我们需要根据业务的数据规模、过期时效要求、系统当前负载、团队运维能力等因素,灵活选择或组合这些模式。下次当你再面临类似“海量数据批量处理”的挑战时,不妨从这几个维度思考:能否异步?能否分片?能否延迟?能否并行? 想清楚这些问题,解决方案的轮廓自然会在你脑中浮现。

记住,好的架构不是设计出来的,而是在不断的权衡和演进中生长出来的。如果想深入了解更多的后端架构实践和社区讨论,欢迎来云栈社区交流分享。




上一篇:达沃斯2024技术洞察:AI巨头交锋下的AGI时间表与行业变革
下一篇:聊聊游戏圈那103天:米哈游、叠纸们是如何在抖音同好会里“和谐共处”的
您需要登录后才可以回帖 登录 | 立即注册

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

GMT+8, 2026-1-28 16:53 , Processed in 0.259165 second(s), 43 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

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