站内信数据结构应以messages表为核心,含id、sender_id(0表示系统)、receiver_id、title、content、status(0未读/1已读/2已删除)、created_at及read_at;receiver_id需建索引,复合索引(receiver_id, status, created_at)优化查询;未读数用Redis HINCRBY维护,DB与Redis不一致时通过定时补偿任务修复;发送前须校验用户有效性、通知开关及敏感词;已读操作应限流、幂等并异步落库,接受最终一致性。

站内信的数据结构怎么设计才不容易翻车
站内信本质是「用户到用户」或「系统到用户」的轻量通知,不是 IM。别一上来就搞 WebSocket 或消息队列——90% 的中小项目用不到。messages 表字段必须包含:id、sender_id(可为 0 表示系统)、receiver_id、title、content、status(0=未读,1=已读,2=已删除)、created_at。别加 updated_at,状态变更用 status 和独立的 read_at 字段更清晰。
常见错误:把「已删除」和「彻底清除」混为一谈。用户删信只是设 status = 2,后台定时任务再物理清理;否则撤回、恢复功能没法做。
-
receiver_id必须建索引,查询未读数、列表都靠它 - 复合索引
(receiver_id, status, created_at)能加速「某用户未读信列表」查询 - 别用 JSON 字段存扩展数据(比如跳转链接、图标类型),拆成
extra_dataTEXT 或单独字段更易查、好维护
如何高效查未读数并避免 N+1 查询
首页右上角小红点,不能每次渲染都 SELECT COUNT(*) FROM messages WHERE receiver_id = ? AND status = 0——高并发下 DB 压力大,且没缓存时延迟明显。
推荐做法:用 Redis 的 HINCRBY 维护每个用户的未读计数。发信时:redis.HIncrBy(ctx, "user:unread", receiverID, 1);标为已读时:redis.HIncrBy(ctx, "user:unread", receiverID, -1)。注意事务边界:DB 写成功后再更新 Redis,失败则重试或告警。
立即学习“go语言免费学习笔记(深入)”;
初始化并管理阿里云SDK Go 客户端,涵盖 sync.Once 单例、goroutine 安全、endpoint 与 region 配置、VPC 端点及调试。
- Redis 计数和 DB 不一致?加个补偿任务,每天扫描
status = 0但 Redis 计数为 0 的用户,重新同步 - 别用
INCR单 key 存所有用户未读数——key 冲突、无法分片、不支持批量查 - 用户登出或长时间未操作?不主动清 Redis 计数,等下次登录/拉取时自动对齐即可
发送逻辑里最容易漏掉的三个检查点
写一个 SendNotification() 函数,光插入 DB 不够。真实业务中常踩的坑在边界:
- 检查
receiver_id是否真实存在且未被禁用(查users表status != 'banned'),否则发一堆死信还占存储 - 检查是否开启「接收系统通知」开关(用户个人设置表里
notify_system字段),默认关闭就别硬塞 - 敏感词过滤必须同步执行——别依赖前端或异步 job,否则脏数据进库后难追溯。用
github.com/wangbin/jiebago+ 本地词库,响应快、无外网依赖
示例片段:
if !user.IsActive() { return errors.New("user banned") }<br>if !user.AllowSystemNotify && senderID == 0 { return errors.New("system notify disabled") }<br>if hasSensitiveWord(content) { return errors.New("content blocked") }
已读/批量已读接口为什么总被刷爆
MarkAsRead(userID, messageID) 这类接口,看似简单,但上线后常被恶意脚本高频调用,导致 DB update 压力飙升,甚至拖慢主业务。
根本解法不是加锁,而是限流 + 幂等 + 异步落库。实际做法:MarkAsRead 只写 Redis(SET user:read:message:{id} 1 EX 3600),同时发消息到本地 channel 触发异步 DB 更新;前端轮询或 WebSocket 推送已读状态,DB 更新失败不影响用户体验。
- 别在 HTTP handler 里直接
UPDATE messages SET status = 1—— 慢查询会卡住整个 goroutine - 批量标记(如「全部已读」)必须走
WHERE receiver_id = ? AND status = 0,别用 IN 列表传几千个 ID - Redis 缓存已读状态后,列表接口要联合查询:
SELECT * FROM messages WHERE receiver_id = ? AND (status != 0 OR EXISTS (SELECT 1 FROM redis_read_cache WHERE ...))—— 实际用 LEFT JOIN 或应用层 merge
最常被忽略的是「已读状态的最终一致性窗口」。用户点一下立刻看到红点消失,但 DB 里可能 2 秒后才更新——只要业务允许这个延迟(绝大多数站内信场景都允许),就别强求实时落库。

















