天机学堂——领取优惠券优化

目录

[1. 集群下的锁失效:Redisson 分布式锁登场](#1. 集群下的锁失效:Redisson 分布式锁登场)

[1.1 单机锁的局限性](#1.1 单机锁的局限性)

[1.2 分布式锁核心原理](#1.2 分布式锁核心原理)

[1.2.1 定义与特征](#1.2.1 定义与特征)

[1.2.2 基于 Redis 的简单实现](#1.2.2 基于 Redis 的简单实现)

[1.2.3 核心问题](#1.2.3 核心问题)

[1.3 为什么选择 Redisson](#1.3 为什么选择 Redisson)

[2. Redisson 分布式锁:并发安全的基础方案](#2. Redisson 分布式锁:并发安全的基础方案)

[2.1 快速集成](#2.1 快速集成)

[2.1.1 引入依赖](#2.1.1 引入依赖)

[2.1.2 核心配置(适配 Spring Boot)](#2.1.2 核心配置(适配 Spring Boot))

[2.2 基本用法](#2.2 基本用法)

[3. 高并发领券优化 V1:Redisson+MQ 异步](#3. 高并发领券优化 V1:Redisson+MQ 异步)

[3.1 优化思路](#3.1 优化思路)

[3.2 核心实现](#3.2 核心实现)

[3.2.1 Redis 缓存设计](#3.2.1 Redis 缓存设计)

[3.2.2 异步领券核心流程](#3.2.2 异步领券核心流程)

[3.2.3 MQ 消费者(异步写库)](#3.2.3 MQ 消费者(异步写库))

[4. 高并发领券优化 V2:Lua 脚本 + MQ 异步](#4. 高并发领券优化 V2:Lua 脚本 + MQ 异步)

[4.1 Lua 脚本的核心价值](#4.1 Lua 脚本的核心价值)

[4.2 核心实现](#4.2 核心实现)

[4.2.1 领券校验 + 扣减 Lua 脚本](#4.2.1 领券校验 + 扣减 Lua 脚本)

[4.2.2 业务改造(执行 Lua 脚本)](#4.2.2 业务改造(执行 Lua 脚本))

5.总结


本文主要是我个人学习天机学堂这个项目自己的一些理解和优化部分,主要是摘出项目中一些比较通用的部分,方便大家以及自己之后如果遇到了类似的业务可以进行参考使用

在高并发场景下,优惠券领取功能面临两大核心问题:集群环境下的并发安全(超发)高并发写的性能瓶颈 。本文跳过手动实现 Redis 分布式锁的繁琐环节,直接基于 Redisson 落地分布式锁,并拆解两个版本的性能优化方案**(Redisson+MQ 异步、Lua 脚本 + MQ 异步)**,聚焦核心实现,避免冗余。

1. 集群下的锁失效:Redisson 分布式锁登场

1.1 单机锁的局限性

单机环境下,Synchronized 或**ReentrantLock**能保证并发安全,但集群部署时,每个实例有独立 JVM,锁对象互不共享:

  • 同一用户的并发请求进入不同实例,多线程同时获取锁成功;
  • 优惠券超发、用户限领数量失效等问题重现。

如何解决这个问题呢,显然我们不能让每个实例去使用各自的JVM内部锁监视器,而是应该在多个实例外部寻找一个锁监视器,多个实例争抢同一把锁。比如像这样设计:

这样的锁就叫做分布式锁

1.2 分布式锁核心原理

1.2.1 定义与特征

分布式锁是跨 JVM 的共享锁,需满足:

  • 多实例可访问(共享存储);
  • 互斥性(同一时间仅一个线程持有);
  • 防死锁(超时释放);
  • 防误删(校验持有者)。

能满足上述特征的组件有很多,因此实现分布式锁的方式也非常多,例如:

  • 基于MySQL

  • 基于Redis

  • 基于Zookeeper

  • 基于ETCD

但目前使用最广泛的还应该是基于Redis的分布式锁,后面我们也采用基于Redis的分布式锁

1.2.2 基于 Redis 的简单实现

利用 Redis 的**SETNX**(不存在则设置)和过期时间机制,核心命令:

bash 复制代码
# 原子操作:不存在则设置key,过期时间20秒(NX=SETNX,EX=过期时间)
SET lock:coupon:100 1 NX EX 20
# 释放锁:删除key
DEL lock:coupon:100

利用Redis实现的简单分布式锁流程如下:

1.2.3 核心问题

①锁误删:释放锁前未校验持有者→存线程标识,删除前对比

第一个问题就是锁误删问题,目前释放锁的操作是基于DEL,但是在极端情况下会出现问题。

例如,有线程1获取锁成功,并且执行完任务,正准备释放锁:

但是因为某种原因导致释放锁的操作被阻塞了,直到锁被超时释放:

就在此时,有一个新的线程2来尝试获取锁。因为线程1的锁被超时释放,因此线程2是可以获取锁成功的:

而就在此时,线程1醒来,继续执行释放锁的操作,也就是DEL.结果就把线程2的锁给删除了:

然而此时线程2还在执行任务,如果有其它线程再来获取锁,就会认为无人持有锁从而获取锁成功,于是多个线程再次并行执行,并发安全问题就可能再次发生了:

解决思路:

还记得我们set时存入了什么吗?

bash 复制代码
SET lock thread1 NX EX 10

我们会将持有锁的线程存入lock中。因此,我们应该在删除锁之前判断当前锁的中保存的是否是当前线程标示,如果不是则证明不是自己的锁,则不删除;如果锁标示是当前线程,则可以删除:

综上,分布式锁的实现逻辑就变化了:

②超时释放问题;

加上了锁标示判断逻辑,可以避免大多数情况下的锁误删问题,但是还有一种极端情况依然会存在误删可能。

例如,线程1获取锁成功,并且执行业务完成,并且也判断了锁标示,确实与自己一致:

接下来,线程1应该去释放自己的锁了,可就在此时发生了阻塞!直到锁超时释放:

此时,如果有线程2来获取锁,肯定可以获取锁成功:

就在线程2获取锁成功后,线程1从阻塞中醒来,继续释放锁。由于在阻塞之前已经完成了锁标示判断,现在就无需判断而是直接删除锁,结果就把线程2的锁删除了:

有一次发生了误删问题!!尴尬不

总结一下,误删的原因归根结底是因为什么?

  • 超时释放

  • 判断锁标示、删除锁两个动作不是原子操作

③原子性问题:可以利用Redis的LUA脚本来编写锁操作,确保原子性

④超时问题:利用WatchDog(看门狗)机制,获取锁成功时开启一个定时任务,在锁到期前自动续期,避免超时释放。而当服务宕机后,WatchDog跟着停止运行,不会导致死锁。

⑥锁重入 问题:可以模拟Synchronized原理,放弃setnx,而是利用Redis的Hash结构来记录锁的持有者 以及重入次数,获取锁时重入次数+1,释放锁是重入次数-1,次数为0则锁删除

⑦主从一致性问题:可以利用Redis官网推荐的RedLock机制来解决

这些解决方案实现起来比较复杂,因此我们通常会使用一些开源框架来实现分布式锁,而不是自己来编码实现。目前对这些解决方案实现的比较完善的一个第三方组件:Redisson

1.3 为什么选择 Redisson

手动基于 Redis 命令实现分布式锁需解决死锁、误删、原子性、重入等一系列问题,开发成本高且易出漏洞。Redisson 是 Redis 官方推荐的分布式锁工具包,封装了所有分布式锁核心问题:

  • 自动超时释放 + 看门狗续期,避免死锁;
  • 校验锁持有者,防止误删;
  • 支持可重入锁、公平锁、读写锁等多种锁类型;
  • 内置 RedLock 算法,解决 Redis 主从一致性问题;
  • 开箱即用,无需手动编码底层逻辑。

2. Redisson 分布式锁:并发安全的基础方案

2.1 快速集成

2.1.1 引入依赖

XML 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
</dependency>

2.1.2 核心配置(适配 Spring Boot)

java 复制代码
@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient(RedisProperties redisProperties) {
        Config config = new Config();
        // 单机模式(集群/哨兵可通过config.useClusterServers()配置)
        config.useSingleServer()
              .setAddress("redis://" + redisProperties.getHost() + ":" + redisProperties.getPort())
              .setPassword(redisProperties.getPassword());
        return Redisson.create(config);
    }
}

2.2 基本用法

java 复制代码
@Service
public class CouponService {
    @Autowired
    private RedissonClient redissonClient;

    public void receiveCoupon(Long couponId) {
        // 1. 获取锁(锁名:coupon:{couponId})
        RLock lock = redissonClient.getLock("lock:coupon:" + couponId);
        try {
            // 2. 尝试获取锁:等待1秒,持有10秒(超时自动释放)
            boolean locked = lock.tryLock(1, 10, TimeUnit.SECONDS);
            if (!locked) {
                throw new BizIllegalException("请求太频繁");
            }
            // 3. 执行业务(扣减库存、新增用户券)
            doReceiveCoupon(couponId);
        } finally {
            // 4. 释放锁(必须在finally中,避免宕机导致死锁)
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

利用Redisson获取锁时可以传3个参数:

  • waitTime:获取锁的等待时间。当获取锁失败后可以多次重试,直到waitTime时间耗尽。waitTime默认-1,即失败后立刻返回,不重试。

  • leaseTime:锁超时释放时间。默认是30,同时会利用WatchDog来不断更新超时时间。需要注意的是,如果手动设置leaseTime值,会导致WatchDog失效。

  • TimeUnit:时间单位

3. 高并发领券优化 V1:Redisson+MQ 异步

即使有分布式锁,高并发下串行执行仍会导致性能瓶颈,流程如图比较长:

我们可以通过 "Redis 缓存前置校验 + MQ 异步写库" 优化,核心是 "锁保证安全,异步提升性能"。

3.1 优化思路

  • 缓存前置:将优惠券核心信息(库存、发放时间、用户限领数)缓存到 Redis,避免频繁查库;
  • 锁保护校验:用 Redisson 分布式锁保证 "校验 + 扣减缓存" 的原子性;
  • MQ 异步写库:校验通过后发送 MQ 消息,异步更新数据库,快速响应前端。

流程示意图如下:

3.2 核心实现

3.2.1 Redis 缓存设计

优惠券资格校验需要校验的内容包括:

  • 优惠券发放时间

  • 优惠券库存

  • 用户限领数量

因此,为了减少对Redis内存的消耗,在构建优惠券缓存的时候,我们并不需要把所有优惠券信息写入缓存,而是只保存上述字段即可。

Redis中的数据结构大概如图:

KEY(couponId) field value
couponId:10 issueBeginTime 20230327
couponId:10 issueEndTime 20230501
couponId:10 totalNum 100
couponId:10 userLimit 1
couponId:20 issueBeginTime 20230827
couponId:20 issueEndTime 20230901
couponId:20 totalNum 200
couponId:20 userLimit 2

注意,上述结构中记录了券的每人限领数量:userLimit , 但是用户已经领取的数量并没有记录。因此,我们还需要一个数据结构,来记录某张券,每个用户领取的数量。

一个券可能被多个用户领取,每个用户的已领取数量都需要记录。显然,还是Hash结构更加适合:

KEY(couponId) field(userId) value(count)
couponId:10 uid:110 1
couponId:10 uid:120 1
couponId:10 uid:130 1
couponId:10 uid:140 1

3.2.2 异步领券核心流程

java 复制代码
package com.tianji.promotion.service.impl;
// ...略

import static com.tianji.promotion.constants.PromotionConstants.COUPON_CODE_MAP_KEY;
import static com.tianji.promotion.constants.PromotionConstants.COUPON_RANGE_KEY;

/**
 * <p>
 * 用户领取优惠券的记录,是真正使用的优惠券信息 服务实现类
 * </p>
 *
 * @author 虎哥
 */
@Service
@RequiredArgsConstructor
public class UserCouponServiceImpl extends ServiceImpl<UserCouponMapper, UserCoupon> implements IUserCouponService {

    private final CouponMapper couponMapper;

    private final IExchangeCodeService codeService;

    private final StringRedisTemplate redisTemplate;

    private final RabbitMqHelper mqHelper;

    @Override
    @Lock(name = "lock:coupon:#{couponId}")
    public void receiveCoupon(Long couponId) {
        // 1.查询优惠券
        Coupon coupon = queryCouponByCache(couponId);
        if (coupon == null) {
            throw new BadRequestException("优惠券不存在");
        }
        // 2.校验发放时间
        LocalDateTime now = LocalDateTime.now();
        if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) {
            throw new BadRequestException("优惠券发放已经结束或尚未开始");
        }
        // 3.校验库存
        if (coupon.getIssueNum() >= coupon.getTotalNum()) {
            throw new BadRequestException("优惠券库存不足");
        }
        Long userId = UserContext.getUser();
        // 4.校验每人限领数量
        // 4.1.查询领取数量
        String key = PromotionConstants.USER_COUPON_CACHE_KEY_PREFIX + couponId;
        Long count = redisTemplate.opsForHash().increment(key, userId.toString(), 1);
        // 4.2.校验限领数量
        if(count > coupon.getUserLimit()){
            throw new BadRequestException("超出领取数量");
        }
        // 5.扣减优惠券库存
        redisTemplate.opsForHash().increment(
                PromotionConstants.COUPON_CACHE_KEY_PREFIX + couponId, "totalNum", -1);

        // 6.发送MQ消息
        UserCouponDTO uc = new UserCouponDTO();
        uc.setUserId(userId);
        uc.setCouponId(couponId);
        mqHelper.send(MqConstants.Exchange.PROMOTION_EXCHANGE, MqConstants.Key.COUPON_RECEIVE, uc);
    }

    private Coupon queryCouponByCache(Long couponId) {
        // 1.准备KEY
        String key = PromotionConstants.COUPON_CACHE_KEY_PREFIX + couponId;
        // 2.查询
        Map<Object, Object> objMap = redisTemplate.opsForHash().entries(key);
        if (objMap.isEmpty()) {
            return null;
        }
        // 3.数据反序列化
        return BeanUtils.mapToBean(objMap, Coupon.class, false, CopyOptions.create());
    }
    // ...略
}

3.2.3 MQ 消费者(异步写库)

java 复制代码
package com.tianji.promotion.handler;

import com.tianji.promotion.domain.dto.UserCouponDTO;
import com.tianji.promotion.service.IUserCouponService;
import lombok.RequiredArgsConstructor;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

import static com.tianji.common.constants.MqConstants.Exchange.PROMOTION_EXCHANGE;
import static com.tianji.common.constants.MqConstants.Key.COUPON_RECEIVE;

@RequiredArgsConstructor
@Component
public class PromotionMqHandler {

    private final IUserCouponService userCouponService;

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "coupon.receive.queue", durable = "true"),
            exchange = @Exchange(name = PROMOTION_EXCHANGE, type = ExchangeTypes.TOPIC),
            key = COUPON_RECEIVE
    ))
    // 更新优惠券库存,新增优惠券
    public void listenCouponReceiveMessage(UserCouponDTO uc){
        userCouponService.checkAndCreateUserCoupon(uc);
    }
}

4. 高并发领券优化 V2:Lua 脚本 + MQ 异步

V1 方案中,"校验 + 扣减缓存" 需多次 Redis 交互(查缓存、扣减库存、扣减用户次数),网络开销大 。用Lua 脚本将多步操作原子化,一次 Redis 请求完成所有逻辑,进一步提升性能。

注意注意:

这里网络开销是一部分原因,我觉得还有一部分原因是在并发量大的时候锁的竞争比较严重。我自己也做过一个对比的jemeter压测,对于两种方案,由于项目做得比较久了,我就直接文字叙述一下。比如:

V1版本的分布式锁:100张券,你使用1000个线程在1秒内进行压测抢券,可能最终只能成功一部分。这是为什么呢?

因为对于分布式锁,你肯定会设置一个锁重试机会嘛,一般情况是3次,可能有些线程重试了3次也没能拿到锁就直接放弃了,所以看似1000个线程都有机会,但是到最后不一定能够抢完。但是如果你使用的是无限重试那肯定可以成功,但是一般不会这么搞。

V2版本的Lua脚本是属于Redis的范畴,即使来了1000个线程,你也得乖乖排好队,单线程执行,都没有并发的可能,所以券一定是可以抢完的

4.1 Lua 脚本的核心价值

  • 原子性:脚本内所有 Redis 操作串行执行,无需分布式锁;
  • 减少网络开销:一次请求完成校验 + 扣减,避免多次 Redis 通信;
  • 简化逻辑:无需手动加锁,脚本内直接保证并发安全。

4.2 核心实现

4.2.1 领券校验 + 扣减 Lua 脚本

领券资格校验的思路和脚本如图:

java 复制代码
if(redis.call('exists', KEYS[1]) == 0) then
    return 1
end
if(tonumber(redis.call('hget', KEYS[1], 'totalNum')) <= 0) then
    return 2
end
if(tonumber(redis.call('time')[1]) > tonumber(redis.call('hget', KEYS[1], 'issueEndTime'))) then
    return 3
end
if(tonumber(redis.call('hget', KEYS[1], 'userLimit')) < redis.call('hincrby', KEYS[2], ARGV[1], 1)) then
    return 4
end
redis.call('hincrby', KEYS[1], "totalNum", "-1")
return 0

4.2.2 业务改造(执行 Lua 脚本)

java 复制代码
package com.tianji.promotion.service.impl;

import lombok.RequiredArgsConstructor;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;

/**
 * <p>
 * 用户领取优惠券的记录,是真正使用的优惠券信息 服务实现类
 * </p>
 */
@Service
@RequiredArgsConstructor
public class UserCouponServiceImpl extends ServiceImpl<UserCouponMapper, UserCoupon> implements IUserCouponService {

    // ... 略

    private static final RedisScript<Long> RECEIVE_COUPON_SCRIPT;
    private static final RedisScript<String> EXCHANGE_COUPON_SCRIPT;

    static {
        RECEIVE_COUPON_SCRIPT = RedisScript.of(new ClassPathResource("lua/receive_coupon.lua"), Long.class);
        EXCHANGE_COUPON_SCRIPT = RedisScript.of(new ClassPathResource("lua/exchange_coupon.lua"), String.class);
    }
    
    // ... 略
}
java 复制代码
// 使用LUA脚本后无需加锁也是线程安全的
@Override
public void receiveCoupon(Long couponId) {
    // 1.执行LUA脚本,判断结果
    // 1.1.准备参数
    String key1 = PromotionConstants.COUPON_CACHE_KEY_PREFIX + couponId;
    String key2 = PromotionConstants.USER_COUPON_CACHE_KEY_PREFIX + couponId;
    Long userId = UserContext.getUser();
    // 1.2.执行脚本
    Long r = redisTemplate.execute(RECEIVE_COUPON_SCRIPT, List.of(key1, key2), userId.toString());
    int result = NumberUtils.null2Zero(r).intValue();
    if (result != 0) {
        // 结果大于0,说明出现异常
        throw new BizIllegalException(PromotionConstants.RECEIVE_COUPON_ERROR_MSG[result - 1]);
    }
    // 2.发送MQ消息
    UserCouponDTO uc = new UserCouponDTO();
    uc.setUserId(userId);
    uc.setCouponId(couponId);
    mqHelper.send(MqConstants.Exchange.PROMOTION_EXCHANGE, MqConstants.Key.COUPON_RECEIVE, uc);
}

5.总结

优惠券秒杀大致思路:

  • 并发安全:Redisson 分布式锁保证 "校验 + 扣减" 原子性(V1),或 Lua 脚本原子化操作(V2);
  • 库存校验:Redis 缓存预扣库存,DB 异步更新,避免查库瓶颈;
  • 兜底校验:DB 层用乐观锁(WHERE id=? AND stock>0)防止最终数据不一致。

方案对比:

版本 核心方案 优点 缺点
V1 Redisson+MQ 逻辑简单,易调试 多次 Redis 交互,性能略低
V2 Lua 脚本 + MQ 性能最优,无需锁 脚本调试成本高

感兴趣的宝子可以关注一波,后续会更新更多有用的知识!!!

相关推荐
输出输入2 小时前
Java Swing和JavaFX用哪个好
java·前端
星火开发设计2 小时前
C++ 异常处理:try-catch-throw 的基本用法
java·开发语言·jvm·c++·学习·知识·对象
没有bug.的程序员2 小时前
分布式配置深潜:Spring Cloud Config 与 Git 集成内核、版本回滚机制与多环境治理实战指南
java·分布式·git·spring cloud·分布式配置·版本回滚
好家伙VCC2 小时前
# 发散创新:基于ARCore的实时3D物体识别与交互开发实战 在增强现实(
java·python·3d·ar·交互
清水白石0082 小时前
函数签名内省实战:打造通用参数验证装饰器的完整指南
java·linux·数据库
only-qi2 小时前
Spring Boot 异步任务深度解析:从入门到避坑指南
java·spring boot·线程池·async
EXI-小洲2 小时前
2025年度总结 EXI-小洲:技术与生活两手抓
java·python·生活·年度总结·ai开发
小钻风33662 小时前
Knife4j 文件上传 multipart/data 同时接受文件和对象,调试时上传文件失效
java·springboot·knife4j
~央千澈~2 小时前
抖音弹幕游戏开发之第7集:识别不同类型的消息·优雅草云桧·卓伊凡
java·服务器·前端