商城优惠券系统设计与实践:防超发、幂等与对账

简介: 本文详解大促优惠券系统防超发与幂等设计:通过Redis Lua原子扣减+数据库条件更新兜底,解决并发超发;利用唯一索引+状态机实现领券、核销、回退全流程幂等;结合定时对账保障最终一致性。压测验证5000 QPS下超发率归零,RT降至38ms。

一、问题背景

大促期间,营销优惠券是商城拉新促单的核心手段,但优惠券系统有两个典型的坑:一是并发领券时超发,券发出去的数量超过预算;二是同一用户重复领券,或者订单退款后券无法正确回补,导致资损和客诉。

我们团队在承接某零售客户的大促项目时,业务前台基于凡科商城的SaaS能力快速上线了营销页面,但优惠券的发放、核销链路需要与自研的用户中心、订单中心、财务系统打通,因此自研了券中台。上线前压测发现,领券接口在2000 QPS下存在约0.5%的超发,且重复领券拦截有漏洞。本文记录完整的解决过程。

凡科简介.jpg

二、优惠券模型设计

先确定核心表结构,券的整个生命周期需要四张表支撑:

-- 券模板表:定义一张券的预算、面额、有效期

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. **状态机 + 对账双保险**。状态机保证正常链路不出错,对账任务兜底极端异常,两者缺一不可。

这套券中台上线后经历了多次大促验证,期间还对接过凡科商城营销页面的领券入口,券的发放、核销、回补数据始终准确。如果你的商城也依赖优惠券做增长,可以参考这套方案,核心组件在阿里云上都有对应产品可以直接落地。

相关文章
|
22天前
|
消息中间件 NoSQL 关系型数据库
商城系统秒杀场景下的库存防超卖架构设计与实践
本文详述电商高并发场景下库存超卖问题的完整解决方案:从TOCTOU根因分析,到数据库行锁、Redis Lua预扣、RocketMQ异步落库+幂等保障的三阶段演进,最终基于阿里云SLB/Redis/RDS/RocketMQ/OSS构建稳定架构。5000 QPS压测零超卖,TPS达4600+,数据库CPU降至35%。
|
22天前
|
NoSQL 关系型数据库 Redis
微信商城系统技术架构选型:不同规模阶段的部署方案与实践
本文针对微信商城商家规模差异大、架构选型易“一刀切”的痛点,提出四阶段演进方案:起步期用SaaS零运维;成长期采用单体+云数据库;扩张期模块化拆分+消息队列;规模化阶段落地微服务+云原生。强调架构复杂度应随业务增长渐进演进,避免过早过度设计。(239字)
|
算法 C++ 索引
【C++STL基础入门】深入浅出string类查找字串、返回字串和交换操作
【C++STL基础入门】深入浅出string类查找字串、返回字串和交换操作
1379 1
|
开发者 Python
|
索引 存储 数据库
数据库设计规范
基于阿里数据库设计规范扩展而来
52429 4
|
消息中间件 安全 Kafka
服务调用:微服务架构的默契交流
在微服务架构中,服务调用是构建分布式系统的核心组成部分。本博客将深入探讨服务调用的概念、重要性以及如何在微服务环境中有效地进行服务之间的交流。
|
2月前
|
人工智能 自然语言处理 前端开发
最新版通义千问(Qwen3.7-Plus)功能介绍
在大模型从单一文本处理迈向多模态智能体的时代,通义千问Qwen3.7-Plus凭借“多模态交互混合智能体”的核心定位,成为平衡性能、成本与实用性的标杆产品。它并非简单的功能叠加,而是将视觉感知、文本推理、代码生成、工具调用与自主执行深度融合,实现“看、想、写、做、验”的端到端任务闭环。作为Qwen3.7系列的均衡主力,它在保留接近旗舰级文本与编程能力的同时,全面升级视觉-语言融合能力,以更亲民的成本覆盖90%以上企业与个人高频场景,是当前最具落地价值的多模态大模型之一。
1465 1
|
9月前
|
机器学习/深度学习 存储 知识图谱
🫗 知识蒸馏
知识蒸馏是一种模型压缩技术,通过让小模型(学生)模仿大模型(教师)的输出或中间特征,实现性能逼近甚至超越。核心方法包括软标签蒸馏、带温度的Softmax提升信息保留,以及特征层对齐。按信息访问程度分为黑盒与白盒蒸馏,广泛用于加速推理、降低资源消耗,同时提升泛化能力。
|
算法
关于按位运算与逻辑运算
本文介绍了按位运算与逻辑运算的基础知识,并通过一个实际问题展示了按位运算的应用。按位运算直接对二进制位操作,包括按位与(`&`)、或(`|`)、异或(`^`)、取反(`~`)、左移(`&lt;&lt;`)和右移(`&gt;&gt;`)。逻辑运算则针对布尔表达式,包含逻辑与(`&&`)、或(`||`)、非(`!`)。文中通过“寻找独特数字卡片”例题,利用异或运算特性解决数组中唯一单次出现的数字问题,算法时间复杂度为O(n),空间高效。
763 1
|
存储 负载均衡 算法
从海量数据中挖出TOP100热词,这个算法太绝了!
小米,一位热爱技术的29岁程序员,今天探讨如何在海量搜索词汇中找出最热的TOP100词汇。面对包含数百亿词汇的大文件,小米介绍了一种实用的方法:通过哈希分流将大文件拆分成小文件,接着利用哈希表统计词频,并运用小根堆选出每个小文件的TOP100词汇。最后通过外排序或再次使用小根堆选出全局TOP100。此外还提出了并行处理、内存优化及数据压缩等优化手段。这一系列技巧能有效应对大数据处理挑战。
548 9