侧边栏壁纸
博主头像
Xancel's blog

行动起来,活在当下

  • 累计撰写 11 篇文章
  • 累计创建 7 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

【黑马点评|项目日记】第四天 优惠券秒杀(乐观锁解决超卖|悲观锁解决一人一单)

我不是Administrator
2025-05-27 / 0 评论 / 1 点赞 / 3 阅读 / 0 字
温馨提示:
部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

1.全局唯一ID

两个问题:什么是全局唯一ID?为什么要使用全局唯一ID

1.1.什么是全局唯一ID

全局唯一ID(如雪花算法、Redis自增ID、UUID)是分布式系统中保证唯一性的标识符,具有以下特点:

  • 唯一性:不同机器、不同时间生成的ID不会重复。
  • 有序性(部分方案):如雪花ID含时间戳,便于排序和查询。
  • 分布式友好:适合分库分表、微服务架构。

1.2.为什么要使用全局唯一ID

原因 问题场景 解决方案
避免ID冲突 多台服务器同时生成ID可能导致重复。 雪花算法、Redis原子自增ID。
防止重复下单/超卖 用户快速提交相同请求,造成数据不一致。 用唯一ID做幂等校验(如Redis SETNX)。
隐藏业务信息 自增ID暴露订单量,易被恶意爬取。 使用无规律ID(如雪花ID)。
分库分表支持 自增ID在分片时可能冲突。 全局唯一ID天然适配分布式存储。
优化查询性能 有序ID(如雪花)减少数据库索引碎片。 利用ID的时间戳范围快速查询。

1.3.方法

方案 优点 缺点 适用场景
雪花算法 高性能、有序、可反解时间。 依赖系统时钟(需处理回拨)。 高并发秒杀、订单系统。
Redis自增 简单、原子性。 依赖Redis,连续性暴露信息。 中小规模业务。
UUID 无中心化,生成快。 无序、存储空间大。 临时数据、低性能需求。

总结:秒杀场景下,雪花算法是最优选择(需处理时钟回拨),兼顾唯一性、性能和安全性。

在这个项目中使用的ID包含三个部分:

符号位:1bit,永远为0

时间戳:31bit,以秒为单位,可以使用69年

序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

2.Redis实现全局唯一Id

在utils包下添加RedisIdWorker类:

@Component
public class RedisIdWorker {

    public static final long BEGIN_TIMESTAMP = 1735689600L;
    public static final int COUNT_BITS = 32;
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    public long nextId(String keyPrefix){
        // 1 生成时间戳(用当前时间戳减去开始的 得到的秒数就是)
        LocalDateTime now = LocalDateTime.now();
        long nowSecond= now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;

        // 2 生成序列号
        //2.1 获取当前日期 精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //2.2 自增长
        long count = stringRedisTemplate.opsForValue().increment("irc:" + keyPrefix + ":" + date);

        // 3 拼接并返回

        return timestamp << COUNT_BITS | count;
    }

}

测试类

 @Test
    void testIdWorker() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(300); // 300 个任务

        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                long id = redisIdWorker.nextId("order"); // 每个线程生成 100 个 ID
                System.out.println("id = " + id);
            }
            latch.countDown(); // 每个任务完成后计数减 1
        };

        long begin = System.currentTimeMillis(); // 记录开始时间

        // 提交 300 个任务到线程池
        for (int i = 0; i < 300; i++) {
            es.submit(task);
        }

        latch.await(); // 等待所有任务完成
        long end = System.currentTimeMillis(); // 记录结束时间

        System.out.println("time = " + (end - begin)); // 计算总耗时
    }

知识小贴士:关于countdownlatch

countdownlatch名为信号枪:主要的作用是同步协调在多线程的等待于唤醒问题

我们如果没有CountDownLatch ,那么由于程序是异步的,当异步程序没有执行完时,主线程就已经执行完了,然后我们期望的是分线程全部走完之后,主线程再走,所以我们此时需要使用到CountDownLatch

CountDownLatch 中有两个最重要的方法

1、countDown

2、await

await 方法 是阻塞方法,我们担心分线程没有执行完时,main线程就先执行,所以使用await可以让main线程阻塞,那么什么时候main线程不再阻塞呢?当CountDownLatch 内部维护的 变量变为0时,就不再阻塞,直接放行,那么什么时候CountDownLatch 维护的变量变为0 呢,我们只需要调用一次countDown ,内部变量就减少1,我们让分线程和变量绑定, 执行完一个分线程就减少一个变量,当分线程全部走完,CountDownLatch 维护的变量就是0,此时await就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。

3.添加优惠券,实现秒杀下单

优惠券介绍:

tb_voucher:优惠券的基本信息,优惠金额、使用规则等 tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息

平价卷由于优惠力度并不是很大,所以是可以任意领取

而代金券由于优惠力度大,所以像第二种卷,就得限制数量,从表结构上也能看出,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段

3.1.新增优惠券

**新增普通卷代码: **VoucherController

@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
    voucherService.save(voucher);
    return Result.ok(voucher.getId());
}

新增秒杀卷代码:

VoucherController

@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
    voucherService.addSeckillVoucher(voucher);
    return Result.ok(voucher.getId());
}

VoucherServiceImpl

@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
    // 保存优惠券
    save(voucher);
    // 保存秒杀信息
    SeckillVoucher seckillVoucher = new SeckillVoucher();
    seckillVoucher.setVoucherId(voucher.getId());
    seckillVoucher.setStock(voucher.getStock());
    seckillVoucher.setBeginTime(voucher.getBeginTime());
    seckillVoucher.setEndTime(voucher.getEndTime());
    seckillVoucherService.save(seckillVoucher);
    // 保存秒杀库存到Redis中
    stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());  
}

3.2.实现秒杀下单

image-eoeI.png

秒杀下单应该思考的内容:

下单时需要判断两点:

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  • 库存是否充足,不足则无法下单

下单核心逻辑分析:

当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件

比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。

image-szzz.png

VoucherOrderServiceImpl

@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
    // 1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2.判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
        // 尚未开始
        return Result.fail("秒杀尚未开始!");
    }
    // 3.判断秒杀是否已经结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
        // 尚未开始
        return Result.fail("秒杀已经结束!");
    }
    // 4.判断库存是否充足
    if (voucher.getStock() < 1) {
        // 库存不足
        return Result.fail("库存不足!");
    }
    //5,扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update();
    if (!success) {
        //扣减库存
        return Result.fail("库存不足!");
    }
    //6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 6.1.订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 6.2.用户id
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    // 6.3.代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);

    return Result.ok(orderId);

}

4.解决库存超卖问题

4.1.分析

之前的秒杀在多线程执行时会发生超卖问题:
假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。

image-eFNn.png

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案:见下图:

image-rKOE.png

4.2.乐观锁解决

乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如cas

image-CERj.png

代码:

boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0

5.悲观锁实现一人一单功能

需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单

现在的问题在于:

优惠卷是为了引流,但是目前的情况是,一个人可以无限制的抢这个优惠卷,所以我们应当增加一层逻辑,让一个用户只能下一个单,而不是让一个用户下多个单

具体操作逻辑如下:比如时间是否充足,如果时间充足,则进一步判断库存是否足够,然后再根据优惠卷id和用户id查询是否已经下过这个订单,如果下过这个订单,则不再下单,否则进行下单

image-ysrq.png

5.1.重点思路:

锁粒度选择是按用户ID加锁,所以要使用intern方法(intern方法让相同值的字符串指向内存中的同一对象)
代理对象是为了保证事务和锁的正确顺序,也就是:
获取锁->通过代理调用方法->代理开启事务->执行业务->事务提交/回滚->释放锁

这段代码实现了一个优惠券秒杀系统,并特别加入了"一人一单"的限制逻辑。我来详细解释它的实现思路和关键逻辑。

5.1.1. 整体流程概述

代码主要包含两个核心方法:

  • seckillVoucher():处理秒杀请求的入口方法
  • createVoucherOrder():实际创建订单的方法

5.1.2. 一人一单的实现逻辑

5.1.2.1 用户维度的同步锁

Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
    // 获取代理对象(事务)
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId);
}

关键点

  1. 细粒度锁:使用用户ID作为锁对象,确保同一用户的操作串行化
  2. intern()方法:保证相同用户ID字符串使用同一把锁
  3. 代理对象:通过AOP代理确保事务生效

5.1.2.2 数据库层面的二次校验

int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0){
    return Result.fail("用户已经购买过了");
}

防御性编程:即使并发请求突破了锁机制,数据库查询也能确保一人一单

5.1.2.3 事务处理

@Transactional注解确保:

  1. 库存扣减
  2. 订单创建
    这两个操作要么全部成功,要么全部失败

5.1.3. 代码中的关键优化点

5.1.3.1 锁粒度的选择

  • 不是锁整个方法(粗粒度)
  • 不是锁整个系统(全局锁)
  • 而是按用户ID加锁(细粒度)

5.1.3.2 双重检查机制

  1. 同步块外:快速失败检查(库存、时间等)
  2. 同步块内:精确检查(一人一单)

5.1.3.3 事务与锁的顺序

正确的执行顺序:

  1. 先获取用户锁
  2. 再开始事务
  3. 执行业务
  4. 提交事务
  5. 释放锁

5.1.4. 完整执行流程

  1. 参数校验:检查优惠券是否存在、秒杀时间是否有效
  2. 库存检查:确保有足够库存
  3. 获取用户锁:防止同一用户并发操作
  4. 创建代理对象:确保事务生效
  5. 二次校验:数据库查询是否已购买
  6. 扣减库存:使用乐观锁防止超卖
  7. 创建订单:生成唯一订单ID并保存
  8. 返回结果:将订单ID返回给前端

5.1.5. 总结

这段代码通过以下方式实现了"一人一单":

  1. 用户维度锁:防止同一用户并发请求
  2. 数据库校验:最终一致性保障
  3. 事务管理:确保数据完整性
  4. 乐观锁:解决超卖问题

这种设计在保证功能正确性的同时,也兼顾了系统性能,是一个典型的高并发场景解决方案。

5.2.完整代码:

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    public Result seckillVoucher(Long voucherId) {
        // 1 查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2 判断是否开始
        LocalDateTime beginTime = voucher.getBeginTime();
        if (beginTime.isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀还未开始");
        }
        // 3 判断是否结束
        LocalDateTime endTime = voucher.getEndTime();
        if (endTime.isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }
        // 4 判断库存是否充足
        Integer stock = voucher.getStock();
        if (stock < 1) {
            return Result.fail("库存不足");
        }

        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            //获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
    }

    @Transactional
    public Result createVoucherOrder(Long voucherId) {
        // add 增加一人一单逻辑
        // a.1获取用户id
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
            // a.2判断是否存在
            if (count > 0) {
                return Result.fail("用户已经购买过了");
            }

            // 5 扣减库存
            boolean success = seckillVoucherService
                    .update()
                    .setSql("stock = stock-1")
                    .eq("voucher_id", voucherId).gt("stock", 0)  //乐观锁解决超卖问题 用于更新业务(此处为扣减库存)
                    .update();
            if (!success) {
                return Result.fail("库存不足");
            }
            // 6 创建订单
            VoucherOrder voucherOrder = new VoucherOrder();
            // 订单id
            long orderId = redisIdWorker.nextId("order");
            voucherOrder.setId(orderId);
            // 用户id
            voucherOrder.setUserId(userId);
            // 代金券id
            voucherOrder.setVoucherId(voucherId);
            save(voucherOrder);

            return Result.ok(orderId);
        }
    }
}

6.集群模式下的锁失效问题

由于现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,这就是 集群环境下,syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。

image-kqbi.png

1

评论区