黑马点评|项目日记(day02)

目录

[一. 全局id生成器](#一. 全局id生成器)

1.为什么需要全局id生成器

2.传统方式的缺陷:

[3.典型全局 ID 生成方案的设计思路](#3.典型全局 ID 生成方案的设计思路)

二.优惠券秒杀-Redis实现全局唯一id

三.优惠券秒杀-添加优惠券

四.优惠券秒杀-实现秒杀下单

[五. 一人一单问题](#五. 一人一单问题)

1.单体项目下

1,超卖问题思路分析

2.乐观锁解决问题方法

2.集群项目下

1.为什么需要分布式锁

2.分布式锁要求及对比

[3. 使用及基本问题解决](#3. 使用及基本问题解决)

1.改进1

2.改进2

3.改进3

4.改进4(基于redis分布式锁的优化)

5.改进4(异步秒杀)

[六. 消息队列实现异步秒杀](#六. 消息队列实现异步秒杀)

1.为什么需要消息队列而不是阻塞队列

1.解耦与部署边界

[2. 高并发应对](#2. 高并发应对)

[3. 可靠性保障](#3. 可靠性保障)

[4. 功能扩展性](#4. 功能扩展性)

[2. Redis消息队列LIst](#2. Redis消息队列LIst)

[3. Redis消息队列PubSub](#3. Redis消息队列PubSub)

[4. Redis消息队列Stream](#4. Redis消息队列Stream)

[2.基于Redis的Stream结构作为消息队列, 实现异步秒杀](#2.基于Redis的Stream结构作为消息队列, 实现异步秒杀)


一. 全局id生成器

1.为什么需要全局id生成器

  • 单个数据库或服务节点可通过自增ID, 来保证局部唯一性,但不同节点的自增逻辑独立, 必然出现ID重复, 导致业务出错.

  • 在分布式系统下, 业务流程可能跨多个服务(如订单, 支付, 物流), 同一业务实体(如一笔订单)的相关诗句需在多服务间关联, 若无全局唯一标识, 数据关联将完全失效

  • 业务场景对 ID 的核心诉求需要全局生成器支撑

  • 除了唯一性,实际业务往往对 ID 有更复杂的要求

1.有序性与可排序性

2.可读性与业务关联性

3.安全性与防猜测性

4.高可用性与性能适配

2.传统方式的缺陷:

传统方案 局限性
数据库自增 ID 1. 依赖单库单点,数据库故障会导致 ID 生成中断; 2. 高并发下数据库写入瓶颈明显; 3. 跨库分表时无法保证全局唯一。
UUID/GUID 1. 无时间信息,无法通过 ID 排序; 2. 字符串格式(36 位)占用存储空间大,索引效率低; 3. 无业务含义,可读性差。
本地节点自增 不同节点的 ID 范围可能重叠,无法保证全局唯一

3.典型全局 ID 生成方案的设计思路

  1. 雪花算法(Snowflake)

    由 Twitter 提出,ID 结构为 "时间戳 + 机器 ID + 数据中心 ID + 序列号",通过划分不同节点的 ID 生成范围保证唯一性,同时包含时间信息支持排序。

    • 优点:高性能、含时间戳、可定制化;
    • 缺点:依赖节点 ID 配置,需避免机器 ID 冲突。
  2. 号段模式(Segment)

    由数据库预分配 ID 号段(如节点 A 分配1-10000,节点 B 分配10001-20000),节点本地生成 ID,用完后向数据库申请新号段。

    • 优点:减少数据库交互,性能高;
    • 缺点:需设计号段回收机制,避免 ID 浪费。
  3. Redis 自增 + 时间戳

    利用 Redis 的INCR命令生成全局递增序列,结合时间戳拼接成 ID。

    • 优点:部署简单、性能高;
    • 缺点:依赖 Redis 可用性,需处理 Redis 宕机后的 ID 连续性问题。
  4. 分布式 UUID(如 ULID)

    兼容 UUID 格式,但包含时间戳和随机数,支持排序且唯一性更强

二.优惠券秒杀-Redis实现全局唯一id

为了防止订单号出现重复或者暴露信息的情况,通过Redis生成全局唯一ID

RedisWorker工具类代码:

java 复制代码
@Component
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1735689600L;
    /**
     * 序列号的位数
     */
    private 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("yyyyMMdd"));
        // 2.2 自增长
        Long count = stringRedisTemplate.opsForValue().increment("irc" + KeyPrefix + ":" + date);

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

三.优惠券秒杀-添加优惠券

手动添加优惠券,为之后做准备, 我是使用apifox通过接口添加的.

四.优惠券秒杀-实现秒杀下单

下单时需要判断两点:

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  • 库存是否充足,不足则无法下单

这里还有一个问题, 也就是在微服务下, 大量请求情况下如何保持一人一单**(线程问题)**. 我们会在后面解决

VoucherOrderServiceImpl代码:

java 复制代码
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;        //获取全局唯一id

@Override
@Transactional  //设计对两张表进行操作,加上事务回滚,一旦出现问题可以进行事务回滚
public Result seckillVoucher(Long voucherId) {
    //1.查询优惠券
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    //2.判断秒杀是否开始
    if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
        //尚未开始
        return Result.fail("秒杀尚未开始!!");
    }
    //3.判断秒杀是否已经结束
    if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
        //已经结束
        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");
    voucherOrder.setId(orderId);
    //6.2用户id
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    //6.3代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);

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

数据库中的券订单表中会新增秒杀券的订单,同时数据库中券的库存也会减少一个

五. 一人一单问题

1.单体项目下

1,超卖问题思路分析

果我们使用Jmeter创建多个线程来抢券,库存可能会出现负数。这是个线程安全问题。也是并发安全问题,根本原因就是多个线程在操作共享的资源并且操作资源的代码有好多行,多个线程的代码没有按顺序执行,而是穿插执行。解决这个问题就是采用锁的方案。超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁。锁有两种(理念),悲观锁和乐观锁。

2.乐观锁解决问题方法

我们在编写代码的时候,代码如下:其实很简单

java 复制代码
// 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);

但是用JMeter测试,一个用户去抢券,秒杀券的库存少了十个。原因就是多线程并发操作的安全问题,发生了代码穿插执行的问题(与超卖问题原因相同)。这里没法用乐观锁,因为我这里是要插入操作,不是判断是否被修改,本来这条记录没有,你怎么使用乐观锁去判断是否原来的数据被修改呢,所以不可以用乐观锁。这里只能用悲观锁方案。

我们把代码分为两部分,原来的通过查询来判断是否还有库存放在上面一个函数,创建优惠券订单放在下面一个新的函数createVoucherOrder中。

但是如果像上图中,那样在函数上加锁,那么任何一个用户来了都要加锁,而且是同一把锁,那整个方法只能被串行执行了,性能会很差。一人一单应该是同一个用户来了,才去判断并发安全问题。我们应该对用户(ID)进行加锁

java 复制代码
@Transactional
public Result createVoucherOrder(Long voucherId) {
    // 5.一人一单
    Long userId = UserHolder.getUser().getId();

    synchronized (userId.toString().intern()) {//每一次请求来Id对象都会改变,我们要对值进行加锁(toString也会new一个字符串对象,所以加上个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);
    }
}

又出现了一个新的问题,就是我们现在用了事务,我再整个方法的代码运行完毕,锁也释放了的时候,springboot才对事务进行提交(把数据写入数据库),可能还没来得及提交,已经有新的线程拿到了锁,开始查数据库了,发现没有针对同一个用户Id的订单,他也会继续执行针对相同用户Id创建订单的代码,这时候又会出现线程安全问题。这时候就是锁的范围太小了,我们应该在整个方法的外面加锁。优化方法如下

这时候虽然已经线程安全了,但是还存在一个事务的问题。

this.createVoucherOrder(voucherId);他是没有事务功能的。因为我们是通过注解,对方法生成一个代理对象,来实现事务。你用了this就是使用(java类中的)它本身,不是它的代理对象。我们需要获得代理对象,代码如下。还需要暴露代理对象,之前还要引入依赖。

在集群下synchronized并不能保证线程的并发安全。因为在集群模式下,每一个节点都是一个全新的JVM,每个JVM都有自己的锁。锁监视器只能在当前JVM的范围内,监视线程实现互斥。

需要实现多个JVM(多个Tomcat)使用相同的锁监视器,需要一把跨进程、跨JVM的锁(Redis刚好可以胜任)。

2.集群项目下

1.为什么需要分布式锁

一个JVM有一个锁监视器, 只会有一个线程获取锁, 可以实现线程互斥, 而在集群下, 有多个JVM同时运行, 也就是多个线程并发, 获取到锁, 无法实现线程互斥. 这要求多个JVM需要同一个锁监视器.

2.分布式锁要求及对比

集群下对分布式锁的要求:

常见方法的对比:

由图可知redis胜出

3. 使用及基本问题解决
1.改进1

将锁的基本操作封装一个工具类

使用redis锁替代 synchronized ,解决锁同一个用户的多个进程方法是在锁上加上用户id

但是也存在线程安全问题

2.改进2

问题分析: 当线程A获取锁, 进行业务处理时, 发生了阻塞, 因等待超时释放锁,;而线程B获取锁, 进行业务逻辑, 此时, 线程A脱离阻塞, 完成业务, 释放了不属于线程A的锁, 也就是线程B的锁.

解决办法: 可以给每个线程的锁添加上标识, 在释放锁时判断是否为自己的锁

解决方案实现:

由于线程id是一个jvm内部递增的数字,集群条件下多个jvm可能导致线程id重复,因此要确保线程标识唯一

对封装的锁操作进行改写

3.改进3

**问题分析:**判断锁标识和释放锁之间发生阻塞(原子性问题)

在某个线程判断锁标识和释放锁之间发生阻塞, 其他线程就可以乘虚而入, 出现线程安全问题

**解决办法:**判断锁和删除锁应该具有原子性,一气呵成

有redis事务和lua脚本两种可选择, 以下对比:

  • Redis 事务

    基于MULTIEXECDISCARDWATCH等命令实现,本质是将多个命令打包成一个序列,一次性提交执行。事务执行期间,其他客户端的命令不会插入到事务队列中,保证了操作的 "隔离性",但不保证严格的 "原子性"(单个命令失败不影响其他命令执行)。

  • Lua 脚本

    基于 Redis 内置的 Lua 解释器,允许将复杂逻辑编写为 Lua 脚本,通过EVALEVALSHA命令执行。脚本中的所有 Redis 命令会被视为一个不可分割的原子操作,执行期间不会被其他请求打断,且脚本本身可以包含条件判断、循环等复杂逻辑。

由此可见Redis事务无法保证原子性, Lua脚本胜出.

编写Lua脚本

lua脚本执行是 java调用redis,redis调用lua脚本

在spring资源目录下 创建一个lua脚本,名为**unlock.lua**

修改封装的释放锁方法:

4.改进4(基于redis分布式锁的优化)

由于redis分布式锁无法实现重入, 重试获取等方法, 不够完善, 引入redisson

可重入锁原理

一个线程多次获取到锁,称为可重入锁。自定义获取锁的方式是采用 setNx lock thread,当此线程想要再次获取锁时,还是通过setNx lock thread,导致获取失败。对此线程和其他线程不加判别一律失败。

而 Redission 采用的是 setNx lock thread 1这种hash结构,key 是锁的标识 filed是线程的标识,value是获取的次数。当此线程再次获取时,会对线程进行判断,然后value值+1,在释放时,同样进行判断然后 value-1 直到为0,释放锁

流程图

有多次查询判断和数据操作不是同时进行,为防止线程安全问题,保证原子性,使用lua脚本

获取锁时:

释放锁时:

重试原理

redission并没有一味的循环尝试,而是根据自己的剩余的时间和锁
RedissonLock 类

第一次获取,若失败,则在自己剩余的时间里,监听锁的释放时间

若监听到锁的释放却没有争取到,则开始再次进行订阅,在自己剩余的时间里,进行循环

流程图

锁释放原理

在获取锁时,若传递锁过期释放时间,则过期就释放。若不传递,采用看门狗方式,初始为30s,每隔10s刷新过期时间,重新设置10s,即使锁重入也是如此。在释放锁时取消刷新

判断是否是新创建的锁,若是,给添加上定时刷新任务

主从一致原理

当redis以主从模式,读写分离下,主节点负责写,然后将数据同步给从节点,从节点负责读。如果创建锁后,在同步给从节点一瞬间主节点宕机,将导致锁的数据的丢失,其他线程还能创建锁,导致并发安全问题

redission 解决方案:

设置三个及以上的主节点,同时向各个节点保存锁的标识。只要有一个节点没来得及同步就宕机,会导致获取锁失败

缺点: 运维成本高, 实现复杂

5.改进4(异步秒杀)

**问题分析:**客户端发送请求, NGINX负载均衡到Tomcat, Tomcat内部进行业务处理, 大家可以看到其业务时串行执行, 整个业务耗时是每一步的耗时之和, 还有对数据库的读写操作和加上了分布式锁, 导致耗时非常长.

**解决方案:**将判断与数据库处理分离, redis处理的快, 而Tomcat中数据库的读写操作很慢, 让reids先行处理, 可以增加吞吐量, 只需要Tomcat不断从队列中获取,

在实现秒杀库存时, key为该优惠券编号,存入库存,可以选用String类型; key为该优惠券编号,值为购买过的用户,且不能重复。因此需要采用Set类型

实现步骤:

  1. 将对redis的查询、判断、数据操作写入一个lua脚本,防止线程并发安全问题
  1. 在java中调用redis,使得redis调用该脚本,同时将信息返回用户
  1. 开启阻塞队列 ,将信息放入队列
  1. 新开辟一个线程,从阻塞队列中获取信息后,执行数据库操作

当前问题

  • 内存限制 若不加以限制,可能导致内存溢出;若限制,队列中满了可能会丢失数据
  • 数据安全问题:
    • 基于内存储存,如果突然宕机,使得任务丢失
    • 从队列取出后,处理发生异常,导致任务丢失

六. 消息队列实现异步秒杀

1.为什么需要消息队列而不是阻塞队列

1.解耦与部署边界
  • 消息队列跨进程 / 服务,生产者、消费者可分布在不同机器,秒杀场景中前端接收请求、后端处理订单能彻底解耦,独立扩容维护;
  • 阻塞队列局限单进程,线程间通信,无法支撑分布式秒杀架构,耦合性高。
2. 高并发应对
  • 消息队列异步非阻塞,秒杀时生产者发消息即返回,快速承接瞬时流量;
  • 阻塞队列满 / 空时阻塞线程,高并发下生产者 / 消费者线程挂起,拖慢响应、压垮系统。
3. 可靠性保障
  • 消息队列支持持久化(如 Redis 持久化、Kafka 磁盘存储 ),是JVM以外的, 不受JVM内存限制, 服务器故障也能留存秒杀请求;
  • 阻塞队列依赖内存,进程重启 / 故障则消息全丢,无法保障秒杀订单完整性。
4. 功能扩展性
  • 消息队列自带重试、死信、优先级等机制,适配秒杀的订单重试、插队(如 VIP 优先)等复杂需求;
  • 阻塞队列功能单一,仅基础队列操作,难满足秒杀场景多样化逻辑。

简单说:消息队列能跨进程解耦、扛高并发、保消息不丢、灵活扩展,天然适配秒杀;阻塞队列受限于单进程、易阻塞、无持久化,撑不起秒杀的复杂场景 。

2. Redis消息队列LIst

3. Redis消息队列PubSub

4. Redis消息队列Stream

单消费者模式:

消费者组:

2.基于Redis的Stream结构作为消息队列, 实现异步秒杀

使用MQ的Stream方式结合group机制 代替之前的JVM的阻塞队列

  1. 为每个异步任务创建一个消息队列异步秒杀任务,创建一个队列名为 stream.order.voucher
    采用创建组时,如果指定的队列key不存在将队列也进行创建 使用 mkstream
    xGroup create stream.order.voucher g1 0 mkstream
  2. 修改lua脚本,成功后直接向redis队列中发送订单信息 免去java自己去指定阻塞队列,自己去放入数据,取出数据
  3. 父线程先去生成订单编号作为参数,执行脚本 根据脚本结果进行判断返回前台,如果创建成功,脚本会自动将订单信息发送到队列中
  4. 子线程从redis指定消息队列中获取消息,完成真正下单任务
    从队列取出进行处理

从失败队列取出再次处理

真正操作数据库的方法

通过redisMq处理异步消息,使得前台返回速度加快,后台消息不会丢失,且集群环境下能够一起处理,合理分配任务且不会重复