从“点赞”到“私信”:手把手设计一个高可用的站内信系统
当用户在你的平台上点赞了一篇帖子,或是收到一条私信时,如何确保通知能实时、可靠地送达?站内信系统作为用户互动的核心枢纽,直接影响着产品的用户体验和留存率。本文将带你从产品视角出发,设计一套涵盖公告、提醒、私信等多种通知类型的完整解决方案。
1. 站内信系统的核心要素
任何优秀的站内信系统都需要解决三个基本问题:通知分类、实时性保障和用户体验控制。我们先从最基础的通知类型划分开始。
1.1 通知类型的三层架构
现代社区平台的通知通常分为三个层级:
- 系统公告:平台向全体或特定用户群发送的广播消息
- 行为触发通知:由用户互动(如点赞、评论)产生的个性化提醒
- 私信对话:用户之间的点对点通信
每种类型在存储结构、推送逻辑和用户权限控制上都有显著差异。下面是一个典型的类型对比表:
| 类型 | 触发方式 | 存储要求 | 实时性要求 | 用户控制权 |
|---|---|---|---|---|
| 系统公告 | 管理员手动触发 | 永久存储 | 中等 | 可退订类别 |
| 行为通知 | 用户行为自动触发 | 短期存储(30天) | 高 | 可关闭特定类型 |
| 私信 | 用户主动发送 | 永久存储 | 极高 | 可屏蔽特定用户 |
1.2 用户行为的事件化建模
将用户互动抽象为标准化事件是设计灵活通知系统的关键。每个事件应包含以下核心属性:
CREATE TABLE `notify_event` ( `id` bigint NOT NULL AUTO_INCREMENT, `actor_id` bigint NOT NULL COMMENT '触发用户ID', `action` varchar(32) NOT NULL COMMENT '行为类型:like/comment/share', `object_type` varchar(32) NOT NULL COMMENT '对象类型:post/user/comment', `object_id` bigint NOT NULL COMMENT '对象ID', `target_user_id` bigint NOT NULL COMMENT '目标用户ID', `created_at` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_target_user` (`target_user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;提示:使用utf8mb4字符集确保支持emoji等特殊符号
2. 实时推送的技术实现
当事件产生后,如何高效地将其转化为用户可见的通知?这需要一套可靠的消息管道。
2.1 消息管道的三层缓冲
为避免高并发下的系统过载,建议采用三级缓冲架构:
- 应用层队列:使用RabbitMQ或Kafka接收原始事件
- 内存缓存:Redis存储待推送通知
- 持久化存储:MySQL最终落库
# 伪代码示例:事件处理流程 def process_event(event): # 1. 写入消息队列 mq.publish('notify_queue', event) # 2. 消费者处理 if user_is_online(event.target_user_id): # 实时推送 ws_server.push(event.target_user_id, render_notification(event)) else: # 存入待推送列表 redis.sadd(f"pending:{event.target_user_id}", event.id) # 3. 持久化存储 db.insert('notifications', user_id=event.target_user_id, content=render_content(event), is_read=False)2.2 WebSocket的优化实践
保持长连接的稳定性是实时系统的难点,以下是几个关键优化点:
- 心跳机制:客户端每50秒发送ping帧
- 断线重连:采用指数退避算法(1s, 2s, 4s...)
- 多路复用:单个连接承载所有通知类型
前端实现示例:
class NotificationSocket { constructor() { this.reconnectDelay = 1000; this.connect(); } connect() { this.ws = new WebSocket('wss://api.example.com/notify'); this.ws.onopen = () => { this.reconnectDelay = 1000; // 重置重连延迟 this.startHeartbeat(); }; this.ws.onmessage = (event) => { this.handleNotification(JSON.parse(event.data)); }; this.ws.onclose = () => { setTimeout(() => this.connect(), this.reconnectDelay); this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); }; } startHeartbeat() { this.heartbeatInterval = setInterval(() => { this.ws.send('ping'); }, 50000); } }3. 用户偏好与权限控制
不是所有用户都希望收到所有类型的通知,完善的权限体系必不可少。
3.1 通知设置的数据模型
用户应该能够:
- 全局关闭特定类型的通知
- 屏蔽特定用户的私信
- 设置免打扰时段
CREATE TABLE `notification_settings` ( `user_id` bigint NOT NULL, `channel` varchar(32) NOT NULL COMMENT 'push/email/sms', `type` varchar(32) NOT NULL COMMENT 'like/comment/mention', `enabled` tinyint(1) NOT NULL DEFAULT '1', PRIMARY KEY (`user_id`,`channel`,`type`) ); CREATE TABLE `user_blocks` ( `user_id` bigint NOT NULL, `blocked_user_id` bigint NOT NULL, PRIMARY KEY (`user_id`,`blocked_user_id`) );3.2 权限校验的拦截逻辑
在消息进入管道前进行权限检查:
def should_deliver_notification(sender_id, receiver_id, action): # 检查全局设置 setting = db.get_settings(receiver_id, action) if not setting.enabled: return False # 检查屏蔽关系 if db.exists('user_blocks', user_id=receiver_id, blocked_user_id=sender_id): return False # 检查免打扰时段 if is_quiet_time(receiver_id): return False return True4. 性能优化与扩展策略
随着用户量增长,系统需要应对新的挑战。
4.1 分库分表策略
通知数据通常按用户ID进行分片:
notifications_0 notifications_1 ... notifications_9分片路由规则:
public String determineTableShard(long userId) { int shard = (int) (userId % 10); return "notifications_" + shard; }4.2 冷热数据分离
- 热数据:最近3天的未读通知,存储在Redis
- 温数据:30天内的通知,MySQL主库
- 冷数据:历史归档,MySQL从库或对象存储
4.3 推送降级策略
当系统压力过大时,可依次降级:
- 关闭WebSocket实时推送,改为轮询
- 合并同类通知(如"3个新点赞")
- 延迟非关键通知(如每周摘要)
5. 监控与故障排查
完善的监控体系能帮助快速定位问题。
5.1 关键监控指标
| 指标 | 报警阈值 | 检查项 |
|---|---|---|
| WebSocket连接数 | > 80% 最大负载 | 是否需要扩容 |
| 未送达消息堆积量 | > 10,000 | 消费者是否异常 |
| 通知延迟 | P99 > 3s | 队列处理能力 |
5.2 常见问题排查指南
问题:用户反映收不到点赞通知
排查步骤:
检查事件是否生成
SELECT * FROM notify_event WHERE action='like' AND target_user_id=?;检查用户设置
SELECT * FROM notification_settings WHERE user_id=? AND type='like';检查推送日志
grep "user_id=123" /var/log/push-service.log
问题:WebSocket连接频繁断开
检查项:
Nginx超时配置
proxy_read_timeout 600s; proxy_connect_timeout 60s;客户端心跳是否正常
防火墙会话超时设置
在实际项目中,我们曾遇到因TCP keepalive设置不当导致的连接中断问题。通过调整内核参数解决:
# 调整TCP keepalive参数 sysctl -w net.ipv4.tcp_keepalive_time=300 sysctl -w net.ipv4.tcp_keepalive_intvl=60 sysctl -w net.ipv4.tcp_keepalive_probes=5