Java优惠券系统案例

wen java案例 3

构建高并发Java优惠券系统的核心架构与实践案例

📚 目录导读

  1. 为什么优惠券系统是Java后端的分水岭?
  2. 系统整体架构设计(含核心模块拆分)
  3. 数据库表设计与防超发难点
  4. 高并发抢券 scenarios:Redis + Lua 原子性实战
  5. 异步对账与用户资产一致性保障
  6. 经典问答:面试官必问的5个陷阱
  7. 从案例中提炼的架构思维

为什么优惠券系统是Java后端的分水岭?

在电商大促场景下,优惠券系统看似简单(无非CRUD),实则暗藏超发、重复领取、库存穿透、对账不平等致命问题,一个合格的Java工程师能写CRUD,但只有能解决并发下数据一致性问题的人,才配称为架构师,本文结合某电商平台真实重构案例,拆解一套日发券量超千万的Java优惠券系统。

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只做最终落库。

从案例中提炼的架构思维

  1. 读多写少用缓存,写多读少用队列:抢券是典型的写并发,需用Redis原子扣减。
  2. 最终一致性优于强一致性:允许一瞬间的不一致,但必须通过对账闭环。
  3. 拆分粒度决定性能:用户券表分64个库,避免单库热点。
  4. 失败重试 + 幂等设计:所有写操作必须有唯一ID,才能安全重试。
  5. 监控告警:对Redis内存、MQ积压量、对账差值设置阈值告警。

注:本文所有技术方案基于公开案例及作者多年后端实战总结,不涉及具体商业系统内部数据,如需完整源码,可参考GitHub开源项目 mall-coupon(仅作学习用途)。

(完)

抱歉,评论功能暂时关闭!