黑马点评面试知识总结3-优惠卷秒杀(单机环境)

1.全局唯一id

首先提出一个问题,就是全局唯一id,当用户量很大的时候怎么确保订单的id是唯一的而且是安全的,尤其是涉及到sql的分库分表,直接上干货,本项目采用了一个类似雪花算法的方法,实现了一个全局唯一id生成器。思路如下:

主要就是三部分:符号位(永远为0)加上时间戳和序列号

直接看代码:

@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;
    }

    public long nextId(String keyPrefix) {
        // 1.生成时间戳,就是中间的31位,可以gpt一下看看这里到底在干嘛,不然没写过看着还是挺抽象的
        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.自增长,这个地方使用了redis的自增长机制,很牛逼的一点是每天生成的count都不一样,都比前一天加一
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

        // 3.拼接并返回
        return timestamp << COUNT_BITS | count;
    }
}

2.优惠券秒杀

优惠券分为两种,一种是普通优惠卷,比如47代50,第二种则是秒杀优惠卷,数量有限,所以有库存限制。

添加优惠卷的代码,和业务无关,主要用于方便后面进行抢购优惠卷:

//新增普通优惠卷,直接用mp的save就行了
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
    voucherService.save(voucher);
    return Result.ok(voucher.getId());
}
//新增秒杀优惠卷
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
    voucherService.addSeckillVoucher(voucher);
    return Result.ok(voucher.getId());
}

@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());
}
//这里就和业务有关了,先把秒杀优惠卷的普通信息和独有信息保存到数据库里,然后把
库存信息保存到redis里面,用于后面秒杀使用

实现秒杀下单:

思路很简单,就是两个问题,一是秒杀是否开始,而是库存够不够,都满足就能下单:

代码实现:

@Override
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);

}

这是最基础的数据库操作,还没用到redis

还有许多问题需要继续解决:

问题一:库存超卖

库存超卖就是典型的多线程安全问题,解决的办法就是加锁:

锁分为两种:乐观锁和悲观锁:

悲观锁:

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

乐观锁:

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

项目中是采用乐观锁解决这个问题的,直接上图:

修改代码就是以下两种方案:

不细说,反正后面也要优化,但这里使用的思想是乐观锁的思想要知道,只不过不是用版本号,而是判断库存的变化。

这里对锁的知识做一个拓展:

什么是公平锁,非公平锁和可重入锁?

1. 公平锁(Fair Lock)

公平锁是指按照线程请求锁的顺序来获取锁,保证先请求的线程先获得锁。也就是说,线程在请求锁时,会排队,按照请求的顺序来依次获得锁,避免了线程饥饿的情况。

特性:
  • FIFO 排队:公平锁的核心特点是先来先得,线程会按照请求的顺序排队获取锁。
  • 避免线程饥饿:由于线程会按顺序获得锁,所以不会出现某个线程长时间得不到执行的情况。

优缺点:

  • 优点:公平锁能够避免线程饥饿,保证了每个线程都有机会获取锁。
  • 缺点:性能通常较差,因为每个线程都需要按顺序排队获取锁,可能导致更多的上下文切换。

2. 非公平锁(Non-Fair Lock)

非公平锁与公平锁的最大不同在于,非公平锁并不保证线程按照请求的顺序来获取锁,而是允许线程绕过排队机制直接尝试获取锁。

特性:
  • 非顺序获取:非公平锁的线程会尽量快速地获取锁,如果当前锁没有被占用,线程就可以直接获取锁,即使其他线程正在等待。
  • 可能导致线程饥饿:由于线程可能绕过队列直接获取锁,可能会导致某些线程长时间得不到执行(出现线程饥饿)。
  • 性能较好:非公平锁在很多场景下能提高性能,减少了排队等待的时间。
优缺点:
  • 优点:非公平锁通常能获得更高的性能,因为它不需要线程严格按照请求顺序来排队。
  • 缺点:由于线程有可能绕过排队机制,可能会导致线程饥饿,某些线程可能一直得不到执行。

3. 可重入锁(Reentrant Lock)

可重入锁是指同一个线程在请求锁的时候,如果该线程已经持有锁,它可以再次获取该锁而不会导致死锁。这意味着一个线程可以多次进入已加锁的代码块,每次加锁都会记录锁的深度,当线程释放锁时,只有在锁的深度归零时,锁才会被释放。

特性:
  • 锁的重入性:同一个线程可以多次获取它已经持有的锁,每次获取锁时,锁的深度会增加,只有深度为零时,锁才会被释放。
  • 避免死锁:可重入锁可以防止死锁,因为同一线程在多次请求锁时不会被阻塞。

优缺点:

  • 优点:可重入锁能够避免因递归调用或函数内部调用时产生死锁,提供了更灵活的锁管理。
  • 缺点:如果过度使用可能导致锁的持有时间过长,从而影响其他线程的执行。

CAS(Compare-And-Swap)是什么?

CAS 是一种原子操作,通常用于并发编程中,表示 比较并交换。它的操作过程如下:

  1. 比较某个值(原始值)与期望值(当前值)。
  2. 如果它们相等,则将该值替换为新值。
  3. 如果它们不相等,则不做任何操作。

CAS 通过硬件原子操作来实现,在多线程环境下避免了锁的使用,从而可以提高并发性能。

  • 优势:

    • 无锁:CAS 不需要像传统锁那样进行加锁和解锁,因此可以减少锁的开销,提高并发性能。
    • 高效:由于 CAS 是一种硬件级的操作,速度非常快。
  • 问题:

    • ABA 问题:在 CAS 操作中,可能会遇到所谓的 ABA 问题。如果某个线程在更新数据时,先检测到期望值是 A,然后其他线程把数据改回了 A,那么 CAS 可能会误认为数据没有变化,从而出现错误。为了解决这个问题,可以使用版本号或时间戳来辅助。
    • 自旋问题:CAS 会自旋多次进行尝试,直到成功为止。如果 CAS 的失败率很高(比如频繁发生冲突),可能会导致大量的 CPU 占用。

针对cas自旋压力过大,可以使用LongAddr这个类去解决

这里添加一个cas锁的真实使用案例:

import java.util.concurrent.atomic.AtomicInteger;

public class VisitCounter {
    // 使用AtomicInteger来实现线程安全的计数器
    private AtomicInteger visitCount = new AtomicInteger(0);

    // 增加访问量
    public void increment() {
        int oldValue;
        int newValue;
        do {
            oldValue = visitCount.get(); // 获取当前值
            newValue = oldValue + 1;     // 计算新值
        } while (!visitCount.compareAndSet(oldValue, newValue)); // CAS操作
    }

    // 获取当前访问量
    public int getVisitCount() {
        return visitCount.get();
    }

    public static void main(String[] args) throws InterruptedException {
        VisitCounter counter = new VisitCounter();

        // 创建多个线程来模拟并发访问
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);
        Thread thread3 = new Thread(task);

        thread1.start();
        thread2.start();
        thread3.start();

        // 等待所有线程执行完毕
        thread1.join();
        thread2.join();
        thread3.join();

        // 输出最终的访问量
        System.out.println("Total visit count: " + counter.getVisitCount());
    }
}
  1. AtomicIntegerAtomicInteger是Java提供的原子类,内部使用CAS操作来保证线程安全。

  2. increment方法

    • 使用do-while循环和compareAndSet方法实现CAS操作。

    • 如果当前值oldValue未被其他线程修改,则更新为newValue;否则重试。

  3. 多线程测试

    • 创建3个线程,每个线程增加计数器1000次。

    • 最终输出计数器的值,预期结果为3000。

CAS(Compare-And-Swap)是AtomicInteger等原子类的内置操作,底层通过硬件指令(如CPU的CAS指令)实现,确保操作的原子性。

问题2:一人一单

思路很简单,就是增加一层逻辑,让一个用户只能下一个单,而不是让一个用户下多个单

// 5.一人一单逻辑
    // 5.1.用户id
    Long userId = UserHolder.getUser().getId();
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    // 5.2.判断是否存在
    if (count > 0) {
        // 用户已经购买过了
        return Result.fail("用户已经购买过一次!");
    }

只需在查询完库存以后判断一下即可 ,但是又引入了同样的问题,并发过来,查询数据库,都查不到订单,然后就都去创建订单,那不就出事了吗,所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作

解决方案:

第一版方案:把下单业务单独抽出一个函数,并添加synchronized锁:

@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {

	Long userId = UserHolder.getUser().getId();
         // 5.1.查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2.判断是否存在
        if (count > 0) {
            // 用户已经购买过了
            return Result.fail("用户已经购买过一次!");
        }

        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
                .update();
        if (!success) {
            // 扣减失败
            return Result.fail("库存不足!");
        }

        // 7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 7.1.订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 7.2.用户id
        voucherOrder.setUserId(userId);
        // 7.3.代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        // 7.返回订单id
        return Result.ok(orderId);
}

这是第一版方案,但是存在许多问题,首先就是锁的粒度太大了,锁的是这一段业务流程,每个进程进入这段代码都会被锁住,因为前四步是公共部分,包括查询优惠券是否在发放时间和库存是否充足这几个操作,做完这些操作后这些进程就都被锁住了,这也太离谱了,一次只能一个进程创建订单,那得多慢啊。同一时间内,同一个 JVM 实例只能允许一个线程执行该方法,无论是哪个用户发起的请求。多个用户的请求都必须等待前一个请求完成才能继续执行,即使是不同用户的订单创建,也会被锁住。

所以采用第二版方案:

@Transactional
public  Result createVoucherOrder(Long voucherId) {
	Long userId = UserHolder.getUser().getId();
	synchronized(userId.toString().intern()){
         // 5.1.查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2.判断是否存在
        if (count > 0) {
            // 用户已经购买过了
            return Result.fail("用户已经购买过一次!");
        }

        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
                .update();
        if (!success) {
            // 扣减失败
            return Result.fail("库存不足!");
        }

        // 7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 7.1.订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 7.2.用户id
        voucherOrder.setUserId(userId);
        // 7.3.代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        // 7.返回订单id
        return Result.ok(orderId);
    }
}

和第一版方案的区别就是synchronized的位置变了,这次锁的是常量池中的userID这个字符串,这段代码中,intern() 方法的作用是确保所有相同 userId 的字符串在 JVM 运行时常量池中只存储一份,从而保证多个线程在对同一个 userId 进行加锁时,使用的是同一把锁,确保线程安全。这样写达到的效果就是不同用户之间的请求可以并发执行,而同一个用户的线程一次只能执行一个。并发性能更好。

这里有个小问题,就是如果在常量池中存储用户的字符串id,倘若用户量很大是不是会导致堆区炸了呢,所以这里我进行了优化,改用concurrentHashmap,既能保证线程安全,又能防止字符串常量池膨胀。代码如下:

private static final concurrentHashMap<Long,Object> lockMap=new ConcurrentHashMap<>();

...
Long userId=123L;
Object lock=lockMap.computeIfAbsent(userId,k->new Object());//value就是随遍生成的一个object对象
synchronized(lock){


}

但是第二版还是存在问题,这个函数是有一个@Transactional注解,这个注解是spring的事务注解,而synchronized锁是咱们自己加的,这就有一个问题,把锁加在了事务里面,想象一种情况,在线程a执行这个函数的时候,

开启事务->获得锁->执行业务->释放锁,

当执行到这里的时候,下一步是提交事务,但是就在此时另一个线程b进入了这个函数,因为锁已经释放了,所以b可以顺利的获得锁,但是b在执行业务的时候就读到的是旧数据,因为a的事务还没有提交,所以这就是问题所在。

解决方法:

通过这种方法把锁加在事务外面就可以了

但是。。。。

问题又出现了,那就是spring的事务要想生效就需要利用代理来生效,所以这地方我们需要获得原始的事务对象, 来操作事务。

这就是全部的解决方案了,写了两个函数,一个是setkill Voucher,在这个函数的最后,获得代理对象并调用createVoucherOrder函数来实现创建订单。

这里拓展一下Sring如何实现事务的,为什么这么设计,事务执行的流程是什么?

1. 什么是代理机制?

代理机制,简单来说就是“找替身”。
假设你是一个明星(目标对象),每天要接很多通告(方法调用),但你太忙了,于是你找了一个经纪人(代理对象)帮你处理这些事。

  • 当有人找你拍广告(调用方法)时,他们会先找到经纪人。

  • 经纪人会先做一些准备工作(比如开启事务),然后再把任务交给你。

  • 等你完成任务后,经纪人还会做一些收尾工作(比如提交或回滚事务)。

在 Spring 中,代理对象就是那个“经纪人”,而目标对象就是“你”。
Spring 通过代理对象来拦截方法调用,并在方法执行前后加入一些额外的逻辑(比如开启事务、提交事务等)。

2. Spring AOP 如何通过代理机制实现事务拦截?

Spring 的事务管理是基于 AOP 的,具体步骤如下:

(1)定义事务逻辑

Spring 定义了一些事务逻辑,比如:

  • 方法执行前:开启事务。

  • 方法执行后:提交事务。

  • 方法抛出异常时:回滚事务。

(2)创建代理对象

当你在一个方法上加了 @Transactional 注解时,Spring 会为这个类创建一个代理对象。
这个代理对象会“包裹”原来的目标对象,并在方法调用时插入事务逻辑。

(3)方法调用过程

当你调用一个带有 @Transactional 注解的方法时,实际发生的事情是这样的:

  1. 你调用的是代理对象的方法,而不是直接调用目标对象的方法。

  2. 代理对象在方法执行前,先开启事务。

  3. 代理对象调用目标对象的实际方法。

  4. 目标对象的方法执行完成后,代理对象根据方法执行结果(成功或异常)决定提交或回滚事务。


3. 为什么这么设计?

(1)解耦
  • 目标对象只需要关注自己的核心业务逻辑(比如保存用户数据)。

  • 事务管理这种横切关注点(Cross-Cutting Concern)交给代理对象处理。

  • 这样设计可以让代码更清晰,职责更单一。

(2)灵活性
  • 通过代理机制,Spring 可以在不修改目标对象代码的情况下,动态地为其添加功能(比如事务管理、日志记录、权限检查等)。

  • 这种设计符合 AOP(面向切面编程) 的思想,即通过切面将横切关注点与核心业务逻辑分离。

(3)透明性
  • 对于开发者来说,你只需要在方法上加一个 @Transactional 注解,Spring 就会自动帮你处理事务。

  • 你不需要关心事务是如何开启、提交或回滚的,这些细节都由代理对象处理。

(4)避免重复代码
  • 如果没有代理机制,你需要在每个方法中手动编写事务管理的代码(比如开启事务、提交事务、回滚事务等)。

  • 通过代理机制,Spring 可以统一处理这些重复的逻辑,减少代码冗余。

spring中事务失效的情况:

1. 非 public 方法上使用 @Transactional

原因:

Spring 的事务拦截是基于代理机制的,而 Spring 默认只对 public 方法生效。如果 @Transactional 注解用在非 public 方法(如 privateprotected 或包级私有方法)上,事务不会生效。

解决方法:
  • 确保 @Transactional 注解只用在 public 方法上。

2. 方法内部调用导致事务失效

原因:

当一个方法内部直接调用另一个带有 @Transactional 注解的方法时,事务会失效。这是因为 Spring 的事务拦截是通过代理对象实现的,而内部调用是通过 this 直接调用目标方法,绕过了代理对象。就是项目中的情况

3. 异常被捕获且未抛出

原因:

Spring 默认只在抛出 未检查异常(继承自 RuntimeException)或 检查异常(需要在 @Transactional 中显式配置)时回滚事务。如果异常被捕获且未重新抛出,事务不会回滚。

4. 多线程环境下事务失效

原因:

Spring 的事务管理是基于 ThreadLocal 实现的,事务上下文绑定在当前线程中。如果在多线程环境下开启新线程执行任务,新线程无法继承原线程的事务上下文,导致事务失效。

数据库不支持事务

事务未开启

事务配置错误

问题三.集群环境下的并发问题

刚才讲的一切都是基于单机环境的秒杀功能,但是在集群模式下就不行了。

具体问题如下:

解决方法放在下一章

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值