知光项目用户关系模块设计与实现:高并发下的关注 / 粉丝体系架构实践
本文介绍知光项目用户关系模块的设计与实现,该模块为社交核心基础设施,针对高并发读写、计数一致性、大 V 场景性能瓶颈等挑战,采用关注表与粉丝表分表设计,将 following 表作为唯一可信主表。基于 Canal+Outbox+Kafka 构建事件驱动异步架构,解决双写不一致问题;写流程通过 Lua 令牌桶限流,同步更新主表、异步维护下游数据并做幂等去重;读流程采用 Redis ZSet + 本地
知光项目用户关系模块设计与实现:高并发下的关注 / 粉丝体系架构实践
知光项目来源于小红书程序员流年的开源项目,欢迎大家一起学习。
在社交类产品中,用户关系模块(关注 / 粉丝体系)是核心基础设施之一,既要支撑高并发的关注 / 取消关注操作,也要满足粉丝 / 关注列表的低延迟查询,同时保证关注数、粉丝数等核心指标的一致性。本文将以 “知光项目” 为例,深度拆解用户关系模块的设计思路、核心实现与性能优化策略,还原从业务场景到技术落地的完整链路。
一、模块背景与核心挑战
知光项目的用户关系模块需支撑以下核心场景:
- 高频写操作:百万级用户的关注 / 取消关注行为,要求接口响应时间 < 50ms,且具备限流防刷能力;
- 高频读操作:用户主页、推荐流等场景需快速查询关注 / 粉丝列表,支持偏移 / 游标两种分页方式;
- 计数一致性:关注数、粉丝数等核心指标需实时可用,且能应对缓存与数据库的一致性校验;
- 高并发兼容:大 V 用户(粉丝数≥50 万)的列表查询需避免性能瓶颈。
针对以上挑战,模块设计核心遵循 “空间换时间、读写分离、异步解耦” 三大原则,最终实现了高并发下的高性能与高可用。
二、核心数据模型:关注表与粉丝表的分表设计
用户 A 关注用户 B 是 “单向行为”,但会产生两个异构的业务维度:A 的关注列表(主动维度)、B 的粉丝列表(被动维度)。项目并未采用单表存储fromUserId(关注者)+toUserId(被关注者)的方案,而是拆分出关注表与粉丝表,核心原因如下:
1. 极致优化高频查询性能
关注 / 粉丝列表是超高频读场景,分表可针对性设计索引,避免单表多维度查询的性能损耗:
- 关注表:核心索引
fromUserId + toUserId(唯一索引),精准服务 “查 A 关注了谁”(listFollowing),查询时无需额外过滤,直接走覆盖索引; - 粉丝表:核心索引
toUserId + fromUserId(唯一索引),精准服务 “查谁关注了 B”(listFollowers),避免单表下toUserId维度的回表 / 全表扫描。
// RelationMapper.java 分表查询示例
List<Long> listFollowing(@Param("fromUserId") Long fromUserId,
@Param("limit") int limit,
@Param("offset") int offset);
List<Long> listFollowers(@Param("toUserId") Long toUserId,
@Param("limit") int limit,
@Param("offset") int offset);
2. 解耦主动 / 被动业务逻辑
关注是 “用户主动行为”,粉丝是 “被动结果”,分表让两类逻辑完全独立:
- 关注表:同步写入,用户点击 “关注” 后即时生效,保证操作反馈的实时性;
- 粉丝表:异步写入,通过 Outbox 事件 + Kafka 异步更新,不阻塞主接口响应。
若合表存储,同步写粉丝维度会增加接口耗时,异步写又会导致 “关注列表生效、粉丝列表未生效” 的感知不一致,分表则完美规避该问题。
3. 简化计数统计与一致性校验
用户维度的 “关注数 / 粉丝数” 是核心指标,分表让计数逻辑更高效:
- 关注数:直接统计关注表中
fromUserId=A的有效行数(countFollowingActive); - 粉丝数:直接统计粉丝表中
toUserId=B的有效行数(countFollowerActive)。
// RelationController.java 计数校验示例
int dbFollowings = relationMapper.countFollowingActive(userId);
int dbFollowers = relationMapper.countFollowerActive(userId);
4. 避免高并发下的锁竞争
单表存储时,“查大 V 粉丝列表”(toUserId维度)与 “用户点关注”(fromUserId维度)会抢占同一张表的锁,导致接口超时;分表后,关注表与粉丝表的锁完全隔离,锁竞争概率大幅降低。
三、核心流程实现:写、读、计数三大链路
1. 写流程:关注 / 取消关注(同步 + 异步结合)
写流程的核心目标是 “主流程快速响应,被动维度异步兜底”,整体链路如下:
(1)关注操作
// RelationServiceImpl.java 关注核心逻辑
@Transactional
public boolean follow(long fromUserId, long toUserId) {
// 1. Lua令牌桶限流:防刷,每用户100令牌/秒
Long ok = redis.execute(tokenScript, List.of("rl:follow:" + fromUserId), "100", "1");
if (ok == 0L) return false;
// 2. 同步写入关注表(主动维度)
long id = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
int inserted = mapper.insertFollowing(id, fromUserId, toUserId, 1);
// 3. 写入Outbox事件,异步更新粉丝表
if (inserted > 0) {
String payload = objectMapper.writeValueAsString(new RelationEvent("FollowCreated", fromUserId, toUserId, id));
outboxMapper.insert(outId, "following", id, "FollowCreated", payload);
return true;
}
return false;
}
(2)异步更新粉丝表
Outbox 表的行变更由 Canal 捕获并转发至 Kafka,消费者消费后更新粉丝表与缓存:
// RelationEventProcessor.java 事件处理
public void process(RelationEvent evt) {
// 幂等去重:Redis键10分钟过期
String dk = "dedup:rel:" + evt.type() + ":" + evt.fromUserId() + ":" + evt.toUserId() + ":" + (evt.id() == null ? "0" : evt.id());
Boolean first = redis.opsForValue().setIfAbsent(dk, "1", Duration.ofMinutes(10));
if (!first) return;
if ("FollowCreated".equals(evt.type())) {
// 异步插入粉丝表(被动维度)
mapper.insertFollower(evt.id(), evt.toUserId(), evt.fromUserId(), 1);
// 更新Redis ZSet缓存
redis.opsForZSet().add("uf:flws:" + evt.fromUserId(), String.valueOf(evt.toUserId()), System.currentTimeMillis());
redis.opsForZSet().add("uf:fans:" + evt.toUserId(), String.valueOf(evt.fromUserId()), System.currentTimeMillis());
// 更新计数
userCounterService.incrementFollowings(evt.fromUserId(), 1);
userCounterService.incrementFollowers(evt.toUserId(), 1);
}
}
(3)取消关注
逻辑与关注对称:同步更新关注表(逻辑删除),异步更新粉丝表,同时清理缓存、调整计数。
2. 读流程:关注 / 粉丝列表(缓存优先 + 双分页)
读流程的核心目标是 “缓存优先,按需回填,大 V 优化”,支持偏移分页(简单场景)与游标分页(高一致性场景)。
(1)缓存策略:Redis ZSet + 本地 Caffeine 缓存
- Redis ZSet:存储关注 / 粉丝列表,分值为关注时间戳(毫秒),支持按时间倒序分页,设置 2 小时 TTL 避免陈旧数据;
- 本地 Caffeine 缓存:针对大 V 用户(粉丝≥50 万),缓存前 500 名粉丝 / 关注列表,降低冷启动回源开销,当有人第一次去查这些大 V 的粉丝列表 / 关注列表时,不用每次都跑到数据库里去翻数据(也就是常说的 “冷启动回源”),既减少了数据库的查询压力,也能让查数据的速度更快。
// RelationServiceImpl.java 偏移分页读取
private List<Long> getListWithOffset(
String key, // uf:flws:{userId} / uf:fans:{userId}
int offset,
int limit,
IntFunction<Map<Long, Map<String, Object>>> rowsFetcher,
String idField,
String tsField,
Cache<Long, List<Long>> localCache,
long userId
) {
// 1. 优先读Redis ZSet
Set<String> cached = redis.opsForZSet().reverseRange(key, offset, offset + limit - 1L);
if (cached != null && !cached.isEmpty()) return toLongList(cached);
// 2. 大V读本地缓存
List<Long> top = localCache.getIfPresent(userId);
if (top != null) return top.subList(Math.min(offset, top.size()), Math.min(offset + limit, top.size()));
// 3. 缓存未命中,从DB回填Redis
Map<Long, Map<String, Object>> rows = rowsFetcher.apply(Math.min(limit + offset, 1000));
fillZSet(key, rows, idField, tsField, null);
redis.expire(key, Duration.ofHours(2));
// 4. 回填后再次读取
Set<String> filled = redis.opsForZSet().reverseRange(key, offset, offset + limit - 1L);
return filled == null ? Collections.emptyList() : toLongList(filled);
}
(2)双分页支持
- 偏移分页:适用于普通用户列表,简单易实现,但存在 “数据偏移” 问题(分页过程中数据变更);
- 游标分页:基于 ZSet 的时间戳分值,分页游标为上一页最后一条的时间戳,保证分页一致性,适用于时序性要求高的场景。
3. 计数体系:SDS 结构 + 采样校验
用户计数(关注 / 粉丝 / 发文 / 获赞 / 获藏)采用SDS 固定结构(5×4 字节,大端编码)存储在 Redis 中,核心设计如下:
- 结构:5 个 4 字节段依次对应关注数、粉丝数、发文数、获赞数、获藏数,单 Key 存储,读取效率极高;
- 一致性:每 300 秒触发一次采样校验,对比 Redis 计数与 DB 计数,不一致则触发全量重建;
- 可用性:计数缺失 / 异常时自动重建,重建失败则返回 0,保证接口不报错。
// RelationController.java 计数读取与校验
@GetMapping("/counter")
public Map<String, Long> counter(@RequestParam("userId") long userId) {
// 1. 读取Redis SDS
byte[] raw = redis.execute((RedisCallback<byte[]>)
c -> c.stringCommands().get(("ucnt:" + userId).getBytes(StandardCharsets.UTF_8)));
// 2. 结构异常则重建
if (raw == null || raw.length < 20) {
userCounterService.rebuildAllCounters(userId);
// 重建后二次读取
raw = redis.execute((RedisCallback<byte[]>) c -> c.stringCommands().get(("ucnt:" + userId).getBytes(StandardCharsets.UTF_8)));
if (raw == null || raw.length < 20) {
// 返回默认值,保证可用性
m.put("followings", 0L);
m.put("followers", 0L);
return m;
}
}
// 3. 采样校验:每300秒一次
Boolean doCheck = redis.opsForValue().setIfAbsent(chkKey, "1", Duration.ofSeconds(300));
if (doCheck) {
int dbFollowings = relationMapper.countFollowingActive(userId);
int dbFollowers = relationMapper.countFollowerActive(userId);
// 不一致则重建
if (sdsFollowings != (long) dbFollowings || sdsFollowers != (long) dbFollowers) {
userCounterService.rebuildAllCounters(userId);
}
}
// 4. 解析并返回计数
IntFunction<Long> read = idx -> {
int off = (idx - 1) * 4;
long n = 0;
for (int i = 0; i < 4; i++) {
n = (n << 8) | (raw[off + i] & 0xFFL);
}
return n;
};
m.put("followings", read.apply(1));
m.put("followers", read.apply(2));
return m;
}
四、关键优化点与架构亮点
1. 限流防刷:Lua 令牌桶
关注操作通过 Redis Lua 脚本实现令牌桶限流,每用户每秒最多 100 次关注操作,避免恶意刷接口:
第一步:定义令牌桶 Lua 脚本(TOKEN_BUCKET_LUA)
Redis 执行 Lua 脚本是 “单线程原子操作”,能避免并发下令牌计算出错(比如两个请求同时扣令牌,结果扣成负数):
local key = KEYS[1] -- 每个用户的令牌桶Key:rl:follow:用户ID
local capacity = tonumber(ARGV[1]) -- 桶容量:100
local rate = tonumber(ARGV[2]) -- 每秒补令牌数:1
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) -- 补令牌,但不超过桶容量
-- 没令牌:更新时间和令牌数,返回0(拒绝请求)
if tokens < 1 then
redis.call('HSET', key, 'last', now);
redis.call('HSET', key, 'tokens', tokens);
return 0
end
-- 有令牌:消耗1个,更新时间和令牌数,返回1(允许请求)
tokens = tokens - 1
redis.call('HSET', key, 'last', now)
redis.call('HSET', key, 'tokens', tokens)
redis.call('PEXPIRE', key, 60000) -- 60秒过期,清理无用桶
return 1
第二步:调用脚本做限流判断(RelationServiceImpl.java)
@Override
@Transactional
public boolean follow(long fromUserId, long toUserId) {
// 第一步:执行Lua脚本,判断是否允许关注(限流核心)
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);
// ... 后续发Outbox事件的逻辑
}
2. 异步架构:Outbox + Canal + Kafka
采用 “Outbox 事件模式” 保证消息可靠投递:
- 关注 / 取消关注操作在事务内写入 Outbox 表;
- Canal 监听 Outbox 表变更,将事件转发至 Kafka;
- 消费者消费 Kafka 消息,异步更新粉丝表、缓存、计数;
- 全程手动 ACK,保证 “至少一次” 语义,结合 Redis 去重键实现幂等。
3. 关系三态查询:高效判断互关
通过两次单表查询(关注表 + 粉丝表),快速判断 “我关注 TA、TA 关注我、互关” 三态,满足社交场景的核心需求:
// RelationServiceImpl.java 关系三态查询
public Map<String, Boolean> relationStatus(long userId, long otherUserId) {
boolean following = isFollowing(userId, otherUserId);
boolean followedBy = isFollowing(otherUserId, userId);
boolean mutual = following && followedBy;
Map<String, Boolean> m = new LinkedHashMap<>();
m.put("following", following);
m.put("followedBy", followedBy);
m.put("mutual", mutual);
return m;
}
4.幂等去重:避免重复处理同一事件
1. 先搞懂:为啥会重复处理?
咱们的关注 / 取消关注操作是异步处理的(用户点关注后,先写库 + 发事件,后续缓存更新、粉丝数统计都靠 Kafka 异步做)。但异步场景下很容易出现 “同一事件被处理多次”:
- 网络卡了,Kafka 重试发送同一条消息;
- Canal 监听 Outbox 表时,重复抓取到同一条事件;
- 消费者处理完事件后,位点提交失败,导致重新消费。
如果不做去重,会出现:用户只点了 1 次关注,结果粉丝数加了 2 次;或者取消关注后,缓存里的关注列表被重复删除,数据全乱了。
2. 核心思路:给事件贴 “唯一标签”,处理前先查 “有没有处理过”
就像给每个快递贴唯一单号,处理(签收)前先查单号是否已签收 —— 签收过就跳过,没签收就标记 “已签收”+ 处理。
3. 代码层面怎么实现(逐行拆)
核心代码在 RelationEventProcessor.java 的 process 方法里:
// 第一步:生成事件的唯一去重Key(dk = dedup:rel:事件类型:发起者ID:被关注者ID:事件ID)
String dk = "dedup:rel:" + evt.type() + ":" + evt.fromUserId() + ":" + evt.toUserId() + ":" + (evt.id() == null ? "0" : String.valueOf(evt.id()));
// 第二步:用Redis的setIfAbsent做“首次处理判断”(分布式锁思想)
Boolean first = redis.opsForValue().setIfAbsent(dk, "1", Duration.ofMinutes(10));
// 第三步:非首次处理直接返回,不做任何操作
if (first == null || !first) {
return; // 重复事件,跳过
}
// 第四步:首次处理才执行真正的逻辑(更新粉丝表、缓存、计数)
if ("FollowCreated".equals(evt.type())) {
mapper.insertFollower(...); // 插入粉丝关系
redis.opsForZSet().add(...); // 更新缓存
userCounterService.incrementFollowers(...); // 加粉丝数
} else if ("FollowCanceled".equals(evt.type())) {
// 取消关注的同理操作
}
关键细节解释:
- 唯一 Key 怎么来?:
evt.type()(是关注还是取消关注)+fromUserId(谁操作的)+toUserId(操作谁)+evt.id()(事件唯一 ID),这四个维度拼起来,能保证 “同一个事件只有一个 Key”; - setIfAbsent 的作用?:Redis 的这个方法是 “原子操作”—— 如果 Key 不存在,就设置值为 1,返回 true;如果 Key 已存在,直接返回 false。相当于给事件打 “已处理” 标签,且不会有并发问题;
- 为啥设置 10 分钟过期?:事件处理完成后,这个 Key 就没用了,10 分钟后自动删除,避免 Redis 里堆积大量无用 Key,占内存。
4. 最终效果
不管同一个事件被 Kafka 推送多少次,只会被处理 1 次 —— 保证 “关注 1 次只加 1 次粉丝数,取消 1 次只更 1 次缓存”,数据绝对准。
五、总结与展望
知光项目的用户关系模块通过 “分表设计 + 缓存分层 + 异步解耦 + 一致性校验” 的组合策略,成功支撑了高并发下的关注 / 粉丝场景,核心收益如下:
- 性能:关注 / 取消关注接口响应时间 < 50ms,列表查询 P99<100ms;
- 可用性:计数体系支持自动重建,接口无失败返回;
- 扩展性:分表设计让关注 / 粉丝维度的业务扩展互不干扰。
未来可进一步优化的方向:
- 引入分库分表:应对超大规模用户的表数据膨胀;
- 缓存预热:针对大 V 用户,主动预热缓存,降低首次查询延迟;
- 实时计数:基于 Flink 实时计算关注 / 粉丝数,替代采样校验。
用户关系模块的设计本质是 “业务场景驱动技术选型”,分表、缓存、异步等技术手段均围绕 “高并发、高性能、高可用” 的核心目标,这也是社交类产品基础设施设计的通用思路。
更多推荐
所有评论(0)