如何设计一个社交平台的关注/粉丝系统?—— 一位8年Java开发者的架构心路
当你的社交平台面临百万用户实时互动,如何确保关注操作毫秒级响应?如何保证粉丝列表的实时性和一致性? 这个看似基础的功能背后,隐藏着读写扩散、数据一致性、热点用户等架构难题。本文将带你从业务模型到代码落地,构建一个支撑千万级关系的社交系统。
一、业务场景与核心挑战
典型关注业务流程:
graph TD
A[用户A关注用户B] --> B{关系检查}
B -->|未关注| C[写入关注关系]
C --> D[更新粉丝数/关注数]
D --> E[推送粉丝动态]
B -->|已关注| F[返回错误]
高并发场景下的核心挑战:
- 读写扩散:读操作(粉丝列表)远多于写操作(关注)
- 数据一致性:如何保证计数与关系列表的强一致?
- 热点用户:明星账号百万粉丝列表如何高效存储?
- 实时推送:新粉丝动态如何实时触达?
二、架构设计要点
1. 分层架构设计
graph LR
Client --> API_Gateway
API_Gateway --> Follow_Service[关注服务]
Follow_Service --> Cache[Redis集群]
Follow_Service --> DB[分库分表MySQL]
Follow_Service --> MQ[Kafka消息队列]
Follow_Service --> GraphDB[Neo4j图数据库]
2. 关键设计决策
- 存储策略:组合使用关系型DB + 图数据库 + Redis
- 计数方案:Redis原子计数 + MySQL异步持久化
- 列表查询:Redis SortedSet存储热点粉丝列表
- 实时推送:Kafka + WebSocket双通道推送
- 冷热分离:ES存储历史粉丝数据
三、核心代码实现(附详细注释)
1. 关注关系服务(写扩散)
@Service
public class FollowService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
private static final String FOLLOWING_KEY = "following:%d"; // 用户关注集合
private static final String FOLLOWERS_KEY = "followers:%d"; // 粉丝集合
private static final String FOLLOW_COUNT = "follow_count:%d"; // 计数Key
/**
* 关注操作(原子性保证)
* @param userId 操作者ID
* @param targetId 被关注用户ID
* @return 是否成功
*/
public boolean followUser(long userId, long targetId) {
// 1. 检查是否已关注
String followingKey = String.format(FOLLOWING_KEY, userId);
if (Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(followingKey, String.valueOf(targetId)))) {
throw new BusinessException("已关注该用户");
}
// 2. 使用Lua脚本保证原子操作
String luaScript =
"local followingKey = KEYS[1] " +
"local followersKey = KEYS[2] " +
"local userId = ARGV[1] " +
"local targetId = ARGV[2] " +
// 添加关注关系
"redis.call('sadd', followingKey, targetId) " +
"redis.call('sadd', followersKey, userId) " +
// 更新计数
"redis.call('hincrby', KEYS[3], 'following', 1) " +
"redis.call('hincrby', KEYS[4], 'followers', 1) " +
"return 1";
DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
String followersCountKey = String.format(FOLLOW_COUNT, targetId);
String followingCountKey = String.format(FOLLOW_COUNT, userId);
redisTemplate.execute(
script,
Arrays.asList(
followingKey,
String.format(FOLLOWERS_KEY, targetId),
followingCountKey,
followersCountKey
),
String.valueOf(userId),
String.valueOf(targetId)
);
// 3. 异步持久化到数据库
kafkaTemplate.send("follow-events",
new FollowEvent(userId, targetId, System.currentTimeMillis()).toJson()
);
// 4. 实时推送新粉丝通知
pushNewFollowerNotification(targetId, userId);
return true;
}
// WebSocket实时推送
private void pushNewFollowerNotification(long targetId, long followerId) {
String channel = "user:" + targetId + ":followers";
redisTemplate.convertAndSend(channel, String.valueOf(followerId));
}
}
2. 粉丝列表查询(读扩散优化)
@Service
public class FollowerQueryService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ElasticsearchRestTemplate esTemplate;
/**
* 获取粉丝列表(带分页和缓存)
* @param userId 用户ID
* @param page 页码
* @param size 每页大小
* @return 粉丝ID列表
*/
public List<Long> getFollowers(long userId, int page, int size) {
String redisKey = String.format("followers:%d", userId);
// 1. 尝试从Redis获取
Set<String> followers = redisTemplate.opsForZSet().reverseRange(
redisKey, page * size, (page + 1) * size - 1
);
if (followers != null && !followers.isEmpty()) {
return followers.stream()
.map(Long::valueOf)
.collect(Collectors.toList());
}
// 2. Redis未命中,查询ES(冷数据)
return queryFollowersFromES(userId, page, size);
}
// ES分页查询(用于历史数据)
private List<Long> queryFollowersFromES(long userId, int page, int size) {
NativeSearchQuery query = new NativeSearchQueryBuilder()
.withQuery(QueryBuilders.termQuery("targetId", userId))
.withPageable(PageRequest.of(page, size))
.withSort(SortBuilders.fieldSort("followTime").order(SortOrder.DESC))
.build();
SearchHits<FollowerDoc> hits = esTemplate.search(query, FollowerDoc.class);
return hits.getSearchHits().stream()
.map(hit -> hit.getContent().getFollowerId())
.collect(Collectors.toList());
}
}
3. 计数服务(缓存+异步持久化)
@Service
public class CountService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 获取粉丝数(优先缓存)
* @param userId 用户ID
* @return 粉丝数量
*/
public long getFollowerCount(long userId) {
String countKey = String.format("follow_count:%d", userId);
String count = redisTemplate.opsForHash().get(countKey, "followers");
if (count != null) {
return Long.parseLong(count);
}
// 缓存未命中,从DB加载
long dbCount = jdbcTemplate.queryForObject(
"SELECT follower_count FROM user_stats WHERE user_id = ?",
Long.class, userId
);
// 回填缓存
redisTemplate.opsForHash().put(countKey, "followers", String.valueOf(dbCount));
return dbCount;
}
/**
* 异步更新数据库计数
*/
@KafkaListener(topics = "count-updates")
public void updateCount(ConsumerRecord<String, String> record) {
CountUpdateEvent event = CountUpdateEvent.fromJson(record.value());
jdbcTemplate.update(
"INSERT INTO user_stats (user_id, follower_count, following_count) " +
"VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE " +
"follower_count = follower_count + ?, " +
"following_count = following_count + ?",
event.getUserId(),
event.getFollowerDelta(), event.getFollowingDelta(),
event.getFollowerDelta(), event.getFollowingDelta()
);
}
}
4. 关系图谱服务(图数据库)
@Service
public class RelationGraphService {
@Autowired
private Neo4jTemplate neo4jTemplate;
/**
* 查询共同关注(二度关系)
* @param userId1 用户A
* @param userId2 用户B
* @return 共同关注的用户列表
*/
public List<Long> findMutualFollows(long userId1, long userId2) {
String query = "MATCH (u1:User {id: $id1})-[:FOLLOWS]->(common:User)<-[:FOLLOWS]-(u2:User {id: $id2}) " +
"RETURN common.id";
Map<String, Object> params = Map.of("id1", userId1, "id2", userId2);
return neo4jTemplate.findAll(query, params, Long.class);
}
/**
* 推荐可能认识的人(三度关系)
* @param userId 当前用户
* @return 推荐用户列表
*/
public List<Long> recommendUsers(long userId) {
String query = "MATCH (me:User {id: $userId})-[:FOLLOWS*2..3]->(potential:User) " +
"WHERE NOT (me)-[:FOLLOWS]->(potential) " +
"RETURN potential.id, COUNT(*) AS commonConnections " +
"ORDER BY commonConnections DESC LIMIT 10";
return neo4jTemplate.findAll(query, Map.of("userId", userId), Long.class);
}
}
四、关键优化点与性能指标
性能优化方案:
-
读写分离:
- 写:同步更新Redis,异步持久化DB
- 读:优先Redis,冷数据走ES
-
热点用户特殊处理:
// 对百万粉丝账号使用分片存储 String shardKey = "followers:" + userId + ":" + (followerId % 32);
-
缓存策略:
- 粉丝列表:Redis SortedSet(按关注时间排序)
- 计数数据:Redis Hash持久存储
-
数据冷热分离:
- 热数据:最近6个月粉丝存储在Redis
- 冷数据:历史数据迁移到ES
五、避坑指南(血泪经验)
-
粉丝列表分页陷阱:
// 错误:ZRANGE不支持跨分片 // 正确:使用ZSCAN+游标分页 Cursor<ZSetOperations.TypedTuple<String>> cursor = redisTemplate.opsForZSet() .scan(key, ScanOptions.scanOptions().count(100).build());
-
缓存穿透解决方案:
// 布隆过滤器防止无效用户查询 if (!bloomFilter.mightContain(userId)) { throw new UserNotFoundException(); }
-
数据一致性保障:
- 采用监听Redis Streams的Binlog同步
- 每日对账任务修复差异数据
// 对账任务伪代码 public void reconcileCount(long userId) { long redisCount = getFollowerCountFromRedis(userId); long dbCount = getFollowerCountFromDB(userId); if (redisCount != dbCount) { logger.warn("计数不一致: userId={}, redis={}, db={}", userId, redisCount, dbCount); // 自动修复逻辑... } }
-
突发流量应对:
- 粉丝列表查询降级方案:
// 降级返回前100粉丝+总数 public FollowerList getFollowersDegraded(long userId) { return new FollowerList( redisTemplate.opsForZSet().reverseRange("followers:"+userId, 0, 99), getFollowerCount(userId) ); }
结语
设计关注/粉丝系统就像构建一座社交关系的大厦:既要保证地基(数据存储)的稳固可靠,又要让电梯(读写性能)高速运行,还要能随时扩建(水平扩展)。通过本文的读写分离、冷热分层、图数据库等方案,我们成功支撑了千万级用户的实时社交关系维护。
记住三条黄金法则:
- 写操作轻量化:异步化所有非必要操作
- 读操作多样化:根据场景选择最佳存储
- 数据最终一致:接受短暂延迟换取高性能
架构没有银弹,最好的设计永远是适合业务发展的设计。当你的系统面临下一个量级挑战时,不妨回头看看:当前的瓶颈是否源于昨天的妥协?