Skip to main content

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

作者:程序员马丁

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

note

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

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

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


内容摘要:技术架构并非一成不变,合适的业务场景配合对应的架构往往能发挥最大的效用。对于类似订单或 12306 抢票这样的场景,每一笔数据都必须精确无误,这种情况下如果不依赖数据库保障一致性,单纯依赖 Redis 缓存会存在一定的不足。但对于用户优惠券场景,偶尔多发一张优惠券问题不大,因此没必要将压力过多转移到数据库,毕竟数据库是宝贵的资源。通过消息队列进行异步解耦,不仅可以减少数据库的压力,还能提高系统的吞吐量,肯定更为合适。

课程目录如下所示:

  • 业务背景
  • Git 分支
  • 开发基于消息队列秒杀逻辑
  • 文末总结

业务背景

在上一节中,我们介绍了通过数据库扣减完成用户兑换优惠券的逻辑,这种方式虽然稳妥,但性能有所不足,因为主流程的操作是同步执行的,导致响应时间变长,吞吐量下降。在本章节中,我们通过引入消息队列进行异步解耦,主流程仅同步操作 Redis,后续的数据库耗时操作则交由消息队列消费者来执行,从而提升整体性能。

Git 分支

20240910_dev_acquire-coupon-v2_seckill_ding.ma

开发基于消息队列秒杀逻辑

1. 编写兑换优惠券 v2 接口

保持原有代码不变,我们开发一个 v2 版本的方法。前置校验部分可以直接复用 v1 版本的通用逻辑。

代码如下所示:

@Override
public void redeemUserCouponByMQ(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("不满足优惠券领取时间");
}

// 获取 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));
}

UserCouponRedeemEvent userCouponRedeemEvent = UserCouponRedeemEvent.builder()
.requestParam(requestParam)
.receiveCount((int) StockDecrementReturnCombinedUtil.extractSecondField(stockDecrementLuaResult))
.couponTemplate(couponTemplate)
.userId(UserContext.getUserId())
.build();
SendResult sendResult = userCouponRedeemProducer.sendMessage(userCouponRedeemEvent);
// 发送消息失败解决方案简单且高效的逻辑之一:打印日志并报警,通过日志搜集并重新投递
if (ObjectUtil.notEqual(sendResult.getSendStatus().name(), "SEND_OK")) {
log.warn("发送优惠券兑换消息失败,消息参数:{}", JSON.toJSONString(userCouponRedeemEvent));
}
}

我们的 Event 事件仅保留必须的,也就是 v1 接口后半部分需要新增数据库、缓存的内容。

解锁付费内容,👉 戳