需求分析

  1. 用户自定义添加标签,标签的分类(类型,怎么分类)比如Java/C++/网络安全,工作/大学
  2. 主动搜索,允许用户根据标签去搜索其他用户
  3. 组队
    • 创建队伍
    • 加入队伍
    • 根据标签查询队伍
    • 邀请其他人
    • 退出队伍
  4. 允许用户修改标签
  5. 推荐
    • 相似度计算算法+本地分布式计算

后端技术栈

  1. Java+SpringBoot框架
  2. SpringMVC+MyBatis+MyBatisPlus(提高开发效率)
  3. MySQL数据库
  4. Redis缓存
  5. Swagger+Knife4j接口文档

数据库表设计

标签的分类(有哪些、怎么分)
不建议使用boolean定义,不灵活

新增标签表(分类表)

建议用标签,不使用分类,更灵活.
性别:男、女
学习方向:Java、C++、Go、前端、网络安全
目标:考研、考公、春招、社招、竞赛、转行、跳槽
段位:
身份:
状态:
着重自定义|

表设计
  • id int 主键、
  • 标签名 varchar 非空(必须唯一,唯一索引)
  • 上传标签的用户 userld int(如果要根据 userld 查已上传标签的话,最好加上,普通索引)
  • 父标签 id ,parentld,int(分类)
  • 是否为父标签 isParent, tinyint(0 不是父标签、1-父标签)
  • 创建时间 createTime, datetime
  • 更新时间 updateTime,datetime
  • 是否删除 isDelete, tinyint(0、1)

索引最好加在有很多个值的列

验证设计
  • 通过父标签id分组实现查询所有标签
  • 根据父标签id查询子标签
修改用户表

hh又是经典大小写踩坑环节,记得选择实际的列.(className也注意一下)

image-20250121162505817

image-20250121162434713

lambda表达式进化
初始化

1
2
3
userList.forEach(user -> {
getSafetyUser(user);
});

2.0
image-20250121224426591

Java 8 的函数式编程特性,将当前对象的 getSafetyUser 方法作为参数传递给 forEach.

1
userList.forEach(this::getSafetyUser);

map 是一种中间操作,用于将流中的每个元素应用某个函数并返回一个新的流.

Java 8 Stream API

操作类型 方法 描述
中间操作 filter(Predicate) 筛选符合条件的元素,返回一个新的流。
map(Function) 将每个元素转换为另一种形式,返回新的流。
flatMap(Function) 将每个元素转换为一个流,然后将多个流合并为一个流。
sorted() 根据自然顺序或提供的比较器对元素进行排序,返回排序后的流。
distinct() 去除流中的重复元素,返回一个只含唯一元素的流。
limit(long n) 截取前 n 个元素,返回一个新的流。
skip(long n) 跳过前 n 个元素,返回剩下的元素组成的新流。
终端操作 collect(Collector) 将流中的元素收集到一个集合中,如 ListSet
forEach(Consumer) 对每个元素执行给定的动作,没有返回值。
reduce(BinaryOperator) 将流中的元素组合成一个值,常用于求和、乘积等聚合操作。
count() 返回流中元素的数量。
findFirst() 返回流中的第一个元素(如果存在则返回一个 Optional)。
findAny() 返回流中的任意一个元素(适用于并行流)。
allMatch(Predicate) 检查是否所有元素都匹配给定的条件,返回布尔值。
anyMatch(Predicate) 检查是否至少有一个元素匹配给定的条件,返回布尔值。
noneMatch(Predicate) 检查是否没有任何元素匹配给定的条件,返回布尔值。

shift + alt + -/+ 折叠/展开所有方法

Mybatis-Plus开启SQL日志打印

1
2
3
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

使用stream API filter过滤不满足条件的用户.

1
userList.stream().filter(user -> {}) ;

性能优化

根据实际业务场景做取舍.【不断测试,注意考虑连接数据库耗时】

搜索标签
  1. 允许用户传入多个标签,多个标签同时存在才能搜索的出.and like’%Java%’ and like ‘%Python%’
  2. 允许用户传入多个标签,有任何一个标签存在就能搜索出来.or like’%Java%’ or like ‘%Python%’
  • SQL查询(简单易实现,可以拆分查询)
  • 内存查询(灵活,可以通过并发进一步优化)
  • SQL和内存计算相结合,比如先用SQL过滤掉部分tag

如果参数可以分析,根据用户的参数去选择查询方式,比如标签数.
如果参数不可以分析,并且数据库连接足够、内存空间足够,可以同时并发查询,谁先返回用谁.
通过实际测试决定.

表示过时的方法

1
@Deprecated

SQL查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    @Deprecated
private List<User> searchUsersByTagsSQL(List<String> tagNameList) {
if (CollectionUtils.isEmpty(tagNameList)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
// long startTime = System.currentTimeMillis();
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
// // 拼接and 查询
// // like '%Java%' and line '%Python%'
for (String tagName : tagNameList) {
queryWrapper = queryWrapper.like("tags", tagName);
}
List<User> userList = userMapper.selectList(queryWrapper);
return userList.stream().map(this::getSafetyUser).collect(Collectors.toList());
// log.info("sql query time = " + (System.currentTimeMillis() - startTi
并发处理parallelStream()

原查询(先数据库过滤再内存过滤)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public List<User> searchUsersByTags(List<String> tagNameList) {
//1. 查询所有用户
QueryWrapper queryWrapper = new QueryWrapper<>();
List<User> userList = userMapper.selectList(queryWrapper);
Gson gson = new Gson();
//2. 在内存中查询是否包含符合要求的标签
return userList.stream().filter(user -> {
String tagStr = user.getTags();
//重要的校验
if (StringUtils.isBlank(tagStr)) {
return false;
}
Set<String> tempTagList = gson.fromJson(tagStr, new TypeToken<Set<String>>() {
}.getType());
for (String tagName : tagNameList) {
if (!tempTagList.contains(tagName)) {
return false;
}
}
return true;
}).map(this::getSafetyUser).collect(Collectors.toList());
}

并发处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public List<User> searchUsersByTags(List<String> tagNameList) {
//1. 查询所有用户
QueryWrapper queryWrapper = new QueryWrapper<>();
List<User> userList = userMapper.selectList(queryWrapper);
Gson gson = new Gson();
//2. 在内存中查询是否包含符合要求的标签
return userList.parallelStream().filter(user -> {
String tagStr = user.getTags();
//重要的校验
if (StringUtils.isBlank(tagStr)) {
return false;
}
Set<String> tempTagList = gson.fromJson(tagStr, new TypeToken<Set<String>>() {
}.getType());
for (String tagName : tagNameList) {
if (!tempTagList.contains(tagName)) {
return false;
}
}
return true;
}).map(this::getSafetyUser).collect(Collectors.toList());
}

由于parallelStream()使用的是一个公共线程池,如果是一个特别复杂的并发查询,此时一个线程池中可能会全部是parallelStream()执行的相应查询操作,线程池无法将其他任务进行分配,导致性能降低.

image-20250122233025028

image-20250122233035549

TODO 补充parallel陷阱.

Optional.ofNullable().orElse(),判断一个可能为空的值,如果为空返回对应的值,不为空返回orElse中的值.
image-20250122234851260

后端整合Swagger + Knife4j接口文档

什么是接口文档

写接口的文档,每条接口包括:

  • 请求参数
  • 相应参数
    • 错误码
  • 接口地址
  • 接口名称
  • 请求类型
  • 请求格式
  • 备注

一般由后端/负责人提供,前端和后端使用.

为什么需要接口文档?
  • 书面内容(归档/背书),便于参考和查阅以及维护和沉淀,
  • 接口文档便于前后端做开发对接,前后端联调的介质.
  • 好的接口文档支持在线调试、在线测试,作为工具提高我们的开发效率.
怎么做接口文档?
  • 手写(比如腾讯文档、Markdown笔记)
  • 自动化接口文档生成,自动根据项目代码生成完整的文档或在线调试的网页.Swagger,Postman(侧重接口管理)
接口文档有哪些技巧

swagger原理

  1. 引入依赖(Swagger/Knife4j)
  2. 自定义Swagger配置类
  3. 定义需要生成接口文档的代码位置(Controller)
  4. 注意!!线上环境不要把接口暴露出去,可以通过SwaggerConfig配置文件开头加上@Profile({"dev","test"})让定义的Bean在特定的环境生效【限定配置仅在部分环境开启】
  5. 启动即可
  6. 可以通过在Conroller方法上添加@Api、@ApilmplicitParam(name =”name”,value=”姓名”,required=true)、@ApiOperation(value=”向客人问好”)等注解来自定义生成的接口描述信息

Swagger和 SpirngBoot版本 >=2.6,不兼容问题.需配置默认的路径匹配策略(同步配置Knif4j ,修改注解)@EnableSwagger2WebMvc +@Bean(value = "defaultApi2")

1
2
3
4
5
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>2.0.9</version>
</dependency>

image-20250123162838032

1
2
3
4
5
# 修改swagger的路径匹配配置,使其兼容新版的SpringBoot
spring:
mvc:
pathmatch:
matching-strategy: ANT_PATH_MATCHER

之后访问http://ip:port/doc.html,就可以看到对应的管理页面.
image-20250124003710541

抓取网页信息

  1. 分析原网站是根据哪个接口获取数据(F12打开控制台,网络中复制CURL)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    curl "https://api.zsxq.com/v2/hashtags/48844541281228/topics?count=20" ^
    -H "authority: api.zsxq.com" ^
    -H "accept: application/json, text/plain, */*" ^
    -H "accept-language: zh-CN,zh;q=0.9" ^
    -H "cache-control: no-cache" ^
    -H "origin: https://wx.zsxq.com" ^
    -H "pragma: no-cache" ^
    -H "referer: https://wx.zsxq.com/" ^
    --compressed

    cookie使用自己的

    2.用程序调用接口
    3.处理(清洗)数据,之后写入数据库

EasyExcel

两种读对象的方式

  1. 确定表头:建立对象,和表头形成映射关系
  2. 不确定表头:每一行数据映射为Map<Stirng, Object>

两种读取模式

  1. 监听器:先创建监听器、在读取文件时绑定监听器.单独抽离处理逻辑,代码清晰易维护,一条条处理,适用于数量大的场景.
  2. 同步读:无序创建监听器,一次性获取完整数据.方便简单,但数据量大时会有等待异常,也可能内存溢出,
使用Terminal运行SpringBoot项目
  1. 先利用Maven自带打包工具打包好jar包
  2. 打开Terminal
1
java -jar .\user-center-0.0.1-SNAPSHOT.jar --server.port=8080

哦豁,出现”0.0.1-SNAPSHOT.jar中没有主清单属性”
解决:在pom文件的maven插件中增加repackage

1
2
3
4
5
6
7
8
9
10
11
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>

前端请求的时候带上一个JSESSIONID,和每个服务器的后台建一个唯一的会话(一一对应),服务器能根据JSESSIONID中定位到请求到对象,进而从请求对象的session中找到请求对象的信息.

image-20250124004306747

Session共享

种session的时候注意范围.
image-20250124103502790

1
2
3
4
servlet:
session:
cookie:
domain:

比如两个域名:aaa.yub.com bbb.yub.com
如果要共享cookie,可以种一个更高层的公共域名,比如yub.com

为什么服务器A登录后,请求发送到服务区B,不认识该用户?

用户在A登录,所以session(用户登录信息)存在了A上.
结果请求B,B没有用户信息,无法登录.

image-20250124104319917

image-20250124104405411

如何共享内存?

  1. Redis(基于内存的K / V数据库)此处选择Redis。用户信息/是否登录的判断及其频繁,Redis基于内存,读写性能很高,简单的数据 qps 5W -10W.
  2. MySQL
  3. 文件服务器ceph
Session共享实现
  1. github下载Redis

  2. Maven仓库中找到喝spring-starter版本一致的redis依赖版本

  3. Redis管理工具 QuickRedis QuickOfficial - QuickRedis

  4. 引入Redis,操作Redis

    1
    2
    3
    4
    5
    6
    <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis -->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <version>2.6.13</version>
    </dependency>

5.引入spring-session和Redis的整合,使得session自动存储到redis中

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/org.springframework.session/spring-session-data-redis -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>2.6.3</version>
</dependency>

6.修改spring-session存储配置,spinrg-session.store-type,默认是null表示存储在单台服务器.【可以修改为redis,表示默认存储在redis中】

1
2
store-type: redis
#表示从redis 读写session

记得测试时,使用不同的端口(debug+.\jar),在观察redis确定验证是否成功.

1
java -jar .\user-center-0.0.1-SNAPSHOT.jar --server.port=8081
主页开发

最简单:直接list列表
模拟1000w个用户,再去查询

批量导入数据
  1. 用可视化界面:适合一次性导入、数据量可控
  2. 写程序:for循环,建议分批,不要一把梭哈(可以使用接口来控制)
  3. 执行SQL语句,适用于小数据量(方法和以下操作相同,选择SQL Inserts)

保证可控性、幂等【注意线上环境和测试库有区别】

导出csv文件

image-20250125130414250

编写一次性任务

定时任务,spring提供的StopWatch类

1
2
3
4
StopWatch stopWatch = new StopWatch();
stopWatch.start();
stopWatch.stop();
stopWatch.getTotalTimeMillis()

在Application类中使用@EnableScheduling开启spring对定时任务的支持之后,创建单次任务
使用@Scheduled注解,其中限制只能出现一个,否则单次限定失败.

1
2
//能成功限定单次执行任务(不优雅
@Scheduled(initialDelay = 5000, fixedRate = Long.MAX_VALUE)

for循环插入数据库的问题

  1. 建立和释放数据库连接(批量查询解决)
  2. for循环是绝对线性的,一条卡住下一条就得等待

并发要注意执行的先后顺序无所谓,不要用到非并发类的集合(List x Collection.synchronizedList(new ArrayList<>()) √)

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
@Test
public void doConcurrencyInsertUsers() {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
// 分十组
int batchSize = 5000;
int j = 0;
List<CompletableFuture<Void>> futureList = new ArrayList<>();
for (int i = 0; i < 100; i++) {
List<User> userList = new ArrayList<>();
while (true) {
j++;
//此处的j++不保证原子性 所以不能加到异步执行中
User user = new User();
user.setUsername("玉皇小白");
user.setUserAccount("fakebo");
user.setAvatarUrl("https://636f-codenav-8grj8px727565176-1256524210.tcb.qcloud.la/img/logo.png");
user.setGender(0);
user.setUserPassword("12345678");
user.setPhone("123");
user.setEmail("123@qq.com");
user.setTags("[]");
user.setUserStatus(0);
user.setUserRole(0);
user.setSpaceCode("11111111");
userList.add(user);
if (j % batchSize == 0) {
break;
}
}
// 异步执行
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
userService.saveBatch(userList, batchSize);});
futureList.add(future);
}
CompletableFuture.allOf(futureList.toArray(new CompletableFuture[]{})).join();
// 20 秒 10 万条
stopWatch.stop();
System.out.println(stopWatch.getTotalTimeMillis());
}

自定义并发线程池测试(由于并发测试和自身机器的CPU核数有关,自定义约束)

1
2
3
4
5
6
7
private ExecutorService executorService = new ThreadPoolExecutor(
40, // 核心线程数
1000, // 最大线程数
10000, // 空闲线程存活时间
TimeUnit.MINUTES, // 空闲线程存活时间单位
new ArrayBlockingQueue<>(10000) // 阻塞队列,用于存储等待执行的任务
);
  • CPU密集型:分配的核心线程数 = CPU - 1(依赖于CPU的计算能力,需要大量的数学运算或逻辑处理,几乎始终占用 CPU 资源的任务)
  • IO密集型:分配的核心线程数可以大于CPU核数(依赖于输入/输出操作(如网络通信、磁盘读写、数据库查询等)的任务)

批量查询由Mybatis-Plus框架封装好,saveBatch方法实现.

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
@Test
public void doInsertUsers() {
StopWatch stopWatch = new StopWatch();
stopWatch.start();

final int INSERT_NUM = 1000;
List<User> userList = new ArrayList<>();
for (int i = 0; i < INSERT_NUM; i++) {
User user = new User();
user.setUsername("玉皇鸡毛");
user.setUserAccount("fakeyub");
user.setAvatarUrl("https://636f-codenav-8grj8px727565176-1256524210.tcb.qcloud.la/img/logo.png");
user.setGender(0);
user.setUserPassword("12345678");
user.setPhone("123");
user.setEmail("123@qq.com");
user.setTags("[]");
user.setUserStatus(0);
user.setUserRole(0);
user.setSpaceCode("11111111");
userList.add(user);
}
//批量插入
userService.saveBatch(userList, 100);
stopWatch.stop();
System.out.println(stopWatch.getTotalTimeMillis());
}

问题
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4f93bf0a]
解决

  1. 添加事务注释

数据库慢?
预先把数据查出来,放到一个更快读取的地方,不再查询数据库.
预加载缓存,定时更新缓存.【找一个用户少的时间更新】(定时任务)

多个机器都要执行任务吗?
分布式锁,控制只有一台机器去执行定时任务,其他机器不再重复执行了.
应用场景:限制只有一部分用户访问(限制数量),例如抢票.

数据查询慢怎么办?

用缓存:提前把数据去出来保存好(通常保存到读写更快的介质,比如内存),就可以更快的读写.

缓存的实现
  • Redis(分布式)

  • memcached(分布式)

  • Etcd(云原生架构的一个分布式存储,存储配置,扩容能力很强)


  • ehcache(早期纯净单机)

  • 本地缓存(Java内存Map)

  • caffeine(Java内存缓存,高性能)

  • Google Guava

什么是单机缓存

  • 单机缓存是将数据存储在本地内存中的缓存机制,用于提升单个应用服务器的性能
  • 用户A访问服务器A(有缓存A),用户B访问服务器B,但是B没有缓存A.
  • 数据不一致性,用户多次访问服务器的时候可能拿到的数据不一致(比如查询余额)

Redis

NoSQL 数据库

key-value(键值对)存储系统(区别MySQL,存储的是键值对)

Redis的数据结构
  • String字符串类型:name:”yub”
  • List列表:names:[“yub”,”dogYub”]
  • Set集合:naems[“yub”,”dogyub”](值不能重复)
  • Hash哈希:nameAge{ “yub” : 1, “dogYub”:2}(键不能重复)
  • Zset集合 :names:{yub - 1, dogYub - 9 } (值多一个分数)

  • bloomfilter(布隆过滤器,主要从大量的数据中快速过滤值,比如邮件黑名单拦截)
  • geo(计算地理位置)
  • hyperloglog(pv/uv)
  • pub/sub(发布订阅,类似消息队列)
  • BitMap(01010101010101,存储大量可以压缩的信息)
Java中的实现方法
Spring Data Redis(推荐)

Spring Data:通用的数据访问框架,定义了一组增删改查的接口(操作mysql、jpa、redis)
Spring Data Redis就可以看成其对应的实现类

1)引入

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.6.13</version>
</dependency>

2)配置Redis地址

1
2
3
4
5
6
spring:
#redis 配置
redis:
port: 6379
host: localhost
database: 0
Jedis

独立于Spring操作 Redis 的 Java 客户端(默认线程不安全)
配合Jedis Pool使用

Lettuce

高阶的操作 Redis 的Java客户端
支持异步和连接池(复用,减少开销)

Redisson

分布式操作Redis的Java客户端,像在使用本地的集合一样操作Redis(分布式Redis数据网格)

JetCache

对比

  1. 使用的是Spring,没有过多的定制化要求,可以用Spring Data Redis,最方便
  2. 用的不是Spring,且追求简单化没有过高的性能要求,可以用Jedis+Jedis Pool
  3. 项目不是Spring,且追求高性能、高定制化,可以用Lettuce
  4. 项目是分布式的,需要用到一些分布式的特性(比如分布式锁、分布式集合),推荐使用redission

问题
设置Redis增、查测试,再QuickRedis中查询不到内容.(乱码引起)

image-20250126172817712

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
//大多情况都使用String【灵活】    
@SpringBootTest
public class RedisTest {

@Resource
private RedisTemplate redisTemplate;


@Test
void test() {
ValueOperations valueOperations = redisTemplate.opsForValue();
//增
valueOperations.set("yubString","god");
valueOperations.set("yubInt",1);
valueOperations.set("yubDouble",2.0);
User user = new User();
user.setId(518L);
user.setUsername("fun");
valueOperations.set("yubUser",user);
//查
Object yub = valueOperations.get("yubString");
Assertions.assertTrue("god".equals((String) yub));
yub = valueOperations.get("yubInt");
Assertions.assertTrue(1 == (Integer) yub);
yub = valueOperations.get("yubDouble");
Assertions.assertTrue(2.0 == (Double) yub);
System.out.println(valueOperations.get("yubUser"));
}
}

利用cmd写入新的yubString再观察(cmd默认存储到db0库)

1
2
3
4
5
6
7
8
9
10
>redis-cli
127.0.0.1:6379> get
(error) ERR wrong number of arguments for 'get' command
127.0.0.1:6379> get yubString
(nil)
127.0.0.1:6379> set yubString good
OK
127.0.0.1:6379> get yubString
"good"
127.0.0.1:6379>

追踪源码发现Redis默认的(反)序列化构造器是JDK原生序列化影响

image-20250126174824202image-20250126174719972

且查找源码发现只有一个StringRedisTemplate实现(),那么测试类Integer就无法转换.

1
2
3
4
5
6
7
public class StringRedisTemplate extends RedisTemplate<String, String> {
public StringRedisTemplate() {
this.setKeySerializer(RedisSerializer.string());
this.setValueSerializer(RedisSerializer.string());
this.setHashKeySerializer(RedisSerializer.string());
this.setHashValueSerializer(RedisSerializer.string());
}

此时只能自定义RedisTemplate,ConnectionFactory + 自己需要的序列化方式

1
2
3
4
5
6
7
8
9
10
@Configuration
public class RedisTemplateConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setKeySerializer(RedisSerializer.string());
return redisTemplate;
}
}

发现成功,对应传进来的也是对象(序列化之故).
image-20250126182958609

查询发现ok

1
2
3
4
5
127.0.0.1:6379> select 1
OK
127.0.0.1:6379[1]> get yubString
"\xac\xed\x00\x05t\x00\x03god"
127.0.0.1:6379[1]>
设计缓存Key

目标:不同的用户看到的数据不同
systemId:modleId:func:(原则不要和别人冲突)
yub:user:recommed:userId
redis内存不能无限增加,一定要设置过期时间

缓存预热

问题:第一个用户访问还是很慢怎么办?

  • 缓存预热,可以让用户访问很快(解决该问题)且在一定程度上能保护数据库.
  • 但增加了开发成本(需要额外的开发、设计),预热的时机和时间如果错了,有可能缓存的数据不对或太老
  • 需要占用空间
怎么缓存?
  1. 定时触发(常用)
  2. 模拟触发(手动触发)

实现
用定时任务,每天刷新所有用户的推荐
注意

  1. 缓存预热的意义(系统每日新增不多但是总的用户量多,提前一天缓存数据量也不大且能提高用户的加载速度)
  2. 缓存的空间不能太大,要预留给其他缓存空间(可能很多项目用同一个Redis)
  3. 缓存数据的周期(此处每天一次)
定时任务实现
  1. Spring Scheduler(SpringBoot默认整合)
  2. Quartz(独立于Spring存在的定时任务框架)
  3. XXL—Job之类的分布式任务调度平台

第一种方式

  1. 主类开启@EnableScheduling

  2. 给要执行定时任务的方法添加@Scheduling注解,指定cron表达式或者执行频率(注意Spring中cron只接收六位如0 20 15 * * *,和Linux有所不同)

    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
    @Component
    @Slf4j
    public class PreCacheJob {
    @Resource
    private UserService userService;
    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    //重点用户
    private List<Long> mainUserList = Arrays.asList(1L);

    //每天执行预热推荐用户
    @Scheduled(cron = "0 53 22 * * *")
    public void doCacheRecommendUser() {
    for (Long userId : mainUserList) {
    QueryWrapper queryWrapper = new QueryWrapper<>();
    Page<User> userPage = userService.page(new Page<>(1, 20), queryWrapper);
    String redisKey = String.format("yub:user:recommed:userId:%s", userId);
    ValueOperations<String, Object> valueOperations = redisTemplate.opsForValue();
    try {
    valueOperations.set(redisKey, userPage,30000, TimeUnit.MILLISECONDS);
    } catch (Exception e) {
    log.error("redis set ket error",e);
    }
    }
    }

    }

    在线Cron表达式生成器 - 码工具

控制定时任务的执行
  1. 避免资源浪费,1w台服务器同时“打鸣”
  2. 脏数据,比如重复插入

要控制定时任务在同一时间只有1个服务器能运行定时任务.

怎么做?

  1. 分离定时任务和主程序,只在1个服务器能运行定时任务,成本太高.

  2. 写死配置,每个服务器都能执行定时任务,但是只有ip符合配置的服务器才真实执行业务逻辑(写死),其他的直接返回,成本最低.但是我们的IP可能是不固定的.

  3. 动态配置,配置方便更新(代码无需重启).只有ip符合配置的服务器才真实执行业务逻辑.

    • 数据库

    • Redis

    • 配置中心(Nacos、Apollo、Spring Cloud Config)

      Q:服务器多了,IP不可控还是很麻烦要人工修改

  4. 分布式锁,只有抢到锁的服务器才能执行业务逻辑.缺点:增加成本;优点:不用手动配置,多少个服务器都一视同仁.

单机就会存在单点故障.

在有限资源的情况下,控制同一时间(段)只有某些线程(用户/服务器)能访问到资源.
Java实现锁:synchronized关键字、并发包的类

分布式锁

为啥需要分布式锁?

  1. 在有限资源的情况下,控制同一时间(段)只有某些线程(用户/服务器)能访问到资源.
  2. 单个锁只对单个JVM有效.
分布式锁实现的关键
抢锁机制

怎么保证同一时间只有1个服务器能抢到锁?

核心思想
先来的人吧数据改成自己的标识(服务器ip),后来的人发现标识已存在,抢锁失败,继续等待.等先来的人执行方法结束,清空标识之后其他的人继续抢锁.

MySQL数据库:select for update行级锁,保证同一时间段有一个线程对数据库进行查询和更改,这个期间其他线程无法插足.(最简单)也可以用乐观锁实现.
Redis存标识:内存数据库,读写速度快.支持setnx、lua脚本,比较方便实现分布锁.
setnx:set if not exists 如果不存在,则设置;只有设置成功次啊会返ture,否则返回false.

image-20250129234041235

注意事项

  1. 用完锁要释放del lock

  2. 锁一定要加过期时间set lock uum ex 10 nx

  3. 如果方法执行过长,锁提前过期?

    问题:

    1. 连锁效应:释放掉别人的锁.(还是会存在多个方法同时执行的情况,执行完的时间>加锁时间)比如A执行40s,加锁30s,B执行30s,加锁30s;A没执行完,数据库标识已经更改为B的了.b
    2. 还是会存在多个方法同时执行的情况?
    • 解决连锁效应:在释放锁之前检查一下当前的锁是不是自己标识的,不是则忽略.
    • 解决锁未执行完:续期set lock uum ex 10 nx
    1
    2
    3
    4
    5
    6
    boolean end = false;
    new Thread (() -> {
    if(!end) {
    续期
    })
    end = true;
  4. 释放锁的时候,有可能先判断出是自己的锁,结果锁刚好过期了,最后还是释放了别人的锁

    1
    2
    3
    4
    5
    //原子操作
    if(get lock == A) {
    // set lock B 不允许
    del lock
    }

    Redis+lua脚本实现

  5. Redis如果是集群(而不是只有一个Redis),如果分布式锁的数据不同步怎么办?

Zookeeper实现

Redisson实现分布式锁

Java客户端,数据网格,实现了很多Java里支持的接口和数据结构.
是一俄格Java操作Redis的客户端,提供了大量的分布式数据集来简化对Redis的操作和使用,可以让开发者像使用本地集合一样使用Redis,完全感知不到Redis的存在.

2种引入方式
  1. spring boot starter引入(不推荐)
  2. 直接引入:Getting Started - Redisson Reference Guide

实例代码

1
2
3
4
5
6
7
8
9
10
11
//list 数据存储在 JVM 中
List<String> list = new ArrayList<>();
list.add("yub");
System.out.println("list:" + list.get(0));

list.remove(0);
//数据存储在 redis 内存中
RList<String> rList = redissonClient.getList("test-list");
rList.add("yub");
System.out.println("rList:" + rList.get(0));
rList.remove(0);
定时任务+锁
  1. waitTime设置为0,只抢一次,抢不到就放弃
  2. 注意释放锁要写在finally中

面试官:说一下红锁RedLock的实现原理? - Java中文社群 - SegmentFault 思否

看门狗机制

redisson中提供的续期机制

开一个监听线程,如果方法还没执行完,就帮你重置redis锁的过期时间.
原理

  1. 监听当前线程,默认过期时间是30s,每10s续期一次(补到30s)
  2. 如果线程挂掉(debug模式也会被当成服务器宕机),则不会续期
  3. Redisson 分布式锁的watch dog自动续期机制_redisson续期-CSDN博客
实现代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void testWatchDog() {
RLock lock = redissonClient.getLock("yub:user:recommed:doCache:lock");
try {
//只有一个线程能获取锁
if(lock.tryLock(0,-1, TimeUnit.MILLISECONDS)) {
//TODO 实际要执行的方法
System.out.println("getLock:" + Thread.currentThread().getId());
}
} catch (InterruptedException e) {
log.error(e.getMessage());
}finally {
//只能释放自己的锁
if(lock.isHeldByCurrentThread()) {
System.out.println("unLock:" + Thread.currentThread().getId());
lock.unlock();
}
}
}

组队功能

需求分析

用户可以创建一个队伍,设置队伍的人数、队伍名称(标题)、描述、超时时间 P0

队长、剩余的人数
聊天?
公开 or private or 加密
信息流中不展示已过期的队伍

展示队伍列表,根据标签或名称搜索队伍 P0
修改队伍信息 P0~P1
用户创建队伍上限5个
用户可以加入队伍(其他人、未满、未过期),允许加入多个队伍,但是有上限 P0

是否需要队长同意?筛选审批?转让队伍?

用户可以退出队伍(转让队伍给第二时间来的成员)P1
队长可以解散队伍 P0
分享队伍,邀请队员 P1
队伍人满之后发送消息通知 P1

系统(接口)设计
1.创建队伍

用户可以创建一个队伍,设置队伍的人数、队伍名称(标题)、描述、超时时间 P0

队长、剩余的人数
聊天?
公开 or private or 加密
信息流中不展示已过期的队伍

  1. 请求参数是否为空?
  2. 是否登录,未登录不允许创建
  3. 校验信息
    1. 队伍人数 > 1 且 <= 20
    2. 队伍名称 <= 20
    3. 队伍描述 <= 512
    4. status 是否公开(int)不传默认为0(公开)
    5. status 为加密,必须有密码,密码 <= 32
    6. 超时时间 > 当前时间
    7. 校验用户最多创建5个队伍
  4. 插入队伍信息到队伍表
  5. 插入用户 = > 队伍关系用户表
  6. 关联查询已加入队伍的用户信息
2.查询队伍列表

展示队伍列表,根据名称、最大人数等搜索队伍,信息流中不展示已过期的队伍信息

  1. 从请求参数中去除队伍名称,如果存在则作为查询条件
  2. 不展示已经过期的队伍(根据时间筛选)
  3. 可以通过某个关键词同时对名称和描述查询
  4. 只有管理员才能查看加密的房间
  5. 关联查询已加入的队伍的用户信息(可能会很耗费性能,建议自己写SQL实现)

实现方式

  1. 自己写SQL

    1
    2
    3
    4
    5
    6
    7
    8
    9
     1. 自己写SQL
    // 查询队伍和创建人的信息
    select * from team t left join user u on t.userId = u.id
    // 查询队伍和已加入队伍成员的信息
    select * from team t join user_team ut on t.id = user_teamId
    select *
    from team t
    left join user_team ut on t.id = ut.teamId;
    left join user u om ut.userId = u.id;
3. 修改用户信息
  1. 判断请求参数是否为空
  2. 查询队伍是否存在
  3. 只有管理员或者用户创建者可以修改
  4. 如果用户传入的新值和老值一致,就不用update
  5. 如果队伍改成加密,必须要有密码
  6. 更新成功
4. 用户可以加入队伍

其他人、未满、未过期,允许加入多个队伍,但是有上限 P0

  1. 用户最多加入5个队伍
  2. 队伍必须存在,只能加入未满、未过期
  3. 不能重复加入已加入的队伍(幂等性)
  4. 禁止加入私有的队伍
  5. 如果加入队伍是加密的,密码必须匹配
  6. 新增队伍 - 用户关联信息

注意:并发可能出现问题,一定加上事务注解防止数据不一致

5. 用户可以退出队伍

如果队长退出,权限转移给第二早的用户

请求参数:用户id

  1. 校验参数
  2. 校验队伍是否存在
  3. 校验用户是否加入队伍
  4. 如果队伍
    1. 只剩1人,队伍解散
    2. 还有其他人
      1. 队长退出,权限转移给第二早的用户(取id最小的数据)
      2. 非队长直接退出
6. 队长可以解散队伍

请求参数:队伍id
业务流程:

  1. 校验请求参数
  2. 校验队伍是否存在
  3. 校验你是不是队长
  4. 移除所有加入队伍的关联信息
  5. 删除队伍
7. 分享队伍

业务流程:

  1. 生成分享链接/二维码
  2. 用户访问链接,可以点击加入
数据库表设计

队伍表 team
字段:

  • id 主键 bigint(最简单、连续,放在url上比较简短,但缺点是怕爬虫)
  • name 队伍名称
  • description 描述
  • maxNum 最大人数
  • expireTime 超时时间
  • userId 创建人 id
  • status 0 - 公开,1 - 私有,2 - 加密
  • password 密码
  • creatTime 创建时间
  • updateTime 更新时间
  • isDelete 是否删除

两个关系:

  1. 用户加了那些队伍?
  2. 队伍有哪些用户?

方式:

  1. 建立用户-队伍关系表 teamld userld(便于修改,查询性能高一点,可以选择这个,不用全表遍历)
  2. 用户表补充已加入的队伍字段,队伍表补充已加入的用户字段(便于查询,不用写多对多的代码,可以直接根据队伍查用户、根据用户查队伍)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
create table team
(
id bigint auto_increment comment 'id'
primary key,
name varchar(256) not null comment '队伍名称',
description varchar(1024) null comment '描述',
maxNum int default 1 not null comment '最大人数',
expireTime datetime null comment '过期时间',
userId bigint comment '用户id',
status int default 0 not null comment '0 - 公开,1 - 私有,2 - 加密',
password varchar(512) null comment '密码',

createTime datetime default CURRENT_TIMESTAMP null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP,
isDelete tinyint default 0 not null comment '是否删除'
)
comment '队伍';

用户-队伍表 user_team表
字段:

  • id 主键
  • userId 用户 id
  • teamId 队伍 id
  • joinTime 加入时间
  • creatTime 创建时间
  • updateTime 更新时间
  • isDelete 是否删除
1
2
3
4
5
6
7
8
9
10
11
12
create table user_team
(
id bigint auto_increment comment 'id'
primary key,
userId bigint comment '用户id',
teamId bigint comment '队伍id',
joinTime datetime null comment '加入时间',
createTime datetime default CURRENT_TIMESTAMP null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP,
isDelete tinyint default 0 not null comment '是否删除'
)
comment '用户队伍关系';
为什么需要请求参数包装类?
  1. 请求参数名称/类型和实体类不一样
  2. 有一些参数用不到,如果要自动生成接口文档,会增加理解成本
  3. 对个体类映射到同一个对象
为什么需要包装类
  1. 可能有些字段需要隐藏,不能返回给前端
  2. 或者有些字段是不关心的
实现

库表设计
增删改查
业务逻辑开发(P0优先)

随机匹配

高效匹配志同道合的朋友

1. 怎么匹配
  • 匹配多个,按相似度从高到低匹配
  • 根据标签匹配
  • 根据user_team 匹配加入相同队伍的用户

本质:找到相似标签的用户
举例:
用户A:[Java, 大一,女]
用户B:[Java, 大二,女]
用户C:[Python, 大二,女]

  1. 找到有共同标签最多的用户(TopN)
  2. 共同标签越多,分数越高,越在前面
  3. 如果没有匹配用户,随机推荐几个(降级方案)

两种算法
编辑距离算法: 详解编辑距离算法-Levenshtein Distance-CSDN博客

最小编辑距离:字符串 1 通过最少多少次增删改字符的操作可以编程字符串2

余弦相似度算法:用户画像标签数据开发之标签相似度计算-CSDN博客

2. 怎么对所有用户进行匹配,取TOP

直接取出所有用户,一次和当前用户计算分数,取TopN
优化:

  1. 切记不要在数据量大的时候循环输出日志(取消日志20s)
  2. Map存储所有分数信息,占用内存
    解决:维护一个固定长度的有序集合(sortedSet),只保留分数最高的几个用户【TOP 5】,时间换空间
  3. 注意需要除去自己
  4. 尽量只查需要的用户
    1. 过滤掉标签为空的用户
    2. 根据部分标签取用户(前提是能区分出来哪个标签比较重要)
    3. 只查需要的数据(比如id和tags 只用了7s)
  5. 提前查?
    1. 提前缓存所有用户(不适合标签频繁更新的数据)
    2. 提前运算出结果,缓存(针对重点用户,提前缓存)

大数据推荐流程:检索 => 召回 => 粗排 => 精排 => 重排序等等
检索:尽可能多地查符合要求得数据
召回:查询可能要用到的数据(不做运算)
粗排:粗略排序,简单地运算
精排:精细排序,确定固定排位

优化

使用Redis GEO实现距离编辑和搜索附近用户功能.

  1. 数据库表增加字段
    • 新增经纬度字段 decimal
    • 新增纬度字段 decimal
    • RedisZset实现,
  2. UserVO类增加字段
    • 添加distance字段(向前端返回用户间位置信息) double类型
  3. 编写测试类
    • 使用Spring Data Redis提供的StringRedisTemplate(Key/Value都是Stirng类型)
Tips
  1. controller校验是否为空,service层校验是否合法.

  2. 内外层均进行鉴权,对性能影响微乎其微.

  3. 数据类型为非包装类(如long Id),可以不用判空,默认为0.

  4. 在Application类中使用@EnableScheduling开启spring对定时任务的支持.

  5. 分页使用Page(MybatisPlus提供)current自动计算不需利用(pageNum - 1) * pageSize
    image-20250125185506972

    1
    2
    3
    4
    5
    6
    @GetMapping("/recommend")
    public BaseResponse<Page<User>> recommendUsers(long pageSize, long pageNum, HttpServletRequest request) {
    QueryWrapper queryWrapper = new QueryWrapper<>();
    Page<User> userList = userService.page(new Page<>(pageNum, pageSize), queryWrapper);
    return ResultsUtils.success(userList);
    }
  6. MybatisPlus提供批量插入,saveBatch方法实现.

  7. SpringMVC负责post、get等等请求处理

  8. 引入一个库时,先写测试类

  9. ctrl + alt + t可以抛异常包裹

  10. 写缓存,使用@Slf4j中的log.error(),即使失败也可以讲数据库查到的内容返回给前端 故不直接使用全局异常处理器(返回错误给用户)

  11. 分析有缺点时要从整个项目由0到1的链路上分析(比如设计、开发)

  12. 白名单内容不要写死,动态(比如给数据库中做标记)

  13. synchronized只对当前的线程有效(只能控制单个JVM【服务器】)

  14. 设置分布式锁时统一使用setnnx(使用set会进行更改)

  15. 响应值有关安全性

  16. 包装类要使用Optional.ofNullable().orElse()设置默认值

    1
    int maxNum = Optional.ofNullable(team.getMaxNum()).orElse(0);
  17. 开启事务在类上方引入(有增删改事务最好加上)

    1
    @Transactional(rollbackFor = Exception.class)
  18. 开发者工具可以帮助我们获取时间,配合Knif4j测试

    1
    2
    >console.log(JSON.stringify(new Date()))
    "2025-01-31T05:47:02.462Z"
  19. dto:业务封装类;vo:返回给前端的封装类

  20. 关联多个表推荐自己写SQL

  21. 使用equals()时注意翻转,确保不为空对象在前,避免不必要的NPE

  22. 对数据库的操作,建议把用户传参

  23. ctrl + alt + v 提取变量替换

  24. 复用listTeam方法,只新增查询条件,不做修改(开闭原则)

  25. 涉及查数据的地方限制页数,防止数据库泄露、

  26. 加锁synchronized加锁接收的是对象使用String.valueOf(userId).intern()锁住常量(同一个对象 intern()实现)

  27. ErrorCode枚举类中不使用final修饰description取代反射来动态设置description