Chiriri's blog Chiriri's blog
首页
  • Java

    • JavaSE
    • JavaEE
    • 设计模式
  • Python

    • Python
    • Python模块
    • 机器学习
  • Golang

    • Golang
    • gRPC
  • 服务器

    • Linux
    • MySQL
    • NoSQL
    • Kubernetes
  • 项目

    • 传智健康
    • 畅购商城
  • Hadoop生态

    • Hadoop
    • Zookeeper
    • Hive
    • Flume
    • Kafka
    • Azkaban
    • Hbase
    • Scala
    • Spark
    • Flink
  • 大数据项目

    • 离线数仓
  • 青训营

    • 第四届青训营
  • HTML

    • HTML
    • JavaScript
  • Vue

    • Vue2
    • TypeScript
    • Vue3
    • Uni-APP
  • 数据结构与算法
  • C语言
  • 考研数据结构
  • 计算机组成原理
  • 计算机操作系统
  • Java基础

    • Java基础
    • Java集合
    • JUC
    • JVM
  • 框架

    • Spring
    • Dubbo
    • Spring Cloud
  • 数据库

    • MySQL
    • Redis
    • Elasticesearch
  • 消息队列

    • RabbitMQ
    • RocketMQ
  • 408

    • 计算机网络
    • 操作系统
    • 算法
  • 分类
  • 标签
  • 归档
  • 导航站
GitHub (opens new window)

Iekr

苦逼后端开发
首页
  • Java

    • JavaSE
    • JavaEE
    • 设计模式
  • Python

    • Python
    • Python模块
    • 机器学习
  • Golang

    • Golang
    • gRPC
  • 服务器

    • Linux
    • MySQL
    • NoSQL
    • Kubernetes
  • 项目

    • 传智健康
    • 畅购商城
  • Hadoop生态

    • Hadoop
    • Zookeeper
    • Hive
    • Flume
    • Kafka
    • Azkaban
    • Hbase
    • Scala
    • Spark
    • Flink
  • 大数据项目

    • 离线数仓
  • 青训营

    • 第四届青训营
  • HTML

    • HTML
    • JavaScript
  • Vue

    • Vue2
    • TypeScript
    • Vue3
    • Uni-APP
  • 数据结构与算法
  • C语言
  • 考研数据结构
  • 计算机组成原理
  • 计算机操作系统
  • Java基础

    • Java基础
    • Java集合
    • JUC
    • JVM
  • 框架

    • Spring
    • Dubbo
    • Spring Cloud
  • 数据库

    • MySQL
    • Redis
    • Elasticesearch
  • 消息队列

    • RabbitMQ
    • RocketMQ
  • 408

    • 计算机网络
    • 操作系统
    • 算法
  • 分类
  • 标签
  • 归档
  • 导航站
GitHub (opens new window)
  • JavaSE

  • JavaEE

  • Linux

  • MySQL

  • NoSQL

  • Python

  • Python模块

  • 机器学习

  • 设计模式

  • 传智健康

  • 畅购商城

  • 博客项目

  • JVM

  • JUC

  • Golang

  • Kubernetes

  • 硅谷课堂

  • C

  • 源码

  • 神领物流

  • RocketMQ

  • 短链平台

    • 平台业务介绍与搭建
    • 账号微服务注册、压测与架构核心技术
      • 注册功能和流程介绍
        • 第三⽅短信验证码平台接⼊
        • 短信验证码发送⼯具类
        • 单元测试
      • 架构核⼼技术-池化思想-异步结合 性能优化最佳实践
        • 接⼝压测和常⽤压⼒测试⼯具对⽐
        • Jmeter
        • 菜单栏主要组件
        • 短信发送压测实践
        • @Async组件
        • @Async失效情况
        • 异步调⽤-压测⾼QPS后的背后原因和问题拆解
        • @Async+ThreadPoolTaskExecutor⾃定义线程池
        • ThreadPoolTaskExecutor线程池的⾯试题
        • 线程池多参数调整-性能压测+现象对⽐分析
        • RestTemplate⾥⾯的存在的问题
        • Broken pipe错误
        • 重新认识RestTemplate
        • RestTemplate连接池封装
        • 封装配置
        • 压测对比
        • 短信验证码组件性能优化实战
        • 图形验证码开发
        • Redis6.X配置连接池实战
        • 加⼊缓存+Try-with-resource知识巩固
      • 验证码接口
        • 短信验证码防刷
      • 阿⾥云OSS接⼊实战
        • 分布式⽂件存储常⻅解决⽅案介绍
        • 阿⾥云OSS对象存储介绍和开通
        • RBAC-ACL模式应⽤之阿⾥云RAM访问控制
        • OSS对象存储客户端集成和测试服务
      • 账号微服务注册-登录功能
        • 注册功能业务介绍和代码
        • 注册⼿机号唯⼀性安全保证⽅案
        • 登录模块逻辑和解密
        • 分布式应⽤的登录检验解决⽅案 JWT
        • 登录拦截器开发和⽤户信息传递
        • ThreadLocal
        • ThreadLocal底层源码+原理
        • ThreadLocal常见核心面试题
        • 登录拦截器InterceptorConfig拦截和放⾏路径
    • 海量数据下的分库分表
  • 后端
  • 短链平台
Iekr
2024-02-03
目录

账号微服务注册、压测与架构核心技术

# 账号微服务注册、压测与架构核心技术

# 注册功能和流程介绍

功能需求

  • 使⽤⼿机号注册,已经注册的⼿机号不能︎重复注册,密码不能使⽤简单的 MD5 加密
  • ⽤户上传头像需要⽤⽂件存储

安全需求

  • ⾼并发下账号唯⼀性 注册邮箱或者⼿机验证码不能被恶意调⽤,验证码 + 唯⼀索引
  • 头像⽂件存储访问⽅便扩容和管理,阿⾥云 OSS
  • 针对每个功能,初级开发和⾼级开发的思路是不⼀样
  • 产品经理提业务需求 安全需求就是⾃⼰的经验,不然最终背锅的还是⾃⼰
  • ⾼并发处理,异步 + 池化思想

短链平台选择

  • 使⽤短信验证码注册
  • 头像存储使⽤阿⾥云 OSS

# 第三⽅短信验证码平台接⼊

短信验证码平台选择考虑点

  • 各个类型短信价格
  • 短信到达率、到达时间
  • 短信内容变量灵活,⽅便⽀持多场景
  • ⽀持多种推⼴内容的短信发放,例如业务推⼴、新产品宣讲、会员关怀等内容的短信
  • 多维度数据统计 - 查看请求量、发送成功量、失败量等

各个短信平台

  • 阿⾥云:https://www.aliyun.com/product/sms
  • 腾讯云:https://cloud.tencent.com/product/sms
  • 第三⽅⼚商:https://market.aliyun.com/products/57000002/cmapi00046920.html

我们选择接入阿里云市场中的一些第三方产品

阿⾥云市场:https://market.console.aliyun.com/imageconsole/index.htm

# 短信验证码发送⼯具类

dlcloud-common 模块下 net.xdclass.config.RestTemplate 配置

@Configuration
public class RestTemplateConfig {
    @Bean
    public RestTemplate restTemplate(ClientHttpRequestFactory factory) {
        return new RestTemplate(factory);
    }

    @Bean
    public ClientHttpRequestFactory simpleClientHttpRequestFactory() {
        SimpleClientHttpRequestFactory factory = new
                SimpleClientHttpRequestFactory();
        factory.setReadTimeout(10000);
        factory.setConnectTimeout(10000);
        return factory;
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

dlcloud-account 模块下添加到 application.yaml

#----------sms短信配置--------------
sms:
  app-code: 6999d4df3e7d48028470bbe517169a8d
  template-id: M72CB42894
1
2
3
4

net.xdclass.config.SmsConfig 配置

@ConfigurationProperties(prefix = "sms")
@Configuration
@Data
public class SmsConfig {

    private String templateId;

    private String appCode;

}
1
2
3
4
5
6
7
8
9
10

net.xdclass.component.SmsComponent

package net.xdclass.component;

import lombok.extern.slf4j.Slf4j;
import net.xdclass.config.SmsConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

/**
 * @author iekrw
 * Date:  2024/2/3/003 11:20
 */
@Component
@Slf4j
public class SmsComponent {

    /**
     * 发送地址
     */
    private static final String URL_TEMPLATE = "https://jmsms.market.alicloudapi.com/sms/send?mobile=%s&templateId=%s&value=%s";

    @Autowired
    private RestTemplate restTemplate;

    @Autowired
    private SmsConfig smsConfig;


    /**
     * 发送短信验证码
     * @param to
     * @param templateId
     * @param value
     */
    public void send(String to,String templateId,String value){

        String url = String.format(URL_TEMPLATE,to,templateId,value);
        HttpHeaders headers = new HttpHeaders();
        //最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105
        headers.set("Authorization","APPCODE "+smsConfig.getAppCode());
        HttpEntity entity = new HttpEntity<>(headers);
        ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
        log.info("url={},body={}",url,response.getBody());
        if(response.getStatusCode().is2xxSuccessful()){
            log.info("发送短信验证码成功");
        }else {
            log.error("发送短信验证码失败:{}",response.getBody());
        }

    }



}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58

# 单元测试

net.xdclass.db.net.xdclass.biz

@RunWith(SpringRunner.class)
@SpringBootTest
@Slf4j
public class SmsTest {

    @Autowired
    private SmsComponent smsComponent;

    @Autowired
    private SmsConfig smsConfig;

    @Test
    public void testSendSms() {
        smsComponent.send("1377777777", smsConfig.getTemplateId(), "123456");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 架构核⼼技术 - 池化思想 - 异步结合 性能优化最佳实践

# 接⼝压测和常⽤压⼒测试⼯具对⽐

  • LoadRunner:性能稳定,压测结果及细粒度⼤,可以⾃定义脚本进⾏压测,但是太过于重⼤,功能⽐较繁多

  • Apache AB (单接⼝压测最⽅便):模拟多线程并发请求,ab 命令对发出负载的计算机要求很低,既不会占⽤很多 CPU,也不会占⽤太多的内存,但却会给⽬标服务器造成巨⼤的负载,简单 DDOS 攻击等

  • Webbench:webbench ⾸先 fork 出多个⼦进程,每个⼦进程都循环做 web 访问测试。⼦进程把访问的结果通过 pipe 告诉⽗进程,⽗进程做最终的统计结果。

  • Jmeter (GUI):开源免费,功能强⼤,在互联⽹公司普遍使⽤

    • 压测不同的协议和应⽤:

      1. Web - HTTP, HTTPS (Java, NodeJS, PHP, ASP.NET, …)
      2. SOAP / REST Webservices
      3. FTP
      4. Database via JDBC
      5. LDAP 轻量⽬录访问协议
      6. Message-oriented middleware (MOM) via JMS
      7. Mail - SMTP(S), POP3(S) and IMAP(S)
      8. TCP 等等
    • 使⽤场景及优点:

      1. 功能测试

      2. 压⼒测试

      3. 分布式压⼒测试

      4. 纯 java 开发

      5. 上⼿容易,⾼性能

      6. 提供测试数据分析

      7. 各种报表数据图形展示

# Jmeter

需要安装 JDK8 以上,建议安装 JDK 环境,虽然 JRE 也可以,但是压测 https 需要 JDK ⾥⾯的 keytool ⼯具 快速下载 https://jmeter.apache.org/download_jmeter.cgi ⽂档地址:http://jmeter.apache.org/usermanual/get-started.html

目录结构:

  • bin: 核⼼可执⾏⽂件,包含配置
    • jmeter.bat: windows 启动⽂件 (window 系统⼀定要配置显示⽂件拓展名)
    • jmeter: mac 或者 linux 启动⽂件
    • jmeter-server:mac 或者 Liunx 分布式压测使⽤的启动⽂件
    • jmeter-server.bat:window 分布式压测使⽤的启动⽂件
    • jmeter.properties: 核⼼配置⽂件
  • extras:插件拓展的包
  • lib: 核⼼的依赖包

Jmeter 语⾔版本中英⽂切换:

  • 控制台修改 menu -> options -> choose language
  • 配置⽂件修改
    • bin ⽬录 -> jmeter.properties
    • 默认 #language=en,改为 language=zh_CN

# 菜单栏主要组件

添加 ->threads-> 线程组(控制总体并发)

image-20240203115318504

  • 线程数:虚拟⽤户数。⼀个虚拟⽤户占⽤⼀个进程或线程
  • 准备时⻓(Ramp-Up Period (in seconds)):全部线程启动的时⻓,⽐如 100 个线程,20 秒,则表示 20 秒内 100 个线程都要启动完成,每秒启动 5 个线程
  • 循环次数:每个线程发送的次数,假如值为 5,100 个线程,则会发送 500 次请求,可以勾选永远循环

线程组 -> 添加 -> Sampler (采样器) -> Http (⼀个线程组下⾯可以增加⼏个 Sampler)

image-20240203115337221

  • 名称:采样器名称
  • 注释:对这个采样器的描述
  • web 服务器:
    • 默认协议是 http
    • 默认端⼝是 80
    • 服务器名称或 IP :请求的⽬标服务器名称或 IP 地址
  • 路径:服务器 URL

查看测试结果

  • 线程组 -> 添加 -> 监听器 -> 察看结果树
  • 线程组 -> 添加 -> 监听器 -> 聚合报告

常规压测流程

  • 内⽹环境
  • ⾮ GUI 下压测
  • 停⽌其他⽆关资源进程
  • 压测机和被压测机器隔离

# 短信发送压测实践

由于我们短信发送是需要费用,进行压测可能会产生大量的费用,我们使用埋点发送多次请求,来获取该接口大概耗时,此处只是进行模拟。在短信发送前和发送后记录请求耗时,然后通过线程睡眠模拟该接口耗时。

SmsComponent

    public void send(String to, String templateId, String value) {

        long beginTime = CommonUtil.getCurrentTimestamp();

        String url = String.format(URL_TEMPLATE, to, templateId, value);
        HttpHeaders headers = new HttpHeaders();
        //最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105
        headers.set("Authorization", "APPCODE " + smsConfig.getAppCode());
        HttpEntity entity = new HttpEntity<>(headers);
        ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);

        long endTime = CommonUtil.getCurrentTimestamp();

        log.info("耗时={},url={},body={}", endTime - beginTime, url, response.getBody());
        if (response.getStatusCode().is2xxSuccessful()) {
            log.info("发送短信验证码成功");
        } else {
            log.error("发送短信验证码失败:{}", response.getBody());
        }

    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

SmsTest

    @Test
    public void testSendSms() {
        for (int i = 0; i < 3; i++) {
            smsComponent.send("1377777777", smsConfig.getTemplateId(), "123456");
            
        }
    }
1
2
3
4
5
6
7

image-20240203120727455

通过埋点 http 请求得出请求响应耗时【粗略统计,⾮线上⼤量数据测试得出】

NotifyController

@RestController
@RequestMapping("api/notify/v1")
public class NotifyController {

    @Autowired
    private NotifyService notifyService;

    /**
     * 测试发送验证码接口,用于压测
     *
     * @return
     */
    @RequestMapping("send_code")
    public JsonData senCode() {
        notifyService.testSend();
        return new JsonData().buildSuccess();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

NotifyService

public interface NotifyService {

    /**
     * 测试方法
     */
    void testSend();
}
1
2
3
4
5
6
7

NotifyServiceImpl

@Service
@Slf4j
public class NotifyServiceImpl implements NotifyService {

    @Autowired
    private RestTemplate restTemplate;
    @Override
    public void testSend() {
        // 使用睡眠模拟耗时
//        try {
//            TimeUnit.MILLISECONDS.sleep(200);
//        } catch (InterruptedException e) {
//            e.printStackTrace();
//        }

        // 发起另外的链接进行测试
        ResponseEntity<String> forEntity = restTemplate.getForEntity("http://old.xdclass.net", String.class);
        String body = forEntity.getBody();
        log.info("body={}", body);

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

压测参数配置

  • 200 并发
  • 2 秒启动
  • 循环 500 次

image-20240203152614860

同步发送 + resttemplate 未池化

  • 错误:Connection timed out
  • 400 到 500 qps

# @Async 组件

⾼并发下异步请求解决⽅案⼀

由于发送短信涉及到⽹络通信,因此 sendMessage ⽅法可能会有⼀些延迟。为了改善⽤户体验,我们可以使⽤异步发送短信的⽅法

什么是异步任务

  • 异步调⽤是相对于同步调⽤⽽⾔的,同步调⽤是指程序按预定顺序⼀步步执⾏,每⼀步必须等到上⼀步执⾏完后才能执⾏,异步调⽤则⽆需等待上⼀步程序执⾏完即可执⾏
  • 多线程就是⼀种实现异步调⽤的⽅式
  • MQ 也是⼀种宏观上的异步

使⽤场景

  • 适⽤于处理 log、发送邮件、短信…… 等
  • 涉及到⽹络 IO 调⽤等操作

使⽤⽅式

  • 启动类⾥⾯使⽤ @EnableAsync 注解开启功能,⾃动扫描
  • 定义异步任务类并使⽤ @Component 标记组件被容器扫描,异步⽅法加上 @Async
@MapperScan("net.xdclass.mapper")
@EnableTransactionManagement
@EnableFeignClients
@EnableDiscoveryClient
@EnableAsync
@SpringBootApplication
public class AccountApplication {
    public static void main(String[] args) {
        SpringApplication.run(AccountApplication.class, args);
    }
}
1
2
3
4
5
6
7
8
9
10
11
    @Override
    @Async
    public void testSend() {
        // 使用睡眠模拟耗时
//        try {
//            TimeUnit.MILLISECONDS.sleep(200);
//        } catch (InterruptedException e) {
//            e.printStackTrace();
//        }

        // 发起另外的链接进行测试
        ResponseEntity<String> forEntity = restTemplate.getForEntity("http://old.xdclass.net", String.class);
        String body = forEntity.getBody();
        log.info("body={}", body);

    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# @Async 失效情况

  • 注解 @Async 的⽅法不是 public ⽅法
  • 注解 @Async 的返回值只能为 void 或者 Future
  • 注解 @Async ⽅法使⽤ static 修饰也会失效
  • spring ⽆法扫描到异步类,没加注解 @Async 或 @EnableAsync 注解
  • 调⽤⽅与被调⽅不能在同⼀个类
    • Spring 在扫描 bean 的时候会扫描⽅法上是否包含 @Async 注解,动态地⽣成⼀个⼦类(即 proxy 代理类),当这个有注解的⽅法被调⽤的时候,实际上是由代理类来调⽤的,代理类在调⽤时增加异步作⽤
    • 如果这个有注解的⽅法是被同⼀个类中的其他⽅法调⽤的,那么该⽅法的调⽤并没有通过代理类,⽽是直接通过原来的那个 bean,所以就失效了
    • 所以调⽤⽅与被调⽅不能在同⼀个类,主要是使⽤了动态代理,同⼀个类的时候直接调⽤,不是通过⽣成的动态代理类调⽤
    • ⼀般将要异步执⾏的⽅法单独抽取成⼀个类
  • 类中需要使⽤ @Autowired 或 @Resource 等注解⾃动注⼊,不能⾃⼰⼿动 new 对象
  • 在 Async ⽅法上标注 @Transactional 是没⽤的,但在 Async ⽅法调⽤的⽅法上标注 @Transactional 是有效的,即需要在调用方上添加事务注解,反之在被调用方上添加事务是不会生效的

# 异步调⽤ - 压测⾼ QPS 后的背后原因和问题拆解

使用 @Async 异步调用压测后很快跑完全部内容,是因为都在线程池内部的阻塞队列⾥⾯

  • 但极容易出现 OOM,或者消息丢失

  • 默认 8 个核⼼线程数占⽤满了之后,新的调⽤就会进⼊队列,最⼤值是 Integer.MAX_VALUE,表现为没有执⾏

    • task-XXX ⽇志⾥⾯会出现递增

我们设置下 idea 启动进程的 jvm 参数: -Xms50M -Xmx50M ,设置最小和最大堆内存模拟出现 OOM 情况

image-20240203154450153

Spring 默认线程池代码位置

  • TaskExecutionProperties
  • TaskExecutionAutoConfiguration

说明:

  • 直接使⽤ @Async 注解没指定线程池的话,即未设置 TaskExecutor 时
  • 默认使⽤ Spring 创建 ThreadPoolTaskExecutor
  • 核⼼线程数:8
  • 最⼤线程数:Integer.MAX_VALUE (21 亿多)
  • 队列使⽤ LinkedBlockingQueue
  • 容量是:Integer.MAX_VALUE
  • 空闲线程保留时间:60s
  • 线程池拒绝策略:AbortPolicy

# @Async+ThreadPoolTaskExecutor ⾃定义线程池

⾃定义线程池可以解决上述的问题

⼤家的疑惑 使⽤线程池的时候搞混淆 ThreadPoolTaskExecutor 和 ThreadPoolExecutor

  • ThreadPoolExecutor:这个类是 JDK 中的线程池类,继承⾃ Executor,⾥⾯有⼀个 execute () ⽅法,⽤来执⾏线程,线程池主要提供⼀个线程队列,队列中保存着所有等待状态的线程,避免了创建与销毁的额外开销
  • ThreadPoolTaskExecutor,是 spring 包下的,是 Spring 为我们提供的线程池类
    • Spring 异步线程池的接⼝类是 TaskExecutor,本质还是 java.util.concurrent.Executor

解决⽅式

  • spring 会先搜索 TaskExecutor 类型的 bean 或者名字为 taskExecutor 的 Executor 类型的 bean
  • 所以我们最好来⾃定义⼀个线程池,加⼊ Spring IOC 容器⾥⾯,即可覆盖

net.xdclass.config.ThreadPollTaskConfig

@Configuration
@EnableAsync // 如果config类中开启async了 启动类中的开启async注解可以删除 没必要多次开启
public class ThreadPollTaskConfig {

    @Bean("threadPoolTaskExecutor")
    public ThreadPoolTaskExecutor threadPoolTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        //线程池创建的核⼼线程数,线程池维护线程的最少数量,即使没有任务需要执⾏,也会⼀直存活
        executor.setCorePoolSize(16);

        //如果设置allowCoreThreadTimeout=true(默认false)时,核⼼线程会超时关闭
        // executor.setAllowCoreThreadTimeOut(true);

        //缓存队列(阻塞队列)当核⼼线程数达到最⼤时,新任务会放在队列中排队等待执⾏
        executor.setQueueCapacity(1024);

        //最⼤线程池数量,当线程数>=corePoolSize,且任务队列已满时。线程池会创建新线程来处理任务
        //当线程数=maxPoolSize,且任务队列已满时,线程池会拒绝处理任务⽽抛出异常
        executor.setMaxPoolSize(64);

        //当线程空闲时间达到keepAliveTime时,线程会退出,直到线程数量=corePoolSize
        //允许线程空闲时间60秒,当maxPoolSize的线程在空闲时间到达的时候销毁
        //如果allowCoreThreadTimeout=true,则会直到线程数量 = 0
        executor.setKeepAliveSeconds(30);

        //spring 提供的 ThreadPoolTaskExecutor 线程池,是有setThreadNamePrefix() ⽅法的。
        //jdk 提供的ThreadPoolExecutor 线程池是没有setThreadNamePrefix() ⽅法的
        executor.setThreadNamePrefix("自定义线程池");

        // rejection-policy:当pool已经达到max size的时候,如何处理新任务
        // CallerRunsPolicy():交由调⽤⽅线程运⾏,⽐如main 线程;如果添加到线程池失败,那么主线程会⾃⼰去执⾏该任务,不会等待线程池中的线程去执⾏
        //AbortPolicy():该策略是线程池的默认策略,如果线程池队列满了丢掉这个任务并且抛出RejectedExecutionException异常。
        //DiscardPolicy():如果线程池队列满了,会直接丢掉这个任务并且不会有任何异常
        //DiscardOldestPolicy():丢弃队列中最⽼的任务,队列满了,会将最早进⼊队列的任务删掉腾出空间,再尝试加⼊队列
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());


        executor.initialize();
        return executor;

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

调用方使用自定义线程池

    @Override
    @Async("threadPoolTaskExecutor")
    public void testSend() {
        // 使用睡眠模拟耗时
//        try {
//            TimeUnit.MILLISECONDS.sleep(200);
//        } catch (InterruptedException e) {
//            e.printStackTrace();
//        }

        // 发起另外的链接进行测试
        ResponseEntity<String> forEntity = restTemplate.getForEntity("http://old.xdclass.net", String.class);
        String body = forEntity.getBody();
        log.info("body={}", body);

    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

总结:先是 CorePoolSize 是否满⾜,然后是 Queue 阻塞队列是否满,最后才是 MaxPoolSize 是否满⾜

# ThreadPoolTaskExecutor 线程池的⾯试题

  • 请你说下 ThreadPoolTaskExecutor 线程池 有哪⼏个重要参数,什么时候会创建线程
    • 查看核⼼线程池是否已满,不满就创建⼀条线程执⾏任务,否则执⾏第⼆步。
    • 查看阻塞队列是否已满,不满就将任务存储在阻塞队列中,否则执⾏第三步。
    • 查看线程池是否已满,即是否达到最⼤线程池数,不满就创建⼀条线程执⾏任务,否则就按照策略处理⽆法执⾏的任务。
  • ⾼并发下核⼼线程怎么设置?
    • 分 IO 密集还是 CPU 密集
      • CPU 密集设置为跟核⼼数⼀样⼤⼩
      • IO 密集型设置为 2 倍 CPU 核⼼数
    • ⾮固定,根据实际情况压测进⾏调整,俗称【调参程序员】【调参算法⼯程师】

# 线程池多参数调整 - 性能压测 + 现象对⽐分析

异步发送 + resttemplate 未池化

线程池参数

threadPoolTaskExecutor.setCorePoolSize(4);
threadPoolTaskExecutor.setMaxPoolSize(16);
threadPoolTaskExecutor.setQueueCapacity(32);
1
2
3

qps 少,等待队列⼩


异步发送 + resttemplate 未池化

线程池参数

threadPoolTaskExecutor.setCorePoolSize(32);
threadPoolTaskExecutor.setMaxPoolSize(64);
threadPoolTaskExecutor.setQueueCapacity(10000);
//如果等待队列⻓度为10万,则qps瞬间很⾼8k+,可能oom
1
2
3
4

qps,等待队列⼤ (瞬间⾼)


问题

  • 采⽤异步发送⽤户体验变好了,但是存在丢失的可能,阻塞队列存储内存中,如果队列⻓度过多则重启容易出现丢失数据情况
  • 采⽤了异步发送了 + 阻塞队列存缓冲,刚开始瞬间 QPS ⾼,但是后续也降低很多
  • 问题是在哪⾥?消费⽅⻆度,提⾼消费能⼒

# RestTemplate ⾥⾯的存在的问题

# Broken pipe 错误

还原代码(暂时不⽤异步)异步 - ⾥⾯是⽤线程池 - 是池化思想的⼀种应⽤

同步发送 + resttemplate 未池化

  • 压测结果 ⼏百吞吐量
  • 错误 Caused by: java.io.IOException: Broken pipe,服务端向前端 socket 连接管道写返回数据时 链接(pipe)却断开了
    • 从应⽤⻆度分析,这是因为客户端等待返回超时了,主动断开了与服务端链接
    • 连接数设置太⼩,并发量增加后,造成⼤量请求排队等待
    • ⽹络延迟,是否有丢包
    • 内存是否⾜够多⽀持对应的并发量

resttemplate 底层是怎样的?

基于之前的认知 - 池化思想,联想到是否使⽤了 http 连接池?

# 重新认识 RestTemplate

  • RestTemplate 是 Spring 提供的⽤于访问 Rest 服务的客户端
  • 底层通过使⽤ java.net 包下的实现创建 HTTP 请求
  • 通过使⽤ ClientHttpRequestFactory 指定不同的 HTTP 请求⽅式,主要提供了两种实现⽅式
    • SimpleClientHttpRequestFactory(默认)
      • 底层使⽤ J2SE 提供的⽅式,既 java.net 包提供的⽅式,创建底层的 Http 请求连接
      • 主要 createRequest ⽅法( 断点调试),每次都会创建⼀个新的连接,每次都创建连接会造成极⼤的资源浪费,⽽且若连接不能及时释放,会因为⽆法建⽴新的连接导致后⾯的请求阻塞
    • HttpComponentsClientHttpRequestFactory
      • 底层使⽤ HttpClient 访问远程的 Http 服务

问题解决

  • 客户端每次请求都要和服务端建⽴新的连接,即三次握⼿将会⾮常耗时
  • 通过 http 连接池可以减少连接建⽴与释放的时间,提升 http 请求的性能
  • Spring 的 restTemplate 是对 httpclient 进⾏了封装,⽽ httpclient 是⽀持池化机制
  • 拓展
    • 对 httpclient 进⾏封装的有:Apache 的 Fluent、es 的 restHighLevelClient、spring 的 restTemplate 等

# RestTemplate 连接池封装

# 封装配置

RestTemplateConfig

//    @Bean
//    public ClientHttpRequestFactory simpleClientHttpRequestFactory() {
//        SimpleClientHttpRequestFactory factory = new
//                SimpleClientHttpRequestFactory();
//        factory.setReadTimeout(10000);
//        factory.setConnectTimeout(10000);
//        return factory;
//    }

    @Bean
    public ClientHttpRequestFactory httpRequestFactory() {
        return new HttpComponentsClientHttpRequestFactory(httpClient());
    }

    @Bean
    public HttpClient httpClient() {
        Registry<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory>create()
                .register("http", PlainConnectionSocketFactory.getSocketFactory())
                .register("https", SSLConnectionSocketFactory.getSocketFactory())
                .build();
        PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(registry);

        //设置整个连接池最⼤连接数
        connectionManager.setMaxTotal(500);

        //MaxPerRoute路由是对maxTotal的细分,每个主机的并发,这⾥route指的是域名
        connectionManager.setDefaultMaxPerRoute(300);

        RequestConfig requestConfig = RequestConfig.custom()
                //返回数据的超时时间
                .setSocketTimeout(20000)
                //连接上服务器的超时时间
                .setConnectTimeout(10000)
                //从连接池中获取连接的超时时间
                .setConnectionRequestTimeout(1000)
                .build();
        CloseableHttpClient httpClient = HttpClientBuilder.create().setDefaultRequestConfig(requestConfig)
                .setConnectionManager(connectionManager).build();

        return httpClient;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

# 压测对比

  • 同步发送 + resttemplate 未池化
    • 压测结果 ⼏百 吞吐量
  • 同步发送 + resttemplate 池化

image-20240203170625632

# 短信验证码组件性能优化实战

  • 调整代码
  • 采⽤异步调⽤⽅式
  • 采⽤ resttemplate 池化⽅式

SmsComponent

    /**
     * 发送短信验证码
     *
     * @param to
     * @param templateId
     * @param value
     */
    @Async("threadPoolTaskExecutor")
    public void send(String to, String templateId, String value) {

        long beginTime = CommonUtil.getCurrentTimestamp();

        String url = String.format(URL_TEMPLATE, to, templateId, value);
        HttpHeaders headers = new HttpHeaders();
        //最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105
        headers.set("Authorization", "APPCODE " + smsConfig.getAppCode());
        HttpEntity entity = new HttpEntity<>(headers);
        ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);

        long endTime = CommonUtil.getCurrentTimestamp();

        log.info("耗时={},url={},body={}", endTime - beginTime, url, response.getBody());
        if (response.getStatusCode().is2xxSuccessful()) {
            log.info("发送短信验证码成功");
        } else {
            log.error("发送短信验证码失败:{}", response.getBody());
        }

    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

删除用于压测进行测试的方法

# 图形验证码开发

Kaptcha 框架介绍 ⾕歌开源的⼀个可⾼度配置的实⽤验证码⽣成⼯具

  • 验证码的字体 / ⼤⼩ / 颜⾊
  • 验证码内容的范围 (数字,字⺟,中⽂汉字!)
  • 验证码图⽚的⼤⼩,边框,边框粗细,边框颜⾊
  • 验证码的⼲扰线
  • 验证码的样式 (⻥眼样式、3D、普通模糊)

聚合⼯程依赖添加(使⽤国内 baomidou ⼆次封装的 springboot 整合 starter)

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>kaptcha-spring-boot-starter</artifactId>
        </dependency>
1
2
3
4

CaptchaConfig

@Configuration
public class CaptchaConfig {
    /**
     * 验证码配置
     * Kaptcha配置类名
     *
     * @return /**
     * 验证码配置类
     * @return
     */
    @Bean
    @Qualifier("captchaProducer")
    public DefaultKaptcha kaptcha() {
        DefaultKaptcha kaptcha = new DefaultKaptcha();
        Properties properties = new Properties();
//		properties.setProperty(Constants.KAPTCHA_BORDER, "yes");
//		properties.setProperty(Constants.KAPTCHA_BORDER_COLOR, "220,220,220");
//		//properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_FONT_COLOR, "38,29,12");
//		properties.setProperty(Constants.KAPTCHA_IMAGE_WIDTH, "147");
//		properties.setProperty(Constants.KAPTCHA_IMAGE_HEIGHT, "34");
//		properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_FONT_SIZE, "25");
//		//properties.setProperty(Constants.KAPTCHA_SESSION_KEY, "code");
        //验证码个数
        properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_CHAR_LENGTH, "4");
//		properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_FONT_NAMES, "Courier");
        //字体间隔
        properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_CHAR_SPACE, "8");
        //干扰线颜色
//		properties.setProperty(Constants.KAPTCHA_NOISE_COLOR, "white");
        //干扰实现类
        properties.setProperty(Constants.KAPTCHA_NOISE_IMPL, "com.google.code.kaptcha.impl.NoNoise");
        //图片样式
        properties.setProperty(Constants.KAPTCHA_OBSCURIFICATOR_IMPL, "com.google.code.kaptcha.impl.WaterRipple");
        //文字来源
        properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_CHAR_STRING, "0123456789");
        Config config = new Config(properties);
        kaptcha.setConfig(config);
        return kaptcha;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

NotifyController

@Autowired
private Producer captchaProducer;

/**
 * 生成验证码
 * @param request
 * @param response
 */
@GetMapping("captcha")
public void getCaptcha(HttpServletRequest request, HttpServletResponse response) {
    String captchaText = captchaProducer.createText();
    log.info("验证码内容:{}", captchaText);
    // 存储redis,配置过期时间 todo

    BufferedImage bufferedImage = captchaProducer.createImage(captchaText);
    try {
        ServletOutputStream outputStream = response.getOutputStream();
        ImageIO.write(bufferedImage, "jpg", outputStream);
        outputStream.flush();
        outputStream.close();
    } catch (IOException e) {
        log.error("获取流出错:{}", e.getMessage());
    }


}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

# Redis6.X 配置连接池实战

连接池好处

  • 使⽤连接池不⽤每次都⾛三次握⼿、每次都关闭 Jedis
  • 相对于直连,使⽤相对麻烦,在资源管理上需要很多参数来保证,规划不合理也会出现问题
  • 如果 pool 已经分配了 maxActive 个 jedis 实例,则此时 pool 的状态就成 exhausted 了

连接池配置 common 项⽬

   <!--redis客户端-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>io.lettuce</groupId>
                    <artifactId>lettuce-core</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

配置 Redis 连接

  redis:
    client-type: jedis
    host: 192.168.130.24
    password: xdclass.net
    port: 6379
    jedis:
      pool:
        # 连接池最大连接数(使用负值表示没有限制)
        max-active: 100
        # 连接池中的最大空闲连接
        max-idle: 100
        # 连接池中的最小空闲连接
        min-idle: 100
        # 连接池最大阻塞等待时间(使用负值表示没有限制)
        max-wait: 60000
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

序列化配置,common 模块下

RedisTemplateConfiguration

@Configuration
public class RedisTemplateConfiguration {
    @Bean
    public RedisTemplate<Object,Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){

        RedisTemplate<Object,Object> redisTemplate = new RedisTemplate<>();

        redisTemplate.setConnectionFactory(redisConnectionFactory);

        //配置序列化规则
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);

        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);


        //设置key-value序列化规则
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);

        //设置hash-value序列化规则
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);

        return redisTemplate;

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

# 加⼊缓存 + Try-with-resource 知识巩固

redis 做隔离,多集群:核⼼集群和⾮核⼼集群,⾼并发集群和⾮⾼并发集群

  • 资源隔离

  • 数据保护

  • 提⾼性能

  • key 规范:业务划分,冒号隔离

    • account-service:captcha:xxxx
    • ⻓度不能过⻓

NotifyController

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 验证码过期时间
     * 临时使⽤10分钟有效,⽅便测试
     */
    private static final long CAPTCHA_CODE_EXPIRED = 60 * 1000 * 10;

    /**
     * 生成验证码
     *
     * @param request
     * @param response
     */
    @GetMapping("captcha")
    public void getCaptcha(HttpServletRequest request, HttpServletResponse response) {
        String captchaText = captchaProducer.createText();
        log.info("验证码内容:{}", captchaText);
        // 存储redis,配置过期时间
        redisTemplate.opsForValue().set(getCaptchaKeY(request), captchaText, CAPTCHA_CODE_EXPIRED, TimeUnit.MILLISECONDS);

        BufferedImage bufferedImage = captchaProducer.createImage(captchaText);
        try {
            ServletOutputStream outputStream = response.getOutputStream();
            ImageIO.write(bufferedImage, "jpg", outputStream);
            outputStream.flush();
            outputStream.close();
        } catch (IOException e) {
            log.error("获取流出错:{}", e.getMessage());
        }


    }

    private String getCaptchaKeY(HttpServletRequest request) {
        String ipAddr = CommonUtil.getIpAddr(request);
        String userAgent = request.getHeader("User_Agent");
        String key = "account-service:" + CommonUtil.MD5(ipAddr + userAgent);
        log.info("验证码key:{}", key);
        return key;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

什么是 try-with-resources

  • 资源的关闭很多⼈停留在旧的流程上,jdk7 新特性就有,但是很多⼈以为是 jdk8 的
  • 在 try( ...) ⾥声 明的资源,会在 try-catch 代码块结束后⾃动关闭掉
  • 注意点
    • 实现了 AutoCloseable 接⼝的类,在 try () ⾥声明该类实例的时候,try 结束后⾃动调⽤的 close ⽅法,这个动作会早于 finally ⾥调⽤的⽅法
    • 不管是否出现异常,try () ⾥的实例都会被调⽤ close ⽅法 try ⾥⾯可以声明多个⾃动关闭的对象,越早声明的对象,会越晚被 close 掉
    /**
     * 生成验证码
     *
     * @param request
     * @param response
     */
    @GetMapping("captcha")
    public void getCaptcha(HttpServletRequest request, HttpServletResponse response) {
        String captchaText = captchaProducer.createText();
        log.info("验证码内容:{}", captchaText);
        // 存储redis,配置过期时间
        redisTemplate.opsForValue().set(getCaptchaKeY(request), captchaText, CAPTCHA_CODE_EXPIRED, TimeUnit.MILLISECONDS);

        BufferedImage bufferedImage = captchaProducer.createImage(captchaText);
        try (ServletOutputStream outputStream = response.getOutputStream();){
            ImageIO.write(bufferedImage, "jpg", outputStream);
            outputStream.flush();
        } catch (IOException e) {
            log.error("获取流出错:{}", e.getMessage());
        }

    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 验证码接口

net.xdclass.controller.request.SendCodeRequest

@Data
public class SendCodeRequest {
    private String captcha;
    
    private String to;
}
1
2
3
4
5
6

common 模块下 net.xdclass.enums.SendCodeEnum

public enum SendCodeEnum {

    /**
     * 用户注册
     */
    USER_REGISTER;
}
1
2
3
4
5
6
7

common 模块下 net.xdclass.util.CheckUtil

public class CheckUtil {
    /**
     * 邮箱正则
     */
    private static final Pattern MAIL_PATTERN = Pattern.compile("^([a-z0-9A-Z]+[-|\\.]?)+[a-z0-9A-Z]@([a-z0-9A-Z]+(-[a-z0-9A-Z]+)?\\.)+[a-zA-Z]{2,}$");

    /**
     * 手机号正则
     */
    private static final Pattern PHONE_PATTERN = Pattern.compile("^((13[0-9])|(14[0-9])|(15[0-9])|(17[0-9])|(18[0-9]))\\d{8}$");

    /**
     * @param email
     * @return
     */
    public static boolean isEmail(String email) {
        if (null == email || "".equals(email)) {
            return false;
        }
        Matcher m = MAIL_PATTERN.matcher(email);
        return m.matches();
    }

    /**
     * @param phone
     * @return
     */
    public static boolean isPhone(String phone) {
        if (null == phone || "".equals(phone)) {
            return false;
        }
        Matcher m = PHONE_PATTERN.matcher(phone);
        boolean result = m.matches();
        return result;

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

NotifyService

public interface NotifyService {
    /**
     * 发送短信验证码
     * @param sendCodeEnum
     * @param to
     * @return
     */
    JsonData sendCode(SendCodeEnum sendCodeEnum, String to);
    
}
1
2
3
4
5
6
7
8
9
10

NotifyServiceImpl

@Service
@Slf4j
public class NotifyServiceImpl implements NotifyService {

    @Autowired
    private SmsComponent smsComponent;

    @Autowired
    private SmsConfig smsConfig;


    @Override
    public JsonData sendCode(SendCodeEnum sendCodeEnum, String to) {
        String randomCode = CommonUtil.getRandomCode(6);

        if (CheckUtil.isEmail(to)) {
            //邮箱验证码 todo
        } else if (CheckUtil.isPhone(to)) {
            //短信验证码
            smsComponent.send(to, smsConfig.getTemplateId(), randomCode);


        }
        return JsonData.buildSuccess();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

NotifyController

    @Autowired
    private Producer captchaProducer;

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private NotifyService notifyService;

    /**
     * 验证码过期时间
     * 临时使⽤10分钟有效,⽅便测试
     */
    private static final long CAPTCHA_CODE_EXPIRED = 60 * 1000 * 10;

    /**
     * 生成验证码
     *
     * @param request
     * @param response
     */
    @GetMapping("captcha")
    public void getCaptcha(HttpServletRequest request, HttpServletResponse response) {
        String captchaText = captchaProducer.createText();
        log.info("验证码内容:{}", captchaText);
        // 存储redis,配置过期时间
        redisTemplate.opsForValue().set(getCaptchaKeY(request), captchaText, CAPTCHA_CODE_EXPIRED, TimeUnit.MILLISECONDS);

        BufferedImage bufferedImage = captchaProducer.createImage(captchaText);
        try (ServletOutputStream outputStream = response.getOutputStream();) {
            ImageIO.write(bufferedImage, "jpg", outputStream);
            outputStream.flush();
        } catch (IOException e) {
            log.error("获取流出错:{}", e.getMessage());
        }


    }

    private String getCaptchaKeY(HttpServletRequest request) {
        String ipAddr = CommonUtil.getIpAddr(request);
        String userAgent = request.getHeader("User_Agent");
        String key = "account-service:" + CommonUtil.MD5(ipAddr + userAgent);
        log.info("验证码key:{}", key);
        return key;
    }


    /**
     * 测试发送验证码接口,用于压测
     *
     * @return
     */
    @PostMapping("send_code")
    public JsonData senCode(@RequestBody SendCodeRequest sendCodeRequest, HttpServletRequest request) {
        String key = getCaptchaKeY(request);
        String cacheCaptcha = redisTemplate.opsForValue().get(key);
        String captcha = sendCodeRequest.getCaptcha();
        if (captcha != null && captcha != null && cacheCaptcha.equalsIgnoreCase(captcha)) {
            redisTemplate.delete(key);
            JsonData jsonData = notifyService.sendCode(SendCodeEnum.USER_REGISTER, sendCodeRequest.getTo());
            return jsonData;

        } else {
            return new JsonData().buildResult(BizCodeEnum.CODE_CAPTCHA_ERROR);

        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68

# 短信验证码防刷

注册短信验证码防刷⽅案你能想到⼏个

需求:⼀定时间内禁⽌重复发送短信,⼤家想下有哪⼏种实现⽅式

两个时间要求

  • 60 秒后才可以重新发送短信验证码
  • 发送的短信验证码 10 分钟内有效

⽅式⼀:前端增加校验倒计时,不到 60 秒按钮不给点击

  • 简单
  • 不安全,存在绕过的情况

⽅式⼆:增加 Redis 存储,发送的时候设置下额外的 key,并且 60 秒后过期

  • ⾮原⼦操作,存在不⼀致性

  • 增加的额外的 key - value 存储,浪费空间

    /**
    * 前置:判断是否重复发送
    *
    * 1、存储验证码到缓存
    *
    * 2、发送短信验证码
    *
    * 后置:存储发送记录
    **/
    
    1
    2
    3
    4
    5
    6
    7
    8
    9

⽅式三:基于原先的 key 拼装时间戳

  • 好处:满⾜了当前节点内的原⼦性,也满⾜业务需求

我们选择方式三实现

common 模块下 net.xdclass.constant.RedisKey

public class RedisKey {
    /**
     * 验证码缓存key,第一个是类型,第二个是手机或邮箱
     */
    public static final String CHECK_CODE_KEY = "code:%s:%s";
}
1
2
3
4
5
6

修改 NotifyServiceImpl

    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final long CAPTCHA_CODE_EXPIRED = 60 * 1000 * 10;


    @Override
    public JsonData sendCode(SendCodeEnum sendCodeEnum, String to) {
        String cacheKey = String.format(RedisKey.CHECK_CODE_KEY, sendCodeEnum.name(), to);
        String cacheValue = redisTemplate.opsForValue().get(cacheKey);
        // 如果不为空,再判断是否60秒内重发 value为 123456_1706960556
        if (StringUtils.isNoneBlank(cacheValue)) {
            long ttl = Long.parseLong(cacheValue.split("_")[1]);
            // 判断是否大于60秒
            long leftTime = CommonUtil.getCurrentTimestamp() - ttl;
            if (leftTime < (1000 * 60)) {
                log.info("重复发送短信验证码,时间间隔为{}秒", leftTime);
                return JsonData.buildResult(BizCodeEnum.CODE_LIMITED);
            }

        }

        String randomCode = CommonUtil.getRandomCode(6);
        // 生成拼接好的验证码
        String value = randomCode + "_" + CommonUtil.getCurrentTimestamp();
        redisTemplate.opsForValue().set(cacheKey, value, CAPTCHA_CODE_EXPIRED, TimeUnit.MILLISECONDS);

        if (CheckUtil.isEmail(to)) {
            //邮箱验证码 todo
        } else if (CheckUtil.isPhone(to)) {
            //短信验证码
            smsComponent.send(to, smsConfig.getTemplateId(), randomCode);


        }
        return JsonData.buildSuccess();
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

# 阿⾥云 OSS 接⼊实战

# 分布式⽂件存储常⻅解决⽅案介绍

⽬前业界⽐较多这个解决⽅案,这边就挑选⼏个介绍下

  • MinIO:是在 Apache License v2.0 下发布的对象存储服务器,学习成本低,安装运维简单,主流语⾔的客户端整合都有,号称最强的对象存储⽂件服务器,且可以和容器化技术 docker/k8s 等结合,社区活跃但不够成熟,业界参考资料较少官⽹:https://docs.min.io/cn/
  • FastDFS:⼀个开源的轻量级分布式⽂件系统,⽐较少的客户端可以整合,⽬前主要是 C 和 java 客户端,在⼀些互联⽹创业公司中有应⽤⽐较多,没有官⽅⽂档,社区不怎么活跃。架构 + 部署结构复杂,出问题定位⽐较难定位,可以说是 fastdfs 零件的组装过程,需要去理解 fastDFS 的架构设计,才能够正确的安装部署
  • 云⼚商
    • 阿⾥云 OSS
    • 七⽜云
    • 腾讯云
    • 亚⻢逊云
    • CDN 最强:Akamai https://www.akamai.com/cn

选云⼚商理由

  • 优点:开发简单,功能强⼤,容易维护(不同⽹络下图⽚质量、⽔印、加密策略、扩容、加速)
  • 缺点:要钱,个性化处理,未来转移⽐较复杂,不排除有些⼚商会提供⼀键迁移⼯具

选开源 MinIO 的理由

  • 优点:功能强⼤、可以根据业务做⼆次的定制,新⼀代分布式⽂件存储系统,容器化结合强⼤,更重要的是免费(购买磁盘、内存、带宽)
  • 缺点:⾃⼰需要有专⻔的团队进⾏维护、扩容等

⽂件上传流程

  • web 控制台
  • 前端 -> 后端程序 -> 阿⾥云 OSS

image-20240204155436820

# 阿⾥云 OSS 对象存储介绍和开通

对象存储 OSS(Object Storage Service)是阿⾥云提供的海量、安全、低成本、⾼持久的云存储服务。其数据设计持久性不低于 99.9999999999%(12 个 9),服务设计可⽤性不低于 99.995%。

OSS 具有与平台⽆关的 RESTful API 接⼝,您可以在任何应⽤、任何时间、任何地点存储和访问任意类型的数据。提供标准、低频访问、归档和冷归档四种存储类型,全⾯覆盖从热到冷的各种数据存储场景:

标准存储类型 ⾼持久、⾼可⽤、⾼性能的对象存储服务,⽀持频繁的数据访问。是各种社交、分享类的图⽚、⾳视频应⽤、⼤型⽹站、⼤数据分析的合适选择。
低频访问存储类型 适合⻓期保存不经常访问的数据(平均每⽉访问频率 1 到 2 次)。存储单价低于标准类型,适合各类移动应⽤、智能设备、企业数据的⻓期备份,⽀持实时数据访问。
归档存储类型 适合需要⻓期保存(建议半年以上)的归档数据,在存储周期内极少被访问,数据进⼊到可读取状态需要 1 分钟的解冻时间。适合需要⻓期保存的档案数据、医疗影像、科学资料、影视素材。
冷归档存储类型 适合需要超⻓时间存放的极冷数据。例如因合规要求需要⻓期留存的数据、⼤数据及⼈⼯智能领域⻓期积累的原始数据、影视⾏业⻓期留存的媒体资源、在线教育⾏业的归档视频等。

开通阿⾥云 OSS

  • 有阿⾥云账号、实名认证
  • OSS 介绍:https://www.aliyun.com/product/oss
  • OSS 控制台:https://oss.console.aliyun.com/bucket
  • 学习路径:https://help.aliyun.com/learn/learningpath/oss.html

开通后的操作

  • 创建 Bucket
  • 上传⽂件
  • 访问⽂件

# RBAC-ACL 模式应⽤之阿⾥云 RAM 访问控制

  • ACL: Access Control List 访问控制列表
    • 以前盛⾏的⼀种权限设计,它的核⼼在于⽤户直接和权限挂钩
    • 优点:简单易⽤,开发便捷
    • 缺点:⽤户和权限直接挂钩,导致在授予时的复杂性,⽐较分散,不便于管理
    • 例⼦:常⻅的⽂件系统权限设计,直接给⽤户加权限
  • RBAC: Role Based Access Control
    • 基于⻆⾊的访问控制系统。权限与⻆⾊相关联,⽤户通过成为适当⻆⾊的成员⽽得到这些⻆⾊的权限
    • 优点:简化了⽤户与权限的管理,通过对⽤户进⾏分类,使得⻆⾊与权限关联起来
    • 缺点:开发对⽐ ACL 相对复杂
    • 例⼦:基于 RBAC 模型的权限验证框架与应⽤ Apache Shiro、spring Security

总结:不能过于复杂,规则过多,维护性和性能会下降, 更多分类 ABAC、PBAC 等

RAM 权限介绍

  • 阿⾥云⽤于各个产品的权限,基于 RBAC、ACL 模型都有,进⾏简单管理账号、统⼀分配权限、集中管控资源,从⽽建⽴安全、完善的资源控制体系。
  • 众多产品,⼀般采⽤⼦账号进⾏分配权限,防⽌越权攻击 image-20240204160023912
  • 建⽴⽤户,勾选编程访问(保存 accessKey 和 accessSecret,只出现⼀次)
  • ⽤户登录名称 [email protected] AccessKey ID : LTAI5tHVGvYw7twoVFyruB1H AccessKey Secret :r4d0EudzSvPfVMb9Zp0TfmsE32RXmN
  • 为新建⽤户授权 OSS 全部权限 image-20240204162450082

# OSS 对象存储客户端集成和测试服务

添加阿⾥云 OSS 的 SDK

地址:https://help.aliyun.com/document_detail/32008.html

账号微服务添加 maven 依赖

        <!-- OSS各个项目单独加依赖,根据需要进行添加-->
        <dependency>
            <groupId>com.aliyun.oss</groupId>
            <artifactId>aliyun-sdk-oss</artifactId>
        </dependency>
1
2
3
4
5

账号微服务配置 OSS

#----------阿里云OSS配置--------------
aliyun:
  oss:
    endpoint: oss-cn-guangzhou.aliyuncs.com
    access-key-id: LTAI5tHVGvYw7twoVFyruB1H
    access-key-secret: r4d0EudzSvPfVMb9Zp0TfmsE32RXmN
    bucketname: dcloud-link
1
2
3
4
5
6
7

新建配置类 (配置⾥⾯的横杠会,⾃动转驼峰)

net.xdclass.config.OSSConfig

@ConfigurationProperties(prefix = "aliyun.oss")
@Configuration
@Data
public class OSSConfig {
    private String endpoint;
    private String accessKeyId;
    private String accessKeySecret;
    private String bucketname;
}
1
2
3
4
5
6
7
8
9

net.xdclass.service.FileService

public interface FileService {
    /**
     * 文件上传
     * @param file
     * @return
     */
    String uploadUserImg(MultipartFile file);
}
1
2
3
4
5
6
7
8

net.xdclass.service.impl.FileServiceImpl

@Service
@Slf4j
public class FileServiceImpl implements FileService {

    @Autowired
    private OSSConfig ossConfig;


    @Override
    public String uploadUserImg(MultipartFile file) {
        String bucketname = ossConfig.getBucketname();
        String endpoint = ossConfig.getEndpoint();
        String accessKeyId = ossConfig.getAccessKeyId();
        String accessKeySecret = ossConfig.getAccessKeySecret();

        // 创建OSS客户端
        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);

        // 文件原始名称
        String originalFilename = file.getOriginalFilename();

        // 存储路径为 user/2024/02/04/xxxx/xxx.jpg
        LocalDateTime ldt = LocalDateTime.now();
        DateTimeFormatter pattern = DateTimeFormatter.ofPattern("yyyy/MM/dd");
        String folder = ldt.format(pattern);
        String fileName = CommonUtil.generateUUID();
        String extendsion = originalFilename.substring(originalFilename.lastIndexOf("."));

        // oss上传文件
        String newFileName = "user/" + folder + "/" + fileName + extendsion;
        try {
            PutObjectResult putObjectResult = ossClient.putObject(bucketname, newFileName, file.getInputStream());
            // 拼装返回路径
            if (putObjectResult != null) {
                String url = "https://" + bucketname + "." + endpoint + "/" + newFileName;
                return url;
            }
        } catch (IOException e) {
            log.error("文件上传失败{}", e.getMessage());
        }
        return null;


    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

net.xdclass.controller.AccountController

@Controller
@RequestMapping("api/account/v1")
public class AccountController {
    @Autowired
    private FileService fileService;

    /**
     * 文件上传 最大文件大小为默认为1M
     * @param file
     * @return
     */
    @PostMapping("upload")
    // @requestPart注解 接收⽂件以及其他更为复杂的数据类型
    public JsonData UploadUserImg(@RequestPart("file") MultipartFile file) {
        String result = fileService.uploadUserImg(file);
        return result != null ? JsonData.buildSuccess(result) : JsonData.buildResult(BizCodeEnum.FILE_UPLOAD_USER_IMG_FAIL);
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

⽂件上传流程

  • 先上传⽂件,返回 url 地址,再和普通表单⼀并提交(推荐这种,更加灵活,失败率低)
  • ⽂件和普通表单⼀并提交(设计流程⽐较多,容易超时和失败)

注意:默认 SpringBoot 最⼤⽂件上传是 1M, ⼤家测试的时候记得关注下

@requestPart 注解 接收⽂件以及其他更为复杂的数据类型

postman 进行测试

image-20240204165724033

# 账号微服务注册 - 登录功能

# 注册功能业务介绍和代码

微服务注册接⼝开发

  • 请求实体类编写
  • controller
  • service
    • ⼿机验证码验证
    • 密码加密(TODO)
    • 账号唯⼀性检查 (TODO)
    • 插⼊数据库
    • 新注册⽤户福利发放 (TODO)
  • mapper

密码存储安全

  • 彩虹表暴⼒破解
    • ⽹站:https://www.cmd5.com/
  • 密码存储常⽤⽅式
    • 双重 MD5
    • MD5 + 加盐
    • 双重 MD5 + 加盐

net.xdclass.controller.request.AccountRegisterRequest

@Data
public class AccountRegisterRequest {


    /**
     * 头像
     */
    private String headImg;

    /**
     * 手机号
     */
    private String phone;

    /**
     * 密码
     */
    private String pwd;



    /**
     * 邮箱
     */
    private String mail;

    /**
     * 用户名
     */
    private String username;

    /**
     * 短信验证码
     */
    private String code;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

common 模块下 net.xdclass.enums.AuthTypeEnum

public enum AuthTypeEnum {
    /**
     * 默认级别
     */
    DEFAULT,

    /**
     * 实名制
     */
    REALNAME,

    /**
     * 企业
     */
    ENTERPRISE;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

NotifyService

public interface NotifyService {
    /**
     * 发送短信验证码
     * @param sendCodeEnum
     * @param to
     * @return
     */
    JsonData sendCode(SendCodeEnum sendCodeEnum, String to);

    /**
     *校验验证码
     * @return
     */
    boolean checkCode(SendCodeEnum sendCodeEnum, String to,String code);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

NotifyServiceImpl

    /**
     * 验证码检验逻辑
     * @param sendCodeEnum
     * @param to
     * @param code
     * @return
     */
    @Override
    public boolean checkCode(SendCodeEnum sendCodeEnum, String to, String code) {
        String cacheKey = String.format(RedisKey.CHECK_CODE_KEY, sendCodeEnum.name(), to);
        String cacheValue = redisTemplate.opsForValue().get(cacheKey);
        if (StringUtils.isNoneBlank(cacheValue)) {
            String cacheCode = cacheValue.split("_")[0];
            if (cacheCode.equalsIgnoreCase(code)) {
                // 删除验证码
                redisTemplate.delete(cacheKey);
                return true;
            }
        }
        return false;


    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

AccountService

public interface AccountService {
    /**
     * 用户注册
     * @param accountRegisterRequest
     * @return
     */
    JsonData register(AccountRegisterRequest registerRequest);
}

1
2
3
4
5
6
7
8
9

AccountServiceImpl

@Service
@Slf4j
public class AccountServiceImpl implements AccountService {

    @Autowired
    private NotifyService notifyService;

    @Autowired
    private AccountManager accountManager;


    /**
     * ⼿机验证码验证
     * 密码加密(TODO)
     * 账号唯⼀性检查(TODO)
     * 插⼊数据库
     * 新注册⽤户福利发放(TODO)
     *
     * @param registerRequest
     * @return
     */
    @Override
    public JsonData register(AccountRegisterRequest registerRequest) {
        boolean checkCode = false;
        // 判断验证码
        if (StringUtils.isNoneBlank(registerRequest.getPhone())) {
            checkCode = notifyService.checkCode(SendCodeEnum.USER_REGISTER, registerRequest.getPhone(), registerRequest.getCode());
        }

        // 验证码错误
        if (!checkCode) {
            return JsonData.buildResult(BizCodeEnum.CODE_ERROR);
        }

        AccountDO accountDO = new AccountDO();
        BeanUtils.copyProperties(registerRequest, accountDO);
        // 认证级别
        accountDO.setAuth(AuthTypeEnum.DEFAULT.name());

        // 密码 密钥 盐
        accountDO.setSecret("$1$" + CommonUtil.getStringNumRandom(8));
        String cryptPwd = Md5Crypt.md5Crypt(registerRequest.getPwd().getBytes(), accountDO.getSecret());
        accountDO.setPwd(cryptPwd);

        int rows = accountManager.insert(accountDO);
        log.info("rows:{},注册成功:{}", rows, accountDO);

        // 用户注册成功,发放福利
        userRegisterInitTask(accountDO);

        return JsonData.buildSuccess();
    }

    /**
     * 用户初始化 发放福利 流量包 todo
     *
     * @param accountDO
     */
    private void userRegisterInitTask(AccountDO accountDO) {

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

AccountManager

public interface AccountManager {

    int insert(AccountDO accountDO);
}
1
2
3
4

AccountManagerImpl

@Component
@Slf4j
public class AccountManagerImpl implements AccountManager {

    @Autowired
    private AccountMapper accountMapper;

    @Override
    public int insert(AccountDO accountDO) {
        return accountMapper.insert(accountDO);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

AccountController

/**
 * 用户注册
 * @param registerRequest
 * @return
 */
@PostMapping("register")
public JsonData register(@RequestBody AccountRegisterRequest registerRequest){
    JsonData jsonData = accountService.register(registerRequest);
    return jsonData;
}
1
2
3
4
5
6
7
8
9
10

# 注册⼿机号唯⼀性安全保证⽅案

注册业务,同个时刻注册,需要保证注册⼿机号在数据库⾥唯⼀

⾼并发下问题发现扩⼤

  • 万分之⼀的时间,放⼤ 100 万倍
  • 不是你的代码安全,⽽是你的并发量过少,⼏个⼏⼗个并发量发现不了问题
  • ⼏⼗万⼏百万并发 ,线下难模拟
    • 代码暂停思维:假如⾮原⼦性代码运⾏到某⼀⾏暂停,其他线程重新操作是否会出问题
    • 时间扩⼤思维:1 纳秒的时间,扩⼤到 1 分钟,代码逻辑是否会有问题
    • 类似幂等性处理

解决思路

  • Redis:先看 redis 是否有,然后没的话则是新的注册
    • key -value 存储,配置 60 秒过期
    • ⾮原⼦性操作,存在不⼀致
  • 数据库唯⼀索引 (建表的时间已经添加)
  • 库分表下 - ⼿机号唯⼀性保证怎么做? https://www.zhihu.com/people/xdclass/asks image-20240204173721460

# 登录模块逻辑和解密

核⼼逻辑

  • 通过 phone 找数据库记录
  • 获取盐,和当前传递的密码就⾏加密后匹配
  • ⽣成 token 令牌

# 分布式应⽤的登录检验解决⽅案 JWT

JWT 是⼀个开放标准,它定义了⼀种⽤于简洁,⾃包含的⽤于通信双⽅之间以 JSON 对象的形式安全传递信息的⽅法。 可以使⽤ HMAC 算法或者是 RSA 的公钥密钥对进⾏签名

简单来说:就是通过⼀定规范来⽣成 token,然后可以通过解密算法逆向解密 token,这样就可以获取⽤户信息

{
    id:888,
    name:'⼩D',
    expire:10000
}

funtion 加密(object, appsecret){
    xxxx
    return base64( token);
}

function 解密(token ,appsecret){
    xxxx
    //成功返回true,失败返回false
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

优点

  • ⽣产的 token 可以包含基本信息,⽐如 id、⽤户昵称、头像等信息,避免再次查库
  • 存储在客户端,不占⽤服务端的内存资源

缺点

  • token 是经过 base64 编码,所以可以解码,因此 token 加密前的对象不应该包含敏感信息,如⽤户权限,密码等
  • 如果没有服务端存储,则不能做登录失效处理,除⾮服务端改秘钥

JWT 格式组成 头部、负载、签名

header+payload+signature

  • 头部:主要是描述签名算法
  • 负载:主要描述是加密对象的信息,如⽤户的 id 等,也可以加些规范⾥⾯的东⻄,如 iss 签发者,exp 过期时间,sub ⾯向的⽤户
  • 签名:主要是把前⾯两部分进⾏加密,防⽌别⼈拿到 token 进⾏ base 解密后篡改 token

关于 jwt 客户端存储

  • 可以存储在 cookie,localstorage 和 sessionStorage ⾥⾯

image-20240204183537574

common 模块下 net.xdclass.util.JWTUtil

@Slf4j
public class JWTUtil {

    /**
     * 主题
     */
    private static final String SUBJECT = "xdclass";

    /**
     * 加密密钥
     */
    private static final String SECRET = "xdclass.net168";

    /**
     * 令牌前缀
     */
    private static final String TOKEN_PREFIX = "dcloud-link";

    /**
     * token 过期时间 7天
     */
    private static final long EXPIRED = 1000 * 60 * 60 * 24 * 7;


    /**
     * 生成token
     *
     * @param loginUser
     * @return
     */
    public static String geneJsonWebToken(LoginUser loginUser) {
        if (loginUser != null) {
            throw new NullPointerException("loginUser is null");
        }
        String token = Jwts.builder().setSubject(SUBJECT)
                .claim("head_img", loginUser.getHeadImg())
                .claim("userame", loginUser.getUserame())
                .claim("mail", loginUser.getMail())
                .claim("phone", loginUser.getPhone())
                .claim("auth", loginUser.getAuth())
                .setIssuedAt(new Date())
                .setExpiration(new Date(CommonUtil.getCurrentTimestamp() + EXPIRED))
                .signWith(SignatureAlgorithm.HS256, SECRET)
                .compact();
        token = TOKEN_PREFIX + token;
        return token;
    }

    /**
     * 解密jwt
     *
     * @param tokne
     * @return
     */

    public static Claims checkJWT(String tokne) {
        try {
            final Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(tokne.replace(TOKEN_PREFIX, "")).getBody();
            return claims;
        } catch (Exception e) {
            log.error("jwt 解析异常:{}", e.getMessage());
            return null;
        }

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66

common 模块下 net.xdclass.model.LoginUser

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class LoginUser {
    /**
     * 账号
     */
    private long accountNo;

    /**
     * 用户名称
     */
    private String userame;

    /**
     * 头像
     */
    private String headImg;

    /**
     * 邮箱
     */
    private String mail;

    /**
     * 手机号
     */
    private String phone;

    /**
     * 认证级别
     */
    private String auth;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

net.xdclass.controller.request.AccountLoginRequest

@Data
public class AccountLoginRequest {

    private String phone;
    private String pwd;
}

1
2
3
4
5
6
7

AccountController

/**
 * 用户登录
 */
@PostMapping("login")
public JsonData login(@RequestBody AccountLoginRequest loginRequest){
    return accountService.login(loginRequest);

}
1
2
3
4
5
6
7
8

AccountService

    /**
     * 用户登录
     * @param loginRequest
     * @return
     */
    JsonData login(AccountLoginRequest loginRequest);
1
2
3
4
5
6

AccountServiceImpl

/**
 * 用户登录逻辑
 * 通过phone找数据库记录
 * 获取盐,和当前传递的密码就⾏加密后匹配
 * ⽣成token令牌
 *
 * @param loginRequest
 * @return
 */
@Override
public JsonData login(AccountLoginRequest loginRequest) {
    List<AccountDO> accountDOList = accountManager.findByPhone(loginRequest.getPhone());
    if (accountDOList != null && accountDOList.size() == 1) {
        AccountDO accountDO = accountDOList.get(0);
        String md5Crypt = Md5Crypt.md5Crypt(loginRequest.getPwd().getBytes(), accountDO.getSecret());
        if (md5Crypt.equalsIgnoreCase(accountDO.getPwd())) {
            LoginUser loginUser = LoginUser.builder().build();
            BeanUtils.copyProperties(accountDO, loginUser);
            // 生成token
            String token = JWTUtil.geneJsonWebToken(loginUser);
            return JsonData.buildSuccess(token);

        } else {
            return JsonData.buildResult(BizCodeEnum.ACCOUNT_PWD_ERROR);
        }
    } else {
        return JsonData.buildResult(BizCodeEnum.ACCOUNT_UNREGISTER);
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

AccountManager

    List<AccountDO> findByPhone(String phone);
1

AccountManagerImpl

    @Override
    public List<AccountDO> findByPhone(String phone) {
        List<AccountDO> accountDoList = accountMapper.selectList(new QueryWrapper<AccountDO>().eq("phone", phone));
        return accountDoList;

    }
1
2
3
4
5
6

# 登录拦截器开发和⽤户信息传递

开发登录拦截器

  • 解密 JWT
  • 传递登录⽤户信息
    • attribute 传递
    • threadLocal 传递

SpringBoot 拦截器代码开发

common 模块下 net.xdclass.interceptor.LoginInterceptor

@Slf4j
public class LoginInterceptor implements HandlerInterceptor {

    public static ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // options请求直接放行
        if (HttpMethod.OPTIONS.toString().equalsIgnoreCase(request.getMethod())) {
            response.setStatus(HttpStatus.NO_CONTENT.value());
            return true;
        }
        String accessToken = request.getHeader("token");
        if (StringUtils.isBlank(accessToken)) {
            accessToken = request.getParameter("token");
        }
        // 检验token
        if (StringUtils.isBlank(accessToken)) {
            Claims claims = JWTUtil.checkJWT(accessToken);
            if (claims == null) {
                CommonUtil.sendJsonMessage(response, JsonData.buildResult(BizCodeEnum.ACCOUNT_UNLOGIN));
                return false;
            }

            long accountNo = Long.parseLong(claims.get("account_no").toString());
            String headImg = (String) claims.get("head_img");
            String username = (String) claims.get("username");
            String mail = (String) claims.get("mail");
            String phone = (String) claims.get("phone");
            String auth = (String) claims.get("auth");
            LoginUser loginUser = LoginUser.builder()
                    .accountNo(accountNo)
                    .headImg(headImg)
                    .mail(mail)
                    .auth(auth)
                    .phone(phone)
                    .userame(username)
                    .build();
            threadLocal.set(loginUser);
            return true;


        }
        CommonUtil.sendJsonMessage(response, JsonData.buildResult(BizCodeEnum.ACCOUNT_UNLOGIN));
        return false;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除一下 防止内存泄漏
        threadLocal.remove();

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

# ThreadLocal

thread local variable(线程局部变量)功用非常简单,使用场合主要解决多线程中数据因并发产生不一致问题。

ThreadLocal 为每一个线程都提供了变量的副本,使得每个线程在某时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享,这样的结果是耗费了内存,但大大减少了线程同步所带来性能消耗,也减少了线程并发控制的复杂度。

总结起来就是:同个线程共享数据

注意:ThreadLocal 不能使用原子类型,只能使用 Object 类型

ThreadLocal 用作每个线程内需要独立保存信息,方便同个线程的其他方法获取该信息的场景。

每个线程获取到的信息可能都是不一样的,前面执行的方法保存了信息后,后续方法可以通过 ThreadLocal 直接获取到,避免了传参,类似于全局变量的概念,比如用户登录令牌解密后的信息传递(还有用户权限信息、从用户系统获取到的用户名、用户 ID)

image-20240204215125459

# ThreadLocal 底层源码 + 原理

  • ThreadLocal 中的一个内部类 ThreadLocalMap,这个类没有实现 map 接口,就是一个普通的 Java 类,但是实现的类似 map 的功能

  • 每个数据用 Entry 保存,其中的 Entry 继承与 WeakReference,用一个键值对存储,键为 ThreadLocal 的引用。

  • 每个线程持有一个 ThreadLocalMap 对象,每一个新的线程 Thread 都会实例化一个 ThreadLocalMap 并赋值给成员变量 threadLocals,使用时若已经存在 threadLocals,则直接使用已经存在的对象。

image-20240204215206467

# ThreadLocal 常见核心面试题

ThreadLocal 和 Synchronized 的区别

  • 都是为了解决多线程中相同变量的访问冲突问题
  • Synchronized 是通过线程等待,牺牲时间来解决访问冲突
  • ThreadLocal 是通过每个线程单独一份存储空间,牺牲空间来解决冲突,
  • 对比 Synchronized,ThreadLocal 具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问到想要的值

为啥什么 ThreadLocal 的键是弱引用,如果是强引用有什么问题?

Java 中除了基础的数据类型以外,其它的都为引用类型。 而 Java 根据其生命周期的长短将引用类型又分为强引用 、 软引用 、 弱引用 、 虚引用 正常情况下我们平时基本上我们只用到强引用类型,而其他的引用类型我们也就在面试中,或者平日阅读类库或其他框架源码的时候才能见到

  1. 强引用 new 了一个对象就是强引用 Object obj = new Object ();
  2. 软引用的生命周期比强引用短一些,通过 SoftReference 类实现,当内存空间足够,垃圾回收器就不会回收它;当 JVM 认为内存空间不足时,就会去试图回收软引用指向的对象,也就是说在 JVM 抛出 OutOfMemoryError 之前,会去清理软引用对象 主要用来描述一些【有用但并不是必需】的对象 使用场景:适合用来实现缓存,内存空间充足的时候将数据缓存在内存中,如果空间不足了就将其回收掉
  3. 弱引用是通过 WeakReference 类实现的,它的生命周期比软引用还要短,在 GC 的时候,不管内存空间足不足都会回收这个对象 使用场景:一个对象只是偶尔使用,希望在使用时能随时获取,但也不想影响对该对象的垃圾收集,则可以考虑使用弱引用来指向该对象。

ThreadLocal 为什么是 WeakReference 呢?

  • 如果是强引用,即使把 ThreadLocal 设置为 null,但是 ThreadLocalMap 还持有 ThreadLocal 的强引用,如果没有手动删除,ThreadLocal 不会被回收,导致 Entry 内存泄漏

  • 如果是弱引用引用 ThreadLocal 的对象被回收了,由于 ThreadLocalMap 持有 ThreadLocal 的弱引用,即使没有手动删除,ThreadLocal 也会被回收。value 在下一次 ThreadLocalMap 调用 set、get、remove 的时候会被清除。

# 登录拦截器 InterceptorConfig 拦截和放⾏路径

拦截器配置

  • 拦截路径
  • 不拦截路径

net.xdclass.config.InterceptorConfig

@Configuration
@Slf4j
public class InterceptorConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                // 添加拦截路径
                .addPathPatterns("/api/account/*/**", "/api/traffic/*/**")
                // 排除不拦截
                .excludePathPatterns(
                        "/api/account/*/login", "/api/account/*/register","/api/account/*/upload",
                        "/api/notify/*/captcha", "/api/notify/*/send_code");

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
编辑 (opens new window)
上次更新: 2025/01/01, 10:09:39
平台业务介绍与搭建
海量数据下的分库分表

← 平台业务介绍与搭建 海量数据下的分库分表→

最近更新
01
k8s
06-06
02
进程与线程
03-04
03
计算机操作系统概述
02-26
更多文章>
Theme by Vdoing | Copyright © 2022-2025 Iekr | Blog
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式