在这里插入图片描述

前言

上一篇整理了笔记维度的点赞收藏计数,今天我们来看用户维度计数。

用户维度计数主要包括:

  • 关注数
  • 粉丝数
  • 发文数
  • 获赞数
  • 获收藏数

其中关注数和粉丝数来自用户关注关系,发文数来自发布系统,获赞数和获收藏数则来自笔记维度的点赞收藏事件。

本篇重点放在关注/取关这条链路上,看看项目里是如何用:

following 主表
follower 投影表
Outbox 事件
Canal + Kafka
Redis ZSet 列表缓存
Redis SDS 用户计数
采样一致性校验
自愈重建

来维护用户维度计数的。


一、用户关系与计数整体设计

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

src/main/java/com/tongji/relation
├── api/RelationController.java
├── service/impl/RelationServiceImpl.java
├── processor/RelationEventProcessor.java
├── mapper/RelationMapper.java
└── outbox

src/main/java/com/tongji/counter
├── service/impl/UserCounterServiceImpl.java
└── schema/UserCounterKeys.java

关注流程可以概括为:

用户关注
  ↓
Lua 令牌桶限流
  ↓
写 following 主表
  ↓
同事务写 outbox 事件
  ↓
Canal 捕获 outbox 变更并投递 Kafka
  ↓
消费者处理 FollowCreated / FollowCanceled
  ↓
更新 follower 投影表
  ↓
更新关注/粉丝 ZSet 缓存
  ↓
更新 ucnt:{userId} 用户计数 SDS

这里有一个很重要的设计:

following 是权威主表,follower、Redis 缓存、用户计数 SDS 都是派生视图。

派生视图允许最终一致,但必须具备重建能力。


二、关系表设计

项目中有两张关系表。

-- 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)
);

CREATE TABLE IF NOT EXISTS follower (
    id BIGINT UNSIGNED NOT NULL,
    to_user_id BIGINT UNSIGNED NOT NULL,
    from_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_to_from (to_user_id, from_user_id),
    KEY idx_to_created (to_user_id, created_at, from_user_id, rel_status),
    KEY idx_from (from_user_id, to_user_id, rel_status)
);

两张表的视角不同:

视角
following 我关注了谁
follower 谁关注了我

following 是主表,关注写入时先写它。

follower 是投影表,由异步事件同步。


三、用户计数 SDS 结构

用户维度计数 Key:

// src/main/java/com/tongji/counter/schema/UserCounterKeys.java
public final class UserCounterKeys {
    public static String sdsKey(long userId) {
        return "ucnt:" + userId;
    }
}

结构是:

ucnt:{userId}
5 段 * 4 字节 = 20 字节

5 个段分别表示:

含义
1 followings,关注数
2 followers,粉丝数
3 posts,发文数
4 likesReceived,获赞数
5 favsReceived,获收藏数

这种结构和笔记维度 SDS 类似,都是定长二进制紧凑计数。


四、关注接口实现

1. Controller 层

// src/main/java/com/tongji/relation/api/RelationController.java
@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);
}

前端只需要传入被关注用户 ID。

当前登录用户 ID 从 JWT 中解析。


2. Service 层:关注写主表 + Outbox

// 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;
}

这段代码做了三件事:

  1. 使用 Redis Lua 令牌桶做关注限流。
  2. 写入 following 主表。
  3. 同事务写入 Outbox 事件。

Outbox 的意义是:关注关系写成功后,下游的粉丝表、缓存、计数都可以异步更新。


3. 取消关注

@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;
}

取消关注同样先更新主表,再写 Outbox 事件。


五、Mapper 层实现

<!-- 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>

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

这里关注使用了唯一索引:

UNIQUE KEY uk_from_to (from_user_id, to_user_id)

所以重复关注不会插入多条关系,而是更新已有关系状态。


六、关系事件处理与用户计数更新

Outbox 事件经过 Canal + Kafka 后,会进入 RelationEventProcessor

// src/main/java/com/tongji/relation/processor/RelationEventProcessor.java
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;
    }

    if ("FollowCreated".equals(evt.type())) {
        mapper.insertFollower(evt.id(), evt.toUserId(), evt.fromUserId(), 1);

        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.incrementFollowings(evt.fromUserId(), 1);
        userCounterService.incrementFollowers(evt.toUserId(), 1);

    } else if ("FollowCanceled".equals(evt.type())) {
        mapper.cancelFollower(evt.toUserId(), evt.fromUserId());

        redis.opsForZSet().remove(
                "uf:flws:" + evt.fromUserId(),
                String.valueOf(evt.toUserId())
        );

        redis.opsForZSet().remove(
                "uf:fans:" + evt.toUserId(),
                String.valueOf(evt.fromUserId())
        );

        userCounterService.incrementFollowings(evt.fromUserId(), -1);
        userCounterService.incrementFollowers(evt.toUserId(), -1);
    }
}

这里做了四件事:

  1. 使用 Redis 去重 Key 保证消息幂等。
  2. 同步 follower 投影表。
  3. 更新关注列表和粉丝列表 ZSet 缓存。
  4. 原子更新用户维度 SDS 计数。

七、用户 SDS 计数原子更新

1. Service 层入口

// src/main/java/com/tongji/counter/service/impl/UserCounterServiceImpl.java
@Override
public void incrementFollowings(long userId, int delta) {
    String key = UserCounterKeys.sdsKey(userId);
    redis.execute(incrScript, List.of(key), "5", "4", "1", String.valueOf(delta));
}

@Override
public void incrementFollowers(long userId, int delta) {
    String key = UserCounterKeys.sdsKey(userId);
    redis.execute(incrScript, List.of(key), "5", "4", "2", String.valueOf(delta));
}

这里注意参数 "1""2"

用户维度 SDS 使用的是 1 基坐标:

1 -> 关注数
2 -> 粉丝数

2. Lua 原子更新

local cntKey = KEYS[1]
local schemaLen = tonumber(ARGV[1])
local fieldSize = tonumber(ARGV[2])
local idx = tonumber(ARGV[3])
local delta = tonumber(ARGV[4])

local cnt = redis.call('GET', cntKey)
if not cnt then
  cnt = string.rep(string.char(0), schemaLen * fieldSize)
end

local off = (idx - 1) * fieldSize
local v = read32be(cnt, off) + delta

if v < 0 then v = 0 end

local seg = write32be(v)
cnt = string.sub(cnt, 1, off) .. seg .. string.sub(cnt, off + fieldSize + 1)

redis.call('SET', cntKey, cnt)
return 1

这段 Lua 和笔记计数的 SDS 更新很像。

区别是用户维度使用 1 基坐标,而笔记维度使用的是 Schema 下标。


八、用户计数读取与采样校验

1. 读取接口

// src/main/java/com/tongji/relation/api/RelationController.java
@GetMapping("/counter")
public Map<String, Long> counter(@RequestParam("userId") long userId) {
    byte[] raw = redis.execute((RedisCallback<byte[]>)
            c -> c.stringCommands().get(("ucnt:" + userId)
                    .getBytes(StandardCharsets.UTF_8)));

    Map<String, Long> m = new LinkedHashMap<>();

    if (raw == null || raw.length < 20) {
        userCounterService.rebuildAllCounters(userId);
        raw = redis.execute((RedisCallback<byte[]>)
                c -> c.stringCommands().get(("ucnt:" + userId)
                        .getBytes(StandardCharsets.UTF_8)));
    }

    // 后续按 5 段读取
}

如果 SDS 缺失或长度异常,会先触发一次重建。


2. 按大端格式读取

IntFunction<Long> read = idx -> {
    if (idx < 1 || idx > seg) return 0L;

    int off = (idx - 1) * 4;
    long n = 0;

    for (int i = 0; i < 4; i++) {
        n = (n << 8) | (buf[off + i] & 0xFFL);
    }

    return n;
};

最后返回:

m.put("followings", read.apply(1));
m.put("followers", read.apply(2));
m.put("posts", read.apply(3));
m.put("likedPosts", read.apply(4));
m.put("favedPosts", read.apply(5));

这里接口字段名是面向前端展示的,底层 SDS 的第 4、5 段在 Service 语义中对应作者收到的点赞和收藏。


3. 采样一致性校验

项目不是每次读取都查数据库校验,因为那样会失去 Redis 计数的意义。

而是使用采样校验:

String chkKey = "ucnt:chk:" + userId;

Boolean doCheck = redis.opsForValue()
        .setIfAbsent(chkKey, "1", Duration.ofSeconds(300));

每个用户 300 秒内最多触发一次校验。

如果触发校验,就查 DB 中的真实关注数和粉丝数:

int dbFollowings = relationMapper.countFollowingActive(userId);
int dbFollowers = relationMapper.countFollowerActive(userId);

然后和 SDS 对比:

if ((seg != 5)
        || sdsFollowings != (long) dbFollowings
        || sdsFollowers != (long) dbFollowers) {
    userCounterService.rebuildAllCounters(userId);
}

这就是采样一致性校验。

它的好处是:

  • 正常读只查 Redis
  • 周期性抽样对账
  • 发现不一致时自动重建
  • 不会让数据库被每次计数查询打满

九、用户计数自愈重建

重建逻辑在 UserCounterServiceImpl 中。

@Override
public void rebuildAllCounters(long userId) {
    String key = UserCounterKeys.sdsKey(userId);
    byte[] buf = new byte[5 * 4];

    long followings = relationMapper.countFollowingActive(userId);
    long followers = relationMapper.countFollowerActive(userId);

    List<Long> ids = knowPostMapper.listMyPublishedIds(userId);
    List<String> idStr = ids.stream()
            .map(String::valueOf)
            .collect(Collectors.toList());

    if (!idStr.isEmpty()) {
        long posts = idStr.size();
        long likeSum = 0L;
        long favSum = 0L;

        Map<String, Map<String, Long>> counts =
                counterService.getCountsBatch("knowpost", idStr, List.of("like", "fav"));

        for (String id : idStr) {
            Map<String, Long> v = counts.get(id);
            likeSum += v.getOrDefault("like", 0L);
            favSum += v.getOrDefault("fav", 0L);
        }

        write32be(buf, 2 * 4, posts);
        write32be(buf, 3 * 4, likeSum);
        write32be(buf, 4 * 4, favSum);
    }

    write32be(buf, 0, followings);
    write32be(buf, 4, followers);

    redis.execute((RedisCallback<Void>) c -> {
        c.stringCommands().set(key.getBytes(StandardCharsets.UTF_8), buf);
        return null;
    });
}

重建来源分成两类:

计数 重建来源
关注数 following
粉丝数 follower
发文数 用户已发布知文数量
获赞数 批量读取该用户所有知文的 like 计数
获收藏数 批量读取该用户所有知文的 fav 计数

这说明 SDS 虽然是高性能读模型,但不是唯一事实。

真正的事实仍然可以从 MySQL 关系表和笔记计数系统重建出来。


十、点赞收藏如何反向影响用户计数

笔记点赞收藏事件除了更新 Feed 缓存,也会更新作者收到的点赞数和收藏数。

// src/main/java/com/tongji/knowpost/listener/FeedCacheInvalidationListener.java
@EventListener
public void onCounterChanged(CounterEvent event) {
    if (!"knowpost".equals(event.getEntityType())) {
        return;
    }

    String metric = event.getMetric();
    String eid = event.getEntityId();
    int delta = event.getDelta();

    KnowPost post = knowPostMapper.findById(Long.valueOf(eid));

    if (post != null && post.getCreatorId() != null) {
        long owner = post.getCreatorId();

        if ("like".equals(metric)) {
            userCounterService.incrementLikesReceived(owner, delta);
        }

        if ("fav".equals(metric)) {
            userCounterService.incrementFavsReceived(owner, delta);
        }
    }
}

也就是说,用户维度计数并不只来自关注系统。

它还会接收发布系统和点赞收藏系统产生的变化。


十一、知识点总结

1. 为什么 following 和 follower 要拆成两张表?

因为查询视角不同。

  • 查“我关注了谁”,适合从 following 查。
  • 查“谁关注我”,适合从 follower 查。

如果只用一张表,也能查,但在分页和索引设计上会更吃力。

2. 为什么用户计数也用 SDS?

用户主页计数读取频率比较高,比如关注数、粉丝数、作品数都经常展示。

用 SDS 可以用一个 Redis String 保存多个计数,结构紧凑,读取也简单。

3. 为什么需要采样校验?

因为关注关系、粉丝投影、Redis 计数都是异步最终一致。

采样校验可以在不增加太多数据库压力的情况下,周期性发现 Redis 计数异常,并触发重建。

4. 自愈重建的价值是什么?

如果 Redis 计数丢失、长度异常、消息延迟或部分事件处理失败,就可以重新从事实表和内容计数系统恢复。

这让计数系统具备“可修复”的能力,而不是错了就只能手动改。


总结

这一篇主要整理了用户维度计数系统。

关注/取关先写 MySQL 的 following 主表,再通过 Outbox + Canal + Kafka 异步更新 follower 投影表、Redis ZSet 列表缓存和用户 SDS 计数。读取用户计数时优先读 ucnt:{userId},如果 SDS 缺失或采样校验发现不一致,就触发 rebuildAllCounters 从事实数据中重建。

这套设计的重点是把“事实”和“视图”拆开:MySQL 关系表保存事实,Redis SDS 提供高性能读取,异步事件负责同步,采样校验和自愈重建负责兜底。

相比普通的 Redis INCR,它更复杂,但也更适合真实社区系统里的关注数、粉丝数和作者主页计数展示。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐