沉梦手记 沉梦手记
首页
  • 基础篇
  • 集合篇
  • 并发篇
  • JVM
  • 新特性
  • 计算机网络
  • 操作系统
  • 数据结构与算法
  • 数据库基础
  • MySql
  • Redis
  • 达梦数据库
  • Spring
  • SpringBoot
  • Mybatis
  • Shiro
  • 设计须知
  • UML画图
  • 权限校验
  • 设计模式
  • API网关
  • 网络通信
  • 消息队列
  • SpringCloud
  • 分布式事务
  • 云存储
  • 搜索引擎
  • 前端环境搭建
  • HTML与CSS
  • JS学习
  • Axios入门
  • Vue Router入门
  • Pinia入门
  • Vue3入门
  • Vue3进阶
  • 黑马Vue3
  • 开发工具篇
  • 工具库篇
  • 开发技巧篇
  • 工具类系列
  • 运维与部署
  • 音视频处理
  • 随笔
  • 博客搭建
  • 网站收藏箱
  • 断墨寻径摘录
  • 费曼学习法
  • 脚手架搭建
  • 瑞吉外卖
  • 黑马点评
  • vue-blog
  • 沉梦接口开放平台
  • 用户中心
  • 聚合搜索平台
  • 仿12306项目
  • 壁纸小程序项目
  • RuoYi-Vue
Github (opens new window)

沉梦听雨

时间是最好的浸渍剂,而沉淀是最好的提纯器🚀
首页
  • 基础篇
  • 集合篇
  • 并发篇
  • JVM
  • 新特性
  • 计算机网络
  • 操作系统
  • 数据结构与算法
  • 数据库基础
  • MySql
  • Redis
  • 达梦数据库
  • Spring
  • SpringBoot
  • Mybatis
  • Shiro
  • 设计须知
  • UML画图
  • 权限校验
  • 设计模式
  • API网关
  • 网络通信
  • 消息队列
  • SpringCloud
  • 分布式事务
  • 云存储
  • 搜索引擎
  • 前端环境搭建
  • HTML与CSS
  • JS学习
  • Axios入门
  • Vue Router入门
  • Pinia入门
  • Vue3入门
  • Vue3进阶
  • 黑马Vue3
  • 开发工具篇
  • 工具库篇
  • 开发技巧篇
  • 工具类系列
  • 运维与部署
  • 音视频处理
  • 随笔
  • 博客搭建
  • 网站收藏箱
  • 断墨寻径摘录
  • 费曼学习法
  • 脚手架搭建
  • 瑞吉外卖
  • 黑马点评
  • vue-blog
  • 沉梦接口开放平台
  • 用户中心
  • 聚合搜索平台
  • 仿12306项目
  • 壁纸小程序项目
  • RuoYi-Vue
Github (opens new window)
  • 脚手架搭建
  • 瑞吉外卖

  • 黑马点评

    • Redis实战笔记
    • 登录与缓存优化
    • 优惠券秒杀
      • 3. 你在实现优惠券秒杀有出现哪些问题?
        • 1. 设计优惠券
        • 2. 实现抢优惠券逻辑
        • 3. 超卖问题
        • 4. 一人一单问题
    • 分布式锁
    • 秒杀优化与消息队列
    • 社交功能与Feed流
    • GEO与签到统计
    • 面试问答整理
    • 电商场景分析
    • 项目技术亮点
  • vue-blog

  • 沉梦接口开放平台

  • 用户中心

  • 聚合搜索平台

  • 仿12306项目

  • 壁纸小程序项目

  • RuoYi-Vue

  • 项目笔记
  • 黑马点评
沉梦听雨
2023-06-08
目录

优惠券秒杀

# 优惠券秒杀

# 3. 你在实现优惠券秒杀有出现哪些问题?

  1. 设计优惠券
  2. 实现抢优惠券逻辑
  3. 超卖问题
  4. 一人一单问题
  5. 集群情况下,并发问题

# 1. 设计优惠券

当用户抢购商品时,生成的订单会保存到 tb_voucher_order 表中,而订单表如果使用数据库自增 ID 就会存在一些问题:

  1. id 规律性太明显
  2. 受单表数据量的限制
  • 如果我们的订单 id 有太明显的规律,那么对于用户或者竞争对手,就很容易猜测出我们的一些敏感信息,例如商城一天之内能卖出多少单,这明显不合适。
  • 随着我们商城的规模越来越大,MySQL 的单表容量不宜超过 500W,数据量过大之后,我们就要进行拆库拆表。拆分表了之后,它们从逻辑上讲是同一张表,所以它们的 id 不能重复,于是乎我们就要保证 id 的唯一性。

所以我们这里使用了全局 ID 生成器。

  • 全局 ID 生成器是一种在分布式系统下用来生成全局唯一 ID 的工具,一般要满足以下特性:
    • 唯一性
    • 高可用
    • 高性能
    • 递增性
    • 安全性
  • 为了增加 ID 的安全性,我们可以不直接使用 Redis 自增的数值,而是拼接一些其他信息。
  • ID 组成部分:
    • 符号位:1bit,永远为 0
    • 时间戳:31bit,以秒为单位,可以使用 69 年(2^31 秒约等于 69 年)
    • 序列号:32bit,秒内的计数器,支持每秒传输 2^32 个不同 ID

image.png

这里进行了一个封装,将这个生成全局唯一 ID 功能封装起来:

@Component
public class RedisIdWorker {
    // 开始时间戳
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    // 时间戳位数位移数,也就是序列号的位数
    private static final int COUNT_BITS = 32;

    private StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    // 传入一个key前缀,在不同程序下生成的全局Id不一样
    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("icr:" + keyPrefix + ":" + date);

        // 3.拼接并返回
        return timestamp << COUNT_BITS | count;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

# 2. 实现抢优惠券逻辑

  1. 首先提交优惠券 id,然后查询优惠券信息
  2. 之后判断秒杀时间是否开始
    • 开始了,则判断是否有剩余库存
      • 有库存,那么扣减一个库存
        • 然后创建订单
      • 无库存,则返回一个错误信息
    • 没开始,则返回一个错误信息
  3. 创建成功,返回订单 id

image.png

public Result seckillVoucher(Long voucherId) {
    LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
    // 1. 查询优惠券
    queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
    SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
    // 2. 判断秒杀时间是否开始
    if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
        return Result.fail("秒杀还未开始,请耐心等待");
    }
    // 3. 判断秒杀时间是否结束
    if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
        return Result.fail("秒杀已经结束!");
    }
    // 4. 判断库存是否充足
    if (seckillVoucher.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");
    // 6.2 设置用户id
    Long id = UserHolder.getUser().getId();
    // 6.3 设置代金券id
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setId(orderId);
    voucherOrder.setUserId(id);
    // 7. 将订单数据保存到表中
    save(voucherOrder);
    // 8. 返回订单id
    return Result.ok(orderId);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

# 3. 超卖问题

导致超卖问题的原因:假设现在只剩下一张优惠券,线程 1 过来查询库存,判断库存数大于 1,但还没来得及去扣减库存,此时线程 2 也过来查询库存,发现库存数也大于 1,那么这两个线程都会进行扣减库存操作,最终相当于是多个线程都进行了扣减库存,那么此时就会出现超卖问题。

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

  1. 悲观锁
    • 悲观锁认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。
    • 例如 Synchronized、Lock 等都是悲观锁。
  2. 乐观锁
    • 乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新数据的时候再去判断有没有其他线程对数据进行了修改。
      • 如果没有修改,则认为自己是安全的,自己才可以更新数据。
      • 如果已经被其他线程修改,则说明发生了安全问题,此时可以重试或者异常。

悲观锁:悲观锁可以实现对于数据的串行化执行,比如 syn 和 lock 都是悲观锁的代表。同时,悲观锁中又可以再细分为公平锁、非公平锁、可重入锁等等。

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

这里并不需要真的来指定一下版本号,完全可以使用 stock 来充当版本号,在扣减库存时,比较查询到的优惠券库存和实际数据库中优惠券库存是否相同。

在扣减库存的语句里添加一段判断语句 eq("stock", seckillVoucher.getStock()):

// 4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
    return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
}
// 5. 扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        .eq("stock", seckillVoucher.getStock())
        .update();
if (!success) {
    return Result.fail("库存不足");
}
1
2
3
4
5
6
7
8
9
10
11
12
13

不过这样也会出现一个问题:以上逻辑的核心含义是,只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的。但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设 100 个线程同时都拿到了 100 的库存,然后大家一起去进行扣减,但是 100 个人中只有 1 个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败。

所以继续完善代码:在这种场景,我们可以只判断是否有剩余优惠券,即只要数据库中的库存大于 0,都能顺利完成扣减库存操作。

去掉原来的判断,改成 gt("stock", 0):

// 5. 扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        .gt("stock", 0)
        .update();
if (!success) {
    return Result.fail("库存不足");
}
1
2
3
4
5
6
7
8
9

# 4. 一人一单问题

  • 需求:修改秒杀业务,要求同一个优惠券,一个用户只能抢一张。
  • 具体操作逻辑:我们在判断库存是否充足之后,根据我们保存的订单数据,判断用户订单是否已存在
    • 如果已存在,则不能下单,返回错误信息
    • 如果不存在,则继续下单,获取优惠券

在扣减库存前,加上判断该用户是否抢过优惠券:

// 一人一单逻辑
Long userId = UserHolder.getUser().getId();
int count = query().eq("voucherId", voucherId).eq("userId", userId).count();
if (count > 0) {
    return Result.fail("你已经抢过优惠券了哦");
}
1
2
3
4
5
6
  • 存在问题:还是和之前一样,如果这个用户故意开多线程抢优惠券,那么在判断库存充足之后、执行一人一单逻辑之前,在这个区间如果进来了多个线程,还是可以抢多张优惠券的。那我们这里使用悲观锁来解决这个问题。
  • 初步代码:我们把一人一单逻辑之后的代码都提取到一个 createVoucherOrder 方法中,然后给这个方法加锁。不管哪一个线程(例如线程 A),运行到这个方法时,都要检查有没有其它线程 B(或者 C、D 等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用 synchronized 方法的线程 B(或者 C、D)运行完这个方法后再运行此线程 A,没有的话,锁定调用者,然后直接运行。
private Result createVoucherOrder(Long voucherId) {
    // 一人一单逻辑
    Long userId = UserHolder.getUser().getId();
    int count = query().eq("voucherId", voucherId).eq("userId", userId).count();
    if (count > 0) {
        return Result.fail("你已经抢过优惠券了哦");
    }
    // 略......
}
1
2
3
4
5
6
7
8
9

但是这样加锁,锁的细粒度太粗了。在使用锁的过程中,控制锁粒度是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会被锁住。现在的情况就是所有用户都公用这一把锁,串行执行,效率很低。我们现在要完成的业务是一人一单,所以这个锁应该只加在单个用户上,用户标识可以用 userId:

@Transactional
public Result createVoucherOrder(Long voucherId) {
    // 一人一单逻辑
    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
        int count = query().eq("voucherId", voucherId).eq("userId", userId).count();
        if (count > 0) {
            return Result.fail("你已经抢过优惠券了哦");
        }
        // 略....
    }
    // 执行到这里,锁已经被释放了,但是可能当前事务还未提交,如果此时有线程进来,不能确保事务不出问题
}
1
2
3
4
5
6
7
8
9
10
11
12
13

由于 toString 的源码是 new String,所以如果我们只用 userId.toString() 拿到的也不是同一个用户,需要使用 intern()。如果字符串常量池中已经包含了一个等于这个 String 对象的字符串(由 equals(Object) 方法确定),那么将返回池中的字符串。否则,将此 String 对象添加到池中,并返回对此 String 对象的引用。

public static String toString(long i) {
    if (i == Long.MIN_VALUE)
        return "-9223372036854775808";
    int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i);
    char[] buf = new char[size];
    getChars(i, size, buf);
    return new String(buf, true);
}
1
2
3
4
5
6
7
8

但是以上代码还是存在问题,问题的原因在于当前方法被 Spring 的事务控制,如果你在内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放了,这样也会导致问题。所以我们选择将当前方法整体包裹起来,确保事务不会出现问题:

@Override
public Result seckillVoucher(Long voucherId) {
    LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
    // 1. 查询优惠券
    queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
    SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
    // 2. 判断秒杀时间是否开始
    if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
        return Result.fail("秒杀还未开始,请耐心等待");
    }
    // 3. 判断秒杀时间是否结束
    if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
        return Result.fail("秒杀已经结束!");
    }
    // 4. 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
        return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
    }
    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
        return createVoucherOrder(voucherId);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

但是以上做法依然有问题,因为你调用的方法其实是 this. 的方式调用的,事务想要生效,还得利用代理来生效。所以这个地方,我们需要获得原始的事务对象来操作事务,这里可以使用 AopContext.currentProxy() 来获取当前对象的代理对象,然后再用代理对象调用方法。记得要去 IVoucherOrderService 中创建 createVoucherOrder 方法:

Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId);
}
1
2
3
4
5

但是该方法会用到一个依赖,我们需要导入一下:

<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>
1
2
3
4

同时在启动类上加上 @EnableAspectJAutoProxy(exposeProxy = true) 注解:

@MapperScan("com.hmdp.mapper")
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class HmDianPingApplication {
    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }
}
1
2
3
4
5
6
7
8
上次更新: 2026/10/10 17:43:40
登录与缓存优化
分布式锁

← 登录与缓存优化 分布式锁→

Theme by Vdoing | Copyright © 2023-2026 沉梦听雨 | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式