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 是一种原子操作,通常用于并发编程中,表示 比较并交换。它的操作过程如下:
- 比较某个值(原始值)与期望值(当前值)。
- 如果它们相等,则将该值替换为新值。
- 如果它们不相等,则不做任何操作。
CAS 通过硬件原子操作来实现,在多线程环境下避免了锁的使用,从而可以提高并发性能。
-
优势:
- 无锁:CAS 不需要像传统锁那样进行加锁和解锁,因此可以减少锁的开销,提高并发性能。
- 高效:由于 CAS 是一种硬件级的操作,速度非常快。
-
问题:
- ABA 问题:在 CAS 操作中,可能会遇到所谓的 ABA 问题。如果某个线程在更新数据时,先检测到期望值是
A,然后其他线程把数据改回了A,那么 CAS 可能会误认为数据没有变化,从而出现错误。为了解决这个问题,可以使用版本号或时间戳来辅助。 - 自旋问题:CAS 会自旋多次进行尝试,直到成功为止。如果 CAS 的失败率很高(比如频繁发生冲突),可能会导致大量的 CPU 占用。
- ABA 问题:在 CAS 操作中,可能会遇到所谓的 ABA 问题。如果某个线程在更新数据时,先检测到期望值是
针对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());
}
}
-
AtomicInteger:
AtomicInteger是Java提供的原子类,内部使用CAS操作来保证线程安全。 -
increment方法:
-
使用
do-while循环和compareAndSet方法实现CAS操作。 -
如果当前值
oldValue未被其他线程修改,则更新为newValue;否则重试。
-
-
多线程测试:
-
创建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 注解的方法时,实际发生的事情是这样的:
-
你调用的是代理对象的方法,而不是直接调用目标对象的方法。
-
代理对象在方法执行前,先开启事务。
-
代理对象调用目标对象的实际方法。
-
目标对象的方法执行完成后,代理对象根据方法执行结果(成功或异常)决定提交或回滚事务。
3. 为什么这么设计?
(1)解耦
-
目标对象只需要关注自己的核心业务逻辑(比如保存用户数据)。
-
事务管理这种横切关注点(Cross-Cutting Concern)交给代理对象处理。
-
这样设计可以让代码更清晰,职责更单一。
(2)灵活性
-
通过代理机制,Spring 可以在不修改目标对象代码的情况下,动态地为其添加功能(比如事务管理、日志记录、权限检查等)。
-
这种设计符合 AOP(面向切面编程) 的思想,即通过切面将横切关注点与核心业务逻辑分离。
(3)透明性
-
对于开发者来说,你只需要在方法上加一个
@Transactional注解,Spring 就会自动帮你处理事务。 -
你不需要关心事务是如何开启、提交或回滚的,这些细节都由代理对象处理。
(4)避免重复代码
-
如果没有代理机制,你需要在每个方法中手动编写事务管理的代码(比如开启事务、提交事务、回滚事务等)。
-
通过代理机制,Spring 可以统一处理这些重复的逻辑,减少代码冗余。
spring中事务失效的情况:
1. 非 public 方法上使用 @Transactional
原因:
Spring 的事务拦截是基于代理机制的,而 Spring 默认只对 public 方法生效。如果 @Transactional 注解用在非 public 方法(如 private、protected 或包级私有方法)上,事务不会生效。
解决方法:
-
确保
@Transactional注解只用在 public 方法上。
2. 方法内部调用导致事务失效
原因:
当一个方法内部直接调用另一个带有 @Transactional 注解的方法时,事务会失效。这是因为 Spring 的事务拦截是通过代理对象实现的,而内部调用是通过 this 直接调用目标方法,绕过了代理对象。就是项目中的情况
3. 异常被捕获且未抛出
原因:
Spring 默认只在抛出 未检查异常(继承自 RuntimeException)或 检查异常(需要在 @Transactional 中显式配置)时回滚事务。如果异常被捕获且未重新抛出,事务不会回滚。
4. 多线程环境下事务失效
原因:
Spring 的事务管理是基于 ThreadLocal 实现的,事务上下文绑定在当前线程中。如果在多线程环境下开启新线程执行任务,新线程无法继承原线程的事务上下文,导致事务失效。
数据库不支持事务
事务未开启
事务配置错误
问题三.集群环境下的并发问题
刚才讲的一切都是基于单机环境的秒杀功能,但是在集群模式下就不行了。
具体问题如下:

解决方法放在下一章
&spm=1001.2101.3001.5002&articleId=145875216&d=1&t=3&u=b31d856d982b429bbdb72e494ca965aa)
2万+

被折叠的 条评论
为什么被折叠?



