天机学堂学习笔记

前言

本篇博客会总结黑马天机学堂的一些要点,有些内容会比较简略,详细内容可参考

‍⁠​⁠​‌⁠​⁠​​​‍​​​‬‬‌​​​​​‬‬‬​​‍⁠​​‬‬​⁠​​‌‬‌​项目总结 - 飞书云文档


1:项目总览

项目技术架构:

Jenkins持续集成:

代码提交到Gogs (私有仓库),触发钩子函数,通知Jenkins ,Jenkins将新提交的代码打包成Docker容器,然后部署到服务器。

IDEA远程调试:

对于bug,最好是在相应出现bug的环境进行调试,所以当测试环境或生产环境遇到bug,需要用到IDEA的远程调试功能


2:课表和学习记录

2.1分析业务的流程:

2.2项目中如何获取用户信息?

天机学堂基于JWT实现,首先是网关解析token中用户信息,然后由具体微服务通过拦截器 将用户信息保存至UserContext 中(基于ThreadLocal实现),而拦截器被抽取在了tj-auth 模块中,具体的服务模块只需要引入tj-auth-resource-sdk依赖即可。

2.3如何保存用户播放视频记录?

情景:当播放视频中途退出时,下一次重新打开时,需要跳转到上一次的位置

2.3.1前端播放视频流程:
(1)获取视频资源

前端拿到后端返回的视频地址(一般是 mp4 或 m3u8 流地址),通过 <video> 标签加载播放。

(2)监听播放进度事件

用 HTML5 原生的 timeupdate 事件,实时获取当前播放时间:

复制代码
const video = document.getElementById('video-player');

// 视频时长(总秒数)

const duration = video.duration;

// 当前播放时间(秒)

const currentTime = video.currentTime;
(3)定时上报进度

视频播放过程中,按固定频率(比如每 10 秒)把 currentTime 传给后端,避免频繁请求影响性能。

(4)判断是否学完

前端根据 currentTime / duration >= 0.5 判断是否超过 50%,并标记为已学完,同步给后端。

2.3.2后端:

后端需要定义相关修改学习记录的接口并同步修改课表中的记录

2.3.3定期提交学习记录的优化:

后端处理定期提交学习进度存在高并发时性能差的问题,需要进行优化这里我们使用合并写请求核心优化思路是当用户在观看视频的过程中不保存记录到数据库中,当用户进度不再增加时再保存,以减少无效的数据库操作。

整体流程

1、前端上报进度,不直接改数据库,先把进度存入Redis

2、同时往延迟队列扔一个 20 秒延迟任务

3、20 秒后任务触发: 对比 Redis 里的进度有没有变化 有变化 :说明还在看,不写库,放弃本次持久化 无变化:说明暂停 / 看完了,把 Redis 数据持久化到 MySQL

Redis数据结构使用的是hash(value是json字符串):

延迟任务实现方案:

DelayQueue Redisson MQ 时间轮
原理 JDK自带延迟队列,基于阻塞队列实现。 基于Redis数据结构模拟JDK的DelayQueue实现 利用MQ的特性。例如RabbitMQ的死信队列 时间轮算法
优点 * 不依赖第三方服务 * 分布式系统下可用 * 不占用JVM内存 * 分布式系统下可以 * 不占用JVM内存 * 不依赖第三方服务 * 性能优异
缺点 * 占用JVM内存 * 只能单机使用 * 依赖第三方服务 * 依赖第三方服务 * 只能单机使用

本项目使用DelayQueue,具体实现方法略。


3、问答系统和点赞系统:

3.1问答系统:主要是一些CRUD
3.2点赞系统:
3.2.1点赞系统需要满足:

所以需要将点赞系统与其他业务解耦,抽取为一个独立服务

3.2.2实现思路:
3.2.3改进思路:

以上思路涉及许多数据库操作,需要进行改进

高并发写操作常见的优化手段有:​

•优化SQL和代码​

•变同步写为异步写​

•合并写请求

这里主要问题是高频数据库操作,所以要合并写请求:

用户点赞状态缓存 :使用 Redis 的 Set 集合存储,Key 为业务 id,Value 为点赞用户 id,通过 SADD(点赞)、SREM(取消点赞)、SISMEMBER(判断点赞状态)操作实现;批量查询时采用 Pipeline 减少网络请求次数 -
点赞次数缓存:使用 Redis 的 SortedSet(ZSet)结构存储,Key 为业务类型,Member 为业务 id,Score 为点赞总数,利用 Member 唯一性避免同一业务多次重复更新,同时支持原子性操作 -

定时任务 使用XXL-JOB

3.2.4分布式任务调度:XXL-JOB

关于XXL-JOB的详细使用:略


4、积分系统和排行榜

4.1积分系统:

签到:使用BitMap
添加积分:

需要使用MQ来进行异步解耦

4.2排行榜:

实时排行榜:

两种不同的实现思路:​

•方案一:基于MySQL的离线排序​

•方案二:基于Redis的SortedSet

这里使用SortedSet :在添加积分记录的同时更新Redis,以当前月为key,对相应的UserId添加积分

历史数据排行榜:

需要将Redis中的历史数据持久化到MySQL中

分库分表:

当数据量太大时,就涉及到分库分表了,这里使用的是水平分表按赛季划分

何时建表?

相应赛季对应的表何时创建?使用XXL-JOB定时任务


5.优惠券模块

5.1优惠券管理:

优惠券生成和兑换算法:

基于Base32和JWT

线程池异步生成兑换码:

当需要批量生成兑换码时耗时较长,需要使用线程池异步完成

1、定义线程池:注册为bean

复制代码
@Slf4j
@Configuration
public class PromotionConfig {

    @Bean
    public Executor generateExchangeCodeExecutor(){
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 1.核心线程池大小
        executor.setCorePoolSize(2);
        // 2.最大线程池大小
        executor.setMaxPoolSize(5);
        // 3.队列大小
        executor.setQueueCapacity(200);
        // 4.线程名称
        executor.setThreadNamePrefix("exchange-code-handler-");
        // 5.拒绝策略
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

2、使用MQ异步生成兑换码(原项目用SpringAsync,但是我认为这里用MQ比较好,因为SpringAsync只支持单机模式)

5.2领取优惠券:

5.2.1查询发放中的优惠券:

页面原型
原型分析

需要返回的结果中包含用户是否有优惠券和用户是否可以领取优惠券

代码思路
业务代码:
复制代码
@Override
public List<CouponVO> queryIssuingCoupons() {
    // 1.查询发放中的优惠券列表
    List<Coupon> coupons = lambdaQuery()
            .eq(Coupon::getStatus, ISSUING)
            .eq(Coupon::getObtainWay, ObtainType.PUBLIC)
            .list();
    if (CollUtils.isEmpty(coupons)) {
        return CollUtils.emptyList();
    }
    // 2.统计当前用户已经领取的优惠券的信息
    List<Long> couponIds = coupons.stream().map(Coupon::getId).collect(Collectors.toList());
    // 2.1.查询当前用户已经领取的优惠券的数据
    List<UserCoupon> userCoupons = userCouponService.lambdaQuery()
            .eq(UserCoupon::getUserId, UserContext.getUser())
            .in(UserCoupon::getCouponId, couponIds)
            .list();
    // 2.2.统计当前用户对优惠券的已经领取数量
    Map<Long, Long> issuedMap = userCoupons.stream()
            .collect(Collectors.groupingBy(UserCoupon::getCouponId, Collectors.counting()));
    // 2.3.统计当前用户对优惠券的已经领取并且未使用的数量
    Map<Long, Long> unusedMap = userCoupons.stream()
            .filter(uc -> uc.getStatus() == UserCouponStatus.UNUSED)
            .collect(Collectors.groupingBy(UserCoupon::getCouponId, Collectors.counting()));
    // 3.封装VO结果
    List<CouponVO> list = new ArrayList<>(coupons.size());
    for (Coupon c : coupons) {
        // 3.1.拷贝PO属性到VO
        CouponVO vo = BeanUtils.copyBean(c, CouponVO.class);
        list.add(vo);
        // 3.2.是否可以领取:已经被领取的数量 < 优惠券总数量 && 当前用户已经领取的数量 < 每人限领数量
        vo.setAvailable(
                c.getIssueNum() < c.getTotalNum()
                && issuedMap.getOrDefault(c.getId(), 0L) < c.getUserLimit()
        );
        // 3.3.是否可以使用:当前用户已经领取并且未使用的优惠券数量 > 0
        vo.setReceived(unusedMap.getOrDefault(c.getId(),  0L) > 0);
    }
    return list;
}

同时需要配置拦截器,使没有登录的用户也能查询到(项目中有两个拦截器,一个是获取用户身份信息,一个是拦截未登录用户)

5.2.2并发问题:

(这里并不是很好的解决方案,在5.2.3中,优惠券领取数据最终被缓存到Redis中,也不再使用乐观锁(因为乐观锁一般用于数据库的操作))

1、超卖问题:使用乐观锁进行优惠券库存判断
2、锁失效

除了优惠券库存判断,领券时还有对于用户限领数量的判断

这里不能使用乐观锁 ,因为乐观锁常用在更新 ,而且这里用户和优惠券的关系并不具备唯一性,因此新增时无法基于乐观锁做判断。

所以在单机情况下可以使用Sychronized锁住用户对象

但是因为事务而导致锁失效:

所以需要调整事务和锁边界,使得事务在锁内部。

但这又催生出新的问题:事务失效

3、事务失效

这里属于情景2 :因为事务底层基于动态代理,而外方法没有开启事务,所以它内部所调用的方法自然是没有开启事务的方法

解决方法 :使用AspectJ(Aspectj是Java的AOP框架)

5.2.3Redisson分布式锁:

Sychronized只支持单机模式,所以需要使用分布式锁,这里使用Redisson

Redisson 是基于Redis的框架,封装了很多基于Redis的功能,比如Redis分布式

5.2.3.1Redisson基本使用:
1、引入依赖:
复制代码
<!--redisson-->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
</dependency>
2、配置RedissonClient
复制代码
 @Configuration
 public class RedisConfig {
    @Bean
    public RedissonClient redissonClient() {
        // 配置类
        Config config = new Config();
        // 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址 
        config.useSingleServer()
            .setAddress("redis://192.168.150.101:6379")
            .setPassowrd("123321");
        // 创建客户端
        return Redisson.create(config);
    }
 }
3、使用
复制代码
@Autowired
 private RedissonClient redissonClient;

 @Test
 void testRedisson() throws InterruptedException {
    // 1.获取锁对象,指定锁名称
    RLock lock = redissonClient.getLock("anyLock");
    try {
        // 2.尝试获取锁,参数:waitTime、leaseTime、时间单位
        boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
        if (!isLock) {
            // 获取锁失败处理 ..
        } else {
            // 获取锁成功处理
        }
    } finally {
        // 4.释放锁
        lock.unlock();
    }
 }
在项目中的配置:

几个关键点:

  • 这个配置上添加了条件注解 @ConditionalOnClass({RedissonClient.class , Redisson.class }) 也就是说,只要引用了tj-common,并且引用了Redisson依赖,这套配置就会生效。不引入Redisson依赖,配置自然不会生效,从而实现按需引入。

  • RedissonClient的配置无需自定义Redis地址,而是直接基于SpringBoot中的Redis配置 即可。而且不管是Redis单机、Redis集群、Redis哨兵模式都可以支持。(传入的RedisPropertity会自动拉取Spring配置文件中的配置)

5.2.3.2通用分布式组件:

在使用Redisson分布式锁时有很多重复代码,所以需要进行代码抽取

这里使用AOP:

1、首先定义一个MyLock的注解
复制代码
package com.tianji.promotion.utils;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MyLock {
    String name();

    long waitTime() default 1;

    long leaseTime() default -1;

    TimeUnit unit() default TimeUnit.SECONDS;
}
2、然后定义一个环绕增强的切面类
复制代码
package com.tianji.promotion.utils;

import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

@Component
@Aspect
@RequiredArgsConstructor
public class MyLockAspect implements Ordered{

    private final RedissonClient redissonClient;

    @Around("@annotation(myLock)")
    public Object tryLock(ProceedingJoinPoint pjp, MyLock myLock) throws Throwable {
        // 1.创建锁对象
        RLock lock = redissonClient.getLock(myLock.name());
        // 2.尝试获取锁
        boolean isLock = lock.tryLock(myLock.waitTime(), myLock.leaseTime(), myLock.unit());
        // 3.判断是否成功
        if(!isLock) {
            // 3.1.失败,快速结束
            throw new BizIllegalException("请求太频繁");
        }
        try {
            // 3.2.成功,执行业务
            return pjp.proceed();
        } finally {
            // 4.释放锁
            lock.unlock();
        }
    }
    
    @Override
    public int getOrder() {
        return 0;
    }
}
3、使用锁:
4.使用锁对象工厂类创建特点的锁:

Redisson中锁的类型有多种,因此,我们不能在切面中把锁的类型写死

复制代码
public enum MyLockType {
    RE_ENTRANT_LOCK, // 可重入锁
    FAIR_LOCK, // 公平锁
    READ_LOCK, // 读锁
    WRITE_LOCK, // 写锁
    ;
}

package com.tianji.promotion.utils;

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

import java.util.EnumMap;
import java.util.Map;
import java.util.function.Function;

import static com.tianji.promotion.utils.MyLockType.*;

@Component
public class MyLockFactory {

    private final Map<MyLockType, Function<String, RLock>> lockHandlers;

    public MyLockFactory(RedissonClient redissonClient) {
        this.lockHandlers = new EnumMap<>(MyLockType.class);
        this.lockHandlers.put(RE_ENTRANT_LOCK, redissonClient::getLock);
        this.lockHandlers.put(FAIR_LOCK, redissonClient::getFairLock);
        this.lockHandlers.put(READ_LOCK, name -> redissonClient.getReadWriteLock(name).readLock());
        this.lockHandlers.put(WRITE_LOCK, name -> redissonClient.getReadWriteLock(name).writeLock());
    }

    public RLock getLock(MyLockType lockType, String name){
        return lockHandlers.get(lockType).apply(name);
    }
}
5、失败策略:

多线程争抢锁,大部分线程会获取锁失败,而失败后的处理方案和策略是多种多样的。目前,我们获取锁失败后就是直接抛出异常,没有其它策略,这与实际需求不一定相符。

这里使用设计模式中策略模式的思想:定义失败策略的枚举类

复制代码
package com.tianji.promotion.utils;

import com.tianji.common.exceptions.BizIllegalException;
import org.redisson.api.RLock;

public enum MyLockStrategy {
    SKIP_FAST(){
        @Override
        public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
            return lock.tryLock(0, prop.leaseTime(), prop.unit());
        }
    },
    FAIL_FAST(){
        @Override
        public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
            boolean isLock = lock.tryLock(0, prop.leaseTime(), prop.unit());
            if (!isLock) {
                throw new BizIllegalException("请求太频繁");
            }
            return true;
        }
    },
    KEEP_TRYING(){
        @Override
        public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
            lock.lock( prop.leaseTime(), prop.unit());
            return true;
        }
    },
    SKIP_AFTER_RETRY_TIMEOUT(){
        @Override
        public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
            return lock.tryLock(prop.waitTime(), prop.leaseTime(), prop.unit());
        }
    },
    FAIL_AFTER_RETRY_TIMEOUT(){
        @Override
        public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
            boolean isLock = lock.tryLock(prop.waitTime(), prop.leaseTime(), prop.unit());
            if (!isLock) {
                throw new BizIllegalException("请求太频繁");
            }
            return true;
        }
    },
    ;

    public abstract boolean tryLock(RLock lock, MyLock prop) throws InterruptedException;
}
6、基于SPEL的动态锁名

在当前业务中,我们的锁对象本来应该是当前登录用户,是动态获取的。而加锁是基于注解参数添加的,在编码时就需要指定。怎么办?

Spring中提供了一种表达式语法,称为SPEL表达式(Spring Expression Language,Spring 表达式语言,运行时动态取值、运算、调用方法

5.2.3.3异步领券:

领取优惠券的总体流程:

这里需要使用MQ来进行削峰填谷

我们可以将优惠券的关键信息缓存到Redis中, 用户请求进入后先读取Redis缓存,做好优惠券库存、领取数量的校验,如果校验不通过直接返回失败结果。如果校验通过则通过MQ发送消息,异步去写数据库,然后告诉用户领取成功即可。

5.2.4最终领取优惠券模块参考代码:

Service方法:
复制代码
@Service
@RequiredArgsConstructor
public class UserCouponServiceImpl extends ServiceImpl<UserCouponMapper, UserCoupon> implements IUserCouponService {

    private final CouponMapper couponMapper;

    private final IExchangeCodeService codeService;

    private final StringRedisTemplate redisTemplate;

    private final RabbitMqHelper mqHelper;

    @Override
    @Lock(name = "lock:coupon:#{couponId}")
    public void receiveCoupon(Long couponId) {
        // 1.查询优惠券
        Coupon coupon = queryCouponByCache(couponId);
        if (coupon == null) {
            throw new BadRequestException("优惠券不存在");
        }
        // 2.校验发放时间
        LocalDateTime now = LocalDateTime.now();
        if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) {
            throw new BadRequestException("优惠券发放已经结束或尚未开始");
        }
        // 3.校验库存
        if (coupon.getIssueNum() >= coupon.getTotalNum()) {
            throw new BadRequestException("优惠券库存不足");
        }
        Long userId = UserContext.getUser();
        // 4.校验每人限领数量
        // 4.1.查询领取数量
        String key = PromotionConstants.USER_COUPON_CACHE_KEY_PREFIX + couponId;
        Long count = redisTemplate.opsForHash().increment(key, userId.toString(), 1);
        // 4.2.校验限领数量
        if(count > coupon.getUserLimit()){
            throw new BadRequestException("超出领取数量");
        }
        // 5.扣减优惠券库存
        redisTemplate.opsForHash().increment(
                PromotionConstants.COUPON_CACHE_KEY_PREFIX + couponId, "totalNum", -1);

        // 6.发送MQ消息
        UserCouponDTO uc = new UserCouponDTO();
        uc.setUserId(userId);
        uc.setCouponId(couponId);
        mqHelper.send(MqConstants.Exchange.PROMOTION_EXCHANGE, MqConstants.Key.COUPON_RECEIVE, uc);
    }

    private Coupon queryCouponByCache(Long couponId) {
        // 1.准备KEY
        String key = PromotionConstants.COUPON_CACHE_KEY_PREFIX + couponId;
        // 2.查询
        Map<Object, Object> objMap = redisTemplate.opsForHash().entries(key);
        if (objMap.isEmpty()) {
            return null;
        }
        // 3.数据反序列化
        return BeanUtils.mapToBean(objMap, Coupon.class, false, CopyOptions.create());
    }
    // ...略
}
MQ监听类:
复制代码
package com.tianji.promotion.handler;

import com.tianji.promotion.domain.dto.UserCouponDTO;
import com.tianji.promotion.service.IUserCouponService;
import lombok.RequiredArgsConstructor;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

import static com.tianji.common.constants.MqConstants.Exchange.PROMOTION_EXCHANGE;
import static com.tianji.common.constants.MqConstants.Key.COUPON_RECEIVE;

@RequiredArgsConstructor
@Component
public class PromotionMqHandler {

    private final IUserCouponService userCouponService;

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "coupon.receive.queue", durable = "true"),
            exchange = @Exchange(name = PROMOTION_EXCHANGE, type = ExchangeTypes.TOPIC),
            key = COUPON_RECEIVE
    ))
    public void listenCouponReceiveMessage(UserCouponDTO uc){
        userCouponService.checkAndCreateUserCoupon(uc);
    }
}

// 移除了锁,这里不需要加锁了
@Transactional
@Override
public void checkAndCreateUserCoupon(UserCouponDTO uc) {
    // 1.查询优惠券
    Coupon coupon = couponMapper.selectById(uc.getCouponId());
    if (coupon == null) {
        throw new BizIllegalException("优惠券不存在!");
    }
    // 2.更新优惠券的已经发放的数量 + 1
    int r = couponMapper.incrIssueNum(coupon.getId());
    if (r == 0) {
        throw new BizIllegalException("优惠券库存不足!");
    }
    // 3.新增一个用户券
    saveUserCoupon(coupon, uc.getUserId());
    // 4.更新兑换码状态
    if (uc.getSerialNum()!= null) {
        codeService.lambdaUpdate()
                .set(ExchangeCode::getUserId, uc.getUserId())
                .set(ExchangeCode::getStatus, ExchangeCodeStatus.USED)
                .eq(ExchangeCode::getId, uc.getSerialNum())
                .update();
    }
}

(Redis的操作可以使用Lua脚本,这样子可以进一步提高性能)

5.3 使用优惠券

1、使用流程:

2、优惠券规则:

不同的优惠券使用的规则不同,所以这里使用策略模式,类似于5.2.3.2中失败策略

3、优惠券智能推荐:

流程:

这个功能,大概步骤包括:

定义接口
查询用户的优惠券
初步筛选:查询出属于当前用户且未使用的优惠券
细筛并完成全排列:

细筛:每个优惠券的都有自己的使用范围(指定的课程分类),而订单中的课程也会有不同的分类,因此每张优惠券可以使用的课程可能不同。

因此,细筛步骤有两步:

  • 首先要基于优惠券的限定范围对课程筛选,找出可用课程。如果没有可用课程,则优惠券不可用。

  • 然后对可用课程计算总价,判断是否达到优惠门槛,没有达到门槛则优惠券不可用

全排列:多张优惠券使用顺序不同优惠的金额可能不能,所以需要使用全排列来找出所有可能的顺序(全排列使用回溯法来实现)

5.计算优惠明细

需要考虑多张优惠券叠加的场景下前一张优惠券对后一张优惠券的影响

实现代码:

复制代码
package com.tianji.promotion.service.impl;

import com.tianji.api.dto.promotion.CouponDiscountDTO;
import com.tianji.api.dto.promotion.OrderCourseDTO;
import com.tianji.common.utils.CollUtils;
import com.tianji.common.utils.UserContext;
import com.tianji.promotion.domain.po.Coupon;
import com.tianji.promotion.domain.po.CouponScope;
import com.tianji.promotion.mapper.UserCouponMapper;
import com.tianji.promotion.service.ICouponScopeService;
import com.tianji.promotion.service.IDiscountService;
import com.tianji.promotion.strategy.discount.Discount;
import com.tianji.promotion.strategy.discount.DiscountStrategy;
import com.tianji.promotion.utils.PermuteUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class DiscountServiceImpl implements IDiscountService {

    private final UserCouponMapper userCouponMapper;
    
    @Override
    public List<CouponDiscountDTO> findDiscountSolution(List<OrderCourseDTO> orderCourses) {
        // 1.查询我的所有可用优惠券
        List<Coupon> coupons = userCouponMapper.queryMyCoupons(UserContext.getUser());
        if (CollUtils.isEmpty(coupons)) {
            return CollUtils.emptyList();
        }
        // 2.初筛
        // 2.1.计算订单总价
        int totalAmount = orderCourses.stream().mapToInt(OrderCourseDTO::getPrice).sum();
        // 2.2.筛选可用券
        List<Coupon> availableCoupons = coupons.stream()
                .filter(c -> DiscountStrategy.getDiscount(c.getDiscountType()).canUse(totalAmount, c))
                .collect(Collectors.toList());
        if (CollUtils.isEmpty(availableCoupons)) {
            return CollUtils.emptyList();
        }
        // 3.排列组合出所有方案
        // 3.1.细筛(找出每一个优惠券的可用的课程,判断课程总价是否达到优惠券的使用需求)
        Map<Coupon, List<OrderCourseDTO>> availableCouponMap = findAvailableCoupon(availableCoupons, orderCourses);
        if (CollUtils.isEmpty(availableCouponMap)) {
            return CollUtils.emptyList();
        }
        // 3.2.排列组合
        availableCoupons = new ArrayList<>(availableCouponMap.keySet());
        List<List<Coupon>> solutions = PermuteUtil.permute(availableCoupons);
        // 3.3.添加单券的方案
        for (Coupon c : availableCoupons) {
            solutions.add(List.of(c));
        }
        
        // 4.计算方案的优惠明细
        List<CouponDiscountDTO> list = 
                Collections.synchronizedList(new ArrayList<>(solutions.size()));
        for (List<Coupon> solution : solutions) {
            list.add(calculateSolutionDiscount(availableCouponMap, orderCourses, solution));
        }
        // 5.筛选最优解
        return null;
    }
    
    // ... 略
}

private CouponDiscountDTO calculateSolutionDiscount(
        Map<Coupon, List<OrderCourseDTO>> couponMap, List<OrderCourseDTO> courses, List<Coupon> solution) {
    // 1.初始化DTO
    CouponDiscountDTO dto = new CouponDiscountDTO();
    // 2.初始化折扣明细的映射
    Map<Long, Integer> detailMap = courses.stream().collect(Collectors.toMap(OrderCourseDTO::getId, oc -> 0));
    // 3.计算折扣
    for (Coupon coupon : solution) {
        // 3.1.获取优惠券限定范围对应的课程
        List<OrderCourseDTO> availableCourses = couponMap.get(coupon);
        // 3.2.计算课程总价(课程原价 - 折扣明细)
        int totalAmount = availableCourses.stream()
                .mapToInt(oc -> oc.getPrice() - detailMap.get(oc.getId())).sum();
        // 3.3.判断是否可用
        Discount discount = DiscountStrategy.getDiscount(coupon.getDiscountType());
        if (!discount.canUse(totalAmount, coupon)) {
            // 券不可用,跳过
            continue;
        }
        // 3.4.计算优惠金额
        int discountAmount = discount.calculateDiscount(totalAmount, coupon);
        // 3.5.计算优惠明细
        calculateDiscountDetails(detailMap, availableCourses, totalAmount, discountAmount);
        // 3.6.更新DTO数据
        dto.getIds().add(coupon.getCreater());
        dto.getRules().add(discount.getRule(coupon));
        dto.setDiscountAmount(discountAmount + dto.getDiscountAmount());
    }
    return dto;
}

private void calculateDiscountDetails(Map<Long, Integer> detailMap, List<OrderCourseDTO> courses,
                                      int totalAmount, int discountAmount) {
    int times = 0;
    int remainDiscount = discountAmount;
    for (OrderCourseDTO course : courses) {
        // 更新课程已计算数量
        times++;
        int discount = 0;
        // 判断是否是最后一个课程
        if (times == courses.size()) {
            // 是最后一个课程,总折扣金额 - 之前所有商品的折扣金额之和
            discount = remainDiscount;
        } else {
            // 计算折扣明细(课程价格在总价中占的比例,乘以总的折扣)
            discount = discountAmount * course.getPrice() / totalAmount;
            remainDiscount -= discount;
        }
        // 更新折扣明细
        detailMap.put(course.getId(), discount + detailMap.get(course.getId()));
    }
}
6.基于CompleteableFuture做并行计算

对于多种优惠方法,使用for循环效率低,所以需要使用线程池,

这里的难点有两个:

  • 1)线程任务是带返回值的任务

  • 2)虽然是多线程运行,但是我们要等待所有线程都执行完毕后才返回结果

针对第二个点,我们可以利用JUC包提供的工具CountDownLatch 来实现。​

针对第一个点,我们则需要利用一个JDK1.8的新工具:CompletableFuture 来实现。​

代码实现

配置线程池:

复制代码
package com.tianji.promotion.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

@Slf4j
@Configuration
public class PromotionConfig {

    // ... 略

    @Bean
    public Executor discountSolutionExecutor(){
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 1.核心线程池大小
        executor.setCorePoolSize(12);
        // 2.最大线程池大小
        executor.setMaxPoolSize(12);
        // 3.队列大小
        executor.setQueueCapacity(99999);
        // 4.线程名称
        executor.setThreadNamePrefix("discount-solution-calculator-");
        // 5.拒绝策略
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
        executor.initialize();
        return executor;
    }
}

修改代码主体:

复制代码
        // 4.计算方案的优惠明细
        List<CouponDiscountDTO> list = Collections.synchronizedList(new ArrayList<>(solutions.size()));
        // 4.1.定义闭锁
        CountDownLatch latch = new CountDownLatch(solutions.size());
        for (List<Coupon> solution : solutions) {
            // 4.2.异步计算
            CompletableFuture
                    .supplyAsync(
                            () -> calculateSolutionDiscount(availableCouponMap, orderCourses, solution),
                            discountSolutionExecutor
                    ).thenAccept(dto -> {
                        // 4.3.提交任务结果
                        list.add(dto);
                        latch.countDown();
                    });
        }
        // 4.4.等待运算结束
        try {
            latch.await(1, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            log.error("优惠方案计算被中断,{}", e.getMessage());
        }
7.筛选最优解

首先来看最优标准:​

•用券相同时,优惠金额最高的方案​

•优惠金额相同时,用券最少的方案​

  • 个遍历数组,判断当前元素是否比最小值更小

  • 如果是,则覆盖最小值;如果否,则放弃

  • 循环结束,变量中记录的就是最小值

我们寻找最优解可以参考上述过程,不过略有差异,核心原因是:券组合有多种,因此最优解不止一个。因此我们不能用一个变量类记录最优解,而是用Map来记录,结构如下:

其中:

  • 第一个Map用来记录用券相同时,优惠金额最高的方案;

  • 第二个Map用来记录优惠金额相同时,用券最少的方案。

最终,两个Map的values的交集就是我们要找的最优解。

最终代码
复制代码
package com.tianji.promotion.service.impl;

import com.tianji.api.dto.promotion.CouponDiscountDTO;
import com.tianji.api.dto.promotion.OrderCourseDTO;
import com.tianji.common.utils.CollUtils;
import com.tianji.common.utils.UserContext;
import com.tianji.promotion.domain.po.Coupon;
import com.tianji.promotion.domain.po.CouponScope;
import com.tianji.promotion.mapper.UserCouponMapper;
import com.tianji.promotion.service.ICouponScopeService;
import com.tianji.promotion.service.IDiscountService;
import com.tianji.promotion.strategy.discount.Discount;
import com.tianji.promotion.strategy.discount.DiscountStrategy;
import com.tianji.promotion.utils.PermuteUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class DiscountServiceImpl implements IDiscountService {

    private final UserCouponMapper userCouponMapper;
    private final ICouponScopeService scopeService;
    private final Executor discountSolutionExecutor;
    
    @Override
    public List<CouponDiscountDTO> findDiscountSolution(List<OrderCourseDTO> orderCourses) {
        // 1.查询我的所有可用优惠券
        List<Coupon> coupons = userCouponMapper.queryMyCoupons(UserContext.getUser());
        if (CollUtils.isEmpty(coupons)) {
            return CollUtils.emptyList();
        }
        // 2.初筛
        // 2.1.计算订单总价
        int totalAmount = orderCourses.stream().mapToInt(OrderCourseDTO::getPrice).sum();
        // 2.2.筛选可用券
        List<Coupon> availableCoupons = coupons.stream()
                .filter(c -> DiscountStrategy.getDiscount(c.getDiscountType()).canUse(totalAmount, c))
                .collect(Collectors.toList());
        if (CollUtils.isEmpty(availableCoupons)) {
            return CollUtils.emptyList();
        }
        // 3.排列组合出所有方案
        // 3.1.细筛(找出每一个优惠券的可用的课程,判断课程总价是否达到优惠券的使用需求)
        Map<Coupon, List<OrderCourseDTO>> availableCouponMap = findAvailableCoupon(availableCoupons, orderCourses);
        if (CollUtils.isEmpty(availableCouponMap)) {
            return CollUtils.emptyList();
        }
        // 3.2.排列组合
        availableCoupons = new ArrayList<>(availableCouponMap.keySet());
        List<List<Coupon>> solutions = PermuteUtil.permute(availableCoupons);
        // 3.3.添加单券的方案
        for (Coupon c : availableCoupons) {
            solutions.add(List.of(c));
        }
        
        // 4.计算方案的优惠明细
        List<CouponDiscountDTO> list = Collections.synchronizedList(new ArrayList<>(solutions.size()));
        // 4.1.定义闭锁
        CountDownLatch latch = new CountDownLatch(solutions.size());
        for (List<Coupon> solution : solutions) {
            // 4.2.异步计算
            CompletableFuture
                    .supplyAsync(
                            () -> calculateSolutionDiscount(availableCouponMap, orderCourses, solution),
                            discountSolutionExecutor
                    ).thenAccept(dto -> {
                        // 4.3.提交任务结果
                        list.add(dto);
                        latch.countDown();
                    });
        }
        // 4.4.等待运算结束
        try {
            latch.await(1, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            log.error("优惠方案计算被中断,{}", e.getMessage());
        }
        
        // 5.筛选最优解
        return findBestSolution(list);
    }
    
    private List<CouponDiscountDTO> findBestSolution(List<CouponDiscountDTO> list) {
        // 1.准备Map记录最优解
        Map<String, CouponDiscountDTO> moreDiscountMap = new HashMap<>();
        Map<Integer, CouponDiscountDTO> lessCouponMap = new HashMap<>();
        // 2.遍历,筛选最优解
        for (CouponDiscountDTO solution : list) {
            // 2.1.计算当前方案的id组合
            String ids = solution.getIds().stream()
                    .sorted(Long::compare).map(String::valueOf).collect(Collectors.joining(","));
            // 2.2.比较用券相同时,优惠金额是否最大
            CouponDiscountDTO best = moreDiscountMap.get(ids);
            if (best != null && best.getDiscountAmount() >= solution.getDiscountAmount()) {
                // 当前方案优惠金额少,跳过
                continue;
            }
            // 2.3.比较金额相同时,用券数量是否最少
            best = lessCouponMap.get(solution.getDiscountAmount());
            int size = solution.getIds().size();
            if (size > 1 && best != null && best.getIds().size() <= size) {
                // 当前方案用券更多,放弃
                continue;
            }
            // 2.4.更新最优解
            moreDiscountMap.put(ids, solution);
            lessCouponMap.put(solution.getDiscountAmount(), solution);
        }
        // 3.求交集
        Collection<CouponDiscountDTO> bestSolutions = CollUtils
                .intersection(moreDiscountMap.values(), lessCouponMap.values());
        // 4.排序,按优惠金额降序
        return bestSolutions.stream()
                .sorted(Comparator.comparingInt(CouponDiscountDTO::getDiscountAmount).reversed())
                .collect(Collectors.toList());
    }
    
    // ... 略
    
}
相关推荐
摇滚侠1 小时前
Spring 面试题 真正的 offer 偏方 Java 基础 Java 高级
java·后端·spring
问心无愧05131 小时前
ctf show web入门91
android·前端·笔记
Genevieve_xiao1 小时前
【xjtuse】【数学建模】课程笔记(二)代数模型、微积分模型(上)
笔记·数学建模
凯瑟琳.奥古斯特1 小时前
IP组播跨子网传输核心技术解析
java·开发语言·网络·网络协议·职场和发展
若水不如远方1 小时前
Java JSON 序列化原理与实战问题总结
java
hexu_blog1 小时前
前端vue后端java+springboot如何实现pdf,word,excel之间的相互转换
java·前端·vue.js·spring boot·文档转换
贺国亚1 小时前
synchronized- 并发
java·面试
员宇宙1 小时前
k8s学习笔记
笔记·学习·kubernetes
martian6651 小时前
在 IntelliJ IDEA 中安装、配置 Claude Code 及解决连接错误完全指南
java·ide·intellij-idea