当前位置: 首页 > news >正文

【知识获取与分享社区项目 | 项目日记第 7 天】关注取关实现:following 主表 + Outbox 同事务

前言

今天开始整理项目中的用户关系系统。

用户关系模块主要负责:

  • 关注用户
  • 取消关注
  • 查询是否关注、是否被关注、是否互关
  • 查询关注列表和粉丝列表
  • 维护关注数、粉丝数
  • 异步更新缓存和粉丝表

这部分没有简单地在一个接口里同时更新所有数据,而是采用了“一主多从 + 事件驱动”的模型。

其中:

following 表:主事实表 follower 表:粉丝视角的伪从表 用户计数 SDS:计数伪从 Redis ZSet:列表缓存伪从

关注动作发生时,只要求following主表和outbox事件表在同一个 MySQL 事务中成功。后面的 follower 表、计数、缓存都通过事件异步更新。

这一篇先看最核心的写入链路:关注 / 取关接口如何落到 following 主表,并在同一事务中写入 Outbox 事件。


一、用户关系模块整体结构

用户关系相关代码主要在:

src/main/java/com/tongji/relation ├── api │ └── RelationController.java ├── service │ ├── RelationService.java │ └── impl/RelationServiceImpl.java ├── mapper │ └── RelationMapper.java ├── event │ └── RelationEvent.java └── outbox └── OutboxMapper.java

本篇关注这几个文件:

RelationController.java RelationServiceImpl.java RelationMapper.xml OutboxMapper.xml RelationEvent.java

整体写入流程如下:

用户点击关注 ↓ Controller 获取当前登录用户 ID ↓ Service 执行关注限流 ↓ 写 following 主表 ↓ 同一事务写 outbox 事件 ↓ 返回关注结果

这里最重要的是:关注主事实和事件投递意图在一个事务中完成


二、数据库表设计

1. following 主表

following表表示“我关注了谁”。

-- db/schema.sqlCREATETABLEIFNOTEXISTSfollowing(idBIGINTUNSIGNEDNOTNULL,from_user_idBIGINTUNSIGNEDNOTNULL,to_user_idBIGINTUNSIGNEDNOTNULL,rel_statusTINYINTNOTNULLDEFAULT1,created_atDATETIME(3)NOTNULL,updated_atDATETIME(3)NOTNULL,PRIMARYKEY(id),UNIQUEKEYuk_from_to(from_user_id,to_user_id),KEYidx_from_created(from_user_id,created_at,to_user_id,rel_status),KEYidx_to(to_user_id,from_user_id,rel_status));

字段说明:

字段含义
from_user_id发起关注的人
to_user_id被关注的人
rel_status关系状态,1表示关注中,0表示已取消
created_at创建时间
updated_at更新时间

这里有一个唯一索引:

UNIQUEKEYuk_from_to(from_user_id,to_user_id)

它保证同一对用户之间不会插入多条关注关系。


2. outbox 事件表

Outbox 表用于保存领域事件。

CREATETABLEIFNOTEXISTSoutbox(idBIGINTUNSIGNEDNOTNULL,aggregate_typeVARCHAR(64)NOTNULL,aggregate_idBIGINTUNSIGNEDNULL,typeVARCHAR(64)NOTNULL,payload JSONNOTNULL,created_atTIMESTAMP(3)NOTNULLDEFAULTCURRENT_TIMESTAMP(3),PRIMARYKEY(id),KEYix_outbox_agg(aggregate_type,aggregate_id),KEYix_outbox_ct(created_at));

关注成功时,会写入一条FollowCreated事件;取消关注时,会写入一条FollowCanceled事件。


三、关注接口 Controller 层

// src/main/java/com/tongji/relation/api/RelationController.java@RestController@RequestMapping("/api/v1/relation")publicclassRelationController{privatefinalRelationServicerelationService;privatefinalJwtServicejwtService;@PostMapping("/follow")publicbooleanfollow(@RequestParam("toUserId")longtoUserId,@AuthenticationPrincipalJwtjwt){longuid=jwtService.extractUserId(jwt);returnrelationService.follow(uid,toUserId);}@PostMapping("/unfollow")publicbooleanunfollow(@RequestParam("toUserId")longtoUserId,@AuthenticationPrincipalJwtjwt){longuid=jwtService.extractUserId(jwt);returnrelationService.unfollow(uid,toUserId);}}

接口路径:

POST /api/v1/relation/follow?toUserId=123 POST /api/v1/relation/unfollow?toUserId=123

Controller 层主要做两件事:

  1. 通过@AuthenticationPrincipal Jwt获取当前登录用户。
  2. 把当前用户 ID 和目标用户 ID 交给 Service 层处理。

这里的fromUserId不由前端传,而是从 JWT 中解析,这样可以避免用户伪造关注发起者。


四、关注 Service 层实现

1. 关注流程

// src/main/java/com/tongji/relation/service/impl/RelationServiceImpl.java@Override@Transactionalpublicbooleanfollow(longfromUserId,longtoUserId){Longok=redis.execute(tokenScript,List.of("rl:follow:"+fromUserId),"100","1");if(ok==0L){returnfalse;}longid=ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);intinserted=mapper.insertFollowing(id,fromUserId,toUserId,1);if(inserted>0){try{LongoutId=ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);Stringpayload=objectMapper.writeValueAsString(newRelationEvent("FollowCreated",fromUserId,toUserId,id));outboxMapper.insert(outId,"following",id,"FollowCreated",payload);}catch(Exceptionignored){}returntrue;}returnfalse;}

这段代码可以拆成三步看。

第一步,关注限流:

redis.execute(tokenScript,List.of("rl:follow:"+fromUserId),"100","1");

每个用户都有自己的限流 Key:

rl:follow:{fromUserId}

第二步,写入following主表:

mapper.insertFollowing(id,fromUserId,toUserId,1);

第三步,写入 Outbox 事件:

outboxMapper.insert(outId,"following",id,"FollowCreated",payload);

由于方法上有@Transactional,所以 following 主表和 outbox 表处在同一个事务里。

这就保证了:

关注关系写成功,事件一定会落库; 关注关系失败,事件也不会落库。

2. 取消关注流程

@Override@Transactionalpublicbooleanunfollow(longfromUserId,longtoUserId){intupdated=mapper.cancelFollowing(fromUserId,toUserId);if(updated>0){try{LongoutId=ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);Stringpayload=objectMapper.writeValueAsString(newRelationEvent("FollowCanceled",fromUserId,toUserId,null));outboxMapper.insert(outId,"following",null,"FollowCanceled",payload);}catch(Exceptionignored){}returntrue;}returnfalse;}

取消关注也是同样的思路:

更新 following 主表 rel_status=0 ↓ 写入 FollowCanceled 事件

这里没有直接删除关系,而是逻辑取消。

这样做的好处是可以保留关系变更痕迹,也方便后续重新关注时复用唯一键。


五、令牌桶限流 Lua

关注操作前会先执行 Redis Lua 令牌桶。

-- src/main/java/com/tongji/relation/service/impl/RelationServiceImpl.javalocalkey=KEYS[1]localcapacity=tonumber(ARGV[1])localrate=tonumber(ARGV[2])localnow=redis.call('TIME')[1]locallast=redis.call('HGET',key,'last')localtokens=redis.call('HGET',key,'tokens')ifnotlastthenlast=now tokens=capacityendlocalelapsed=tonumber(now)-tonumber(last)localadd=elapsed*rate tokens=math.min(capacity,tonumber(tokens)+add)iftokens<1thenredis.call('HSET',key,'last',now)redis.call('HSET',key,'tokens',tokens)return0endtokens=tokens-1redis.call('HSET',key,'last',now)redis.call('HSET',key,'tokens',tokens)redis.call('PEXPIRE',key,60000)return1

调用参数是:

List.of("rl:follow:"+fromUserId),"100","1"

也就是说:

  • 桶容量:100
  • 补充速率:1 个 token / 秒
  • 每次关注消耗 1 个 token

这样可以防止短时间内恶意批量关注。


六、Mapper 层实现

1. 插入关注关系

<!-- src/main/resources/mapper/RelationMapper.xml --><insertid="insertFollowing">INSERT INTO following ( id, from_user_id, to_user_id, rel_status, created_at, updated_at ) VALUES ( #{id}, #{fromUserId}, #{toUserId}, #{relStatus}, NOW(3), NOW(3) ) ON DUPLICATE KEY UPDATE rel_status = VALUES(rel_status), updated_at = VALUES(updated_at)</insert>

这里用了:

ONDUPLICATEKEYUPDATE

因为(from_user_id, to_user_id)有唯一索引。

如果之前已经关注过又取消了,再次关注时不会插入重复数据,而是把rel_status更新回1


2. 取消关注

<updateid="cancelFollowing">UPDATE following SET rel_status = 0, updated_at = NOW(3) WHERE from_user_id = #{fromUserId} AND to_user_id = #{toUserId}</update>

取消关注只修改状态,不删除记录。


七、Outbox 事件结构

关系事件定义如下:

// src/main/java/com/tongji/relation/event/RelationEvent.javapublicrecordRelationEvent(Stringtype,LongfromUserId,LongtoUserId,Longid){}

关注成功事件示例:

{"type":"FollowCreated","fromUserId":100,"toUserId":200,"id":123456}

取消关注事件示例:

{"type":"FollowCanceled","fromUserId":100,"toUserId":200,"id":null}

八、Outbox Mapper 实现

<!-- src/main/resources/mapper/OutboxMapper.xml --><insertid="insert">INSERT INTO outbox ( id, aggregate_type, aggregate_id, type, payload, created_at ) VALUES ( #{id}, #{aggregateType}, #{aggregateId}, #{type}, #{payload}, NOW(3) )</insert>

在关注系统中,Outbox 的aggregate_type是:

following

事件类型是:

FollowCreated FollowCanceled

payload 中保存具体事件内容。


九、为什么要引入 Outbox?

如果关注接口中直接这样写:

写 following 表 发送 Kafka 消息

就会遇到一个经典问题:

数据库写成功了,但消息发送失败怎么办? 消息发送成功了,但数据库事务回滚怎么办?

Outbox 模式解决的就是这个问题。

它把“发送消息”变成“写一条事件记录”:

写 following 主表 写 outbox 事件表 提交同一个 MySQL 事务

后续再由 Canal 订阅 outbox 表 binlog,把事件异步推送到 Kafka。

这样可以保证主事实和事件意图一致。


十、知识点总结

1. following 为什么是主表?

因为它表示“我关注了谁”,是关注行为的直接事实来源。

后面的 follower 表、缓存、计数都是可以重建的派生数据。

2. 为什么取消关注不直接删除?

逻辑取消可以保留关系记录,也方便后续重新关注时使用唯一键做幂等更新。

3. Outbox 模式解决什么问题?

解决数据库事务和消息发送之间的一致性问题。

主表和事件表同事务成功,后续再通过 Canal/Kafka 异步分发。

4. 令牌桶有什么作用?

关注属于容易被滥用的行为,令牌桶可以按用户维度限制短时间高频关注。


总结

这一篇主要整理了用户关系系统的主写入流程。

关注和取关并不是一次性同步更新所有数据,而是只保证following主表和outbox表在同一事务内完成。这样following成为唯一主事实,Outbox 事件成为后续异步同步的入口。

这就是“一主多从 + 事件驱动”模型的第一步:主表强一致,派生视图最终一致。

http://www.jsqmd.com/news/859810/

相关文章:

  • 历史遗留炮弹排查技术解析:广州红鹏JM1000方案
  • 站长日记:实测一款神仙工具,终于搞定了Bing和360的收录难题
  • Vue UI样式兼容性常见问题与解决方案
  • Nodejs后端服务接入Taotoken多模型API的实践教程
  • Turnitin AI 检测算法深度剖析与绕过技术可行性方案
  • 2605C++,C++继承类实现调试器
  • SleeperX:macOS系统级电源管理架构解析与深度集成方案
  • YOLOv8水稻病害识别检测系统(项目源码+YOLO数据集+模型权重+UI界面+python+深度学习+环境配置)
  • API调用延迟飙升300%?ElevenLabs潮州话合成性能瓶颈诊断,工程师连夜修复的4个关键配置
  • 存储巨头日赚近3亿,长鑫科技还要让A股等多久?
  • NOBOOK账号使用指南:付费后能否多人共用?
  • Wand-Enhancer终极指南:免费解锁WeMod专业版与远程控制功能
  • 数据主权驱动:即时通讯私有化成选型必选项
  • 大模型智能体 (LLM Agent) 从入门到实战:让大模型真正 “会做事“
  • Visual Studio Code 1.121 发布:新增 Mermaid 和 HTML 预览,优化终端工具
  • 如何为你的Python数据分析脚本注入多模型AI能力
  • 520,选ROG NUC 2026,把最好的爱送给自己,也送给TA!
  • SSH密钥不能直接访问phpMyAdmin:正确使用隧道方案
  • 3分钟快速上手:VoiceFixer语音修复工具终极指南
  • 如何用Wannakey免费恢复WannaCry加密文件?3步内存密钥恢复指南
  • Ladybug深度解析:建筑环境数据分析的Python利器
  • 【三角形面积】信息学奥赛一本通C语言解法(题号2073)
  • 滚动吸顶+淡入淡出
  • YOLOv8小麦叶片病害识别检测系统(项目源码+YOLO数据集+模型权重+UI界面+python+深度学习+环境配置)
  • Java Excel导出:如何实现自定义表头与字段顺序的完全控制
  • 非遗传承风:千年古法香云纱,大宋幽兰让非遗走入寻常生活
  • 老挝语TTS项目被拒3次?ElevenLabs合规性红线清单(含Lao语言政策备案要求、儿童语音禁用场景、宗教术语过滤规则)
  • 从IO视角深度对比:BST、红黑树、B树、B+树
  • 终极LiveSplit指南:从新手到速度跑大师的完整计时方案
  • 本地视频怎样去水印?2026年实用去水印方法对比与软件推荐