场景
某平台有1亿用户,平均每个用户5张优惠价,一共5亿条优惠价数据,都在一张表里(表结构如下),每天有 100 万张优惠券过期,怎么设计处理系统?
CREATE TABLE coupons (id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',coupon_code VARCHAR(64) NOT NULL COMMENT '优惠券唯一码(用户可见)',user_id BIGINT NOT NULL COMMENT '用户ID',status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-未使用,2-已使用,3-已过期,4-已冻结',denomination DECIMAL(10,2) NOT NULL COMMENT '面额(如10元)',expire_time DATETIME NOT NULL COMMENT '过期时间',create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '领取时间',used_time DATETIME NULL COMMENT '使用时间',PRIMARY KEY (id),UNIQUE KEY uk_coupon_code (coupon_code) COMMENT '唯一码防重复',KEY idx_user_status (user_id, status) COMMENT '用户查可用券:按用户+未使用筛选')ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券主表';一、定时扫主表方案(危险)使用调度中心定时扫描优惠卷表修改优惠价状态,执行如下SQL:
update couponsSET status = '3'WHERE expire_time < NOW() AND status = 'unused';这个方案有如下问题:
A、数据库性能灾难
主表的expire_time和status如果没有联合索引,会触发全表扫描 ,会占很多的数据库 I/O 和 CPU,导致核心业务超时。
即使加了索引避免了全表扫描,若过期券数量很大,单次更新会生成巨大的事务日志(redo/undo log),日志刷盘会占用磁盘 IO,同样影响数据库稳定性。
B、锁竞争:阻塞正常业务操作
MySQL 的 InnoDB 引擎在执行 UPDATE 时,会对匹配的行加 “行锁”。如果单次更新 100 万条记录,这些行锁会持有几分钟甚至十几分钟,期间用户若要使用其中某张券(比如用户刚好想用一张快过期的券付款),会被阻塞直到锁释放,直接导致业务卡顿。
C、数据一致性风险
如果定时任务执行到一半(比如更新了 50 万条后),数据库突然宕机,重启后无法判断 “哪些券已经更新,哪些没更新”—— 重新执行会导致重复更新(把已过期的券再更一遍,虽不影响状态,但冗余操作),不执行又会遗漏 50 万条,最终导致 “部分过期券仍显示为可用“,引发业务问题。
二、过期任务表方案(解耦、推荐)
某大厂的核心思路是 “用轻量任务表减轻主表扫描压力”,细节如下:
1、剥离出 优惠券过期任务表(coupon_expire_task):仅服务过期处理
CREATE TABLE coupon_expire_task (id BIGINT NOT NULL AUTO_INCREMENT COMMENT '任务ID',coupon_code VARCHAR(64) NOT NULL COMMENT '优惠券编号',expire_time DATETIME NOT NULL COMMENT '优惠券过期时间',task_status TINYINT NOT NULL DEFAULT 0 COMMENT '任务状态, 0-待处理,1-已处理,2-处理失败',create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '任务创建时间',PRIMARY KEY (id),UNIQUE KEY uk_coupon_code (coupon_code) COMMENT '避免重复创建任务',KEY idx_expire_taskstatus (expire_time,task_status) COMMENT '核心索引,查待处理的过期任务') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券过期任务表';这张表是 “轻量中间层”,只存 3 个关键信息:coupon_code(关联主表)、expire_time(判断过期)、task_status(任务状态),数据量虽大但单条记录小,查询效率高。
关键设计:idx_expire_taskstatus联合索引, 让“查询过期且未处理的任务”(WHERE expire_time < NOW() AND task_status = 0)能走索引,避免全表扫描,这是整个方案的性能核心。
2、核心业务点
(1)用户领券:双表事务同步,确保任务不遗漏
用户领取优惠券时,必须同时在 “主表” 和 “任务表” 插入数据,且用事务保证一致性。
(2)用户用券:清理任务表,避免无效扫描
用户使用优惠券时,主表状态会更新为 “已使用”,此时任务表的“过期任务” 已无意义 —— 必须同步删除或标记任务表记录,避免定时任务重复扫描已用券。
(3)过期处理:定时任务批量处理,控制负载
用分布式定时任务调度器按固定频率(如 1 分钟)触发,每次只处理一批数据(如 1000 条),避免单次任务负载过高。
核心逻辑如下:
// 分布式定时任务:每分钟执行一次@XxlJob("handleExpiredCoupon")public void handleExpiredCoupon() {// 1、批量查询待处理的过期任务(每次1000条,控制负载)List taskList = expireTaskMapper.selectExpiredTasks(new Date(), 0, 1000);// 2、批量修改优惠卷主表的状态为“已过期”List couponCodes = taskList.stream().map(CouponExpireTask::getCouponCode).collect(Collectors.toList());couponMapper.batchUpdateStatus(couponCodes, 3, new Date());// 3、批量修改任务表状态为“已处理”List taskIds = taskList.stream().map(CouponExpireTask::getId).collect(Collectors.toList());expireTaskMapper.batchUpdateTaskStatus(taskIds, 1)}(4)定期清理任务表,保持轻量化
任务表的 “已处理” 记录会越来越多,需要另一个定时任务(如每天凌晨 3 点),删除 “已处理且创建时间超过 30 天” 的记录。
DELETE FROM coupon_expire_taskWHERE task_status = 1 AND create_time < DATE_SUB(NOW(),INTERVAL 30 DAY);3、方案优势
性能可控:定时任务只扫描轻量的任务表,且走联合索引,避免主表全表扫描;每次处理 1000 条,不会压垮数据库;
可靠性高:领券用事务保证双表一致,过期处理 “先更主表、再更任务表”—— 即使中间失败,任务表仍是 “待处理”,下轮仍可处理;
架构解耦:过期处理逻辑独立于 “领券、用券” 核心业务,后续可单独调整定此逻辑,不影响核心业务。
- MySQL8 磁盘只剩 10G,380G 巨表不敢 DELETE/ALTER?一招原地瘦身 70%
- MySQL 30 个配置改完 TPS 翻 10 倍,结果内存纹丝不动:60% 稳如老狗
- 同样的 SQL,为啥别人 1000 并发不卡?99% 程序员没注意的行锁细节
- MySQL多表关联使用 NLJ 发挥最大效能
- xxl_job巧妙的设计保证任务精准调度
- 同样是分库分表,为啥淘宝查订单这么快?核心就这 2 个设计
- 百万用户 IM 不卡顿?秘密藏在 3 张数据库表,信箱表设计太巧妙了
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.