站内信几乎是所有平台型应用的标配功能。但系统通知、用户私信、已读未读、离线推送……这些需求背后隐藏着不少设计挑战。本文将从接入层到存储层,逐层拆解一个高可用站内信系统的完整实现。
一、为什么写这篇文章?
站内信系统看似简单,就是一个"发消息、收消息"的功能。但当你面对以下场景时,会发现没那么容易:
- 系统要给 100万用户 同时发一条活动通知
- 用户需要知道 哪些消息已读、哪些未读
- 用户在线时要 实时收到 消息,离线时要 上线补发
- 不能因为发消息把数据库打崩,也不能因为推送把带宽占满
本文将从分层架构出发,结合完整代码,带你实现一个生产可用的站内信系统。
二、整体架构:四层模型
一个成熟的站内信系统,从上到下分为四层:
下面逐层展开。
三、接入层:把好第一道门
接入层是系统的"门卫",负责拦截非法请求、控制流量、防止重复提交。
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缓存、未读数缓存 |
核心设计理念:
- 写入异步化:所有写操作通过MQ削峰填谷
- 读取缓存化:读操作尽量走Redis,DB只做最终存储
- 状态水位线化:用最后阅读时间代替逐条已读标记
- 消息懒加载化:公共消息等用户真正需要时才构建关系