一、问题背景
大促期间,营销优惠券是商城拉新促单的核心手段,但优惠券系统有两个典型的坑:一是并发领券时超发,券发出去的数量超过预算;二是同一用户重复领券,或者订单退款后券无法正确回补,导致资损和客诉。
我们团队在承接某零售客户的大促项目时,业务前台基于凡科商城的SaaS能力快速上线了营销页面,但优惠券的发放、核销链路需要与自研的用户中心、订单中心、财务系统打通,因此自研了券中台。上线前压测发现,领券接口在2000 QPS下存在约0.5%的超发,且重复领券拦截有漏洞。本文记录完整的解决过程。
二、优惠券模型设计
先确定核心表结构,券的整个生命周期需要四张表支撑:
-- 券模板表:定义一张券的预算、面额、有效期
CREATE TABLE coupon_template (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
template_no VARCHAR(32) NOT NULL COMMENT '模板编号',
name VARCHAR(64) NOT NULL COMMENT '券名称',
type TINYINT NOT NULL COMMENT '券类型: 1-满减券 2-折扣券 3-无门槛券',
total_quantity INT NOT NULL COMMENT '发行总量',
issued_quantity INT DEFAULT 0 COMMENT '已发放量',
amount DECIMAL(10,2) DEFAULT 0 COMMENT '面额',
min_amount DECIMAL(10,2) DEFAULT 0 COMMENT '使用门槛',
valid_start DATETIME NOT NULL,
valid_end DATETIME NOT NULL,
status TINYINT DEFAULT 1 COMMENT '1-生效 2-暂停 3-下线',
UNIQUE KEY uk_template_no (template_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 用户券表:用户领取后生成的券实例
CREATE TABLE user_coupon (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
coupon_no VARCHAR(32) NOT NULL COMMENT '券号(幂等唯一键)',
template_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
status TINYINT DEFAULT 0 COMMENT '0-未使用 1-已锁定 2-已核销 3-已过期 4-已退回',
order_id BIGINT DEFAULT NULL COMMENT '锁定/核销关联订单',
received_time DATETIME NOT NULL,
used_time DATETIME DEFAULT NULL,
expire_time DATETIME NOT NULL,
UNIQUE KEY uk_coupon_no (coupon_no),
KEY idx_user (user_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 券核销流水表:每笔核销/退回记录留痕
CREATE TABLE coupon_flow_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
coupon_no VARCHAR(32) NOT NULL,
action_type TINYINT NOT NULL COMMENT '1-发放 2-锁定 3-核销 4-退回 5-过期',
order_id BIGINT DEFAULT NULL,
operator VARCHAR(32) COMMENT '触发来源',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_coupon (coupon_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
券的状态机是:`未使用 → 锁定(下单) → 核销(支付成功)`,其中锁定环节订单未支付时允许回退为未使用。这条状态机是后面所有幂等设计的骨架。
三、防超发设计
3.1 问题根因
最开始的实现是应用层先查 `issued_quantity`,再判断是否小于 `total_quantity`,最后 UPDATE 自增:
// 错误示例:存在超发风险
int issued = templateMapper.getIssued(templateId);
if (issued < totalQuantity) {
templateMapper.incrementIssued(templateId);
issueCouponToUser(userId, templateId);
}
两个请求同时读到 `issued = 99`、`total = 100`,都会通过判断并发放,最终发出 101 张,这就是超发。
3.2 方案一:数据库原子更新
利用 SQL 的条件更新保证原子性:
UPDATE coupon_template
SET issued_quantity = issued_quantity + 1
WHERE id = #{templateId}
AND issued_quantity < total_quantity;
返回 `affected_rows = 1` 说明预算扣减成功,才允许发放。这一条 SQL 利用行锁解决并发超发,逻辑正确但性能受限:同一张模板的领券请求全部串行化在数据库行锁上,2000 QPS 时数据库连接池被打满。
3.3 方案二:Redis 预算池 + Lua 原子扣减
把每个模板的剩余预算同步到 Redis,用 Lua 脚本保证扣减原子性:
-- coupon_quota_deduct.lua
local quota_key = KEYS[1]
local remaining = tonumber(redis.call('GET', quota_key))
if remaining and remaining > 0 then
redis.call('DECR', quota_key)
return 1
else
return 0
end
应用层配合**数据库兜底**:Redis 扣减成功后,异步通过 RocketMQ 消息把 `issued_quantity` 累加,用 3.2 的条件更新 SQL 做最终校验。如果数据库侧发现已超预算(affected_rows = 0),则通过消息补偿把 Redis 预算回滚。
这套"Redis 挡流量 + 数据库兜底"的方案把领券接口的瓶颈从数据库行锁转移到了 Redis,2000 QPS 下超发率降为 0。
四、领券幂等与限领
4.1 重复领券问题
用户在弱网环境反复点击领券,或者前端重试机制触发重复请求,如果接口不幂等,同一用户可能领到多张同模板券。解决方案是在用户券表上建立唯一索引,以"用户+模板+批次"作为幂等键:
ALTER TABLE user_coupon ADD UNIQUE KEY uk_user_template (user_id, template_id, batch_no);
领券逻辑改为:
@Transactional
public boolean receiveCoupon(Long userId, Long templateId, String batchNo) {
// 1. 预算扣减(Redis Lua)
Long deduct = quotaService.deductQuota(templateId);
if (deduct == null || deduct != 1L) {
return false; // 预算不足
}
// 2. 生成券实例,唯一索引兜底幂等
UserCoupon coupon = new UserCoupon();
coupon.setCouponNo(generateCouponNo());
coupon.setUserId(userId);
coupon.setTemplateId(templateId);
coupon.setBatchNo(batchNo);
try {
userCouponMapper.insert(coupon);
return true;
} catch (DuplicateKeyException e) {
// 重复领取,回滚预算
quotaService.rollbackQuota(templateId);
return false;
}
}
唯一索引是最可靠的幂等手段,比先查再插更安全——查和插之间永远存在时间窗口。
4.2 限领规则
不同模板有不同限领策略(每人限领 1 张、每日限领 2 张等)。把限领规则也做成 Lua 脚本原子执行,避免并发下破限:
-- receive_limit_check.lua
local limit_key = KEYS[1] -- 用户维度计数器
local limit = tonumber(ARGV[1])
local current = tonumber(redis.call('GET', limit_key) or '0')
if current >= limit then
return 0
end
redis.call('INCR', limit_key)
redis.call('EXPIRE', limit_key, ARGV[2]) -- 有效期,比如按天
return 1
五、核销链路与回滚
5.1 下单锁券
用户下单使用优惠券时,先把券置为"锁定"状态并记录订单号,防止订单处理期间券被其他订单使用:
public boolean lockCoupon(String couponNo, Long orderId) {
return userCouponMapper.lockCoupon(couponNo, orderId) > 0;
}
UPDATE user_coupon
SET status = 1, order_id = #{orderId}, used_time = NOW()
WHERE coupon_no = #{couponNo}
AND status = 0 -- 只允许从未使用状态锁定
5.2 支付成功核销
支付回调到达后核销券,同样用条件更新保证只核销一次:
UPDATE user_coupon
SET status = 2
WHERE coupon_no = #{couponNo}
AND status = 1
AND order_id = #{orderId}
支付回调消息可能重复投递,但 `status = 1` 的条件保证第二次更新 affected_rows 为 0,天然幂等。
5.3 订单取消回退
订单超时未支付或用户取消时,需要把券退回"未使用"状态:
UPDATE user_coupon
SET status = 0, order_id = NULL, used_time = NULL
WHERE coupon_no = #{couponNo}
AND status = 1
AND order_id = #{orderId}
这里同样依赖状态条件保证幂等。回退动作通过 RocketMQ 延迟消息触发,订单超时 15 分钟后自动执行,不需要用户感知。
六、对账与补偿
状态机保证单条链路正确,但分布式环境下总会有极端情况(消息丢失、进程重启、缓存和数据库短暂不一致)。因此必须加一道对账兜底:
每天凌晨跑定时任务做三类对账:
对账项 |
比对来源 |
处理动作 |
券发放对账 |
Redis 预算剩余 vs 数据库 issued_quantity |
差异超过阈值触发告警,人工介入 |
券核销对账 |
用户券表已核销数量 vs 订单支付成功且使用券的数量 |
漏核销的自动补核销,多核销的告警 |
锁定超时对账 |
锁定状态且关联订单已关闭的券 |
自动执行回退,补发补偿事件 |
对账任务基于阿里云 DataWorks 定时调度,SQL 层面直接比对两张表:
-- 找出锁定但订单已关闭的券,执行回退
SELECT c.coupon_no, c.order_id
FROM user_coupon c
JOIN orders o ON c.order_id = o.id
WHERE c.status = 1
AND o.status = 5; -- 5-已关闭
七、整体架构与压测
生产环境基于阿里云产品部署:
层级 |
组件 |
作用 |
接入层 |
阿里云SLB |
负载均衡 |
应用层 |
ECS集群 |
券中台服务,水平扩展 |
缓存层 |
阿里云Redis |
预算池、限流计数器、限领计数 |
存储层 |
阿里云RDS for MySQL |
券模板、用户券、流水持久化 |
消息层 |
阿里云RocketMQ |
发放落库、核销、延迟回退消息 |
调度层 |
阿里云DataWorks |
每日对账任务定时调度 |
使用阿里云PTS压测领券和核销两个核心接口:
指标 |
优化前 |
优化后 |
并发量 |
2000 QPS |
5000 QPS |
超发率 |
0.5% |
0% |
重复领券 |
存在漏洞 |
0(唯一索引兜底) |
平均RT |
180ms |
38ms |
数据库CPU |
88% |
30% |
八、总结
优惠券系统的核心设计原则可以归纳为三点:
1. **预算扣减必须原子**。Redis Lua + 数据库条件更新兜底,是防超发的标准解法,不要用"先查后改"。
2. **幂等靠唯一约束,不靠先查后判**。唯一索引兜底重复领券,状态条件更新兜底重复核销,这是最省心的方案。
3. **状态机 + 对账双保险**。状态机保证正常链路不出错,对账任务兜底极端异常,两者缺一不可。
这套券中台上线后经历了多次大促验证,期间还对接过凡科商城营销页面的领券入口,券的发放、核销、回补数据始终准确。如果你的商城也依赖优惠券做增长,可以参考这套方案,核心组件在阿里云上都有对应产品可以直接落地。