构建高并发Java优惠券系统的核心架构与实践案例
📚 目录导读
- 为什么优惠券系统是Java后端的分水岭?
- 系统整体架构设计(含核心模块拆分)
- 数据库表设计与防超发难点
- 高并发抢券 scenarios:Redis + Lua 原子性实战
- 异步对账与用户资产一致性保障
- 经典问答:面试官必问的5个陷阱
- 从案例中提炼的架构思维
为什么优惠券系统是Java后端的分水岭?
在电商大促场景下,优惠券系统看似简单(无非CRUD),实则暗藏超发、重复领取、库存穿透、对账不平等致命问题,一个合格的Java工程师能写CRUD,但只有能解决并发下数据一致性问题的人,才配称为架构师,本文结合某电商平台真实重构案例,拆解一套日发券量超千万的Java优惠券系统。

系统整体架构设计(含核心模块拆分)
我们采用 Spring Cloud Alibaba + Redis Cluster + RocketMQ + ShardingSphere 技术栈,核心模块如下:
- 券模板服务:管理券的规则(满减、折扣、有效期),写入MySQL,同步至Redis。
- 发券服务:接收用户请求,预扣库存,异步落库。
- 用户券服务:用户资产查看,核销逻辑。
- 对账任务:定时校验库存流水与用户记录。
关键设计原则:将“券模板”与“用户券实例”分离,模板是静态的,实例是动态的,避免单表过大。
数据库表设计与防超发难点
常见错误设计是单表 coupon 字段过多,导致锁竞争激烈,我们拆分为:
-- 券模板表(库存独立) CREATE TABLE coupon_template ( id BIGINT PRIMARY KEY, stock INT NOT NULL, -- 总库存 remain_stock INT NOT NULL, -- 剩余库存(采用乐观锁更新) version INT DEFAULT 0 ); -- 用户券表(分库分表,按user_id mod 64) CREATE TABLE user_coupon ( id BIGINT PRIMARY KEY, user_id BIGINT, template_id BIGINT, status TINYINT, -- 0未使用 1已使用 2过期 create_time DATETIME );
防超发的核心:不能用 select * from template where id=? 再 update,必须用 update ... set remain_stock=remain_stock-1 where id=? and remain_stock>0,但单纯数据库行锁在峰值(10万QPS)下会拖垮主库,因此引入Redis。
高并发抢券 scenarios:Redis + Lua 原子性实战
场景还原:双11零点,100万用户抢1万张券,如果全部走MySQL,行锁等待会让系统直接雪崩。
解决方案:使用 Redis 预扣库存,结合 Lua 脚本保证原子性。
-- 伪代码 KEYS[1]=stockKey ARGV[1]=userId
local stock = tonumber(redis.call('get', KEYS[1]))
if stock and stock > 0 then
redis.call('decr', KEYS[1])
redis.call('sadd', 'bought_users', ARGV[1]) -- 防止重复领取
return 1
else
return -1
end
Java端调用:
DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaContent, Long.class);
Long result = redisTemplate.execute(script, Arrays.asList("coupon:stock:1001"), String.valueOf(userId));
异步落库:抢到后发送MQ消息,消费者异步写入 user_coupon 表,达到最终一致性,注意:一定要在Redis成功后发送MQ,否则会丢失。
异步对账与用户资产一致性保障
异步带来的一致性问题如何兜底?我们设计了对账Job(每小时执行一次):
- 对比Redis当日扣减数量 vs MySQL中用户券数量。
- 若MySQL少,则说明MQ消息丢失,主动补偿发券。
- 若MySQL多,则说明重复消费,根据唯一索引
(user_id, template_id)删除多余数据。
幂等策略:在用户券表增加 biz_id(雪花算法生成),消费MQ时先查 biz_id 是否存在,存在则跳过。
经典问答:面试官必问的5个陷阱
Q1:为什么不用数据库悲观锁 for update?
悲观锁会锁行,导致其他请求等待,在秒杀场景下,等待时间不可控,Redis+Lua 是原子操作,性能高出几个数量级。
Q2:Redis扣减后,如果服务宕机,库存和MySQL不一致怎么办?
先扣Redis,再发MQ,若宕机在MQ发送前,则Redis已扣但MySQL未加,解决:对账Job从Redis读取当日扣减流水(可用
appendonly.aof或另一个Redis记录),比对MySQL进行补偿,或者采用发券补偿表,先把“待发放”写入本地事务表,再异步发送。
Q3:多张券叠加规则如何设计?
不要试图在SQL中计算最优组合,采用策略模式:定义
DiscountCalculator接口,分别实现满减、折扣、无门槛,在用户下单时,用规则引擎选取最优券(贪心算法即可,实际业务不需要背包DP,除非做全站最优)。
Q4:如何防止黑产刷券?
增加设备指纹、手机号风控,但最有效是令牌桶限流 + 每用户限购1次(通过Redis SETNX实现),在发券前增加滑动验证码,成本可控。
Q5:库存为什么不能直接先写MySQL再同步Redis?
这样会导致Redis预热延迟,正确做法:系统启动时全量加载模板库存到Redis,之后MySQL只做最终落库。
从案例中提炼的架构思维
- 读多写少用缓存,写多读少用队列:抢券是典型的写并发,需用Redis原子扣减。
- 最终一致性优于强一致性:允许一瞬间的不一致,但必须通过对账闭环。
- 拆分粒度决定性能:用户券表分64个库,避免单库热点。
- 失败重试 + 幂等设计:所有写操作必须有唯一ID,才能安全重试。
- 监控告警:对Redis内存、MQ积压量、对账差值设置阈值告警。
注:本文所有技术方案基于公开案例及作者多年后端实战总结,不涉及具体商业系统内部数据,如需完整源码,可参考GitHub开源项目 mall-coupon(仅作学习用途)。
(完)