【黑马点评-3秒杀优惠券】零、RabbitMQ + lua

1 Redis实现的Demo分布式锁的问题

  1. 分布式锁不可重入:
    • 不可重入是指同一线程不能重复获取同一把锁。
    • 比如,方法A中调用方法B,方法A需要获取分布式锁,方法B同样需要获取分布式锁,线程1进入方法A获取了一次锁,进入方法B又获取一次锁,由于锁不可重入,所以就会导致死锁
  2. 分布式锁不可重试:
    • 获取锁只尝试一次就返回false,没有重试机制,这会导致数据丢失,
    • 比如线程1获取锁,然后要将数据写入数据库,但是当前的锁被线程2占用了,线程1直接就结束了而不去重试,这就导致数据发生了丢失
  3. 分布式锁超时释放:
    • 超时释放机制虽然一定程度避免了死锁发生的概率,但是如果业务执行耗时过长,期间锁就释放了,这样存在安全隐患。
    • 锁的有效期过短,容易出现业务没执行完就被释放,
    • 锁的有效期过长,容易出现死锁,所以这是一个大难题!
    • 我们可以设置一个较短的有效期,但是加上一个 心跳机制 和 自动续期:(以下尚未理解)
      • 在锁被获取后,可以使用心跳机制并自动续期锁的持有时间。通过定期发送心跳请求,显示地告知其他线程或系统锁还在使用中,同时更新锁的过期时间。
      • 如果某个线程持有锁的时间超过了预设的有效时间,其他线程可以尝试重新获取锁。
  4. 主从一致性问题:如果Redis提供了主从集群,主从同步存在延迟,?

解决:Redssion是一个十分成熟的Redis框架,功能也很多,比如:分布式锁和同步器、分布式对象、分布式集合、分布式服务,各种Redis实现分布式的解决方案。

2 代码逻辑

1. 使用Guava库的RateLimiter,通过限制1s 10个许可的方式,限制请求

  1. 设置1s只发10 个许可
  2. 在不超过指定超时时间1s的情况下获得许可
private final RateLimiter rateLimiter = RateLimiter.create(10);
if (!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)) {
	return Result.fail("目前网络正忙,请重试");
}

2. 通过ThreadLocal获取当前用户id

从ThreadLocal中获取UserDTO中的属性id

UserDTO的三个属性:id、nickname、icon

Long userId = UserHolder.getUser().getId();

3. 执行lua脚本

  1. excute的三个参数
  • DefaultRedisScript封装的要执行的脚本
  • 空集合,填充用的,因为用的args传的
  • voucherId(方法参数) + userId(ThreadLocal里获得的)
Long r = stringRedisTemplate.execute(
	SECKILL_SCRIPT,
	Collections.emptyList(),
	voucherId.toString(),
	userId.toString()
);

4. 根据lua脚本的结果,判断

  1. 获取优惠券id,用户id
  2. 用redis的get命令,判断优惠券id小于0么,小于失败返回1
  3. 判断这个用户获取了这个优惠券么,用set判断,属于,失败返回2
  4. 都不失败,则order-1, 将用户id保存到oderKey对应的set里
-- 1.参数列表
-- 1.1优惠卷id
local voucherId = ARGV[1]
-- 1.2用户id
local userId = ARGV[2]

-- 2.数据key
-- 2.1库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2订单key
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1判断库存是否充足
if(tonumber(redis.call('get',stockKey)) <= 0)then
    -- 3.2 库存不足 返回1
    return 1
end
--3.2判断用户是否下单
if(redis.call('sismember',orderKey,userId) == 1) then
    -- 3.3存在,说明是重复下单
    return 2
end
-- 3.4扣库存
redis.call('incrby',stockKey,-1)
-- 3.5下单并保存用户
redis.call('sadd',orderKey,userId)
return 0

5. 若lua脚本允许下单,创建VoucherOrder实体,存储相关信息

VoucherOrder的字段如下

  • id
  • userId
  • voucherId
long orderId = redisIdWorker.nextId("order");
// 设置订单属性: 将生成的订单 ID、用户 ID 和代金券 ID 设置到 voucherOrder 对象中。
voucherOrder.setId(orderId);
// 用户id
voucherOrder.setUserId(userId);
// 代金卷id
voucherOrder.setVoucherId(voucherId);

6. 用RabbitMQ发送秒杀信息,返回订单id,成功

mqSender.sendSeckillMessage(JSON.toJSONString(voucherOrder));
// 返回订单id
return Result.ok(orderId);

3 总结

基于Redis的分布式锁实现思路:

  • 利用set nx ex获取锁,并设置过期时间,保存线程标示
  • 释放锁时先判断线程标示是否与自己一致,一致则删除锁

特性:

  • 利用set nx满足互斥性
  • 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
  • 利用Redis集群保证高可用和高并发特性

4 示例代码

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder>
        implements IVoucherOrderService {
    /*
     * 1. 定义一个静态常量 SECKILL_SCRIPT,用于存储 Redis 脚本
     * 使用 DefaultRedisScript<Long> 类型,表示该脚本执行后返回值的类型为 Long。
     */
    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;

    /*
     * 2. 静态初始化块:
     * 在静态初始化块中,对 SECKILL_SCRIPT 进行实例化和配置。
     */
    static {
        /*
         * 实例化 DefaultRedisScript:
         * 创建 DefaultRedisScript 的实例,用于封装将要执行的 Lua 脚本。
         * DefaultRedisScript 是 Spring Data Redis 提供的一个类,用于在 Redis 中执行脚本。
         */
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        /*
         * 设置脚本位置:
         * setLocation 方法用于指定脚本的位置。这里使用了 ClassPathResource,表示脚本位于类路径下的 seckill.lua 文件。
         * ClassPathResource 是 Spring 提供的一个类,用于访问类路径下的资源文件。
         */
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        /*
         * setResultType 方法用于指定脚本执行后的返回值类型,这里设置为 Long.class,表示脚本返回的是一个长整型值。
         */
        SECKILL_SCRIPT.setResultType(Long.class);
    }

    /**
     * RateLimiter 的使用: rateLimiter 是基于 Google Guava 库中的 RateLimiter 类实例,
     * 采用令牌桶算法进行限流。
     * RateLimiter.create(10) 表示每秒生成 10 个令牌,即每秒允许最多 10 个请求通过。
     */
    private final RateLimiter rateLimiter = RateLimiter.create(10);
    @Resource
    private RedisIdWorker redisIdWorker;
    @Resource
    private MQSender mqSender;
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result seckillVoucher(Long voucherId) {
        /*
         * tryAcquire 方法: 尝试在指定时间内获取一个令牌。这里设置了超时时间为 1000 毫秒(1 秒)。
         * 如果在超时时间内未能获取到令牌,说明当前请求过多,返回错误信息 "目前网络正忙,请重试"。
         */
        if (!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)) {
            return Result.fail("目前网络正忙,请重试");
        }
        /*
         * 1.执行lua脚本
         * 获取用户 ID: 通过 UserHolder.getUser().getId() 获取当前登录用户的 ID。
         */
        Long userId = UserHolder.getUser().getId();
        /*
         * 执行 Lua 脚本: 使用 stringRedisTemplate 的 execute 方法执行预先定义的 Lua 脚本 SECKILL_SCRIPT。
         * 该脚本的主要功能包括:
         * 检查代金券库存是否充足。
         * 验证用户是否已经参与过此次秒杀(防止重复下单)。
         * 如果上述条件满足,扣减库存并记录用户的秒杀资格。
         * Lua 脚本的执行保证了上述操作的原子性,避免了并发问题。
         */
        Long r = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(),
                userId.toString());
        /*
         * 2.判断结果
         * 结果判断: 将 Lua 脚本的返回值转换为整数 result。根据约定:
         * 返回 0 表示秒杀成功。
         * 返回 1 表示库存不足。
         * 返回 2 表示用户重复下单。
         * 处理失败情况: 如果 result 不为 0,根据返回值提供相应的错误提示信息。
         */
        int result = r.intValue();
        if (result != 0) {
            // 2.1不为0代表没有购买资格
            return Result.fail(r == 1 ? "库存不足" : "该用户重复下单");
        }
        // 2.2为0代表有购买资格,将下单信息保存到阻塞队列

        // 3. 创建订单并发送消息
        // 订单对象创建: 如果秒杀资格验证通过,创建一个新的 VoucherOrder 对象。
        VoucherOrder voucherOrder = new VoucherOrder();
        /*
         * 生成订单 ID: 使用 redisIdWorker.nextId("order") 生成全局唯一的订单 ID。
         * redisIdWorker 是一个基于 Redis 实现的分布式 ID 生成器,确保在分布式环境下生成唯一的 ID。
         */

        long orderId = redisIdWorker.nextId("order");
        // 设置订单属性: 将生成的订单 ID、用户 ID 和代金券 ID 设置到 voucherOrder 对象中。
        voucherOrder.setId(orderId);
        // 用户id
        voucherOrder.setUserId(userId);
        // 代金卷id
        voucherOrder.setVoucherId(voucherId);
        /*
         * 发送消息: 将 voucherOrder 对象转换为 JSON 字符串,
         * 将信息放入MQ中
         */
        mqSender.sendSeckillMessage(JSON.toJSONString(voucherOrder));

        // 返回订单id
        return Result.ok();
    }
}
posted @ 2025-04-13 13:48  kuki'  阅读(121)  评论(0)    收藏  举报