Administrator
Administrator
Published on 2025-02-25 / 27 Visits
0
0

项目ONE《技术派社区》

  1. 技术派网址:https://paicoding.com

  2. 技术派教程:https://paicoding.com/column 目前已更新高并发手册、JVM 手册、Java 并发编程手册、二哥的 Java 进阶之路,以及技术派部分免费教程。我们的宗旨是:学编程,就上技术派😁

  3. 技术派管理端源码:paicoding-admin

  4. 技术派专属学习圈子:不走弯路,少采坑,附 120 篇技术派全套教程

  5. 派聪明AI助手:AI 时代,怎能掉队,欢迎体验 技术派的派聪明 AI 助手

  6. 码云仓库:https://gitee.com/itwanger/paicoding (国内访问速度更快)

入手:拉代码——看readme文档——启动项目——看文档教程+MD笔记

项目演示

前台社区系统

  后台社区系统

零:技术派面试问题

脑图——

https://t085ylr6bri.feishu.cn/sync/PmBddNejOs4TLgbwBghcCBzHnDf

通过提前建立Socket链接,来解决本地服务启动时8080端口可能被占用的问题。

通过 Redis 实现计数统计和用户活跃度排行,并通过先写 MySQL,再删除 Redis 的方来保证高并发场景下的缓存一致性。同时利用Redis实现轻量级的用户白名单和用户活跃度排行榜。

采用自旋锁策略优化缓存架构,针对热key的并发访问同步,防止其失效时导致的缓存击穿。

通过AOP+TraceID记录接口访问日志,实现任务的追踪、监控和诊断。

小点,后续再看(对技术提升不大 / 工作用到的少——浅尝辄止)

  • 整合Knife4j代替丑陋的Swagger,实现API文档生成和RESTful Web测试

一. 三层架构+MVC分层架构

https://www.yuque.com/itwanger/az7yww/bgvqcehwttws2biw

三层架构:采用面向接口编程。各层之间采用接口相互访问,并通过对象模型的实体类作为数据传递的载体

  • 表示层

  • 业务逻辑层

  • 数据访问层

MVC层次

  • Model

  • View

  • Controller

二. 实体类对象分类

https://www.yuque.com/itwanger/az7yww/cyec3at7t1lboawl

  • do:数据库实体(po/entity)

  • dto:返回给前端的数据实体

  • bo:业务对象,通常是服务内的相关对象。

  • po:持久化对象

  • vo:封装返回给前端的数据实体

  • req:前端传递给后端的请求参数

  • rsp:返回结果封装类,通常用于将返回给展示层的对象格式化同统一返回的场景

三. Spring的AOP实现切面日志

https://www.yuque.com/itwanger/az7yww/vskfv3g4rs6tpgx4

暂时无法在飞书文档外展示此内容

对AOP的理解:面对切面编程,核心单元是切面,利用AOP可以对业务逻辑的各个部分进行隔离,从而降低耦合度,提高程序的可重用性和开发效率。

AOP概念理清

https://blog.csdn.net/q982151756/article/details/80513340

AOP中的相关概念 接下来就来讲解一下AOP中的相关概念,了解了AOP中的概念,才能真正的掌握AOP的精髓。这里还是先给出一个比较专业的概念定义:

具体实现

看具体实现之前要先知道几样东西:

  • SkyWalking:一款开源的应用性能监控系统。支持对分布式系统中的服务进行最追踪、监控和诊断。

https://skywalking.apache.org/

要注意以下几个注解——

  • @Ponitcut

  • @Aspect

  • @Before

  • @After

  • @AfterReturning

  • @AfterThrowing

  • @Around

日志功能的实现包括以下五个类

  1. 🔵SkyWalkingTraceIdGenerator

该类是从SkyWalking直接复制粘贴过来的,代码如下:

其中最主要的方法就是一个静态的generate方法,用于生成traceid

这个类生成的traceid包含三部分:

  • 第一部分是应用实例ID,它是在类加载时生成的一个UUID,对于每个进程,它是唯一的。

  • 第二部分是当前线程的ID

  • 第三部分是一个由时间戳和线程序列号组成的数字,时间戳是毫秒级的,而线程序列号是一个在0到9999之间的数。

  这个工具类的设计思想主要是生成一个既唯一又能包含一些上下文信息的 traceid,帮助我们更好地追踪和理解分布式系统中的请求执行路径

  1. 🔵SelfTraceIdGenerator

这个文件不是必须的,是一个用户自定义的traceid生成器,其中最重要的也是其generator方法

  生成的traceld包括以下四部分:

  • IP地址(8位):取得当前机器的IP地址,并将其转换为十六进制格式。

  • 时间戳(13位):使用Java 8的Instant类获取当前的毫秒级时间截。

  • 进程号(5位):使用Java的ManagementFactory类获取当前IVM进程的PID,并保证总长度为5位。

  • 自增序列号(4位):一个在1000到9999之间循环自增的数。

对traceId的分析

当你的系统是分布式或者微服务时,一个球友可能会穿过多个服务,每个服务可能都会生成一些日志,但由于系统是微服务/分布式的,会运行在不同的物理机器上,如果没有一个统一的标识符来链接这些日志,就很难理解一个请求的完整过程。

traceid 就是这样一个标识符,它在请求进入系统时生成,然后沿着请求的执行路径传递给所有参与处理该请求的服务。这些服务在生成日志时,会把traceid包含在日志中。这样,通过搜索同-traceid的所有日志,就可以追踪

到整个请求的执行过程。

  1. 🔵MdcUtil

    1.   MDC 全称为 Mapped Diagnostic Context,可译为上下文诊断映射,也不知道标准不标准,大概就这么一个意思。主要用于在多线程环境中存储每个线程特定的诊断信息,比如 traceld

    2.   该类主要提供了五个方法:

    3. add方法:往MDC中添加一个键值对,

    4. addTraceld方法:生成-个traceld并添加到MDC中,

    5. getTraceld方法:从MDC中获取traceld,

    6. reset方法:清除MDC中的所有信息,然后把traceld添加回去。

    7. clear方法:清除MDC中的所有信息

    8.   如果你在技术派的源码中搜 MdcUtil 的话,可以在 ReqRecordFilter 中找得到,顾名思义,该类是对请求的一个过滤器,会在每个请求中加上全链路的 traceid。

  1. 🔵MdcDot

这段代码定义了一个Java注解@MdcDot

  1. 🔵MdcAspect

    1.   创建一个AspectJ切面用于实现面向切面编程(AOP):主要是用来处理添加了@MdcDot注解的方法和类。具体怎样处理,由@Around注解标注的handle方法定义

    2. package com.github.paicoding.forum.core.mdc; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.context.expression.BeanFactoryResolver; import org.springframework.core.DefaultParameterNameDiscoverer; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.stereotype.Component; import java.lang.reflect.Method; /** * @author YiHui * @date 2023/5/26 */ @Slf4j @Aspect @Component public class MdcAspect implements ApplicationContextAware { private ExpressionParser parser = new SpelExpressionParser(); private ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); @Pointcut("@annotation(MdcDot) || @within(MdcDot)") public void getLogAnnotation() { } @Around("getLogAnnotation()") public Object handle(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); boolean hasTag = addMdcCode(joinPoint); try { Object ans = joinPoint.proceed(); return ans; } finally { log.info("执行耗时: {}#{} = {}ms", joinPoint.getSignature().getDeclaringType().getSimpleName(), joinPoint.getSignature().getName(), System.currentTimeMillis() - start); if (hasTag) { MdcUtil.reset(); } } } private boolean addMdcCode(ProceedingJoinPoint joinPoint) { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); MdcDot dot = method.getAnnotation(MdcDot.class); if (dot == null) { dot = (MdcDot) joinPoint.getSignature().getDeclaringType().getAnnotation(MdcDot.class); } if (dot != null) { MdcUtil.add("bizCode", loadBizCode(dot.bizCode(), joinPoint)); return true; } return false; } private String loadBizCode(String key, ProceedingJoinPoint joinPoint) { if (StringUtils.isBlank(key)) { return ""; } StandardEvaluationContext context = new StandardEvaluationContext(); context.setBeanResolver(new BeanFactoryResolver(applicationContext)); String[] params = parameterNameDiscoverer.getParameterNames(((MethodSignature) joinPoint.getSignature()).getMethod()); Object[] args = joinPoint.getArgs(); for (int i = 0; i < args.length; i++) { context.setVariable(params[i], args[i]); } return parser.parseExpression(key).getValue(context, String.class); } private ApplicationContext applicationContext; @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } }

  1. 🔵xxController

直接看用到@MdcDot注解的recommend方法:

🟢 整体实现流程:

在我的项目中,我使用了AOP来实现日志追踪功能。

  1. 首先,我使用@Pointcut注解定义了一个切点,它通过 @annotation(MdcDot) 和 @within(MdcDot)两个注解匹配需要日志记录的方法。

  2. 然后,我应用了一个环绕通知,使用 @Around 注解将 handle() 方法应用于该切点。这个方法环绕了目标方法的执行,允许我在目标方法执行前后执行自定义好的逻辑。

  3. 我还利用了MDC来存储和传递方法执行期间的关键上下文信息,如业务代码。

  4. 此外,我使用了Spring的SpEL来动态解析方法参数,并将这些参数值添加到日志上下文中。

给3道关于 Spring AOP 的面试题,这可不是单纯的八股,需要结合实际的项目来回答最好:

  • 说说什么是 AOP ?

  • AOP 有哪些核心概念?

  • AOP 有哪些环绕方式?

说说你平时都是怎么使用 AOP 的?

  • 说说 Spring AOP Aspect AOP 有什么区别?

  • 说说 JDK动态代理CGLIB 代理?

四. Springboot整合MyBatis-plus

技术派文档:https://www.yuque.com/itwanger/az7yww/czz7nsd5bmfnn8ln

我的幕布总结:https://www.mubu.com/doc/yxSewOw25H

  • 注意MP中条件构造器的使用,最好采用Lambada的方式。

  • 通过条件构造器,可以和数据库的字段隔开,完全通过代码的方式进行数据查询。

  • MP支持自定义SQL,也就是根据方法传进来的参数,使用@Select注解进行数据库操作。

  • 对于复杂的SQL操作,MP还可以使用xml文件方式进行定义。

    • 具体用法还要探究!!!

  • MP有以下几种主键策略

    • IDType.AUTO:(雪花策略)在插入数据时,无需设置主键值,数据库会自动分配主键值

    • IDType.NONE:(无主键策略)不使用任何主键生成策略,主键值需要自己设置

    • IDType.UUID:(UUID策略)插入数据时,MP会自动生成一个UUID值作为主键值

    • IDType.WORKER:(雪花算法策略)插入数据时,MP会自动使用雪花算法生成一个分布式唯一ID作为主键值。

具体用法:

五. 多配置文件

多个配置文件情况下,可以在默认配置文件application.yml里用配置spring.profiles.active,表示哪些配置文件可以被激活使用

spring:
  #  Spring Boot 2.4为了提升对Kubernetes的支持 将 spring.profiles 作废了
  profiles:
      active: dal,web,config,image
  # 替换上面作废的spring.profiels.actice配置参数

后面使用配置spring.config.import替换上面作废的spring.profiels.actice配置参数

spring:
    config:
      # 直接设置配置文件名,可以是protites文件或者yml文件
      import: application-dal.yml,application-web.yml,application-config.yml,application-image.yml,application-email.yml,application-rabbitmq.yml,application-ai.yml
      # 上面的多个配置文件优先级从右到左递减,最低优先级为本文件application.yml

默认的配置文件是放在src/main/resources目录下,当然也是可以放其他位置的(假设./为当前运行路径)

各配置文件优先级由上而下为:

  1. 外置,在相对于应用程序运行目录的/config 子目录中(即./config/目录下)

  2. 外置,在应用程序运行的目录中(即./目录下)

  3. 内置,放在config包下(即src/main/resources/config/目录下)

  4. 内置,放在classpath根目录下(即默认的src/main/resources/目录下)

    1.   以内置的两个进行对比,实测结果如下

    2. config/application.yml

      • source配置值为 config-application.yml

    3. application.yml

      • source配置值为 base-application.yml

Spring Boot 自带的多环境配置

🟢创建不同的yml文件代表不同的环境:

  • application-dev.yml 开发环境

  • application-test.yml 测试环境

  • application-prod.yml 生产环境

  • application.yml

「注意」:配置文件的名称一定要是application-name.properties或者application-name.yml格式。这个name可以自定义,主要用于区分。

🟢指定运行环境:

  • 配置文件中指定

spring:
  profiles:
    active: test

要是上面的不行,就改用spring.config.imoport

  • 运行Jar的时候指定

Spring Boot 内置的环境切换能够在运行Jar包的时候指定环境,命令如下:

java -jar xxx.jar --spring.profiles.active=test
  • 但这种情况有一个不好的地方在于每次改动都要手动改配置文件的代码,所以有了更好的Maven提供的一键切换环境

Maven的多环境配置(Better

🟢创建多环境配置文件,与上同

🟢定义激活的变量

需要将Maven激活的环境作用于Spring Boot,实际还是利用了spring.profiles.active这个属性,只是现在这个属性的取值将是取值于Maven。配置如下:

spring.profiles.active=@profile.active@

profile.active实际上就是一个变量,在maven打包的时候指定的-P test传入的就是值

🟢pom文件中定义profiles

需要在mavenpom.xml文件中定义不同环境的profile,如下:

<!--定义三种开发环境-->
    <profiles>
        <profile>
            <!--不同环境的唯一id-->
            <id>dev</id>
            <activation>
                <!--默认激活开发环境-->
                <activeByDefault>true</activeByDefault>
            </activation>
            <properties>
                <!--profile.active对应application.yml中的@profile.active@-->
                <profile.active>dev</profile.active>
            </properties>
        </profile>

        <!--测试环境-->
        <profile>
            <id>test</id>
            <properties>
                <profile.active>test</profile.active>
            </properties>
        </profile>

        <!--生产环境-->
        <profile>
            <id>prod</id>
            <properties>
                <profile.active>prod</profile.active>
            </properties>
        </profile>
    </profiles>
  • 标签<profile.active>正是对应着配置文件中的@profile.active@

  • <activeByDefault>标签指定了默认激活的环境,则是打包的时候不指定-P选项默认选择的环境。

  • 以上配置完成后,将会在IDEA的右侧Maven选项卡中出现以下内容:

可以选择打包的环境,然后点击package即可。

或者在项目的根目录下用命令打包,不过需要使用-P指定环境,如下:

mvn clean package package -P test

maven中的profile的激活条件还可以根据jdk操作系统文件存在或者缺失来激活。这些内容都是在<activation>标签中配置,如下:

<!--activation用来指定激活方式,可以根据jdk环境,环境变量,文件的存在或缺失-->
  <activation>
       <!--配置默认激活-->
      <activeByDefault>true</activeByDefault>
                
      <!--通过jdk版本-->
      <!--当jdk环境版本为1.8时,此profile被激活-->
      <jdk>1.8</jdk>
      <!--当jdk环境版本1.8或以上时,此profile被激活-->
      <jdk>[1.8,)</jdk>

      <!--根据当前操作系统-->
      <os>
        <name>Windows XP</name>
        <family>Windows</family>
        <arch>x86</arch>
        <version>5.1.2600</version>
      </os>
  </activation>

🟢资源过滤

如果你不配置这一步,将会在任何环境下打包都会带上全部的配置文件,但是我们可以配置只保留对应环境下的配置文件,这样安全性更高。

这一步配置很简单,只需要在pom.xml文件中指定<resource>过滤的条件即可,如下:

配置主要分为两个方面,第一是先排除所有配置文件,第二是根据profile.active动态的引入配置文件:

<build>
  <resources>
  <!--排除配置文件-->
    <resource>
      <directory>src/main/resources</directory>
      <!--先排除所有的配置文件-->
        <excludes>
          <!--使用通配符,当然可以定义多个exclude标签进行排除-->
          <exclude>application*.properties</exclude>
        </excludes>
    </resource>

    <!--根据激活条件引入打包所需的配置和文件-->
    <resource>
      <directory>src/main/resources</directory>
      <!--引入所需环境的配置文件-->
      <filtering>true</filtering>
      <includes>
        <include>application.yml</include>
          <!--根据maven选择环境导入配置文件-->
        <include>application-${profile.active}.yml</include>
      </includes>
    </resource>
  </resources>
</build>

六. 请求参数解析

https://www.yuque.com/itwanger/az7yww/nr2lu4f17ghm5num

如何理解GET:有一种看法,如果一个请求不会导致服务器上任何资源的状态发生变化,那么就可以用GET请求

GET请求参数解析

  • HttpServletRequest

@GetMapping(path = {"/", "/index"})
public String index(Model model, HttpServletRequest request) {
    String activeTab = request.getParameter("category");
    IndexVo vo = indexRecommendHelper.buildIndexVo(activeTab);
    model.addAttribute("vo", vo);
    return "views/home/index";
}
  • RequestParam

RerquestParam一共有四个参数,常用的是name(参数名)和required(是否必须)

@Permission(role = UserRole.ADMIN)
@GetMapping(path = "operate")
public ResVo<String> operate(@RequestParam(name = "articleId") Long articleId, @RequestParam(name = "operateType") Integer operateType) {
    if (operateType == OperateArticleEnum.EMPTY) {
        return ResVo.fail(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, operateType + "非法");
    }
    articleSettingService.operateArticle(articleId, operateType);
    return ResVo.ok("ok");
}
  • IDEA里可以通过curl这个强大的命令行工具进行接口测试,用法如下:

curl 是一个强大的命令行工具,可以用于发送 HTTP 请求进行接口测试。下面是使用 curl 进行接口测试的一些常用命令:

  1. GET 请求:

curl -X GET <URL>

其中 <URL> 是你要发送 GET 请求的接口 URL。

  1. POST 请求:

curl -X POST -H "Content-Type: application/json" -d '{"key1":"value1", "key2":"value2"}' <URL>

其中 <URL> 是你要发送 POST 请求的接口 URL。-H "Content-Type: application/json" 指定请求的 Content-Type 为 JSON 格式,-d 参数后跟着要发送的 JSON 数据。

  1. 带有请求头的请求:

curl -H "HeaderName: HeaderValue" <URL>

其中 HeaderName 是请求头的名称,HeaderValue 是请求头的值,<URL> 是你要发送请求的接口 URL。

  1. 带有查询参数的请求:

curl "<URL>?param1=value1&param2=value2"

其中 <URL> 是你要发送请求的接口 URL,param1=value1&param2=value2 是查询参数的键值对。

  1. 文件上传:

curl -X POST -F "file=@/path/to/file" <URL>

其中 <URL> 是你要发送 POST 请求的接口 URL。-F "file=@/path/to/file" 指定要上传的文件路径。

  • PathVarible

Spring MVC中的一个注解,可以将占位符参数绑定到控制器处理方法的参数上

  • 表示将路径中的userId传给方法中的userId做相关操作

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{userId}")
    public ResponseEntity<User> getUserById(@PathVariable Long userId) {
        // 根据用户ID查询用户信息
        User user = userService.getUserById(userId);
        if (user != null) {
            return ResponseEntity.ok(user);
        } else {
            return ResponseEntity.notFound().build();
        }
    }
}

POST请求参数解析

POST请求的数据通常放在请求体中,而不是很像GET那样放在URL中。这意味着我们可以发送更大量的数据,数据类型也更加丰富,如文本、JSON、XML、二进制数等

  • HttpServletRequest

可以像GET请求那样使用HttpServletRequest获取POST的请求参数,我们在TestController中新增一个方法,如下所示:

@PostMapping(path = "/testPost")
public String testPost(@RequestBody User user) {
    String name = user.getParameter("name");
    int age = user.getParameter("age");
    return "name=" + name + ", age=" + age;
}
  • HTTP Client

HttpservletRequest.getParameter(string name)方法可以用于获取 HTTP 请求中的参数,但是,这个方法通常用于获取 GET 请求中的参数或者 application/x-www-form-urlencoded 编码的 POST 请求参数。

如果 POST 请求中的参数是JSON 格式的,那么这些参数通常位于请求的 body中, getParameter()方法无法直接获取这些参数。如果非要使用 HttpServletRequest 来获取的话,我们需要读取整个请求体。

@PostMapping(path = "/testPostJson2")
public String testPostJson2(HttpServletRequest request) throws IOException {
    ObjectMapper mapper = new ObjectMapper();
    User user = mapper.readValue(request.getReader(), User.class);
    String name = user.getName();
    int age = user.getAge();
    return "name=" + name + ", age=" + age;
}
  • HttpServletRequestWrapper

https://www.yuque.com/itwanger/az7yww/nr2lu4f17ghm5num#HttpServletRequestWrapper

  • RequestBody

https://www.yuque.com/itwanger/az7yww/nr2lu4f17ghm5num#cd2c4892

  • MultipartFile

https://www.yuque.com/itwanger/az7yww/nr2lu4f17ghm5num#MultipartFile

小结:

在一个 Web 应用中,处理 HTTP 请求参数是非常常见的任务。常见的解析请求参数的方法有:

  • 通过 HttpServletRequest: 可以使用 request.getParameter(name)来获取 GET 或 POST 请求中的参数。

  • 使用@RequestParam 注解来获取请求参数。

如果 URL 中包含路径变量,比如/user/{id},你可以使用@PathVariable注解获取这个路径变量的值。如果请求的 Content-Type 为 application/json,可以使用@RequestBody 注解将请求体中的 JSON 数据自动绑定到一个 Java 对象上。

  • 对于文件上传请求,可以使用 MultipartHttpServletRequest 或 Multipartfile 来获取上传的文件。

并且我们在这个过程中穿插讲了两个非常重要的问题,你现在能答得上来吗?

  • POST 请求的参数为JSON 字符串的时候,HttpServletRequest 能正确获取到参数吗?

  • 如果在切面日志中通过 InputStream 中读取了参数打印,业务控制器中还能正确获得到参数吗?

七. Redis实现排行榜

(这里主要说的是用户活跃度排行榜)

https://www.yuque.com/itwanger/az7yww/mihaylb41qqpl06y

使用Redis的ASET数据结构,因为这个结构本身带有比较好的特性

  • set: 集合确保里面元素的唯一性

  • 权重:这个可以看做我们的score,这样每个元素都有一个score;

  • zset:根据score进行排序的集合

从zset的特性来看,我们每个用户的积分,丢到zset中,就是一个带权重的元素,而且是已经排好序的了,只需要获取元素对应的index,就是我们预期的排名

实现用户活跃度排行榜有以下几个要点:

  1. 更新用户活跃积分算法的实现

    1. 幂等策略

    2. 榜单评分更新

  2. 在提供了活跃度计算方法之后,要什么时候调用呢?

  1. 排行榜显示和查询

    1. 从redis中获取topN的用户+评分

    2. 查询用户的信息

    3. 根据用户评分进行排序,并更新每个用户的排名

八. Redis实现作者白名单

(用于社区特定文章跳过审核,直接上线)

https://www.yuque.com/itwanger/az7yww/bt5qysfplg7nww0l

低级版本:

  1. 配置文件写死白名单内容,但是不优雅,而且在生产环境肯定是不方便的

  2. 设置一个数据表来存放白名单用户,但是实现也差不多,而且性能肯定是不如Redis,因为Redis的数据结构更适合

Redis的set结构:

基于Redis的set来实现:

  1. 判断value是否在set中

  2. 获取set中的所有内容

  3. 往set中添加内容

  4. 移除set中的内容

对于管理员而言的相关业务:

  1. 判断用户是否在白名单中(在白名单中意味着发表文章可以跳过审核)

  2. 获取所有白名单用户

  3. 添加用户到白名单中

  4. 移除白名单中的用户

对于用户的相关业务:

  • 对于非白名单的用户,若操作为发布文章或者更新已上线文章,则需要经过审核

需要清楚Redis中每个数据结构的实用场景,要做到结合实际场景来选型,从而加深知识储备。

九. Redis实现计数统计

(用于用户和文章相关统计信息,如文章数,点赞数等)

https://www.yuque.com/itwanger/az7yww/bqlelic0dgz3qv7k

一般来说,关于查询技术相关信息方案有两种

  • 一是基于db中的操作记录进行实时计数更新

  • 二是基于Redis的incr特性来实现计数器(重点关注这种)

redis计数器,主要是借助原生的incr指令来实现原子的+1/-1,更棒的是不仅redis的string数据结构支持incr,hash、zset数据结构同样也是支持incr的

通常而言,项目初期,或者项目本身非常简单,访问量低,只希望快速上线支撑业务时,使用db进行直接统计即可,优势时是简单,叙述,不容易出问题;缺点则是每次都实时统计性能差,扩展性不强。

当我们项目发展起来之后,借助redis直接存储最终的结果,在展示层直接获取即可,性能更强,满足各位的高并发的遐想,缺点则是数据的一致性保障难度更高。

所以有必要做一个校对/定时同步任务来保证缓存和实际数据中的一致性。

十. SpringBoot集成Redis集群

在项目中如何引入Redis

关于多个Redis的连接配置

  1. 对应的配置类采用Lettuce

  2. 先读取配置,初始化ConnectionFactory,然后创建RedisTemplate实例,设置连接工厂

可能会报多个ConnectionFactory存在的错误,可以用以下两个方法解决

  • 指定默认的ConnectionFactory

  • 忽略默认的自动配置类,只加载一个配置

关于Redis集群

参考文章:https://spring.hhui.top/spring-blog/2019/09/27/190927-SpringBoot%E7%B3%BB%E5%88%97%E6%95%99%E7%A8%8B%E4%B9%8BRedis%E9%9B%86%E7%BE%A4%E7%8E%AF%E5%A2%83%E9%85%8D%E7%BD%AE/

十一. 项目启动自动创建库表并初始化测试数据

有以下两种实现姿势,技术派用的是Liquibase

Liquibase数据库表版本管理

liquibase也是我们现在技术派采用的方案,liquibase可以跟踪数据库的每一次变更,包括表的创建、修改、删除,以及数据的插入、更新和删除;对于开源项目而言,不管其他小伙伴当前使用的是什么版本,当他拉最新代码之后,可以无需关注库表的变更,启动之后自动同步更新,非常方便 。

Liquibase的使用——

  1. 配置依赖

pom.xml 文件中添加

<dependency>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-core</artifactId>
</dependency>

然后就是核心的配置,首先是 application.yml 配置文件中,有两个关键参数


spring:
    liquibase:
        change-log: classpath:liquibase/master.xml
        enabled: true # 当实际使用的数据库不支持liquibase,如 mariadb 时,将这个参数设置为false
  • 对于不支持liquibase的数据库,如mariadb,请将上面的 spring.liquibase.enabled 设置为 false

  • change-log: 对应的是核心的数据库版本变更配置

指定 Liquibase 的主变更日志文件的位置。这里使用的是 classpath: 前缀,表示文件位于类路径下,liquibase/master.xml 是文件的路径。

master.xml 文件中的内容如下


<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
    http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd">

    <include file="liquibase/changelog/000_initial_schema.xml" relativeToChangelogFile="false"/>

</databaseChangeLog>

再看一下 000_initial_schema.xml 文件的内容


<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd">

    <property name="now" value="now()" dbms="mysql"/>
    <property name="autoIncrement" value="true"/>

    <changeSet id="00000000000001" author="YiHui">
        <sqlFile dbms="mysql" endDelimiter=";" encoding="UTF-8" path="liquibase/data/init_schema_221209.sql"/>
    </changeSet>

    <changeSet id="00000000000002" author="YiHui">
        <sqlFile dbms="mysql" endDelimiter=";" encoding="UTF-8" path="liquibase/data/init_data_221209.sql"/>
    </changeSet>
</xml>
  • changeSet 标签,id必须唯一,不能出现冲突

  • sqlFile 里面的path,对应的可以是标准的sql文件,也可以是xml格式的数据库表定义、数据库操作文件

path里一般放的就是项目要创建的库表~

  • 一旦写上去,changeSet的顺序不要调整

项目启动之后,一切正常的话,直接连上数据库可以看到库表创建成功,数据也初始化完成,当然也可以直接观察控制台的输出;注意如果是一个新的项目,接入liquibase之后,数据库还是要自己创建的

●下面红框中的 ChangeSet xxx ran successfully in 401ms 就表示对应的sql执行成功了

还有一个问题:

非常重要的一个点是,上面的每个ChangeSet只会执行一次,因此当执行完毕之后发现不对,要回滚怎么办?或者又需要修改怎么办?

liquibase 提供了回滚的机制,这个放在liquibase的专题中进行说明;现在单独的以修改来说明

当ChangeSet执行完毕之后,对应的sql文件/xml文件(即path定义的文件)不允许再修改,因为db中会记录这个文件的md5,当修改这个文件之后,这个md5也会随之发生改变

  • 因此两个解决方案:新增一个changeSet

  • 删除 DATABASECHANGELOG 表中 changeSet id对应的记录,然后重新走一遍

DataSourceInitializer首次初始化方案

可以在项目首次运行时初始化数据库和表,但缺点是只能在首次初始化时执行;之后要是在代码中添加了数据库表;还是得手动添加才行~

DataSourceInitializer 是 Spring Boot 数据库初始化流程中的一个重要组件,它帮助确保应用的数据库环境在启动时是正确配置和初始化的。通过使用这个类,开发者可以自动化数据库的设置过程,减少手动干预的需要。

  • DatabasePopulator: 通过addScripts来指定对应的sql文件

  • DataSourceInitializer#setEnabled: 判断是否需要执行初始化

库的初始化

我们主要借助DataSourceInitializer来实现Liquibase的表创建、数据变更等操作;但是在此之前,我们还做了一个库的初始化

我们定义一个方法,先判断数据库是否存在,若不存在时,则创建数据库;然后再判断表是否存在,以此来决定是否需要执行初始化方法


/**
     * 检测一下数据库中表是否存在,若存在则不初始化;
     *
     * @param dataSource
     * @return
     */
    private boolean needInit(DataSource dataSource) {
        if (autoInitDatabase()) {
            return true;
        }
        // 根据是否存在表来判断是否需要执行sql操作, 当user_info表已经存在时,表示项目启动过了,不需要再重新初始化
        JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
        List list = jdbcTemplate.queryForList("SELECT table_name FROM information_schema.TABLES where table_name = 'user_info' and table_schema = '" + database + "';");
        return CollectionUtils.isEmpty(list);
    }

    /**
     * 数据库不存在时,尝试创建数据库
     */
    private boolean autoInitDatabase() {
        // 查询失败,可能是数据库不存在,尝试创建数据库之后再次测试
        URI url = URI.create(SpringUtil.getConfig("spring.datasource.url").substring(5));
        String uname = SpringUtil.getConfig("spring.datasource.username");
        String pwd = SpringUtil.getConfig("spring.datasource.password");
        try (Connection connection = DriverManager.getConnection("jdbc:mysql://" + url.getHost() + ":" + url.getPort() +
                "?useUnicode=true&characterEncoding=UTF-8&useSSL=false", uname, pwd);
             Statement statement = connection.createStatement()) {
            ResultSet set = statement.executeQuery("select schema_name from information_schema.schemata where schema_name = '" + database + "'");
            if (!set.next()) {
                // 不存在时,创建数据库
                String createDb = "CREATE DATABASE IF NOT EXISTS " + database;
                connection.setAutoCommit(false);
                statement.execute(createDb);
                connection.commit();
                log.info("创建数据库({})成功", database);
                if (set.isClosed()) {
                    set.close();
                }
                return true;
            }
            set.close();
            log.info("数据库已存在,无需初始化");
            return false;
        } catch (SQLException e2) {
            throw new RuntimeException(e2);
        }
    }

技术派新增了一个DbChangeSetLoader 类来实现初始化sql的加载,实际上借助了Liquibase 的xml文件来解析来加载对应的数据库表变更历史sql

对于liquibase的xml文件解析,核心逻辑在 DbChangeSetLoader 中,借助sax来进行xml文件的解析(Spring也是用sax解析xml的)


十二. 消息队列RabbitMQ

十三. 策略模式的运用——AI聊天的选择与封装

策略模式(Strategy Pattern)是一种行为型设计模式,它定义了一系列的算法,将每个算法封装起来,使得它们可以相互替换。策略模式让算法的变化独立于使用它的客户端。

在策略模式中,有三个角色:上下文(Context)、策略(Strategy)和具体策略(ConcreteStrategy)。

  1. 上下文(Context)类持有一个策略对象,并在需要执行算法时调用策略对象的方法。上下文类不关心具体的算法实现,只关心调用合适的策略对象。

  2. 策略(Strategy)接口定义了算法的方法,具体的策略类实现了该接口,提供了不同的算法实现。

  3. 具体策略(ConcreteStrategy)类是策略的具体实现,封装了不同的算法实现。

技术派中相似的技术实现点——主要体现在AI的接入选择上:

  1. 设计一个通用的 ChatService接口,定义聊天相关的方法

  2. 具体策略实现:

    1. ChatGptAiServiceImpl: chatgpt的聊天实现

    2. XunFeiAiServiceImpl: 讯飞星火大模型的聊天实现

    3. PaiAiDemoServiceImpl:技术派价值1个亿的聊天实现

  3. 选择策略的上下文:ChatServiceFactory

主要借助了Spring的bean List注入方式,一次拿到上面的这些策略实现,然后保存到一个Map中,然后根据 AiSourceEnum 来进行选择具体的策略。

十四. 事务使用实操

事务:一组操作要么成功,要么失败。主要使用场景:发布文章

声明式事务方式

@Transactional 注解修饰方法的方式,也就是我们说的声明式事务方式

通常使用姿势为:

  1. 修饰位置

    1. 在方法上添加注解

    2. 在类上添加注解:表示这个类的所有公共方法,都支持事务

  2. 注解内属性

name

当在配置文件中有多个 TransactionManager , 可以用该属性指定选择哪个事务管理器。

propagation

事务的传播行为,默认值为 REQUIRED。

isolation

事务的隔离度,默认值采用 DEFAULT。

timeout

事务的超时时间,默认值为-1。如果超过该时间限制但事务还没有完成,则自动回滚事务。

read-only

指定事务是否为只读事务,默认值为 false;为了忽略那些不需要事务的方法,比如读取数据,可以设置 read-only 为 true。

rollback-for

用于指定能够触发事务回滚的异常类型,如果有多个异常类型需要指定,各类型之间可以通过逗号分隔。

no-rollback- for

抛出 no-rollback-for 指定的异常类型,不回滚事务。

不指定具体的异常时,默认只有运行时异常才会触发事务回滚


/**
 * 保存文章,当articleId存在时,表示更新记录; 不存在时,表示插入
 *
 * @param req
 * @return
 */  
@Transactional
@Override
public Long saveArticle(ArticlePostReq req, Long author) throws Exception {
    ArticleDO article = ArticleConverter.toArticleDo(req, author);
    String content = imageService.mdImgReplace(req.getContent());
    Long ans;
    if (NumUtil.nullOrZero(req.getArticleId())) {
        ans = insertArticle(article, content, req.getTagIds());
    } else {
        ans = updateArticle(article, content, req.getTagIds());
    }
    throw new Exception("声明异常");
}

编程式事务方式

因为声明式的最小颗粒度是方法,但有可能会出现一个方法内并非所有东西都应该放入事务,比如从外网图片下载并上传到oss,通常这个比较耗时,完全不需要加入事务中

这时候就需要用到编程式事务,最小颗粒度为代码块;可以更加随心所欲

首先要进行依赖注入TransactionTemplate

@Autowired
private TransactionTemplate transactionTemplate;

编程式下的代码——

使用 Spring 框架的 TransactionTemplate 来处理事务,而不是使用 @Transactional 注解

@Override
public Long saveArticle(ArticlePostReq req, Long author) {
    ArticleDO article = ArticleConverter.toArticleDo(req, author);
    String content = imageService.mdImgReplace(req.getContent());
    return transactionTemplate.execute(new TransactionCallback<Long>() {
        @Override
        public Long doInTransaction(TransactionStatus status) {
            if (NumUtil.nullOrZero(req.getArticleId())) {
                return insertArticle(article, content, req.getTagIds());
            } else {
                return updateArticle(article, content, req.getTagIds());
            }
        }
    });
}

不难看出:

  • 声明式:基于AOP方式实现,代码更简洁、使用更方便,理解更容易

  • 编程式:非常灵活,完全由用户自己控制,可以最小粒度的控制事务范围

关于事务的使用场景

首先确定事务的使用场景,什么时候要用事务,不要瞎用,就比如非常简单的只读场景,那么什么场景需要用呢?

  • 当一个基础的业务单元(如对外提供的http接口,service方法),存在>=2的数据变更时,就需要考虑,部分更行成功,部分更新失败是否会出现脏数据,是否会有影响,

    • 若是:则可以考虑引入事务

    • 若否:不建议加入事务

  • 只有一个数据变更,但是后续有强依赖这个数据的逻辑,也需要加入事务

    • 比如接到支付成功消息,我要做一个校对的后台任务

    • 我的业务逻辑是: 先调用第三方校验是否属实,然后更新本地状态为成功,最后MQ方式通知订单状态变更

    • 此时我们需要将第二步数据变更与第三步消息通知放在事务中,只要通知失败,我们还是不更新本地状态,避免出现我们校验成功了,但是订单还是未支付

但是事务的使用要注意以下几点

避免大事务,只在需要的地方加事务

  • 注意分布式事务场景

  • 所有的操作,走索引,避免出现锁表

  • 减少范围、大批量的数据处理场景

  • 如果业务支撑,可以降低隔离级别,一是减少隔离级别带来的附加成本,二则是可以避免某些场景下的死锁

十五. MySQL和Redis的缓存一致性

不好的方案:

  • 先写MySQL,再写Redis

假如前面的请求写入Redis慢了,就会使Redis数据出错

  • 先写Redis,再写MySQL

同理,有可能会出现先请求的却后面才写入

  • 先删除Redis,再写MySQL

这幅图和上面有些不一样,前面的请求 A 和 B 都是更新请求,这里的请求 A 是更新请求,但是请求 B 是读请求,且请求 B 的读请求会回写 Redis

这里可能会出现先发的请求比后发的查询请求跑得慢,所以获取不到先发请求写入的MySQL;而且发生的可能性很大

好的方案:

  • 先删除Redis,再写MySQL,过段时间再删MySQL(延时双删)

这里的重点是要保证第二次删除Redis缓存的操作要在查询回写缓存之后; 如何保证——

  1. 暴力的方法:让第二次删除缓存较于第一次删缓存有一定时间的延时;但是这样风险还是不可控的

  2. 异步串行化删除——也就是把删除缓存放进消息队列

  • 先写MySQL,再删除Redis(技术派所用方法

对于第一次查询,请求 B 查询的数据是 10,但是 MySQL 的数据是 11,只存在这一次不一致的情况,对于不是强一致性要求的业务,可以容忍。(那什么情况下不能容忍呢,比如秒杀业务、库存服务等。)

  • 先写 MySQL,通过 Binlog,异步更新 Redis

这种方案,主要是监听 MySQL 的 Binlog,然后通过异步的方式,将数据更新到 Redis,这种方案有个前提,查询的请求,不会回写 Redis。

这个方案,会保证 MySQL 和 Redis 的最终一致性,但是如果中途请求 B 需要查询数据,如果缓存无数据,就直接查 DB;如果缓存有数据,查询的数据也会存在不一致的情况。 所以这个方案,是实现最终一致性的终极解决方案,但是不能保证实时性

总结——

  • 实时一致性方案:采用“先写 MySQL,再删除 Redis”的策略,这种情况虽然也会存在两者不一致,但是需要满足的条件有点苛刻,所以是满足实时性条件下,能尽量满足一致性的最优解。

  • 最终一致性方案:采用“先写 MySQL,通过 Binlog,异步更新 Redis”,可以通过 Binlog,结合消息队列异步更新 Redis,是最终一致性的最优解。


Comment