优惠券平台(十五):实现兑换/秒杀优惠券功能(2)

embedded/2025/2/10 23:31:33/

业务背景

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

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

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

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

代码如下所示:

java">@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));}
}

2. 消息消费者

开发用户兑换优惠券消息消费者,并通过幂等注解避免消息重复消费。

代码如下所示:

java">@Component
@RequiredArgsConstructor
@RocketMQMessageListener(topic = EngineRockerMQConstant.COUPON_TEMPLATE_REDEEM_TOPIC_KEY,consumerGroup = EngineRockerMQConstant.COUPON_TEMPLATE_REDEEM_CG_KEY
)
@Slf4j(topic = "UserCouponRedeemConsumer")
public class UserCouponRedeemConsumer implements RocketMQListener<MessageWrapper<UserCouponRedeemEvent>> {
​private final UserCouponMapper userCouponMapper;private final CouponTemplateMapper couponTemplateMapper;private final UserCouponDelayCloseProducer couponDelayCloseProducer;private final StringRedisTemplate stringRedisTemplate;
​@NoMQDuplicateConsume(keyPrefix = "user-coupon-redeem:",key = "#messageWrapper.keys",keyTimeout = 600)@Transactional(rollbackFor = Exception.class)@Overridepublic void onMessage(MessageWrapper<UserCouponRedeemEvent> messageWrapper) {// 开头打印日志,平常可 Debug 看任务参数,线上可报平安(比如消息是否消费,重新投递时获取参数等)log.info("[消费者] 用户兑换优惠券 - 执行消费逻辑,消息体:{}", JSON.toJSONString(messageWrapper));
​CouponTemplateRedeemReqDTO requestParam = messageWrapper.getMessage().getRequestParam();CouponTemplateQueryRespDTO couponTemplate = messageWrapper.getMessage().getCouponTemplate();String userId = messageWrapper.getMessage().getUserId();
​int decremented = couponTemplateMapper.decrementCouponTemplateStock(Long.parseLong(requestParam.getShopNumber()), Long.parseLong(requestParam.getCouponTemplateId()), 1L);if (!SqlHelper.retBool(decremented)) {log.warn("[消费者] 用户兑换优惠券 - 执行消费逻辑,扣减优惠券数据库库存失败,消息体:{}", JSON.toJSONString(messageWrapper));return;}
​// 添加 Redis 用户领取的优惠券记录列表Date now = new Date();DateTime validEndTime = DateUtil.offsetHour(now, JSON.parseObject(couponTemplate.getConsumeRule()).getInteger("validityPeriod"));UserCouponDO userCouponDO = UserCouponDO.builder().couponTemplateId(Long.parseLong(requestParam.getCouponTemplateId())).userId(Long.parseLong(userId)).source(requestParam.getSource()).receiveCount(messageWrapper.getMessage().getReceiveCount()).status(UserCouponStatusEnum.UNUSED.getCode()).receiveTime(now).validStartTime(now).validEndTime(validEndTime).build();userCouponMapper.insert(userCouponDO);
​// 添加用户领取优惠券模板缓存记录String userCouponListCacheKey = String.format(EngineRedisConstant.USER_COUPON_TEMPLATE_LIST_KEY, UserContext.getUserId());String userCouponItemCacheKey = StrUtil.builder().append(requestParam.getCouponTemplateId()).append("_").append(userCouponDO.getId()).toString();stringRedisTemplate.opsForZSet().add(userCouponListCacheKey, userCouponItemCacheKey, now.getTime());
​// 由于 Redis 在持久化或主从复制的极端情况下可能会出现数据丢失,而我们对指令丢失几乎无法容忍,因此我们采用经典的写后查询策略来应对这一问题Double scored;try {scored = stringRedisTemplate.opsForZSet().score(userCouponListCacheKey, userCouponItemCacheKey);// scored 为空意味着可能 Redis Cluster 主从同步丢失了数据,比如 Redis 主节点还没有同步到从节点就宕机了,解决方案就是再新增一次if (scored == null) {// 如果这里也新增失败了怎么办?我们大概率做不到绝对的万无一失,只能尽可能增加成功率stringRedisTemplate.opsForZSet().add(userCouponListCacheKey, userCouponItemCacheKey, now.getTime());}} catch (Throwable ex) {log.warn("[消费者] 用户兑换优惠券 - 执行消费逻辑,查询Redis用户优惠券记录为空或抛异常,可能Redis宕机或主从复制数据丢失,基础错误信息:{}", ex.getMessage());// 如果直接抛异常大概率 Redis 宕机了,所以应该写个延时队列向 Redis 重试放入值。为了避免代码复杂性,这里直接写新增,大家知道最优解决方案即可stringRedisTemplate.opsForZSet().add(userCouponListCacheKey, userCouponItemCacheKey, now.getTime());}
​// 发送延时消息队列,等待优惠券到期后,将优惠券信息从缓存中删除UserCouponDelayCloseEvent userCouponDelayCloseEvent = UserCouponDelayCloseEvent.builder().couponTemplateId(requestParam.getCouponTemplateId()).userCouponId(String.valueOf(userCouponDO.getId())).userId(userId).delayTime(validEndTime.getTime()).build();SendResult sendResult = couponDelayCloseProducer.sendMessage(userCouponDelayCloseEvent);
​// 发送消息失败解决方案简单且高效的逻辑之一:打印日志并报警,通过日志搜集并重新投递if (ObjectUtil.notEqual(sendResult.getSendStatus().name(), "SEND_OK")) {log.warn("[消费者] 用户兑换优惠券 - 执行消费逻辑,发送优惠券关闭延时队列失败,消息参数:{}", JSON.toJSONString(userCouponDelayCloseEvent));}}
}

本章总结

主要就是通过消息队列进行重构,创建了新的用户优惠券兑换消费者:UserCouponRedeemConsumer和用户优惠券生产者:UserCouponRedeemProducer。并且消费者也使用了之前的Spring AOP环绕通知面向切面思想用于幂等性判断,总体流程图如下:

方案存在的问题

1. Redis 极端场景

Redis 提供了两套持久化机制,RDB 快照和 AOF 日志文件追加。

  • RDB 它会根据情况定期的 Fork 出一个子进程,生成当前数据库的全量快照。对于 RDB 快照,假如我们在 RDB 快照生成后宕机,那么会丢失快照生成期间全部增量数据,如果在连快照都没成功生成,那么就会丢掉全部数据

  • 另一个是 AOF,它通过向 AOF 日志文件追加每一条执行过的指令实现。而当我们仅开启了 AOF 时,丢失数据的多少取决于我们设置的刷盘策略:当设置为每条指令执行后都刷盘 Always,我们最多丢失一条指令;当设置为每秒刷一次盘的 Eversec 时,最多丢失一秒内的数据;当设置为非主动刷盘的 No 时,则可能丢失上次刷盘后到现在的全部数据。

2. 库存扣减的几种场景

在应对于企业中不同场景的库存扣减需求,这里分析下:

  • 在商品流量较低的情况下,通常不会出现大量请求同时访问单个商品进行库存扣减。此时,可以使用 Redis 进行防护,并直接同步到 MySQL 进行库存扣减,以防止商品超卖。虽然在此场景中涉及多个商品的数据扣减,可能会出现锁竞争,但竞争程度通常不会很激烈。

  • 对于秒杀商品,通常会在短时间内出现大量请求同时访问单个商品进行库存扣减。为此,可以使用 Redis 进行防护,并直接将库存扣减同步到 MySQL,以防止商品超卖。由于秒杀商品的库存一般较少,因此造成的锁竞争相对可控。假设库存扣减采用串行方式,每次扣减耗时 5 毫秒,处理 100 个库存也仅需 500 毫秒。

  • 某些秒杀商品的库存较多,或同时进行多个热门商品的秒杀(如直播间商品)。在这种情况下,直接扣减数据库库存会给系统带来较大压力,导致接口响应延迟。为应对这种场景,我们设计了优惠券秒杀 v2 接口。虽然基于 Redis 扣减库存和消息队列异步处理的方案可能会引发前后不一致的问题,但它能显著提升性能。此外,Redis 的持久化和主从宕机的风险相对较小。即使发生宕机,对平台或商家来说,也不会造成直接的损失。

    不存在绝对的银弹。Redis 之所以能快速响应,是因为它直接与内存交互,作为缓存中间件,如果每次都为了数据一致性而与磁盘交互,那就本末倒置了。市场上的云 Redis,包括腾讯 Redis 和阿里云 Tair,它们的持久化和主从复制本质上都是异步的。


    http://www.ppmy.cn/embedded/161195.html

    相关文章

    22.2、Apache安全分析与增强

    目录 Apache Web安全分析与增强 - Apache Web概述Apache Web安全分析与增强 - Apache Web安全威胁Apache Web安全机制Apache Web安全增强 Apache Web安全分析与增强 - Apache Web概述 阿帕奇是一个用于搭建WEB服务器的应用程序&#xff0c;它是开源的&#xff0c;它的配置文件…

    【在线优化】【有源程序】基于遗传算法(GA)和粒子群优化(PSO)算法的MPPT控制策略

    目录 一、背景 二、源程序及结果 2.1 simulink仿真程序 2.2 GA模块源程序 2.3 PSO模块源程序 三、程序运行结果 3.1 基于GA优化的MPPT 3.2 基于PSO优化的MPPT 一、背景 MPPT策略能够显著提高光伏、风电等发电效率&#xff0c;节省大量成本。该策略的经典算法是&#xf…

    Elasticsearch:如何使用 Elastic 检测恶意浏览器扩展

    作者&#xff1a;来着 Elastic Aaron Jewitt 当你的 CISO 询问你的任何工作站上是否安装过特定的浏览器扩展时&#xff0c;你多快能得到正确答案&#xff1f;恶意浏览器扩展是一个重大威胁&#xff0c;许多组织无法管理或检测。这篇博文探讨了 Elastic Infosec 团队如何使用 os…

    redis的数据结构介绍(string

    redis是键值数据库&#xff0c;key一般是string类型&#xff0c;value的类型很多 string&#xff0c;hash&#xff0c;list&#xff0c;set&#xff0c;sortedset&#xff0c;geo&#xff0c;bitmap&#xff0c;hyperlog redis常用通用命令&#xff1a; keys&#xff1a; …

    docker环境下部署face-search开源人脸识别模型

    由于我们是直接将face-search部署在docker容器中的,所以,在部署之前一定要检查一下自己的docker环境,要不然部署过程中会出现各种各样的问题 我这里的docker环境是 一、安装docker环境 如果docker版本比较低或者docker-compose的版本比较低的情况下,部署的时候docker的yml…

    思科模拟器配置VRRP-详细

    VRRP的状态&#xff1a; standby 待命状态 speak 发言 active 激活 VRRP基础配置案例 网络图 R1配置命令 Router>enable Router#conf t Router(config)#hostname R1 R1(config)#int g0/0 R1(config-if)#no shutdown R1(config-if)#ip add 192.168.10.251 255.255.255…

    SQL精度丢失:CAST(ce.fund / 100 AS DECIMAL(10, 2)) 得到 99999999.99

    当你使用 CAST(ce.fund / 100 AS DECIMAL(10, 2)) 进行计算并转换时得到 99999999.99 这个结果&#xff0c;可能由以下几种原因导致&#xff1a; 1. DECIMAL 类型精度限制 DECIMAL(10, 2) 表示总共可以存储 10 位数字&#xff0c;其中小数部分占 2 位。这意味着整数部分最多只…

    STM32的HAL库开发---高级定时器

    一、高级定时器简介 1、STM32F103有两个高级定时器&#xff0c;分别是TIM1和TIM8。 2、主要特性 16位递增、递减、中心对齐计数器(计数值:0~65535)16位预分频器(分频系数:1~65536)可用于触发DAC、ADC在更新事件、触发事件、输入捕获、输出比较时&#xff0c;会产生中断/DMA请…