Skip to main content

08小节:用户兑换秒杀优惠券功能(一)

作者:程序员马丁

在线博客:https://nageoffer.com

note

热门项目实战社群,收获国内众多知名公司面试青睐,近千名同学面试成功!助力你在校招或社招上拿个offer。

开发兑换/秒杀优惠券功能(一),元数据信息:

©版权所有 - 拿个offer-开源&项目实战星球专属学习项目,依据《中华人民共和国著作权法实施条例》《知识星球产权保护》,严禁未经本项目原作者明确书面授权擅自分享至 GitHub、Gitee 等任何开放平台。违者将面临法律追究。


内容摘要:本文介绍了优惠券兑换和秒杀业务的关键技术实现。首先,通过编程式事务保障了数据的正确性,同时整合了 Redis 和 RocketMQ 等组件行为。考虑到秒杀业务对高吞吐量的需求,我们在 v2 版本中对架构进行了优化,通过 Canal Binlog 监听机制解决了这一问题,大幅提升了系统吞吐量并减少了异常发生的概率。

课程目录如下所示:

  • 业务背景
  • Git 分支
  • 秒杀业务难点
  • 优惠券秒杀前置拦截
  • 优惠券保存数据库
  • 重构优惠券秒杀方案
  • Redis 缓存序列化器
  • 执行优惠券兑换
  • 文末总结

业务背景

通过之前的章节,我们已经把优惠券相关的业务铺垫的差不多了,接下来我们正式开启引擎服务的优惠券兑换/秒杀业务。

普通兑换自然没有那么多弯弯绕绕,但是涉及到一些比较大额或者有吸引力的优惠券来说,就不一样了,会面临大量用户抢购少量的库存,也就是我们常说的秒杀架构。和常规的秒杀系统一样,我们也是采用了缓存抗并发、然后扣减缓存成功的请求可以进行扣减数据库,并将优惠券添加到用户的领券记录中。

有一点需要和大家先说明下,因为优惠券的领取不像 12306 一样需要保障强一致性,所以,理论上可以从技术方案上做减法,会有一些容忍故障。

再和大家多扯一句,那就是面试可以造火箭,但是你得知道复杂技术方案带来的落地难度问题。有时候针对没那么重要的场景,方案简单粗暴一点并没有问题。很多同学会说缓存宕机了怎么办?缓存主从异步同步丢失数据怎么办?缓存扣减成功数据库没扣减时应用宕机了怎么办?诸如此类,只是一张优惠券而已,其实没有啥问题。

虽然我们是这么说,但还是会以较严谨的方式给大家讲解牛券的秒杀架构。

Git 分支

20240908_dev_acquire-coupon_seckill_ding.ma

秒杀业务难点

在我们兑换/秒杀优惠券模板的接口中,可能会存在以下三个难点:

  • 高并发流量压力:秒杀活动往往会瞬间吸引大量用户访问系统,导致流量骤增,如果直接访问数据库,可能会让数据库负载过重,甚至导致宕机。
  • 库存超卖问题:由于并发请求,多个用户同时抢购可能会导致系统超卖,即多个用户同时购买到同一库存。
  • 用户超领问题:优惠券中会有一个限制,每个用户限流几张,应该如何避免用户领取超过这个限制。

在接下来的讲解中,我们会逐一完成这些难点说明和解决方案讲解。

优惠券秒杀前置拦截

1. 验证优惠券

首先呢,我们应该对前端传来的数据秉承着完全不可信原则,首先验证是否存在,其次呢验证优惠券是否有效活动期间。

代码如下所示:

@Override
public void redeemUserCoupon(CouponTemplateRedeemReqDTO requestParam) {
// 验证缓存是否存在,保障数据存在并且缓存中存在
CouponTemplateQueryRespDTO couponTemplate = couponTemplateService.findCouponTemplate(BeanUtil.toBean(requestParam, CouponTemplateQueryReqDTO.class));

// 验证领取的优惠券是否在活动有效时间
boolean isInTime = DateUtil.isIn(new Date(), couponTemplate.getValidStartTime(), couponTemplate.getValidEndTime());
if (!isInTime) {
// 一般来说优惠券领取时间不到的时候,前端不会放开调用请求,可以理解这是用户调用接口在“攻击”
throw new ClientException("不满足优惠券领取时间");
}
}

2. 扣减缓存

如果验证优惠券模板没有问题,那我们开始进行库存扣减和验证用户是否领取优惠券超额。

代码如下所示:

private final static String STOCK_DECREMENT_AND_SAVE_USER_RECEIVE_LUA_PATH = "lua/stock_decrement_and_save_user_receive.lua";

@Override
public void redeemUserCoupon(CouponTemplateRedeemReqDTO requestParam) {
// ......

// 获取 LUA 脚本,并保存到 Hutool 的单例管理容器,下次直接获取不需要加载
DefaultRedisScript<Long> buildLuaScript = Singleton.get(STOCK_DECREMENT_AND_SAVE_USER_RECEIVE_LUA_PATH, () -> {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(STOCK_DECREMENT_AND_SAVE_USER_RECEIVE_LUA_PATH)));
redisScript.setResultType(Long.class);
return redisScript;
});

// 验证用户是否符合优惠券领取条件
JSONObject receiveRule = JSON.parseObject(couponTemplate.getReceiveRule());
String limitPerPerson = receiveRule.getString("limitPerPerson");

// 执行 LUA 脚本进行扣减库存以及增加 Redis 用户领券记录次数
String couponTemplateCacheKey = String.format(EngineRedisConstant.COUPON_TEMPLATE_KEY, requestParam.getCouponTemplateId());
String userCouponTemplateLimitCacheKey = String.format(EngineRedisConstant.USER_COUPON_TEMPLATE_LIMIT_KEY, UserContext.getUserId(), requestParam.getCouponTemplateId());
Long stockDecrementLuaResult = stringRedisTemplate.execute(
buildLuaScript,
ListUtil.of(couponTemplateCacheKey, userCouponTemplateLimitCacheKey),
String.valueOf(couponTemplate.getValidEndTime().getTime()), limitPerPerson
);

// 判断 LUA 脚本执行返回类,如果失败根据类型返回报错提示
long firstField = StockDecrementReturnCombinedUtil.extractFirstField(stockDecrementLuaResult);
if (RedisStockDecrementErrorEnum.isFail(firstField)) {
throw new ServiceException(RedisStockDecrementErrorEnum.fromType(firstField));
}
}

为了避免访问库存扣减和判断用户是否已超额领取优惠券多次 Redis 请求,所以我们还是依然采用 Redis Lua 脚本执行。

Lua 脚本如下所示:

-- Lua 脚本: 检查用户是否达到优惠券领取上限并记录领取次数

-- 参数列表:
-- KEYS[1]: 优惠券库存键 (coupon_stock_key)
-- KEYS[2]: 用户领取记录键 (user_coupon_key)
-- ARGV[1]: 优惠券有效期结束时间 (timestamp)
-- ARGV[2]: 用户领取上限 (limit)

local function combineFields(firstField, secondField)
-- 确定 SECOND_FIELD_BITS 为 14,因为 secondField 最大为 9999
local SECOND_FIELD_BITS = 14

-- 根据 firstField 的实际值,计算其对应的二进制表示
-- 由于 firstField 的范围是0-2,我们可以直接使用它的值
local firstFieldValue = firstField

-- 模拟位移操作,将 firstField 的值左移 SECOND_FIELD_BITS 位
local shiftedFirstField = firstFieldValue * (2 ^ SECOND_FIELD_BITS)

-- 将 secondField 的值与位移后的 firstField 值相加
return shiftedFirstField + secondField
end

-- 获取当前库存
local stock = tonumber(redis.call('HGET', KEYS[1], 'stock'))

-- 判断库存是否大于 0
if stock <= 0 then
return combineFields(1, 0) -- 库存不足
end

-- 获取用户领取的优惠券次数
local userCouponCount = tonumber(redis.call('GET', KEYS[2]))

-- 如果用户领取次数不存在,则初始化为 0
if userCouponCount == nil then
userCouponCount = 0
end

-- 判断用户是否已经达到领取上限
if userCouponCount >= tonumber(ARGV[2]) then
return combineFields(2, userCouponCount) -- 用户已经达到领取上限
end

-- 增加用户领取的优惠券次数
if userCouponCount == 0 then
-- 如果用户第一次领取,则需要添加过期时间
redis.call('SET', KEYS[2], 1)
redis.call('EXPIRE', KEYS[2], ARGV[1])
else
-- 因为第一次领取已经设置了过期时间,第二次领取沿用之前即可
redis.call('INCR', KEYS[2])
end

-- 减少优惠券库存
redis.call('HINCRBY', KEYS[1], 'stock', -1)

return combineFields(0, userCouponCount)

这里我们还是采用了之前的策略,将返回的两个参数包装为一个 long 类型的数据,并进行拆分。两个参数分别如下:

  • 请求是否成功:有 3 个参数,0 代表请求成功,1 代表优惠券已被领取完啦,2 代表用户已经达到领取上限。
  • 用户领取次数:初始化为 0,每次领取成功后自增加 1。

如果返回 0,x 代表请求成功,x 就是目前用户已领取优惠券的次数,会把这个 x 保存到数据库表 t_user_coupon 的领取次数 receive_count 字段中。

解锁付费内容,👉 戳