1 准备对应的DTO,VO,PO等文件
1.1 po
package com.xiaoce.zhiguang.knowpost.domain.po;
import lombok.Builder;
import lombok.Data;
import java.time.Instant;
/**
* KnowPostDetailRow
* <p>
* TODO: 请在此处简要描述类的功能
*
* @author 小策
* @date 2026/2/6 14:52
*/
@Data
@Builder
public class KnowPostDetailRow {
private Long id;
private Long creatorId;
private String title;
private String description;
private String tags; // JSON 字符串
private String imgUrls; // JSON 字符串
private String contentUrl;
private String contentEtag;
private String contentSha256;
private String authorAvatar;
private String authorNickname;
private String authorTagJson;
private Instant publishTime;
private Boolean isTop;
private String visible;
private String type;
private String status;
}
package com.xiaoce.zhiguang.knowpost.domain.po;
import lombok.Data;
import java.time.Instant;
/**
* KnowPostFeedRow
* <p>
* TODO: 请在此处简要描述类的功能
*
* @author 小策
* @date 2026/2/6 14:53
*/
@Data
public class KnowPostFeedRow {
private Long id;
private String title;
private String description;
private String tags; // JSON 字符串
private String imgUrls; // JSON 字符串
private String authorAvatar;
private String authorNickname;
private String authorTagJson; // 作者的领域标签 JSON
private Instant publishTime;
private Boolean isTop;
}
package com.xiaoce.zhiguang.knowpost.domain.po;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.time.Instant;
/**
* <p>
* 知识库文章表
* </p>
*
* @author 小策
* @since 2026-01-20
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("know_posts")
@Builder
@Schema(description = "知识库文章实体对象")
public class KnowPosts implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "主键ID (雪花算法生成)", example = "1745678901234567890")
@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;
@Schema(description = "作者ID,关联 users.id", example = "1745678901234567800")
private Long creatorId;
@Schema(description = "主分类ID", example = "101")
private Long tagId;
@Schema(description = "标签数组 JSON", example = "[\"Java\", \"Spring\"]")
private String tags;
@Schema(description = "标题", example = "深入理解 Java 虚拟机")
private String title;
@Schema(description = "摘要", example = "本文详细介绍了 JVM 的内存模型...")
private String description;
@Schema(description = "文章内容OSS地址 (大字段分离)", example = "https://oss.zhiguang.com/posts/content/1.md")
private String contentUrl;
@Schema(description = "OSS Key", example = "posts/content/1.md")
private String contentObjectKey;
@Schema(description = "OSS ETag校验")
private String contentEtag;
@Schema(description = "正文大小 (字节)")
private Long contentSize;
@Schema(description = "正文SHA-256哈希(hex)")
private String contentSha256;
@Schema(description = "是否置顶 (0:否, 1:是)", example = "false")
private Boolean isTop;
@Schema(description = "类型 (image, text, video)", example = "text")
private String type;
@Schema(description = "状态: draft(草稿), auditing(审核中), published(已发布)", example = "published")
private String status;
@Schema(description = "可见性 (public/private)", example = "public")
private String visible;
@Schema(description = "图片列表 JSON", example = "[\"https://oss.../1.jpg\"]")
private String imgUrls;
@Schema(description = "视频地址")
private String videoUrl;
@Schema(description = "创建时间")
private Instant createTime;
@Schema(description = "更新时间")
private Instant updateTime;
@Schema(description = "发布时间")
private Instant publishTime;
}
1.2 DTO
package com.xiaoce.zhiguang.knowpost.domain.dto;
import jakarta.validation.constraints.NotBlank;
/**
* DescriptionSuggestRequest
* <p>
* 知文 AI 摘要请求
*
* @author 小策
* @date 2026/2/6 15:03
*/
public record DescriptionSuggestRequest(
@NotBlank(message = "content 不能为空") String content
) {
}
package com.xiaoce.zhiguang.knowpost.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* KnowPostContentConfirmRequest
* <p>
* 内容上传确认请求。
*
* @author 小策
* @date 2026/2/6 15:05
*/
public record KnowPostContentConfirmRequest(
@NotBlank String objectKey,
@NotBlank String etag,
@NotNull Long size,
@NotBlank String sha256
) {
}
package com.xiaoce.zhiguang.knowpost.domain.dto;
import jakarta.validation.constraints.Size;
import java.util.List;
/**
* KnowPostPatchRequest
* <p>
* 帖子元数据更新请求(部分字段可选)。
*
* @author 小策
* @date 2026/2/6 15:09
*/
public record KnowPostPatchRequest(
String title,
Long tagId,
@Size(max = 20) List<String> tags,
@Size(max = 20) List<String> imgUrls,
String visible,
Boolean isTop,
String description
) {
}
package com.xiaoce.zhiguang.knowpost.domain.dto;
import jakarta.validation.constraints.NotNull;
/**
* KnowPostTopPatchRequest
* <p>
* TODO: 请在此处简要描述类的功能
*
* @author 小策
* @date 2026/2/6 15:15
*/
public record KnowPostTopPatchRequest(
@NotNull Boolean isTop
) {
}
package com.xiaoce.zhiguang.knowpost.domain.dto;
import jakarta.validation.constraints.NotBlank;
/**
* KnowPostVisibilityPatchRequest
* <p>
* TODO: 请在此处简要描述类的功能
*
* @author 小策
* @date 2026/2/6 15:16
*/
public record KnowPostVisibilityPatchRequest(
@NotBlank String visible
) {
}
1.3 vo
package com.xiaoce.zhiguang.knowpost.domain.vo;
/**
* DescriptionSuggestResponse
* <p>
* 知文 AI 摘要响应
*
* @author 小策
* @date 2026/2/6 15:06
*/
public record DescriptionSuggestResponse(
String description
) {
}
package com.xiaoce.zhiguang.knowpost.domain.vo;
import java.util.List;
/**
* FeedItemResponse
* <p>
* 首页 Feed 单条记录
*
* @author 小策
* @date 2026/1/22 19:06
*/
public record FeedItemResponse(
String id,
String title,
String description,
String coverImage,
List<String> tags,
String authorAvatar,
String authorNickname,
String tagJson,
Long likeCount,
Long favoriteCount,
Boolean liked,
Boolean faved,
Boolean isTop
) {
}
package com.xiaoce.zhiguang.knowpost.domain.vo;
import java.util.List;
/**
* FeedPageResponse
* <p>
* 首页 Feed 分页响应。
*
* @author 小策
* @date 2026/1/22 19:06
*/
public record FeedPageResponse(
List<FeedItemResponse> items,
int page,
int size,
boolean hasMore
) {
}
package com.xiaoce.zhiguang.knowpost.domain.vo;
import java.time.Instant;
import java.util.List;
/**
* KnowPostDetailResponse
* <p>
* 知文详情响应。
*
* @author 小策
* @date 2026/2/6 15:08
*/
public record KnowPostDetailResponse(
String id,
String title,
String description,
String contentUrl,
List<String> images,
List<String> tags,
String authorId,
String authorAvatar,
String authorNickname,
String authorTagJson,
Long likeCount,
Long favoriteCount,
Boolean liked,
Boolean faved,
Boolean isTop,
String visible,
String type,
Instant publishTime
) {
}
package com.xiaoce.zhiguang.knowpost.domain.vo;
/**
* KnowPostDraftCreateResponse
* <p>
* 创建草稿响应:返回新建的帖子 ID(字符串避免前端精度丢失)。
*
* @author 小策
* @date 2026/2/6 15:09
*/
public record KnowPostDraftCreateResponse(
String id
) {
}
2 准备一些用到的工具
2.1 雪花ID生成器
这是一个非常经典的 分布式 ID 生成器 实现,采用的是 Twitter 的 Snowflake(雪花)算法。
简单来说,这个类就像是一个 "全球唯一的发号机" 。无论你的系统部署了多少台服务器,只要每台服务器配置了不同的 datacenterId(数据中心ID)和 workerId(机器ID),它们生成的 ID 就永远不会重复,而且大致是按照时间递增的。
下面我为你详细拆解这个类。
这个类是干什么的?
它的作用是生成一个 64位的 Long 类型整数 (例如:1783492348234293),这个数字包含以下信息:
- 符号位 (1位):始终为 0,保证 ID 是正数。
- 时间戳 (41位):记录生成 ID 的时间(毫秒级)。可以使用 69 年。
- 机器标识 (10位):
-
- 5位 数据中心 ID (
datacenterId) - 5位 工作机器 ID (
workerId) - 这保证了不同服务器生成的 ID 不会冲突。
- 5位 数据中心 ID (
- 序列号 (12位) :在 同一 毫秒 内,如果并发量很高,可以通过这个序列号区分不同的请求。一毫秒内最多生成 4096 个 ID。
为什么要写这个?(使用场景)
在单体应用(只有一台服务器,一个数据库)中,我们通常使用数据库的 自增 主键 (Auto Increment) 就够了。
但是在 分布式 系统 / 微服务架构 中,自增主键有很大的缺陷:
- 分库分表难:如果你的数据分布在不同的数据库里,每个库都从 1 开始自增,合并数据时 ID 就会冲突。
- 性能瓶颈:数据库自增需要加锁,高并发写时数据库压力大。
- 信息泄露:自增 ID 是连续的,竞争对手很容易爬取你的数据量(比如今天订单号是 100,明天是 200,就知道你卖了 100 单)。
UUID 也是一种选择,但它太长(字符串类型),且无序,作为数据库主键时会导致 B+ 树索引频繁分裂,写入性能极差。
Snowflake 的优势:
- 全局唯一:解决分库分表冲突。
- 有序递增:基于时间戳,对数据库索引友好,写入性能高。
- 高性能:纯内存位运算,不依赖数据库,每秒可生成几百万个 ID。
如果要自己写,怎么写出来?(开发逻辑)
如果你想手写一个,逻辑其实就是"拼积木"。你需要把 64 个 bit 填满。
核心步骤:
- 确定"基准时间" (Epoch):
-
- 你的代码里定义了
EPOCH = 1704067200000L(2024-01-01)。 - 所有的 ID 都是基于这个时间开始计算偏移量,这样可以延长 ID 的使用寿命。
- 你的代码里定义了
- 定义位数结构:
-
- 一般标准:41位时间 + 5位机房 + 5位机器 + 12位序列。
- 编写 nextId****核心方法 (加锁 synchronized**)**:
-
- 获取当前时间 :
timestamp = currentMillis()。 - 时钟回拨处理(难点):
- 获取当前时间 :
-
-
- 如果当前时间 < 上次生成时间,说明服务器时间被回调了(Bug 或 NTP 校时)。
- 你的代码做了一个很好的优化:如果回拨时间很短(<=5ms),就睡过去,等时间追上来。如果回拨太多,直接报错拒绝生成。
-
-
- 同一 毫秒 内的处理:
-
-
- 如果
timestamp == lastTimestamp,说明并发很高,需要增加序列号sequence++。 - 如果序列号超过 4095(12位最大值),必须等待下一毫秒 (
waitNextMillis)。
- 如果
-
-
- 不同 毫秒 的处理:
-
-
- 序列号归零
sequence = 0。
- 序列号归零
-
- 位运算 拼接:
-
- 利用位移 (
<<) 和 或运算 (|) 把上面几个部分拼成一个long。
- 利用位移 (
代码中的亮点
-
时钟回拨的容错:
-
Java
if (offset <= 5) { Thread.sleep(offset); ... }
-
很多简单的实现遇到时钟回拨直接抛异常,导致服务不可用。你的代码尝试"等待"几毫秒,增加了系统的健壮性。
有没有替代方案?(不要重复造轮子)
虽然了解原理很重要,但在实际的大厂项目中,我们很少自己手写这个类,而是直接用成熟的开源库,避免踩坑(比如时钟回拨的深坑)。
常见的替代方案:
- Hutool 工具包 (推荐中小项目)
-
- Java 最常用的工具库。
- 代码:
IdUtil.getSnowflake(1, 1).nextId() - 极其简单,开箱即用。
- MyBatis-Plus (推荐)
-
- 如果你用了 MyBatis-Plus,它内置了雪花算法。
- 实体类注解:
@TableId(type = IdType.ASSIGN_ID)(其实在我的实体类上我写了这个注释) - 插入数据库时自动生成,完全不用你操心。
- 美团 Leaf (大厂方案)
-
- 美团开源的分布式 ID 生成系统。
- 解决了 workerId 难以管理的问题(自动注册到 ZooKeeper),适合超大规模集群。
- 百度 UidGenerator
-
- 基于 Snowflake 优化,吞吐量更高,但配置稍复杂。
package com.xiaoce.zhiguang.knowpost.id;
import org.springframework.stereotype.Component;
/**
-
SnowflakeIdGeneratorUtils
-
-
线程安全的雪花算法 ID 生成器。
-
41 位时间戳 + 5 位数据中心 + 5 位工作节点 + 12 位序列。
-
@author 小策
-
@date 2026/2/6 15:36
*/
@Component
public class SnowflakeIdGenerator {
// 定义起始时间戳(2024-01-01 00:00:00 UTC)
private static final long EPOCH = 1704067200000L; // 2024-01-01 00:00:00 UTC
// 定义各部分的位数
private static final long WORKER_ID_BITS = 5L; // 工作节点ID的位数
private static final long DATACENTER_ID_BITS = 5L; // 数据中心ID的位数
private static final long SEQUENCE_BITS = 12L; // 序列号的位数
// 计算各部分的最大值
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); // 最大工作节点ID
private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BITS); // 最大数据中心ID
private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
// 定义各部分的偏移量
private static final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; // 工作节点ID的偏移量
private static final long TIMESTAMP_LEFT_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS;
private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS); // 数据中心ID的偏移量
// 时间戳的偏移量
private final long datacenterId; // 序列号掩码
private final long workerId;
// 数据中心ID和工作节点ID
private long lastTimestamp = -1L;
private long sequence = 0L;
// 上次生成ID的时间戳和序列号
public SnowflakeIdGenerator() {
this(1, 1);
}
// 默认构造器,使用默认的数据中心ID和工作节点ID(都为1)
public SnowflakeIdGenerator(long datacenterId, long workerId) {
if (workerId > MAX_WORKER_ID || workerId < 0) {
throw new IllegalArgumentException("workerId out of range");
// 带参数的构造器,可以指定数据中心ID和工作节点ID
}
if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) {
// 检查工作节点ID是否在有效范围内
throw new IllegalArgumentException("datacenterId out of range");
}
this.datacenterId = datacenterId;
this.workerId = workerId;
}
// 检查数据中心ID是否在有效范围内public synchronized long nextId() {
long timestamp = currentTime();
// if (timestamp < lastTimestamp) {
// throw new IllegalStateException("Clock moved backwards. Refusing to generate id");
// }
/**
* 生成下一个ID
* @return 生成的64位ID
*/
// 等待时钟追回的方案
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;// 1. 小幅度回拨(比如 NTP 校时导致的 1~5ms 间抖动):等待一会儿再试 if (offset <= 5) { try { // 睡 offset 毫秒,给系统时钟一点时间"追上来" Thread.sleep(offset); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException("Thread interrupted while waiting for clock to catch up", e); } timestamp = currentTime(); if (timestamp < lastTimestamp) { // 等完还是没追上,说明问题较严重,直接拒绝 throw new IllegalStateException( "Clock is still behind after waiting. last=" + lastTimestamp + ", now=" + timestamp); } } else { // 2. 回拨幅度太大,直接拒绝,避免线程长时间阻塞 throw new IllegalStateException( "Clock moved backwards too much. Refusing to generate id. offset=" + offset + "ms"); } } // 处理同一毫秒内的并发请求:序列号逻辑 if (lastTimestamp == timestamp) { sequence = (sequence + 1) & SEQUENCE_MASK; if (sequence == 0) { // 这一毫秒的 4096 个名额用完了 timestamp = waitNextMillis(lastTimestamp); } } else { sequence = 0L; } lastTimestamp = timestamp; // 组装 64 位 ID return ((timestamp - EPOCH) << TIMESTAMP_LEFT_SHIFT) | (datacenterId << DATACENTER_ID_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence; }/**
-
等待下一个毫秒时间戳
-
该方法用于在当前时间戳小于等于上一个时间戳时,循环获取新的时间戳
-
直到获取到大于上一个时间戳的新时间戳为止
-
@param lastTimestamp 上一次生成的时间戳
-
@return 返回新的时间戳
*/
private long waitNextMillis(long lastTimestamp) {
long timestamp = currentTime(); // 获取当前时间戳
while (timestamp <= lastTimestamp) { // 如果当前时间戳小于等于上一个时间戳,则循环等待
timestamp = currentTime(); // 重新获取当前时间戳
}
return timestamp; // 返回新的时间戳
}private long currentTime() {
return System.currentTimeMillis();
}
}
2.2 缓存页失效和计数监听器
FeedCacheInvalidationListener 监听器 详细解析
这个类是整个 Feed 流系统的 "实时数据维护者"。它的核心职责是监听系统中的点赞、收藏等事件,并利用反向索引技术,精准地找到所有包含该帖子的缓存页面,更新其中的计数,保证用户看到的数据是实时的。
类的核心作用与职责
简单来说,这个类就是一个 "后台自动修正员"。
在 Feed 流(信息流)系统中,为了高性能,我们通常会把生成的页面(例如"推荐页第一页")整个缓存起来。但这就带来了一个大问题:如果用户给某个帖子点了赞,缓存里的数字还是旧的,怎么办?
这个类的作用就是解决这个问题:
- 监听动作 :时刻竖着耳朵听(
@EventListener),看系统里有没有发生"点赞"或"收藏"的事件。 - 联动更新作者数据:如果有人给帖子点赞,顺便把帖子作者的"总获赞数"也加 1。
- 精准定位缓存 :利用 反向索引(Reverse Index),快速查出"这个帖子被缓存在了哪几个页面里"。
- 原地修正:把那些缓存页面拿出来,把点赞数 +1 或 -1,然后塞回去。
为什么要写这个类?
在没有这个类的情况下,你面临两个糟糕的选择:
- 选择 A(不做处理):用户点赞后,刷新页面数字不跳动。用户体验极差,觉得系统卡了。
- 选择 B(暴力删除):用户点赞后,直接把包含这个帖子的整个缓存页面删掉。用户下次刷新时触发回源查数据库。如果热点帖子并发高,数据库会被瞬间打爆(缓存击穿/雪崩)。
这个类实现了 "缓存原地更新",既保证了数据实时性(用户看得到变化),又保护了数据库(不需要回源查询)。
核心逻辑拆解(如果要自己写,怎么写?)
如果你要从零写一个这样的逻辑,可以按照以下步骤进行"拼积木":
第一步:搭建监听框架
你需要一个机制来接收消息。在 Spring 中,使用 @EventListener 监听自定义的 CounterEvent。这相当于一个大喇叭广播,谁关心谁就来听。
第二步:过滤无效信息
广播里可能有很多杂音(比如用户改了头像、关注了别人)。你需要用 if 判断:
- 是不是"帖子"相关的事件?
- 是不是"点赞"或"收藏"这种需要展示数字的事件?
第三步:利用"反向索引"找目标
这是最关键的一步。你不能遍历 Redis 里几百万个页面去检查有没有这个帖子。
- 写入时 :每当生成一个缓存页面,就记录一条索引
feed:index:帖子ID -> [页面Key1, 页面Key2]。 - 更新时 (本类逻辑):直接查
feed:index:帖子ID,Redis 瞬间告诉你需要修改哪几个页面。 - 时间分片技巧:为了防止索引无限膨胀,代码中按"小时"记录索引。查找时,查"当前小时"和"上一小时"即可覆盖绝大多数场景。
第四步:精细化手术(修改数据)
找到缓存页面(JSON)后,不能直接改字符串(太复杂)。
- 反序列化:把 JSON 变成 Java 对象。
- 遍历查找:在对象列表里找到那个 ID 对应的帖子。
- 数学计算:点赞数 +1,注意别减成负数。
- 脱敏处理:
-
- 本地缓存(给特定用户看的):保留"我已点赞"的状态。
- Redis 缓存 (给所有人看的):必须抹除"我已点赞"的状态,否则张三点赞,李四也能看到红心。
第五步:安全写回( TTL 续命)
这是一个容易被忽视的细节。Redis 的 SET 命令默认会清除过期时间。
- 错误做法 :直接
SET key value。结果:原本还有 10 秒过期的热点缓存,变成了永久有效,内存爆炸。 - 正确做法 :先查
getExpire剩余时间,然后SET key value EX 剩余时间。
代码中的关键技术点解析
- @EventListener:Spring 的观察者模式实现,实现了业务逻辑(点赞)和辅助逻辑(更新缓存)的解耦。
- LinkedHashSet:用于合并当前小时和上个小时查到的索引 Key,同时自动去重,防止同一个页面被处理两次。
- preserveUserFlags****参数:这是一个非常细腻的设计。它解决了"公共缓存"与"个性化状态"的冲突问题,确保公共缓存永远是纯净的。
有没有替代方案?
除了这种"监听器 + 反向索引"的模式,业界还有以下几种做法:
- 写扩散( Push Model):
-
- 类似微博/Twitter。大 V 发帖时,直接把数据推送到所有粉丝的"收件箱(Timeline)"里。用户读取时不需要组装。
- 优点:读取极快。
- 缺点:存储成本极其巨大,不适合普通内容平台。
- 纯动态计算(Pull Model):
-
- 缓存里只存 ID 列表
[101, 102, 103]。 - 每次读取时,根据 ID 实时去 Redis 查最新的计数和标题。
- 优点:不需要反向索引,逻辑简单,数据绝对实时。
- 缺点:每次刷新页面需要发起 N 次 Redis 请求(N+1 问题),网络开销大,适合小流量系统。
- 缓存里只存 ID 列表
- 前端伪更新:
-
- 用户点赞后,前端 JS 直接把数字 +1 变红。后端异步处理,不更新列表缓存。
- 优点:后端压力最小。
- 缺点:用户刷新页面后,数字又变回去了(如果缓存没过期),会有"回滚"的感觉。
再简单说一下反向索引
这是一个非常核心的架构设计问题。你代码里的"反向索引"其实就是整个缓存一致性系统的**"导航仪"**。
我就结合你的 FeedCacheInvalidationListener 类,用大白话把这个概念讲透,再看看大厂是怎么玩的。
什么是"反向索引"?
为了理解"反向",我们先得知道什么是"正向"。
➡️ 正向索引(Forward Index)
这是我们生成 Feed 流时的自然逻辑:
- 逻辑:"我要看第 1 页,里面有哪些帖子?"
- 结构 :
页面 Key -> [帖子A, 帖子B, 帖子C] - 你的代码体现 :
feed:public:ids:...(存的是 ID 列表)
⬅️ 反向索引(Reverse / Inverted Index)
这是为了更新数据而存在的"逆向逻辑":
- 逻辑:"帖子 A 发生了变化(比如点赞了),它到底被藏在哪些页面里?"
- 结构 :
帖子ID -> [页面 Key 1, 页面 Key 2, 页面 Key 3] - 你的代码体现:
形象的比喻:
- 正向索引 就像**"书的目录"**:第一章讲了什么,第二章讲了什么。
- 反向索引 就像**"书后的关键词索引"**:单词 "Java" 出现在了第 12 页、第 45 页。
为什么代码里必须用它?
在 FeedCacheInvalidationListener 里,当 onCounterChanged 监听到"帖子 1001 点赞 +1"时:
如果没有反向索引:
- 你不知道帖子 1001 在哪。你只能遍历 Redis 里成千上万个
feed:public:ids:*的 Key,挨个检查里面有没有 1001。
-
- 后果:Redis CPU 飙升,系统卡死,根本不可行。
有了反向索引:
- 你直接问 Redis:
SMEMBERS feed:public:index:1001:当前小时。 - Redis 秒回:
["feed:public:ids:50:1", "feed:public:ids:50:2"]。
-
- 操作:你只需要去修改这两个页面即可,精准打击,效率极高。
大厂(字节/阿里/腾讯)是怎么用反向索引的?
在大厂的 Feed 流(信息流) 和 电商 业务中,反向索引是标配,主要用于**"牵一发而动全身"**的场景。
场景一:内容合规与快速下架(字节/抖音/小红书)
- 背景:一个大 V 发的视频,系统把它推给了 500 万人的"推荐流"缓存里。
- 突发 :审核后台发现视频违规(涉黄/暴),必须秒级下架。
- 反向索引用法:
-
- 视频状态变更
status=banned。 - 查询反向索引:
idx:video:123-> 返回几万个user_feed:uid_xyz的 Key。 - 异步队列:把这几万个 Key 丢给 Kafka/RocketMQ。
- 消费者:批量删除这些缓存 Key。
- 结果:所有用户刷新 Feed,该视频瞬间消失。
- 视频状态变更
场景二:电商价格变动(淘宝/京东)
- 背景:一个 iPhone 商品卡片,缓存在了"搜索结果页"、"购物车推荐"、"猜你喜欢"等无数个页面片段里。
- 突发:商家把价格从 5999 改成了 4999。
- 反向索引用法:
-
idx:item:iphone15->[cache:search:mobile, cache:cart:rec, cache:home:feed...]- 一旦改价,系统顺着索引找到所有包含该商品价格的缓存片段,强制失效或更新。
- 如果不这么做,用户看到 4999 点进去是 5999,会被投诉价格欺诈。
场景三:社交关系链(微博/Twitter)
- 背景:你拉黑了一个人。
- 需求:你的时间线(Timeline)里不能再出现他的微博。
- 反向索引用法:
-
- 当拉黑动作发生时,查询
idx:author:被拉黑者ID-> 找到所有包含他微博的缓存页面。 - 在你的 Timeline 缓存中精准剔除他的内容。
- 当拉黑动作发生时,查询
什么时候该用,什么时候不该用?
反向索引虽然好用,但它是有代价的(空间换时间,且增加了写入复杂度)。
✅ 必须用的情况:
- 读多写少,但要求高一致性:缓存要存很久(比如 10 分钟),但中间如果数据变了,用户必须立马看到变化(如点赞、改价、下架)。
- "多对多"映射:一个小的子元素(帖子)被包含在很多个大的父容器(页面)里,且父容器无法通过算法推导出来。
- 数据敏感:涉及合规、价格、库存等不能出错的数据。
❌ 不该用的情况(避坑):
- 高频更新的"热点":
-
- 如果一个帖子是"周杰伦发新歌",它可能出现在 1 亿人的缓存里。
- 这时候反向索引
idx:post:jay会巨大无比(BigKey),光是读这个索引就能把 Redis 搞挂。 - 替代方案 :这种热点通常走读时动态合并(Lua 脚本实时查),不走静态缓存更新。
- 数据时效性要求低:
-
- 比如"相关推荐",就算推的内容改了标题,用户 5 分钟后才看到也没关系。那就等缓存自然过期(TTL),别费劲维护索引了。
- 写入极其频繁:
-
- 如果数据每秒变 100 次,你每秒都要去更新索引和缓存,系统会崩。
代码中的亮点:
代码里有一个非常聪明的点,这是很多初学者写不出来的:
为什么要加上 hourSlot**?**
- 防止索引无限膨胀 :如果没有时间片,
feed:public:index:1001这个 Set 可能会存几十万个页面 Key,变成 BigKey。 - 自动过期:因为加上了时间,过了一个小时,旧的索引就没人查了(代码里只查当前和上个小时)。随着 Redis 的 TTL,旧索引自动消失,不需要专门写个定时任务去清理垃圾数据。
总结:
现在用的这套 " 监听器 + 时间分片反向索引 + 原地更新缓存" 的方案,已经是 标准的高并发 Feed 流缓存架构 了。
package com.xiaoce.zhiguang.knowpost.listener;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.xiaoce.zhiguang.counter.event.CounterEvent;
import com.xiaoce.zhiguang.counter.service.impl.UserCounterService;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPosts;
import com.xiaoce.zhiguang.knowpost.domain.vo.FeedItemResponse;
import com.xiaoce.zhiguang.knowpost.domain.vo.FeedPageResponse;
import com.xiaoce.zhiguang.knowpost.mapper.KnowPostsMapper;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* FeedCacheInvalidationListener
* <p>
* Feed 页面缓存失效与计数旁路更新监听器。
* <p>职责:</p>
* - 监听点赞/收藏等计数事件(仅处理实体类型为 "knowpost");
* - 根据"页面反向索引"(`feed:public:index:{eid}:{hour}`)定位受影响页面,
* 同步更新本地 Caffeine 缓存与 Redis 页面 JSON(保持 TTL 不变);
* - 同步创作者收到的点赞/收藏用户维度计数(UserCounterService)。
* <p>设计要点:</p>
* - preserveUserFlags=true 时仅更新本地缓存并保留用户态标志 liked/faved,
* 写回 Redis 页面 JSON 时不携带用户态标志,避免污染共享缓存;
* - 页面 JSON 写回前读取并沿用剩余 TTL,防止覆盖过期策略;
* - 反向索引按小时维护,监听器会同时覆盖当前与上一个小时段的页面键。
* @author 小策
* @date 2026/2/6 17:02
*/
@Component
public class FeedCacheInvalidationListener {
// 【工具箱】
// 1. 本地缓存(Caffeine):存取速度最快,放在应用内存里。
private final Cache<String, FeedPageResponse> feedPublicCache;
// 2. 远程缓存(Redis):所有服务器共享的中央仓库。
private final StringRedisTemplate redis;
// 3. 翻译官(Jackson):负责把 JSON 字符串转成 Java 对象,或者转回去。
private final ObjectMapper objectMapper;
// 4. 用户计数服务:负责更新"张三总共收到了多少个赞"。
private final UserCounterService userCounterService;
// 5. 数据库查询:用来查帖子的作者是谁。
private final KnowPostsMapper knowPostMapper;
// 构造函数:把上面的工具都注入进来
// Qualifier:即便有再多同类型的缓存,Spring 也能精准地把对应的那个"喂"给你的字段。
public FeedCacheInvalidationListener(@Qualifier("feedPublicCache") Cache<String, FeedPageResponse> feedPublicCache,
StringRedisTemplate redis,
ObjectMapper objectMapper,
UserCounterService userCounterService,
KnowPostsMapper knowPostMapper) {
this.feedPublicCache = feedPublicCache;
this.redis = redis;
this.objectMapper = objectMapper;
this.userCounterService = userCounterService;
this.knowPostMapper = knowPostMapper;
}
/**
* 【核心工作入口】监听计数变化事件
* 当系统的任何地方发出 "CounterEvent" 广播时,这个方法会被触发。
*/
@EventListener
public void onCounterChanged(CounterEvent event) {
// 1. 【过滤杂音】
// 我们只关心"帖子(knowpost)"的变化。如果是"关注用户"或者"浏览量",这里不管。
if (!"knowpost".equals(event.getEntityType())) {
return;
}
String metric = event.getMetric(); // 是点赞(like)?还是收藏(fav)?
// 2. 【再次过滤】只处理点赞和收藏,因为这两个需要在列表页实时展示数字。
if ("like".equals(metric) || "fav".equals(metric)) {
String eid = event.getEntityId(); // 帖子 ID
int delta = event.getDelta(); // 变化量(+1 或 -1)
// 3. 【更新作者的个人总数据】
// 比如:帖子是张三写的,张三的"获赞总数"要 +1。
try {
// 查一下这个帖子是谁写的
KnowPosts post = knowPostMapper.selectById(Long.valueOf(eid));
if (post != null && post.getCreatorId() != null) {
long owner = post.getCreatorId();
if ("like".equals(metric)) {
userCounterService.incrementLikesReceived(owner, delta);
}
if ("fav".equals(metric)) {
userCounterService.incrementFavsReceived(owner, delta);
}
}
} catch (Exception ignored) {
// 容错:如果这一步挂了(比如数据库闪断),不要影响后面更新缓存的主逻辑,吞掉异常。
}
// ==========================================================
// 重点来了!下面开始利用"反向索引"找缓存
// ==========================================================
// 4. 【定位时间段】
// 我们的反向索引是按小时存储的。
// 比如 key 是 "feed:index:帖子ID:2026020612" (第12个小时的索引)
long hourSlot = System.currentTimeMillis() / 3600000L;
Set<String> keys = new LinkedHashSet<>();
// 5. 【查反向索引 - 当前小时】
// 问 Redis:"在这个小时生成的 Feed 流里,有哪些页面包含了这篇帖子?"
// 结果可能是一堆 Key,例如 ["feed:page:userA:1", "feed:page:public:5"]
Set<String> cur = redis.opsForSet().members("feed:public:index:" + eid + ":" + hourSlot);
if (cur != null) {
keys.addAll(cur);
}
// 6. 【查反向索引 - 上个小时】
// 为什么要查上个小时?因为用户可能还没刷新,正在看 50 分钟前生成的旧页面。
// 为了防止漏网之鱼,我们要把上一个时间段的页面也找出来更新。
Set<String> prev = redis.opsForSet().members("feed:public:index:" + eid + ":" + (hourSlot - 1));
if (prev != null) {
keys.addAll(prev);
}
// 如果两个时间段都没找到引用这篇帖子的页面,说明这篇帖子太冷门或者太老了,缓存里没有它。
// 直接下班(return)。
if (keys.isEmpty()) {
return;
}
// 7. 【循环修复】遍历找到的每一个缓存页面 Key,开始改数字
for (String key : keys) {
// --- A. 修复本地内存 (Caffeine) ---
FeedPageResponse local = feedPublicCache.getIfPresent(key);
if (local != null) {
// 关键点:preserveUserFlags = true
// 本地缓存是用来给特定用户快速读取的,所以要保留 "liked=true" 这种红色心形状态。
FeedPageResponse updatedLocal = adjustPageCounts(local, eid, metric, delta, true);
feedPublicCache.put(key, updatedLocal);
}
// --- B. 修复远程缓存 (Redis) ---
// Redis 里的数据是公共的,所有人共用一份。
String cached = redis.opsForValue().get(key);
if (cached != null) {
try {
// 1. 把 JSON 字符串反序列化成 Java 对象
FeedPageResponse resp = objectMapper.readValue(cached, FeedPageResponse.class);
// 2. 修改对象里的数字
// 关键点:preserveUserFlags = false
// 写入 Redis 公共缓存时,必须把 "liked" 状态抹除(设为 null)。
// 否则:用户 A 点赞了,Redis 记住了 liked=true;用户 B 读这份缓存时,也会看到红心,以为自己点赞了。
FeedPageResponse updated = adjustPageCounts(resp, eid, metric, delta, false);
// 3. 写回 Redis(注意:这里有防止 TTL 重置的逻辑)
writePageJsonKeepingTtl(key, updated);
} catch (Exception ignored) {}
} else {
// 坑位清理:
// 如果发现 Redis 里其实已经没有这个页面了(可能刚好过期了),
// 那就在反向索引里把这个废弃的 Key 删掉,免得下次还来空跑一次。
redis.opsForSet().remove("feed:public:index:" + eid + ":" + hourSlot, key);
}
}
}
}
/**
* 【精细化手术】修改页面里某一条帖子的点赞数
* * @param page 整个页面数据(包含 10 条帖子)
* @param eid 要修改的那条帖子 ID
* @param preserveUserFlags 是否保留"我已点赞"这种个人状态?
* 本地缓存选 true,Redis 公共缓存选 false。
*/
private FeedPageResponse adjustPageCounts(FeedPageResponse page, String eid, String metric, int delta, boolean preserveUserFlags) {
List<FeedItemResponse> items = new ArrayList<>(page.items().size());
// 遍历这页的每一条帖子
for (FeedItemResponse it : page.items()) {
// 找到了目标帖子!
if (eid.equals(it.id())) {
Long like = it.likeCount();
Long fav = it.favoriteCount();
// 数学计算:防止减成负数(Math.max(0L, ...))
if ("like".equals(metric)) {
like = Math.max(0L, (like == null ? 0L : like) + delta);
}
if ("fav".equals(metric)) {
fav = Math.max(0L, (fav == null ? 0L : fav) + delta);
}
// 状态处理:
// 如果 preserveUserFlags 是 false,强制把 liked/faved 设为 null。
// 这样前端拿到数据后,会根据用户自己的实时状态去判断,而不是盲目相信缓存。
Boolean liked = preserveUserFlags ? it.liked() : null;
Boolean faved = preserveUserFlags ? it.faved() : null;
// 重新构建一个新的帖子对象(因为 Java 的 Record 或某些对象是不可变的)
it = new FeedItemResponse(
it.id(), it.title(), it.description(), it.coverImage(), it.tags(),
it.authorAvatar(), it.authorNickname(), it.tagJson(),
like, // 新的点赞数
fav, // 新的收藏数
liked, // 新的状态
faved,
it.isTop()
);
}
items.add(it);
}
// 返回修改后的新页面
return new FeedPageResponse(items, page.page(), page.size(), page.hasMore());
}
/**
* 【安全写入】写回 Redis 并保持原来的过期时间
* * 为什么要写这个方法?
* Redis 的默认 SET 命令会把 key 的过期时间(TTL)清除,变成永久有效。
* 这会导致缓存里的旧数据永远不消失,这是个大 Bug。
* 所以我们必须:先查剩余寿命,再写入数据并把寿命设回去。
*/
private void writePageJsonKeepingTtl(String key, FeedPageResponse page) {
try {
String json = objectMapper.writeValueAsString(page);
// 1. 查:这个 Key 还能活多久?
long ttl = redis.getExpire(key);
if (ttl > 0) {
// 2. 写:带上剩余寿命写入
redis.opsForValue().set(key, json, java.time.Duration.ofSeconds(ttl));
} else {
// 如果本来就是永久的或者查不到 TTL,就直接写
redis.opsForValue().set(key, json);
}
} catch (Exception ignored) {}
}
}
2.3 定义热键探测器
这是一个非常经典的 "滑动窗口计数器" (Sliding Window Counter) 的实现,采用了 环形数组 ( Ring Buffer ) 的设计思想。
简单来说,这个类就是一个 "热点探测仪"。
这个类是干什么的?
它的核心作用是:在本地 内存 中,统计过去一段时间内(比如过去 60 秒),某个 Key 被访问了多少次。
基于这个统计数据,它实现了以下功能:
- 记账:谁被访问了,就在小本本上记一笔。
- 定级:根据访问量,给 Key 贴上标签(无热度、低热度、中热度、高热度)。
- 续命 :这是最核心的目的。如果发现某个 Key 是"高热度",就在生成缓存时给它设置更长的过期时间(TTL)。
形象的比喻:
想象一个 "环形计分板",上面有 6 个格子(代表 6 个 10秒的时间段)。
- 指针指向格子 A,现在的访问量都记在 A 里。
- 过 10 秒,指针指向格子 B,把 B 清零,新的访问量记在 B 里。
- 如果要算"过去 60 秒的总热度",就把 A+B+C+D+E+F 的数字加起来。
为什么要写这个类?
在没有这个类的时候,所有的缓存过期时间通常是固定的(比如 5 分钟)。这会带来两个大问题:
- 缓存击穿 (Cache Stampede):
-
- 假设"周杰伦发新歌"这个热点新闻缓存设置了 5 分钟过期。
- 在 05:00 这一秒,缓存过期消失。
- 在 05:01 这一毫秒,哪怕只有 0.1 秒的间隙,可能有 10 万个用户同时请求。
- 因为缓存没了,这 10 万个请求全部打到数据库,数据库瞬间崩溃。
- 如果有这个类:系统检测到这是"高热点",自动把过期时间延长到 30 分钟,避免它在高峰期失效。
- 资源浪费:
-
- 对于没人看的冷门数据,缓存存 5 分钟都嫌多,占内存。
- 对于热门数据,存 5 分钟太短,频繁回源查库浪费 CPU。
- 如果有这个类:实现"能者多劳,热者长存"。
如果要自己写,怎么写出来?
如果你要从零手写一个热点探测器,逻辑就是"搭积木"。
第一步:怎么存?(数据结构)
你不能只存一个总数 int count,因为你无法剔除"1小时前"的旧数据。
大厂写法 :使用时间分片。
- 定义一个
Map<String, int[]>。 String是 Key。int[]是一个数组(比如长度为 6),每个元素代表 10 秒钟。
第二步:怎么记?(写入逻辑)
- 搞一个指针
current,指向当前的时间格。 - 请求来了 ->
map.get(key)[current]++。 - 并发控制 :你的代码用了
ConcurrentHashMap,保证多线程写不崩;虽然arr[current]++不是原子的(会丢少量数据),但在统计热度场景下,丢失 1% 的计数完全可以接受,换来的是极致的性能。
第三步:怎么动?(时间轮转)
- 关键点 :使用
@Scheduled定时任务。 - 每隔 10 秒,把指针往后移一格:
index = (index + 1) % 6。 - 核心动作 :移过去之后,必须把新格子里的旧数据清零(因为那是 60 秒前的数据了)。这就是"滑动窗口"滑动的本质。
第四步:怎么算?(读取逻辑)
- 把数组里所有数字加起来,就是过去 60 秒的总访问量。
替代方案(业界标准)
你的这个实现是轻量级、单机版的优秀实现,适合中等规模系统。但在业界,根据规模不同,有以下替代方案:
- Caffeine (内置能力)
-
- 如果你只是想做本地缓存,Caffeine 内部使用的是 W-TinyLFU 算法。它不需要你手动探测,它内部会自动识别热点,热的数据几乎永远不会被剔除,冷的自动淘汰。
- 适用场景:纯本地缓存。
- 京东 JD-HotKey (重量级)
-
- 这是一个专门的开源中间件。它有专门的 Server 端。
- 它能探测 集群热点。比如 Key 在机器 A 访问 5 次,在机器 B 访问 5 次,单机看都不热,但 JD-HotKey 能汇总发现它其实很热(10次)。
- 适用场景:超大规模集群,需要精确控制 Redis 热 key 的场景。
- 阿里 Sentinel
-
- Sentinel 不仅做限流熔断,也有热点参数限流的功能。原理和你的代码非常像(也是滑动窗口),但它主要用于"限流"(超过阈值直接拒绝),而不是"延长 TTL"。
HotKeyDetector 是一个非常标准的工业级轻量实现。
- 优点:
-
- 无锁设计 :使用数组 +
AtomicInteger指针,读写非常快。 - 内存 友好 :只存 int 数组,且通过
current自动复用空间,不会无限增长。 - 容错性 :
computeIfAbsent和构造函数里的除数检查,体现了代码的健壮性。
- 无锁设计 :使用数组 +
- 微小瑕疵:
-
arr[current.get()]++并不是线程安全的(非原子操作)。但在热点探测场景,为了性能牺牲一点点准确度是完全正确的设计决策(Approximate Counting)。
一句话总结: 这个类是保护数据库的防弹衣,通过识别"谁是红人",让"红人"在缓存里待久一点。
package com.xiaoce.zhiguang.cache.hotkey;
import com.xiaoce.zhiguang.cache.config.CacheProperties;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* HotKeyDetector
* <p>
* 热键探测器:工业级的高并发热点识别组件
* @author 小策
* @date 2026/2/7 10:52
*/
@Component
public class HotKeyDetector {
// 定义热度等级:就像交通灯,绿色(NONE)到红色(HIGH)
public enum Level { NONE, LOW, MEDIUM, HIGH }
private final CacheProperties properties;
/** * 核心存储:String(Key) -> int[](每个时间段的访问量)
* 使用 ConcurrentHashMap 是为了保证在成千上万个线程同时访问时,内存数据不会崩坏
*/
private final Map<String, int[]> counters = new ConcurrentHashMap<>();
/** * 当前"指针":指向现在正在计数的那个时间格
* AtomicInteger 保证了在多线程切换格子时,不会出现"两个人都以为自己在改格子 1"的冲突
*/
private final AtomicInteger current = new AtomicInteger(0);
/** 总共有多少个时间格(比如:观察 60 秒,每 10 秒一格,就是 6 格) */
private final int segments;
public HotKeyDetector(CacheProperties properties) {
this.properties = properties;
int segSeconds = properties.getHotkey().getSegmentSeconds();
int winSeconds = properties.getHotkey().getWindowSeconds();
// 健壮性设计:防止除数为 0 导致的程序崩溃
this.segments = Math.max(1, winSeconds / Math.max(1, segSeconds));
}
/**
* 记录访问:每当有人访问一次这个 Key,就调用一次这个方法
*/
public void record(String key) {
// computeIfAbsent:如果这个 Key 是第一次出现,就给它初始化一个长度为 segments 的数组
int[] arr = counters.computeIfAbsent(key, k -> new int[segments]);
// 对应当前时间格子的计数 +1(注意:这里在高并发下会有微小的计数偏差,但在热点探测场景下,性能比绝对准确更重要)
arr[current.get()]++;
}
/**
* 计算热度:把所有时间格子的计数加起来,就是最近一段时间的总访问量
*/
public int heat(String key) {
int[] arr = counters.get(key);
if (arr == null) return 0;
int sum = 0;
for (int v : arr) {
sum += v;
}
return sum;
}
/**
* 判定等级:根据总热度对照配置中的阈值,判断它属于哪个级别
*/
public Level level(String key) {
int h = heat(key);
// 从高到底判断,体现了"保护优先"的思想
if (h >= properties.getHotkey().getLevelHigh()) return Level.HIGH;
if (h >= properties.getHotkey().getLevelMedium()) return Level.MEDIUM;
if (h >= properties.getHotkey().getLevelLow()) return Level.LOW;
return Level.NONE;
}
/**
* 核心价值:动态延长热点数据的生命(TTL)
* 逻辑:如果一个数据很热,我们就让它在缓存里多待一会儿,减少查数据库的次数
*/
public int ttlForPublic(int baseTtlSeconds, String key) {
Level l = level(key);
return baseTtlSeconds + extendSeconds(l);
}
/**
* 计算"我的发布"页面的动态 TTL:基准 TTL + 等级扩展秒数。
* @param baseTtlSeconds 基准 TTL 秒数
* @param key 缓存键
* @return 动态 TTL 秒数
*/
public int ttlForMine(int baseTtlSeconds, String key) {
Level l = level(key);
return baseTtlSeconds + extendSeconds(l);
}
/**
* 计算需要额外奖励的"寿命"(秒)
*/
private int extendSeconds(Level l) {
return switch (l) {
case HIGH -> properties.getHotkey().getExtendHighSeconds();
case MEDIUM -> properties.getHotkey().getExtendMediumSeconds();
case LOW -> properties.getHotkey().getExtendLowSeconds();
default -> 0;
};
}
/**
* 关键定时任务:每隔一段时间(如 10 秒),就把指针指向下一个格
* 并把下一个格子的旧数据清零(因为它已经过时了)
*/
@Scheduled(fixedRateString = "${cache.hotkey.segment-seconds:10}000")
public void rotate() {
// 计算下一个格子的位置
// 假设数组长度 segments = 10,当前是 0
// 下一步变成 1,... 到 9 之后,(9+1)%10 = 0,回到原点
int next = (current.get() + 1) % segments;
current.set(next); // 移动指针
for (int[] arr : counters.values()) {
// 因为这是一个环,即将转过去的那个格子可能存着 10 秒前的旧数据,必须清零,准备迎接新的统计
arr[next] = 0; // 清理即将使用的"新格"中的"老数据"
}
}
/**
* 手动清理:比如某个商品下架了,或者配置变了,需要手动重置热度
*/
public void reset(String key) {
int[] arr = counters.get(key);
if (arr != null) Arrays.fill(arr, 0);
}
}
3 Feed缓存服务的实现
这个类的作用是什么?
它的核心职责是处理 "缓存失效" (Cache Invalidation),具体做两件事:
- 清理公共流缓存 :当运营置顶文章、或者系统需要全站刷新时,把
feed:public:*开头的缓存全删掉。 - 清理个人流缓存 :当用户张三发了新文章,把他自己的
feed:mine:张三:*缓存删掉,让他自己能立马看到新发的帖。
但这只是表面,它真正的核心价值在于使用了 "延时双删" (Delayed Double Deletion) 策略。
为什么要写这个?(为什么要搞这么复杂的双删?)
你可能会问:"数据变了,我直接 redis.delete(key)**删一次不就行了吗?为什么要删两次,中间还要等几秒?"
这是为了解决高并发下的 "脏数据回填" 问题。
❌ 如果只删一次(普通模式)会出什么事?
想象这个场景:
- 线程 A(写请求) :准备改数据库。它先把缓存删了(
delete)。 - 线程 B(读请求) :紧接着进来了,发现缓存是空的。于是它去查数据库。
-
- 关键点 :此时线程 A 还没来得及把新数据写入数据库!
- 所以线程 B 查到的是 旧数据。
- 线程 B :把这个旧数据写入了缓存。
- 线程 A :终于把新数据写入了数据库。
后果:数据库是新的,但缓存里却是线程 B 写进去的旧数据(脏数据)。在缓存过期前,所有用户看到的都是错的。
✅ 为什么"延时双删"能解决?
这个类的逻辑是:
- 先删 :
deleteAllFeedCache()------ 既然要改数据,先清场。 - 再删(延时) :
delayQueueUtil.addDelayMessage(...)------ 告诉系统:"500毫秒后再来删一次"。
效果 : 即使发生了上面的"脏数据回填",500毫秒后,你的"第二次删除"任务启动,会把线程 B 刚才写入的那个脏缓存再砍一刀。
这样,下一个用户 C 再来查,缓存又是空的了,就会去数据库查到最新的数据。
如果要自己写,怎么写出来?
如果你要从零实现这个类,可以分三步走:
第一步:写"删除逻辑"
你需要一个方法能找到并删除特定的 key。
- 初级写法(你代码里的) :使用
keys("pattern*")。
-
- 缺点:性能差,会阻塞 Redis,生产环境禁用。
- 高级写法 :使用
scan命令或者维护一个Set集合索引(即我们在之前对话中优化的feed:public:pages)。
第二步:引入"延时机制"
你需要一个能"过一会儿再执行"的工具。
- 方案 A(最简陋) :开个新线程
Thread.sleep(500)然后执行删除。
-
- 缺点:浪费线程资源,不可靠。
- 方案 B( MQ 消息队列 ):发送一条 RocketMQ/RabbitMQ 的延时消息。
-
- 缺点:依赖太重,还得部署 MQ。
- 方案 C(Redis 延时队列,你的代码用的):
-
- 利用 Redis 的
ZSet(有序集合)。 - Score 存执行时间戳,Value 存任务内容。
- 后台起个线程轮询 ZSet,时间到了就拿出来执行。
- 利用 Redis 的
第三步:组装业务
把上面两步拼起来:
其中代码中的隐患与优化(必看,这里就不做了,实际开发建议维护索引)
你代码里有一段非常危险的注释和实现,我必须再次强调:
为什么不能用? KEYS 命令是 O(N) 复杂度的。如果你的 Redis 里有 1000 万个 key,执行这条命令可能需要几秒钟。在这几秒钟里,Redis 是单线程 的,它在全力找 key,导致其他所有请求(比如用户登录、支付)全部卡住超时。这叫 "Redis 阻塞",是严重的生产事故。
✅ 替代方案(优化后的写法):
- 维护索引(推荐) : 在写入缓存时,把生成的 key 记在一个
Set里,比如feed:public:index。 删除时,直接SMEMBERS feed:public:index拿到所有 key,然后删除。这是 O(1) 的,极快。 - 使用 SCAN 命令 : 如果没维护索引,必须用
scan命令代替keys。scan是分批扫描,不会卡死线程。
3.1 定义延迟删除事件
package com.xiaoce.zhiguang.knowpost.event;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.UUID;
/**
* CacheDoubleDeleteEvent
* <p>
* 缓存延时双删事件
*
* @author 小策
* @date 2026/2/6 20:59
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CacheDoubleDeleteEvent implements Serializable {
/**
* 缓存类型
* "PUBLIC" - 代表公共缓存(feed:public:*)
* "MINE" - 代表个人缓存(feed:mine:userId:*)
*/
private String type;
/**
* 用户ID
* 如果 type 是 "PUBLIC",这里传 null
* 如果 type 是 "MINE",这里传具体的 userId
*/
private Long userId;
/**
* 唯一流水号
* 作用:防止 Redis ZSet 将内容相同的消息去重(吞消息)。
* 默认值:直接生成一个随机 UUID,不用业务层操心。
*/
private String eventId = UUID.randomUUID().toString();
public CacheDoubleDeleteEvent(String type, Long userId) {
this.type = type;
this.userId = userId;
}
}
3.2 定义延迟删除工具类
package com.xiaoce.zhiguang.knowpost.event;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
/**
* RedisDelayQueueUtil
* <p>
* TODO: 请在此处简要描述类的功能
*
* @author 小策
* @date 2026/2/6 21:07
*/
@RequiredArgsConstructor
@Component
@Slf4j
public class RedisDelayQueueUtil {
private final StringRedisTemplate redis;
private final ObjectMapper objectMapper;
private static final String DELAY_QUEUE_KEY = "cache:delay:queue";
/**
* 添加延时消息到 Redis
* @param event 消息对象
* @param delayMillis 延迟多少毫秒
*/
public void addDelayMessage(CacheDoubleDeleteEvent event, long delayMillis) {
try {
// 将消息对象转换为 JSON 字符串
String json = objectMapper.writeValueAsString(event);
//计算延迟时间
long expireTime = System.currentTimeMillis() + delayMillis;
redis.opsForZSet().add(DELAY_QUEUE_KEY, json, expireTime);
log.info("延时消息已入列 Redis,将在 {} ms 后投递", delayMillis);
} catch (JsonProcessingException e) {
log.error("延时消息入列失败", e);
}
}
}
3.2 定义延迟删除定时扫描器
package com.xiaoce.zhiguang.knowpost.event;
import com.xiaoce.zhiguang.common.constant.Kafka.KafkaTopic;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* DelayMessageScheduler
* <p>
* 异步双删的定时任务
*
* @author 小策
* @date 2026/2/6 21:21
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class DelayMessageScheduler {
private final StringRedisTemplate redis;
private final KafkaTemplate<String, String> kafkaTemplate;
private static final String DELAY_QUEUE_KEY = "cache:delay:queue";
private static final String LOCK_KEY = "lock:scheduler:delay_msg";
private final RedissonClient redissonClient;
// 定义 Lua 脚本对象
private DefaultRedisScript<List> luaScript;
/**
* 初始化 Lua 脚本
* 修改点:删除了 redis.call('TIME'),改为从 ARGV[2] 获取时间
*/
@PostConstruct
public void initLua() {
luaScript = new DefaultRedisScript<>();
luaScript.setResultType(List.class);
luaScript.setScriptText(
"local queueKey = KEYS[1]; " +
"local limit = tonumber(ARGV[1]); " + // 参数1:取多少条 (20)
"local now = tonumber(ARGV[2]); " + // 参数2:当前时间戳 (Java传进来的)
// 1. 获取分数小于等于 now 的消息
"local messages = redis.call('ZRANGEBYSCORE', queueKey, 0, now, 'LIMIT', 0, limit); " +
"if #messages > 0 then " +
// 2. 如果有消息,原子性地移除它们 (防止多线程重复消费)
" redis.call('ZREM', queueKey, unpack(messages)); " +
" return messages; " +
"else " +
" return {}; " +
"end"
);
}
/**
* 每 500 毫秒执行一次搬运
*/
@Scheduled(fixedDelay = 500)
public void sendDelayMessage() {
RLock lock = redissonClient.getLock(LOCK_KEY);
try {
// 1. 获取分布式锁 (简单实现,防止多实例并发执行)
// 参数1 (waitTime): 0 => 拿不到锁立刻返回,不等待(因为这是定时任务,这台机器没抢到,下一次或者别的机器会抢,不用阻塞等)
// 参数2 (leaseTime): 5秒 => 锁的自动过期时间。如果 5秒 还没执行完,锁自动释放,防止死锁。
// 注意:如果 leaseTime 设置为 -1,Redisson 会开启"看门狗"自动续期。这里任务简单,设个固定值更省事。
boolean isLocked = lock.tryLock(0, 5, TimeUnit.SECONDS);
if (!isLocked) {
return;
}
// 2. 准备参数
long now = System.currentTimeMillis();
// 3. 执行 Lua 脚本
// 参数说明:
// KEYS[1]: DELAY_QUEUE_KEY
// ARGV[1]: "20" (limit)
// ARGV[2]: String.valueOf(now) (当前时间戳)
List<String> messages = redis.execute(
luaScript,
Collections.singletonList(DELAY_QUEUE_KEY),
"20",
String.valueOf(now) // 把时间从这里传进去!
);
// 4. 处理结果
if (messages == null || messages.isEmpty()) {
return;
}
// 5. 发送 Kafka
for (String message : messages) {
try {
kafkaTemplate.send(KafkaTopic.DELAY_MESSAGE, message);
log.info("延迟消息投递成功: {}", message);
} catch (Exception e) {
log.error("Kafka发送失败, message={}", message, e);
// 注意:这里如果发送失败,消息已经从Redis删了。
// 严格场景下需要做"死信队列"或"回滚重新入队"逻辑。
}
}
} catch (Exception e) {
log.error("定时任务执行异常", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
//TODO 这里建议加一个重试机制,kafka出现问题了,能够补救,晚点有时间实现
}
3.3 定义延迟删除消费者
package com.xiaoce.zhiguang.knowpost.event;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xiaoce.zhiguang.common.constant.Kafka.KafkaTopic;
import com.xiaoce.zhiguang.knowpost.service.impl.FeedCacheServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
/**
* CacheDeleteConsumer
* <p>
* 异步延迟双删的消费者
*
* @author 小策
* @date 2026/2/6 22:03
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CacheDeleteConsumer {
private final FeedCacheServiceImpl feedCacheService;
private final ObjectMapper objectMapper;
@KafkaListener(topics = KafkaTopic.DELAY_MESSAGE , groupId = "delay-delete-cache")
public void onMessage(String message){
log.info("收到延时消息,开始删除缓存");
try {
CacheDoubleDeleteEvent event = objectMapper.readValue(message, CacheDoubleDeleteEvent.class);
// 判断类型,执行"回马枪"(第二次删除)
if ("PUBLIC".equals(event.getType())) {
// 调用 Service 里的纯删除方法
feedCacheService.deleteAllFeedCache();
log.info("【双删完成】全站公共缓存已清理");
} else if ("MINE".equals(event.getType())) {
if (event.getUserId() != null) {
// 调用 Service 里的纯删除方法
feedCacheService.deleteMyFeedCache(event.getUserId());
log.info("【双删完成】用户 {} 缓存已清理", event.getUserId());
}
}
} catch (JsonProcessingException e) {
log.info("延迟删除失败:" + e);
}
}
}
这里简单的说一下实现逻辑:因为kafka不支持发送延迟消息。因此这里利用redis的zet来简单延迟消息的实现。score就是时间戳,至于为什么选择zet,因为这个能排序,可以根据时间来进行排序,然后选出过期的,去往kafka发送延迟删除消息,然后通过kafka的消费者去实现延迟删除逻辑。
3.3 Feed服务实现类
package com.xiaoce.zhiguang.knowpost.service.impl;
import com.xiaoce.zhiguang.common.utils.CollectionUtil;
import com.xiaoce.zhiguang.knowpost.event.CacheDoubleDeleteEvent;
import com.xiaoce.zhiguang.knowpost.event.RedisDelayQueueUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Set;
/**
* FeedCacheServiceImpl
* <p>
* Feed 流缓存一致性管理服务
* 该类负责处理 Feed 流相关的缓存清理工作,核心通过"延时双删"策略
* 保证在数据库更新期间,缓存数据与数据库数据的最终一致性。
* </p>
* @author 小策
* @date 2026/2/6 19:54
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class FeedCacheServiceImpl {
private final StringRedisTemplate redis;
private final RedisDelayQueueUtil delayQueueUtil;
/**
* 删除所有的公共 Feed 流缓存(危险操作)
* 场景:比如运营置顶了一篇新文章,需要让所有人立刻刷出来,就得清理公共缓存。
*/
public void deleteAllFeedCache() {
// redis.keys(*) 这个命令就像在几百万人的电话本里,一行一行去对名字。
// 也就是 O(N) 的复杂度。
// 如果你的缓存里有 100万个 key,执行这行代码时,Redis 就会"卡住"几秒钟。
// Redis 是单线程的,它卡住了,其他所有请求(登录、下单)全部都会超时报错!
// 生产环境建议使用 SCAN 命令替代,或者维护一个专门的 Set 集合来存这些 key。
Set<String> keys = redis.keys("feed:public:*");
// 【小白注释】:如果找到了 key,就删掉;没找到就啥也不干
if (CollectionUtil.isNotEmpty(keys)){
redis.delete(keys);
}
}
/**
* 公共缓存的【延时双删】策略
* @param delayMillis 等待时间(毫秒)
*/
public void doubleDeleteAll(Long delayMillis){
// 1. 立刻删除旧缓存
deleteAllFeedCache();
// 2. 实现异步删除
CacheDoubleDeleteEvent event = new CacheDoubleDeleteEvent("PUBLIC", null);
// 扔进 Redis 延时队列,方法立刻返回,不阻塞!
delayQueueUtil.addDelayMessage(event, delayMillis);
log.info("已发起全站缓存异步双删任务,延迟: {}ms", delayMillis);
}
/**
* 删除指定用户的个人 Feed 缓存
* 场景:用户发布了文章,或者取关了某人,需要刷新他自己的列表。
* @param userId 用户ID
*/
public void deleteMyFeedCache(Long userId) {
// 拼装 key,比如 feed:mine:10086:* // 这里同样使用了 keys 命令,虽然比全量扫描好一点(范围小了),但依然有性能风险。
// 最好是把该用户的所有缓存 key 存在一个 Set 结构里(比如 key叫 user:10086:feed_keys),直接取出来删。
Set<String> keys = redis.keys("feed:mine:" + userId + ":*");
if (CollectionUtil.isNotEmpty(keys)){
redis.delete(keys);
}
}
/**
* 个人缓存的【延时双删】策略
* @param userId 用户ID
* @param delayMillis 等待时间
*/
public void doubleDeleteMyFeedCache(Long userId, Long delayMillis) {
// 1. 先删一次
deleteMyFeedCache(userId);
// 2. 再异步删一次
CacheDoubleDeleteEvent event = new CacheDoubleDeleteEvent("MINE", userId);
delayQueueUtil.addDelayMessage(event, delayMillis);
log.info("已发起用户 {} 缓存异步双删任务,延迟: {}ms", userId, delayMillis);
}
}
4 AI模块部分功能实现
因为知文发布需要往向量库中建立索引等等,因此这里先实现部分AI功能,保证运行时不出错
4.1 大模型的配置类
这里使用@Qualifier注解,是因为 Spring AI 能扫描到了两个可用的 ChatModel(聊天模型)Bean:
-
deepSeekChatModel:来自 DeepSeek 的自动配置。
-
openAiChatModel:来自 OpenAI 的自动配置。
package com.xiaoce.zhiguang.llm.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;/**
-
LlmConfig
-
-
该类用于配置大语言模型相关的Bean,提供ChatClient实例
-
@author 小策
-
@date 2026/2/8 15:35
*/
@Configuration
public class LlmConfig {/**
- 创建并配置ChatClient Bean
- @param chatModel 通过@Qualifier注解指定使用名为"deepseekChatModel"的ChatModel实例
- @return 返回一个配置好的ChatClient实例,用于与语言模型进行交互
*/
@Bean // 将此方法的返回值注册为一个Spring Bean
public ChatClient chatClient(@Qualifier("deepSeekChatModel")ChatModel chatModel){
// 使用ChatModel构建器创建ChatClient实例
return ChatClient.builder(chatModel).build();
}
}
-
4.2 Rag索引构建服务
这个类 RagIndexService 是整个 RAG(检索增强生成)系统 的"搬运工"和"图书管理员"。
简单来说,它的工作就是把数据库里的文章 ,搬运到向量数据库( Elasticsearch **)**里去,并且在搬运过程中把文章切成小块(Chunk),以便 AI 能够更精准地搜索到。
为什么要实现这个类?(核心痛点)
我们平时用的 MySQL 数据库,擅长做精确匹配(比如 WHERE id=1)或者简单的模糊搜索(LIKE %Java%)。
但是,当用户问 AI:"怎么解决 内存 溢出?"时:
- 你的文章里可能根本没有"解决"、"内存"、"溢出"这几个字连在一起。
- 文章标题可能是:"JVM OOM 排查实战"。
- MySQL 搜不到这篇文章。
- 向量数据库能搜到。因为它算出了"内存溢出"和"OOM"意思很像。
所以,我们需要把文章从 MySQL 同步到向量数据库,让 AI 能够"理解"并检索到这些知识。 这就是这个类的作用。
这个类的具体作用
- 知识搬运:把业务数据(KnowPosts)变成 AI 可读的向量数据(Vector)。
- 切片(Chunking):
-
- 一篇文章太长了(比如 1 万字),直接塞给 AI,AI 会"消化不良"(超过 Token 限制),而且很贵。
- 这个类把文章切成 800 字一段的小块。搜的时候,只把最相关的那一段给 AI,既省钱又精准。
- 增量更新(幂等性):
-
- 它不会傻傻地每次都把所有文章重写一遍。
- 它会对比指纹( SHA256 )。如果文章没改过,它就直接跳过。省资源、省时间。
- 数据清洗:
-
- 只有"已发布"且"公开"的文章才会被索引。草稿和私密文章会被自动过滤,保护隐私。
实现思路是什么
第一步:选品( Query )
- 动作:去数据库里查文章。
- 代码逻辑 :
select * from posts where id = ? - 过滤 :检查
status == 'published'。
第二步:验货(Check)
- 动作:看看向量库里是不是已经有了?是不是最新的?
- 代码逻辑:
-
- 拿数据库里的
SHA256。 - 去 Elasticsearch 里查一下这篇文章的
metadata.contentSha256。 - 如果一样,直接
return(下班)。
- 拿数据库里的
第三步:进货(Fetch)
- 动作:文章的正文通常存在 OSS(对象存储)或者 URL 里,不在数据库字段里。
- 代码逻辑 :用
RestTemplate发个 HTTP GET 请求,把 Markdown 文本下载下来。
第四步:切菜(Chunking)
- 动作:把一大块文本切成适合入口的小块。
- 核心逻辑:
-
- 先按**标题(#)**切分,保证一个知识点尽量在一个块里。
- 如果一段太长(超过 800 字),就强制截断。
- 关键技巧 :切分时要留一点重叠(Overlap)。比如第一块是 0~800 字,第二块是 700~1500 字。这样防止把一句话切断了,AI 读不懂。
第五步:上架(Indexing)
- 动作:先把旧的下架,再把新的上架。
- 代码逻辑:
-
- 先调用
es.deleteByQuery把metadata.postId == 1001的所有旧切片删掉(防止搜出两份)。 - 把切好的新块封装成
Document对象。 - 调用
vectorStore.add(docs),Spring AI 会自动把文本转成向量(Embedding)并存入 ES。
- 先调用
这里再说一下**"验货"** 步骤中的比对中里面使用到的**"指纹"**
在计算机领域,指纹(Fingerprint) 并不是真的手指印,而是数据的唯一摘要 或身份证。
通俗解释: 想象你写了一篇 1 万字的文章。怎么判断这篇文章今天有没有被修改过?
- 笨办法:把昨天的 1 万字和今天的 1 万字,一个字一个字地对比。太慢了!
- 指纹法 :用一种算法(比如 SHA-256)把这 1 万字压缩成一串很短的乱码(比如
a3f9...b2)。
-
- 只要文章改了一个标点符号,这串乱码就会变得完全不一样。
- 如果乱码没变,说明文章内容绝对没变。
在该代码中,指纹有两种:
-
- SHA-256:一种哈希算法。它非常精准,用来代表文章内容的唯一性。
- ETag:通常是对象存储(如 AWS S3、阿里云 OSS)给文件打的标签。文件一变,ETag 就变。
代码里的指纹就是这两个字符串: currentSha 和 currentEtag。
package com.xiaoce.zhiguang.llm.rag;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Hit;
import com.xiaoce.zhiguang.common.config.Es.EsProperties;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPosts;
import com.xiaoce.zhiguang.knowpost.mapper.KnowPostsMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import java.io.IOException;
import java.util.*;
/**
* RagIndexService
* <p>
* RAG 索引构建服务:
* - 将公开且已发布的知文切片并写入向量库
* - 通过指纹(SHA256/ETag)判断是否需要重建,保证幂等
* - 采用 delete-by-query 清理旧切片,再批量 upsert 新切片
* 作用是:把数据库里的文章,"搬运"到向量数据库(Elasticsearch)里去。
* 为什么要搬运?因为数据库只能精确搜索(比如 SQL WHERE title LIKE %...%)
* 而向量数据库可以进行语义搜索(比如搜"如何优化Java性能",能搜出这篇文章,即使标题里没有这几个字)。
* @author 小策
* @date 2026/2/7 11:22
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class RagIndexService {
// 【核心组件1】Spring AI 提供的向量库接口,用来把处理好的文本存进去
private final VectorStore vectorStore;
// 【核心组件2】查数据库的 Mapper,用来查文章的标题、状态、下载地址
private final KnowPostsMapper knowPostMapper;
// 【核心组件3】HTTP 客户端,用来根据 URL 下载 Markdown 正文
private final RestTemplate http = new RestTemplate();
// 【核心组件4】Elasticsearch 原生客户端。因为 Spring AI 的 delete 功能可能不够灵活,
// 这里直接用原生客户端做"按条件删除"和"查重"
private final ElasticsearchClient es;
// 配置文件,里面存了索引的名字(比如 "know_post_index")
private final EsProperties esProps;
public void ensureIndexed(long postId) {
// 外部调用的入口。只传一个文章 ID。
// 实际上直接调用了下面的核心方法。
reindexSinglePost(postId);
}
public int reindexSinglePost(long postId) {
//1. 查询数据库是否有该文章
KnowPosts row = knowPostMapper.selectById(postId);
// 2. 数据库中没有
if (row == null) {
log.warn("Post {} not found", postId);
return 0;
}
// 3. 业务校验:只有"已发布"且"公开"的文章才允许被 AI 搜到
// 如果是草稿或私密文章,直接跳过。
if (!"published".equalsIgnoreCase(row.getStatus()) || !"public".equalsIgnoreCase(row.getVisible())) {
log.warn("Post {} is not public/published, skip indexing", postId);
return 0;
}
// 4. 校验下载地址:没有 contentUrl 说明文章没有正文文件,跳过
if (!StringUtils.hasText(row.getContentUrl())) {
log.warn("Post {} missing contentUrl or not found", postId);
return 0;
}
// 5. 【幂等性检查】这是为了省钱省资源!
// 获取当前数据库里的指纹(SHA256 哈希值)
String currentSha = row.getContentSha256();
String currentEtag = row.getContentEtag();
if (isUpToDate(postId, currentSha, currentEtag)) {
log.info("Post {} already indexed with same fingerprint, skip", postId);
return 0;
}
// 6. 下载正文:根据 URL 把 Markdown 文本下载下来
String text = fetchContent(row.getContentUrl());
if (!StringUtils.hasText(text)) {
log.warn("Post {} content empty", postId);
return 0;
}
// 7. 【切片】把长文本切成小段(Chunk)。
// 先按 Markdown 的标题(#)切,再按固定长度切。
List<String> chunks = chunkMarkdown(text);
// 8. 清理旧数据:在写入新版之前,先把向量库里这个 postId 对应的旧切片全删了。
// 防止搜出两个版本的同一篇文章。
deleteExistingChunks(postId);
// 9. 组装 Document 对象
// Document 是 Spring AI 定义的标准对象,包含"文本内容"和"元数据(Metadata)"
List<Document> docs = new ArrayList<>(chunks.size());
for (int i = 0; i < chunks.size(); i++) {
// 给每个切片生成唯一的 ID,格式是 "文章ID#片段序号"
String cid = postId + "#" + i;
// 准备元数据,这些数据会和向量一起存进去
Map<String, Object> meta = new HashMap<>();
meta.put("postId", String.valueOf(postId)); // 以后可以按 postId 删除
meta.put("chunkId", cid);
meta.put("position", i); // 记录是第几段
meta.put("contentEtag", currentEtag);
meta.put("contentSha256", currentSha); // 存入指纹,下次用来做比对
meta.put("contentUrl", row.getContentUrl());
meta.put("title", row.getTitle()); // 存标题,搜出来时能显示标题
docs.add(new Document(chunks.get(i), meta));
}
try {
// 批量写入向量库
vectorStore.add(docs);
} catch (Exception e) {
log.error("VectorStore add failed: {}", e.getMessage());
return 0;
}
// 返回本次写入的切片数量
return docs.size();
}
/**
* 删除指定帖子ID关联的所有现有数据块
* @param postId 要删除数据块的帖子ID
*/
private void deleteExistingChunks(long postId) {
try {
// 检查索引名称是否为空,如果为空则直接返回
if (!StringUtils.hasText(esProps.getIndex())) return;
// 使用Elasticsearch的deleteByQuery API删除匹配的数据
es.deleteByQuery(d -> d
// 指定要操作的索引
.index(esProps.getIndex())
// 设置查询条件,匹配metadata.postId字段等于指定postId的文档
.query(q -> q.term(t -> t
.field("metadata.postId")
.value(v -> v.stringValue(String.valueOf(postId))))));
} catch (Exception e) {
// 记录删除操作失败的警告日志
log.warn("Delete old chunks failed for post {}: {}", postId, e.getMessage());
}
}
/**
* 将Markdown文本分割为块
* @param text 输入的Markdown文本
* @return 分割后的文本块列表
*/
private List<String> chunkMarkdown(String text) {
// 创建一个列表用于存储分割后的段落
List<String> paras = new ArrayList<>();
// 按行分割输入文本
String[] lines = text.split("\r?\n");
// 使用StringBuffer构建当前段落
StringBuffer buf = new StringBuffer();
// 遍历每一行
for (String line : lines) {
// 检查当前行是否是标题行(以#开头)
boolean isHeader = line.startsWith("#");
// 如果是标题行且缓冲区不为空,说明遇到了新的标题,需要收束上一段
if (isHeader && !buf.isEmpty()) { // 遇到新的标题,收束上一段
// 将缓冲区内容添加到段落列表中
paras.add(buf.toString());
// 清空缓冲区
buf.setLength(0);
}
// 将当前行添加到缓冲区,并添加换行符
buf.append(line).append('\n');
}
// 处理最后一段内容(如果缓冲区不为空)
if (!buf.isEmpty()) paras.add(buf.toString()); //收尾工作
// 调用getChunks方法进一步处理段落
return getChunks(paras);
}
/**
* 将段落列表分割成较小的文本块,确保每个块不超过800字符,并在必要时保留部分重叠以保持语义连续性
* @param paras 原始段落列表
* @return 分割后的文本块列表,每个块长度不超过800字符
*/
private List<String> getChunks(List<String> paras) {
// 创建一个新的列表用于存储分割后的文本块
List<String> chunks = new ArrayList<>();
// 遍历输入的每个段落
for (String para : paras) {
// 如果段落长度小于800字符,直接添加到结果列表中
if (para.length() < 800) chunks.add(para);
else {
// 对于较长的段落,需要分割成多个小块
int start = 0;
// 使用循环将长段落分割成小块
while (start < para.length()) {
// 计算当前块的结束位置,最多800字符
int end = Math.min(start + 800, para.length());
// 添加当前块到结果列表
chunks.add(para.substring(start, end));
if (end >= para.length()) break;
start = Math.max(end - 100, start + 1); // 重叠 100 字符以保留语义连续
}
}
}
return chunks;
}
/**
* 检查指定帖子是否为最新版本,通过比较当前内容的SHA256或ETag与索引中的值
* @param postId 要检查的帖子ID
* @param currentSha 当前内容的SHA256值
* @param currentEtag 当前内容的ETag值
* @return 如果内容是最新的返回true,否则返回false
*/
private boolean isUpToDate(long postId, String currentSha, String currentEtag) {
try {
// 如果没配索引名,没法查,默认当做需要更新
if (!StringUtils.hasText(esProps.getIndex())){
return false;
}
// 在Elasticsearch中搜索指定postId的文档
SearchResponse<Map> resp = es.search(s -> s.index(esProps.getIndex()) // 指定去哪个索引查
.size(1) // 只要查到一条切片就行
.query(q -> q.term(t -> t
.field("metadata.postId") // 搜索条件:metadata.postId == 传入的 postId
.value(v -> v.stringValue(String.valueOf(postId))))),
Map.class);
// 如果没查到,说明是新文章,返回 false (需要索引)
List<Hit<Map>> hits = resp.hits().hits();
if (hits == null || hits.isEmpty()) return false;
Map source = hits.getFirst().source();
if (source == null) return false;
// 获取metadata字段并进行类型检查
Object metaObj = source.get("metadata");
if (!(metaObj instanceof Map<?, ?> meta)) return false;
// 从索引的文档中提取SHA256和ETag值
String indexedSha = asString(meta.get("contentSha256"));
String indexedEtag = asString(meta.get("contentEtag"));
// 【核心比对】
// 如果向量库里的 SHA256 和 数据库里现在的 SHA256 一样,说明内容没变。
if (StringUtils.hasText(currentSha) && StringUtils.hasText(indexedSha)) {
return Objects.equals(currentSha, indexedSha);
}
if (StringUtils.hasText(currentEtag) && StringUtils.hasText(indexedEtag)) {
return Objects.equals(currentEtag, indexedEtag);
}
return false;
} catch (IOException e) {
log.warn("Fingerprint check failed for post {}: {}", postId, e.getMessage());
return false;
}
}
/**
* 将对象转换为字符串的静态方法
* 该方法统一处理了对象为null的情况,避免了NullPointerException
*
* @param o 需要转换为字符串的对象
* @return 如果输入对象为null,则返回null;否则返回对象的字符串表示形式
*/
private static String asString(Object o) {
// 统一处理 null → String 的转换
return o == null ? null : java.lang.String.valueOf(o);
}
/**
* 该方法通过HTTP请求获取指定URL的文本内容
* @param url 需要拉取内容的网页地址
* @return 返回从指定URL获取的Markdown文本内容,如果发生异常则返回null
*/
private String fetchContent(String url) {
try {
// 使用HTTP客户端发送GET请求并获取响应内容
return http.getForObject(url, String.class);
} catch (Exception e) {
// 记录错误日志,包含异常信息
log.error("Fetch content failed: {}", e.getMessage());
// 发生异常时返回null
return null;
}
}
}
4.3 知文摘要生成接口
package com.xiaoce.zhiguang.llm.service;
/**
* KnowPostDescriptionService
* <p>
* 这是一个服务接口,用于处理和生成帖子描述信息的相关功能
* 该接口定义了生成描述信息的方法规范
*
* @author 小策
* @date 2026/2/8 15:38
*/
public interface IKnowPostDescriptionService {
/**
* 生成描述信息的方法
* 根据输入的内容生成相应的描述信息
* 该方法接收一个字符串参数,处理后返回描述信息
* @param content 输入的内容字符串,用于生成描述
* @return 返回生成的描述信息字符串
*/
String generateDescription(String content);
}
4.4 知文摘要实现类
package com.xiaoce.zhiguang.llm.service.impl;
import com.xiaoce.zhiguang.llm.service.IKnowPostDescriptionService;
import org.springframework.stereotype.Service;
/**
* KnowPostDescriptionServiceImpl
* <p>
*
* 该类是知识帖描述服务的实现类,用于处理知识帖描述相关的业务逻辑
*
* @author 小策
* @date 2026/2/8 15:39
*/
@Service
public class KnowPostDescriptionServiceImpl implements IKnowPostDescriptionService {
/**
* 生成知识帖摘要的方法
* @param content 原始内容字符串
* @return 返回生成的描述字符串,当前实现为返回空字符串
*/
@Override
public String generateDescription(String content) {
return "";
}
}
这里不实现的问题,还是因为目前来说只保证运行不出错,后续在AI模块实现
5 知文业务实现
5.1 知文业务实现接口
package com.xiaoce.zhiguang.knowpost.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPosts;
import com.xiaoce.zhiguang.knowpost.domain.vo.KnowPostDetailResponse;
import java.util.List;
/**
* <p>
* 知识库文章表 服务类
* 知文(KnowPost)业务逻辑接口。
* * 该接口负责处理文章从创建草稿、内容确认、元数据编辑到最终发布的全生命周期管理。
* 采用了"先创建草稿,后异步/分步完善内容"的设计模式。
*
*
* @author 小策
* @since 2026-01-20
*/
public interface IKnowPostsService extends IService<KnowPosts> {
/**
* 创建一个文章草稿。
* @param creatorId 创建者用户ID。
* @return 返回新生成的文章唯一主键 ID。
*/
long createDraft(long creatorId);
/**
* 确认并关联文章的实际内容文件。
* 通常用于文件上传到对象存储(如 OSS/S3)后的回调或确认环节,将文件元数据与文章记录绑定。
* @param creatorId 操作者ID(校验权限)。
* @param id 文章ID。
* @param objectKey 文件在对象存储中的路径或键值。
* @param etag 文件的 ETag(通常为文件 MD5 摘要,用于一致性校验)。
* @param size 文件大小(单位:字节)。
* @param sha256 文件的 SHA256 校验码,确保内容完整安全性。
*/
void confirmContent(long creatorId, long id, String objectKey, String etag, Long size, String sha256);
/**
* 更新文章的元数据信息。
* @param creatorId 操作者ID。
* @param id 文章ID。
* @param title 文章标题。
* @param tagId 主标签/分类ID。
* @param tags 副标签列表(字符串集合)。
* @param imgUrls 文章配图或插图的 URL 列表。
* @param visible 可见性状态(如:PUBLIC-公开, PRIVATE-私密, FRIEND-好友可见)。
* @param isTop 是否置顶。
* @param description 文章摘要或简介。
*/
void updateMetadata(long creatorId, long id, String title, Long tagId, List<String> tags, List<String> imgUrls, String visible, Boolean isTop, String description);
/**
* 正式发布文章。
* 将文章状态从"草稿"或其他中间状态变更为"已发布",使其对目标受众可见。
* @param creatorId 操作者ID。
* @param id 文章ID。
*/
void publish(long creatorId, long id);
/**
* 更新文章的置顶状态。
* @param creatorId 操作者ID。
* @param id 文章ID。
* @param isTop true 表示置顶,false 表示取消置顶。
*/
void updateTop(long creatorId, long id, boolean isTop);
/**
* 更新文章的可见性权限。
* @param creatorId 操作者ID。
* @param id 文章ID。
* @param visible 可见性标识字符串。
*/
void updateVisibility(long creatorId, long id, String visible);
/**
* 删除文章。
* @param creatorId 操作者ID(通常需校验是否为作者或管理员)。
* @param id 文章ID。
*/
void delete(long creatorId, long id);
/**
* 获取文章详情。
* @param id 文章ID。
* @param currentUserIdNullable 当前访问者的用户ID。如果为 null,表示匿名访问。
* 用于判断当前用户是否有点赞、收藏该文章,或是否有权查看私密内容。
* @return 包含文章全量信息的 DTO 响应对象。
*/
KnowPostDetailResponse getDetail(long id, Long currentUserIdNullable);
}
5.2 实现知文业务实现类
5.2.1 定义延迟双删AOP切面
5.2.1.1 添加AOP依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>
5.2.1.2 定延迟双删注解
package com.xiaoce.zhiguang.knowpost.annotation;
import java.lang.annotation.*;
/**
* DelayedDoubleDelete 注解类
* <p>
* 该注解用于实现延迟双删机制,常用于缓存与数据库同步的场景
* 1.先写延迟双删的注解,用于标记需要进行延迟双删的方法
*
* @author 小策
* @date 2026/2/7 22:29
*/
@Target({ElementType.METHOD}) // 注解可以应用于方法上
@Retention(RetentionPolicy.RUNTIME) // 注解在运行时保留
@Documented // 注解会被包含在JavaDoc中
public @interface DelayedDoubleDelete {
/**
* 缓存 id 的 SpEL 表达式
* 必须动态解析,因为每个文章 ID 不一样
*/
String id();
/**
* 延迟时间(毫秒),默认 500ms
*/
long delay() default 500;
}
5.2.1.3 定义切面拦截器
package com.xiaoce.zhiguang.knowpost.annotation;
import com.github.benmanes.caffeine.cache.Cache;
import com.xiaoce.zhiguang.knowpost.domain.vo.KnowPostDetailResponse;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.expression.MethodBasedEvaluationContext;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.lang.reflect.Method;
import java.util.concurrent.Executor;
/**
* CacheDelayDeleteAspect
* <p>
* 延迟双删切面:缓存一致性的"守门员"。
* 作用:拦截所有加了 @DelayedDoubleDelete 注解的方法,在事务提交后,自动异步延迟删除缓存。
*
* @author 小策
* @date 2026/2/7 22:30
*/
@Aspect // 标记这是一个切面类(拦截器)
@Component // 交给 Spring 管理
@Slf4j
public class CacheDelayDeleteAspect {
@Resource(name = "stringRedisTemplate")
private StringRedisTemplate redisTemplate;
// 注入本地缓存 Caffeine,为了实现多级缓存的一致性清理
@Resource
@Qualifier("knowPostDetailCache")
private Cache<String, KnowPostDetailResponse> localCache;
// 注入我们之前配置好的自定义线程池
// 作用:执行 sleep 和 delete 操作,避免阻塞主业务线程
@Resource(name = "taskExecutor")
private Executor taskExecutor;
// SpEL 解析器:用于解析注解里的 "#id" 这种占位符
private final ExpressionParser parser = new SpelExpressionParser();
// 参数发现器:用于获取方法参数的名字
private final ParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer();
// 缓存的版本号(注意:如果 key 规则变了,这里要同步修改)
private static final int DETAIL_LAYOUT_VER = 1;
/**
* 核心拦截逻辑
* @param point 切点(原本要执行的方法)
* @param delayedDelete 注解对象(能拿到注解里配的参数,如 delay 时间)
*/
@Around("@annotation(delayedDelete)")
public Object around(ProceedingJoinPoint point, DelayedDoubleDelete delayedDelete) throws Throwable {
// 无前置操作
// 【执行核心业务】
// 让 Service 里的 update/delete 方法先执行,拿到返回值
// 如果这里抛异常,后面的代码不会执行,保证了"更新失败不删缓存"
Object result = point.proceed();
// 【后置操作】业务执行成功后,开始准备删缓存
try {
//解析 SpEL,获取具体的 ID 值(例如:把 "#dto.id" 解析成 "10086")
Object id = parseId(delayedDelete.id(), point);
// 拼装 Redis 和 Caffeine 里的 Key
// 注意:这里的 Key 规则必须和 Service 里查询缓存的规则完全一致!
String finalCacheKey = "knowpost:detail:" + id + ":v" + DETAIL_LAYOUT_VER;
//判断当前是否在数据库事务中
if (TransactionSynchronizationManager.isSynchronizationActive()) {
// 情况 A:在事务中 -> 注册回调,等事务提交后再干活
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
// 事务提交成功,交给线程池去异步删除
doAsyncDelayDelete(finalCacheKey, delayedDelete.delay());
}
});
} else {
// 情况 B:不在事务中 -> 直接交给线程池干活
// 这种场景较少,通常出现在非事务的写操作中,或者事务配置失效时
log.warn("当前环境无事务,直接执行延迟删除: {}", finalCacheKey);
doAsyncDelayDelete(finalCacheKey, delayedDelete.delay());
}
} catch (Exception e) {
// 切面的异常绝不能影响主业务!
// 比如解析 ID 失败了,或者线程池满了,只记录日志,不能让用户看到"保存失败"
log.error("AOP 延迟双删处理失败,请手动检查缓存一致性", e);
}
return result;
}
/**
* 执行异步延迟删除的实际动作
* @param cacheKey 要删除的 Key
* @param delayTime 延迟多少毫秒
*/
private void doAsyncDelayDelete(String cacheKey, long delayTime) {
taskExecutor.execute(() -> {
try {
// 1. 睡眠(延迟),等待数据库主从同步完成
if (delayTime > 0) {
Thread.sleep(delayTime);
}
log.info("AOP 延迟双删执行,清理 Key: {}", cacheKey);
// 2. 删除 Redis 分布式缓存
redisTemplate.delete(cacheKey);
// 3. 删除 Caffeine 本地进程缓存
localCache.invalidate(cacheKey);
} catch (InterruptedException e) {
// 恢复中断状态,规范写法
Thread.currentThread().interrupt();
} catch (Exception e) {
log.error("AOP 异步删除任务异常", e);
}
});
}
/**
* 解析 SpEL 表达式的工具方法
* 作用:比如注解写的是 @DelayedDoubleDelete(key = "#post.id")
* 这个方法负责把 "#post.id" 变成具体的数字 "123"
*/
private Object parseId(String keySpel, ProceedingJoinPoint point) {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
Object[] args = point.getArgs();
// 创建上下文,把方法的参数名和参数值对应起来
EvaluationContext context = new MethodBasedEvaluationContext(
point.getTarget(), method, args, nameDiscoverer);
// 解析表达式并取值
return parser.parseExpression(keySpel).getValue(context);
}
}
5.2.2 获取知文详细方法逻辑图
这里说一下,为了防止占用太多内存, redis中存的知文信息都是不详细的,比如说缺少点赞数,收藏数,用户点赞状态等等。因此还需要实现丰富从redis中取出来的数据的方法。同时取出来了,还需要根据热点等阶写回缓存。
暂时无法在飞书文档外展示此内容
5.2.3 知文业务实现类实现
package com.xiaoce.zhiguang.knowpost.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.xiaoce.zhiguang.cache.hotkey.HotKeyDetector;
import com.xiaoce.zhiguang.common.exception.BusinessException;
import com.xiaoce.zhiguang.common.exception.ErrorCode;
import com.xiaoce.zhiguang.common.utils.BeanCopyUtils;
import com.xiaoce.zhiguang.common.utils.CollectionUtil;
import com.xiaoce.zhiguang.counter.service.ICounterService;
import com.xiaoce.zhiguang.counter.service.IUserCounterService;
import com.xiaoce.zhiguang.knowpost.annotation.DelayedDoubleDelete;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPostDetailRow;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPosts;
import com.xiaoce.zhiguang.knowpost.domain.vo.KnowPostDetailResponse;
import com.xiaoce.zhiguang.knowpost.id.SnowflakeIdGenerator;
import com.xiaoce.zhiguang.knowpost.mapper.KnowPostsMapper;
import com.xiaoce.zhiguang.knowpost.service.IKnowPostsService;
import com.xiaoce.zhiguang.llm.rag.RagIndexService;
import com.xiaoce.zhiguang.oss.config.OssProperties;
import com.xiaoce.zhiguang.user.domain.po.Users;
import com.xiaoce.zhiguang.user.service.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
/**
* <p>
* 知识库文章表 服务实现类
* </p>
*
* @author 小策
* @since 2026-01-20
*/
@Slf4j
@Service
public class KnowPostsServiceImpl extends ServiceImpl<KnowPostsMapper, KnowPosts> implements IKnowPostsService {
private final SnowflakeIdGenerator idGen;
private final ObjectMapper objectMapper;
private final OssProperties ossProperties;
private final ICounterService counterService;
private final IUserCounterService userCounterService;
private final StringRedisTemplate redis;
private final IUserService userService;
@Qualifier("knowPostDetailCache")
private final Cache<String, KnowPostDetailResponse> knowPostDetailCache;
private final HotKeyDetector hotKey;
private static final int DETAIL_LAYOUT_VER = 1;
private final ConcurrentHashMap<String, Object> singleFlight = new ConcurrentHashMap<>();
private final RagIndexService ragIndexService;
public KnowPostsServiceImpl(SnowflakeIdGenerator idGen, ObjectMapper objectMapper,
OssProperties ossProperties, ICounterService counterService,
IUserCounterService userCounterService, StringRedisTemplate redis,
@Qualifier("knowPostDetailCache") Cache<String, KnowPostDetailResponse> knowPostDetailCache,
HotKeyDetector hotKey, RagIndexService ragIndexService,
IUserService userService) {
this.idGen = idGen;
this.objectMapper = objectMapper;
this.ossProperties = ossProperties;
this.counterService = counterService;
this.userCounterService = userCounterService;
this.redis = redis;
this.knowPostDetailCache = knowPostDetailCache;
this.hotKey = hotKey;
this.ragIndexService = ragIndexService;
this.userService = userService;
}
/**
* 使指定ID的缓存失效
*
* @param id 要使缓存失效的文章ID
*/
private void invalidateCache(long id) {
// 构建缓存键,包含文章ID和布局版本号
String pageKey = "knowpost:detail:" + id + ":v" + DETAIL_LAYOUT_VER;
// 从Redis中删除缓存
redis.delete(pageKey);
// 从本地缓存中删除
knowPostDetailCache.invalidate(pageKey);
}
/**
* 根据对象键生成公开访问的URL
*
* @param objectKey 对象在OSS中的键
* @return 完整的公开访问URL
*/
private String publicUrl(String objectKey) {
// 从配置中获取公开域名
String publicDomain = ossProperties.getPublicDomain();
// 如果配置中设置了公开域名,则使用公开域名拼接URL
if (publicDomain != null && !publicDomain.isBlank()) {
// 移除域名末尾的斜杠(如果有),然后与对象键拼接
return publicDomain.replaceAll("/$", "") + "/" + objectKey;
}
// 如果未配置公开域名,则使用默认的endpoint格式拼接URL
return "https://" + ossProperties.getBucket() + "." + ossProperties.getEndpoint() + "/" + objectKey;
}
/**
* 将字符串列表转换为JSON字符串,如果列表为空则返回null
*
* @param tagList 需要转换为JSON的字符串列表
* @return 转换后的JSON字符串,如果输入列表为空则返回null
* @throws BusinessException 当JSON处理失败时抛出业务异常
*/
private String toJsonOrNull(List<String> tagList) {
// 检查列表是否为空,如果为空则直接返回null
if (CollectionUtil.isEmpty(tagList)) {
return null;
}
try {
// 使用ObjectMapper将列表转换为JSON字符串
return objectMapper.writeValueAsString(tagList);
} catch (JsonProcessingException e) {
// 捕获JSON处理异常,并抛出业务异常
throw new BusinessException(ErrorCode.BAD_REQUEST, "JSON 处理失败");
}
}
/**
* 验证可见性参数是否有效
*
* @param visible 可见性参数字符串,可能为null
* @return 如果可见性参数有效则返回true,否则返回false
*/
private boolean isValidVisible(String visible) {
// 检查输入参数是否为null
if (visible == null) {
return false;
}
// 使用switch表达式检查可见性参数是否为允许的值之一
return switch (visible) {
// 允许的可见性值:public, followers, school, private, unlisted
case "public", "followers", "school", "private", "unlisted" -> true;
// 其他所有值都视为无效
default -> false;
};
}
@Override // 标记重写父类方法
@Transactional // 标记事务注解,确保方法在事务中执行
public long createDraft(long creatorId) {
// 生成唯一ID
long id = idGen.nextId();
// 获取当前时间戳
Instant now = Instant.now();
// 构建知识帖子对象
KnowPosts knowPosts = KnowPosts.builder()
.id(id) // 设置帖子ID
.creatorId(creatorId) // 设置创建者ID
.status("draft") // 设置帖子状态为"草稿" // 设置帖子类型为"图文"
.type("image_text") // 设置帖子可见性为"公开"
.visible("public") // 设置是否置顶为"否"
.isTop(false) // 设置创建时间为当前时间
.createTime(now) // 设置更新时间为当前时间
.updateTime(now)
// 保存帖子对象到数据库
.build();
// 返回新创建的帖子ID
this.save(knowPosts);
return id;
}
/**
* 确认并关联文章的实际内容文件。
* <p>
* 场景:前端直接将文件上传到 OSS 后,拿到 objectKey 等元数据,调用此接口通知后端。
* 后端负责:
* 1. 校验并更新数据库记录。
* 2. 清除旧缓存,防止用户看到旧内容。
* 3. 异步触发 AI 索引,为后续的 RAG(检索增强生成)做准备。
* </p>
*
* @param creatorId 操作者ID(用于权限校验,确保是作者本人)。
* @param id 文章草稿ID。
* @param objectKey 文件在 OSS 中的路径(唯一标识)。
* @param etag 文件的 ETag(通常是 MD5),用于校验文件一致性。
* @param size 文件大小(字节)。
* @param sha256 文件的 SHA-256 哈希值,用于后续可能的去重或完整性校验。
*/
@Override
@Transactional(rollbackFor = Exception.class) // 使用事务注解,确保方法执行过程中出现异常时事务会回滚
@DelayedDoubleDelete(id = "#id", delay = 500)
public void confirmContent(long creatorId, long id, String objectKey, String etag, Long size, String sha256) {
//延迟双删策略:第一次删除缓存是在事务提交前,第二次是在事务提交后延迟500ms
// 这样可以确保数据库主从同步完成后再删除缓存,避免缓存不一致问题
invalidateCache(id);
boolean success = lambdaUpdate().eq(KnowPosts::getId, id)
.eq(KnowPosts::getCreatorId, creatorId)
.set(objectKey != null, KnowPosts::getContentObjectKey, objectKey)
.set(etag != null, KnowPosts::getContentEtag, etag)
.set(size != null, KnowPosts::getContentSize, size)
.set(sha256 != null, KnowPosts::getContentSha256, sha256)
.set(KnowPosts::getContentUrl, publicUrl(objectKey)) // 生成公开访问URL
.set(KnowPosts::getUpdateTime, Instant.now()) // 设置更新时间为当前时间
.update();
if (!success) {
// 如果更新失败,抛出业务异常
throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限");
}
// 【核心机制:注册监听器】
// 翻译:等下如果数据库事务"盖章生效"了,请帮我做下面的事
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
// 触发 RAG(检索增强生成)预索引
// 这是一个"附加动作",不是核心业务,所以用 try-catch 包裹。
// 目的:将文章内容发送给向量数据库或 LLM 服务,提前生成 Embeddings。
// 好处:当文章正式发布时,无需等待索引时间,用户立刻就能搜到。
try {
// ensureIndexed 内部应该是幂等的:如果已索引则跳过,未索引则创建。
ragIndexService.ensureIndexed(id);
} catch (Exception e) {
// 7. 容错处理
// 如果 AI 服务挂了,只记录警告日志,不要阻断用户保存文章的主流程。
// 此时文章已保存成功,索引可以在后续通过定时任务补偿。
log.warn("Pre-index after content confirm failed, post {}: {}", id, e.getMessage());
}
}
});
}
/**
* 更新文章元数据
*
* @param creatorId 创建者ID
* @param id 文章ID
* @param title 文章标题
* @param tagId 标签ID
* @param tags 标签列表
* @param imgUrls 图片URL列表
* @param visible 可见性设置
* @param isTop 是否置顶
* @param description 文章描述
*/
@Override
@Transactional(rollbackFor = Exception.class)
@DelayedDoubleDelete(id = "#id", delay = 500)
public void updateMetadata(long creatorId, long id, String title, Long tagId, List<String> tags, List<String> imgUrls, String visible, Boolean isTop, String description) {
// 使指定ID的缓存失效,确保获取最新数据
invalidateCache(id);
// 构建KnowPosts对象,设置更新后的文章元数据
boolean success = lambdaUpdate().set(title != null, KnowPosts::getTitle, title)
.set(tagId != null, KnowPosts::getTagId, tagId)
.set(tags != null, KnowPosts::getTags, toJsonOrNull(tags))
.set(imgUrls != null, KnowPosts::getImgUrls, toJsonOrNull(imgUrls))
.set(visible != null, KnowPosts::getVisible, visible)
.set(isTop != null, KnowPosts::getIsTop, isTop)
.set(description != null, KnowPosts::getDescription, description)
.set(KnowPosts::getUpdateTime, Instant.now()) // 更新更新时间
.eq(KnowPosts::getId, id) // 确保更新的是指定ID的文章
.eq(KnowPosts::getCreatorId, creatorId) // 确保操作者是文章作者
.update();
if (!success) {
// 如果更新失败,抛出业务异常,提示草稿不存在或无权限
throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限");
}
}
/**
* 发布文章的方法
* 使用了事务管理和延迟双删除的自定义注解
*
* @param creatorId 创建者ID
* @param id 文章ID
*/
@Override
@Transactional
@DelayedDoubleDelete(id = "#id", delay = 500) // 自定义注解,用于延迟双删除,指定id参数和500毫秒延迟
public void publish(long creatorId, long id) {
invalidateCache(id); // 清除指定ID的缓存
// 执行更新操作,如果更新失败则抛出业务异常
boolean success = lambdaUpdate().eq(KnowPosts::getId, id)
.eq(KnowPosts::getCreatorId, creatorId)
.set(KnowPosts::getStatus, "published")
.set(KnowPosts::getPublishTime, Instant.now())
.set(KnowPosts::getUpdateTime, Instant.now())
.update();
if (!success) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限");
}
// 注册事务同步回调,在事务提交后执行
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() { // 重写afterCommit方法,在事务提交后执行
try {
// 触发 RAG(检索增强生成)预索引
// 这是一个"附加动作",不是核心业务,所以用 try-catch 包裹。
// 目的:将文章内容发送给向量数据库或 LLM 服务,提前生成 Embeddings。
// 好处:当文章正式发布时,无需等待索引时间,用户立刻就能搜到。
ragIndexService.ensureIndexed(id);
} catch (Exception e) {
log.warn("Pre-index after publish failed, post {}: {}", id, e.getMessage());
}
}
});
}
/**
* 更新帖子置顶状态的方法
*
* @param creatorId 创建者ID,用于验证权限
* @param id 帖子ID,需要更新的目标帖子
* @param isTop 是否置顶,true表示置顶,false表示取消置顶
* @throws BusinessException 当草稿不存在或无权限时抛出业务异常
*/
@Override
@DelayedDoubleDelete(id = "#id", delay = 500)
@Transactional
public void updateTop(long creatorId, long id, boolean isTop) {
invalidateCache(id); // 清除缓存,确保数据一致性
boolean success = lambdaUpdate().eq(KnowPosts::getId, id) // 设置更新条件:帖子ID匹配
.eq(KnowPosts::getCreatorId, creatorId) // 设置更新条件:创建者ID匹配,验证权限
.set(KnowPosts::getIsTop, isTop) // 设置更新字段:置顶状态
.set(KnowPosts::getUpdateTime, Instant.now()) // 设置更新字段:更新时间
.update(); // 执行更新操作
if (!success) { // 如果更新失败
throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限"); // 抛出业务异常
}
}
/**
* 更新帖子的可见性
* 该方法使用事务管理,并带有延迟双删除功能
*
* @param creatorId 发帖者ID,用于验证权限
* @param id 要更新的帖子ID
* @param visible 可见性值,必须是合法的可见性选项
* @throws BusinessException 如果可见性值不合法或帖子不存在/无权限
*/
@Override
@Transactional
@DelayedDoubleDelete(id = "#id", delay = 500)
public void updateVisibility(long creatorId, long id, String visible) {
// 验证可见性值是否合法
if (!isValidVisible(visible)) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "可见性取值非法");
}
// 使相关缓存失效
invalidateCache(id);
// 执行数据库更新操作,设置新的可见性和更新时间
boolean success = lambdaUpdate().eq(KnowPosts::getId, id)
.eq(KnowPosts::getCreatorId, creatorId) // 确保只有发帖者能修改
.set(KnowPosts::getVisible, visible) // 设置新的可见性
.set(KnowPosts::getUpdateTime, Instant.now()) // 更新修改时间
.update(); // 执行更新操作
// 检查更新是否成功
if (!success) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限");
}
}
/**
* 删除指定ID的草稿,带有事务支持和延迟双删功能
*
* @param creatorId 创建者ID,用于权限验证
* @param id 要删除的草稿ID
* @throws BusinessException 当草稿不存在或无权限时抛出
*/
@Override
@Transactional
@DelayedDoubleDelete(id = "#id", delay = 500)
public void delete(long creatorId, long id) {
invalidateCache(id); // 首先使缓存失效
// 使用Lambda更新器更新草稿状态
boolean success = lambdaUpdate()
.eq(KnowPosts::getId, id) // 设置条件:草稿ID等于传入的id
.eq(KnowPosts::getCreatorId, creatorId) // 设置条件:创建者ID等于传入的creatorId
.set(KnowPosts::getStatus, "deleted") // 设置状态为"deleted"
.set(KnowPosts::getUpdateTime, Instant.now()) // 更新修改时间为当前时间
.update(); // 执行更新操作
// 如果更新失败,抛出业务异常
if (!success)
throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限");
}
/**
* 获取知文详情(含作者信息、图片列表)。
* <p>
* 流程:
* 1. 尝试读取 Redis 缓存。
* 2. 若缓存命中,直接返回(需叠加实时计数与用户状态)。
* 3. 若缓存未命中,使用 SingleFlight 锁机制防止缓存击穿。
* 4. 锁内再次检查缓存(双重检查)。
* 5. 若仍未命中,回源查询数据库。
* 6. 校验内容状态与访问权限。
* 7. 组装数据并写入 Redis 缓存(带随机过期时间与热点自动延期)。
* 8. 返回最终结果(叠加用户维度状态)。
* </p>
*
* @param id 知文 ID
* @param currentUserIdNullable 当前用户 ID(可空,用于判断权限与点赞状态)
* @return 知文详情响应
*/
@Override
@Transactional(readOnly = true)
public KnowPostDetailResponse getDetail(long id, Long currentUserIdNullable) {
// 1. 构造缓存 Key:knowpost:detail:{id}:v{version}
String pageKey = "knowpost:detail:" + id + ":v" + DETAIL_LAYOUT_VER;
KnowPostDetailResponse local = knowPostDetailCache.getIfPresent(pageKey);
//如果本地缓存不为空
if (local != null) {
// 如果是热点数据,则延长缓存时间 异步记录热点
CompletableFuture.runAsync(() -> recordHotKeyAndExtendTtl(id, pageKey));
log.info("detail source=local key={}", pageKey);
return enrichDetailResponse(local, currentUserIdNullable, true);
}
String cached = redis.opsForValue().get(pageKey);
// 2. 第一次尝试处理缓存命中
// 如果缓存中有数据(且不是 "NULL"),则解析并返回
KnowPostDetailResponse resp = tryProcessCacheHit(cached, id, pageKey, currentUserIdNullable, "page");
if (resp != null) {
return resp;
}
// 3. 缓存未命中,进入 SingleFlight 模式
// 对同一个 pageKey 加锁,防止高并发下大量请求同时打到数据库(缓存击穿/惊群效应)
Object lock = singleFlight.computeIfAbsent(pageKey, k -> new Object());
try {
synchronized (lock) {
// 4. 双重检查(Double Check)
// 在获取锁后,再次检查缓存,因为在排队等待锁的过程中,前一个请求可能已经把数据写入缓存了
String again = redis.opsForValue().get(pageKey);
try {
resp = tryProcessCacheHit(again, id, pageKey, currentUserIdNullable, "page(after-flight)");
} catch (BusinessException e) {
// 如果缓存中明确记录了 "NULL"(即内容不存在),则直接抛出异常,不再查库
singleFlight.remove(pageKey);
throw e;
}
if (resp != null) {
// 缓存已由其他线程填充,直接返回
singleFlight.remove(pageKey);
return resp;
}
// 5. 回源查询数据库
KnowPosts post = this.getById(id);
// 增加判空,防止 copy 时空指针,并处理缓存穿透
if (post == null || "deleted".equals(post.getStatus())) {
// 缓存空对象,防止攻击者一直查不存在的 ID
redis.opsForValue().set(pageKey, "NULL",
java.time.Duration.ofSeconds(30 + ThreadLocalRandom.current().nextInt(31)));
throw new BusinessException(ErrorCode.BAD_REQUEST, "内容不存在");
}
KnowPostDetailRow row = BeanCopyUtils.copy(post, KnowPostDetailRow.class);
//补充缺失的属性
Users user = userService.findById(row.getCreatorId()).orElseThrow(()-> new BusinessException(ErrorCode.BAD_REQUEST, "作者不存在"));
if (user != null ) {
row.setAuthorAvatar(user.getAvatar());
row.setAuthorNickname(user.getNickname());
} else {
row.setAuthorNickname("未知用户"); // 兜底
row.setAuthorAvatar("");
}
//6.防止缓存穿透和缓存雪崩
if (row == null || "deleted".equals(row.getStatus())) {
redis.opsForValue().set(pageKey, "NULL",
java.time.Duration.ofSeconds(30 + ThreadLocalRandom.current().nextInt(31)));
singleFlight.remove(pageKey);
throw new BusinessException(ErrorCode.BAD_REQUEST, "内容不存在");
}
// 7. 权限校验
// 公开策略:状态为 published 且可见性为 public 的内容可直接访问
// 私有策略:否则仅作者本人可见
boolean isPublic = "published".equals(row.getStatus()) && "public".equals(row.getVisible());
//当前用户等于创建该知文的用户
boolean isOwner = currentUserIdNullable != null && row.getCreatorId() != null
&& currentUserIdNullable.equals(row.getCreatorId());
if (!isPublic && !isOwner) {
singleFlight.remove(pageKey);
throw new BusinessException(ErrorCode.BAD_REQUEST, "无权限查看");
}
// 8. 组装响应对象
// 解析图片和标签 JSON
List<String> images = parseStringArray(row.getImgUrls());
List<String> tags = parseStringArray(row.getTags());
Map<String, Long> counts = counterService.getCounts("knowpost", String.valueOf(row.getId()), List.of("like", "fav"));
Long likeCount = counts.getOrDefault("like", 0L);
Long favoriteCount = counts.getOrDefault("fav", 0L);
resp = new KnowPostDetailResponse(
String.valueOf(row.getId()),
row.getTitle(),
row.getDescription(),
row.getContentUrl(),
images,
tags,
String.valueOf(row.getCreatorId()),
row.getAuthorAvatar(),
row.getAuthorNickname(),
row.getAuthorTagJson(),
likeCount,
favoriteCount,
null, // liked 状态暂时留空,由 enrich 填充
null, // faved 状态暂时留空,由 enrich 填充
row.getIsTop(),
row.getVisible(),
row.getType(),
row.getPublishTime()
);
//写入缓存
try {
String json = objectMapper.writeValueAsString(resp);
int baseTtl = 60;
// 增加随机抖动(Jitter),防止大量缓存同时过期(雪崩)
int jitter = ThreadLocalRandom.current().nextInt(30);
// 根据热度检测结果动态调整 TTL,热点内容缓存时间更长
int target = hotKey.ttlForPublic(baseTtl, pageKey);
redis.opsForValue().set(pageKey, json, java.time.Duration.ofSeconds(Math.max(target, baseTtl + jitter)));
// L1 填充 写入本地缓存
knowPostDetailCache.put(pageKey, resp);
} catch (Exception ignore) {}
// 10. 释放锁并返回最终结果
// 返回前调用 enrich 填充用户维度的 liked/faved 状态
singleFlight.remove(pageKey);
return enrichDetailResponse(resp, currentUserIdNullable, false);
}
} finally {
// 释放锁
// 无论上面是 return 了,还是 throw Exception 了,这里一定会执行!
// 这行代码是防止内存泄漏的"救命稻草"。
singleFlight.remove(pageKey);
}
}
/**
* 解析JSON字符串为字符串列表
* @param json 需要解析的JSON格式字符串
* @return 解析后的字符串列表,如果输入为空或解析失败则返回空列表
*/
private List<String> parseStringArray(String json) {
// 检查输入是否为空或空白字符串
if (json == null || json.isBlank()) return Collections.emptyList();
try {
// 使用ObjectMapper将JSON字符串解析为List<String>类型
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
}catch (Exception e){
// 发生异常时返回空列表
return Collections.emptyList();
}
}
/**
* 尝试处理缓存命中逻辑。
* @param cached Redis 中读取的缓存字符串
* @param id 内容 ID
* @param pageKey 页面缓存 Key
* @param uid 当前用户 ID
* @param sourceLog 日志来源标识
* @return 若成功处理命中则返回响应对象,否则返回 null
*/
private KnowPostDetailResponse tryProcessCacheHit(String cached, long id, String pageKey,
Long uid, String sourceLog) {
// 1. 缓存为空,未命中
if (cached == null) {
return null;
}
// 2. 命中空值缓存(防止穿透)
if ("NULL".equals(cached)) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "内容不存在");
}
//3. 缓存命中
try {
KnowPostDetailResponse base = objectMapper.readValue(cached, KnowPostDetailResponse.class);
// L1 填充
knowPostDetailCache.put(pageKey, base);
// 4. 记录热度并尝试续期
// 如果该内容正在被高频访问,自动延长其缓存 TTL
recordHotKeyAndExtendTtl(id, pageKey);
log.info("detail source={} key={}", sourceLog, pageKey);
// 5. 叠加实时数据(计数与用户状态)并返回
return enrichDetailResponse(base, uid, true);
}catch (Exception e){
// 反序列化失败等异常情况,视为未命中,回源修复
return null;
}
}
/**
* 丰富详情响应:叠加实时计数与用户状态。
* @param base 基础响应对象(来自缓存或 DB)
* @param uid 当前用户 ID
* @param refreshCounts 是否需要从 CounterService 刷新计数(缓存命中时需要,DB 回源时不需要)
* @return 叠加了最新状态的响应对象
*/
private KnowPostDetailResponse enrichDetailResponse(KnowPostDetailResponse base, Long uid
, boolean refreshCounts) {
Long likeCount = base.likeCount();
Long favCount = base.favoriteCount();
// 1. 刷新计数(仅在走缓存时执行)
// 因为缓存中的计数可能是旧的,权威计数在 CounterService (Redis SDS)
if (refreshCounts){
Map<String, Long> counts = counterService.getCounts("knowpost", base.id(), List.of("like", "fav"));
likeCount = counts.getOrDefault("like", likeCount == null ? 0L : likeCount);
favCount = counts.getOrDefault("fav", favCount == null ? 0L : favCount);
}
// 2. 获取用户维度的状态(是否已点赞/收藏)
// 这部分数据是个性化的,不能存入公共缓存
Boolean liked = uid != null && counterService.isLiked("knowpost", base.id(), uid);
Boolean faved = uid != null && counterService.isFaved("knowpost", base.id(), uid);
// 3. 构造新的 Record 对象返回
return new KnowPostDetailResponse(
base.id(),
base.title(),
base.description(),
base.contentUrl(),
base.images(),
base.tags(),
base.authorId(),
base.authorAvatar(),
base.authorNickname(),
base.authorTagJson(),
likeCount,
favCount,
liked,
faved,
base.isTop(),
base.visible(),
base.type(),
base.publishTime()
);
}
/**
* 记录内容热度,并根据热度等级延长相关缓存的 TTL。
* 延长的缓存包括:
* 1. 详情页整页缓存 (knowpost:detail:{id})
* 2. Feed 流内容片段缓存 (feed:item:{id})
* 这样可以确保热点内容在 Feed 流中也不会轻易过期,避免 Feed 流回源。
* @param id 内容 ID
* @param detailPageKey 详情页缓存 Key
*/
private void recordHotKeyAndExtendTtl(long id, String detailPageKey) {
// 统一使用 knowpost:{id} 作为热度统计 Key
String hotKeyId = "knowpost:" + id;
hotKey.record(hotKeyId);
int baseTtl = 60;
int target = hotKey.ttlForPublic(baseTtl, hotKeyId);
Long expire = redis.getExpire(detailPageKey);
if (expire < target) {
redis.expire(detailPageKey, java.time.Duration.ofSeconds(target));
}
// 2. 延长 Feed 流内容片段缓存
String itemKey = "feed:item:" + id;
Long itemTtl = redis.getExpire(itemKey);
if (itemTtl < target) {
redis.expire(itemKey, java.time.Duration.ofSeconds(target));
}
}
}
5.4 实现知文Feed业务类
5.4.1 实现知文Feed业务接口
package com.xiaoce.zhiguang.knowpost.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPosts;
import com.xiaoce.zhiguang.knowpost.domain.vo.FeedPageResponse;
/**
* IKnowPostFeedService
* <p>
* 该接口定义了获取动态信息的相关服务方法
* 主要用于获取公开动态和用户发布的动态信息
*
* @author 小策
* @date 2026/2/7 10:08
*/
public interface IKnowPostFeedService extends IService<KnowPosts> {
/**
* 获取公开动态信息
*
* @param page 当前页码
* @param size 每页大小
* @param currentUserIdNullable 当前登录用户的ID(可为null)
* @return 返回分页的动态信息响应对象
*/
FeedPageResponse getPublicFeed(int page, int size, Long currentUserIdNullable);
/**
* 获取指定用户发布的动态信息
*
* @param userId 用户ID
* @param page 当前页码
* @param size 每页大小
* @return 返回分页的动态信息响应对象
*/
FeedPageResponse getMyPublished(long userId, int page, int size);
}
5.4.2 实现知文Feed业务实现类
5.4.2.1 完善count的查询所有用户点赞/收藏状态方法
查询所有用户点赞/收藏状态方法的接口
Map<String, Boolean> getBatchIsLiked(String entityType, List<String> entityIds, long userId);
Map<String, Boolean> getBatchIsFaved(String entityType, List<String> entityIds, long userId);
查询所有用户点赞/收藏状态方法的实现
@Override
/**
* 批量获取用户对多个实体的点赞状态
* @param entityType 实体类型,如文章、评论等
* @param entityIds 实体ID列表
* @param userId 用户ID
* @return 包含实体ID和对应点赞状态的Map
*/
public Map<String, Boolean> getBatchIsLiked(String entityType, List<String> entityIds, long userId) {
// 参数校验,确保输入参数不为空
if (entityType == null || entityIds == null || entityIds.isEmpty()) {
throw new BusinessException(ErrorCode.BAD_REQUEST,"参数不能为空");
}
// 计算用户ID在位图中的分片位置
long chunk = BitmapShard.chunkOf(userId);
// 计算用户ID在位图中的偏移量
long offset = BitmapShard.bitOf(userId);
// 3. 使用 Pipeline 批量查询
List<Object> results = redis.executePipelined(new RedisCallback<Object>() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
for (String entityId : entityIds) {
// 构造每一个实体对应的 Redis Key
// 注意:这里要把 "like" 还是 "fav" 分清楚,你的方法名是 isLiked,但上面单条用的是 "fav"
// 假设这里是查点赞,那就是 "like"
String key = CounterKeys.bitmapKey("like", entityType, entityId, chunk);
// 发送 getBit 命令
connection.stringCommands().getBit(key.getBytes(), offset);
}
return null; // 在 Pipeline 中必须返回 null
}
});
// 组装结果
// results 里的顺序和 entityIds 的顺序是严格一一对应的
Map<String, Boolean> resultMap = new HashMap<>(entityIds.size());
for (int i = 0; i < entityIds.size(); i++) {
String entityId = entityIds.get(i);
Object res = results.get(i);
// Redis 返回的 getBit 结果通常是 Boolean 或者 0/1,Spring Data Redis 会转为 Boolean
boolean isLiked = res != null && (Boolean) res;
resultMap.put(entityId, isLiked);
}
return resultMap;
}
/**
* 批量获取用户是否收藏了指定类型的实体
* @param entityType 实体类型
* @param entityIds 实体ID列表
* @param userId 用户ID
* @return 包含实体ID和是否收藏的映射关系
* @throws BusinessException 当参数为空时抛出
*/
@Override
public Map<String, Boolean> getBatchIsFaved(String entityType, List<String> entityIds, long userId) {
// 检查参数合法性
if (entityType == null || entityIds == null || entityIds.isEmpty()) {
throw new BusinessException(ErrorCode.BAD_REQUEST,"参数不能为空");
}
// 计算用户ID在位图中的分片位置
long chunk = BitmapShard.chunkOf(userId);
// 计算用户ID在位图中的偏移量
long offset = BitmapShard.bitOf(userId);
// 3. 使用 Pipeline 批量查询
List<Object> results = redis.executePipelined(new RedisCallback<Object>() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException{
for (String entityId : entityIds){
// 构造每一个实体对应的 Redis Key
// 注意:这里要把 "like" 还是 "fav" 分清楚,你的方法名是 isLiked,但上面单条用的是 "fav"
// 假设这里是查点赞,那就是 "like"
String key = CounterKeys.bitmapKey("fav", entityType, entityId, chunk);
// 发送 getBit 命令
connection.stringCommands().getBit(key.getBytes(), offset);
}
return null;
}
});
// 组装结果
// results 里的顺序和 entityIds 的顺序是严格一一对应的
Map<String, Boolean> resultMap = new HashMap<>(entityIds.size());
for (int i = 0; i < entityIds.size(); i++) {
String entityId = entityIds.get(i);
Object res = results.get(i);
// Redis 返回的 getBit 结果通常是 Boolean 或者 0/1,Spring Data Redis 会转为 Boolean
boolean isFaved = res != null && (Boolean) res;
resultMap.put(entityId, isFaved);
}
return resultMap;
}
5.4.2.2 知文Feed流业务实现类
package com.xiaoce.zhiguang.knowpost.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.xiaoce.zhiguang.cache.hotkey.HotKeyDetector;
import com.xiaoce.zhiguang.common.utils.BeanCopyUtils;
import com.xiaoce.zhiguang.common.utils.CollectionUtil;
import com.xiaoce.zhiguang.counter.service.impl.CounterService;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPostFeedRow;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPosts;
import com.xiaoce.zhiguang.knowpost.domain.vo.FeedItemResponse;
import com.xiaoce.zhiguang.knowpost.domain.vo.FeedPageResponse;
import com.xiaoce.zhiguang.knowpost.mapper.KnowPostsMapper;
import com.xiaoce.zhiguang.knowpost.service.IKnowPostFeedService;
import com.xiaoce.zhiguang.user.domain.po.Users;
import com.xiaoce.zhiguang.user.service.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* KnowPostFeedServiceImpl
* <p>
* 知文Feed流实现类
*
* @author 小策
* @date 2026/2/7 10:09
*/
@Service
@Slf4j
public class KnowPostFeedServiceImpl extends ServiceImpl<KnowPostsMapper, KnowPosts> implements IKnowPostFeedService {
private final StringRedisTemplate redis;
private final ObjectMapper objectMapper;
private final CounterService counterService;
private final Cache<String, FeedPageResponse> feedPublicCache;
private final Cache<String, FeedPageResponse> feedMineCache;
private final HotKeyDetector hotKey;
private static final int LAYOUT_VER = 1;
private final ConcurrentHashMap<String, Object> singleFlight = new ConcurrentHashMap<>();
private final IUserService userService;
/**
* 构造函数:注入 Mapper、Redis、对象映射器、计数服务与本地缓存。
* @param redis Redis 客户端
* @param objectMapper JSON 序列化/反序列化器
* @param counterService 点赞/收藏计数服务
* @param feedPublicCache 首页公共 Feed 本地缓存
* @param feedMineCache 我的发布 Feed 本地缓存
* @param hotKey 热点 Key 检测器,用于动态延长 TTL
*/
public KnowPostFeedServiceImpl(
StringRedisTemplate redis,
ObjectMapper objectMapper,
CounterService counterService,
@Qualifier("feedPublicCache") Cache<String, FeedPageResponse> feedPublicCache,
@Qualifier("feedMineCache") Cache<String, FeedPageResponse> feedMineCache,
HotKeyDetector hotKey,
IUserService userService
) {
this.redis = redis;
this.objectMapper = objectMapper;
this.counterService = counterService;
this.feedPublicCache = feedPublicCache;
this.feedMineCache = feedMineCache;
this.hotKey = hotKey;
this.userService = userService;
}
/**
* 生成公共 Feed 页面的缓存 Key(包含分页与布局版本)。
* @param page 页码(1 起)
* @param size 每页大小
* @return Redis/Page 缓存的 Key
*/
private String cacheKey(int page, int size) {
return "feed:public:" + size + ":" + page + ":v" + LAYOUT_VER;
}
private void recordItemHotKey(String itemId) {
// 使用内容 ID 作为热点统计 Key,而不是页面 Key 只计数
String hotKeyId = "knowpost:" + itemId;
hotKey.record(hotKeyId);
int baseTtl = 60;
int target = hotKey.ttlForPublic(baseTtl, hotKeyId);
// 延长该内容的详情片段缓存 页面具体缓存的key
String itemKey = "feed:item:" + itemId;
Long itemTtl = redis.getExpire(itemKey);
if (itemTtl < target) {
redis.expire(itemKey, Duration.ofSeconds(target));
}
}
/**
* 叠加用户维度状态,将 liked/faved 根据用户计算覆盖到列表上。
* 不改写底层缓存,避免不同用户状态互相污染。
* @param base 基础列表(含计数)
* @param uid 用户 ID(可空)
* @return 叠加 liked/faved 的列表,根据当前页判断用户是否点赞,收藏
*/
private List<FeedItemResponse> enrich(List<FeedItemResponse> base, Long uid) {
if (CollectionUtil.isEmpty(base)) {
return Collections.emptyList();
}
// 创建一个新的列表,用于存储处理后的 FeedItemResponse 对象
ArrayList<FeedItemResponse> out = new ArrayList<>(base.size());
// 获取所有知文id
List<String> feedIds = base.stream().map(FeedItemResponse::id).collect(Collectors.toList());
// 准备状态 Map (默认为空,防止 NPE)
Map<String, Boolean> isLikedMap = Collections.emptyMap();
Map<String, Boolean> isFavedMap = Collections.emptyMap();
//只有用户登录了 (uid != null) 才去查状态!
if (uid != null) {
// 建议:CounterService 内部要处理好异常,如果报错返回空 Map 而不是 null
isLikedMap = counterService.getBatchIsLiked("knowpost", feedIds, uid);
isFavedMap = counterService.getBatchIsFaved("knowpost", feedIds, uid);
// 防御性编程:万一 Service 真的返回了 null,将其修正为空 Map,防止下面 crash
if (isLikedMap == null) isLikedMap = Collections.emptyMap();
if (isFavedMap == null) isFavedMap = Collections.emptyMap();
}
// 遍历基础列表中的每个 FeedItemResponse 对象
for (FeedItemResponse feed : base) {
// 从 isLikedMap 中获取当前 FeedItemResponse 对象的 isLike 值,如果不存在则默认为 false
boolean isLike = isLikedMap.getOrDefault(feed.id(), false);
// 从 isFavedMap 中获取当前 FeedItemResponse 对象的 isFaved 值,如果不存在则默认为 false
boolean isFaved = isFavedMap.getOrDefault(feed.id(), false);
// 创建一个新的 FeedItemResponse 对象,包含原有属性和用户点赞、收藏状态
// 创建一个新的 FeedItemResponse 对象,包含原有属性和用户点赞、收藏状态
out.add(new FeedItemResponse(
feed.id(), // 条目 ID
feed.title(), // 标题
feed.description(), // 描述
feed.coverImage(), // 封面图片
feed.tags(), // 标签
feed.authorAvatar(),// 作者头像
feed.authorNickname(), // 作者昵称
feed.tagJson(), // 标签 JSON
feed.likeCount(), // 点赞数
feed.favoriteCount(), // 收藏数
isLike, // 当前用户是否点赞
isFaved, // 当前用户是否收藏
feed.isTop() // 是否置顶
));
}
// 返回处理后的列表
return out;
}
/**
* 从缓存中组装Feed页面响应数据
* @param idsKey 存储ID列表的Redis键
* @param hasMoreKey 存储是否有更多数据的标记的Redis键
* @param page 当前页码
* @param size 每页大小
* @param uid 用户ID
* @return 组装好的Feed页面响应数据,如果缓存中没有数据则返回null
*/
private FeedPageResponse assembleFromCache(String idsKey, String hasMoreKey, int page, int size, Long uid) {
// 先取 hasMore 标记
String hasMoreStr = redis.opsForValue().get(hasMoreKey);
// // 从Redis中获取指定范围内的ID列表
List<String> idList = redis.opsForList().range(idsKey, 0, size - 1);
// 如果 Redis 里明确写着 hasMore="0",并且 idList 是空的。
// 这说明:这是一个被我们特意标记的"空缓存页面"。
// 动作:直接返回空对象,不要返回 null (返回 null 会触发数据库回源)。
if ("0".equals(hasMoreStr) && (idList == null || idList.isEmpty())) {
return new FeedPageResponse(new ArrayList<>(), page, size, false);
}
// 如果清单是空的,说明缓存过期了或者根本没生成过,直接返回 null。
// 外层逻辑看到 null 后,会触发"回源"(去数据库查)。
if (idList == null || idList.isEmpty()) {
return null;
}
// 我们要根据 ID 列表,拼凑出每一篇文章的 Redis Key。
// 比如 id="1001" -> key="feed:item:1001"
List<String> itemKeys = new ArrayList<>(idList.size());
for (String id : idList) {
itemKeys.add("feed:item:" + id);
}
// 这里用 multiGet,相当于开了一辆大卡车,一次往返就把 10 个包裹都拉回来了。
List<String> itemJsons = redis.opsForValue().multiGet(itemKeys);
// 反序列化为对象列表
List<FeedItemResponse> items = new ArrayList<>(idList.size());
/**
* 遍历ID列表,处理每个ID对应的JSON数据
* 1. 从itemJsons列表中获取对应索引的JSON字符串,如果为null则直接返回null
* 2. 使用objectMapper将JSON字符串转换为FeedItemResponse对象
* 3. 将转换后的对象添加到items列表中
*/
for (int i = 0; i < idList.size(); i++) {
// 获取当前索引对应的JSON字符串,如果itemJsons为null或索引超出范围,则设为null
String itemJson = (itemJsons != null && i < itemJsons.size()) ? itemJsons.get(i) : null;
// 如果JSON字符串为null,直接返回null
if (itemJson == null) {
return null;
}
try {
// 将JSON字符串转换为FeedItemResponse对象并添加到items列表
FeedItemResponse item = objectMapper.readValue(itemJson, FeedItemResponse.class);
items.add(item);
} catch (JsonProcessingException e) {
// JSON解析失败时返回null
return null;
}
}
// 提取所有项目的ID列表
List<String> itemsIds = items.stream().map(FeedItemResponse::id).collect(Collectors.toList());
// 批量获取每个项目的点赞和收藏数量
Map<String, Map<String, Long>> batchCounts =
counterService.getCountsBatch("knowpost", itemsIds, List.of("like", "fav"));
// 3.3 【批量查询】用户状态(我是否点赞/收藏)
Map<String, Boolean> batchLiked = new HashMap<>();
Map<String, Boolean> batchFaved = new HashMap<>();
// 如果用户ID不为null,则查询该用户对每个项目的点赞和收藏状态
if (uid != null) {
batchLiked = counterService.getBatchIsLiked("knowpost", itemsIds, uid);
batchFaved = counterService.getBatchIsFaved("knowpost", itemsIds, uid);
}
// 创建结果列表,大小与ID列表相同
List<FeedItemResponse> enriched = new ArrayList<>(idList.size());
// 遍历处理后的项目列表
for (FeedItemResponse base : items) {
// 获取当前项目的点赞和收藏数量
Map<String, Long> counts = batchCounts.getOrDefault(base.id(), Collections.emptyMap());
long likeCount = counts.getOrDefault("like", 0L);
long favCount = counts.getOrDefault("fav", 0L);
// 获取当前项目的点赞和收藏状态
Boolean isLike = batchLiked.getOrDefault(base.id(), false);
Boolean isFav = batchFaved.getOrDefault(base.id(), false);
// 重新组装FeedItemResponse对象,注入批量查询到的数据
enriched.add(new FeedItemResponse(
base.id(),
base.title(),
base.description(),
base.coverImage(),
base.tags(),
base.authorAvatar(),
base.authorNickname(),
base.tagJson(),
likeCount, // 注入批量查到的数据
favCount, // 注入批量查到的数据
isLike, // 注入批量查到的状态
isFav, // 注入批量查到的状态
base.isTop())
);
}
// hasMore 优先使用软缓存值;若缺失,则以"满页"作为兜底判断
boolean hasMore = hasMoreStr != null ? "1".equals(hasMoreStr) : (idList.size() == size);
return new FeedPageResponse(enriched, page, size, hasMore);
}
/**
* 将数据库行映射为响应条目。
* 计数通过计数服务填充;liked/faved 按需计算;isTop 仅在个人列表返回计数。
* @param rows 查询结果行
* @param userIdNullable 当前用户 ID(可空)
* @param includeIsTop 是否在响应中包含 isTop
* @return 条目列表
*/
private List<FeedItemResponse> mapRowsToItems(List<KnowPostFeedRow> rows, Long userIdNullable, boolean includeIsTop) {
if (rows == null || rows.isEmpty()) {
return new ArrayList<>();
}
//优化性能,批量查询计数,用户对帖子的状态
List<String> postIds = rows.stream().map(r -> String.valueOf(r.getId())).collect(Collectors.toList());
Map<String, Map<String, Long>> batchCounts =
counterService.getCountsBatch("knowpost", postIds, List.of("like", "fav"));
Map<String, Boolean> batchLiked = new HashMap<>();
Map<String, Boolean> batchFaved = new HashMap<>();
if (userIdNullable != null) {
batchLiked = counterService.getBatchIsLiked("knowpost", postIds, userIdNullable);
batchFaved = counterService.getBatchIsFaved("knowpost", postIds, userIdNullable);
}
// 准备一个空箱子,容量和原材料数量一样大(避免扩容浪费性能)
List<FeedItemResponse> items = new ArrayList<>(rows.size());
for (KnowPostFeedRow row : rows) {
// --- 数据清洗 ---
String postIdStr = String.valueOf(row.getId());
// 数据库里存的是 "java,spring,redis" 这种字符串
// 前端要的是 ["java", "spring", "redis"] 这种数组
List<String> tags = parseStringArray(row.getTags());
// 图片同理,数据库存的是逗号分隔的字符串,要转成列表
List<String> imgs = parseStringArray(row.getImgUrls());
// 封面图逻辑:如果有图,取第一张;没图就是 null
String cover = imgs.isEmpty() ? null : imgs.getFirst();
Map<String, Long> count = batchCounts.getOrDefault(row.getId().toString(), Collections.emptyMap());
long likeCount = count.getOrDefault("like", 0L);
long favCount = count.getOrDefault("fav", 0L);
Boolean isLike = batchLiked.getOrDefault(row.getId().toString(), false);
Boolean isFav = batchFaved.getOrDefault(row.getId().toString(), false);
Boolean isTop = includeIsTop ? row.getIsTop() : null;
// --- 组装对象 ---
items.add(new FeedItemResponse(
postIdStr,
row.getTitle(),
row.getDescription(),
cover,
tags,
row.getAuthorAvatar(),
row.getAuthorNickname(),
row.getAuthorTagJson(),
likeCount,
favCount,
isLike,
isFav,
isTop
));
}
return items;
}
/**
* 解析JSON字符串为字符串列表
* @param json 需要解析的JSON格式字符串
* @return 解析后的字符串列表,如果解析失败或输入为空则返回空列表
*/
private List<String> parseStringArray(String json) {
// 检查输入是否为null或空白字符串
if (json == null || json.isBlank()){
// 返回空列表
return Collections.emptyList();
}
try {
// new TypeReference<List<String>>() {}它强行把 List<String> 这个泛型信息保留了下来,
// 递给了 Jackson,让它知道:"嘿,别光给我一个 List,我要的是装着 String 的 List!
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (JsonProcessingException e) {
// 如果解析过程中发生异常,返回空列表
return Collections.emptyList();
}
}
/**
* 缓存写入核心方法
* 作用:将数据库查到的数据,拆解成"清单"、"信号灯"、"详情"、"索引"四份,存入 Redis。
* @param pageKey 当前页面的 Key (如 feed:ids:10:46000:1)
* @param idsKey ID 清单 Key
* @param hasMoreKey 是否有下一页的标记 Key
* @param size 分页大小
* @param rows 数据库查出来的原始行(用于提取 ID)
* @param items 处理好的详情对象(用于存 JSON)
* @param hasMore 数据库告诉我们后面还有没有数据
* @param frTtl 缓存过期时间 (Time To Live)
*/
private void writeCaches(String pageKey, String idsKey, String hasMoreKey, int size, List<KnowPostFeedRow> rows,
List<FeedItemResponse> items, boolean hasMore, Duration frTtl) {
if (rows == null || rows.isEmpty()) {
// 写入 hasMore = "0" (明确告诉读缓存逻辑:这里是尽头,别查库了)
// TTL 是外面传进来的,空结果通常传 30秒 左右,不要太长。
redis.opsForValue().set(hasMoreKey, "0", frTtl);
return;
}
List<String> idVals = new ArrayList<>();
for (KnowPostFeedRow row : rows) {
idVals.add(String.valueOf(row.getId()));
}
if (CollectionUtil.isEmpty(idVals)){
return;
}
// 计算当前小时的时间槽(用于索引)
long hourSlot = System.currentTimeMillis() / 3600000L;
// 计算 Jitter 随机时间(提取到循环外)
long randomJitter = ThreadLocalRandom.current().nextInt(11);
// 所有的 redis 操作不会立即执行,而是先放入队列,最后一次性打包发给 Redis Server。
redis.executePipelined((RedisCallback<Object>) connection -> {
// 1. 存 ID 清单。
// 注意:这里需要将 String 转为 byte[],因为是底层 Connection 操作
byte[][] idBytes = idVals.stream().map(String::getBytes).toArray(byte[][]::new);
connection.listCommands().lPush(idsKey.getBytes(), idBytes);
connection.keyCommands().expire(idsKey.getBytes(), frTtl.getSeconds());
// 2. 存 HasMore 标记 setEx (SET with Expire):是给 字符串(String) 赋值,还是个独生子(只能存一个值)。
String hasMoreVal = (idVals.size() == size && hasMore) ? "1" : "0";
long hasMoreTtl = (idVals.size() == size && hasMore) ? (10 + randomJitter) : 10;
connection.stringCommands().setEx(hasMoreKey.getBytes(), hasMoreTtl, hasMoreVal.getBytes());
// 3. 存页面总索引 sAdd (Set Add):是给 集合(Set) 添加元素,是个大家庭(能存很多不重复的值)。
// 把当前生成的 pageKey 记在一个 Set 里。
// 作用:如果未来需要"一键清空所有信息流缓存",直接遍历这个 Set 删除即可。
connection.setCommands().sAdd("feed:public:pages".getBytes(), pageKey.getBytes());
// 4. 循环写入"详情"和"反向索引"
for (FeedItemResponse item : items) {
try {
// A. 反向索引 Key
String idxKey = "feed:public:index:" + item.id() + ":" + hourSlot;
connection.setCommands().sAdd(idxKey.getBytes(), pageKey.getBytes());
connection.keyCommands().expire(idxKey.getBytes(), frTtl.getSeconds());
// B. 详情 Key
String itemKey = "feed:item:" + item.id();
byte[] bytes = objectMapper.writeValueAsBytes(item);
connection.stringCommands().setEx(itemKey.getBytes(), frTtl.getSeconds(), bytes);
}catch (Exception e){
// 序列化失败时不中断整个 Pipeline,只跳过这一条
log.error("缓存序列化失败 item={}", item.id(), e);
}
}
return null;
});
}
private String myCacheKey(long userId, int page, int size) {
return "feed:mine:" + userId + ":" + size + ":" + page;
}
/**
* 根据热点key策略可能延长TTL时间
* @param key 需要检查的Redis键
*/
private void maybeExtendTtlMine(String key) {
// 设置基础TTL时间为30秒
int baseTtl = 30;
// 根据热点key策略计算目标TTL时间
int target = hotKey.ttlForMine(baseTtl, key);
// 获取当前键的TTL时间
Long currentTtl = redis.getExpire(key);
// 如果当前TTL小于目标TTL,则更新TTL
if (currentTtl < target) {
redis.expire(key, Duration.ofSeconds(target));
}
}
/**
* 获取公开的首页 Feed(按发布时间倒序,不受置顶影响)。
* 采用三级缓存:本地 Caffeine、Redis 页面缓存、Redis 片段缓存(ids/item/count)。
* @param page 页码(≥1)
* @param size 每页数量(1~50)
* @param currentUserIdNullable 当前用户 ID(为空表示匿名)
* @return 带分页信息的 Feed 列表(liked/faved 为用户维度)
*/
@Override
public FeedPageResponse getPublicFeed(int page, int size, Long currentUserIdNullable) {
int safeSize = Math.min(Math.max(size, 1), 50);
int safePage = Math.max(page, 1);
// 这个 localPageKey 是本地缓存的页面 Key(非 Redis)
String localPageKey = cacheKey(safePage, safeSize);
// 按小时分片的片段缓存键:降低跨小时内容更新导致的大面积失效风险
// 将分页维度(size/page)与时间维度(hourSlot)组合,避免热门页在整站失效时同时回源
long hourSlot = System.currentTimeMillis() / 3600000L;
String idsKey = "feed:public:ids:" + safeSize + ":" + hourSlot + ":" + safePage;
//用来判断当前是否是最后一页,后面是否还有数据
String hasMoreKey = "feed:public:ids:" + safeSize + ":" + hourSlot + ":" + safePage + ":hasMore";
FeedPageResponse local = feedPublicCache.getIfPresent(localPageKey);
if(local != null && local.items() != null){
// 对返回列表中的每个条目进行热度统计,并根据统计热度,进行延长ttl
for (FeedItemResponse item : local.items()) {
recordItemHotKey(item.id());
}
log.info("feed.public source=local localPageKey={} page={} size={}", localPageKey, safePage, safeSize);
List<FeedItemResponse> enrichedLocal = enrich(local.items(), currentUserIdNullable);
return new FeedPageResponse(enrichedLocal, safePage, safeSize, local.hasMore());
}
// L2: 二级缓存,Redis 片段缓存,组装
FeedPageResponse fromCache = assembleFromCache(idsKey, hasMoreKey, safePage, safeSize, currentUserIdNullable);
if (fromCache != null) {
feedPublicCache.put(localPageKey, fromCache);
// 对返回列表中的每个条目进行热度统计,并延长缓存时间
if (fromCache.items() != null) {
for (FeedItemResponse item : fromCache.items()) {
recordItemHotKey(item.id());
}
}
log.info("feed.public source=3tier localPageKey={} page={} size={}", localPageKey, safePage, safeSize);
return fromCache;
}
// 当上述两级缓存都没有数据,说明需要回源查数据库
// 为了防止高并发下(例如 1000 个请求同时访问同一页)
// 所有请求同时打到数据库(造成 缓存击穿 ),这里使用了锁
// 单航班机制:以 idsKey 作为"航班号"
// 并发下同一页只允许一个请求回源数据库,其余在锁内优先重查缓存,避免击穿惊群
Object lock = singleFlight.computeIfAbsent(idsKey, k -> new Object());
try {
synchronized (lock){
// 双重检查,防止缓存被其他线程提前更新
// L2: 二级缓存,Redis 片段缓存,组装
FeedPageResponse again = assembleFromCache(idsKey, hasMoreKey, safePage, safeSize, currentUserIdNullable);
if (again != null) {
feedPublicCache.put(localPageKey, again);
// 对返回列表中的每个条目进行热度统计,并延长缓存时间
if (again.items() != null) {
for (FeedItemResponse item : again.items()) {
recordItemHotKey(item.id());
}
}
log.info("feed.public source=3tier localPageKey={} page={} size={}", localPageKey, safePage, safeSize);
return again;
}
// 数据库回源:读取 size+1 以判断是否有下一页,后裁剪为当前页
int offset = (safeSize * (safePage - 1));
//知文信息
List<KnowPosts> knowPosts = lambdaQuery().eq(KnowPosts::getStatus, "published")
.eq(KnowPosts::getVisible, "public")
.orderByDesc(KnowPosts::getPublishTime)
.last("limit " + (safeSize + 1) + " offset " + offset)
.list();
boolean hasMore = knowPosts.size() > safeSize;
if (hasMore){
knowPosts = knowPosts.subList(0,safeSize);
}
//如果查询出来的知文信息为空,缓存空值
// 如果 knowPosts 为空(比如用户翻到了第 10000 页),如果不缓存空结果,
// 下次请求还会打到数据库(缓存穿透)。
// 建议:即使为空,也调用 writeCaches 写入一个空列表,TTL 设置短一点(如 30秒)。
if (CollectionUtil.isEmpty(knowPosts)){
writeCaches(localPageKey, idsKey, hasMoreKey, safeSize,
Collections.emptyList(), // rows 为空
Collections.emptyList(), // items 为空
false, // hasMore 为 false
Duration.ofSeconds(30)); // TTL 短一点 (30秒)
return new FeedPageResponse(Collections.emptyList(), safePage, safeSize, false);
}
//用户信息,为后续做填充做准备
Stream<Long> userId = knowPosts.stream().map(KnowPosts::getCreatorId);
List<Users> users = userService.listByIds(userId.collect(Collectors.toList()));
Map<Long, Users> userMap = users.stream().collect(Collectors.toMap(Users::getId, user -> user));
// 构建基础列表(计数已填充),liked/faved 置为 null 以免污染用户维度缓存
List<KnowPostFeedRow> rows = new ArrayList<KnowPostFeedRow>();
//遍历知识帖子列表,为每个帖子创建一个KnowPostFeedRow对象,并设置作者相关信息
//从userMap中获取作者信息,如果找不到作者则设置默认的错误信息
//knowPosts 知识帖子列表,包含帖子基本信息
// 用户ID到用户信息的映射表,用于快速查找作者信息
//rows 用于存储处理后的KnowPostFeedRow对象的列表
for (KnowPosts knowPost : knowPosts) { // 遍历知识帖子列表
// 使用BeanCopyUtils将knowPost对象复制为KnowPostFeedRow对象
KnowPostFeedRow row = BeanCopyUtils.copy(knowPost, KnowPostFeedRow.class);
// 获取帖子创建者的ID
Long uid = knowPost.getCreatorId();
// 从用户映射表中获取用户信息
Users user = userMap.get(uid);
// 如果用户信息存在* @param
if (user!= null) {
// 设置作者头像
row.setAuthorAvatar(user.getAvatar());
// 设置作者昵称
row.setAuthorNickname(user.getNickname());
// 设置作者标签JSON
row.setAuthorTagJson(user.getTagsJson());
}else {
// 如果用户信息不存在
// 设置默认空头像
row.setAuthorAvatar(" ");
// 设置错误提示的作者昵称
row.setAuthorNickname("出错了!!未知用户!!");
row.setAuthorTagJson("出错了!!");
}
// 将处理后的行添加到结果列表中
rows.add(row);
}
List<FeedItemResponse> items = mapRowsToItems(rows, null, false);
FeedPageResponse response = new FeedPageResponse(items, safePage, safeSize, hasMore);
// 片段缓存(ids/item/count)TTL 更长并加入随机抖动,降低同一时刻大量过期
int baseTtl = 60;
int jitter = ThreadLocalRandom.current().nextInt(30);
Duration frTtl = Duration.ofSeconds(baseTtl + jitter);
// 写入片段缓存与本地缓存
writeCaches(localPageKey, idsKey, hasMoreKey, safeSize, rows, items, hasMore, frTtl);
feedPublicCache.put(localPageKey, response);
// 返回时覆盖用户维度状态,不写回缓存
List<FeedItemResponse> enriched = enrich(items, currentUserIdNullable);
log.info("feed.public source=db localPageKey={} page={} size={} hasMore={}", localPageKey, safePage, safeSize, hasMore);
// 释放单航班锁,允许后续请求正常进入
singleFlight.remove(idsKey);
return new FeedPageResponse(enriched, safePage, safeSize, hasMore);
}
} finally {
// 保底方案,释放单航班锁,允许后续请求正常进入
singleFlight.remove(idsKey);
}
}
/**
* 获取当前用户自己发布的知文列表(按发布时间倒序)。
* 缓存策略:本地 Caffeine + Redis 页面缓存(TTL 更短)。
* 返回的每条目包含 isTop 字段以表示是否置顶。
* @param userId 当前用户 ID
* @param page 页码(≥1)
* @param size 每页数量(1~50)
* @return 带分页信息的个人发布列表
*/
@Override
public FeedPageResponse getMyPublished(long userId, int page, int size) {
// 参数校验:确保 size 在 1~50 范围内,page ≥ 1
int safeSize = Math.min(Math.max(size, 1), 50);
int safePage = Math.max(page, 1);
// 生成缓存 key
String key = myCacheKey(userId, safePage, safeSize);
// 1. 从本地缓存去取
FeedPageResponse local = feedMineCache.getIfPresent(key);
if (local != null) {
hotKey.record(key);
maybeExtendTtlMine(key);
log.info("feed.mine source=local key={} page={} size={} user={}", key, safePage, safeSize, userId);
return local;
}
//2. 本地缓存没有,则去redis中去取
String cache = redis.opsForValue().get(key);
if (cache != null){
try {
//为什么这里填充点赞数,收藏数等等。那是因为个人流缓存是私有的,所以直接在查库转换时就把状态填好并缓存起来了
FeedPageResponse cacheRes = objectMapper.readValue(cache, FeedPageResponse.class);
boolean hasCounts = cacheRes.items() !=null && cacheRes.items().stream()
.allMatch(item -> item.liked() !=null && item.faved() != null);
//如果数据没问题,需要写入本地缓存
if (hasCounts){
feedMineCache.put(key, cacheRes);
hotKey.record(key);
maybeExtendTtlMine(key);
log.info("feed.mine source=page key={} page={} size={} user={}", key, safePage, safeSize, userId);
// 前面缓存中存了点赞数等数据,为什么这里又要填充数据,原因如下:
//在条件允许的情况下,我们尽量调用 enrich 用最新数据覆盖旧数据,给用户最好的体验。
List<FeedItemResponse> enriched = enrich(cacheRes.items(), userId);
return new FeedPageResponse(enriched, safePage, safeSize, cacheRes.hasMore());
}
} catch (JsonProcessingException ignore) {
log.warn("个人流缓存解析失败,触发回源 key={} ", key, ignore);
}
}
//3. 本地缓存和redis缓存都没有,则需要进行数据库中查询
// 数据库回源:读取 size+1 以判断是否有下一页,后裁剪为当前页
int offset = (safeSize * (safePage - 1));
//知文信息
List<KnowPosts> knowPosts = lambdaQuery().eq(KnowPosts::getStatus, "published")
.eq(KnowPosts::getCreatorId, userId)
.eq(KnowPosts::getVisible, "public")
.orderByDesc(KnowPosts::getPublishTime)
.last("limit " + (safeSize + 1) + " offset " + offset)
.list();
// 判断是否是最后一页
boolean hasMore = knowPosts.size() > safeSize;
if (hasMore){
knowPosts = knowPosts.subList(0,safeSize);
}
//如果查询出来的知文信息为空,缓存空值
// 如果 knowPosts 为空(比如用户翻到了第 10000 页),如果不缓存空结果,
// 下次请求还会打到数据库(缓存穿透)。
if (CollectionUtil.isEmpty(knowPosts)){
FeedPageResponse emptyResp = new FeedPageResponse(Collections.emptyList(), safePage, safeSize, false);
try {
// 序列化成 JSON
String emptyJson = objectMapper.writeValueAsString(emptyResp);
// 入 Redis 【关键点:TTL 要短!】
// 设置 30秒 过期。防止用户一会儿发了新帖,结果被这个"空缓存"挡住看不见。
// key 是你在前面定义的:String key = myCacheKey(userId, safePage, safeSize);
redis.opsForValue().set(key, emptyJson, Duration.ofSeconds(30));
// 同步写入本地缓存 (Caffeine)
feedMineCache.put(key, emptyResp);
} catch (Exception e) {
log.error("缓存空值序列化失败 key={}", key, e);
}
// 直接返回!
// 后面查用户信息的逻辑不需要跑了,省一次数据库查询。
return emptyResp;
}
//用户信息,为后续做填充做准备
Users user = userService.getBaseMapper().selectById(userId);
List<KnowPostFeedRow> rows = new ArrayList<>();
for (KnowPosts knowPost : knowPosts) {
KnowPostFeedRow row = BeanCopyUtils.copy(knowPost, KnowPostFeedRow.class);
row.setTags(user == null ? "[]" : user.getTagsJson());
row.setAuthorAvatar(user == null ? " " : user.getAvatar());
row.setAuthorNickname(user == null ? "出错了!!" : user.getNickname());
rows.add(row);
}
List<FeedItemResponse> items = mapRowsToItems(rows, userId, true);
FeedPageResponse resp = new FeedPageResponse(items, safePage, safeSize, hasMore);
// 数据库数据写入缓存
try {
String json = objectMapper.writeValueAsString(resp);
int baseTtl = 30;
int jitter = ThreadLocalRandom.current().nextInt(20);
redis.opsForValue().set(key, json, Duration.ofSeconds(baseTtl + jitter));
feedMineCache.put(key, resp);
hotKey.record(key);
}catch (Exception e){
log.error("序列化失败 key={}", key, e);
}
log.info("feed.mine source=db key={} page={} size={} user={} hasMore={}", key, safePage, safeSize, userId, hasMore);
return resp;
}
}
6 知文业务controller实现
package com.xiaoce.zhiguang.knowpost.controller;
import com.xiaoce.zhiguang.auth.service.impl.JwtServiceImpl;
import com.xiaoce.zhiguang.knowpost.domain.dto.KnowPostContentConfirmRequest;
import com.xiaoce.zhiguang.knowpost.domain.dto.KnowPostPatchRequest;
import com.xiaoce.zhiguang.knowpost.domain.dto.KnowPostTopPatchRequest;
import com.xiaoce.zhiguang.knowpost.domain.dto.KnowPostVisibilityPatchRequest;
import com.xiaoce.zhiguang.knowpost.domain.vo.FeedPageResponse;
import com.xiaoce.zhiguang.knowpost.domain.vo.KnowPostDetailResponse;
import com.xiaoce.zhiguang.knowpost.domain.vo.KnowPostDraftCreateResponse;
import com.xiaoce.zhiguang.knowpost.service.IKnowPostFeedService;
import com.xiaoce.zhiguang.knowpost.service.IKnowPostsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/**
* <p>
* 知识库文章表 前端控制器
* </p>
*
* @author 小策
* @since 2026-01-20
*/
@RestController
@RequestMapping("/api/v1/knowposts")
@Validated
@Tag(name = "知文模块", description = "知文模块相关接口")
@RequiredArgsConstructor
public class KnowPostsController {
private final IKnowPostsService knowPostsService;
private final IKnowPostFeedService feedService;
private final JwtServiceImpl jwtService;
/**
* 创建草稿,返回新 ID。默认类型为 image_text。
*/
@PostMapping("/drafts")
@Operation(description = "创建草稿,返回新 ID。默认类型为 image_text。")
public KnowPostDraftCreateResponse createDraft(@AuthenticationPrincipal Jwt jwt) {
long userId = jwtService.extractUserId(jwt);
long id = knowPostsService.createDraft(userId);
return new KnowPostDraftCreateResponse(String.valueOf(id));
}
/**
* 上传内容成功后回传确认,写入对象存储信息。
*/
@PostMapping("/{id}/content/confirm")
@Operation(description = "写入对象存储信息")
public ResponseEntity<Void> confirmContent(@PathVariable("id") long id,
@Valid @RequestBody KnowPostContentConfirmRequest request,
@AuthenticationPrincipal Jwt jwt) {
long userId = jwtService.extractUserId(jwt);
knowPostsService.confirmContent(userId, id, request.objectKey(), request.etag(), request.size(), request.sha256());
return ResponseEntity.noContent().build();
}
/**
* 更新元数据(标题、标签、可见性、置顶、图片列表等)。
*/
@PatchMapping("/{id}")
@Operation(description = "更新元数据(标题、标签、可见性、置顶、图片列表等)")
public ResponseEntity<Void> patchMetadata(@PathVariable("id") long id,
@Valid @RequestBody KnowPostPatchRequest request,
@AuthenticationPrincipal Jwt jwt) {
long userId = jwtService.extractUserId(jwt);
knowPostsService.updateMetadata(userId, id, request.title(), request.tagId(), request.tags(), request.imgUrls(), request.visible(), request.isTop(), request.description());
return ResponseEntity.noContent().build();
}
/**
* 发布帖子(状态置为 published)。
*/
@PostMapping("/{id}/publish")
@Operation(description = "发布帖子(状态置为 published)")
public ResponseEntity<Void> publish(@PathVariable("id") long id,
@AuthenticationPrincipal Jwt jwt) {
long userId = jwtService.extractUserId(jwt);
knowPostsService.publish(userId, id);
return ResponseEntity.noContent().build();
}
/**
* 设置置顶状态。
*/
@PatchMapping("/{id}/top")
@Operation(description = "设置置顶状态")
public ResponseEntity<Void> patchTop(@PathVariable("id") long id,
@Valid @RequestBody KnowPostTopPatchRequest request,
@AuthenticationPrincipal Jwt jwt) {
long userId = jwtService.extractUserId(jwt);
knowPostsService.updateTop(userId, id, request.isTop());
return ResponseEntity.noContent().build();
}
/**
* 设置可见性(权限)。
*/
@PatchMapping("/{id}/visibility")
@Operation(description = "设置可见性(权限)")
public ResponseEntity<Void> patchVisibility(@PathVariable("id") long id,
@Valid @RequestBody KnowPostVisibilityPatchRequest request,
@AuthenticationPrincipal Jwt jwt) {
long userId = jwtService.extractUserId(jwt);
knowPostsService.updateVisibility(userId, id, request.visible());
return ResponseEntity.noContent().build();
}
/**
* 删除知文(软删除)。
*/
@DeleteMapping("/{id}")
@Operation(description = "删除知文(软删除)")
public ResponseEntity<Void> delete(@PathVariable("id") long id,
@AuthenticationPrincipal Jwt jwt) {
long userId = jwtService.extractUserId(jwt);
knowPostsService.delete(userId, id);
return ResponseEntity.noContent().build();
}
/**
* 首页 Feed(公开、已发布)分页查询;默认每页 20,最大 50。
*/
@GetMapping("/feed")
@Operation(description = "首页 Feed(公开、已发布)分页查询;默认每页 20,最大 50。")
public FeedPageResponse feed(@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "20") int size,
@AuthenticationPrincipal Jwt jwt) {
Long userId = (jwt == null) ? null : jwtService.extractUserId(jwt);
return feedService.getPublicFeed(page, size, userId);
}
/**
* 我的知文(当前用户已发布)分页查询;默认每页 20,最大 50。
*/
@GetMapping("/mine")
@Operation(description = "我的知文(当前用户已发布)分页查询;默认每页 20,最大 50。")
public FeedPageResponse mine(@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "20") int size,
@AuthenticationPrincipal Jwt jwt) {
long userId = jwtService.extractUserId(jwt);
return feedService.getMyPublished(userId, page, size);
}
/**
* 知文详情(公开:published+public;非公开需作者本人)。
*/
@GetMapping("/detail/{id}")
@Operation(description = "知文详情(公开:published+public;非公开需作者本人)")
public KnowPostDetailResponse detail(@PathVariable("id") long id,
@AuthenticationPrincipal Jwt jwt) {
Long userId = (jwt == null) ? null : jwtService.extractUserId(jwt);
return knowPostsService.getDetail(id, userId);
}
}