站内信推送系统,作为现代Web应用的核心交互组件,其设计直接关系到用户体验和系统稳定性。一个健壮的站内信系统,不仅要能可靠地送达消息,还要应对高并发写入、海量历史数据存储、实时推送以及多样化的业务场景。今天,我们就来深入拆解一个站内信推送系统的核心设计与实现方案,重点聚焦于如何从零开始构建一个支持高并发、可扩展、具备实时能力的生产级系统。
本文将带你从架构选型、数据库设计、核心逻辑实现,一直走到实时推送集成与性能优化。无论你是要为一个快速发展的社区、一个复杂的SaaS平台,还是一个需要内部通知的企业系统设计消息中心,这里提供的思路和代码示例都能为你提供直接的参考。我们会重点关注系统的核心能力、技术栈选择、数据库表结构设计、API接口规范,以及如何通过消息队列和WebSocket应对高并发场景。
1. 核心能力速览
在设计之初,我们需要明确系统必须提供哪些核心能力。下表概括了一个成熟站内信系统应具备的关键特性:
| 能力项 | 说明与设计目标 |
|---|---|
| 消息类型 | 支持系统通知、用户私信、公告广播、业务提醒(如评论回复、订单状态更新)等多种类型,并易于扩展。 |
| 发送模式 | 支持单发、批量发送、全站广播。批量发送需考虑性能,避免循环插入。 |
| 消息状态 | 完备的状态流转:待发送->已发送->已读/未读->已删除。支持发送失败重试机制。 |
| 存储与查询 | 支持海量消息的历史存储,并能按用户、类型、时间等维度高效分页查询。需考虑冷热数据分离。 |
| 实时推送 | 用户在线时,新消息能通过WebSocket等长连接技术实时推送到前端,避免轮询开销。 |
| 多端同步 | 用户在不同设备(Web、APP)上登录,消息的已读/未读状态应保持同步。 |
| 性能与扩展 | 核心发送接口需支持高QPS,通过异步化、消息队列、数据库分库分表等手段实现水平扩展。 |
| 管理功能 | 提供管理后台,支持消息模板管理、发送记录查看、错误日志排查等。 |
2. 适用场景与使用边界
适合谁?
- 社区与论坛:用户间的私信交流、系统通知(如帖子被加精、收到回复)。
- 电商平台:订单状态变更通知、促销活动提醒、客服消息。
- SaaS与协作工具:任务分配通知、文档协作@提醒、审批流程通知。
- 企业内部系统:公告发布、待办事项提醒、系统报警信息集成。
能解决什么问题?
- 提升用户粘性:及时、精准的消息触达是提升用户活跃度和留存的关键。
- 替代部分外部推送:对于非紧急或用户偏好设置内的通知,站内信是比短信、邮件更轻量、成本更低的选择。
- 构建统一消息中心:将散落在各业务模块的通知收口,提供一致的用户体验和管理视角。
不适合什么场景?
- 强实时、高可靠的通信:如在线聊天、IM,这类场景对延迟和消息必达性要求极高,通常需要更专业的IM架构。
- 离线用户的长效触达:用户长时间不登录,站内信无法触达,需结合推送(Push)或邮件。
- 海量、非结构化的流式数据:如新闻Feed、动态流,更适合用Timeline或Feeds流架构。
安全与合规边界:
- 隐私保护:用户私信内容必须加密存储,并在传输中使用HTTPS。非管理员不得查看他人消息。
- 内容审核:对于用户生成内容的私信,需建立反垃圾和审核机制,避免传播不良信息。
- 数据清理:应提供消息自动清理策略(如只保留最近N天),并符合数据隐私法规(如GDPR)的“被遗忘权”要求。
3. 技术选型与环境准备
一个典型的站内信系统技术栈分为以下几层:
后端核心 (Java/Spring Boot 示例)
- 框架: Spring Boot 2.7+ (提供快速开发能力)
- 数据库: MySQL 8.0 (主存储) + Redis 7.0 (缓存与实时状态)
- 消息队列: RabbitMQ 或 Apache Kafka (用于异步解耦发送任务)
- 实时推送: Netty 或 Spring WebSocket / Socket.IO (Node.js)
- ORM: MyBatis-Plus 或 Spring Data JPA
前端 (Web)
- 框架: Vue 3 / React 18
- 实时通信: Socket.IO-client 或原生 WebSocket API
环境准备清单
- JDK: 版本 11 或 17。
- Maven或Gradle: 用于项目管理。
- MySQL: 安装并创建数据库,如
message_center。 - Redis: 安装并启动服务,用于存储用户连接映射和未读计数。
- 消息队列 (可选但推荐): 安装RabbitMQ,用于解耦消息发送流程。
- IDE: IntelliJ IDEA 或 VS Code。
4. 数据库设计:表结构详解
数据库设计是系统的基石,核心表通常包括站内信表、用户-消息关联表。采用“内容与关系分离”的设计是常见最佳实践。
4.1 消息内容表 (message)此表存储消息的通用内容,一条内容可能对应多个接收者(如公告)。
CREATE TABLE `message` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `title` varchar(255) DEFAULT '' COMMENT '消息标题', `content` text COMMENT '消息内容(支持富文本/JSON)', `type` tinyint(4) NOT NULL COMMENT '消息类型: 1-系统通知 2-用户私信 3-公告 4-业务提醒...', `sender_id` bigint(20) DEFAULT NULL COMMENT '发送者ID (系统消息可为NULL或0)', `sender_name` varchar(100) DEFAULT '' COMMENT '发送者名称(冗余,避免联查)', `extra_data` json DEFAULT NULL COMMENT '扩展数据,如跳转链接、业务ID等', `is_broadcast` tinyint(1) DEFAULT '0' COMMENT '是否为广播消息', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_sender` (`sender_id`), KEY `idx_type_createtime` (`type`,`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息内容表';4.2 用户消息关系表 (user_message)此表存储消息与接收者的关系,以及接收者的状态。这是查询最频繁的表,需重点设计索引。
CREATE TABLE `user_message` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `user_id` bigint(20) NOT NULL COMMENT '接收用户ID', `message_id` bigint(20) NOT NULL COMMENT '消息内容ID', `is_read` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已读: 0-未读 1-已读', `read_time` datetime DEFAULT NULL COMMENT '阅读时间', `is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否被用户删除', `folder` tinyint(4) DEFAULT '1' COMMENT '文件夹: 1-收件箱 2-已发送 3-草稿箱 (对于发送者)', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_message` (`user_id`,`message_id`), -- 防止重复接收 KEY `idx_user_read_deleted` (`user_id`,`is_read`,`is_deleted`,`create_time`), -- 核心查询索引 KEY `idx_message_id` (`message_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户-消息关系表';核心索引idx_user_read_deleted覆盖了最常见的查询场景:“查询某个用户的未读/全部消息,按时间倒序”。
4.3 未读计数缓存 (Redis)为了快速获取用户未读消息数,避免频繁COUNT数据库,我们使用Redis缓存。
- Key格式:
user:unread_count:{userId} - Value: 整型数字。当用户收到新消息时
INCR,标记已读时DECR。
5. 核心发送逻辑设计与实现
消息发送是核心业务,必须保证可靠性与性能。我们采用“写扩散”模式,并结合消息队列进行异步化处理。
5.1 发送接口设计
// MessageSendDTO.java @Data public class MessageSendDTO { @NotNull(message = "消息类型不能为空") private Integer type; private String title; @NotBlank(message = "消息内容不能为空") private String content; private Long senderId; // 可为空,系统消息 private List<Long> receiverIds; // 接收者ID列表,空列表表示广播 private JSONObject extraData; // 扩展信息 } // MessageController.java @RestController @RequestMapping("/api/message") public class MessageController { @Autowired private MessageService messageService; @PostMapping("/send") public Result<Void> sendMessage(@Valid @RequestBody MessageSendDTO dto) { // 1. 参数校验与风控(如发送频率限制) // 2. 调用异步发送服务 messageService.asyncSend(dto); return Result.success("消息发送处理中"); } }5.2 异步发送服务实现发送流程被拆分为两个步骤,通过消息队列解耦。
// MessageServiceImpl.java @Service @Slf4j public class MessageServiceImpl implements MessageService { @Autowired private RabbitTemplate rabbitTemplate; @Override @Transactional(rollbackFor = Exception.class) public void asyncSend(MessageSendDTO dto) { // 步骤1: 持久化消息内容 (快速写入主表) Message message = new Message(); BeanUtils.copyProperties(dto, message); message.setIsBroadcast(CollectionUtils.isEmpty(dto.getReceiverIds())); messageMapper.insert(message); Long messageId = message.getId(); // 步骤2: 构造发送任务,投入消息队列 MessageTask task = new MessageTask(); task.setMessageId(messageId); task.setReceiverIds(dto.getReceiverIds()); task.setType(dto.getType()); rabbitTemplate.convertAndSend("message.exchange", "message.send.task", JSON.toJSONString(task)); log.info("消息发送任务已投递,消息ID: {}", messageId); } }5.3 消息消费者处理关联关系一个独立的消费者服务从队列中取出任务,处理耗时的用户-关系记录插入。
// MessageTaskConsumer.java @Component @Slf4j public class MessageTaskConsumer { @Autowired private UserMessageService userMessageService; @Autowired private RedisTemplate<String, Object> redisTemplate; @RabbitListener(queues = "message.send.queue") public void handleSendTask(String taskJson) { MessageTask task = JSON.parseObject(taskJson, MessageTask.class); Long messageId = task.getMessageId(); List<Long> receiverIds = task.getReceiverIds(); // 批量插入用户-消息关系 if (CollectionUtils.isEmpty(receiverIds)) { // 广播逻辑:获取所有活跃用户ID(这里需要从用户服务获取,或提前维护列表) // receiverIds = getAllActiveUserIds(); } // 使用MyBatis-Plus的批量插入 List<UserMessage> userMessages = receiverIds.stream().map(userId -> { UserMessage um = new UserMessage(); um.setUserId(userId); um.setMessageId(messageId); um.setIsRead(0); return um; }).collect(Collectors.toList()); userMessageService.saveBatch(userMessages); // 更新Redis未读计数 for (Long userId : receiverIds) { String key = "user:unread_count:" + userId; redisTemplate.opsForValue().increment(key, 1); } // 触发实时推送 (下一节实现) notifyNewMessage(receiverIds, messageId); log.info("消息关系处理完成,messageId: {}, 接收者数量: {}", messageId, receiverIds.size()); } }通过这种异步设计,发送接口可以快速响应,将耗时操作留给后台消费者,极大提升了接口吞吐量。
6. 实时推送集成:WebSocket实战
为了实现新消息的实时提醒,我们需要集成WebSocket。这里使用Spring Boot内置的WebSocket支持。
6.1 WebSocket配置与处理器
// WebSocketConfig.java @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Autowired private MessageWebSocketHandler handler; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(handler, "/ws/message") .setAllowedOrigins("*") // 生产环境应配置具体域名 .addInterceptors(new HttpSessionHandshakeInterceptor()); } } // MessageWebSocketHandler.java @Component public class MessageWebSocketHandler extends TextWebSocketHandler { // 维护在线用户连接映射:userId -> WebSocketSession private static final ConcurrentHashMap<Long, WebSocketSession> userSessionMap = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { Long userId = getUserIdFromSession(session); // 从Session属性中获取用户ID(需在拦截器中设置) if (userId != null) { userSessionMap.put(userId, session); log.info("用户 {} WebSocket连接建立,当前在线用户数: {}", userId, userSessionMap.size()); } } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { // 处理客户端发来的心跳或指令 String payload = message.getPayload(); // 例如:心跳包处理 {"type":"ping"} -> {"type":"pong"} } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { Long userId = getUserIdFromSession(session); if (userId != null) { userSessionMap.remove(userId); log.info("用户 {} WebSocket连接关闭,当前在线用户数: {}", userId, userSessionMap.size()); } } // 提供给外部调用的推送方法 public void sendMessageToUser(Long userId, String messageJson) { WebSocketSession session = userSessionMap.get(userId); if (session != null && session.isOpen()) { try { session.sendMessage(new TextMessage(messageJson)); } catch (IOException e) { log.error("向用户 {} 推送WebSocket消息失败", userId, e); } } } }6.2 在消息消费者中触发推送修改之前的MessageTaskConsumer,在消息关系入库后调用推送。
// 在MessageTaskConsumer的handleSendTask方法末尾添加 private void notifyNewMessage(List<Long> receiverIds, Long messageId) { // 构造推送消息体 Map<String, Object> pushMsg = new HashMap<>(); pushMsg.put("type", "NEW_MESSAGE"); pushMsg.put("messageId", messageId); pushMsg.put("timestamp", System.currentTimeMillis()); String messageJson = JSON.toJSONString(pushMsg); // 遍历接收者,向在线用户推送 for (Long userId : receiverIds) { messageWebSocketHandler.sendMessageToUser(userId, messageJson); } }6.3 前端连接与监听前端在用户登录后建立WebSocket连接,并监听新消息事件。
// message-websocket.js class MessageWebSocket { constructor(userId) { this.ws = null; this.userId = userId; this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; } connect() { const wsUrl = `ws://${window.location.host}/ws/message`; this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { console.log('消息WebSocket连接已建立'); this.reconnectAttempts = 0; // 发送身份标识(实际项目中,身份验证通常在连接时通过URL参数或首条消息完成) this.send({ type: 'auth', userId: this.userId }); }; this.ws.onmessage = (event) => { const data = JSON.parse(event.data); this.handleMessage(data); }; this.ws.onclose = () => { console.log('消息WebSocket连接关闭'); this.scheduleReconnect(); }; this.ws.onerror = (error) => { console.error('消息WebSocket错误:', error); }; } handleMessage(data) { switch(data.type) { case 'NEW_MESSAGE': // 收到新消息,更新未读计数,并显示桌面通知或播放提示音 console.log('收到新消息,ID:', data.messageId); this.updateUnreadCount(); this.showNotification('您有一条新消息'); break; case 'pong': // 心跳响应 break; default: console.log('收到未知类型消息:', data); } } updateUnreadCount() { // 调用API获取最新的未读计数并更新UI fetch('/api/message/unread-count') .then(res => res.json()) .then(result => { if(result.success) { // 更新页面上的未读小红点 document.getElementById('unread-badge').innerText = result.data; } }); } send(data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); } } scheduleReconnect() { if (this.reconnectAttempts < this.maxReconnectAttempts) { this.reconnectAttempts++; const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); // 指数退避 console.log(`${delay}ms后尝试第${this.reconnectAttempts}次重连...`); setTimeout(() => this.connect(), delay); } } } // 在用户登录后初始化 // const wsClient = new MessageWebSocket(currentUserId); // wsClient.connect();7. 消息查询与状态更新API
7.1 分页查询用户消息列表
// MessageController.java @GetMapping("/list") public Result<PageResult<MessageVO>> getMessageList( @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "20") Integer pageSize, @RequestParam(required = false) Integer type, @RequestParam(defaultValue = "0") Integer isRead) { // 0-全部 1-未读 2-已读 Long currentUserId = getCurrentUserId(); // 从安全上下文获取 Page<UserMessage> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<UserMessage> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(UserMessage::getUserId, currentUserId) .eq(UserMessage::getIsDeleted, 0) .orderByDesc(UserMessage::getCreateTime); if (type != null) { // 需要联查message表,这里简化处理。实际可考虑冗余type字段到user_message,或使用JOIN。 wrapper.inSql(UserMessage::getMessageId, "SELECT id FROM message WHERE type = " + type); } if (isRead == 1) { wrapper.eq(UserMessage::getIsRead, 0); } else if (isRead == 2) { wrapper.eq(UserMessage::getIsRead, 1); } Page<UserMessage> userMessagePage = userMessageService.page(page, wrapper); // 将UserMessage与Message关联,组装成VO列表... return Result.success(pageResult); }7.2 标记消息为已读
@PostMapping("/mark-read") public Result<Void> markAsRead(@RequestBody MessageReadDTO dto) { // dto: { messageIds: [1,2,3], isAll: false } Long userId = getCurrentUserId(); if (Boolean.TRUE.equals(dto.getIsAll())) { // 标记所有未读为已读 LambdaUpdateWrapper<UserMessage> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(UserMessage::getUserId, userId) .eq(UserMessage::getIsRead, 0) .set(UserMessage::getIsRead, 1) .set(UserMessage::getReadTime, new Date()); userMessageService.update(wrapper); // 重置Redis未读计数为0 redisTemplate.opsForValue().set("user:unread_count:" + userId, 0); } else { List<Long> messageIds = dto.getMessageIds(); if (!CollectionUtils.isEmpty(messageIds)) { // 批量更新指定消息 LambdaUpdateWrapper<UserMessage> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(UserMessage::getUserId, userId) .in(UserMessage::getMessageId, messageIds) .set(UserMessage::getIsRead, 1) .set(UserMessage::getReadTime, new Date()); userMessageService.update(wrapper); // 更新Redis未读计数 (原子递减) Long decrementCount = userMessageService.count(new LambdaQueryWrapper<UserMessage>() .eq(UserMessage::getUserId, userId) .in(UserMessage::getMessageId, messageIds) .eq(UserMessage::getIsRead, 0)); if (decrementCount > 0) { redisTemplate.opsForValue().decrement("user:unread_count:" + userId, decrementCount); } } } return Result.success(); }8. 性能优化与高并发设计
当用户量激增时,原始设计可能遇到瓶颈。以下是关键的优化方向:
8.1 数据库分库分表
- 分库:按用户ID哈希或取模,将不同用户的消息数据分布到不同的数据库实例。
- 分表:对
user_message表按用户ID或时间进行水平分表(如按月分表)。可以使用ShardingSphere等中间件。
8.2 读写分离与缓存深化
- 读写分离:将消息列表查询等读请求路由到从库,减轻主库压力。
- 热点数据缓存:除了未读计数,可以将用户最近N条消息概要缓存到Redis,加速首页加载。
// 查询时先查缓存 String cacheKey = "user:recent_messages:" + userId + ":page_" + pageNum; String cached = redisTemplate.opsForValue().get(cacheKey); if (StringUtils.isNotBlank(cached)) { return JSON.parseObject(cached, MessageListVO.class); } // 缓存未命中则查库,并写入缓存(设置5分钟过期)8.3 消息队列削峰填谷
- 在大型活动(如全站公告)时,瞬间会产生海量发送任务。使用Kafka等高性能消息队列,确保任务不丢失,消费者平稳处理。
- 可以设置多个消费者组,并行处理不同用户分片的消息。
8.4 WebSocket连接管理优化
- 单机连接数有限,需考虑集群部署。解决方案:
- Sticky Session:通过负载均衡器(如Nginx的
ip_hash)将同一用户的请求固定到同一台服务器,该服务器维护其WebSocket连接。 - Redis Pub/Sub广播:每台服务器将用户连接关系同步到Redis。当A服务器需要向用户推送时,如果用户连接在B服务器,则通过Redis Pub/Sub通知B服务器进行推送。
- Sticky Session:通过负载均衡器(如Nginx的
// 集群推送示例(使用Redis Pub/Sub) @Component public class ClusterPushService { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private MessageWebSocketHandler localHandler; @PostConstruct public void init() { // 订阅集群推送频道 redisTemplate.getConnectionFactory().getConnection().subscribe( (message, pattern) -> { ClusterPushDTO dto = JSON.parseObject(message.toString(), ClusterPushDTO.class); if (!dto.getSourceServerId().equals(currentServerId)) { // 消息来自其他服务器,本地执行推送 localHandler.sendMessageToUser(dto.getUserId(), dto.getMessageJson()); } }, "cluster:push:channel".getBytes()); } public void pushToUser(Long userId, String messageJson) { // 先尝试本地推送 if (!localHandler.sendMessageToUser(userId, messageJson)) { // 本地未找到连接,向集群广播 ClusterPushDTO dto = new ClusterPushDTO(currentServerId, userId, messageJson); redisTemplate.convertAndSend("cluster:push:channel", JSON.toJSONString(dto)); } } }9. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 发送消息接口超时 | 1. 同步插入大量user_message记录。2. 数据库CPU/IO瓶颈。 3. 未使用消息队列。 | 查看接口日志和数据库慢查询日志。监控服务器资源。 | 引入消息队列,将关系插入异步化。优化数据库索引。 |
| 用户未读计数不准 | 1. Redis缓存与数据库不一致。 2. 并发更新导致计数错误。 | 核对Redis key值与数据库COUNT结果。检查更新计数的事务逻辑。 | 使用Redis的INCR/DECR原子操作。定期用数据库计数校正缓存(如每天一次)。 |
| WebSocket连接频繁断开 | 1. 网络不稳定或代理超时。 2. 服务端未处理心跳,连接被中间设备断开。 | 检查前端网络状态。查看服务端连接空闲超时设置。 | 前端实现心跳机制(如每30秒发送ping)。服务端配置合理的超时时间。使用WSS(WebSocket Secure)。 |
| 广播消息发送慢 | 全量用户循环插入,性能差。 | 分析发送任务的执行时间。 | 对于全站广播,可优化为:只插入一条广播消息内容,用户查询时动态关联。或采用分批次异步发送。 |
| 消息列表查询慢 | 1.user_message表数据量过大。2. 缺少有效索引。 3. 联查 message表导致性能低下。 | 使用EXPLAIN分析SQL执行计划。 | 实施分表。确保idx_user_read_deleted索引存在。考虑将message表的type,title等常用查询字段冗余到user_message表。 |
| 生产环境收不到实时推送 | 1. 生产环境为多机部署,WebSocket连接未集群同步。 2. 防火墙/安全组未开放WebSocket端口。 | 检查用户连接在哪台服务器。测试服务器间的网络连通性。 | 实现基于Redis Pub/Sub的集群推送机制。确保负载均衡器支持WebSocket协议升级。 |
10. 最佳实践与部署建议
- 灰度与监控:任何新的消息类型或大规模发送任务,应先对小部分用户灰度。关键指标(发送成功率、推送到达率、接口延迟、未读计数误差)需接入监控告警。
- 数据库清理策略:制定数据归档策略。例如,将6个月前的
user_message记录迁移到历史表,或定期物理删除已删除(is_deleted=1)的消息,避免主表无限膨胀。 - 客户端兼容与降级:前端检测浏览器是否支持WebSocket,若不支持,自动降级为长轮询(Long Polling)方式获取新消息。确保核心功能可用。
- 消息模板与国际化:对于系统通知,建议使用模板引擎,将内容与变量分离,并支持多语言。例如,模板
“您的订单#{orderNo}已发货”,变量从extra_data中获取。 - 幂等性设计:消息发送接口应保证幂等,防止因网络重试导致用户收到重复消息。可在消息内容表中增加唯一键约束(如
业务类型+业务ID+发送者+接收者),或让客户端传递唯一请求ID。 - 安全加固:
- 权限校验:发送私信前,校验发送者与接收者是否存在好友关系或是否允许接收。
- 频率限制:对用户发送消息进行限流(如每秒1条),防止恶意刷消息。
- 内容安全:集成文本内容过滤服务,对用户发送的私信内容进行实时检测。
构建一个高可用的站内信系统,关键在于理解其“存储-关系-推送”的核心模型,并针对性能瓶颈点(如关系写入、实时推送、海量查询)进行分层优化。从简单的单表设计出发,随着业务增长,逐步引入消息队列、缓存、分库分表、集群推送等架构组件。本文提供的方案是一个坚实的起点,你可以根据自身业务的特定需求(如消息的永久存储要求、实时性要求、用户规模)进行调整和扩展。建议在项目初期就采用异步发送和WebSocket实时推送的设计,这将为未来的平滑扩展打下良好基础。