Skip to content

Commit fea6f11

Browse files
author
yqz
committed
feat:增加文章和列表缓存逻辑
1 parent 6713d52 commit fea6f11

6 files changed

Lines changed: 155 additions & 4 deletions

File tree

OpenBlog-business/src/main/java/com/yqz/openblog/article/service/ArticlePublishedContentCacheService.java

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package com.yqz.openblog.article.service;
22

3+
import com.fasterxml.jackson.core.type.TypeReference;
34
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.yqz.openblog.article.dto.ArticleListItemResponse;
46
import com.yqz.openblog.article.dto.ArticlePublishedContentCachePayload;
7+
import com.yqz.openblog.common.PageResult;
58
import com.yqz.openblog.config.CacheProperties;
69
import org.slf4j.Logger;
710
import org.slf4j.LoggerFactory;
@@ -12,14 +15,25 @@
1215
import java.util.Optional;
1316

1417
/**
15-
* 已发布文章「正文 + 相对稳定元数据」的 Redis 缓存(默认 30 分钟)
18+
* 已发布文章的 Redis 缓存服务
1619
* <p>
20+
* 包含两部分缓存:
21+
* <ol>
22+
* <li>文章正文缓存(个体):缓存相对稳定的正文与元数据,计数类字段每次从数据库合并。</li>
23+
* <li>文章列表缓存(分页):使用版本号机制,任何影响列表的写操作会递增版本号,
24+
* 使旧版本缓存自然过期,避免全量扫描删除。</li>
25+
* </ol>
1726
* Redis 不可用或读写异常时:读侧返回 empty,写侧忽略,由 {@link ArticleService} 直接走数据库。
1827
*/
1928
@Service
2029
public class ArticlePublishedContentCacheService {
2130

2231
private static final String KEY_PREFIX = "openblog:article:published:content:";
32+
private static final String LIST_KEY_PREFIX = "openblog:article:published:list:v";
33+
private static final String LIST_VERSION_KEY = "openblog:article:published:list:version";
34+
35+
private static final TypeReference<PageResult<ArticleListItemResponse>> LIST_PAGE_TYPE =
36+
new TypeReference<>() {};
2337

2438
private static final Logger log = LoggerFactory.getLogger(ArticlePublishedContentCacheService.class);
2539

@@ -36,6 +50,8 @@ public ArticlePublishedContentCacheService(
3650
this.cacheProperties = cacheProperties;
3751
}
3852

53+
// ==================== 文章正文缓存(个体) ====================
54+
3955
public Optional<ArticlePublishedContentCachePayload> get(Long articleId) {
4056
if (articleId == null) {
4157
return Optional.empty();
@@ -76,7 +92,68 @@ public void evict(Long articleId) {
7692
}
7793
}
7894

95+
// ==================== 文章列表缓存(分页) ====================
96+
97+
/**
98+
* 获取当前列表版本号。版本号用于构造缓存 key,不存在时默认为 0。
99+
*/
100+
private long getListVersion() {
101+
try {
102+
String v = redisTemplate.opsForValue().get(LIST_VERSION_KEY);
103+
return v == null ? 0L : Long.parseLong(v);
104+
} catch (Exception e) {
105+
log.warn("读取列表版本号失败,降级为 version=0。", e);
106+
return 0L;
107+
}
108+
}
109+
110+
/**
111+
* 递增列表版本号,使所有旧版本列表缓存自然过期(靠 TTL 清理)。
112+
* 任何影响已发布列表的写操作(发布、取消发布、更新已发布文章、删除)都应调用此方法。
113+
*/
114+
public void evictPublishedList() {
115+
try {
116+
redisTemplate.opsForValue().increment(LIST_VERSION_KEY);
117+
} catch (Exception e) {
118+
log.warn("递增列表版本号失败(已忽略)。", e);
119+
}
120+
}
121+
122+
public Optional<PageResult<ArticleListItemResponse>> getList(Long categoryId, int page, int size) {
123+
try {
124+
long version = getListVersion();
125+
String json = redisTemplate.opsForValue().get(listKey(version, categoryId, page, size));
126+
if (json == null || json.isBlank()) {
127+
return Optional.empty();
128+
}
129+
return Optional.of(objectMapper.readValue(json, LIST_PAGE_TYPE));
130+
} catch (Exception e) {
131+
log.warn("读取文章列表缓存失败,降级为数据库。categoryId={}, page={}, size={}", categoryId, page, size, e);
132+
return Optional.empty();
133+
}
134+
}
135+
136+
public void putList(Long categoryId, int page, int size, PageResult<ArticleListItemResponse> payload) {
137+
if (payload == null) {
138+
return;
139+
}
140+
int minutes = Math.max(1, cacheProperties.getArticleListTtlMinutes());
141+
try {
142+
long version = getListVersion();
143+
String json = objectMapper.writeValueAsString(payload);
144+
redisTemplate.opsForValue().set(listKey(version, categoryId, page, size), json, Duration.ofMinutes(minutes));
145+
} catch (Exception e) {
146+
log.warn("写入文章列表缓存失败(已忽略)。categoryId={}, page={}, size={}", categoryId, page, size, e);
147+
}
148+
}
149+
150+
// ==================== Key 构造 ====================
151+
79152
private static String key(Long articleId) {
80153
return KEY_PREFIX + articleId;
81154
}
155+
156+
private static String listKey(long version, Long categoryId, int page, int size) {
157+
return LIST_KEY_PREFIX + version + ":" + (categoryId == null ? "all" : categoryId) + ":" + page + ":" + size;
158+
}
82159
}

OpenBlog-business/src/main/java/com/yqz/openblog/article/service/ArticleService.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,11 @@ private ArticleDetailResponse mergePublishedDetailFromCache(ArticlePublishedCont
158158
}
159159

160160
public PageResult<ArticleListItemResponse> listPublished(int page, int size, Long categoryId) {
161+
Optional<PageResult<ArticleListItemResponse>> cached = publishedContentCache.getList(categoryId, page, size);
162+
if (cached.isPresent()) {
163+
return cached.get();
164+
}
165+
161166
Page<Article> mpPage = new Page<>(page + 1L, size);
162167
LambdaQueryWrapper<Article> w = Wrappers.lambdaQuery();
163168
w.eq(Article::getStatus, ArticleStatus.PUBLISHED)
@@ -171,7 +176,9 @@ public PageResult<ArticleListItemResponse> listPublished(int page, int size, Lon
171176
}
172177
IPage<Article> p = articleMapper.selectPage(mpPage, w);
173178
List<ArticleListItemResponse> items = p.getRecords().stream().map(this::mapListItem).toList();
174-
return new PageResult<>(items, page, size, p.getTotal());
179+
PageResult<ArticleListItemResponse> result = new PageResult<>(items, page, size, p.getTotal());
180+
publishedContentCache.putList(categoryId, page, size, result);
181+
return result;
175182
}
176183

177184
public ArticleDetailResponse detailPublished(Long id, String clientIp) {
@@ -281,6 +288,7 @@ public ArticleListItemResponse updateArticle(Long authorId, Long articleId, Arti
281288

282289
if (a.getStatus() == ArticleStatus.PUBLISHED) {
283290
publishedContentCache.evict(articleId);
291+
publishedContentCache.evictPublishedList();
284292
}
285293
return mapListItem(a);
286294
}
@@ -317,6 +325,7 @@ public ArticleListItemResponse publish(Long authorId, Long articleId, Instant pu
317325
a.setRejectedReason(null);
318326
articleMapper.updateById(a);
319327
publishedContentCache.evict(articleId);
328+
publishedContentCache.evictPublishedList();
320329
return mapListItem(a);
321330
}
322331

@@ -334,6 +343,9 @@ public int publishDueScheduled(int batchSize) {
334343
publishedContentCache.evict(id);
335344
}
336345
}
346+
if (published > 0) {
347+
publishedContentCache.evictPublishedList();
348+
}
337349
return published;
338350
}
339351

@@ -353,6 +365,7 @@ public void unpublishOrDelete(Long authorId, Long articleId) {
353365
a.setScheduledAt(null);
354366
articleMapper.updateById(a);
355367
publishedContentCache.evict(articleId);
368+
publishedContentCache.evictPublishedList();
356369
}
357370

358371
public PageResult<ArticleListItemResponse> listMine(Long authorId, int page, int size) {

OpenBlog-business/src/main/java/com/yqz/openblog/common/PageResult.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ public class PageResult<T> {
88
private int size;
99
private long total;
1010

11+
public PageResult() {
12+
}
13+
1114
public PageResult(List<T> items, int page, int size, long total) {
1215
this.items = items;
1316
this.page = page;
@@ -19,15 +22,31 @@ public List<T> getItems() {
1922
return items;
2023
}
2124

25+
public void setItems(List<T> items) {
26+
this.items = items;
27+
}
28+
2229
public int getPage() {
2330
return page;
2431
}
2532

33+
public void setPage(int page) {
34+
this.page = page;
35+
}
36+
2637
public int getSize() {
2738
return size;
2839
}
2940

41+
public void setSize(int size) {
42+
this.size = size;
43+
}
44+
3045
public long getTotal() {
3146
return total;
3247
}
48+
49+
public void setTotal(long total) {
50+
this.total = total;
51+
}
3352
}

OpenBlog-business/src/main/java/com/yqz/openblog/config/CacheProperties.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,24 @@ public class CacheProperties {
1313
*/
1414
private int articlePublishedTtlMinutes = 30;
1515

16+
/**
17+
* 已发布文章列表页在 Redis 中的 TTL(分钟)。列表页变更频率高,TTL 较短以保证新鲜度。
18+
*/
19+
private int articleListTtlMinutes = 5;
20+
1621
public int getArticlePublishedTtlMinutes() {
1722
return articlePublishedTtlMinutes;
1823
}
1924

2025
public void setArticlePublishedTtlMinutes(int articlePublishedTtlMinutes) {
2126
this.articlePublishedTtlMinutes = articlePublishedTtlMinutes;
2227
}
28+
29+
public int getArticleListTtlMinutes() {
30+
return articleListTtlMinutes;
31+
}
32+
33+
public void setArticleListTtlMinutes(int articleListTtlMinutes) {
34+
this.articleListTtlMinutes = articleListTtlMinutes;
35+
}
2336
}

OpenBlog-business/src/main/resources/application.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ openblog:
4040
cache:
4141
# 已发布文章正文(Redis);不可用或异常时自动仅查 MySQL
4242
article-published-ttl-minutes: 30
43+
# 已发布文章列表页(Redis);变更时通过版本号全局失效,短 TTL 兜底
44+
article-list-ttl-minutes: 5
4345
site:
4446
version: "1.0.0"
4547
# 生产环境请改为前端实际域名,例如 https://blog.example.com

README.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,32 @@
3333
- **惰性回填**:迁移后的旧文章 `content_html` 为空,首次读取时自动渲染并写回。
3434
- **图片等二进制资源**:通过 MinIO(或本地文件系统)独立存储,不在数据库中。
3535

36+
### 缓存架构
37+
38+
已发布文章的读取链路使用 Redis 做两级缓存,减少数据库压力:
39+
40+
| 缓存层级 | Redis Key | TTL | 说明 |
41+
|---------|-----------|-----|------|
42+
| 文章正文 | `openblog:article:published:content:{id}` | 30 min | 缓存标题、摘要、正文(Markdown + HTML)、作者、分类等相对稳定字段。计数类字段(阅读量、点赞数等)每次从数据库合并,避免缓存与数据库不一致。 |
43+
| 文章列表 | `openblog:article:published:list:v{version}:{categoryId}:{page}:{size}` | 5 min | 缓存已发布文章分页列表。写操作通过递增全局版本号使旧版本缓存自然过期,无需全量扫描删除。 |
44+
45+
**缓存一致性策略(Cache-Aside)**
46+
47+
```
48+
读取:先查 Redis → 命中返回 → 未命中查 MySQL 并回写 Redis
49+
写入:更新 MySQL → 删除 Redis 缓存 → 下次读取时自动重建
50+
```
51+
52+
| 写操作 | 正文缓存 | 列表缓存 |
53+
|--------|---------|---------|
54+
| 发布文章 | 删除 | 递增版本号(全局失效) |
55+
| 更新已发布文章 | 删除 | 递增版本号 |
56+
| 删除/下架文章 | 删除 | 递增版本号 |
57+
| 定时发布 | 逐条删除 | 批量完成后递增版本号 |
58+
| 创建草稿 | 不处理 | 不处理 |
59+
60+
**故障降级**:Redis 读写异常时,读侧返回空(走数据库),写侧忽略异常并记录日志,不影响正常业务响应。
61+
3662
---
3763

3864
## 技术栈
@@ -43,7 +69,7 @@
4369
- Spring Boot **3.5.x**(Web、Validation、Security、Data JPA、Data Redis)
4470
- MyBatis-Plus **3.5.x**(与 JPA 并存,按模块使用)
4571
- MySQL **8**(Hibernate `ddl-auto: update` 便于开发迭代)
46-
- Redis(文章正文缓存、阅读量滑动窗口等
72+
- Redis(文章正文缓存、列表页缓存、阅读量滑动窗口去重
4773
- JWT(jjwt **0.12.x**
4874
- MinIO 对象存储(图片上传,可选本地文件系统回退)
4975
- flexmark(服务端 Markdown → HTML 预渲染)
@@ -74,7 +100,7 @@ OpenBlog/
74100
│ ├── java/com/yqz/openblog/
75101
│ │ ├── article/ # 文章:实体、DTO、服务、导入/导出、定时发布
76102
│ │ │ ├── entity/ # Article, ArticleBody(正文独立存储)
77-
│ │ │ ├── service/ # 文章服务、缓存、阅读量、MarkdownRenderer
103+
│ │ │ ├── service/ # 文章服务、缓存(正文+列表)、阅读量、导入导出
78104
│ │ │ └── repo/ # MyBatis-Plus Mapper + JPA Repository
79105
│ │ ├── category/ # 分类
80106
│ │ ├── changelog/ # 更新日志
@@ -121,6 +147,7 @@ OpenBlog/
121147
| `spring.data.redis.*` | Redis 主机、端口、超时等 |
122148
| `openblog.jwt.*` | JWT 密钥、签发方、Access/Refresh 过期时间(秒) |
123149
| `openblog.storage.*` | 本地上传根目录、`public-base-url`(对外访问文件与拼 URL 用)、缩略图长边像素 |
150+
| `openblog.cache.*` | 文章正文缓存 TTL(`article-published-ttl-minutes`,默认 30)、列表缓存 TTL(`article-list-ttl-minutes`,默认 5) |
124151

125152
**务必在部署环境中:**
126153

0 commit comments

Comments
 (0)