完整教程:知光项目用户关系模块
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 KafkaTemplatekafka; // 用于发送 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 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 *
* 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 *
* 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 *
* 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 *
* 使用 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 消费者 *
* 场景说明: * 我们不直接在业务代码里发 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 行数据。 Listrows = 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 *
* * 关系接口控制器 * 职责:关注/取消关注、关系三态查询、关注/粉丝列表(偏移与游标)、用户维度计数读取与采样自检。 * 缓存: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 Mapstatus(@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 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 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 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 ** 该接口负责维护用户间的社交拓扑关系。为了应对高并发和大数据量, * 提供了基于传统 Offset 的分页和基于时间戳 Cursor 的高性能分页。 * * @author 小策 * @date 2026/1/31 14:28 */ public interface IRelationService { /** * 【核心写入】关注操作 *
实现逻辑建议:在事务内插入关系记录并同时写入 Outbox 表,以便 Canal 同步
* @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); /** * 【列表查询】关注列表 (偏移量分页) *适用于数据量较小或后台管理系统
* @param offset 跳过的记录数 * @return 关注的用户 ID 列表 */ Listfollowing(long userId, int limit, int offset); /** * 【列表查询】粉丝列表 (偏移量分页) */ List followers(long userId, int limit, int offset); /** * 【状态查询】关系三态分析 * 返回 Map 包含: * 1. following: 我是否关注了他 * 2. followedBy: 他是否关注了我 * 3. mutual: 是否互粉 * */ Map relationStatus(long userId, long otherUserId); /** * 【高性能查询】关注列表 (游标分页) *
采用时间戳作为游标,避免深分页性能问题,适用于移动端无限下拉 * @param cursor 上一页最后一条记录的时间戳(毫秒) */ ListfollowingCursor(long userId, int limit, Long cursor); /** * 【高性能查询】粉丝列表 (游标分页) */ List followersCursor(long userId, int limit, Long cursor); /** * 【视图聚合】关注列表资料版 * 逻辑:先查 ID 列表,再批量调用 Profile 接口填充头像、昵称等资料 */ List followingProfiles(long userId, int limit, int offset, Long cursor); /** * 【视图聚合】粉丝列表资料版 */ List followersProfiles(long userId, int limit, int offset, Long cursor); Map 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 DefaultRedisScripttokenScript; private final ObjectMapper objectMapper; private final Cache > flwsTopCache; private final Cache > 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 getListWithOffset(String key, int offset, int limit, IntFunction
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表示“请求通过”
1.5.5 用户关系模块的xml代码
FollowingMapper
FollowerMapper

浙公网安备 33010602011771号