站内信系统:从架构到代码,打通推送、存储与高可用

0 阅读8分钟

站内信几乎是所有平台型应用的标配功能。但系统通知、用户私信、已读未读、离线推送……这些需求背后隐藏着不少设计挑战。本文将从接入层到存储层,逐层拆解一个高可用站内信系统的完整实现。


一、为什么写这篇文章?

站内信系统看似简单,就是一个"发消息、收消息"的功能。但当你面对以下场景时,会发现没那么容易:

  • 系统要给 100万用户 同时发一条活动通知
  • 用户需要知道 哪些消息已读、哪些未读
  • 用户在线时要 实时收到 消息,离线时要 上线补发
  • 不能因为发消息把数据库打崩,也不能因为推送把带宽占满

本文将从分层架构出发,结合完整代码,带你实现一个生产可用的站内信系统。


二、整体架构:四层模型

一个成熟的站内信系统,从上到下分为四层:

提示词模板解释图 (1).png

下面逐层展开。


三、接入层:把好第一道门

接入层是系统的"门卫",负责拦截非法请求、控制流量、防止重复提交。

3.1 参数校验

@Data
public class SendMessageRequest {
    @NotNull(message = "发送者ID不能为空")
    private Long senderId;
    
    @NotNull(message = "接收者ID不能为空")
    private Long receiverId;
    
    @NotBlank(message = "消息内容不能为空")
    @Length(max = 500, message = "消息内容不能超过500字")
    private String content;
    
    @NotNull(message = "消息类型不能为空")
    private Integer msgType;
}

@RestController
@Validated
public class MessageController {
    @PostMapping("/send")
    public ResultVO<Void> send(@Valid @RequestBody SendMessageRequest request) {
        // 敏感词过滤(DFA算法)
        if (sensitiveFilter.contains(request.getContent())) {
            throw new BusinessException("内容包含敏感词");
        }
        messageService.send(request);
        return ResultVO.success();
    }
}

3.2 限流:防止单用户刷接口

使用滑动窗口限流,每个用户每秒最多发10条:

@Component
public class MessageRateLimiter {
    public boolean allowSend(Long userId) {
        String key = "ratelimit:message:" + userId;
        return slidingWindowLimit(key, 1, 10);  // 1秒内最多10次
    }
}

3.3 防重提交(幂等性)

用户网络卡顿连续点两次发送,不能生成两条消息:

@Component
public class IdempotentManager {
    public boolean tryAcquire(String uniqueId, int expireSeconds) {
        String key = "idempotent:" + uniqueId;
        // Redis SET NX,成功表示首次请求
        return redisTemplate.opsForValue()
            .setIfAbsent(key, "1", Duration.ofSeconds(expireSeconds));
    }
}

3.4 流量削峰:MQ异步化

问题:系统通知给100万人发消息,瞬时写入量巨大。

解决:接入层只做校验,真正的发送逻辑扔到MQ异步处理。

@Service
public class MessageAccessLayer {
    public void submitSendTask(SendMessageRequest request) {
        String uniqueId = IdGenerator.nextId();
        MessageTask task = new MessageTask(uniqueId, request);
        // 扔到MQ,立即返回
        mqTemplate.send("message_send_topic", JSON.toJSONString(task));
    }
}

四、存储层:核心设计

存储层是站内信系统的心脏,决定了系统的容量和性能。

4.1 公共消息 vs 私有消息

类型存储方式场景
公共消息内容存1份 + 关系表存N条系统通知、活动公告
私有消息收件箱1条 + 发件箱1条用户私信
-- 消息内容表(公共+私有共用)
CREATE TABLE message_content (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    sender_id BIGINT NOT NULL COMMENT '发送者(0=系统)',
    sender_type TINYINT NOT NULL COMMENT '1用户 2系统',
    msg_type TINYINT NOT NULL COMMENT '1私信 2通知',
    content TEXT NOT NULL,
    extra JSON,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    KEY idx_sender (sender_id, created_at)
);

-- 收件箱索引(每个用户的消息列表)
CREATE TABLE inbox_index (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    msg_id BIGINT NOT NULL,
    is_read TINYINT DEFAULT 0,
    read_at DATETIME DEFAULT NULL,
    is_deleted TINYINT DEFAULT 0,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_user_msg (user_id, msg_id),
    KEY idx_user_read (user_id, is_read, created_at)
);

4.2 核心查询:公共+私有一起返回

SELECT 
    m.id, m.sender_id, m.content, m.msg_type,
    i.is_read, i.created_at
FROM inbox_index i
JOIN message_content m ON i.msg_id = m.id
WHERE i.user_id = #{userId} AND i.is_deleted = 0
ORDER BY i.created_at DESC
LIMIT #{offset}, #{size}

4.3 水位线方案:解决已读/未读高并发更新

传统方案的问题:每条消息都存 is_read,用户看一条消息就要 UPDATE 一次,高并发下数据库压力巨大。

水位线方案:只存用户的最后阅读时间,通过时间判断已读/未读。

用户最后阅读时间: 2026-07-18 12:00:00

消息A: 11:00  →  早于水位线 → 已读 ✅
消息B: 12:30  →  晚于水位线 → 未读 ❌
消息C: 12:05  →  晚于水位线 → 未读 ❌
@Service
public class WatermarkService {
    // 获取用户水位线
    public Long getWatermark(Long userId) {
        String key = "watermark:" + userId;
        String cached = redisTemplate.opsForValue().get(key);
        if (cached != null) return Long.parseLong(cached);
        // 从DB读取,回填缓存
        Long dbValue = inboxMapper.getLastReadTime(userId);
        redisTemplate.opsForValue().set(key, String.valueOf(dbValue), 1, TimeUnit.DAYS);
        return dbValue;
    }
    
    // 更新水位线(单调递增)
    public void updateWatermark(Long userId, Long readTime) {
        String key = "watermark:" + userId;
        Long current = getWatermark(userId);
        if (readTime > current) {
            redisTemplate.opsForValue().set(key, String.valueOf(readTime), 1, TimeUnit.DAYS);
        }
    }
    
    // 计算未读数
    public int getUnreadCount(Long userId) {
        Long watermark = getWatermark(userId);
        return inboxMapper.countByTime(userId, watermark);
    }
}
对比维度传统 is_read 方案水位线方案
更新频率每条消息 UPDATE仅最后阅读时间 UPDATE
数据库压力
缓存友好度好(只缓存一个时间戳)

4.4 公共消息的"懒加载"优化

系统给100万人发通知,不真的插入100万条 inbox_index,而是用户打开消息页时才构建

@Service
public class LazyInboxBuilder {
    public void ensureInboxExists(Long userId) {
        List<Long> existing = inboxMapper.selectMsgIdsByUser(userId);
        List<Long> allPublic = publicMessageCache.getAllIds();
        List<Long> missing = allPublic.stream()
            .filter(id -> !existing.contains(id))
            .collect(Collectors.toList());
        if (!missing.isEmpty()) {
            inboxMapper.batchInsert(missing.stream()
                .map(msgId -> new InboxIndex(userId, msgId, 0))
                .collect(Collectors.toList()));
        }
    }
}

五、推送层:在线实时,离线暂存

推送层负责把消息送到用户手里。

5.1 在线状态管理(Redis)

@Component
public class OnlineStatusManager {
    // 用户上线
    public void userOnline(Long userId, String sessionId) {
        String key = "online:" + userId;
        redisTemplate.opsForHash().put(key, "sessionId", sessionId);
        redisTemplate.opsForHash().put(key, "lastActive", String.valueOf(System.currentTimeMillis()));
        redisTemplate.expire(key, 2, TimeUnit.MINUTES);
    }
    
    // 心跳刷新
    public void heartbeat(Long userId) {
        String key = "online:" + userId;
        redisTemplate.opsForHash().put(key, "lastActive", String.valueOf(System.currentTimeMillis()));
        redisTemplate.expire(key, 2, TimeUnit.MINUTES);
    }
    
    // 检查是否在线
    public boolean isOnline(Long userId) {
        String lastActive = (String) redisTemplate.opsForHash().get("online:" + userId, "lastActive");
        if (lastActive == null) return false;
        return System.currentTimeMillis() - Long.parseLong(lastActive) < 60_000;
    }
}

5.2 智能分发:在线推,离线存

@Service
public class PushDistributionService {
    public void distribute(Long userId, MessageDTO message) {
        if (onlineStatusManager.isOnline(userId)) {
            // 在线 → WebSocket 实时推送
            String sessionId = onlineStatusManager.getSessionId(userId);
            webSocketManager.send(sessionId, JSON.toJSONString(message));
        } else {
            // 离线 → 存 Redis 待补发
            offlineMessageManager.save(userId, message);
        }
    }
}

5.3 用户上线补发

@Component
public class OnlineHandler {
    public void onUserOnline(Long userId) {
        onlineStatusManager.userOnline(userId);
        // 补发离线消息
        List<MessageDTO> offlineMessages = offlineMessageManager.fetchAll(userId);
        for (MessageDTO msg : offlineMessages) {
            pushService.distribute(userId, msg);
        }
        // 刷新未读红点
        pushUnreadBadge(userId);
    }
}

5.4 灰度推送

@Component
public class GrayReleaseManager {
    public boolean isInGray(Long userId) {
        // 按用户ID取模,10%用户灰度
        return userId % 10 == 0;
    }
    
    public void grayPush(Long userId, MessageDTO message, String feature) {
        if (isInGray(userId)) {
            pushWithNewFeature(userId, message);   // 灰度用户
        } else {
            pushWithOldFeature(userId, message);   // 普通用户
        }
    }
}

六、查询层:缓存为王

用户查询消息列表和未读数,是读多写少的场景,缓存是核心。

6.1 未读数缓存(红点)

@Component
public class UnreadCacheManager {
    private static final String UNREAD_PREFIX = "unread:count:";
    private static final long CACHE_EXPIRE = 600; // 10分钟
    
    public int getUnreadCount(Long userId) {
        String key = UNREAD_PREFIX + userId;
        String cached = redisTemplate.opsForValue().get(key);
        if (cached != null) return Integer.parseInt(cached);
        
        int count = watermarkService.getUnreadCount(userId);
        redisTemplate.opsForValue().set(key, String.valueOf(count), CACHE_EXPIRE, TimeUnit.SECONDS);
        return count;
    }
    
    // 有新消息时 +1
    public void increment(Long userId) {
        String key = UNREAD_PREFIX + userId;
        redisTemplate.opsForValue().increment(key);
        redisTemplate.expire(key, CACHE_EXPIRE, TimeUnit.SECONDS);
    }
}

6.2 消息列表缓存(最近50条)

@Component
public class MessageListCache {
    public List<MessageDTO> getMessages(Long userId, int page, int size) {
        if (page == 1 && size <= 50) {
            String key = "message:list:" + userId;
            List<String> cached = redisTemplate.opsForList().range(key, 0, -1);
            if (cached != null && !cached.isEmpty()) {
                return cached.stream().map(JSON::parseObject).collect(Collectors.toList());
            }
        }
        // 缓存未命中,查DB并回填
        List<MessageDTO> list = messageMapper.selectInboxByUser(userId, page, size);
        if (page == 1 && !list.isEmpty()) {
            String key = "message:list:" + userId;
            redisTemplate.delete(key);
            list.forEach(msg -> redisTemplate.opsForList().rightPush(key, JSON.toJSONString(msg)));
            redisTemplate.expire(key, 5, TimeUnit.MINUTES);
        }
        return list;
    }
}

七、高可用保障

7.1 幂等 + 重试

每条消息带唯一ID,防止MQ重复消费:

@Component
public class IdempotentConsumer {
    public void onMessage(MessageTask task) {
        String uniqueId = task.getUniqueId();
        // 幂等检查
        if (redisTemplate.hasKey("processed:" + uniqueId)) {
            return; // 已处理,跳过
        }
        try {
            doSend(task);
            redisTemplate.opsForValue().set("processed:" + uniqueId, "1", 7, TimeUnit.DAYS);
        } catch (Exception e) {
            throw e; // 抛出异常触发MQ重试
        }
    }
}

7.2 降级机制

系统压力大时,主动降级非核心功能:

@Component
public class DegradeManager {
    public boolean isDegrade() {
        return cpuUsage > 80 || mqBacklog > 100000;
    }
    
    public void degrade() {
        // 关闭实时推送,只存离线
        // 关闭已读状态更新
        // 限制消息长度
    }
}

7.3 监控告警

监控项告警阈值
发送失败率> 1%
推送失败率> 5%
MQ积压> 10万
未读数缓存命中率< 80%

7.4 消息过期归档

每天凌晨迁移3个月前的数据:

@Component
public class MessageArchiveTask {
    @Scheduled(cron = "0 0 3 * * ?")
    public void archive() {
        // 迁移到归档表
        messageMapper.archiveOldMessages(3); // 3个月前
    }
}

八、完整流程串联

用户A发私信给用户B
    │
    ▼
【接入层】
① 参数校验 + 敏感词过滤
② 限流检查(1秒10条)
③ 防重检查(clientUniqueId)
④ 生成uniqueId,扔到MQ
    │
    ▼
【存储层】(MQ消费者)
⑤ 幂等检查(uniqueId是否已处理)
⑥ 写入 message_content
⑦ 写入 inbox_index(B收件箱)
⑧ 写入 outbox_index(A发件箱)
⑨ 标记为已处理
    │
    ▼
【推送层】
⑩ 检查B是否在线
    ├─ 在线 → WebSocket推送 + 未读数+1
    └─ 离线 → 存Redis离线列表
    │
    ▼
【查询层】
⑪ B打开消息列表 → 从Redis缓存读取
⑫ B查看消息 → 更新水位线(最后阅读时间)
⑬ 未读数通过水位线重新计算

九、总结

层级核心职责关键技术
接入层拦截非法请求、控制流量校验、限流、MQ削峰、幂等
存储层存消息 + 用户关系公共/私有分离、水位线、懒加载
推送层实时送达 + 离线补发WebSocket、灰度、离线缓存
查询层快速读取消息Redis缓存、未读数缓存

核心设计理念

  1. 写入异步化:所有写操作通过MQ削峰填谷
  2. 读取缓存化:读操作尽量走Redis,DB只做最终存储
  3. 状态水位线化:用最后阅读时间代替逐条已读标记
  4. 消息懒加载化:公共消息等用户真正需要时才构建关系