在这里插入图片描述

前言

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

用户关系模块主要负责:

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

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

其中:

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 层主要做两件事:

  1. 通过 @AuthenticationPrincipal Jwt 获取当前登录用户。
  2. 把当前用户 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 事件成为后续异步同步的入口。

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

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐