项目二笔记记录

运行所有tj开头的容器

docker start $(docker ps -aq -f name=tj*)

第一天(我的课表)

docker查看容器运行状态的命令

dps [-a] 查看[所有]运行中的容器

dlog -f 容器名 动态查看日志

项目二笔记记录_第1张图片

1, 对查询的封装
select * from learning_lesson where user_id = xxx order by latest_learn_time desc limit 0,5

对于以上的查询,可以使用一下的方法

public PageDTO<xxxx> page(PageQuery pageQuery){
    Page<LearningLesson> page = lambdaQuery()
        .eq(LearningLesson::getUserId, userId)
        // 默认根据最后一次学习的时间 降序
        .page(pageQuery.toMpPage("latest_learn_time", false));
            // 判断是否为null
            if (CollUtils.isEmpty(records)) {
              // 如果为空返回
                return PageDTO.empty(page);
            }
}

toMpPage 方法的细节 会默认使用参数的中的page

No pageSize sortBy 所以十分的方便

public <T> Page<T> toMpPage(String defaultSortBy, boolean isAsc) {
    if (StringUtils.isBlank(sortBy)){
        sortBy = defaultSortBy;
        this.isAsc = isAsc;
    }
    Page<T> page = new Page<>(pageNo, pageSize);
    OrderItem orderItem = new OrderItem();
    orderItem.setAsc(this.isAsc);
    orderItem.setColumn(sortBy);
    page.addOrder(orderItem);
    return page;
}
2. 数据的返回

返回查询到的page即可,会处理其中 的总条数, 以及页面信息, 和返回的集合

PageDTO.of(page,learningLessonVOS)
3. 通过Stream流将List集合转Map(十分方便)
Map<Long, CourseSimpleInfoDTO> courseMap = simpleInfoList.stream().collect(Collectors.toMap(CourseSimpleInfoDTO::getId, c -> c));

map中的两个参数都代表当前对象,所以一个可以使用getId 一个使用 c-> c 将自己放入Map集合

4. BeanUtils的拷贝
LearningLessonVO learningLessonVO = BeanUtils.copyBean(record, LearningLessonVO.class);

也可以拷贝List
BeanUtils.copyList()

通过传入数据源以及想要copy后的结果对象即可返回具体的对象十分方便

5. 声明一个数据的集合
CollUtils.singletonList(lesson.getLatestSectionId())
6. update方法的使用
lambdaUpdate().eq(LearningLesson::getUserId, userID)
    .eq(LearningLesson::getCourseId, courseId)
    .set(LearningLesson::getWeekFreq, weekFreq)
    .set(LearningLesson::getPlanStatus, PlanStatus.PLAN_RUNNING)
    .update();
7. maven的一些知识

maven的三套生命周期

第一套: clean清理

第二套: default(compile编译,test测试、package打包、install安装)

第三套: site站点(deploy部署)

三套生命周期相互独立,但是在同一套生命周期内部,执行后面命令时,会依次执行前面的命令


第二天(学习计划和进度)

实体类中使用 @Validate信息校验

具体网址:

javax.validation 说明

实体类中添加注解  ---->  接收参数中使用 @Validate

如下:

@PostMapping
@ApiOperation("提交学习记录")
public void addLearningRecord(@RequestBody @Validated LearningRecordFormDTO dto){
    learningRecordService.addLearningRecord(dto);
}

实体类LearningRecordFormDTO中部分代码如下

@ApiModelProperty("小节类型:1-视频,2-考试")
@NotNull(message = "小节类型不能为空")
@EnumValid(enumeration = {1, 2}, message = "小节类型错误,只能是:1-视频,2-考试")
private SectionType sectionType;

@ApiModelProperty("课表id")
@NotNull(message = "课表id不能为空")
private Long lessonId;

第三天(学习计划和进度)

使用SQL片段求和

项目二笔记记录_第2张图片

对与Map中不存在value设置为默认值

项目二笔记记录_第3张图片

集合中的部分字段求和
int weekFinished = lessons.stream()
    .map(LearningLesson::getId)
    .mapToInt(id -> countMap.getOrDefault(id, 0))
    .sum();
mybatis-Plus中使用Sql片段的用法
iLearningLessonService
    .lambdaUpdate()
    // 如果是第一次学习,需要把学习状态设置为学习中
    .set(lesson.getLearnedSections() == 0 , LearningLesson::getStatus, LessonStatus.LEARNING)
    // 如果是全部学完那么就把转他设置为已学完
    .set(allLearned, LearningLesson::getStatus, LessonStatus.FINISHED)
    // 如果没有全部学完
    .set(!allLearned, LearningLesson::getLatestSectionId, dto.getSectionId())
    .set(!allLearned, LearningLesson::getLatestLearnTime, dto.getCommitTime())
    .setSql(allLearned, "learned_sections = learned_sections + 1")
    .eq(LearningLesson::getId, lesson.getId())
    .update();

第四天(高并发优化)

对于高并发写的优化方案有:
- 优化代码及SQL
- 变同步写为异步写
- 合并写请求
1. 变同步为异步

MQ —> 解耦–异步–削峰

假如一个业务比较复杂,需要有多次数据库的写业务,如图所示:

项目二笔记记录_第4张图片

由于各个业务之间是同步串行执行,因此整个业务的响应时间就是每一次数据库写业务的响应时间之和,并发能力肯定不会太好。

优化的思路很简单,我们之前讲解MQ的时候就说过,利用MQ可以把同步业务变成异步,从而提高效率。

  • 当我们接收到用户请求后,可以先不处理业务,而是发送MQ消息并返回给用户结果。
  • 而后通过消息监听器监听MQ消息,处理后续业务。

项目二笔记记录_第5张图片

这样一来,用户请求处理和后续数据库写就从同步变为异步,用户无需等待后续的数据库写操作,响应时间自然会大大缩短。并发能力自然大大提高。

优点

  • 无需等待复杂业务处理,大大减少响应时间
  • 利用MQ暂存消息,起到流量削峰整形作用
  • 降低写数据库频率,减轻数据库并发压力

缺点

  • 依赖于MQ的可靠性
  • 降低了些频率,但是没有减少数据库写次数

应用场景

  • 比较适合应用于业务复杂, 业务链较长,有多次数据库写操作的业务。
2. 合并写请求

合并写请求方案其实是参考高并发读的优化思路:当读数据库并发较高时,我们可以把数据缓存到Redis,这样就无需访问数据库,大大减少数据库压力,减少响应时间。

既然读数据可以建立缓存,那么写数据可以不可以也缓存到Redis呢?

答案是肯定的,合并写请求就是指当写数据库并发较高时,不再直接写到数据库。而是先将数据缓存到Redis,然后定期将缓存中的数据批量写入数据库。

项目二笔记记录_第6张图片

由于Redis是内存操作,写的效率也非常高,这样每次请求的处理速度大大提高,响应时间大大缩短,并发能力肯定有很大的提升。

而且由于数据都缓存到Redis了,积累一些数据后再批量写入数据库,这样数据库的写频率、写次数都大大减少,对数据库压力小了非常多!

优点:

  • 写缓存速度快,响应时间大大减少
  • 降低数据库的写频率和写次数,大大减轻数据库压力

缺点:

  • 实现相对复杂
  • 依赖Redis可靠性
  • 不支持事务和复杂业务

场景:

  • 写频率较高、写业务相对简单的场景
延迟任务
  1. DelayQueue

JDK中自带的延迟队列功能,存入队列的元素可以指定延迟执行的时间。

  1. Redisson

基于Redis数据结构模拟JDK的DelayQueue

  1. MQ

一些MQ本身支持延迟消息,例如RocketMQ

而RabbitMQ则需要通过插件来实现延迟消息 ( 死信队列 )

  1. 时间轮

时间轮算法可以实现延迟任务或定时任务。其中Netty中有开源的实现

示例1:
package com.itheima;

import java.util.Calendar;
import java.util.Date;
import java.util.concurrent.DelayQueue;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

public class MyDelayedTask implements Delayed {
    
    // 任务的执行时间
    private int executeTime = 0;
    private String name;
    
    public MyDelayedTask(int delay, String name){
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.SECOND,delay);
        this.executeTime = (int)(calendar.getTimeInMillis() /1000 );
        this.name = name;
    }

    /**
     * 元素在队列中的剩余时间
     * @param unit
     * @return
     */
    @Override
    public long getDelay(TimeUnit unit) {
        Calendar calendar = Calendar.getInstance();
        return executeTime - (calendar.getTimeInMillis()/1000);
    }

    /**
     * 元素排序
     * @param o
     * @return
     */
    @Override
    public int compareTo(Delayed o) {
        long val = this.getDelay(TimeUnit.NANOSECONDS) - o.getDelay(TimeUnit.NANOSECONDS);
        return val == 0 ? 0 : ( val < 0 ? -1: 1 );
    }

    @Override
    public String toString() {
        return "MyDelayedTask{" +
                "executeTime=" + executeTime +
                ", name='" + name + '\'' +
                '}';
    }

    public static void main(String[] args) {
        DelayQueue<MyDelayedTask> queue = new DelayQueue<MyDelayedTask>();
        
        queue.add(new MyDelayedTask(10, "李四"));//李四任务10秒后执行
        queue.add(new MyDelayedTask(5, "张三"));//张三任务5秒后执行
        queue.add(new MyDelayedTask(15, "王五"));//王五任务15秒后执行

        System.out.println(new Date() +" start consume ");
        while(queue.size() != 0){
            MyDelayedTask delayedTask = queue.poll();
            if(delayedTask !=null ){
                System.out.println(new Date() +" cosume task" + delayedTask);
            }
            //每隔一秒消费一次
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        /**
         * 打印顺序是按任务的执行时间
         * Mon May 08 23:21:44 CST 2023 start consume
         * Mon May 08 23:21:49 CST 2023 cosume taskDelayedTask{executeTime=1683559309, name='张三'}
         * Mon May 08 23:21:54 CST 2023 cosume taskDelayedTask{executeTime=1683559314, name='李四'}
         * Mon May 08 23:21:59 CST 2023 cosume taskDelayedTask{executeTime=1683559319, name='王五'}
         */
    }
}
示例2:
package com.itheima;

import lombok.Data;

import java.time.Duration;
import java.util.Date;
import java.util.concurrent.DelayQueue;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

@Data
public class DelayTask<D> implements Delayed {
    private D data;
    private long deadlineNanos;

    public DelayTask(D data, Duration delayTime) {
        this.data = data;
        this.deadlineNanos = System.nanoTime() + delayTime.toNanos();
    }

    @Override
    public long getDelay(TimeUnit unit) {
        return unit.convert(Math.max(0, deadlineNanos - System.nanoTime()), TimeUnit.NANOSECONDS);
    }

    @Override
    public int compareTo(Delayed o) {
        long l = getDelay(TimeUnit.NANOSECONDS) - o.getDelay(TimeUnit.NANOSECONDS);
        if(l > 0){
            return 1;
        }else if(l < 0){
            return -1;
        }else {
            return 0;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        DelayQueue<DelayTask> delayQueue = new DelayQueue();
        delayQueue.add(new DelayTask<>("李四", Duration.ofSeconds(10)));
        delayQueue.add(new DelayTask<>("张三", Duration.ofSeconds(5)));
        delayQueue.add(new DelayTask<>("王五", Duration.ofSeconds(15)));
        System.out.println(new Date() + "开始消费延任务");
        while (delayQueue.size()>0){
            DelayTask delayed = delayQueue.take();
            System.out.println(new Date() +" "+ delayed);
        }
        /**
         * Mon May 08 23:32:26 CST 2023开始消费延任务
         * Mon May 08 23:32:31 CST 2023 DelayTask(data=张三, deadlineNanos=399151908886800)
         * Mon May 08 23:32:36 CST 2023 DelayTask(data=李四, deadlineNanos=399156908694300)
         * Mon May 08 23:32:41 CST 2023 DelayTask(data=王五, deadlineNanos=399161909060900)
         */
    }
}

项目二笔记记录_第7张图片

创建Bean之前和销毁Bean之前执行使用的两个注解

一句废话: 下面注意的地方一定要注意

@PostConstruct
public void init(){
    // 注意: 这里为了不让主线程阻塞, 这里要开启新的线程来执行`handleDelayTask`方法. 当然这里也可以采用线程池的技术
    CompletableFuture.runAsync(this::handleDelayTask);
}

@PreDestroy
public void destroy(){
    log.debug("关闭学习记录处理的延迟任务");
    begin = false;
}
线程池的使用

建议1 : 如果任务式属于CPU运算型任务 推荐核心线程为CPU的核心数

建议2 : 如果任务时属于IO型的,推荐核心线程为CPU2倍

第五天(问答系统)

课程名称的模糊查询

多级缓存

多级缓存的使用场景

1. 缓存命中率提高:通过多级缓存,后级缓存可以补充前级缓存未命中的请求,总的缓存命中率会提高,从而减轻后端系统的压力。

2. 热点数据管理:可以将热点数据放在靠前的缓存层,让更多请求可以在前级缓存就获取到数据,减少对后级缓存与数据库的访问。

3. 过期时间控制:每级缓存可以设置不同的过期时间,让靠前的缓存层过期时间短一些,后级的过期时间长一些。这可以实现步进过期,避免大量热点数据直接过期失效。

4. 缓存雪崩防护:如果所有缓存都指向同一后端数据源,当后端数据库宕机等情况发生时,所有缓存都会过期失效,会产生缓存雪崩的情况。采用多级缓存,可以隔离不同级缓存之间的过期影响。

5. 缓存一致性控制:可以采取写CACHE-A,读CACHE-A,CACHE-B,DB的策略。写请求只更新前级缓存,读请求会在多个级别的缓存中查找,最后才读取数据库。这可以保证前级缓存中的数据有很高的一致性,后级缓存与数据库的数据会有一定的时延。
为什么不选用Redis做多级缓存?

Redis虽然能提高性能,但每次查询缓存还是会增加网络带宽消耗,也会存在网络延迟

什么时候选用多级缓存?

(对于本项目中分类数据具备两大特点):

  • 数据量小
  • 长时间不会发生变化。

像这样的数据,除了建立Redis缓存以外,还非常适合做本地缓存(Local Cache)。这样就可以形成多级缓存机制:

  • 数据查询时优先查询本地缓存
  • 本地缓存不存在,再查询Redis缓存
  • Redis不存在,再去查询数据库

项目二笔记记录_第8张图片

本地缓存简单来说就是JVM内存的缓存,比如你建立一个HashMap,把数据库查询的数据存入进去。以后优先从这个HashMap查询,一个本地缓存就建立好了。

本地缓存优点:

  • 读取本地内存,没有网络开销,速度更快

本地缓存缺点:

  • 数据同步困难,一般采用自动过期方案
  • 存储容量有限、可靠性较低、无法共享

本地缓存由于无需网络查询,速度非常快。不过由于上述缺点,本地缓存往往适用于数据量小更新不频繁的数据。而课程分类恰好符合。

Caffeine

缓存常用的基本API

@Test
void testBasicOps() {
    // 构建cache对象
    Cache<String, String> cache = Caffeine.newBuilder().build();

    // 存数据
    cache.put("gf", "迪丽热巴");

    // 取数据
    String gf = cache.getIfPresent("gf");
    System.out.println("gf = " + gf);

    // 取数据,包含两个参数:
    // 参数一:缓存的key
    // 参数二:Lambda表达式,表达式参数就是缓存的key,方法体是查询数据库的逻辑
    // 优先根据key查询JVM缓存,如果未命中,则执行参数二的Lambda表达式       ==================================>>>>>>>>>>
    String defaultGF = cache.get("defaultGF", key -> {
        // 根据key去数据库查询数据
        return "柳岩";
    });
    System.out.println("defaultGF = " + defaultGF);
}

Caffeine既然是缓存的一种,肯定需要有缓存的清除策略,不然的话内存总会有耗尽的时候。

Caffeine提供了三种缓存驱逐策略:

  • 基于容量:设置缓存的数量上限
// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder()
            .maximumSize(1) // 设置缓存大小上限为 1
            .build();
  • 基于时间:设置缓存的有效时间
// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder()
    // 设置缓存有效期为 10 秒,从最后一次写入开始计时 
    .expireAfterWrite(Duration.ofSeconds(10)) 
    .build();
  • 基于引用:设置缓存为软引用或弱引用,利用GC来回收缓存数据。性能较差,不建议使用。

注意: 在默认情况下,当一个缓存元素过期的时候,Caffeine不会自动立即将其清理和驱逐。而是在一次读或写操作后,或者在空闲时间完成对失效数据的驱逐。

缓存的声明

public class CategoryCacheConfig {
    /**
     * 课程分类的caffeine缓存
     */
    @Bean
    public Cache<String, Map<Long, CategoryBasicDTO>> categoryCaches(){
        return Caffeine.newBuilder()
                .initialCapacity(1) // 容量限制
                .maximumSize(10_000) // 最大内存限制
                .expireAfterWrite(Duration.ofMinutes(30)) // 有效期
                .build();
    }
    /**
     * 课程分类的缓存工具类
     */
    @Bean
    public CategoryCache categoryCache(
            Cache<String, Map<Long, CategoryBasicDTO>> categoryCaches, CategoryClient categoryClient){
        return new CategoryCache(categoryCaches, categoryClient);
    }
}
使用of() 方法 构造对象的方法

项目二笔记记录_第9张图片

第六天(点赞系统)

点赞系统需要支持不同业务的点赞功能 所以需要设计为一个独立的系统, 一些热点业务的点赞会很多,此时就需要点赞功能支持高并发的环境

所以我们就需要: 点赞记录, 点赞数,等两个记录的数据 ,

如果业务方需要根据点赞数排序,就必须在数据库中维护点赞数字段。但是点赞系统无法修改其它业务服务的数据库,否则就出现了业务耦合。该怎么办呢?

点赞系统可以在点赞数变更时,通过MQ通知业务方,这样业务方就可以更新自己的点赞数量了。并且还避免了点赞系统与业务方的耦合。

于是,实现思路变成了这样:

项目二笔记记录_第10张图片

比如说问答服务,在统计之后就可以在问答中接收MQ消息,来实现数据库中的数据的更新操作

具体的实现

需要注意的是,由于每次点赞的业务类型不同,所以没有必要通知到所有业务方,而是仅仅通知与当前点赞业务关联的业务方即可

项目二笔记记录_第11张图片

package com.tianji.learning.mq;

import com.tianji.api.dto.remark.LikedTimesDTO;
import com.tianji.learning.domain.po.InteractionReply;
import com.tianji.learning.service.IInteractionReplyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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 java.util.ArrayList;
import java.util.List;

import static com.tianji.common.constants.MqConstants.Exchange.LIKE_RECORD_EXCHANGE;
import static com.tianji.common.constants.MqConstants.Key.QA_LIKED_TIMES_KEY;

@Slf4j
@Component
@RequiredArgsConstructor
public class LikeTimesChangeListener {

    private final IInteractionReplyService replyService;

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "qa.liked.times.queue", durable = "true"),
            exchange = @Exchange(name = LIKE_RECORD_EXCHANGE, type = ExchangeTypes.TOPIC),
            key = QA_LIKED_TIMES_KEY
    ))
    public void listenReplyLikedTimesChange(LikedTimesDTO dto){
        log.debug("监听到回答或评论{}的点赞数变更:{}", dto.getBizId(), dto.getLikedTimes());

        List<InteractionReply> list = new ArrayList<>(likedTimesDTOs.size());
        InteractionReply r = new InteractionReply();
        r.setId(dto.getBizId());
        r.setLikedTimes(dto.getLikedTimes());
        replyService.updateById(r);
    }
}
暴露Feign接口

Feign中实现RequestInterceptor

作用 ----> 在请求发出前对请求作出拦截,可以进行参数变换、Header添加等操作。

public class FeignRelayUserInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        Long userId = UserContext.getUser();
        if (userId == null) {
            return;
        }
        template.header(JwtConstants.USER_HEADER, userId.toString());
    }
}
fallback降级处理

一, 定义feign接口

@FeignClient(value = "remark-service", fallbackFactory = RemarkClientFallback.class)
public interface RemarkClient {
    @GetMapping("/likes/list")
    Set<Long> isBizLiked(@RequestParam("bizIds") Iterable<Long> bizIds);
}

二, 定义RemarkClientFallBack降级类

为什么做降级?

在级联调用中 当业务量比较大的时候很容易出现 雪崩的现象(级联失败) , 即调用业务需要阻塞等待被调用业务的执行但是如果被调用业务宕机,就会导致后面的业务一直处于阻塞状态,如果请求继续增加,那么当前的服务也有可能宕机.从而产生雪崩的状态

package com.tianji.api.client.remark.fallback;

import com.tianji.api.client.remark.RemarkClient;
import com.tianji.common.utils.CollUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;

import java.util.Set;

@Slf4j
public class RemarkClientFallback implements FallbackFactory<RemarkClient> {

    @Override
    public RemarkClient create(Throwable cause) {
        log.error("查询remark-service服务异常", cause);
        return new RemarkClient() {
			// 降级处理的方式 返回一个空的集合
            @Override
            public Set<Long> isBizLiked(Iterable<Long> bizIds) {
                return CollUtils.emptySet();
            }
        };
    }
}

我们需要通过SpringBoot的自动加载机制来加载这些fallback类:

我们需要在feign接口中,resources目录下定义文件META-INF 下 spring.factories文件定义当前的降级类方法

由于SpringBoot会在启动时读取/META-INF/spring.factories文件,我们只需要在该文件中指定了要加载

FallbackConig类:

项目二笔记记录_第12张图片

redis管道技术

不要在一次批处理中传输太多命令,否则单次命令占用带宽过多,会导致网络阻塞

redis管道技术 在某些高并发的场景下,网络开销成了Redis速度的瓶颈,所以需要使用管道技术来实现突破。

原始:

项目二笔记记录_第13张图片

学习网址: redis使用管道(Pipelining)提高查询速度

@Override
public Set<Long> isBizLiked(List<Long> bizIds) {
    // 1.获取登录用户id
    Long userId = UserContext.getUser();
    // 2.查询点赞状态   --- 使用管道查询
    List<Object> objects = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
        StringRedisConnection src = (StringRedisConnection) connection;
        for (Long bizId : bizIds) {
            String key = RedisConstants.LIKES_BIZ_KEY_PREFIX + bizId;
            src.sIsMember(key, userId.toString());
        }
        // 这里不用管
        return null;
    });
    // 3.返回结果
    return IntStream.range(0, objects.size()) // 创建从0到集合size的流
            .filter(i -> (boolean) objects.get(i)) // 遍历每个元素,保留结果为true的角标i
            .mapToObj(bizIds::get)// 用角标i取bizIds中的对应数据,就是点赞过的id
            .collect(Collectors.toSet());// 收集
}

第七天(积分系统)

BitMap

像这种把每一个二进制位,与某些业务数据一一映射(本例中是与一个月的每一天映射),然后用二进制位上的数字0和1来标识业务状态的思路,称为位图。也叫做BitMap.

redis命令操作
# 第1天签到
SETBIT bm 0 1
# 第2天签到
SETBIT bm 1 1
# 第3天签到
SETBIT bm 2 1

获取

BITFIELD key GET encoding offset
- key:就是BitMap的key
- GET:代表查询
- encoding:返回结果的编码方式,BitMap中是二进制保存,而返回结果会转为10进制,但需要一个转换规则,也就是这里的编码方式
  - u:无符号整数,例如 u2,代表读2个bit位,转为无符号整数返回
  - i:又符号整数,例如 i2,代表读2个bit位,转为有符号整数返回
- offset:从第几个bit位开始读取,例如0:代表从第一个bit位开始

示例:

bitfield bm get u3 0
java操作

添加

Boolean weekRecord = redisTemplate.opsForValue().setBit("week", 3, true);  // key  索引(0开始).true 设置为1  
// 返回值表示原始当前位置的值
System.out.println(weekRecord);

获取

List<Long> messageInfo = redisTemplate.opsForValue().bitField(key, BitFieldSubCommands.create()
              .get(BitFieldSubCommands.BitFieldType.unsigned(28)).valueAt(0) // 无符号, 起始位置
			);
// 得到十进制 前28位的十进制
Long aLong = messageInfo.get(0);
获取连续登录的代码: 与运算 移位操作
 private Integer getSignDays(Integer daysOfDifference, String key) {
        //1. 获取当前是本月的多少天

        List<Long> longs = redisTemplate.opsForValue()
                .bitField(key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(daysOfDifference + 1)).valueAt(0));
        //2. 获取值以及处理值
        if (CollUtils.isEmpty(longs)) {
            // 可能是第一天
            return 0;
        }
        int num = longs.get(0).intValue();
        // 此时可以让每一位和1 做与运算
        int count = 0;
        while ((num & 1) == 1) {
            count++;
            num = num >>> 1;
        }
        // 返回连续登录的数量
        return count;
    }

如果查询的结果只有一条数据可以使用如下的方法使用mybatisPlus查询 但是需要注意返回值

QueryWrapper<PointsRecord> wrapper = Wrappers.<PointsRecord>query()
    .select("sum(points) as total")
    .eq("user_id", userId)
    .eq("type", type)
    .between("create_time", begin, end);
Map<String, Object> map = getMap(wrapper);
if (map == null) {
    return 0;
}
BigDecimal total = (BigDecimal) map.get("total");

案例2 先借用实体类中的某一字段用户存值取值

// 2. 查询今日积分情况
QueryWrapper<PointsRecord> queryWrapper = new QueryWrapper<>();
// 3. 构建条件
queryWrapper.select("type", "sum(points) as points");
queryWrapper.eq("user_id", userId);
queryWrapper.between("create_time", dayStartTime, dayEndTime);
queryWrapper.groupBy("type");

第八天(排行榜)

海量数据存储策略

1. 分区

数据库按照规则对表做水平拆分, 根据某个字段的值的不同存放在不同的分区中

表分区(Partition) 是一种数据存储方案,可以解决单表数据较多的问题。MySQL5.1开始支持表分区功能。

数据库的表最终肯定是保存在磁盘中,对于InoDB引擎,一张表的数据在磁盘上对应一个ibd文件。如图,我们的积分榜单表对应的文件:

本地磁盘会生成两个文件:
一个frm文件: 用来存表结构
一个ibd文件: 用来存数据和索引 (分区中可以有多个ibd文件)

但是有一个缺点就是对应指定字段的值必须事先指定好, 如果不在指定的范围内就会报错,

项目二笔记记录_第14张图片

这样做有几个好处:

  • 可以存储更多的数据,突破单表上限。甚至可以存储到不同磁盘,突破磁盘上限
  • 查询时可以根据规则只检索某一个文件,提高查询效率
  • 数据统计时,可以多文件并行统计,最后汇总结果,提高统计效率
  • 对于一些历史数据,如果不需要时,可以直接删除分区文件,提高删除效率

表分区的本质是对数据的水平拆分,而拆分的方式也有多种,常见的有:

  • Range分区:按照指定字段的取值范围分区
  • List分区:按照指定字段的枚举值分区,必须提前指定好所有的分区值,如果数据找不到分区会报错
  • Hash分区:基于字段做hash运算后分区,一般做hash运算的字段都是数值类型
  • Key分区:根据指定字段的值做运算的结果分区,与hash分区类似,但不限定字段类型

具体描述网址:MySQL分区表

对于赛季榜单来说,最合适的分区方式是基于赛季值分区,我们希望同一个赛季放到一个分区。这就只能使用List分区,而List分区却需要枚举出所有可能的分区值。但是赛季分区id是无限的,无法全部枚举,所以就非常尴尬。

2. 分表(排行榜-采用)

开发者按需求对表进行水平或者垂直拆分

分表是一种表设计方案,由开发者在创建表时按照自己的业务需求拆分表。也就是说这是开发者自己对表的处理,与数据库无关。

而且,一旦做了分表,无论是逻辑上,还是物理上,就从一张表变成了多张表!增删改查的方式就发生了变化,必须自己考虑要去哪张表做数据处理。

分区则在逻辑上是同一张表,增删改查与以前没有区别。这就是分区和分表最大的一种区别。

对于赛季榜单,我们可以按照赛季拆分为多张表,每一个赛季一张新的表

水平拆分

项目二笔记记录_第15张图片

这种方式就是水平分表,表结构不变,仅仅是每张表数据不同。查询赛季1,就找第一张表。查询赛季2,就找第二张表。

由于分表是开发者的行为,因此拆分方式更加灵活。除了水平分表,也可以做垂直分表

垂直拆封

什么是垂直分表呢?

如果一张表的字段非常多,比如达到30个以上,这样的表我们称为宽表。宽表由于字段太多,单行数据体积就会非常大,虽然数据不多,但可能表体积也会非常大!从而影响查询效率。

例如一个用户信息表,除了用户基本信息,还包含很多其它功能信息:

项目二笔记记录_第16张图片

这个时候一张表就变成了两张表。而且两张表的结构不同数据也不同。这种按照字段拆分表的方式,称为垂直拆分

分表方案与分区方案相比有一些优点:

  • 拆分方式更加灵活
  • 而且可以解决单表字段过多的问题

但是也有一些确定:

  • 增删改查时,需要自己判断访问哪张表
  • 垂直拆分还会导致事务问题及数据关联问题:原本一张表的操作,变为多张表操作。

不过,在开发中我们很多情况下业务需求复杂,更看重分表的灵活性。因此,我们大多数情况下都会选择分表方案。

3. 分库
4. 集群

根据业务将相关的表存放到不同的数据库中

无论是分区,还是分表,我们刚才的分析都是建立在单个数据库的基础上。但是单个数据库也存在一些问题:

  • 单点故障问题:数据库发生故障,整个系统就会瘫痪
  • 单库的性能瓶颈问题:单库受服务器限制,其网络带宽、CPU、连接数都有瓶颈
  • 单库的存储瓶颈问题:单库的磁盘空间有上限,如果磁盘过大,数据检索的速度又会变慢

综上,在大型系统中,我们除了要做分表、还需要对数据做分库,建立综合集群。

首先,在微服务项目中,我们会按照项目模块,每个微服务使用独立的数据库,因此每个库的表是不同的,这种分库模式成为垂直分库

而为了保证单节点的高可用性,我们会给数据库建立主从集群,主节点向从节点同步数据。两者结构一样,可以看做是水平扩展

这个时候就会出现垂直分库、水平扩展的综合集群,如图:

项目二笔记记录_第17张图片

给读写压力较大的数据库搭建主从集群

这种模式的优缺点:

优点:

  • 解决了海量数据存储问题,突破了单机存储瓶颈
  • 提高了并发能力,突破了单机性能瓶颈
  • 避免了单点故障

缺点:

  • 成本非常高
  • 数据聚合统计比较麻烦
  • 主从同步的一致性问题
  • 分布式事务问题

Xxl-job的使用

------> 超链接: Xxl-job的使用

lombok 开启链式编程
@Accessors(chain = true)

动态表名

持久化的流程中存在一个问题,我们的数据库持久化采用的是MybatisPlus来实现的。而MybatisPlus读取表名的方式是通过实体类上的@Table注解,而注解往往是写死的:如下

@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("points_board")
@ApiModel(value="PointsBoard对象", description="学霸天梯榜")
public class PointsBoard implements Serializable {
    
}

官网地址: mybatis-plus 动态表名插件

Map中存的数据: key----> 旧的表名 value-----> 名称处理器

项目二笔记记录_第18张图片

这个Map的key是旧的表名称,value是TableNameHandler,就是表的名称处理器,用于根据旧名称获取新名称。

TablenameHandler源码如下:

public interface TableNameHandler {

    /**
     * 生成动态表名
     *
     * @param sql       当前执行 SQL
     * @param tableName 表名
     * @return String
     */
    String dynamicTableName(String sql, String tableName);
}

做法:

定义DynamicTableNameInnterInterceptor,向其中添加一个TableNameHandler,将points_board这个表名,替换为points_board_赛季id的名称。

我们的做法是将表名写入到 ThreadLocal中, 然后再拦截器中在获取

创建动态表名的步骤 :

1. 定义ThreadLocal

package com.tianji.learning.utils;

public class TableInfoContext {
    private static final ThreadLocal<String> TL = new ThreadLocal<>();

    public static void setInfo(String info) {
        TL.set(info);
    }

    public static String getInfo() {
        return TL.get();
    }

    public static void remove() {
        TL.remove();
    }
}

2. 自定义动态的拦截器

@Configuration
public class MybatisConfiguration {

    @Bean
    public DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor() {
        // 准备一个Map,用于存储TableNameHandler
        Map<String, TableNameHandler> map = new HashMap<>(1);
        // 存入一个TableNameHandler,用来替换points_board表名称
        // 替换方式,就是从TableInfoContext中读取保存好的动态表名
        map.put("points_board", (sql, tableName) -> TableInfoContext.getInfo());
        return new DynamicTableNameInnerInterceptor(map);
    }
}

注意(重要)

由于DynamicTableNameInnerInterceptor并不是每一个微服务都用了,所以这里加入了@Autowired(required= false),避免未定义该拦截器的微服务报错。

// 下面的判断一定要做

在Mybatis-plus中加入动态表拦截器

@Bean
@ConditionalOnMissingBean
public MybatisPlusInterceptor mybatisPlusInterceptor(
    @Autowired(required = false) DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor) {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    // 添加表名拦截器
    //多租户,动态表名

    if (dynamicTableNameInnerInterceptor != null) {   // 这里一定要做判断
        interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
    }
    //分页,乐观锁
    //sql 性能规范,防止全表更新与删除
    PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
    paginationInnerInterceptor.setMaxLimit(200L);
    interceptor.addInnerInterceptor(paginationInnerInterceptor);
    interceptor.addInnerInterceptor(new MyBatisAutoFillInterceptor());
    return interceptor;
}

最最最最重要:

拦截器中的插件的顺序一定要有先后顺序, 如下:

https://baomidou.com/pages/2976a3/#innerinterceptor

使用多个功能需要注意顺序关系,建议使用如下顺序

多租户,动态表名
分页,乐观锁
sql 性能规范,防止全表更新与删除

总结: 对 sql 进行单次改造的优先放入,不对 sql 进行改造的最后放入

第九天(优惠券管理)

关键字处理

描述: 一般是不太建议使用数据库中的关键字的但是有些情况还是难以避免会使用到, 所以这个时候就需要通过 `` 来区分关键字

如果使用Lombok可以使用@TableField("")注解添加来和数据库中的字段区分

项目二笔记记录_第19张图片

del 和 unlinke命令的区别

Redis的del和unlink区别_yfs1024的博客-CSDN博客

在JDK8之后日期格式化的方式
String yyyyMM = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
兑换码生成算法

具体的代码实现如下博客中: 兑换码的生成_yfs1024的博客-CSDN博客

核心的两个方法:

  • generateCode(long serialNum, long fresh):根据自增id生成兑换码。两个参数
    • serialNum:兑换码序列号,也就是自增id
    • fresh:新鲜值,这里建议使用兑换码对应的优惠券id做新鲜值
  • parseCode(String code):验证并解析兑换码,返回的是兑换码的序列号,也就是自增id

在本项目中对兑换码的要求是: 可读性好, 数据量大,**不可重复 **,不可重兑,防止爆刷,高效

- 可读性好:兑换码是要给用户使用的,用户需要输入兑换码,因此可读性必须好。我们的要求:
  - 长度不超过10个字符
  - 只能是24个大写字母和8个数字:ABCDEFGHJKLMNPQRSTUVWXYZ23456789
- 数据量大:优惠活动比较频繁,必须有充足的兑换码,最好有10亿以上的量
- 唯一性:10亿兑换码都必须唯一,不能重复,否则会出现兑换混乱的情况
- 不可重兑:兑换码必须便于校验兑换状态,避免重复兑换
- 防止爆刷:兑换码的规律性不能很明显,不能轻易被人猜测到其它兑换码
- 高效:兑换码生成、验证的算法必须保证效率,避免对数据库带来较大的压力

要满足唯一性,很多同学会想到以下技术:

  • UUID
  • Snowflake
  • 自增id

那重兑问题该如何判断呢?此处有两种方案:

  • 基于数据库:我们在设计数据库时有一个字段就是标示兑换码状态,每次兑换时可以到数据库查询状态,避免重兑。
    • 优点:简单
    • 缺点:对数据库压力大
  • **基于BitMap:(采用)**兑换或没兑换就是两个状态,对应0和1,而兑换码使用的是自增id.我们如果每一个自增id对应一个bit位,用每一个bit位的状态表示兑换状态,是不是完美解决问题。而这种算法恰好就是BitMap的底层实现,而且Redis中的BitMap刚好能支持2^32个bit位。
    • 优点:简答、高效、性能好
    • 缺点:依赖于Redis

项目中的做法通过Redis来存储自增ID自增后的ID, 这样就可以防止数据库中的兑换码的ID冲突①, 设置一种算法, 通过传入自增ID, 以及优惠券的ID来固定的获取兑换码(秘钥是通过做模运算, 得到十六个秘钥中的一个,即新鲜值),

项目二笔记记录_第20张图片

前面还差14位, 将 自增ID和密钥进行加权运算, 得到的值作为签名, 转为二进制, 前面不够的补零

项目二笔记记录_第21张图片

最终如下:

项目二笔记记录_第22张图片

方法异步执行

这里是通过算法来生成兑换码, 为什么使用异步呢?

因为生成兑换码相对业务的处理来说相对比较耗时, 而采用异步的算法, 对应的业务只需要处理好自己的逻辑即可, 其余的有程序异步执行

如下: SpringBoot异步执行方法_yfs1024的博客-CSDN博客

分组,统计,求和案例
ArrayList<LearningPlanDTO> learningPlanDTOS = new ArrayList<>();

LearningPlanDTO l1 = new LearningPlanDTO().setCourseId(1L).setFreq(100);
LearningPlanDTO l2 = new LearningPlanDTO().setCourseId(1L).setFreq(200);
LearningPlanDTO l3 = new LearningPlanDTO().setCourseId(2L).setFreq(300);

learningPlanDTOS.add(l1);
learningPlanDTOS.add(l2);
learningPlanDTOS.add(l3);

// 1. 统计Freq大于等于200的个数
long count = learningPlanDTOS.stream().filter(c -> c.getFreq() >= 200L).count();
System.out.println("大于等于200的个数" + count);

//2.统计 各个ID下的个数
Map<Long, Long> collect = learningPlanDTOS
    .stream()
    .collect(Collectors.groupingBy(LearningPlanDTO::getCourseId, Collectors.counting()));
System.out.println(collect);

//3. 统计所有的频率之和
int sum = learningPlanDTOS
    .stream()
    .mapToInt(LearningPlanDTO::getFreq).sum();
System.out.println(sum);
        //4.  将数字集合转换为 1,2,3 类似的字符串
        Long arr[] = {1L, 2L, 3L, 4L};
        String collect = Stream.of(arr).sorted(Long::compare).map(String::valueOf).collect(Collectors.joining(","));
        System.out.println(collect);

结果:

大于等于200的个数2
{1=2, 2=1}
600
1,2,3,4

day10-领取优惠券

超卖现象

超卖问题的核心原因就是对于相同的代码, 前面的条件都符合因为此时的数据库的信息还尚未更改,问题就出在这, 合法性校验都通过, 都将执行最后的修改操作,从而导致超卖

采用事务的方法

左边为优惠券一共的数量, 右边为实际领取的数量

项目二笔记记录_第23张图片

此时为什么出现超卖的现象呢? 因为 A- > 过来可以开启事务, 在A还没有提交之前B线程也可以开启事务来进行数据的操作, 此时A提交, B提交

️ (现在我们对于优惠券库存的处理逻辑是这样的:)

  • 查询优惠券
  • 判断库存是否充足(领取数量<总数量)
  • 如果充足,更新优惠券领取数量

这里采用的是先查询,再判断,再更新的方案,而以上三步操作并不具备原子性。单线程的情况下确实没有问题。但如果是多线程并发运行,如果N个线程同时去查询(N大于剩余库存),此时大概率查询到的库存是充足的,然后判断库存自然没问题。最后一起更新库存,自然就会超卖。

项目二笔记记录_第24张图片

总结一下,原因是:

  • 多线程并行运行
  • 多行代码操作共享资源,但不具备原子性
锁可以分为两大类:
  • 悲观锁
  • 乐观锁

何为悲观锁?

悲观锁是一种独占和排他的锁机制,保守地认为数据会被其他事务修改,所以在整个数据处理过程中将数据处于锁定状态。

悲观锁认为安全问题一定会发生,所以直接独占资源。结果就是多个线程会串行执行被保护的代码。

何为乐观锁?

乐观锁是一种较为乐观的并发控制方法,假设多用户并发的不会产生安全问题,因此无需独占和锁定资源。但在更新数据前,会先检查是否有其他线程修改了该数据,如果有,则认为可能有风险,会放弃修改操作

悲观锁优缺点:

  • 优点:安全性非常高
  • 缺点:性能较差

乐观锁优缺点:

  • 优点:性能好、安全性也好
  • 缺点:并发较高时,可能出现更新成功率较低的问题(并行的N个线程只会有1个成功)

项目中的修改: 是允许字段被修改的,只要不超过要求的范围即可以

实例如下:

UPDATE coupon SET issue_num = issue_num + 1 WHERE id = 1 AND issue_num < total_num

单用户并发领券

领券时还有对于用户限领数量的判断:

  • 查询优惠券
  • 判断库存是否充足(领取数量<总数量)
  • 如果充足,更新优惠券领取数量

可以看到,这部分逻辑也是按照三步走:(前面的步骤如上:)

  • 查询数据库
  • 判断是否超出限领数量
  • 新增用户券

因为这三个优惠券并不具备原子性, 所以当多个线程来的时候可以同时的判断数据库中的数据,且都通过验证, 知道最后一步的新增用户券,导致多个线程同时执行. 从而产生超卖

那怎么解决呢? 这是一个循序渐进的步骤,下面就一点一点的进行

1, 加锁(悲观锁)

用户限领数量判断是针对单个用户的,因此锁的范围不需要是整个方法,只要锁定某个用户即可。所以这里建议采用Synchronized的代码块,而不是同步方法。并且同步代码块的锁指定为用户id,那么同一个用户并发操作时会被锁定,不同用户互相没有影响,整体效率也是可以接受的。

@Transactional
private void checkAndCreateUserCoupon(Coupon Coupon , Long userId){
    // 这里对当前用户加锁
    synchronized(userId.toString().intern()){
        ..... 业务代码省略
            // - 查询数据库
            // - 判断是否超出限领数量
            // - 新增用户券
    }
}

Long的toString()方法 会发现底层依然是New 这样同一个UserID 还是不同的对象

public static String toString(long i) {
    int size = stringSize(i);
    if (COMPACT_STRINGS) {
        byte[] buf = new byte[size];
        getChars(i, size, buf);
        return new String(buf, LATIN1);
    } else {
        byte[] buf = new byte[size * 2];
        StringUTF16.getChars(i, size, buf);
        return new String(buf, UTF16);
    }
}

这里采用的直接使用获取当前字符串的常量池中的方法

public native String intern();
2.事务边界问题

上面的代码我们虽然加了锁, 但是由于事务的隔离问题, 导致依然会出现超领现象

当前业务执行的流程如下:

  • 开启事务
  • 获取锁
  • 统计用户已领券的数量
  • 判断是否超出限领数量
  • 如果没超,新增一条用户券
  • 释放锁
  • 提交事务

此时对于开启事务是没有限制的, 所以在高并发的时候,任何一个线程都可以开启事务, 然后阻塞等待锁, 当事务在释放锁之后, 在这一段时间内就会发生超卖的现象, 此时其他的线程就可以获取到锁然后对数据一通操作, 也来到释放锁的位置, 随后二者一同提交事务, 产生超卖

解决方案:

既然是因为事务提交时机的问题, 那么我们就把二者调换一下的位置, 然后就可以解决, 如下:

public void receiveCoupon(Long couponId) {
 // 这里对当前用户加锁
    synchronized(userId.toString()){
		checkAndCreateUserCoupon( Coupon ,  userId)
    }
}

@Transactional // 这里进事务,同时,事务方法一定要public修饰
private void checkAndCreateUserCoupon(Coupon Coupon , Long userId){
            // - 查询数据库
            // - 判断是否超出限领数量
            // - 新增用户券
}

此时如果测试会发现依然出现超卖 这是为什么呢?

3. SpringBoot中事务失效的原因

常见的事务失效原因包括如下六个:

1. 事务方法非public修饰

由于Spring的事务是基于AOP的方式结合动态代理来实现的。因此事务方法一定要是public的,这样才能便于被Spring做事务的代理和增强。

而且,在Spring内部也会有一个 org.springframework.transaction.interceptor.AbstractFallbackTransactionAttributeSource类,去检查事务方法的修饰符:

@Nullable
 protected TransactionAttribute computeTransactionAttribute(
  Method method, @Nullable Class<?> targetClass) {
   // Don't allow non-public methods, as configured.
   if (allowPublicMethodsOnly() && 
  !Modifier.isPublic(method.getModifiers())) {
      return null;
   }

    // ... 略

   return null;
 }
2. 非事务方法调用事务方法
@Service
public class OrderService {
    
    public void createOrder(){
        // ... 准备订单数据
        
        // 生成订单并扣减库存
        insertOrderAndReduceStock();
    }
    
    @Transactional
    public void insertOrderAndReduceStock(){
        // 生成订单
        insertOrder();
        // 扣减库存
        reduceStock();
    }
}

可以看到,insertOrderAndReduceStock方法是一个事务方法,肯定会被Spring事务管理。Spring会给OrderService类生成一个动态代理对象,对insertOrderAndReduceStock方法做增加,实现事务效果。

但是现在createOrder方法是一个非事务方法,在其中调用了insertOrderAndReduceStock方法,这个调用其实隐含了一个this.的前缀。也就是说,这里相当于是直接调用原始的OrderService中的普通方法,而非被Spring代理对象的代理方法。那事务肯定就失效了!

3. 事务方法的异常被捕获了

异常被捕获了但是没有往外抛异常,所以事务没有发现方法中出现错误,所以也就没有回滚

 @Transactional
    public void createOrder(){
        // ... 准备订单数据
        // 生成订单
        insertOrder();
        // 扣减库存
        reduceStock();
    }

    private void reduceStock() {
        try {
            // ...扣库存
        } catch (Exception e) {
            // 处理异常
        }
    }

在这段代码中,reduceStock方法内部直接捕获了Exception类型的异常,也就是说方法执行过程中即便出现了异常也不会向外抛出。

而Spring的事务管理就是要感知业务方法的异常,当捕获到异常后才会回滚事务。

现在事务被捕获,就会导致Spring无法感知事务异常,自然不会回滚,事务就失效了。

4. 事务异常类型不对
@Transactional(rollbackFor = RuntimeException.class)
public void createOrder() throws IOException {
    // ... 准备订单数据

    // 生成订单
    insertOrder();
    // 扣减库存
    reduceStock();

    throw new IOException();
}

项目二笔记记录_第25张图片

Spring的事务管理默认感知的异常类型是RuntimeException,当事务方法内部抛出了一个IOException时,不会被Spring捕获,因此就不会触发事务回滚,事务就失效了。

因此,当我们的业务中会抛出RuntimeException以外的异常时,应该通过@Transactional注解中的rollbackFor属性来指定异常类型:

@Transactional(rollbackFor = Exception.class)
5.事务传播行为不对
    @Transactional
    public void createOrder(){
        // 生成订单
        insertOrder();
        // 扣减库存
        reduceStock();
        throw new RuntimeException("业务异常");
    }

	@Transactional  // 默认的是如果当前没有事务,自己创建事务,如果有事务则加入
    public void insertOrder() {
    
    }
	// 不管当前方法所在方法有没有都开启一个事务
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void reduceStock() {
        
    }

在示例代码中,事务的入口是createOrder()方法,会开启一个事务,可以成为外部事务。在createOrder()方法内部又调用了insertOrder()方法和reduceStock()方法。这两个都是事务方法。

不过,reduceStock()方法的事务传播行为是REQUIRES_NEW,这会导致在进入reduceStock()方法时会创建一个新的事务,可以成为子事务。insertOrder()则是默认,因此会与createOrder()合并事务。

因此,当createOrder方法最后抛出异常时,只会导致insertOrder方法回滚,而不会导致reduceStock方法回滚,因为reduceStock是一个独立事务。

所以,一定要慎用传播行为,注意外部事务与内部事务之间的关系。

6.没有被Spring管理

即当前类没有被SpringBoot扫描

第二种事务失效的解决方案:

上面的问题在于非事务方法中调用事务方法其中隐含了一个this.的前缀, 虽然当前方法的事务也被代理类生成了,但是因为默认关键字的原因,调用的还是原来的是没有事务的方法.

所以我们现在要做的就是要找到被代理之后的类,然后再在方法中调用该方法

1)引入AspectJ依赖:

<dependency>
    <groupId>org.aspectjgroupId>
    <artifactId>aspectjweaverartifactId>
	<version>1.9.7version>
dependency>
2)暴露代理对象

在启动类上添加注解,暴露代理对象:

项目二笔记记录_第26张图片

3)使用代理对象

通过AopContext拿到当前类的代理对象,然后调用对应方法

// 返回值是Object
IUserCouponService userCouponService = (IUserCouponService) AopContext.currentProxy();
userCouponService.insertCouponAndCheck(userId, coupon, null);

(补充 )StringBoot中事务相关的接口

在Spring中有两个和事务相关的接口

  1. PlatformTransactionManager 平台事务管理接口

    作用; 对于不同的数据源采用不同的管理平台, 常用的实现类如下

    • DataSourceTransactionManager:使用JDBC或MyBatis进行事务管理。适用于DataSource数据源。

    • HibernateTransactionManager:使用Hibernate进行事务管理。适用于Hibernate持久层框架。

  2. TransactionDefinition 事务定义接口

TransactionDefinition 接口中定义了事务的描述相关的三类常量:

  1. 事务隔离级别
  2. 事务传播行为
  3. 事务默认超时时限

项目二笔记记录_第27张图片

1. 事务隔离级别
  1. DEFAULT : 采用DB默认的事务隔离级别.mysql中默认的是 REPEATABLE_READ (repeatable_read)

  2. READ_UNCOMMITTED: 读未提交 未解决任何并发问题

  3. READ_COMMITTED: 读已提交 解决了脏读,存在不可重复读和幻读

  4. REPETABLE_READ : 可重复读,解决了脏读,不可重复读,存在幻读

  5. SERIALIZABLE :串行化.不存在并发问题

那么什么是脏读, 幻读和 不可重复读呢?

脏读(dirty read): 当一个事务读取另一个事务尚未提交的修改时,产生脏读

不可重复读(nonrepeatable read):同一查询在同一事务中多次进行,由于其他提交事务所做的修改或删除,每次返回不同的结果集,此时发生不可重复读

幻读(phantom read):同一查询在同一事务中多次进行,由于其他提交事务所做的插入操作,每次返回不同的结果集,此时发生幻读

2. 事务的传播行为
  1. 定义了七个事务的传播行为:都是以 PROPAGATION_开头 propagation(常用三个)

事务传播行为是指,处于不同事务中的方法在相互调用时,执行期间事务的维护情况

  1. propagation_required (spring默认的传播行为)

  2. propagation_requires_new

  3. propagation_supports

propagation_required :

说明:指定的方法必须在事务内执行。若当前存在事务,就加入到当前事务中;若当前没有事务,则创建一个新事务。这种传播行为是最常见的选择,也是 Spring 默认的事务传播行为。 

演示说明:

**如该传播行为加在doOther()**方法上。若 doSome()方法在调用 doOther()方法时就是在事务内运行的,则 doOther()方法的执行也加入到该事务内执行。若 doSome()方法在调用 doOther()方法时没有在事务内执行,则 doOther()方法会创建一个事务,并在其中执行。

项目二笔记记录_第28张图片

propagation_requires_new:

说明:总是新建一个事务,如当前存在事务,就将当前事务挂起,直到新事务执行完毕

项目二笔记记录_第29张图片

propagation_supports:

说明:指定的方法支持当前事务,但若当前没有事务,也可以以非事务方法执行

项目二笔记记录_第30张图片

3. 事务超时时限

该值一般就是用默认值

day11-领取优惠券的优化

问题的提出:

在第十天中,我们已经实现了领取优惠券的功能,并且解决了多线程下的券超发的并发安全问题。不过,之前我们考虑的是单机模式下的多线程问题,解决的思路是基于Synchronized锁。但是在集群模式下,传统的并发锁是否依然有效呢?因锁带来的性能损耗又该如何解决呢?今天我们就来思考并解决这些问题。

Synchronized锁的原理吗?

Synchronized中的重量级锁,底层就是基于**锁监视器(Monitor)**来实现的。简单来说就是锁对象头会指向一个锁监视器,而在监视器中则会记录一些信息,比如:

  • _owner:持有锁的线程
  • _recursions:锁重入次数

项目二笔记记录_第31张图片

为什么要记录重入次数呢?

就好像下面的操作一般: 当两个同步方法存在调用关系时,且两个方法的锁都是当前对象时,如果不允许锁重入就会产生死锁, 即: 我等待我已拥有的资源释放

public static void main(String[] args) {
    MethodA();
}

// MethodA需要获取当前对象作为锁
public static synchronized void MethodA(){
    System.out.println("methodA"+Thread.currentThread().getName());
    // 但是在方法A调用方法B的时候, 此时方法B也需要获得当前锁,但可见在方法A内已经拿到当前锁
    // 如果没有可重入锁的机制,那么就会出现我自己等待我自己释放锁的情况-----> 即死锁
    MethodB();
}

// MethodB也需要获取当前对象作为锁
private static synchronized void MethodB() {
    System.out.println("methodB"+Thread.currentThread().getName());
}

因此每一个锁对象,都会指向一个锁监视器,而每一个锁监视器,同一时刻只能被一个线程持有,这样就实现了互斥效果。但前提是,多个线程使用的是同一把锁

重入次数就是为了释放锁时所使用,执行完一个同步方法,_recursions减一,直到recursions为零释放锁.

此时其它线程想要获取锁,会发现监视器中的_owner已经有值了,就会获取锁失败。由于咱们代码在锁对象是用户id的字符串常量,因此同一个用户肯定是同一把锁,线程是绝对安全的。

RabbitMQ的重试

最后一个配置需要注意

stateless: true

true无状态;false有状态。如果业务中包含事务,这里改为false

spring:
  rabbitmq:
    listener:
      simple:
        retry:
          enabled: true # 开启消费者失败重试
          initial-interval: 1000 # 初识的失败等待时长为1秒
          multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
          max-attempts: 3 # 最大重试次数
          stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
简单分布式锁

Redis本身可以被任意JVM实例访问,同时Redis中的setnx命令具备互斥性,因此符合分布式锁的需求。不过实现分布式锁的时候还有一些细节需要考虑,绝不仅仅是setnx这么简单。

项目二笔记记录_第32张图片

项目二笔记记录_第33张图片

在释放锁的时候首先需要判断是不是自己的锁,其次才是删除锁, 但是因为自己写没法保证是原子操作,所以依然会出现误删锁. 此时就可以使用已经成熟的框架Redisson

Redisson解决的问题

Redisson如何实现分布式锁、锁续约

图解Redisson如何实现分布式锁、锁续约?
Redis:Redisson分布式锁的锁续期原理

1. 原子性问题

可以利用Redis的LUA脚本来编写锁操作, 确保原子

2. 超时问题

利用WatchDog(看门狗)机制,获取锁成功时开启一个定时任务,在锁到期前自动续期,避免超时释放。而当服务宕机后,WatchDog跟着停止运行,不会导致死锁。

3. 锁重入问题:

可以模拟Synchronized原理,放弃setnx,而是利用Redis的Hash结构来记录锁的持有者以及重入次数,获取锁时重入次数+1,释放锁是重入次数-1,次数为0则锁删除

4.主从一致性问题:

可以利用Redis官网推荐的RedLock机制来解决

Redisson的使用

redisson中的锁的说明

项目二笔记记录_第34张图片

基于Redis的Redisson分布式可重入锁实现了java.util.concurrent.locks.Lock接口

Redisson 锁的种类有以下几种:可重入锁,公平锁,联锁,红锁,读写锁

1. 可重入锁
一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁。
类似于java中的  ReentrantLock
public void method1() {
    RLock lock = redissonClient.getLock("lock");
    try {
        lock.lock();
        method2();
    } finally {
        lock.unlock();
    }

    System.out.println("释放锁成功");
}

public void method2() {
    RLock lock = redissonClient.getLock("lock");
    try {
        if (lock.tryLock()) {
            System.out.println("加锁成功");
            //业务逻辑
        }
    } finally {
        lock.unlock();
    }
}
2. 公平锁
它保证了当多个Redisson客户端线程同时请求加锁时,优先分配给先发出请求的线程。所有请求线程会在一个队列中排队,当某个线程出现宕机时,Redisson会等待5秒后继续下一个线程,也就是说如果前面有5个线程都处于等待状态,那么后面的线程会等待至少25秒。
RLock fairLock = redisson.getFairLock("anyLock");
// 最常见的使用方法
fairLock.lock();
3. 联锁
可以将多个锁对象关联到一个锁对象时
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
 
RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 所有的锁都上锁成功才算成功。
lock.lock();
 
//业务逻辑
lock.unlock();
4. 红锁
 当有大部分(一半以上)锁加锁成功后,才算真正获得锁
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
 
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。 这里有3个锁,至少2个加锁成功,才算真的加锁成功
lock.lock();
...
lock.unlock();
5. 读写锁
上读锁的时候可以多个线程获取,上写锁只能有一个线程获取,经过测试当写锁还未释放的时候,读锁阻塞获取不了,直到写锁释放。
/**
 * 保证一定能读到最新数据,修改期间,写锁是一个排他锁(互斥锁)。读锁是一个共享锁
 * 写锁没释放,读就必须等待
 */
@GetMapping("/read")
@ResponseBody
public String readValue() {
 
    RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
    RLock rLock = readWriteLock.readLock();
    try {
        rLock.lock();
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
    }
    return "";
}
 
 
@GetMapping("/write")
@ResponseBody
public String writeValue() {
 
    RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
    RLock rLock = readWriteLock.writeLock();
    try {
        //1、改数据加写锁,读数据加读锁
        rLock.lock();
        TimeUnit.SECONDS.sleep(10000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
    }
    return "";
}
  1. 依赖导入

<dependency>
    <groupId>org.redissongroupId>
    <artifactId>redissonartifactId>
dependency>
  1. 代码中的用法
@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();
    }
 }

利用Redisson获取锁时可以传3个参数:

  • waitTime:获取锁的等待时间。当获取锁失败后可以多次重试,直到waitTime时间耗尽。waitTime默认-1,即失败后立刻返回,不重试。
  • leaseTime:锁超时释放时间。默认是30,同时会利用WatchDog来不断更新超时时间。需要注意的是,如果手动设置leaseTime值,会导致WatchDog失效。
  • TimeUnit:时间单位
Redisson在项目中的集成

因为Redisson底层使用的就是Redis, 所以这里读取的配置直接就是Redis中的

@Slf4j
@ConditionalOnClass({RedissonClient.class, Redisson.class})
@Configuration
@EnableConfigurationProperties(RedisProperties.class)
public class RedissonConfig {
    private static final String REDIS_PROTOCOL_PREFIX = "redis://";
    private static final String REDISS_PROTOCOL_PREFIX = "rediss://";

    @Bean
    @ConditionalOnMissingBean
    public LockAspect lockAspect(RedissonClient redissonClient){
        return new LockAspect(redissonClient);
    }

    @Bean
    @ConditionalOnMissingBean
    // 读取SpringBoot中的Redis配置
    public RedissonClient redissonClient(RedisProperties properties){
        log.debug("尝试初始化RedissonClient");
        // 1.读取Redis配置
        RedisProperties.Cluster cluster = properties.getCluster();
        RedisProperties.Sentinel sentinel = properties.getSentinel();
        String password = properties.getPassword();
        int timeout = 3000;
        Duration d = properties.getTimeout();
        if(d != null){
            timeout = Long.valueOf(d.toMillis()).intValue();
        }
        // 2.设置Redisson配置
        Config config = new Config();
        if(cluster != null && !CollectionUtil.isEmpty(cluster.getNodes())){
            // 集群模式
            config.useClusterServers()
                    .addNodeAddress(convert(cluster.getNodes()))
                    .setConnectTimeout(timeout)
                    .setPassword(password);
        }else if(sentinel != null && !StrUtil.isEmpty(sentinel.getMaster())){
            // 哨兵模式
            config.useSentinelServers()
                    .setMasterName(sentinel.getMaster())
                    .addSentinelAddress(convert(sentinel.getNodes()))
                    .setConnectTimeout(timeout)
                    .setDatabase(0)
                    .setPassword(password);
        }else{
            // 单机模式
            config.useSingleServer()
                    .setAddress(String.format("redis://%s:%d", properties.getHost(), properties.getPort()))
                    .setConnectTimeout(timeout)
                    .setDatabase(0)
                    .setPassword(password);
        }
        // 3.创建Redisson客户端
        return Redisson.create(config);
    }

    private String[] convert(List<String> nodesObject) {
        List<String> nodes = new ArrayList<>(nodesObject.size());
        for (String node : nodesObject) {
            if (!node.startsWith(REDIS_PROTOCOL_PREFIX) && !node.startsWith(REDISS_PROTOCOL_PREFIX)) {
                nodes.add(REDIS_PROTOCOL_PREFIX + node);
            } else {
                nodes.add(node);
            }
        }
        return nodes.toArray(new String[0]);
    }
}

此项目中自定义的RedisClient

    @Bean
    public RedissonClient redissonClient() {
        // 配置类
        Config config = new Config();
        // 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
        config.useSingleServer()
                .setAddress("redis://192.168.150.101:6379")
                .setPassword("123321");
        // 创建客户端
        return Redisson.create(config);
    }

几个关键点:

  • 这个配置上添加了条件注解@ConditionalOnClass({RedissonClient.class, Redisson.class}) 也就是说,只要引用了tj-common,并且引用了Redisson依赖,这套配置就会生效。不引入Redisson依赖,配置自然不会生效,从而实现按需引入。
  • RedissonClient的配置无需自定义Redis地址,而是直接基于SpringBoot中的Redis配置即可。而且不管是Redis单机、Redis集群、Redis哨兵模式都可以支持
Redisson通用分布式锁的设计

Redisson的分布式锁使用并不复杂,基本步骤包括:

  • 1)创建锁对象
  • 2)尝试获取锁
  • 3)处理业务
  • 4)释放锁

项目二笔记记录_第35张图片

但是,除了第3步以外,其它都是非业务代码,对业务的侵入较多:

可以发现,非业务代码格式固定,每次获取锁总是在重复编码。我们可不可以对这部分代码进行抽取和简化呢?

项目二笔记记录_第36张图片

综上,我们计划利用注解来标记切入点,传递锁参数。同时利用AOP环绕增强来实现加锁、释放锁等操作。

注解本身起到标记作用,同时还要带上锁参数:

  • 锁名称
  • 锁等待时间
  • 锁超时时间
  • 时间单位
1. 锁名称
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MyLock {
    /**
     * 加锁key的表达式,支持SPEL表达式
     */
    String name();

    /**
     * 阻塞超时时长,不指定 waitTime 则按照Redisson默认时长
     */
    long waitTime() default 1;

    /**
     * 锁自动释放时长,默认是-1,其实是30秒 + watchDog模式
     */
    long leaseTime() default -1;

    /**
     * 时间单位,默认为秒
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;
}
2. 定义切面 切面的执行顺序(重要)

方式一: 继承Ordered接口

方式二: 在类上添加 @Order 注解

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

    private final RedissonClient redissonClient;

    // 这里使用注解的方式指定切入点, 即带了MyLock的方法   
    @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;
    }
}

注意1: @Around(“@annotation(myLock)”) 后面的参数一定要和方法入参的参数列表中的参数相同

=
注意2: 这里采用继承Ordered来指定不同注解生效的时机,

注意,Spring中的AOP切面有很多,会按照Order排序,按照Order值从小到大依次执行。Spring事务AOP的order值是Integer.MAX_VALUE,优先级最低。

我们的分布式锁一定要先于事务执行,因此,我们的切面一定要实现Ordered接口,指定order值小于Integer.MAX_VALUE即可。

3. 使用锁(优化)

项目二笔记记录_第37张图片

可以看到,业务中无需手动编写加锁、释放锁的逻辑了,没有任何业务侵入,使用起来也非常优雅

不过呢,现在还存在几个问题:(下面就通过一些方式来对其存在的问题优化)

  • Redisson中锁的种类有很多,目前的代码中把锁的类型写死了
  • Redisson中获取锁的逻辑有多种,比如获取锁失败的重试策略,目前都没有设置
  • 锁的名称目前是写死的,并不能根据方法参数动态变化

对于锁的类型我们采用工厂模式来解决

① 工厂模式切换锁类型(优化锁的选择)

Redisson中锁的类型有多种,例如:

项目二笔记记录_第38张图片

那么问题来了,如何让用户选择锁类型呢?

锁的类型虽然有多种,但类型是有限的几种,完全可以通过枚举定义出来。然后把这个枚举作为MyLock注解的参数,交给用户去选择自己要用的类型。

这里我们的需求是根据用户选择的锁类型,创建不同的锁对象。有一种设计模式刚好可以解决这个问题:简单工厂模式

定义一个注解作为锁的key, ----> 目的是根据不同的key 来创建出不同的对象

工厂模式实现的两种方法
方式一(使用枚举,加自定义工厂类)

定义一个锁类型枚举:

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

然后在自定义注解中添加锁类型这个参数

/**
 * 锁的类型,包括:可重入锁、公平锁、读锁、写锁
 */
MyLockType lockType() default MyLockType.RE_ENTRANT_LOCK;

定义一个锁工厂,用于根据锁类型创建锁对象:

@Component
public class MyLockFactory {

    private final Map> lockHandlers;
		// Spring自动注入RedissonClient
    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);
    }
}
方式二(使用枚举类中的抽象方法)

使用枚举中的抽象方法

public enum LockType {
    DEFAULT(){
        @Override
        public RLock getLock(RedissonClient redissonClient, String name) {
            return redissonClient.getLock(name);
        }
    },
    FAIR_LOCK(){
        @Override
        public RLock getLock(RedissonClient redissonClient, String name) {
            return redissonClient.getFairLock(name);
        }
    },
    READ_LOCK(){
        @Override
        public RLock getLock(RedissonClient redissonClient, String name) {
            return redissonClient.getReadWriteLock(name).readLock();
        }
    },
    WRITE_LOCK(){
        @Override
        public RLock getLock(RedissonClient redissonClient, String name) {
            return redissonClient.getReadWriteLock(name).writeLock();
        }
    },
    ;
	// 定义抽象方法
    public abstract RLock getLock(RedissonClient redissonClient, String name);
}

加入到自定义注解中

/**
 * 锁的类型,包括:可重入锁、公平锁、读锁、写锁
*/
LockType lockType() default LockType.DEFAULT;
4. 锁失败策略

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

接下来,我们就分析一下锁失败的处理策略有哪些。

大的方面来说,获取锁失败要从两方面来考虑:

  • 获取锁失败是否要重试?有三种策略:
    • 不重试,对应API:lock.tryLock(0, 10, SECONDS),也就是waitTime小于等于0
    • 有限次数重试:对应API:lock.tryLock(5, 10, SECONDS),也就是waitTime大于0,重试一定waitTime时间后结束
    • 无限重试:对应API lock.lock(10, SECONDS) , lock就是无限重试
  • 重试失败后怎么处理?有两种策略:
    • 直接结束
    • 抛出异常

对应的API和策略名如下:

重试策略 + 失败策略组合,总共以下几种情况:

项目二笔记记录_第39张图片

一般的策略模式大概是这样:

  • 定义策略接口
  • 定义不同策略实现类
  • 提供策略工厂,便于根据策略枚举获取不同策略实现

而在策略比较简单的情况下,我们完全可以用枚举代替策略工厂,简化策略模式。

使用策略模式
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;
}
5. 基于SPEL的动态锁名

玩转Spring中强大的spel表达式! - 知乎 (zhihu.com)

现在还剩下最后一个问题,就是锁名称的问题。

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

Spring中提供了一种表达式语法,称为SPEL表达式,可以执行java代码,获取任意参数。

我们可以让用户指定锁名称参数时不要写死,而是基于SPEL表达式。在创建锁对象时,解析SPEL表达式,动态获取锁名称。

首先,在使用锁注解时,锁名称可以利用SPEL表达式,例如我们指定锁名称中要包含参数中的用户id,则可以这样写:

项目二笔记记录_第40张图片

如果是通过UserContext.getUser()获取,则可以利用下面的语法:

项目二笔记记录_第41张图片

这里T(类名).方法名()就是调用静态方法。

解析SPEL

在切面中,我们需要基于注解中的锁名称做动态解析,而不是直接使用名称:

/**
 * SPEL的正则规则
 */
private static final Pattern pattern = Pattern.compile("\\#\\{([^\\}]*)\\}");
/**
 * 方法参数解析器
 */
private static final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();

/**
 * 解析锁名称
 * @param name 原始锁名称
 * @param pjp 切入点
 * @return 解析后的锁名称
 */
private String getLockName(String name, ProceedingJoinPoint pjp) {
    // 1.判断是否存在spel表达式
    if (StringUtils.isBlank(name) || !name.contains("#")) {
        // 不存在,直接返回
        return name;
    }
    // 2.构建context,也就是SPEL表达式获取参数的上下文环境,这里上下文就是切入点的参数列表
    EvaluationContext context = new MethodBasedEvaluationContext(
            TypedValue.NULL, resolveMethod(pjp), pjp.getArgs(), parameterNameDiscoverer);
    // 3.构建SPEL解析器
    ExpressionParser parser = new SpelExpressionParser();
    // 4.循环处理,因为表达式中可以包含多个表达式
    Matcher matcher = pattern.matcher(name);
    while (matcher.find()) {
        // 4.1.获取表达式
        String tmp = matcher.group();
        String group = matcher.group(1);
        // 4.2.这里要判断表达式是否以 T字符开头,这种属于解析静态方法,不走上下文
        Expression expression = parser.parseExpression(group.charAt(0) == 'T' ? group : "#" + group);
        // 4.3.解析出表达式对应的值
        Object value = expression.getValue(context);
        // 4.4.用值替换锁名称中的SPEL表达式
        name = name.replace(tmp, ObjectUtils.nullSafeToString(value));
    }
    return name;
}

private Method resolveMethod(ProceedingJoinPoint pjp) {
    // 1.获取方法签名
    MethodSignature signature = (MethodSignature)pjp.getSignature();
    // 2.获取字节码
    Class<?> clazz = pjp.getTarget().getClass();
    // 3.方法名称
    String name = signature.getName();
    // 4.方法参数列表
    Class<?>[] parameterTypes = signature.getMethod().getParameterTypes();
    return tryGetDeclaredMethod(clazz, name, parameterTypes);
}

private Method tryGetDeclaredMethod(Class<?> clazz, String name, Class<?> ... parameterTypes){
    try {
        // 5.反射获取方法
        return clazz.getDeclaredMethod(name, parameterTypes);
    } catch (NoSuchMethodException e) {
        Class<?> superClass = clazz.getSuperclass();
        if (superClass != null) {
            // 尝试从父类寻找
            return tryGetDeclaredMethod(superClass, name, parameterTypes);
        }
    }
    return null;
}

day12-优惠券的使用

️‍ 订单号的预生成

那么为什么要预生成订单号呢?

为了防止在订单界面用户多次的点击提交按钮, 导致后台每次的请求都生成订单, 此时就可以使用mybatis-plus中的IdWorker类来生成

代码实现:

long id = IdWorker.getId();
// 1667202759771824129
System.out.println(id);
从Redis中获取多个键值对⛳️

这里是通过entries()方法

    private Coupon queryCouponByCache(Long couponId) {
        // 1 设置key
        String key = PromotionConstants.COUPON_CACHE_KEY_PREFIX + couponId;
        // 1.1 查询Redis获取数据
        Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
        // 2. 数据校验,如果为
        if (entries.isEmpty()) {
            return null;
        }
        // 拷贝到Coupon实体中
        return BeanUtils.mapToBean(entries, Coupon.class, false, CopyOptions.create());

    }
求两个集合的交集
// 求两个集合的交集
        Collection<CouponDiscountDTO> bestSolutions = CollUtils.intersection(discountMaxSolution.values(), lessCouponSolution.values());
多线程下使用集合(线程安全通过)静态方法

利用装饰模式根据传入的Collection生成特定同步的SynchronizedCollection,生成的集合每个同步操作都是持有mutex<互斥锁>这个锁,所以再进行操作时就是线程安全的集合了。

设计模式 装饰者模式 其在JDK中的应用_一叶一菩提魁的博客-CSDN博客

项目二笔记记录_第42张图片

SynchronizedMap()和Hashtable一样,实现上在调用map所有方法时,都对整个map进行同步。而ConcurrentHashMap的实现却更加精细,它对map中的所有桶加了锁。所以,只要有一个线程访问map,其他线程就无法进入map,而如果一个线程在访问ConcurrentHashMap某个桶时,其他线程,仍然可以对map执行某些操作。

所以,ConcurrentHashMap在性能以及安全性方面,明显比Collections.synchronizedMap()更加有优势。同时,同步操作精确控制到桶,这样,即使在遍历map时,如果其他线程试图对map进行数据修改,也不会抛出ConcurrentModificationException。
SpringBoot中使用线程池的几种方法

java多线程基础知识_yfs1024的博客-CSDN博客

1. 使用工具类Executors(禁用)

在阿里巴巴开发手册中禁用这种方式

项目二笔记记录_第43张图片

2. ThreadPoolExecutor 自己创建

项目二笔记记录_第44张图片

参数七是内部类中定义的

ExecutorService pool = new ThreadPoolExecutor(
    3,	//核心线程数有3个
    5,  //最大线程数有5个。   临时线程数=最大线程数-核心线程数=5-3=2
    8,	//临时线程存活的时间8秒。 意思是临时线程8秒没有任务执行,就会被销毁掉。
    TimeUnit.SECONDS,//时间单位(秒)
    new ArrayBlockingQueue<>(4), //任务阻塞队列,没有来得及执行的任务在,任务队列中等待
    Executors.defaultThreadFactory(), //用于创建线程的工厂对象,从线程工具类中的获取
    new ThreadPoolExecutor.CallerRunsPolicy() //拒绝策略  
);
3, 使用Spring中默认的线程池直接注入
@Autowired
ThreadPoolTaskExecutor taskExecutor;

// 方法中使用执行器,并调用
public void testMethod(){
    ThreadPoolExecutor threadPoolExecutor = taskExecutor.getThreadPoolExecutor();
    threadPoolExecutor.execute(()-> System.out.println("hello"));
}
4, 通过配置类,自己手动放入IOC(推荐 )

为什么不使用默认的配置呢?自己看

项目二笔记记录_第45张图片

这个和第一个不使用的原因都一样, 容易产生OOM(Out Of Memory)

注意: 异步方法的强调

项目二笔记记录_第46张图片

这里如果什么都不写使用的就是上面ThreadPoolTaskExecutor中默认的线程池设置,所以很不靠谱一般会自己手动创建

示例:

@Bean("taskExecutor")
public Executor asyncServiceExecutor() {
    log.info("start asyncServiceExecutor");
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    //配置核心线程数
    executor.setCorePoolSize(10);
    //配置最大线程数
    executor.setMaxPoolSize(15);
    //配置队列大小
    executor.setQueueCapacity(99999);
    //配置线程池中的线程的名称前缀
    executor.setThreadNamePrefix("pd-user-async-service-");

    // 设置拒绝策略:当pool已经达到max size的时候,如何处理新任务
    // CALLER_RUNS:不在新线程中执行任务,而是有调用者所在的线程来执行
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    //执行初始化
    executor.initialize();
    return executor;
}
各个优惠方案的并发计算

这里的优化 采用了JUC中的两个类

CountDownLatch

CountDownLatch是通过一个计数器来实现的,计数器的初始化值为线程的数量。每当一个线程完成了自己的任务后,计数器的值就相应的减1。当计数器到达0时,表示所有的线程都已完成任务,然后在闭锁上等待的线程就可以恢复执行任务。

// 构造一个用给定计数初始化的 CountDownLatch。
CountDownLatch(int count)
// 使当前线程在锁存器倒计数至零之前一直等待,除非线程被中断。
void await()
// 使当前线程在锁存器倒计数至零之前一直等待,除非线程被中断或超出了指定的等待时间。
boolean await(long timeout, TimeUnit unit)
// 递减锁存器的计数,如果计数到达零,则释放所有等待的线程。
void countDown()
    
    
    
// 返回当前计数。
long getCount()
// 返回标识此锁存器及其状态的字符串。
String toString()

使用示例:

public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(10);
 
        for (int i=0; i<9; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName() + " 运行");
                    try {
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    } finally {
                        latch.countDown();
                    }
                }
            }).start();
        }
 
        System.out.println("等待子线程运行结束");
        latch.await(10, TimeUnit.SECONDS);
        System.out.println("子线程运行结束");
}f

子线程先等待,主线线程处理完任务后恢复子线程,同时主线程等待子线程处理完任务继续执行

CompletableFuture

使用CompletableFuture

有两种格式,一种是supply开头的方法,一种是run开头的方法

  • supply开头:这种方法,可以返回异步线程执行之后的结果
  • run开头:这种不会返回结果,就只是执行线程任务
// 无返回值
runAsync(Runnable runnable)
// 无返回值 可以自定义线程池
runAsync(Runnable runnable, Executor executor)
// 有返回值
supplyAsync(Supplier<U> supplier)
// 有返回值 可以自定义线程池
supplyAsync(Supplier<U> supplier, Executor executor)

传递任务的结果, 为上面的有返回值的调用—> 这样的目的就是让业务逻辑更加的清晰

// 任务完成后调用action,并传递任务结果  
thenAccept(Consumer<? super T> action)
//任务完成后异步调用action,并传递任务结果
thenAcceptAsync(Consumer<? super T> action)
// 任务完成后调用action,但是不传递任务结果
thenRun(Runnable action)
//任务完成后异步调用action,但是不传递任务结果
thenRunAsync(Runnable action)

你可能感兴趣的:(项目中的笔记,笔记,java,开发语言)