项目简介

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2025.2 - 2025.05
乐享学习
后端开发

项目简介:乐耍学是一款面向中学生的学习论坛,提供课业答疑,知识共享、学习方法交流等功能,支持发帖、评
论、点赞、私信、热门排行及系统通知等功能,旨在提升学习效率。

*技术架构:SpringBoot + MyBatis + MySQL + Redis + JWT + RabbitMQ + Elasticsearch

项目亮点:
*帖子排行:利用 Sorted Set 实现了帖子排行功能,并使用时间戳拼接元素值的方式,解决了权重相同的排行问
题。
*优化热门 Key 问题:使用 Caffeine+Redis 两级缓存,优化了热门帖子的访问,单机可达 80000QPS。
*异步消息处理:利用 RabbitMQ 异步处理站内通知,在用户被点赞、评论、关注后,放入异步队列,以系统通知的
方式推送给用户,降低系统耦合度,提升消息处理效率。
*精准全文搜索:利用 ElasticSearch,可准确匹配搜索结果,并高亮显示关键词。
*UV 和 DAU 统计:利用 HyperLogLog、Bitmap 分别实现了 UV、DAU 的统计功能,100 万用户数据只需
0.129M 内存空间。
*全局异常处理:使用枚举类和自定义异常类实现 SpringBoot 全局异常处理,优化了代码的可读性和异常处理规范
性。

项目收获:
提高了学习能力,在实践中学习项目中未掌握的技术点,例如 RabbitMQ 的应用和两级缓存的优化等

帖子排行(Sorted Set + 时间戳)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
帖子排行:利用 Sorted Set 实现了帖子排行功能,并使用时间戳拼接元素值的方式,解决了权重相同的排行问
题。

1. Sorted Set(有序集合)核心是一个一个元素关联一个分数。将分数用来作为文章的权重,文章id作为元素。但有时候会出现分数一样的情况,但这时候会使先发表的文章排在后发表的上面,不符合新帖优先的规则。
2. 在在文章id后面加上时间戳,若分数一样Redis会默认按元素值(member)的字典序排序就会比较元素,这时会使后发表的文章出现在前面。

3. 详细代码实现:
// 伪代码:更新帖子热度排行,写入Redis
String postId = "1001";
long timestamp = 1620000000; // 帖子发布时的时间戳(固定不变)
double newScore = calculateScore(likeCount, commentCount, collectCount); // 计算新权重
redisTemplate.opsForZSet().add("rank:post:hot", "post:" + postId + ":" + timestamp, newScore);

// 伪代码:查询 Top 10 热门帖子
Set<String> topPosts = redisTemplate.opsForZSet().reverseRange("rank:post:hot", 0, 9);
for (String member : topPosts) {
String postId = member.split(":")[1]; // 从 "post:1001:1620000000" 中提取 ID
// 根据 postId 查询帖子详情并返回
}

优化热门 Key 问题( Caffeine+Redis 两级缓存)

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
69
70
71
72
73
74
75
1. Caffeine选型原因:Java 领域性能最优的本地缓存框架,基于 LRU(最近最少使用)算法,支持过期时间、最大容量限制,且命中率比 Guava 缓存高 10%-20%。

2. Redis(分布式缓存)存储全量热门帖子数据(约 1000 条),作为本地缓存的 “备份”,同时保证多机部署时的数据一致性。

3. 采用 “先查本地缓存,再查分布式缓存,最后查数据库” 的三级查询流程,结合 “更新数据库后主动删除缓存” 的写策略

/**
* 手动实现两级缓存查询
*/
public ArticleDTO getArticleById(String id) {
// 1. 先查询Caffeine本地缓存
ArticleDTO articleDTO = caffeineCache.getIfPresent(id);
if (articleDTO != null) {
System.out.println("从Caffeine缓存获取文章: " + id);
return articleDTO;
}

// 2. 本地缓存未命中,查询Redis
String redisKey = REDIS_CACHE_PREFIX + id;
String jsonStr = redisTemplate.opsForValue().get(redisKey);
if (jsonStr != null) {
articleDTO = JSON.parseObject(jsonStr, ArticleDTO.class);
System.out.println("从Redis缓存获取文章: " + id);

// 同步到本地缓存
caffeineCache.put(id, articleDTO);
return articleDTO;
}

// 3. 缓存均未命中,查询数据库
Article article = articleMapper.selectById(id);
if (article == null) {
return null;
}

// 转换为DTO
articleDTO = convertToDTO(article);
System.out.println("从数据库获取文章: " + id);

// 4. 写入两级缓存
// 写入本地缓存
caffeineCache.put(id, articleDTO);
// 写入Redis缓存并设置过期时间
redisTemplate.opsForValue().set(redisKey, JSON.toJSONString(articleDTO),
REDIS_EXPIRE_MINUTES, TimeUnit.MINUTES);

return articleDTO;
}

/**
* 更新文章并手动清除缓存
*/
public void updateArticle(String id, ArticleDTO dto) {
// 1. 先更新数据库
Article article = convertToEntity(dto);
article.setId(id);
articleMapper.updateById(article);

// 2. 清除两级缓存
caffeineCache.invalidate(id);
System.out.println("清除Caffeine缓存: " + id);

// 清除Redis缓存
String redisKey = REDIS_CACHE_PREFIX + id;
redisTemplate.delete(redisKey);
System.out.println("清除Redis缓存: " + id);
}

4. 数据来源
压测过程:采用 “梯度加压” 方式,从 1000QPS 开始,每次增加 10000QPS,每个梯度稳定运行 5 分钟,观察系统是否出现超时、错误

监控指标:通过 JMeter 记录接口响应时间(P99、平均响应时间)、错误率;通过 Prometheus+Grafana 监控应用服务器 CPU、内存使用率,Redis 的 QPS 和内存占用,数据库的查询次数。”

“在测试中,当压测到 80000QPS 时,系统表现稳定:接口平均响应时间 15ms,P99 响应时间 30ms,错误率 0%;应用服务器 CPU 使用率 70%,内存稳定(无 OOM 风险);Redis QPS 约 4000(仅处理本地缓存未命中的请求),无明显瓶颈

异步消息处理(RabbitMQ)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1. 配置手动确认和重试机制
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
virtual-host: /
listener:
simple:
acknowledge-mode: manual # 手动确认消息,确保处理完成后再删除
retry:
enabled: true # 开启重试机制
max-attempts: 3 # 最大重试次数

2.

es使用

1

UV 和 DAU 统计(HyperLogLog、Bitmap 访问网站的用户数量和活跃的用户数量)

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
1. HyperLogLog:(UV)
优势:无需存储完整用户 ID,通过概率算法估算基数(去重后的数据量),内存占用极小。
特点:误差率约 0.8%,完全满足运营统计需求;100 万用户仅需约 12KB 内存。

2. Bitmap:(DAU)
优势:用二进制位表示用户状态(0 = 不活跃,1 = 活跃),存储空间极致压缩。
特点:精确去重;100 万用户仅需约 125KB(1000000/8/10240.122MB)内存。

3. UV统计:
@Service
public class UVStatService {

@Resource
private RedisTemplate<String, Object> redisTemplate;

// UV键前缀 + 日期(如uv:20240815)
private static final String UV_KEY_PREFIX = "uv:";
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");

/**
* 记录用户访问,用于UV统计
* @param userId 用户ID
*/
public void recordVisit(String userId) {
// 生成当天的UV键(如uv:20240815)
String key = UV_KEY_PREFIX + LocalDate.now().format(DATE_FORMATTER);

// 添加用户ID到HyperLogLog(自动去重)
// PFADD:如果用户ID已存在,不会重复计数
redisTemplate.opsForHyperLogLog().add(key, userId);
}

/**
* 获取当天UV数
*/
public long getTodayUV() {
String key = UV_KEY_PREFIX + LocalDate.now().format(DATE_FORMATTER);
// PFCOUNT:返回HyperLogLog估算的基数(去重用户数)
return redisTemplate.opsForHyperLogLog().size(key);
}

/**
* 获取指定日期的UV数
*/
public long getUVByDate(LocalDate date) {
String key = UV_KEY_PREFIX + date.format(DATE_FORMATTER);
return redisTemplate.opsForHyperLogLog().size(key);
}

/**
* 合并多天UV(如获取本周UV)
*/
public long mergeUV(LocalDate startDate, LocalDate endDate) {
// 生成临时键用于存储合并结果
String tempKey = "uv:merge:" + System.currentTimeMillis();
// 收集日期范围内的所有UV键
while (!startDate.isAfter(endDate)) {
String key = UV_KEY_PREFIX + startDate.format(DATE_FORMATTER);
//将当天的UV合并到临时键中
redisTemplate.opsForHyperLogLog().union(tempKey, key);
startDate = startDate.plusDays(1);
}
// 获取合并后的UV数
long total = redisTemplate.opsForHyperLogLog().size(tempKey);
// 删除临时键
redisTemplate.delete(tempKey);
return total;
}
}


4. DAU统计
@Service
public class DAUStatService {

@Resource
private RedisTemplate<String, Object> redisTemplate;

// DAU键前缀 + 日期(如dau:20240815)
private static final String DAU_KEY_PREFIX = "dau:";
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");

/**
* 记录用户活跃,用于DAU统计
* @param userId 用户ID(需转为数字,如通过哈希映射)
*/
public void recordActive(Long userId) {
// 生成当天的DAU键(如dau:20240815)
String key = DAU_KEY_PREFIX + LocalDate.now().format(DATE_FORMATTER);

// 将userId对应的位设为1(表示活跃)
// BITSET:第n位=1,用户ID需映射为数字索引(如0~1000000)
redisTemplate.opsForValue().setBit(key, userId, true);
}

/**
* 获取当天DAU数
*/
public long getTodayDAU() {
String key = DAU_KEY_PREFIX + LocalDate.now().format(DATE_FORMATTER);
// BITCOUNT:统计键中值为1的位数量(活跃用户数)
return redisTemplate.opsForValue().bitCount(key);
}

/**
* 获取指定日期的DAU数
*/
public long getDAUByDate(LocalDate date) {
String key = DAU_KEY_PREFIX + date.format(DATE_FORMATTER);
return redisTemplate.opsForValue().bitCount(key);
}

/**
* 检查用户当天是否活跃
*/
public boolean isActiveToday(Long userId) {
String key = DAU_KEY_PREFIX + LocalDate.now().format(DATE_FORMATTER);
// BITGET:获取第n位的值(true=活跃,false=不活跃)
return redisTemplate.opsForValue().getBit(key, userId);
}

/**
* 按月归档DAU(节省空间)
* 如将dau:20240801~dau:20240831合并为dau:202408
*/
public void archiveMonthly(LocalDate month) {
String archiveKey = "dau:archive:" + month.format(DateTimeFormatter.ofPattern("yyyyMM"));
LocalDate firstDay = month.withDayOfMonth(1);
LocalDate lastDay = month.plusMonths(1).withDayOfMonth(1).minusDays(1);

// 合并当月所有DAU的Bitmap(OR操作:只要某天活跃则当月活跃)
while (!firstDay.isAfter(lastDay)) {
String dailyKey = DAU_KEY_PREFIX + firstDay.format(DATE_FORMATTER);
redisTemplate.getConnectionFactory().getConnection()
.bitOp(
BitOp.OR,
archiveKey.getBytes(),
dailyKey.getBytes()
);
firstDay = firstDay.plusDays(1);
}
}
}

占位

1