【知识获取与分享社区项目 | 项目日记第 6 天】用户维度关注粉丝计数:Outbox 事件 + Redis SDS + 采样一致性校验

前言
上一篇整理了笔记维度的点赞收藏计数,今天我们来看用户维度计数。
用户维度计数主要包括:
- 关注数
- 粉丝数
- 发文数
- 获赞数
- 获收藏数
其中关注数和粉丝数来自用户关注关系,发文数来自发布系统,获赞数和获收藏数则来自笔记维度的点赞收藏事件。
本篇重点放在关注/取关这条链路上,看看项目里是如何用:
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;
}
这段代码做了三件事:
- 使用 Redis Lua 令牌桶做关注限流。
- 写入
following主表。 - 同事务写入 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);
}
}
这里做了四件事:
- 使用 Redis 去重 Key 保证消息幂等。
- 同步
follower投影表。 - 更新关注列表和粉丝列表 ZSet 缓存。
- 原子更新用户维度 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,它更复杂,但也更适合真实社区系统里的关注数、粉丝数和作者主页计数展示。
更多推荐




所有评论(0)