1. 为什么需要高可用关注系统?
在社交平台和内容社区中,用户关系链(关注/粉丝系统)是最核心的基础设施之一。一个典型的场景是:当用户A关注用户B时,系统需要完成以下操作:
- 在用户A的关注列表中添加用户B
- 在用户B的粉丝列表中添加用户A
- 更新双方的关注数/粉丝数统计
- 触发关注后的推送通知
传统做法是直接操作数据库,但随着用户量增长(比如百万级DAU),这种模式会遇到三个致命问题:
问题1:写扩散风暴
当明星账号发布内容时,需要推送给所有粉丝。假设某明星有1000万粉丝,传统方案会生成1000万条推送记录,这种"写扩散"模式会导致数据库瞬间压力激增。我们实测过一个案例:某明星账号发布动态时,MySQL主库CPU直接冲到100%,持续了15分钟。
问题2:关系链一致性
在分布式环境下,用户A的关注操作可能成功,但用户B的粉丝列表更新失败。我们曾遇到过一个线上故障:由于跨机房网络抖动,导致关注状态不一致,最终需要人工修复数据。
问题3:查询性能瓶颈
获取用户的关注列表是一个高频操作(每次进入个人主页都会触发)。当用户关注了5000人时,使用JOIN查询性能急剧下降。在某次压力测试中,一个关注了3000人的账号,其主页打开延迟达到了2.3秒。
提示:这三个问题不是理论推测,而是我们团队在日活突破50万时真实遇到的线上故障。当时采用的"数据库直连"方案已经完全无法支撑业务需求。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 架构设计:Canal+Kafka的组合方案
2.1 整体架构图
code复制MySQL主库 → Canal Server → Kafka → 多个消费者服务
↑
│
MySQL从库(只读)
2.2 为什么选择Canal?
Canal是阿里开源的MySQL binlog增量订阅组件,相比其他方案(如Debezium),它有三大优势:
- 零侵入性:不需要修改业务代码,通过解析binlog获取变更
- 低延迟:实测从数据库变更到Canal捕获的平均延迟<200ms
- 高可用:支持集群部署和故障自动转移
我们具体用到的Canal配置:
properties复制# canal.instance.mysql.slaveId 需要唯一
canal.instance.mysql.slaveId = 1234
# 只订阅业务库的binlog
canal.instance.filter.regex = mydb\\..*
# 内存队列大小(根据业务量调整)
canal.instance.memory.buffer.size = 16384
2.3 Kafka的拓扑设计
我们采用"一主多从"的消息分区策略:
- 每个用户ID的关注操作路由到固定分区(保证顺序性)
- 创建3个Kafka Topic:
user_relation_action:存储原始关注/取关动作user_relation_snapshot:定期生成的关系链快照user_relation_retry:处理失败的重试队列
关键的生产者配置:
java复制Properties props = new Properties();
props.put("acks", "all"); // 确保消息不丢失
props.put("retries", 3); // 失败重试次数
props.put("partitioner.class", "com.xxx.UserIdPartitioner"); // 自定义分区策略
3. 核心实现细节
3.1 关系链的最终一致性
采用"事件溯源+定期快照"的模式:
- 用户A关注用户B时,业务层只写入主库
- Canal捕获binlog变更,发送到Kafka
- 消费者服务处理消息:
- 更新Redis中的关系图谱
- 异步更新Elasticsearch的粉丝数
- 写HBase留底(用于数据修复)
java复制// 伪代码:处理关注事件
public void handleFollowEvent(FollowEvent event) {
// 1. 更新Redis图数据库
redis.sadd("user:"+event.fromUserId+":following", event.toUserId);
redis.sadd("user:"+event.toUserId+":followers", event.fromUserId);
// 2. 异步更新ES
esClient.prepareUpdate("users", event.toUserId)
.setScript("ctx._source.followerCount += 1")
.execute();
// 3. 写HBase留底
hbase.put("user_relations",
Bytes.toBytes(event.fromUserId + "-" + event.toUserId),
"cf", "type", Bytes.toBytes("FOLLOW"));
}
3.2 异常处理机制
我们设计了三级容错:
- 即时重试:对网络抖动等临时错误,最多重试3次
- 延迟重试:将失败消息写入
user_relation_retry,由单独服务处理 - 人工修复:当连续失败超过阈值时,触发告警并生成修复工单
注意:Kafka消费者的offset提交必须放在业务逻辑成功之后。我们曾因为先提交offset再处理业务,导致数据不一致。
4. 性能优化实践
4.1 缓存设计
采用多级缓存策略:
- L1缓存:本地Caffeine(缓存最近访问的用户关系)
java复制Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(); - L2缓存:Redis集群(存储全量关系数据)
- 使用Hash Tag保证同一个用户的关系数据落在同一节点
- 例如:
user:{12345}:following
4.2 批量处理
对粉丝数更新等非实时需求,采用批量合并写入:
sql复制-- 每小时执行的批量更新
UPDATE user_stats SET follower_count =
(SELECT COUNT(*) FROM user_relations WHERE to_user_id = ?)
WHERE user_id = ?;
4.3 读写分离
- 写路径:应用 → MySQL主库 → Canal → Kafka
- 读路径:
- 最近关系:Redis
- 历史关系:Elasticsearch
- 统计类:预计算的Hive表
5. 监控与运维
5.1 关键监控指标
| 指标名称 | 报警阈值 | 监控工具 |
|---|---|---|
| Canal解析延迟 | >500ms持续5分钟 | Prometheus |
| Kafka消费Lag | >1000 | Grafana |
| Redis内存使用率 | >80% | 阿里云控制台 |
| 关系同步成功率 | <99.9% | 自研监控系统 |
5.2 灾备方案
我们建立了跨机房的双活架构:
- 数据同步:通过DTS实现MySQL跨机房同步
- 流量切换:使用DNS+SLB实现分钟级切换
- 演练周期:每季度进行一次真实故障演练
6. 踩坑实录
坑1:Canal内存泄漏
现象:Canal服务运行24小时后OOM崩溃
根因:默认的MemoryEventStore没有及时清理已消费事件
解决:修改为FileEventStore并调整刷盘策略
xml复制<!-- canal.properties -->
canal.instance.memory.buffer.memunit = 1024
canal.instance.memory.buffer.size = 16384
canal.instance.transaction.size = 1024
坑2:Kafka消息积压
现象:粉丝数更新延迟高达6小时
根因:消费者没有正确设置max.poll.records
优化后的配置:
properties复制max.poll.records=50
fetch.max.bytes=52428800
这套架构上线后,我们实现了:
- 关系操作TP99 <100ms
- 系统可支撑每秒1万+的关注操作
- 数据一致性达到99.999%(每月人工修复<5条)
- 服务器成本降低60%(相比原数据库方案)
对于需要更高性能的场景,我们正在测试用Pulsar替代Kafka,初步测试显示在超大规模粉丝场景下(如顶流明星账号),推送延迟可以进一步降低40%。但这也带来了新的挑战——如何平衡一致性和性能,这将是下一个技术攻关方向。
