开发兑换/秒杀优惠券功能
业务背景
在我们兑换/秒杀优惠券模板的接口中,可能会存在以下三个难点:
- 高并发流量压力:秒杀活动往往会瞬间吸引大量用户访问系统,导致流量骤增,如果直接访问数据库,可能会让数据库负载过重,甚至导致宕机。
- 库存超卖问题:由于并发请求,多个用户同时抢购可能会导致系统超卖,即多个用户同时购买到同一库存。
- 用户超领问题:优惠券中会有一个限制,每个用户限流几张,应该如何避免用户领取超过这个限制。
在接下来的讲解中,我们会逐一完成这些难点说明和解决方案讲解。
优惠券秒杀前置拦截
1. 验证优惠券
首先呢,我们应该对前端传来的数据秉承着完全不可信原则,首先验证是否存在,其次呢验证优惠券是否有效活动期间。
2. 扣减缓存
如果验证优惠券模板没有问题,那我们开始进行库存扣减和验证用户是否领取优惠券超额。
为了避免访问库存扣减和判断用户是否已超额领取优惠券多次 Redis 请求,所以我们还是依然采用 Redis Lua 脚本执行。
优惠券保存数据库
1. 扣减 MySQL 优惠券库存
因为我们要加事务,中间遇到问题可以回滚数据库优惠券模板库存,但是如果加到整个方法感觉又不是很合适,因为前面的验证是不需要事务的。所以,我们采用编程式事务,自己开启、提交和回滚事务。
long extractSecondField = StockDecrementReturnCombinedUtil.extractSecondField(stockDecrementLuaResult);
transactionTemplate.executeWithoutResult(status -> {
try {
int decremented = couponTemplateMapper.decrementCouponTemplateStock(Long.parseLong(requestParam.getShopNumber()), Long.parseLong(requestParam.getCouponTemplateId()), 1L);
if (!SqlHelper.retBool(decremented)) {
throw new ServiceException("优惠券已被领取完啦");
}
代码如下所示:
我们在进行库存扣减时,依然采用类似于乐观锁的机制进行扣减。并且在扣减的基础上,为了避免被多扣,在判断条件里,我们加上了必须大于等于当前库存才可以扣减成功。
SQL 如下所示:
<!-- 通过乐观机制原子扣减优惠券模板库存 -->
<update id="decrementCouponTemplateStock">
UPDATE t_coupon_template
SET stock = stock - #{decrementStock}
WHERE shop_number = #{shopNumber}
AND id = #{couponTemplateId}
AND stock >= #{decrementStock}
</update>
通过之前的章节证明,这个 SQL 记录本质上底层还是 MySQL 行锁,避免扣减冲突。
乐观锁体现:
不需要显式地加锁,而是在更新时检查条件。
如果条件不满足(比如库存已被其他线程消耗),本次更新就会失败。
调用方可以根据返回的影响行数判断是否更新成功,从而决定后续操作
2. 添加用户领券记录
如果扣减数据库成功,那我们则将优惠券领取记录保存到 t_user_coupon 表中。
3. 保存用户领券缓存
添加数据库如果没有异常的话,那我们应该将用户已领取的优惠券添加到 Redis 缓存中
4. 发送优惠券到期事件
在上面代码的基础上,如果都执行成功,我们需要发送个 RocketMQ 延时队列,在指定时间后将优惠券模板的状态设置为已过期状态。
重构优惠券秒杀方案
1. 现有技术方案问题
细心的同学可能发现了一个问题,在如此高并发的场景下,在一个事务中操作了这么多 Redis 和 RocketMQ,就会导致事务时间延长以及接口响应速度变慢等问题。
我们在兑换/秒杀优惠券接口的事务中共执行了以下逻辑:
- 操作优惠券库存表进行扣减库存;
- 添加优惠券模板到用户领券表;
- 保存优惠券模板到用户 Redis 领券记录中;
- 查询用户 Redis 领券记录是否持久化成功;
- 发送 RocketMQ 消息队列延时消息,到期修改用户优惠券状态。
其中 3、4、5 步骤逻辑都是在数据库操作成功的基础上执行的,那我们就可以通过 Canal 监听 Binlog 机制,异步执行这些逻辑就好了,这样就能不占用主逻辑的事务和响应时间了
2. Canal 改造现有秒杀架构
2.1 什么是 Canal
译意为水道/管道/沟渠,主要用途是基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费。
2.2 MySQL 开启 Binlog 监听
开启 Binlog 写入功能
2.3 安装 Canal 中间件
2.4 监听 Canal RocketMQ Topic
一般来说,针对高并发的 Binlog 监听,我们都是将 Canal 的 Binlog 数据丢到消息队列中。Canal 会将 Binlog 的变更内容推送到指定的 RocketMQ Topic。因此,在 Spring Boot 应用中,我们只需要与 RocketMQ 进行对接即可。