1 用户关系模块实现
1.1 准备用户关系模块的相关配置和常量
1.1.1 Canal->kafka桥接器
那么这个时候就有人问了,桥接器是个什么玩意?初学kafka和canal,肯定和我一样不太明白。这里我就简单的说一下。首先说一下mysql,canal,kafka这几个之间的关系。
MySQL (数据库) :是**"工厂"**。它负责源源不断地生产数据(比如用户注册了、有人下单了)。
Canal Server :是**"安插在工厂里的间谍"**。它伪装成工厂的内部人员(Slave),工厂每生产一样东西,它的小本本(Binlog)上就记一笔。但是,它记的内容很原始,外人看不懂。
Kafka (消息队列) :是**"国际物流港口"**。所有要发给下游(消费者)的货物,都必须堆到这里,等待卡车来拉走。
CanalKafkaBridge (这段代码) :就是**"跑腿小哥"(或者叫搬运工**)
如果没有这个桥接器,情况是这样的:
Canal手里拿着一大堆加密的生产记录,站在工厂门口。 港口(Kafka)那边空荡荡的,等着发货。 两者中间隔着一条河,语言也不通,没人运货!
这个桥接器(Bridge)的作用就是把这两头接起来:
-
进货 :它跑到 Canal 那里,问:"刚才工厂生产了什么?把小本本给我看看。" (
connector.getWithoutAck) -
翻译 & 包装 :它发现 Canal 给的是二进制的原始数据,太难懂了。于是它把它翻译成大家都能看懂的 JSON 格式 ,并且只挑出你关心的内容(比如
payload字段)。(objectMapper转换) -
发货 :它把打包好的 JSON 包裹,扔到 Kafka 的集装箱(Topic)里。(
kafka.send) -
签字画押 :发完货后,它跑回去跟 Canal 说:"这批货我发走了,你可以把这一页撕掉了。" (
connector.ack)package com.xiaoce.zhiguang.relation.config;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry;
import com.alibaba.otter.canal.protocol.Message;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.xiaoce.zhiguang.common.constant.Kafka.KafkaTopic;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.SmartLifecycle;
import org.springframework.core.task.TaskExecutor;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;import java.net.InetSocketAddress;
/**
-
CanalKafkaBridge
-
-
这段代码的作用就是订阅outbox,当有修改的时候,将消息发送给kafka
-
@author 小策
-
@date 2026/2/5 12:17
*/
@Service
@Slf4j
public class CanalKafkaBridge implements SmartLifecycle {private final KafkaTemplate<String, String> kafka; // 用于发送 Kafka 消息
private CanalConnector connector; // Canal 的客户端连接对象
private volatile boolean running; // 控制主循环的开关
private final ObjectMapper objectMapper;
private final boolean enabled;
private final String host;
private final int port;
private final String destination;
private final String username;
private final String password;
private final String filter;
private final int batchSize;
private final long intervalMs;
private final TaskExecutor taskExecutor;/**
-
Canal 到 Kafka 的桥接器。
-
@param kafka Kafka 模板
-
@param objectMapper JSON 序列化器
-
@param enabled 是否启用
-
@param host Canal 主机
-
@param port Canal 端口
-
@param destination 实例名
-
@param username 用户名
-
@param password 密码
-
@param filter 订阅过滤表达式
-
@param batchSize 拉取批次大小
-
@param intervalMs 空轮询间隔毫秒
/
public CanalKafkaBridge(KafkaTemplate<String, String> kafka,
ObjectMapper objectMapper,
@Qualifier("taskExecutor") TaskExecutor taskExecutor,
@Value("{canal.enabled}") boolean enabled, @Value("{canal.host}") String host,
@Value("{canal.port}") int port, @Value("{canal.destination}") String destination,
@Value("{canal.username}") String username, @Value("{canal.password}") String password,
@Value("{canal.filter}") String filter, @Value("{canal.batchSize}") int batchSize,
@Value("${canal.intervalMs}") long intervalMs) {
this.kafka = kafka;
this.objectMapper = objectMapper;
this.taskExecutor = taskExecutor;
this.enabled = enabled;
this.host = host;
this.port = port;
this.destination = destination;
this.username = username;
this.password = password;
this.filter = filter;
this.batchSize = batchSize;
this.intervalMs = intervalMs;
}
/* -
启动桥接器:消费 Canal 并投递到 Kafka。
-
该方法通常在应用启动时调用(如实现 SmartLifecycle 或 CommandLineRunner)。
*/
@Override
public void start() {
// 1. 防止重复启动
if (running) {
log.info("Canal bridge start skipped: running={} enabled={} host={} port={} dest={} filter={}", running, enabled, host, port, destination, filter);
return;
}// 2. 标记状态为运行中
running = true;// 3. 异步执行主循环
// Canal 的消费是一个死循环(Long Polling),必须在独立线程中执行,
// 否则会阻塞主线程导致应用无法完全启动。
taskExecutor.execute(() -> {
try {
// ================= 连接初始化阶段 =================// 创建 Canal 连接器:指定 Canal Server 地址、Destination(实例名)、账号密码 connector = CanalConnectors.newSingleConnector(new InetSocketAddress(host, port), destination, username, password); log.info("Canal connecting to {}:{} dest={} user={} filter={}", host, port, destination, username, filter); // 建立 TCP 连接 connector.connect(); // 订阅 Filter:告诉 Canal Server 我们只关心哪些库表的变更 // 例如:filter 可能配置为 "mydb.outbox_table",这样 Server 端会过滤掉其他表的 Binlog connector.subscribe(filter); // 回滚位点:防止上次异常退出导致部分数据已处理但未 ACK, // 回滚到服务端记录的最后一次成功 ACK 的位置,保证"至少一次"消费语义。 connector.rollback(); log.info("Canal connected and subscribed: host={} port={} dest={} filter={} batchSize={} intervalMs={}ms", host, port, destination, filter, batchSize, intervalMs); // ================= 消息拉取主循环 ================= while (running) { // 1. 拉取数据(关键点:getWithoutAck) // 这里的机制是:拉取 batchSize 条数据,但在我们显式调用 ack() 之前, // Canal Server 不会认为这批数据已被消费。如果此时崩溃,下次连接会重发。 Message message = connector.getWithoutAck(batchSize); long batchId = message.getId(); // 2. 空批次处理(心跳或无数据) // batchId == -1 表示没有拉取到数据 // entries 为空表示虽然有包交互但没有实际 Binlog 变更 if (batchId == -1 || message.getEntries() == null || message.getEntries().isEmpty()) { try { // 短暂休眠,避免在无数据时进行高频空轮询消耗 CPU Thread.sleep(intervalMs); } catch (InterruptedException ignored) {} continue; } // 3. 遍历处理这批次中的每一条 Entry(一个事务可能包含多个 Entry) for (CanalEntry.Entry entry : message.getEntries()) { // 过滤掉非行级数据变更 // Canal 会推送 TransactionBegin, TransactionEnd, Heartbeat 等类型, // 我们只关心 ROWDATA (也就是 INSERT/UPDATE/DELETE 产生的数据) if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) { continue; } CanalEntry.RowChange rowChange; try { // 反序列化:Canal 使用 Protocol Buffer 传输数据 // 将二进制 storeValue 解析为 RowChange 对象 rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue()); } catch (Exception e) { // 解析失败通常是严重错误,这里为了健壮性选择了跳过 (生产环境建议记录 Error 日志) continue; } CanalEntry.EventType eventType = rowChange.getEventType(); // 4. 事件类型过滤 // 业务逻辑决定:只处理 INSERT 和 UPDATE。 // 通常在 Outbox 模式中,我们只关心新写入的消息,DELETE 操作通常不包含业务负载。 if (eventType != CanalEntry.EventType.INSERT && eventType != CanalEntry.EventType.UPDATE) { continue; } // 准备构建发送给 Kafka 的 JSON 数组 ArrayNode dataArray = objectMapper.createArrayNode(); // 遍历行数据集(一个 SQL 可能影响多行,例如 batch insert) for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) { ObjectNode rowNode = objectMapper.createObjectNode(); // getAfterColumnsList() 获取变更后的列数据 // getBeforeColumnsList() 获取变更前的列数据(Update 时有用,这里没用) for (CanalEntry.Column col : rowData.getAfterColumnsList()) { // 5. 提取核心业务字段 "payload" // 这里假设数据库表中有一个名为 `payload` 的字段存储了实际的业务 JSON 字符串。 // 这是典型的 Outbox 表结构设计。 if ("payload".equalsIgnoreCase(col.getName())) { rowNode.put("payload", col.getValue()); } // 如果需要其他字段(如 id, created_at),也可以在这里提取 } dataArray.add(rowNode); } // 6. 组装最终发往 Kafka 的消息体 ObjectNode msgNode = objectMapper.createObjectNode(); msgNode.put("table", entry.getHeader().getTableName()); // 来源表名 msgNode.put("type", eventType == CanalEntry.EventType.INSERT ? "INSERT" : "UPDATE"); // 操作类型 msgNode.set("data", dataArray); // 变更数据列表 try { // 7. 发送至 Kafka // 将组装好的 JSON 对象序列化并发送 String json = objectMapper.writeValueAsString(msgNode); kafka.send(KafkaTopic.CANAL_OUTBOX, json); } catch (Exception ignored) { // 注意:这里忽略异常意味着 Kafka 发送失败也会进行 ACK,会导致消息丢失! // 生产环境建议:如果发送失败,应该抛出异常跳出循环,或者重试, // 绝不能执行下面的 connector.ack(batchId)。 } } // 8. 提交确认 (ACK) // 只有当 Kafka 发送成功后(理想情况下),才向 Canal Server 确认该 batchId。 // 这样如果中途程序崩溃,下次启动会重新拉取该 batch,保证数据不丢。 connector.ack(batchId); } } catch (Exception e) { log.error("Canal bridge error", e); } finally { // ================= 资源清理 ================= if (connector != null) { try { // 循环结束(stop被调用)或异常退出时,断开连接 connector.disconnect(); log.info("Canal disconnected: dest={}", destination); } catch (Exception ex) { log.warn("Canal disconnect failed: dest={} err={}", destination, ex.getMessage()); } } }});
}
/**
- 停止桥接器。
- 将 running 置为 false,主线程循环会在下一次判断时退出,并进入 finally 块断开连接。
*/
@Override
public void stop() {
running = false;
}
/**
- 是否处于运行状态。
- @return 运行状态
*/
@Override
public boolean isRunning() {
return running;
}
}
-
-
1.1.2 kafka主题常量
package com.xiaoce.zhiguang.common.constant.Kafka;
/**
* Topic
* <p>
* kafka主题常量
*
* @author 小策
* @date 2026/1/22 14:41
*/
public class KafkaTopic {
private KafkaTopic(){}
public static final String EVENTS = "counter-events"; // 计数事件主题(点赞/收藏等增量)
public static final String CANAL_OUTBOX = "canal-outbox"; // canal binlog 主题
}
1.1.3 点赞状态枚举
package com.xiaoce.zhiguang.relation.Enum;
import lombok.Getter;
/**
* FollowingType
* <p>
* TODO: 请在此处简要描述类的功能
*
* @author 小策
* @date 2026/1/31 19:18
*/
@Getter
public enum FollowingStatus {
FOLLOWING_CANCEL(0, "取消关注"),
FOLLOWING_SUCCESS(1, "关注成功");
private final int code;
private final String desc;
FollowingStatus(int code , String desc){
this.code = code;
this.desc = desc;
}
}
1.1.4 关系事件类型枚举
package com.xiaoce.zhiguang.relation.Enum;
import lombok.Getter;
/**
* RelationEventType
* <p>
* TODO: 请在此处简要描述类的功能
*
* @author 小策
* @date 2026/2/5 15:19
*/
@Getter
public enum RelationEventType {
FollowCreated("FollowCreated"),
FollowCanceled("FollowCanceled");
private final String desc;
RelationEventType(String desc) {
this.desc = desc;
}
}
1.2 准备用户关系模块的事件配置
1.2.1 事件Record类
这个更上一个计数模块的**"发票"** 类似,这里就不在多说
package com.xiaoce.zhiguang.relation.event;
/**
* RelationEvent
* <p>
* 使用 Java Record 类型定义,适用于 Canal 监听 binlog 后解析出来的不可变数据载荷。
*主要用于描述用户之间的社交关系变动(如关注、取消关注、屏蔽等)。
*
* @param type 事件的具体动作类型。
* 例如:"FOLLOWER_ADDED" - 新增关注"FOLLOWER_REMOVED" - 取消关注"FRIEND_ADDED" - 互粉成好友
* @param fromUserId 动作的发起者用户 ID(例如:点击"关注"按钮的人)。
* @param toUserId 动作的接收者用户 ID(例如:被关注的人)。
* @param id 对应数据库业务表(如 user_follower 表)的主键 ID。
* 在异步处理或追溯日志时,用于关联底层持久化记录。
* @author 小策
* @date 2026/1/31 14:12
*/
public record RelationEvent(
String type,
Long fromUserId,
Long toUserId,
Long id
) {
}
1.2.2 定义用户关系模块消费者
这里主要是结合前面定义的桥接器去使用,当canal订阅的outbox发生改变的时候,会发送消息到kafka。具体流程图如下:
暂时无法在飞书文档外展示此内容
package com.xiaoce.zhiguang.relation.event;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xiaoce.zhiguang.common.constant.Kafka.KafkaTopic;
import com.xiaoce.zhiguang.common.utils.OutboxMessageUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* CanalOutboxConsumer 消费者
* <p>
* 场景说明:
* 我们不直接在业务代码里发 Kafka 消息(因为可能会丢失),而是先把消息存到数据库的 Outbox 表。
* Canal 监听数据库变动,把 Outbox 表的数据推送到 Kafka。
* 这个类就是用来消费 Kafka 里这些数据的。
* @author 小策
* @date 2026/2/5 16:10
*/
@Service
@RequiredArgsConstructor
public class CanalOutboxConsumer {
private final ObjectMapper objectMapper;
private final RelationEventProcessor processor;
@KafkaListener(topics = KafkaTopic.CANAL_OUTBOX, groupId = "relation-outbox-consumer")
public void onMessage(String message, Acknowledgment ack) {
try {
// 第一步:拆外包装。
// Canal 传过来的消息通常包了一层壳(比如包含 table, type, data 等字段),我们只需要里面的 data 行数据。
List<JsonNode> rows = OutboxMessageUtil.extractRows(objectMapper, message);
// 如果包裹是空的,直接签收(ACK),然后休息(return)
if (rows.isEmpty()) {
ack.acknowledge();
return;
}
for (JsonNode row : rows) {
JsonNode payloadNode = row.get("payload");
if (payloadNode == null) {
continue;
}
// 翻译。
// 把 payload 里的字符串变成我们可以操作的 RelationEvent Java 对象
// 比如:payload 是 "{"userId":1, "targetId":2, "type":"FOLLOW"}" -> 变成 Java 对象
RelationEvent evt = objectMapper.readValue(payloadNode.asText(), RelationEvent.class);
processor.process(evt);
}
//全部搞定,签字确认!
ack.acknowledge();
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
1.2.3 定义消费者里面的工作类
可能有人会问,这个方法为什么不直接定义在消费者里面?其实,把这段代码(业务逻辑)单独抽出来,遵循的是软件工程中最重要的原则之一:单一职责原则 (Single Responsibility Principle, SRP)。也就是俗话说的 "各司其职"。以下是给出的具体理由:
A. 隔离变化(解耦)
假设有一天,你的老板说:"Kafka 太贵了,我们换成 RabbitMQ 吧!"或者"我们不用消息队列了,改用 HTTP 调用吧!"
- 如果逻辑没抽出来:你需要把写数据库、改 Redis 的几百行代码从 Kafka 的代码里抠出来,再粘贴到 RabbitMQ 的代码里。容易拷错,风险极大。
- 如果抽出来了 :你只需要改
Consumer(服务员),换个监听器就行。Processor(厨师)完全不用动,因为炒菜的步骤(业务逻辑)是不会变的。
B. 代码可读性与维护
你的 process 方法里包含了 4 大块核心逻辑:
- 防重 (Deduplication): Redis 锁。
- 写库 (DB) :
followerMapper.insertOrUpdate。 - 写缓存 (Cache) :
redis.opsForZSet()。 - 写计数 (Counter) :
userCounterService.increment。
如果把这些代码全塞进 Consumer 里,加上 JSON 解析那一堆 try-catch,那个方法可能要 100 多行。代码越长,Bug 越喜欢藏在里面。抽出来后,Consumer 只有短短几行,清清爽爽。
C. 方便测试
如果你想测试"关注逻辑写得对不对":
-
抽出来后 :你可以直接写个单元测试
new RelationEventProcessor().process(evt),根本不需要启动 Kafka。 -
不抽出来:你必须得启动一个 Kafka,还得发一条真实消息,才能测试这段逻辑,太麻烦了。
package com.xiaoce.zhiguang.relation.event;
import com.xiaoce.zhiguang.counter.service.IUserCounterService;
import com.xiaoce.zhiguang.relation.Enum.FollowingStatus;
import com.xiaoce.zhiguang.relation.Enum.RelationEventType;
import com.xiaoce.zhiguang.relation.domain.po.Follower;
import com.xiaoce.zhiguang.relation.mapper.FollowerMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;import java.time.Duration;
/**
-
RelationEventProcessor
-
-
通过订阅outbox和kafka异步更新粉丝表,作为关注表的伪从
-
@author 小策
-
@date 2026/2/5 14:58
*/
@Service
@RequiredArgsConstructor
public class RelationEventProcessor {
private final StringRedisTemplate redis;
private final FollowerMapper followerMapper;
private final IUserCounterService userCounterService;public void process(RelationEvent evt) {
String dk = "dedup:rel:" + evt.type() + ":" + evt.fromUserId() + ":" + evt.toUserId() + ":" + (evt.id() == null ? "0" : String.valueOf(evt.id()));
Boolean first = redis.opsForValue().setIfAbsent(dk, "1", Duration.ofMinutes(10));
if(first == null || !first) return;
//如果是关注事件,为什么是更新粉丝表?当用户点击关注的时候,粉丝表是关注表的伪从,通过outbox和kafka异步更新
if (evt.type().equals(RelationEventType.FollowCreated.getDesc())) {
//更新粉丝表
Follower follower = Follower.builder()
.id(evt.id())
.fromUserId(evt.fromUserId())
.toUserId(evt.toUserId())
.relStatus(FollowingStatus.FOLLOWING_SUCCESS.getCode())
.build();
followerMapper.insertOrUpdate(follower);
//更新关注表与粉丝表缓存
long now = System.currentTimeMillis();
redis.opsForZSet().add("uf:flws:" + evt.fromUserId(), String.valueOf(evt.toUserId()), now);
redis.opsForZSet().add("uf:fans:" + evt.toUserId(), String.valueOf(evt.fromUserId()), now);
redis.expire("uf:flws:" + evt.fromUserId(), Duration.ofHours(2));
redis.expire("uf:fans:" + evt.toUserId(), Duration.ofHours(2));
//更新计数器
userCounterService.incrementFollowers(evt.toUserId(),1);
userCounterService.incrementFollowings(evt.fromUserId(),1);
}
else if (evt.type().equals(RelationEventType.FollowCanceled.getDesc())) {
//更新关注
Follower follower = Follower.builder()
.id(evt.id())
.fromUserId(evt.fromUserId())
.toUserId(evt.toUserId())
.relStatus(FollowingStatus.FOLLOWING_CANCEL.getCode())
.build();
followerMapper.insertOrUpdate(follower);// 更新关注表与粉丝表缓存:移除 ZSet 项并刷新 TTL redis.opsForZSet().remove("uf:flws:" + evt.fromUserId(), String.valueOf(evt.toUserId())); redis.opsForZSet().remove("uf:fans:" + evt.toUserId(), String.valueOf(evt.fromUserId())); redis.expire("uf:flws:" + evt.fromUserId(), Duration.ofHours(2)); redis.expire("uf:fans:" + evt.toUserId(), Duration.ofHours(2)); // 更新关注数与粉丝数 userCounterService.incrementFollowings(evt.fromUserId(), -1); userCounterService.incrementFollowers(evt.toUserId(), -1); }}
}
-
1.3 用户关系模块controller层实现
package com.xiaoce.zhiguang.relation.controller;
import com.xiaoce.zhiguang.auth.service.impl.JwtServiceImpl;
import com.xiaoce.zhiguang.relation.service.IRelationService;
import com.xiaoce.zhiguang.user.domain.vo.ProfileResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
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.*;
import java.util.List;
import java.util.Map;
/**
* RelationController
* <p>
*
* 关系接口控制器
* 职责:关注/取消关注、关系三态查询、关注/粉丝列表(偏移与游标)、用户维度计数读取与采样自检。
* 缓存:ZSet 存储关注/粉丝列表;用户计数采用 SDS 固定结构(5×4 字节,大端编码),提供采样一致性校验与按需重建。
*
*
* @author 小策
* @date 2026/2/4 19:47
*/
@RestController
@RequestMapping("/api/vi/relation")
@RequiredArgsConstructor
@Validated
@Tag(name = "用户关系模块")
public class RelationController {
private final IRelationService relationService;
private final JwtServiceImpl jwtService;
/**
* 处理用户关注请求的接口方法
* @param toUserId 被关注用户的ID
* @param jwt 包含当前用户认证信息的JWT令牌
* @return 关注操作是否成功
*/
@PostMapping("/follow")
@Operation(summary = "关注用户", description = "关注指定用户",
responses = {
@ApiResponse(responseCode = "200", description = "关注成功"),
@ApiResponse(responseCode = "401", description = "用户未登录或 Token 过期"),
@ApiResponse(responseCode = "400", description = "请求参数错误")
})
public boolean follow(@RequestParam("toUserId") long toUserId, @AuthenticationPrincipal Jwt jwt) {
long uid = jwtService.extractUserId(jwt); // 从JWT令牌中提取当前用户的ID
return relationService.follow(uid, toUserId); // 调用关注服务执行关注操作
}
/**
* 取消关注用户的接口方法
* 该方法处理HTTP POST请求,用于取消当前用户对指定用户的关注关系
*
* @param toUserId 要被取消关注的用户ID
* @param jwt 包含当前用户认证信息的JWT令牌
* @return 操作结果,true表示取消关注成功,false表示失败
*/
@PostMapping("/unfollow")
@Operation(summary = "取消关注用户", description = "取消关注指定用户",
responses = {
@ApiResponse(responseCode = "200", description = "取消关注成功"),
@ApiResponse(responseCode = "401", description = "用户未登录或 Token 过期"),
@ApiResponse(responseCode = "400", description = "请求参数错误")})
public boolean unfollow(@RequestParam("toUserId") long toUserId, @AuthenticationPrincipal Jwt jwt) {
// 从JWT令牌中提取当前用户的ID
long uid = jwtService.extractUserId(jwt);
// 调用关系服务层的方法执行取消关注操作
return relationService.unfollow(uid, toUserId);
}
/**
* 获取当前用户与指定用户之间的关系状态
*
* @param toUserId 目标用户的ID
* @param jwt 包含当前用户信息的JWT认证主体
* @return 包含关系状态的Map,键为目标用户ID,值为是否关注/被关注等关系状态
*/
@GetMapping("/status")
@Operation(summary = "获取用户关系状态", description = "获取当前用户与指定用户之间的关系状态",
responses = {
@ApiResponse(responseCode = "200", description = "获取关系状态成功"),
@ApiResponse(responseCode = "401", description = "用户未登录或 Token 过期"),
@ApiResponse(responseCode = "400", description = "请求参数错误")
})
public Map<String, Boolean> status(@RequestParam("toUserId") long toUserId, @AuthenticationPrincipal Jwt jwt) {
long uid = jwtService.extractUserId(jwt);
return relationService.relationStatus(uid, toUserId);
}
/**
* 获取用户关注列表的接口方法
* 通过GET请求访问,需要提供用户ID参数,并支持分页查询
*
* @param userId 用户ID,必填参数,用于指定要查询的用户
* @param limit 每页返回的关注用户数量,默认值为20,最小值为1,最大值为100
* @param offset 分页偏移量,默认值为0,用于指定从第几个记录开始返回
* @param cursor 分页游标,可选参数,用于实现基于游标的分页
* @return 返回ProfileResponse类型的用户关注列表
*/
@GetMapping("/following")
@Operation(summary = "获取关注列表", description = "获取当前用户关注的用户列表",
responses = {
@ApiResponse(responseCode = "200", description = "获取关注列表成功"),
@ApiResponse(responseCode = "401", description = "用户未登录或 Token 过期"),
@ApiResponse(responseCode = "400", description = "请求参数错误")
})
public List<ProfileResponse> following(@RequestParam("userId") long userId,
@RequestParam(value = "limit", defaultValue = "20") int limit,
@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "cursor", required = false) Long cursor){
// 对limit参数进行校验,确保其在1-100的范围内
int need = Math.min(Math.max(1, limit), 100);
// 调用relationService的followingProfiles方法获取关注列表
return relationService.followingProfiles(userId, need, Math.max(offset, 0), cursor);
}
/**
* 获取粉丝列表的接口方法
* 使用 GET 方式访问,需要传入用户ID及相关参数
*
* @param userId 用户ID,必填参数
* @param limit 返回记录数量,默认20,最大100,最小1
* @param offset 偏移量,默认0,不能为负数
* @param cursor 分页游标,可选参数,用于分页查询
* @return 返回粉丝列表,类型为ProfileResponse的List集合
*/
@GetMapping("/followers")
@Operation(summary = "获取粉丝列表", description = "获取当前用户的粉丝列表",
responses = {
@ApiResponse(responseCode = "200", description = "获取粉丝列表成功"),
@ApiResponse(responseCode = "401", description = "用户未登录或 Token 过期"),
@ApiResponse(responseCode = "400", description = "请求参数错误")
})
public List<ProfileResponse> followers(@RequestParam("userId") long userId,
@RequestParam(value = "limit", defaultValue = "20") int limit,
@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "cursor", required = false) Long cursor) {
// 对limit参数进行校验,确保在1-100之间
int need = Math.min(Math.max(limit, 1), 100);
// 调用服务层方法获取粉丝列表,确保offset不小于0
return relationService.followersProfiles(userId, need, Math.max(offset, 0), cursor);
}
/**
* 获取指定用户的计数信息接口
* @param userId 用户ID,作为查询参数
* @return 返回包含计数信息的Map,键为String类型,值为Long类型
*/
@GetMapping("/counter")
@Operation(summary = "获取用户计数信息", description = "获取指定用户的计数信息",
responses = {
@ApiResponse(responseCode = "200", description = "获取计数信息成功"),
@ApiResponse(responseCode = "401", description = "用户未登录或 Token 过期"),
@ApiResponse(responseCode = "400", description = "请求参数错误")
})
public Map<String, Long> counter(@RequestParam("userId") long userId) {
// 调用relationService的counter方法获取指定用户的计数信息
return relationService.counter(userId);
}
}
1.4 用户关系模块service层接口实现
package com.xiaoce.zhiguang.relation.service;
import com.xiaoce.zhiguang.user.domain.vo.ProfileResponse;
import java.util.List;
import java.util.Map;
/**
* IRelationService
* <p>
* 该接口负责维护用户间的社交拓扑关系。为了应对高并发和大数据量,
* 提供了基于传统 Offset 的分页和基于时间戳 Cursor 的高性能分页。
*
* @author 小策
* @date 2026/1/31 14:28
*/
public interface IRelationService {
/**
* 【核心写入】关注操作
* <p>实现逻辑建议:在事务内插入关系记录并同时写入 Outbox 表,以便 Canal 同步</p>
* @param fromUserId 发起关注的用户ID (A)
* @param toUserId 被关注的用户ID (B)
* @return true: 关注成功; false: 已关注或操作失败
*/
boolean follow(long fromUserId, long toUserId);
/**
* 【核心写入】取消关注
* @param fromUserId 发起取消的用户ID
* @param toUserId 被取消的用户ID
* @return true: 取消成功; false: 未关注或操作失败
*/
boolean unfollow(long fromUserId, long toUserId);
/**
* 【状态查询】单向关注判断
* @return true: A 关注了 B
*/
boolean isFollowing(long fromUserId, long toUserId);
/**
* 【列表查询】关注列表 (偏移量分页)
* <p>适用于数据量较小或后台管理系统</p>
* @param offset 跳过的记录数
* @return 关注的用户 ID 列表
*/
List<Long> following(long userId, int limit, int offset);
/**
* 【列表查询】粉丝列表 (偏移量分页)
*/
List<Long> followers(long userId, int limit, int offset);
/**
* 【状态查询】关系三态分析
* 返回 Map 包含:
* 1. following: 我是否关注了他
* 2. followedBy: 他是否关注了我
* 3. mutual: 是否互粉
* </p>
*/
Map<String, Boolean> relationStatus(long userId, long otherUserId);
/**
* 【高性能查询】关注列表 (游标分页)
* <p>采用时间戳作为游标,避免深分页性能问题,适用于移动端无限下拉
* @param cursor 上一页最后一条记录的时间戳(毫秒)
*/
List<Long> followingCursor(long userId, int limit, Long cursor);
/**
* 【高性能查询】粉丝列表 (游标分页)
*/
List<Long> followersCursor(long userId, int limit, Long cursor);
/**
* 【视图聚合】关注列表资料版
* 逻辑:先查 ID 列表,再批量调用 Profile 接口填充头像、昵称等资料
*/
List<ProfileResponse> followingProfiles(long userId, int limit, int offset, Long cursor);
/**
* 【视图聚合】粉丝列表资料版
*/
List<ProfileResponse> followersProfiles(long userId, int limit, int offset, Long cursor);
Map<String, Long> counter(long userId);
}
1.5 用户关系模块service层实现类实现
1.5.1 关注方法的流程图
暂时无法在飞书文档外展示此内容
1.5.2 获取关注列表id的流程图
暂时无法在飞书文档外展示此内容
1.5.3 用户关系模块service层实现类的具体实现
对于关注/取消关注是否加令牌桶进行限流,解释是这样的:
在 follow(关注)方法上加令牌桶限流,而在 unfollow(取关)上不加,主要基于以下 4 个核心原因:
防止"刷粉"和恶意营销(核心原因)
这是最直接的业务原因。
- 关注 (Follow):
-
- 攻击模式 :黑产账号或营销号通常会通过脚本疯狂批量关注正常用户,以此诱导对方回粉(互粉),或者单纯为了在对方的消息列表中刷存在感("某某关注了你")。
- 后果:如果不加限流,一个脚本一分钟关注 1000 人,会给大量用户造成骚扰,破坏社区体验。
- 取关 (Unfollow):
-
- 业务性质:取关通常没有任何"营销收益"。黑产不会通过疯狂取关来获得流量。
- 结论:限制"关注"是为了打击垃圾账号,而限制"取关"没有太大的反垃圾价值。
下游系统的压力差异(通知风暴)
虽然在你的代码中,follow 和 unfollow 都会写入 Outbox 发送消息,但在实际的下游消费链路中,它们的处理成本不同:
- 关注 (Follow):
-
- 触发通知 :关注通常会触发**"消息推送"(Push Notification)或站内信("UserA 关注了您")。这是一连串昂贵的操作(查库、调第三方推送接口)。如果允许瞬间高频关注,会引发下游的通知风暴**。
- 取关 (Unfollow):
-
- 静默操作 :绝大多数社交软件的取关都是静默的(不会发通知告诉对方"UserA 取关了您")。因此,取关操作对下游通知服务的压力几乎为零。
用户体验 (UX) 的容错性
- 清理需求:正常用户确实存在"批量取关"的场景。例如,用户想清理时间线,可能会在一分钟内连续取关几十个不再感兴趣的博主。如果此时系统提示"操作太快,请稍后再试",用户体验会非常差,感觉系统在故意阻拦他/她。
- 误操作修正:如果用户手抖误关注了别人,他希望立刻、马上取关。如果在关注后被限流导致无法取关,会引发用户焦虑。
数据库存储增长风险
- 关注 (INSERT) :代码中
followingMapper.insert是新增数据 。不限流的话,恶意攻击可以在短时间内让数据库的Following和Follower表体积爆炸式增长,消耗存储资源。 - 取关 (UPDATE) :代码中
followingMapper.update只是修改状态(逻辑删除)。虽然也会产生 Binlog,但它不会导致表记录数无限膨胀(甚至如果是物理删除,还能释放空间)。
对于有一个根据偏移量来获取粉丝/关注列表,为什么还要设计一个根据时间戳来获取粉丝/关注列表的解释如下:
为什么需要 Offset (基于偏移量)?
场景:PC 端网页、管理后台
想象你在 PC 浏览器上看 B 站或 GitHub 的粉丝列表,底部通常有一排按钮:[1] [2] [3] ... [Next]。
- 交互习惯:用户习惯点"第 3 页"去查看特定的数据。
- 代码对应 :
getListWithOffset\\rightarrow RedisZREVRANGE key 0 9(第1页),10 19(第2页)。 - 优点:可以随机跳转(比如直接跳到第 10 页)。
- 缺点(致命) :数据漂移(Data Shifting)。
❌ Offset 的致命缺陷:数据"乱跳"
假设你正在看第 1 页(展示第 1-10 个粉丝)。
就在你看的时候,突然有一个新粉丝关注了你。
- 列表头插入了一个新数据,原来的第 10 个粉丝被挤到了第 11 个位置(也就是第 2 页的第 1 个)。
- 当你点击"下一页"时,你请求的是
OFFSET 10。 - 结果 :你会再次看到刚才在第 1 页看到的那个被挤下来的粉丝。
- 用户感受:"哎?这个人我刚才不是看过了吗?怎么又出来了?"
为什么需要 Cursor (基于时间戳/游标)?
场景:手机 App、抖音、朋友圈
你在刷抖音或朋友圈时,从来没见过"第几页"的按钮吧?你只是一直往下滑。
- 交互习惯:无限滚动(Infinite Scroll)。
- 逻辑核心:前端告诉后端"我手里最后一条数据的时间是 12:00,请给我 12:00 之前的数据"。
- 代码对应 :
getListWithCursor\\rightarrow RedisZREVRANGEBYSCORE key -inf 12:00 LIMIT ...。 - 优点(核心) :数据绝对稳定(Stable)。
✅ Cursor 如何解决问题?
不管在你阅读期间,有没有新粉丝进来,或者有没有人取关:
-
你手里的最后一条数据 ID 是固定的(锚点)。
-
你只要请求"这个 ID 之后的数据",得到的结果永远是无缝衔接的下一批数据。
-
结果:永远不会出现重复数据,也不会漏掉数据。
package com.xiaoce.zhiguang.relation.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.xiaoce.zhiguang.common.utils.CollectionUtil;
import com.xiaoce.zhiguang.counter.schema.UserCounterSchema;
import com.xiaoce.zhiguang.counter.service.IUserCounterService;
import com.xiaoce.zhiguang.relation.Enum.FollowingStatus;
import com.xiaoce.zhiguang.relation.domain.po.Follower;
import com.xiaoce.zhiguang.relation.domain.po.Following;
import com.xiaoce.zhiguang.relation.domain.po.Outbox;
import com.xiaoce.zhiguang.relation.event.RelationEvent;
import com.xiaoce.zhiguang.relation.mapper.FollowerMapper;
import com.xiaoce.zhiguang.relation.mapper.FollowingMapper;
import com.xiaoce.zhiguang.relation.mapper.OutboxMapper;
import com.xiaoce.zhiguang.relation.service.IRelationService;
import com.xiaoce.zhiguang.user.domain.po.Users;
import com.xiaoce.zhiguang.user.domain.vo.ProfileResponse;
import com.xiaoce.zhiguang.user.mapper.UsersMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;import java.nio.charset.StandardCharsets;
import java.sql.Timestamp;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.IntFunction;
import java.util.stream.Collectors;/**
-
RelationServiceImpl
-
-
TODO: 请在此处简要描述类的功能
-
@author 小策
-
@date 2026/1/31 14:44
*/
@Service
@Slf4j
public class RelationServiceImpl implements IRelationService {
private final FollowerMapper followerMapper;
private final FollowingMapper followingMapper;
private final OutboxMapper outboxMapper;
private final StringRedisTemplate redis;
private final DefaultRedisScript<Long> tokenScript;
private final ObjectMapper objectMapper;
private final Cache<Long,List<Long>> flwsTopCache;
private final Cache<Long,List<Long>> fansTopCache;
private final UsersMapper usersMapper;
private final IUserCounterService userCounterService;private static final int IDX_FOLLOWER = 2; // (2 - 1) * 4, 下标从 4 开始
private static final int IDX_FOLLOWING = 1; // 下标从 0 开始
public RelationServiceImpl(FollowerMapper followerMapper, FollowingMapper followingMapper, OutboxMapper outboxMapper, StringRedisTemplate redis,
ObjectMapper objectMapper, UsersMapper usersMapper,IUserCounterService userCounterService) {
this.followerMapper = followerMapper;
this.followingMapper = followingMapper;
this.outboxMapper = outboxMapper;
this.redis = redis;
this.tokenScript = new DefaultRedisScript<>();
this.userCounterService = userCounterService;
tokenScript.setResultType(Long.class);
tokenScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/token_bucket.lua")));
this.objectMapper = objectMapper;
this.usersMapper = usersMapper;
// 初始化 Caffeine 本地缓存
// maximumSize(1000): 最多缓存 1000 个大 V 用户的列表,防止内存溢出
// expireAfterWrite(10 min): 写入 10 分钟后过期,保证数据不会太旧
this.flwsTopCache =
Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(Duration.ofMinutes(10)).build();
this.fansTopCache =
Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(Duration.ofMinutes(10)).build();
}
/**
- 根据偏移量和限制获取列表,支持本地缓存和Redis缓存
- @param key Redis中存储的键名
- @param offset 起始偏移量
- @param limit 每页限制数量
- @param rowsFetcher 用于从数据库获取数据的函数式接口
- @param idField 用户ID字段名
- @param tsField 时间戳字段名
- @param localCache 本地缓存(通常是Caffeine)
- @param userId 用户ID
- @return 返回符合条件的长整型列表
*/
private List<Long> getListWithOffset(String key, int offset,
int limit, IntFunction<Map<Long, Map<String, Object>>> rowsFetcher,
String idField, String tsField,
Cache<Long, List<Long>> localCache, long userId) {
// ZSet 的 reverseRange 是按分数从大到小(时间倒序,时间越大,离当前时间越近)取值。
// 比如 offset=0, limit=10,就是取第 0 到第 9 个。
Set<String> cached = redis.opsForZSet().reverseRange(key, offset, offset + limit - 1L);
if (cached != null && !cached.isEmpty()) {
return toLongList(cached);
}
// localCache (通常是 Caffeine) 存的是极其热门的数据(比如大V的前1000个粉丝)。
// 这样做是为了防止某个明星发微博时,Redis 被刷爆。
List<Long> top = localCache != null ? localCache.getIfPresent(userId) : null;
// 检查top是否不为null且不为空集合
if (top != null && !top.isEmpty()) {
// 计算起始索引,确保不超出集合范围
int from = Math.min(offset, top.size());
// 计算结束索引,确保不超出集合范围,同时考虑limit限制
int to = Math.min(offset + limit - 1, top.size());
// 返回从from到to的子列表的新ArrayList实例
return new ArrayList<>(top.subList(from, to));
}
//缓存全挂了,只能查数据库 (回源)
// 计算需要查多少条数据。
// 为什么是 limit + offset?
// 假设你要查第 2 页(offset=10, limit=10),为了保证 ZSet 的顺序连续性,
// 这里采取了暴力策略:直接把前 20 条全查出来重修建立缓存。
int need = Math.max(1, limit + offset);
// 【这里调用了你传入的 Lambda】
// 真正去执行 SQL,限制最多查 1000 条防止数据库炸裂。
// 返回值是一个 Map,Key是ID,Value是包含字段数据的 Map。
Map<Long, Map<String, Object>> rows = rowsFetcher.apply(Math.min(need, 1000));
if (rows != null && !rows.isEmpty()) {
// 3.1 填充 Redis
// 这个函数会遍历 rows,利用反射或 Map.get(idField) 取出 ID 和 时间,
// 然后把你查到的数据塞回 Redis ZSet 中。
fillZSet(key, rows, idField, tsField, null);
// 给 Redis Key 设置 2 小时过期时间
redis.expire(key, Duration.ofHours(2));
// 3.2 判断是否需要更新本地缓存 (大V逻辑)
// 这里的 switch 是在判断当前查的是粉丝还是关注。
// IDX_FOLLOWER 可能是指判断粉丝数的位置。
int idx = switch (idField) {
case "fromUserId" -> IDX_FOLLOWER;
// 如果 ID 是 fromUserId,说明查的是粉丝列表
case "toUserId" -> IDX_FOLLOWING; // 如果 ID 是 toUserId,说明查的是关注列表
default -> 2;
};
// isBigV: 判断这个 userId 在 idx 这个维度(比如粉丝数,关注数)是不是特别多?
// 如果是大V,把这些数据也塞一份到 localCache (JVM内存) 里。
if (localCache != null && isBigV(userId, idx)) {
maybeUpdateTopCache(userId, key, localCache);
}
// 为什么不直接返回 rows?
// 因为 ZSet 会帮我们自动排好序(按时间),直接从 Redis 取能保证顺序绝对正确。
Set<String> filled = redis.opsForZSet().reverseRange(key, offset, offset + limit - 1L);
return filled == null ? Collections.emptyList() : toLongList(filled);
}
return Collections.emptyList();
}
/**
-
将字符串类型的Set集合转换为Long类型的List集合
-
@param set 包含字符串的Set集合
-
@return 转换后的Long类型List集合,如果输入集合为空则返回空List
/
private List<Long> toLongList(Set<String> set) {
// 检查输入集合是否为空
if (set.isEmpty()) {
// 如果为空,返回一个空List
return List.of();
}
// 使用Stream API将字符串集合转换为Long集合
return set.stream().map(Long::parseLong).toList();
}
private void fillZSet(String key,
Map<Long, Map<String, Object>> rows,
String idField,
String tsField,
Long cursor) {
Collection<Map<String, Object>> values = rows.values();
for (Map<String, Object> value : values) {
Object idObj = value.get(idField);
Object tsObj = value.get(tsField);
if (idObj == null || tsObj == null) continue;
long score = tsScore(tsObj);
//场景:数据回填 你的系统发现 Redis 缺数据了,要去数据库捞第 2 页的数据(应该是 11:50 以前的)。
// 但是!就在这一秒,有人发了一条新朋友圈,时间是 11:55。
//如果没有 Cursor 判断: 数据库查的时候可能因为事务隔离级别或者其他原因,
// 把这条 11:55 的新数据也带出来了。 如果不加过滤直接塞进 Redis,Redis 里就会突然多出来一条 11:55 的数据。
// 当你还在刷 11:50 以前的老数据时,突然跳出来一条 11:55 的,这就叫*"时间线乱序"**。
//有了 Cursor 判断 (score <= 11:50): 就算数据库把 11:55 的那条查出来了,
// 代码走到这一行: 11:55 <= 11:50 ? False! 直接丢弃。 这就保证了你这次往 Redis 里填的数据,
// 严格遵守了"11:50 以前"这个界限。
if (cursor == null || score <= cursor) {
redis.opsForZSet().add(key, String.valueOf(idObj), score);
}
}
}/**
- 根据输入的对象类型获取时间戳分数
- @param tsObj 输入的时间对象,可以是Timestamp或Date类型
- @return 返回当前时间的时间戳毫秒数,如果输入是Timestamp或Date则返回其对应的时间戳
/
private long tsScore(Object tsObj) { // 方法:计算时间戳分数
if (tsObj instanceof Timestamp ts) { // 判断是否为Timestamp类型,如果是则获取其时间戳
return ts.getTime();
}
if (tsObj instanceof Date d) { // 判断是否为Date类型,如果是则获取其时间戳
return d.getTime();
}
return System.currentTimeMillis(); // 如果既不是Timestamp也不是Date,则返回当前系统时间的时间戳
}
/* - 判断是否为大V(基于 followers 计数阈值)。
- @param userId 用户ID
- @return 是否为大V
/
private boolean isBigV(long userId, int idx) {
byte[] row = redis.execute((RedisCallback<byte[]>) connection ->
connection.stringCommands().get((("ucnt:" + userId).getBytes(StandardCharsets.UTF_8))));
// 如果 Redis 查不到,说明是第一次关注,直接返回 false
if (row == null || row.length < 20) return false;
// 如果查到了,说明不是第一次关注,解析出粉丝数和关注数
// 初始化一个长整型变量n,值为0
long n = 0L;
// 计算偏移量,idx减1后乘以4,用于确定在row数组中的起始位置
int offset = (idx -1) * 4;
// 循环4次,每次处理一个字节
for (int i = 0; i < 4; i++) {
// 将n左移8位,然后与当前字节进行按位或操作
// (row[offset + i] & 0xff)确保只取低8位,防止符号扩展
n = (n << 8) | (row[offset + i] & 0xff);
}
// 判断n是否大于等于500,000,并返回结果
return n >= 500_000L;
}
/* - 可能更新顶部缓存的方法
- @param userId 用户ID,作为缓存的键
- @param key Redis中的键,用于获取ZSet数据
- @param cache 本地缓存对象,用于存储获取到的数据
/
private void maybeUpdateTopCache(long userId, String key, Cache<Long, List<Long>> cache) {
// 从Redis中获取ZSet的排名前500的数据(从高到低)
Set<String> allSet = redis.opsForZSet().reverseRange(key, 0, 499);
// 如果获取到的集合为空或null,则直接返回
if (allSet == null || allSet.isEmpty()) return;
// 创建一个与allSet大小相同的ArrayList,用于存储转换后的Long类型数据
List<Long> all = new ArrayList<>(allSet.size());
// 遍历allSet,将String类型的元素转换为Long类型并添加到all列表中
for (String s : allSet) all.add(Long.valueOf(s));
// 将转换后的列表存入缓存,以userId作为键
cache.put(userId, all);
}
/*
-
根据游标获取列表数据
-
该方法实现了二级缓存查询机制,首先查询Redis缓存,若未命中则查询数据库并更新缓存
-
@param key Redis中ZSet的键名
-
@param limit 需要获取的数据条数限制
-
@param cursor 游标值,用于分页获取数据,为null时表示获取最新数据
-
@param rowsFetcher 数据库查询函数,用于从数据库获取原始数据
-
@param idField 数据对象中ID字段名
-
@param tsField 数据对象中时间戳字段名
-
@return 返回Long类型的列表,包含查询到的ID值
/
private List<Long> getListWithCursor(String key,
int limit,
Long cursor,
IntFunction<Map<Long, Map<String, Object>>> rowsFetcher,
String idField,
String tsField) {
//ZSet 的 Score 本质是 double。当我们想获取"排行榜最顶端"或者"时间轴最顶端"的数据时,
// 我们不知道最新的时间戳具体是几点几分,所以直接用"正无穷"来囊括所有最新的数据。
double max = cursor == null ? Double.POSITIVE_INFINITY : cursor.doubleValue();
// 尝试查询 Redis 缓存 (Level 1)
// reverseRangeByScore: 按分数从大到小找。范围是 (-∞, max]。
// offset 传 0,count 传 limit。
Set<String> cached = redis.opsForZSet().reverseRangeByScore(key, Double.NEGATIVE_INFINITY, max, 0, limit);
if (cached != null && !cached.isEmpty()) {
return toLongList(cached);
}
// 缓存没有命中,查询数据库 (Level 2)
int need = Math.max(limit,100);
Map<Long, Map<String, Object>> raws = rowsFetcher.apply(need);
if (raws != null || !raws.isEmpty()) {
//写入缓存
fillZSet(key, raws, idField, tsField , cursor);
redis.expire(key, Duration.ofHours(2));
Set<String> filled = redis.opsForZSet().reverseRangeByScore(key, Double.NEGATIVE_INFINITY, max, 0, limit);
return filled == null ? Collections.emptyList() : toLongList(filled);
}
return Collections.emptyList();
}
/*-
将用户ID列表转换为用户档案响应对象列表
-
@param ids 用户ID列表
-
@return 用户档案响应对象列表,如果输入为空则返回空列表
/
private List<ProfileResponse> toProfiles(List<Long> ids) {
// 如果输入的ID列表为空,直接返回空列表
if (CollectionUtil.isEmpty(ids)) return Collections.emptyList();
// 根据ID列表查询用户信息
List<Users> users = usersMapper.selectList(new QueryWrapper<Users>().lambda().in(Users::getId, ids));
// 将用户列表转换为以用户ID为键的Map,过滤掉null值
Map<Long, Users> map = users.stream().filter(Objects::isNull).collect(Collectors.toMap(Users::getId, u -> u));
// 创建结果列表,初始化大小与输入ID列表相同
List<ProfileResponse> profiles = new ArrayList<>(ids.size());
// 遍历ID列表,为每个ID构建档案响应对象
for (Long id : ids) {
// 从Map中获取用户对象
Users u = map.get(id);
// 如果用户不存在则跳过
if (u == null) continue;
// 将用户实体转换为档案响应对象并添加到结果列表
profiles.add(ProfileResponse.fromEntity(u));
}
return profiles;
}
/* -
关注操作,限流通过令牌桶,并写入 Outbox 以异步构建缓存与粉丝表。
-
@param fromUserId 发起关注的用户ID(谁点的关注)
-
@param toUserId 被关注的用户ID(关注了谁)
-
@return 是否关注成功
*/
@Override
@Transactional
public boolean follow(long fromUserId, long toUserId) {
// ========== 第一步:令牌桶限流(防止恶意刷关注) ==========
// "100" 是ARGV[1],令牌桶容量(最多100个令牌)
// "1" 是ARGV[2],每秒恢复1个令牌
Long ok = redis.execute(tokenScript, List.of("r1:follow:" + fromUserId), "100", "1");
if (ok == 0L) {
return false; //关注失败
}
// ThreadLocalRandom.current() 获取当前线程的随机数生成器
// .nextLong(Long.MAX_VALUE) 生成0到Long.MAX_VALUE之间的随机数
long id = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
Following following = Following.builder()
.id(id)
.fromUserId(fromUserId)
.toUserId(toUserId)
.relStatus(FollowingStatus.FOLLOWING_SUCCESS.getCode())
.build();
int success = followingMapper.insert(following);
if (success > 0) {
try {
// 生成事件ID(也是随机的)
long outId = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
// 创建事件对象:"FollowCreated"表示关注创建事件
RelationEvent event = new RelationEvent("FollowCreated", fromUserId, toUserId, id);
// 把事件对象转成JSON字符串(像把东西打包成快递)
String payload = objectMapper.writeValueAsString(event);
Outbox outbox = Outbox.builder()
.id(outId) //事件id
.aggregateId(id) //业务id
.aggregateType("FollowCreated") //业务类型
.payload(payload) //载荷
.type("following") //事件类型
.build();
outboxMapper.insert(outbox);
} catch (JsonProcessingException ignore) {} return true;}
return false;
}
/**
-
取消关注操作,并写入 Outbox 事件。
-
@param fromUserId 发起取消关注的用户ID
-
@param toUserId 被取消关注的用户ID
-
@return 是否取消成功
*/
@Override
@Transactional
public boolean unfollow(long fromUserId, long toUserId) {
//更新关注表,将状态改为0
// 创建一个Following对象,使用建造者模式设置关系状态为"取消关注"
Following following = Following.builder()
.relStatus(FollowingStatus.FOLLOWING_CANCEL.getCode()) // 设置关系状态码为"取消关注"
.build();
// 创建一个LambdaUpdateWrapper对象,用于构建数据库更新条件
LambdaUpdateWrapper<Following> wrapper = new LambdaUpdateWrapper<>();
// 设置更新条件:fromUserId字段等于传入的fromUserId值
wrapper.eq(Following::getFromUserId,fromUserId)
// 添加条件:toUserId字段等于传入的toUserId值
.eq(Following::getToUserId,toUserId)
// 设置更新操作:将relStatus字段更新为following对象中的relStatus值
.set(Following::getRelStatus,following.getRelStatus());
// 执行更新操作,并返回受影响的行数
int success = followingMapper.update(following,wrapper);
if (success > 0) {
try {
Long outId = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
RelationEvent event = new RelationEvent("FollowCanceled", fromUserId, toUserId, null);
// 把事件对象转成JSON字符串(像把东西打包成快递)
String payload = objectMapper.writeValueAsString(event);
Outbox outbox = Outbox.builder()
.id(outId) //事件id
.aggregateId(null) //业务id
.aggregateType("FollowCanceled") //业务类型
.payload(payload) //载荷
.type("following") //事件类型
.build();
outboxMapper.insert(outbox);
} catch (JsonProcessingException ignore) {} return true;}
return false;
}
@Override
/**- 判断用户是否关注了另一个用户
- @param fromUserId 发起关注请求的用户ID
- @param toUserId 被关注的用户ID
- @return 如果关注关系存在则返回true,否则返回false
/
public boolean isFollowing(long fromUserId, long toUserId) {
// 创建Lambda查询包装器
LambdaQueryWrapper<Following> wrapper = new LambdaQueryWrapper<>();
// 设置查询条件:fromUserId等于传入的fromUserId且toUserId等于传入的toUserId
wrapper.eq(Following::getFromUserId,fromUserId)
.eq(Following::getToUserId,toUserId);
// 查询数据库中符合条件的关注记录数量,如果大于0则表示存在关注关系
return followingMapper.selectCount(wrapper) > 0;
}
/* - 获取关注列表(偏移分页),优先读取 Redis ZSet,未命中时回填并设置 TTL。
- @param userId 用户ID
- @param limit 返回数量上限
- @param offset 偏移量
- @return 关注的用户ID列表
/
@Override
public List<Long> following(long userId, int limit, int offset) {
String key = "uf:flws:" + userId; // 用户关注的ZSet键
return getListWithOffset(
key, // Redis键
offset, // ZREVRANGE offset limit
limit,// 分页大小
//【关键】这是一个"回调函数"(Lambda表达式)。
// 意思就是:如果 Redis 和 本地缓存都没数据,请执行这段代码去数据库查。
// 这里的 'need' 是工具方法算出来的:到底需要从数据库查多少条数据才能填满缓存。
need -> followingMapper.listFollowingRows(userId, need, 0),
"toUserId", // 数据库返回的字段名
"createdAt", // 时间戳字段名
flwsTopCache, // 二级缓存(Caffeine)
userId// 用户ID
);
}
/* - 获取粉丝列表(偏移分页),ZSet 优先,DB 回填并设置 TTL。
- @param userId 用户ID
- @param limit 返回数量上限
- @param offset 偏移量
- @return 粉丝用户ID列表
*/
@Override
public List<Long> followers(long userId, int limit, int offset) {
String key = "uf:fans:" + userId;
return getListWithOffset(
key, // Redis键
offset, // 偏移量
limit,// 分页大小
//【关键】这是一个"回调函数"(Lambda表达式)。
// 意思就是:如果 Redis 和 本地缓存都没数据,请执行这段代码去数据库查。
// 这里的 'need' 是工具方法算出来的:到底需要从数据库查多少条数据才能填满缓存。
need -> followerMapper.listFollowerRows(userId, need, 0),
"fromUserId", // 数据库返回的字段名
"createdAt", // 时间戳字段名
fansTopCache, // 二级缓存(Caffeine)
userId// 用户ID
);
}
/**
- 查询双方关系状态,方便前端去渲染
- @param userId 当前用户ID
- @param otherUserId 对方用户ID
- @return 三态关系:following/followedBy/mutual
*/
@Override
public Map<String, Boolean> relationStatus(long userId, long otherUserId) {
// 检查当前用户是否关注了另一个用户
boolean following = isFollowing(userId, otherUserId);
// 检查另一个用户是否关注了当前用户
boolean followedBy = isFollowing(otherUserId, userId);
// 判断是否互相关注
boolean mutual = following && followedBy;
// 创建一个LinkedHashMap来存储关注关系状态
Map<String, Boolean> m = new LinkedHashMap<>();
// 将当前用户关注对方的状态存入map
m.put("following", following);
// 将对方关注当前用户的状态存入map
m.put("followedBy", followedBy);
// 将互相关注的状态存入map
m.put("mutual", mutual);
// 返回包含关注关系状态的map
return m;
}
-
/**
-
获取用户关注列表的分页数据
-
@param userId 用户ID
-
@param limit 每页显示的数据条数
-
@param cursor 分页游标,用于获取下一页数据
-
@return 返回关注用户ID列表
*/
@Override
public List<Long> followingCursor(long userId, int limit, Long cursor) {
// 构建Redis中的关注列表键
String key = "uf:flws:" + userId;
// 使用带游标的分页方法获取关注列表
return getListWithCursor(
key, // Redis中的键
limit, // 每页条数
cursor, // 分页游标
need -> followingMapper.listFollowingRows(userId, need, 0), // 查询函数
"toUserId", // 结果映射字段
"createdAt" // 排序字段
);
}@Override
/** -
获取指定用户的粉丝列表,支持分页查询
-
@param userId 用户ID
-
@param limit 每页返回的记录数
-
@param cursor 分页游标,用于获取下一页数据,null表示从第一页开始
-
@return 返回粉丝ID列表
*/
public List<Long> followersCursor(long userId, int limit, Long cursor) {
// 构建Redis中存储粉丝列表的键
String key = "uf:fans:" + userId;
// 使用带游标的分页查询方法获取粉丝列表
return getListWithCursor(
key, // Redis中的键
limit, // 每页条数
cursor, // 分页游标
need -> followerMapper.listFollowerRows(userId, need, 0), // 查询函数
"toUserId", // 结果映射字段
"createdAt" // 排序字段
);
}
/**
- 获取用户关注列表的个人信息
- @param userId 用户ID
- @param limit 返回结果的最大数量
- @param offset 分页偏移量
- @param cursor 分页游标,用于分页查询
- @return 返回关注用户的个人信息列表
*/
@Override
public List<ProfileResponse> followingProfiles(long userId, int limit, int offset, Long cursor) {
// 根据cursor是否存在选择不同的查询方式
// 如果cursor不为null,使用followingCursor方法进行分页查询
// 如果cursor为null,使用following方法进行常规分页查询
List<Long> ids = cursor != null ? followingCursor(userId, limit, cursor)
: following(userId, limit, offset);
// 将查询到的用户ID列表转换为个人信息响应列表
return toProfiles(ids);
}
/**
-
根据用户ID获取关注者资料列表
-
支持分页查询和基于游标的分页两种方式
-
@param userId 用户ID
-
@param limit 每页返回的记录数
-
@param offset 分页偏移量(当cursor为null时使用)
-
@param cursor 分页游标(用于实现基于游标的分页)
-
@return 关注者资料列表
*/
@Override
public List<ProfileResponse> followersProfiles(long userId, int limit, int offset, Long cursor) {
// 根据cursor是否存在选择不同的分页方式
// 如果cursor不为null,使用基于游标的分页方式
// 如果cursor为null,使用传统的limit-offset分页方式
List<Long> ids = cursor != null ? followersCursor(userId, limit, cursor)
: followers(userId, limit, offset);
// 将用户ID列表转换为ProfileResponse对象列表并返回
return toProfiles(ids);
}@Override
public Map<String, Long> counter(long userId) {
//先去redis中的sds获取数据
byte[] rows = redis.execute((RedisCallback<byte[]>) connection ->
connection.stringCommands().get(("ucnt:" + userId).getBytes(StandardCharsets.UTF_8))
);
Map<String, Long> result = new LinkedHashMap<>(); // 用来存储最终结果的map
//当前redis中没有数据或者其结构出现问题,则进行重建
if(rows == null || rows.length < UserCounterSchema.SCHEMA_LEN * UserCounterSchema.FIELD_SIZE) {
try {
userCounterService.rebuildAllCounters(userId);
} catch (Exception e) {
log.error("重建用户计数器失败", e);
}
}
// 重建后再拿一次数据
rows = redis.execute((RedisCallback<byte[]>) connection ->
connection.stringCommands().get(("ucnt:" + userId).getBytes(StandardCharsets.UTF_8)));
if(rows == null || rows.length < UserCounterSchema.SCHEMA_LEN * UserCounterSchema.FIELD_SIZE){
result.put("followings", 0L);
result.put("followers", 0L);
result.put("posts", 0L);
result.put("likedPosts", 0L);
result.put("favedPosts", 0L);
return result;
}
byte[] buf = rows;
final int seg = buf.length / UserCounterSchema.FIELD_SIZE;
//关注数
long followings = readInt32BE(buf,UserCounterSchema.IDX_FOLLOWING);
//粉丝数
long followers = readInt32BE(buf,UserCounterSchema.IDX_FOLLOWER);
//判断数据库是否查询成功
boolean isDbQuerySuccess = true;
//去数据库中进行比对
String chkKey = "ucnt:chk:" + userId;
// 【流量控制】使用 Redis 的 setIfAbsent (SETNX) 实现一个简单的分布式锁/限流器。
// 只有当 Key 不存在时才设置成功。有效期 300 秒。
// 含义:每 300 秒(5分钟)内,只允许对该用户进行一次数据库比对校验。
// 避免每次请求都查数据库,导致数据库压力过大。
Boolean doCheck = redis.opsForValue().setIfAbsent(chkKey, "1", java.time.Duration.ofSeconds(300));
Long dbFollowings = 0L;
Long dbFollowers = 0L;
if (Boolean.TRUE.equals(doCheck)){
//抢到了锁,就去数据库中查询对应的数据
//查询关注的数量
try {
dbFollowings = followingMapper.selectCount(new LambdaQueryWrapper<Following>()
.eq(Following::getFromUserId, userId)
.eq(Following::getRelStatus , FollowingStatus.FOLLOWING_SUCCESS.getCode()));
} catch (Exception e) {
isDbQuerySuccess = false;
}
//查询粉丝的数量
try {
dbFollowers = followerMapper.selectCount(new LambdaQueryWrapper<Follower>()
.eq(Follower::getToUserId, userId)
.eq(Follower::getRelStatus , FollowingStatus.FOLLOWING_SUCCESS.getCode()));
} catch (Exception e) {
isDbQuerySuccess = false;
}
if (isDbQuerySuccess && (seg != UserCounterSchema.SCHEMA_LEN || followings != dbFollowings || followers != dbFollowers)){
//数据不一致,或结构有问题,再次重建
userCounterService.rebuildAllCounters(userId);
//拿到重建后的数据
byte[] newRows = redis.execute((RedisCallback<byte[]>) connection -> connection.stringCommands().get(("ucnt:" + userId).getBytes(StandardCharsets.UTF_8)));if(newRows != null && newRows.length >= UserCounterSchema.SCHEMA_LEN * UserCounterSchema.FIELD_SIZE){ rows = newRows; buf = rows; followings = readInt32BE(buf,UserCounterSchema.IDX_FOLLOWING); followers = readInt32BE(buf,UserCounterSchema.IDX_FOLLOWER); } } } result.put("followings", followings); // 使用上面解析好的变量,这里可能出现数据不一致的情况 result.put("followers", followers); // 使用上面解析好的变量,这里可能出现数据不一致的情况 // 解析剩余字段 result.put("posts", readInt32BE(buf, UserCounterSchema.IDX_POST)); result.put("likedPosts", readInt32BE(buf, UserCounterSchema.IDX_LIKE_GOT)); result.put("favedPosts", readInt32BE(buf, UserCounterSchema.IDX_FAV_GOT)); return result;}
/**
- 从字节数组中读取32位大端序整数
- @param buf 输入的字节数组
- @param idx 起始索引位置
- @return 解析得到的32位长整数值,如果索引超出范围则返回0
*/
private Long readInt32BE(byte[] buf, int idx) {
// 检查索引是否超出有效范围
if (idx > UserCounterSchema.SCHEMA_LEN || idx < 1) return 0L;
// 初始化结果值为0
Long result = 0L;
// 计算实际的字节偏移量(因为索引从1开始)
int offset = idx - 1;
// 循环处理4个字节(32位)
for (int i = 0; i < UserCounterSchema.FIELD_SIZE; i++) {
// 将已有结果左移8位,然后与当前字节的值进行或运算
// 这样实现大端序字节的组合
result = (result << 8) | buf[offset + i];
}
// 返回最终结果
return result;
}
}
-
1.5.4 用户关系模块的令牌桶lua脚本
-- 设置令牌桶,限制一段时间的水流(流量)
local key = KEYS[1] -- Redis的键名(如:user:123:rate)
local capacity = tonumber(ARGV[1]) -- 桶容量(最大令牌数)
local rate = tonumber(ARGV[2]) -- 令牌生成速度(个/秒)
local now = redis.call('TIME')[1] -- 当前时间戳(秒)
local last = redis.call('HGET', key, 'last') -- 上次检查时间
local tokens = redis.call('HGET', key, 'tokens') -- 当前剩余令牌数
if not last then -- 如果没有上次检查时间为无,则将第一次检查时间设置为现在,并且初始化容量
last = now;
tokens = capacity
end -- 第一次使用时初始化
local elapsed = tonumber(now) - tonumber(last) -- 过了多少秒
local add = elapsed * rate -- 这段时间应该加的令牌数
tokens = math.min(capacity, tonumber(tokens) + add) -- 加令牌(但不能超过容量)
if tokens < 1 then -- 桶里令牌不足1个
redis.call('HSET', key, 'last', now) -- 更新最后时间
redis.call('HSET', key, 'tokens', tokens) -- 保存当前令牌数
return 0 -- 返回0表示"请求被拒绝"
end
tokens = tokens - 1 -- 拿走一个令牌
redis.call('HSET', key, 'last', now) -- 更新最后操作时间
redis.call('HSET', key, 'tokens', tokens) -- 保存新的令牌数
redis.call('PEXPIRE', key, 60000) -- 设置60秒后自动删除这个桶(节省内存)
return 1 -- 返回1表示"请求通过"