《黑马点评》项目复盘--功能实现(2)

三、优惠券秒杀

1. 优惠券秒杀下单

(1)全局唯一ID

    实现逻辑

全局ID生成器,是一种在分布式系统下来生成全局唯一ID的工具,一般需要考虑的特性:

①唯一性②高可用③高性能④递增性(便于数据统计)⑤安全性

为了增强ID的安全性,我们使用拼接的一些信息实现:

符号位:1bit,表示0;时间戳:31bit,以秒为单位表示当前时间戳;序列号:Redis自增实现(每天才开始自增)

全局唯一ID生成策略:UUID、Redis自增、snowflake算法(信息拼接,时间戳达到毫秒级)、数据库自增

代码实现
@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;
    }
}

(2)秒杀下单功能实现

实现流程

①用户id、优惠券的id; ②判断是否在抢购时间内;③优惠券id查找库存是否充足,充足余量-1;④将订单写入数据库,返回优惠券的订单id。

代码实现
    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("库存不足!");
        }

        return createVoucherOrder(voucherId);
    }

(3)乐观锁解决超卖问题

问题描述

        查询库存是否充足和扣减库存是非同时进行,多线程情况下会出现多线程一起扣减出现超卖现象。

解决方案

悲观锁(Synchronized、Lock) 每次操作时需要用户获取锁,防止线程安全问题的发生。

认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行

乐观锁:在修改数据库时需要确认当前的数据有被其它线程修改过,未修改过才能进行数据修改。

认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。 如果没有修改则认为是安全的,自己才更新数据。 如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。

乐观锁的实现方式

版本号法

数据库中的数据引入版本号,当被某个线程修改数据后版本号+1。

CAS法

记录查询时的数据,在修改时当前数据与之前数据一致才能修改。

代码实现

在扣减库存同时判断是否超卖,用一句sql语句实现。

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

乐观锁方法一实现弊端:成功率低。当100个线程同时进入时,可能有超过百分之六七十的线程判断数据被修改过,而修改数据失败。因为通过方式二对代码进行改进,只需判断库存大于0即可。

2. 一人一单

(1)一人一单基础实现

实现逻辑

                  ①在购买之前查询优惠券的订单列表,看当前用户是否购买过。

                  ②为了防止多线程的线程安全问题,需要加锁来实现同一个用户只有一个线程进行购买

                  ③添加事务注解,保证查询、判断、和下单的原子性

代码实现
 @Transactional
    public Result createVoucherOrder(Long voucherId) {
        // 5.一人一单
        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);
        }
    }

(2)互斥锁

乐观锁是在更新数据时使用的,一人一单问题是插入数据,所以需要悲观锁来实现。

a.锁加在哪里?

方法上(作用范围是当前对象,线程是安全的,但是任何一个用户都使用同一把锁,效率低)

代码块上(效率高)

b.以什么做为锁的标识

给用户id加锁,提升效率

为什么需亚奥userId.toString().intern()

原因:每一个字符串都是一个全新的对象,toString()源码中又创建了新的字符串,因此需要intern,返回字符串常量的规范表示,也就是在常量池中查找和当前字符串值一样的值的地址。

synchronized (userId.toString().intern()){}

c.为了事务生效,需要代理对象进行调用。

事务是在释放锁之后进行提交,要是此时其它线程进来,可能新增的订单还未写入数据库,可能存在线程安全问题。因此锁需要加在整个函数的外面。

引申问题:事务失效了怎么办?如何解决事务失效的问题?

①pom文件中导入依赖坐标 ②在启动类上面加入@EnableAspectJAutoProxy(exposeProxy = true)注解③获取代理对象,通过代理对象来调用添加了@Transactional注解的函数。

        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjrt</artifactId>
        </dependency>
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
        </dependency>
 //事务使用的是代理对象进行的,所以要获取代理对象再进行调用对应的与事务有关的函数
        synchronized (userId.toString().intern()){
            IVoucherOrderService proxy =(IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {

(3)分布式锁

         分布式锁的核心是实现多线程之间的互斥。需要具有多线程可见、互斥、高可用(大多数情况下获取锁都是成功的)、高性能、安全性(死锁之类的问题)的特性。

(4)服务器集群下的线程安全问题

每个服务器都有独立的锁监视器,synchronized是通过锁监视器来控制线程的。因此在集群模式下synchronized互斥锁仍会出现线程安全问题。

解决方案

基于Redis的setnx实现分布式锁。

获取锁:①setnx实现互斥    ②添加锁过期时间,避免服务宕机引起的死锁  ③非阻塞的,获取失败后不再进行尝试获取(需要根据业务需求来定)

释放锁:①手动释放delete   ②过期后自动释放锁

代码实现

分布式锁版本一——基础实现--获取锁和释放锁

注意:线程id需要保存,并且为了安全性,需要通过UUID加锁前缀

public class SimpleRedisLock implements ILock {

    private String name;
    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标示
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

  //当前线程只能删除当前线程的锁
   @Override
    public void unlock() {
        // 释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    
    }

②一人一单问题实现 

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

        // 创建锁对象
        SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        // 尝试获取锁
        boolean isLock = redisLock.tryLock(1200);
        // 判断
        if(!isLock){
            // 获取锁失败,直接返回失败或者重试
            return Result.fail("不允许重复下单!");
        }

        try {
            // 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);
        } finally {
            // 释放锁
            redisLock.unlock();
        }

    }

分布式锁版本二——基于分布式锁误由于业务阻塞导致的误删问题

线程一在执行业务的时候发生的阻塞导致锁被超时释放。

线程二此时来获取锁,执行业务。

线程一执行完毕后,释放了线程二的锁。

线程三来了,获取锁成功,出现了线程安全问题。

释放锁的优化版本

    @Override
    public void unlock() {
        // 获取线程标示
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁中的标示
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 判断标示是否一致
        if(threadId.equals(id)) {
            // 释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }

分布式锁版本三——基于lua脚本实现

当线程一在判断是自己的线程id后,发生了阻塞导致锁超时释放。

线程二获取锁之后执行业务,锁再次被线程一误删。可能出现线程安全的问题。

基于lua脚本来实现多条Redis语句的原子性

lua脚本

  • 示例 EVAL  "redis.call('set',KEYS[1],ARGV[1])"  1 name rose  (1表示key类型的参数个数,它后面跟的几个数都是key的值)
  • lua脚本实现释放锁的功能
-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) ==  ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del', KEYS[1])
end
return 0
  • Java代码执行lua脚本(创建一个RedisScript脚本,配置位置和返回类型,通过stringRedisTemplte.execute来执行该脚本)
public class SimpleRedisLock implements ILock {

    private String name;
    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        //创建一个默认脚本,设置脚本的位置,设置返回类型,在静态代码块中初始化
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标示
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        // 调用lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId());
        //stringRedisTemplate.delete(KEY_PREFIX + name);
    }
    /*@Override
    public void unlock() {
        // 获取线程标示
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁中的标示
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 判断标示是否一致
        if(threadId.equals(id)) {
            // 释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }*/
}
总结

基于Redis的分布式锁实现思路:①通过setnx  ex实现互斥,并设置过期时间(保证在故障时锁依然可以释放,避免死锁,提升安全性),保存线程标识。

②释放锁时需要判断是否与当前id一致,再进行释放锁操作。lua脚本保证原子性。

3. 基于Redis的分布式锁优化——Redisson

问题分析

基于setnx实现的分布式锁存在下面的问题

不可重入:同一个线程无法多次获取同一把锁

不可重试:获取锁只尝试一次,返回false,没有重试机制

超时释放:锁超时释放虽然避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患。

主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当主机宕机时,如果从未同步主中的锁数据,则会出现锁实现。

Redisson是一个在Redis的基础上实现Java驻内存数据网络。它提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。

(1)Redisson入门

  • 导入依赖
 <dependency>
     <groupId>org.redisson</groupId>
     <artifactId>redisson</artifactId>
     <version>3.13.6</version>
 </dependency>
  • 配置Ression客户端(Redisson Client定义成了一个Bean,方便后续使用)
@Configuration
public class RedisConfig {
    //通过无参构造器来生成一个RedissonClient Bean
    @Bean
    public RedissonClient redissonClient(){
        //配置类
        Config config = new Config();
        //添加redis地址,这里添加了单点地址
        config.useSingleServer().setAddress("redis://localhost:6379").setPassword("123456");
        //创建客户端
        return Redisson.create(config);
    }
}
  • 使用Redisson的分布式锁
@Resource
private RedissonClient redissonClient;

void testRedisson() throws  InterruptedException{
        //1.创建锁对象
        RLock lock = redissonClient.getLock("anyLock");
        //2.尝试获取锁;阻塞式的,最大等待时间时1s,超时释放时间是10s,时间单位是s
        boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
        //3.获取锁是否成功
        if(isLock){
            try {
                //4.业务在这里有可能出现阻塞
                System.out.println("执行业务逻辑");
            } finally {
                //5.最终都要释放锁
                lock.unlock();
            }
        }
    }

(2)Redisson可重入锁的原理

基于Redis的hash结构,key记录锁的名称,field记录线程id,value记录重入次数。

获取锁

传入3个参数:key,线程id,有效期;

①判断当前key的锁是否存在,不存在,保存当前线程的数据,设置有效期,value设置为1

②当前key存在,看持有锁是不是自己线程,否,获取锁失败。③是,value+1,重置有效期

释放锁

传入3个参数:key,线程id,有效期;

①判断当前的线程id与锁持有者的线程id是否一致,不一致返回异常信息②是,value-1。③判断value是否为0,为0,delete。

(3)Redisson的锁重试和WatchDog

获取锁

①尝试获取锁,获取锁成功,保存数据。②获取锁失败,判断剩余获取锁等待时间是否大于0,大于,等待其它线程释放锁的消息。③等待超时,返回false ④等待到了,判断剩余时间,重试获取锁。⑤获取锁成功后判断锁过期时间是否为-1 ⑥为-1,开启看门狗机制。⑦返回获取锁成功或者在指定时间内获取锁失败的信号。

释放锁

①判断是否与当前线程保持一致。②一致释放锁。③若超时释放时间为默认值,关闭看门狗机制。④返回。

可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制。

超时续约:利用watchDog,每隔一段时间(release/3),重置超时时间。

(4)Redisson分布式锁主从一致性问题--MultiLock

基本原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功。
实现机制:底层是一个ArrayList, 在获取锁时,每获取到一个锁,都会加入到list集合中,只有所有 的锁都获取到才会说锁获取成功;若每个锁获取失败,则会删除已经获取存放在list集合中的锁。

会不会重试由外部设置得到,如果重试,在某一个锁获取失败后,清空已经获取的锁,将指针放在第一个,重新开始循环进行尝试。

在获取锁成功后,如果手动设置了超时释放时间,会遍历锁集合,重置超市释放时间。

lock=redissonClient.getMultiLock(lock1,lock2,lock3);

4. 异步秒杀优化

(1)流程优化思路

秒杀业务流程:①查询优惠券信息②判断库存是否充足③判断该用户是否购买了该产品④创建订单 ⑤扣减库存 ⑥订单信息写入数据库。

分析:用户端下单时间,既需要依赖于前期购买资格的判断,又依赖后面操作数据库时间。实质上购买资格确定,就可以进行下单操作了。因此,可以将购买资格判断和操作数据库异步分离

设计:购买资格判断可以在Redis中功能实现、lua脚本代码实现。①库存--Redis中String数据结构 ②一人一单--Redis中Set数据结构。

注意:在Redis购买资格判断后也需要做数据更新操作。

实现步骤

(2)数据预热

        新增优惠券同时加入Redis

@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)基于Lua脚本购买资格判断

参数传入:用户id、优惠券id、订单id

核心代码分析:①判断优惠券库存是否充足,不充足返回false ②判断用户id是否在已购买用户的集合中,存在,返回false ③不存在,添加用户到集合中,并完成扣减库存的操作。④添加用户id、优惠券id、订单id到消息队列中(下文会详细说明)。

--判断库存是否充足需要传入参数 优惠券id
--判断该用户是否下单需要传入参数 用户id
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]
--1.库存和订单在redis中的key分别是什么
local stockKey="seckill:stock:".. voucherId
local orderKey="seckill:order:"..voucherId

--2.判断库存是否充足
local stockCount = redis.call('get', stockKey)
if  stockCount == false or stockCount == nil  then
   redis.log(redis.LOG_NOTICE, "Stock key does not exist: " .. stockKey)
   return 11  -- 库存不足
elseif tonumber(stockCount) <= 0 then
   redis.log(redis.LOG_NOTICE, "Insufficient stock for key: " .. stockKey)
   return 12  -- 库存不足
end
--3.判断该用户是否已经下过单了
if(redis.call('sismember',orderKey,userId)==1) then
    --已经下过单的,返回2
    return 2
end
--表示可以下单了
--4.减库存
redis.call('incrby',stockKey,-1)
--5.添加用户id到集合中
redis.call('sadd',orderKey,userId)
--6.发送消息到队列中,XADD stream.orders * k1 v1 k2 v2
redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)
return 0

(4)基于阻塞队列的异步秒杀

阻塞队列:如果队列中没有元素,线程会被阻塞,直到有元素,线程才会被唤醒,执行业务逻辑。

整体秒杀逻辑

需要在lua脚本成功后,添加voucherOrder到阻塞队列

/**
     * 基于阻塞队列的秒杀业务逻辑
     * @param voucherId
     * @return
     */
    @Override
    public Result seckillVoucher(Long voucherId){
        //1.执行lua脚本
        Long userId= UserHolder.getUser().getId();
        Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
                Collections.emptyList(),voucherId.toString(),userId.toString());
        //2.判断结果是否为0
        //3.如果不为0,返回异常信息
        int r = result.intValue();
        if(r!=0){
            if(r==1){
                return Result.fail("库存不足");
            }else if(r==2){
                return Result.fail("每人只限购一单");
            }
        }
        //4.为0,将信息添加到阻塞队列里面
        //4.生成订单
        VoucherOrder voucherOrder=new VoucherOrder();
        //4.1 订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //4.2 用户id
        voucherOrder.setUserId(userId);
        //4.3 代金券id
        voucherOrder.setVoucherId(voucherId);
        orderTasks.add(voucherOrder);
        //5.获取代理对象
        proxy=(IVoucherOrderService) AopContext.currentProxy();
        //5.返回订单id

        return Result.ok(orderId);
    }
创建阻塞队列,创建线程池
    //线程池
    private static final ExecutorService SECKILL_ORDER_EXECUTOR= Executors.newSingleThreadExecutor();
    //类初始化之后就开始执行任务
    @PostConstruct  //该注解表示在类初始化完毕就执行
    private void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    private class VoucherOrderHandler implements Runnable{
        @Override
        public void run() {
               //执行线程任务
                return;
        }
    }

说明:线程任务需要在类初始化之后立即执行,所以需要@PostConstruct注解,实现任务的提交

编写线程任务
    private class VoucherOrderHandler implements Runnable{

        @Override
        public void run() {
            while(true){
                //1.获取队列中的订单信息
                try {
                    //take函数获取和删除该队列的头部,如果需要则等待直到元素可用,所以没有元素,会直接阻塞在这里
                    VoucherOrder voucherOrder = orderTasks.take();
                    //2.创建订单
                    handleVoucherOrder(voucherOrder);
                } catch (Exception e) {
                   log.error("处理订单异常:",e);
                }

            }
        }
    }
创建订单
     /**
     * 处理订单写入数据库
     * @param voucherOrder
     * @return
     */
    @Autowired
    private IVoucherOrderService proxy;
    
    private Result handleVoucherOrder(VoucherOrder voucherOrder) {
        long userId=voucherOrder.getUserId();
        //1.获取锁
        RLock lock = redissonClient.getLock("lock:order" + userId);
        boolean isLock = lock.tryLock();
        //2.获取锁失败,下单失败
        if(!isLock){
            //获取锁失败,返回错误或重试
            log.error("不允许重复下单");
            return null;
        }
        try {
            //该线程是子线程,无法在该处获取到事务的对象,因为需要提前获取
            //IVoucherOrderService proxy=(IVoucherOrderService) AopContext.currentProxy();
            proxy.createVoucherOrder(voucherOrder);
            return Result.ok();
        } finally {
            lock.unlock();
        }
    }

注意:该函数由子线程进行调用,因此①在获取用户id,无法从ThreadLocal中获取。②获取代理对象本质也是在ThreadLocal中获取,因此需要将其提前定义成成员变量。

@Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder){
        //5.一人一单
        long userId= voucherOrder.getUserId();
        long voucherId=voucherOrder.getVoucherId();
        //5.1 查询订单
        int count=query().eq("user_id",userId).eq("voucher_id",voucherId).count();
        //5.2 判断是否存在
        if(count>0){
            log.error("用户已经购买过一次了");
            return;
        }
        //6.扣减库存
//        boolean success = iSeckillVoucherService.update()
//                .setSql("stock=stock-1")
//                .eq("voucher_id", voucherId)
//                .eq("stock",stock)
//                .update();
        //解决效率低的问题
        boolean success = iSeckillVoucherService.update()
                .setSql("stock=stock-1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)
                .update();
        if(!success){
            log.error("优惠券已经抢完了");
            return;
        }

        save(voucherOrder);
    }

这里乐观锁只是再操作数据库时再次做双重的保障。

基于阻塞队列的异步秒杀问题

①阻塞队列是基于JVM内存,同时添加了很多订单,存在内存限制问题

②数据安全信息:依赖于JVM内存,可能存在数据丢失。

5. 消息队列

(1)消息队列简介

        消息队列,字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:

  •  消息队列:存储和管理消息,也就是消息代理。
  •  生产者:发送消息到消息队列
  •  消费者:从消息队列中获取消息并处理消息

特点:①消息队列是独立于JVM以外的服务,是独立内存,不受JVM内存的限制 ②消息队列会做数据持久化,需要消费者确认,确保数据安全。

(2)基于List结构模拟消息队列

  •  List这里是一个双向链表:使用Redis中的List的LPUSH和BRPOP实现消息队列

     (B表示带有阻塞效果,而不是没有元素就返回null值无法再进行尝试,也可以设置阻塞等待的最大时长)

  • 基于List的消息队列有哪些优缺点?

    优点:①利用Redis存储,不受限于JVM内存上限②基于Redis的持久化机制,数据安全性有保障。③可以满足消息有序性。

    缺点:①无法避免消息丢失 ②只支持单消费者(当前数据pop出去后无法再提供给其它消费者)

(3)基于PubSub的消息队列

  • PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者能收到消息

  • 基于PubSub的消息队列有哪些优缺点?

    优点:采用发布订阅模型,支持多生产、多消费

    缺点:①不支持数据持久化②无法避免消息丢失 (因为PubSub消息队列是负责发消息的,数据不在Redis中保存,如果没有消费者接收消息,消息可能会丢失)③消息堆积上限(消费者),超出时数据丢失。

(4)基于Stream的消息队列--基础

        Stream是Redis 5.0 引入的一种新数据类型(具有持久化的功能),可以实现一个功能非常完善的消息队列。

发布消息

XADD user(channel名称)  *(消息id的编号方式,默认时间戳-递增数字)name jack age 21(后面是消息体,key-value键值对)

接收消息

XREAD COUNT 1(读取的个数)BLOCK 1000(设置为阻塞队列,最大阻塞等待时间为1s,0为永久阻塞)STREAMS users (频道)  $(表示读取最新的消息,其实也可以指定读取哪一个)

XREAD命令特点

①消息可回溯 ②一个消息可以被多个消费者读取 ③可以阻塞读取 ④有消息漏读的风险 

(5)基于Stream的消息队列——消费者组

消费者组的特点

消息分流:队列中的消息会分流给组内的不同消费者,加快消息处理速率

消息标示:消费者组会维护一个标示,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标示之后读取消息,确保每一个消息被消费,不会出现消息漏读的情况。

消息确认:消费者获取消息后,消息处于pending状态(待确认状态),并存入一个pending-list。当处理完成后需要通过XACK来确认消息,才会被pending-list移除。

命令实现
                                                                                                                                                              消费者监听消息的基本思路

(6)基于Stream队列的异步秒杀

创建Stream类的消息队列     

在Redis客户端直接创建                                                                                                             

更改lua脚本,添加消息队列中消息

在原有逻辑判断购买资格时执行

--判断库存是否充足需要传入参数 优惠券id
--判断该用户是否下单需要传入参数 用户id
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]
--1.库存和订单在redis中的key分别是什么
local stockKey="seckill:stock:".. voucherId
local orderKey="seckill:order:"..voucherId

--2.判断库存是否充足
local stockCount = redis.call('get', stockKey)
if  stockCount == false or stockCount == nil  then
   redis.log(redis.LOG_NOTICE, "Stock key does not exist: " .. stockKey)
   return 11  -- 库存不足
elseif tonumber(stockCount) <= 0 then
   redis.log(redis.LOG_NOTICE, "Insufficient stock for key: " .. stockKey)
   return 12  -- 库存不足
end
--3.判断该用户是否已经下过单了
if(redis.call('sismember',orderKey,userId)==1) then
    --已经下过单的,返回2
    return 2
end
--表示可以下单了
--4.减库存
redis.call('incrby',stockKey,-1)
--5.添加用户id到集合中
redis.call('sadd',orderKey,userId)
--6.发送消息到队列中,XADD stream.orders * k1 v1 k2 v2
redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)
return 0
 获取stream队列中消息

  ①创建线程池 ②在初始化后执行线程任务 ③编写线程任务 ④不断读取消息队列,因此是死循环。⑤获取消息,失败后continue继续获取⑥成功,写入数据库,进行消息确认⑦如抛出异常,处理pendinglist中的消息。

    //线程池
    private static final ExecutorService SECKILL_ORDER_EXECUTOR= Executors.newSingleThreadExecutor();
    //类初始化之后就开始执行任务
    @PostConstruct  //该注解表示在类初始化完毕再去执行
    private void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    private class VoucherOrderHandler implements Runnable{
        @Override
        public void run() {
            String queueName="stream.orders";
            while(true){
                try {
                    //1.获取消息队列中的订单信息
                    //XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                            StreamOffset.create(queueName, ReadOffset.lastConsumed())
                    );
                    //2.获取不成功,重试或者返回
                    if(list==null||list.isEmpty()){
                        continue;
                    }
                    //3.获取成功,写入数据库
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> value = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    proxy.createVoucherOrder(voucherOrder);
                    //4.ack确认
                    stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
                } catch (Exception e) {
                   log.error("处理订单异常:",e);
                    try {
                        handlePengdingList();
                    } catch (InterruptedException interruptedException) {
                        interruptedException.printStackTrace();
                    }
                }
            }
        }
    }
pendinglist中消息处理

①获取pendinglist中的消息,这里需要将XREADGROUP中的最后一个参数设置为0 ②若消息为空,break,表明已经没有待确认的消息了 ③否则,将订单信息写入数据库 ④确认消息 ⑤在消息处理中出现异常,continue。

 private void handlePengdingList() throws InterruptedException {
        String queueName="stream.orders";
        while(true){
            try {
                //1.获取pengding-list消息队列中的订单信息
                //XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                        Consumer.from("g1", "c1"),
                        StreamReadOptions.empty().count(1),
                        StreamOffset.create(queueName, ReadOffset.from("0"))
                );
                //2.获取不成功,重试或者返回
                if(list==null||list.isEmpty()){
                    //如果获取失败,说明pengding-list没有异常消息,跳出循环
                    break;
                }
                //3.获取成功,写入数据库
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> value = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                proxy.createVoucherOrder(voucherOrder);
                //4.ack确认
                stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
            } catch (Exception e) {
                log.error("处理订单异常:",e);
                Thread.sleep(10);
            }
        }
    }
                                                                                                                                                                                                                                                                                         
### 回答1: 2019年黑马项目-畅购商城springcloud微服务实战是一门以实战为主的课程,旨在通过项目实践的方式,帮助学员深入理解和掌握SpringCloud微服务架构以及相关技术的应用。 课程的主要内容包括搭建基础的微服务架构、使用SpringCloud构建服务注册与发现、实现服务间的负载均衡、实现分布式配置中心、服务间的调用与容错处理、使用网关统一接入服务等。通过这些实战练习,学员不仅能够熟悉SpringCloud架构与组件,还能够了解微服务架构下的常见问题与解决方案。 畅购商城项目是一个典型的电商应用,通过实现项目,学员可以接触到真实的业务场景与需求,并能够将所学知识应用到实际项目中。课程中通过模块化的方式逐步完善商城的功能,包括用户注册登录、商品浏览、购物车管理、订单生成与支付等。通过这些实践,学员除了掌握SpringCloud微服务的开发技术,还能够了解和掌握电商项目的开发流程和注意事项。 该课程的目标是让学员通过实战项目,全面了解和掌握SpringCloud微服务架构的设计与开发,在此基础上能够独立完成具有较高要求的微服务项目。通过参与实战项目的过程,学员还能够提升团队协作能力、解决问题的能力以及项目管理能力。 通过这门课程的学习,学员将会对SpringCloud微服务架构有更深入的理解,并能够将这些知识应用到实际项目中,提高自己在微服务开发领域的竞争力。 ### 回答2: 2019年黑马项目-畅购商城springcloud微服务实战是一个基于springcloud微服务架构的商城项目。该项目的目标是通过运用微服务的理念和技术,构建一个高可用、可扩展的商城系统。 在该项目中,使用了springcloud的多个组件,如Eureka注册中心、Feign负载均衡、Ribbon客户端负载均衡、Hystrix服务降级和容错、Zuul网关等。这些组件共同协作,实现了系统的弹性伸缩和高可用性。 畅购商城的功能包括商品展示、购物车、订单管理、支付、用户管理等。通过将这些功能拆分成独立的微服务,使得系统更加灵活和可维护。同时,使用分布式事务和消息队列来保障数据的一致性和可靠性。 在项目的开发过程中,采用了敏捷开发的方法,以迭代的方式进行开发和测试。通过使用Jenkins进行持续集成和部署,保证了代码的质量和系统的稳定性。 在项目的实战过程中,面临了许多挑战和困难,如微服务之间的通信、服务的负载均衡、服务的容错等。但通过团队的共同努力和不断的学习,最终成功地完成了该项目的开发和部署。 在该项目的实施过程中,不仅学到了springcloud微服务架构的相关知识和技术,还体会到了团队合作和解决问题的能力。该项目的成功实施,不仅为公司带来了商业价值,也提升了团队的技术水平和项目管理能力。 ### 回答3: 2019年黑马项目-畅购商城springcloud微服务实战是一个以Spring Cloud为基础的微服务项目。微服务架构是一种将应用拆分成多个小型服务的架构模式,这些服务可以独立开发、部署、扩展和管理。 畅购商城项目使用了Spring Cloud的一系列子项目,如Eureka、Ribbon、Feign、Hystrix、Zuul等,来实现各个微服务之间的通信、负载均衡、服务降级与熔断等功能。 在项目中,我们会通过Eureka来实现服务的注册与发现,每个微服务都会向Eureka注册自己的地址,其他微服务可以通过Eureka来发现并调用这些服务。而Ribbon则负责实现客户端的负载均衡,可以轮询、随机、加权等方式分发请求。 Feign是一种声明式的HTTP客户端,它简化了服务间的调用方式。我们只需编写接口,并通过注解来描述需要调用的服务和方法,Feign会自动实现远程调用。 Hystrix是一个容错机制的实现,可以通过断路器来实现服务的降级与熔断,当某个服务出现故障或超时时,Hystrix会快速响应并返回一个可控制的结果,从而保证系统的稳定性。 另外,Zuul作为微服务网关,可以实现请求的统一入口和路由转发,提高系统的安全性和性能。 通过这些Spring Cloud的组件,畅购商城项目可以实现高可用、容错、自动扩展等优质的微服务架构。 总之,2019年黑马项目-畅购商城springcloud微服务实战是一个基于Spring Cloud的微服务项目,通过使用Spring Cloud的各个子项目,可以实现微服务之间的通信、负载均衡、服务降级与熔断等功能,为项目的开发、部署和管理提供了便利。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值