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.实现秒杀下单
秒杀下单应该思考的内容:
下单时需要判断两点:
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
- 库存是否充足,不足则无法下单
下单核心逻辑分析:
当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件
比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。
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,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案:见下图:
4.2.乐观锁解决
乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如cas
代码:
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0
5.悲观锁实现一人一单功能
需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单
现在的问题在于:
优惠卷是为了引流,但是目前的情况是,一个人可以无限制的抢这个优惠卷,所以我们应当增加一层逻辑,让一个用户只能下一个单,而不是让一个用户下多个单
具体操作逻辑如下:比如时间是否充足,如果时间充足,则进一步判断库存是否足够,然后再根据优惠卷id和用户id查询是否已经下过这个订单,如果下过这个订单,则不再下单,否则进行下单
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);
}
关键点:
- 细粒度锁:使用用户ID作为锁对象,确保同一用户的操作串行化
intern()
方法:保证相同用户ID字符串使用同一把锁- 代理对象:通过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
注解确保:
- 库存扣减
- 订单创建
这两个操作要么全部成功,要么全部失败
5.1.3. 代码中的关键优化点
5.1.3.1 锁粒度的选择
- 不是锁整个方法(粗粒度)
- 不是锁整个系统(全局锁)
- 而是按用户ID加锁(细粒度)
5.1.3.2 双重检查机制
- 同步块外:快速失败检查(库存、时间等)
- 同步块内:精确检查(一人一单)
5.1.3.3 事务与锁的顺序
正确的执行顺序:
- 先获取用户锁
- 再开始事务
- 执行业务
- 提交事务
- 释放锁
5.1.4. 完整执行流程
- 参数校验:检查优惠券是否存在、秒杀时间是否有效
- 库存检查:确保有足够库存
- 获取用户锁:防止同一用户并发操作
- 创建代理对象:确保事务生效
- 二次校验:数据库查询是否已购买
- 扣减库存:使用乐观锁防止超卖
- 创建订单:生成唯一订单ID并保存
- 返回结果:将订单ID返回给前端
5.1.5. 总结
这段代码通过以下方式实现了"一人一单":
- 用户维度锁:防止同一用户并发请求
- 数据库校验:最终一致性保障
- 事务管理:确保数据完整性
- 乐观锁:解决超卖问题
这种设计在保证功能正确性的同时,也兼顾了系统性能,是一个典型的高并发场景解决方案。
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锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。
评论区