【知识获取与分享社区项目 | 项目日记第 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.sql
CREATE TABLE IF NOT EXISTS following (
id BIGINT UNSIGNED NOT NULL,
from_user_id BIGINT UNSIGNED NOT NULL,
to_user_id BIGINT UNSIGNED NOT NULL,
rel_status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_from_to (from_user_id, to_user_id),
KEY idx_from_created (from_user_id, created_at, to_user_id, rel_status),
KEY idx_to (to_user_id, from_user_id, rel_status)
);
字段说明:
| 字段 | 含义 |
|---|---|
from_user_id |
发起关注的人 |
to_user_id |
被关注的人 |
rel_status |
关系状态,1 表示关注中,0 表示已取消 |
created_at |
创建时间 |
updated_at |
更新时间 |
这里有一个唯一索引:
UNIQUE KEY uk_from_to (from_user_id, to_user_id)
它保证同一对用户之间不会插入多条关注关系。
2. outbox 事件表
Outbox 表用于保存领域事件。
CREATE TABLE IF NOT EXISTS outbox (
id BIGINT UNSIGNED NOT NULL,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id BIGINT UNSIGNED NULL,
type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY ix_outbox_agg (aggregate_type, aggregate_id),
KEY ix_outbox_ct (created_at)
);
关注成功时,会写入一条 FollowCreated 事件;取消关注时,会写入一条 FollowCanceled 事件。
三、关注接口 Controller 层
// src/main/java/com/tongji/relation/api/RelationController.java
@RestController
@RequestMapping("/api/v1/relation")
public class RelationController {
private final RelationService relationService;
private final JwtService jwtService;
@PostMapping("/follow")
public boolean follow(@RequestParam("toUserId") long toUserId,
@AuthenticationPrincipal Jwt jwt) {
long uid = jwtService.extractUserId(jwt);
return relationService.follow(uid, toUserId);
}
@PostMapping("/unfollow")
public boolean unfollow(@RequestParam("toUserId") long toUserId,
@AuthenticationPrincipal Jwt jwt) {
long uid = jwtService.extractUserId(jwt);
return relationService.unfollow(uid, toUserId);
}
}
接口路径:
POST /api/v1/relation/follow?toUserId=123
POST /api/v1/relation/unfollow?toUserId=123
Controller 层主要做两件事:
- 通过
@AuthenticationPrincipal Jwt获取当前登录用户。 - 把当前用户 ID 和目标用户 ID 交给 Service 层处理。
这里的 fromUserId 不由前端传,而是从 JWT 中解析,这样可以避免用户伪造关注发起者。
四、关注 Service 层实现
1. 关注流程
// src/main/java/com/tongji/relation/service/impl/RelationServiceImpl.java
@Override
@Transactional
public boolean follow(long fromUserId, long toUserId) {
Long ok = redis.execute(
tokenScript,
List.of("rl:follow:" + fromUserId),
"100",
"1"
);
if (ok == 0L) {
return false;
}
long id = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
int inserted = mapper.insertFollowing(id, fromUserId, toUserId, 1);
if (inserted > 0) {
try {
Long outId = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
String payload = objectMapper.writeValueAsString(
new RelationEvent("FollowCreated", fromUserId, toUserId, id)
);
outboxMapper.insert(
outId,
"following",
id,
"FollowCreated",
payload
);
} catch (Exception ignored) {}
return true;
}
return false;
}
这段代码可以拆成三步看。
第一步,关注限流:
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
@Transactional
public boolean unfollow(long fromUserId, long toUserId) {
int updated = mapper.cancelFollowing(fromUserId, toUserId);
if (updated > 0) {
try {
Long outId = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
String payload = objectMapper.writeValueAsString(
new RelationEvent("FollowCanceled", fromUserId, toUserId, null)
);
outboxMapper.insert(
outId,
"following",
null,
"FollowCanceled",
payload
);
} catch (Exception ignored) {}
return true;
}
return false;
}
取消关注也是同样的思路:
更新 following 主表 rel_status=0
↓
写入 FollowCanceled 事件
这里没有直接删除关系,而是逻辑取消。
这样做的好处是可以保留关系变更痕迹,也方便后续重新关注时复用唯一键。
五、令牌桶限流 Lua
关注操作前会先执行 Redis Lua 令牌桶。
-- src/main/java/com/tongji/relation/service/impl/RelationServiceImpl.java
local key = KEYS[1]
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
redis.call('HSET', key, 'last', now)
redis.call('HSET', key, 'tokens', tokens)
return 0
end
tokens = tokens - 1
redis.call('HSET', key, 'last', now)
redis.call('HSET', key, 'tokens', tokens)
redis.call('PEXPIRE', key, 60000)
return 1
调用参数是:
List.of("rl:follow:" + fromUserId), "100", "1"
也就是说:
- 桶容量:100
- 补充速率:1 个 token / 秒
- 每次关注消耗 1 个 token
这样可以防止短时间内恶意批量关注。
六、Mapper 层实现
1. 插入关注关系
<!-- src/main/resources/mapper/RelationMapper.xml -->
<insert id="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>
这里用了:
ON DUPLICATE KEY UPDATE
因为 (from_user_id, to_user_id) 有唯一索引。
如果之前已经关注过又取消了,再次关注时不会插入重复数据,而是把 rel_status 更新回 1。
2. 取消关注
<update id="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.java
public record RelationEvent(
String type,
Long fromUserId,
Long toUserId,
Long id
) {
}
关注成功事件示例:
{
"type": "FollowCreated",
"fromUserId": 100,
"toUserId": 200,
"id": 123456
}
取消关注事件示例:
{
"type": "FollowCanceled",
"fromUserId": 100,
"toUserId": 200,
"id": null
}
八、Outbox Mapper 实现
<!-- src/main/resources/mapper/OutboxMapper.xml -->
<insert id="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 事件成为后续异步同步的入口。
这就是“一主多从 + 事件驱动”模型的第一步:主表强一致,派生视图最终一致。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)