1.
全局唯一ID
每个店铺都可以发布优惠券,当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:
* id的规律性太明显
* 受单表数据量的限制
场景分析:如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天时间内,卖出了多少单,这明显不合适。
场景分析二:随着我们商城规模越来越大,mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的id是不能一样的,但拆表的话id又得从一开始自增,于是乎我们需要保证id的唯一性。
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性

ID的组成部分:符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

java@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.生成时间戳 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; } }首先long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
toEpochSecond(ZoneOffset.UTC)是一个方法,能返回指定时区(ZoneOffset.UTC)目前到最开始时间搓的那个秒数,做差就可以生成时间搓了。
然后为什么这样涉及从天开始生成序列号,因为前面是秒,你同一秒可能有大量线程去申请这个id,所以就需要后面的序列号控制:
为什么要按 "天" 来划分?
这里用天(
yyyy:MM:dd)来做 Redis Key 的一部分,主要有两个核心原因:
避免序列号无限膨胀如果不按天划分,Redis 的 Key 会一直自增,理论上可以到 263−1,但:
- 占用 Redis 内存,Key 会越来越大;
- 一旦超过
long类型上限(虽然 32 位序列号上限是 42 亿,单日很难打满),会出现溢出风险;- 按天重置,每天从 1 开始,既安全又节省内存。
便于统计和排查
- 按天生成 Key(如
icr:order:2026:02:18),可以方便地统计每天生成了多少 ID;- 出现问题时,也能快速定位是哪一天的 ID 序列出了问题
而那个自增长的话在redis中能够在分布式实现唯一:
原子性 :在分布式环境下,多个线程 / 服务同时调用
increment,Redis 会保证每次返回的count都是唯一且递增的,不会出现重复;测试
private ExecutorService es = Executors.newFixedThreadPool(500);
ExecutorService:Java 并发包中用于管理线程池的核心接口,提供了提交任务、关闭线程池等能力。Executors.newFixedThreadPool(500):这是一个工厂方法,用于创建一个固定线程数 的线程池:
- 线程池里始终有 500 个核心线程,不会被回收;
- 当任务提交时,如果线程池里有空闲线程,就立即执行;
- 如果所有线程都在忙,新任务会进入一个无界队列中等待。
- 要使线程执行某个任务,使用es.submit(task)即可,task是一个线程任务,day02咱们有写过,只不过是直接写的函数式接口
Countdownlatch名为信号枪:主要的作用是同步协调在多线程的等待于唤醒问题:CountDownLatch 中有两个最重要的方法 1、countDown 2、await await 方法 是阻塞方法,我们担心分线程没有执行完时,main线程就先执行(就是这里要统计我们那id的耗时,所以希望那300个线程先执行完再执行那个统计时间的逻辑),所以使用await可以让main线程阻塞,那么什么时候main线程不再阻塞呢?当CountDownLatch内部维护的变量变为0时,就不再阻塞,直接放行,那么什么时候CountDownLatch 维护的变量变为0 呢,我们只需要调用一次countDown ,内部变量就减少1,我们让分线程和变量绑定(CountDownLatch latch = new CountDownLatch(300)就是在设置内部变量), 执行完一个分线程就减少一个变量,当分线程全部走完,CountDownLatch 维护的变量就是0,此时await就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。
javaprivate ExecutorService es = Executors.newFixedThreadPool(500); @Test void testIdWorker() throws InterruptedException { CountDownLatch latch = new CountDownLatch(300); Runnable task = () -> { for (int i = 0; i < 100; i++) { long id = redisWorker.nextId("order"); System.out.println("id = " + id); } latch.countDown(); }; long begin = System.currentTimeMillis(); for (int i = 0; i < 300; i++) { es.submit(task); } latch.await(); long end = System.currentTimeMillis(); System.out.println("time = " + (end - begin)); }
2.添加优惠卷
首先分析基本结构:
tb_voucher:优惠券的基本信息,优惠金额、使用规则等
tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息
平价卷由于优惠力度并不是很大,所以是可以任意领取
而代金券由于优惠力度大,所以像第二种卷,就得限制数量,从表结构上也能看出,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段(即特价卷也是优惠卷,在tb_voucher表里也有)基本的初始化我就不写了,就是添加基础卷,添加秒杀卷并且保存秒杀卷信息到redis里。
3.实现秒杀下单
下单时需要判断两点:
* 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
* 库存是否充足,不足则无法下单
下单核心逻辑分析:
当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件
比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。这是最基本的框架。。
java
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
/*
* 秒杀券下单
* */
@Resource
private VoucherOrderMapper voucherOrderMapper;
@Resource
private RedisWorker redisWorker;
@Resource
private SeckillVoucherMapper seckillVoucherMapper;
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
//1. 查询优惠券
Voucher voucher = seckillVoucherMapper.getById(voucherId);
//2.如果优惠券不存在,返回失败
if (voucher == null){
return Result.fail("优惠券不存在");
}
//3.存在,查看活动是否开始,查看活动是否结束
LocalDateTime beginTime = voucher.getBeginTime();
LocalDateTime endTime = voucher.getEndTime();
if (beginTime.isAfter(LocalDateTime.now()) || endTime.isBefore(LocalDateTime.now())){
return Result.fail("优惠券未开始");
}
//4.判断是否库存充足,不足返回
Integer stock = voucher.getStock();
if (stock <= 0){
return Result.fail("库存不足");
}
//5.扣减库存
int update = seckillVoucherMapper.update(voucherId,stock-1);
//6.生成订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(redisWorker.nextId("order"));
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
voucherOrder.setCreateTime(LocalDateTime.now());
voucherOrderMapper.insert1(voucherOrder);
//7.返回订单
return Result.ok(voucherOrder.getId());
}
4.库存超卖问题:
假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案:
(1)悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等
(2)乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,
那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过
当然乐观锁还有一些变种的处理方式比如cas
(3)
课程中的使用方式是没有像cas一样带自旋的操作,也没有对version的版本号+1 ,他的操作逻辑是在操作时,对版本号进行+1 操作,然后要求version 如果是1 的情况下,才能操作,那么第一个线程在操作后,数据库中的version变成了2,但是他自己满足version=1 ,所以没有问题,此时线程2执行,线程2 最后也需要加上条件version =1 ,但是现在由于线程1已经操作过了,所以线程2,操作时就不满足version=1 的条件了,所以线程2无法执行成功

但其实可以把库存当作版本号,只要现在where stock = 原来的stock这样就可以修改,如果已经有人改过就不会成功!所以只需要在扣减库存时
java
<update id="update">
UPDATE tb_seckill_voucher
SET stock = #{stock1}
WHERE voucher_Id = #{voucherId};
</update>
改变为:这里stock2是需要传入的,表示之前我们查到的库存
java
<update id="update">
UPDATE tb_seckill_voucher
SET stock = #{stock1}
WHERE voucher_Id = #{voucherId} AND stock = #{stock2}
</update>
只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败,这很明显是不对的,应该在库存满足的前提下,尽可能多的成功。不然我抢票的时候我点的是应该能拿到的,但是因为有人和我一块,导致我失败了,然而比我靠后的还能强到,这不对,哈哈哈,其实改动很简单,只要判断库存大于0即可。我们让只要有库存就可以拿到。
java
<update id="update">
UPDATE tb_seckill_voucher
SET stock = #{stock1}
WHERE voucher_Id = #{voucherId} AND stock > 0
</update>
这样就完成了超卖现象的处理。但是还有一个问题,就是一人多单的问题,既然是秒杀卷,是为了推广店面,自然不可能只让一个人抢完,这里我们完成一人一单的功能,可以在这个基础上完成一人限买两单或者几单。
5.一人一单
基础代码就是加上一个判断,去判断一下这个人是不是买了一单。

加上这个代码即可
java
//一人一单
Long userId = UserHolder.getUser().getId();
Long count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0){
return Result.fail("一人一单");
}
同样这仅仅是一个基础代码,这样写是存在问题的,同样也是多个线程进来,一个线程判断当前用户没下单,然后就去下单,修改数据库,但是中间又有一个线程进来,由于第一个线程还没改完于是又下单。所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作。我们使用synchronized修饰一个方法,这样就达到了悲观锁的效果
改进一
java
@Override
public Result seckillVoucher(Long voucherId) {
//1. 查询优惠券
Voucher voucher = seckillVoucherMapper.getById(voucherId);
//2.如果优惠券不存在,返回失败
if (voucher == null){
return Result.fail("优惠券不存在");
}
//3.存在,查看活动是否开始,查看活动是否结束
LocalDateTime beginTime = voucher.getBeginTime();
LocalDateTime endTime = voucher.getEndTime();
if (beginTime.isAfter(LocalDateTime.now()) || endTime.isBefore(LocalDateTime.now())){
return Result.fail("优惠券未开始");
}
//4.判断是否库存充足,不足返回
Integer stock = voucher.getStock();
if (stock <= 0){
return Result.fail("库存不足");
}
Result voucherOrder = this.createVoucherOrder(voucherId);
return voucherOrder;
}
@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("用户已经购买过一次!");
}
//5.扣减库存
int update = seckillVoucherMapper.update(voucherId,stock-1);
if (update <= 0){
return Result.fail("库存不足");
}
//6.生成订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(redisWorker.nextId("order"));
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
voucherOrder.setCreateTime(LocalDateTime.now());
voucherOrderMapper.insert1(voucherOrder);
//7.返回订单
return Result.ok(voucherOrder.getId());
}
但是这样添加锁,锁的粒度太粗了,每个线程每个用户都得加锁,这很明显是不对的,我李四进来锁了,我张三不应该锁呀,所以在使用锁过程中,控制锁粒度是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会锁住,所以合适的不应该锁这个方法,而是锁每个用户,这可以从拦截器哪里得到。
所以应该是这样的
改进二

但注意

synchronized(userId.toString())是有问题的,因为你看这个tostring源码,还是返回一个字符串对象,这样不同线程进来虽然userid一样,但是创建的对象是不同的,这样锁对象是不能完成的,我们可以调用:
意思就是这个就直接看常量池有没有一样的东西,就是值一样锁就一样。所以i就是这样的。
改进三

但是以上代码还是存在问题,问题的原因在于当前方法被spring的事务控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,因为你锁在事务里,所以流程是先开启事务,获取锁,然后释放锁,然后提交事务,可能你释放锁了以后,事务还没有提交,又进来一个线程,这是因为锁有点小了,我们可以先锁再事务这样万无一失。
改进四

但注意这又有一个致命问题,这里事务会失效,就是在类内部调用事务方法,这里是this.方法,事务是基于动态代理的,这样调用事务会失效,所以这个地方,我们需要获得原始的事务对象, 来操作事务。
使用这个要引入依赖
java
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
还要在启动类上面加一个注解去暴露动态代理,默认是不暴露的,加上这个就可以获得IVoucherOrderService代理对象。

这是最终代码:
java
public Result seckillVoucher(Long voucherId) {
//1. 查询优惠券
Voucher voucher = seckillVoucherMapper.getById(voucherId);
//2.如果优惠券不存在,返回失败
if (voucher == null){
return Result.fail("优惠券不存在");
}
//3.存在,查看活动是否开始,查看活动是否结束
LocalDateTime beginTime = voucher.getBeginTime();
LocalDateTime endTime = voucher.getEndTime();
if (beginTime.isAfter(LocalDateTime.now()) || endTime.isBefore(LocalDateTime.now())){
return Result.fail("优惠券未开始");
}
//4.判断是否库存充足,不足返回
stock = voucher.getStock();
if (stock <= 0){
return Result.fail("库存不足");
}
userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
IVoucherOrderService o = (IVoucherOrderService)AopContext.currentProxy();
Result voucherOrder = o.createVoucherOrder(voucherId);
return voucherOrder;
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 5.1.查询订单
Long count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
//5.扣减库存
int update = seckillVoucherMapper.update(voucherId, stock - 1);
if (update <= 0) {
return Result.fail("库存不足");
}
//6.生成订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(redisWorker.nextId("order"));
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
voucherOrder.setCreateTime(LocalDateTime.now());
voucherOrderMapper.insert1(voucherOrder);
//7.返回订单
return Result.ok(voucherOrder.getId());
}

