消息通知系统
最近在给自己的博客系统补一些互动功能,比如点赞、收藏、关注之类的。
做着做着就发现一个问题:用户其实不知道别人对他做了什么。
比如:
- 有人点赞了你的文章
- 有人收藏了你的文章
- 有人关注了你
- 或者有人给你发了私信
如果没有通知系统,这些行为基本就是“悄悄发生”,用户完全感知不到。
所以就顺手把消息通知模块也做了一遍。这篇文章主要记录一下整个实现过程,以及中间踩的一些坑。
一、先想清楚通知是什么
一开始我其实想过几种设计,比如:
- 点赞通知一张表
- 评论通知一张表
- 关注通知一张表
但很快发现这样会非常麻烦。
更简单的方式其实是把通知抽象成一条行为记录:
某个用户对另一个用户做了一件事情。
于是通知结构其实只需要几个关键字段:
- 通知接收者
- 操作发起者
- 操作类型
- 是否已读
- 创建时间
- (如果涉及文章)文章 ID
于是最后设计了一张 notifications 表。
1 | CREATE TABLE notifications ( |
这里有两个字段比较关键。
user_id
表示通知接收者。
比如:
“A 给 B 点赞”
这里 user_id = B。
actor_id
表示触发行为的人。
同样这个例子里:
actor_id = A。
type
通知类型,用来区分不同通知。
目前我的系统里大概有几种:
likefavoritecommentfollowprivate_message
这种设计的好处是以后扩展会很方便,比如以后想加:
- 系统通知
- 管理员警告
- 评论回复提醒
都可以直接扩展 type。
二、通知是在哪生成的
通知本身其实只是一个附属功能,它一定是伴随着其他业务产生的。
比如:
- 点赞成功
- 评论成功
- 关注成功
所以我的做法是把通知逻辑统一封装在一个 NotificationService 里。
各个业务模块只需要在操作成功之后调用它。
比如点赞文章的时候:
1 | notificationService.createNotification( |
收藏、关注基本也是同样的逻辑。
这样做的好处是:
- 通知逻辑和业务逻辑是解耦的
- 以后如果要改通知系统,只需要改一个地方
三、未读通知计数
通知列表其实很好做,分页查数据库就行。
真正麻烦的是未读通知数。
因为很多地方都会显示它,比如:
- 页面右上角的小红点
- 消息中心
- 移动端提示
如果每次都查数据库:
countByUserIdAndIsReadFalse(userId)
在用户量大的情况下其实挺浪费资源的。
所以我给未读计数加了一层 Redis 缓存。
逻辑很简单:
- 先查 Redis
- 没命中再查数据库
- 查到结果写回 Redis
代码大概是这样:
1 | public long getUnreadCount(Long userId) { |
这样大部分请求都会直接命中缓存。
四、缓存更新策略
缓存加上去之后,还要解决一个问题:什么时候更新 Redis?
我的策略是:
新通知产生的时候:
Redis count +1,但前提是缓存已经存在。
1 | if (redisUtil.hasKey(countKey)) { |
如果缓存不存在就不管,因为下次查询会重新建立缓存。
而当通知被标记为已读时,我一开始的想法是:
count -1
但后来发现这个方案在并发情况下其实容易出问题。
比如:
- 用户同时标记多条通知
- 或者新通知和标记已读同时发生
最后可能导致计数不准确。
所以最后我选择了一个更简单的方法:
直接删除缓存。
1 | redisUtil.delete(countKey); |
这样下次查询时会重新统计数据库。
虽然多查了一次数据库,但逻辑会简单很多,也更安全。
五、数据库索引
通知列表是一个比较典型的查询:
1 | WHERE user_id = ? |
另外一个高频查询是:
1 | WHERE user_id = ? |
所以我给表加了一个联合索引:
1 | INDEX idx_user_read (user_id, is_read, created_at DESC) |
这个索引基本可以覆盖:
- 通知列表查询
- 未读通知查询
- 未读数量统计
效果还是挺明显的。
六、一个小坑
功能写完之后测试的时候,我发现一个很奇怪的现象:
我给自己的文章点赞,系统也给我发了一条通知。
技术上没问题,但体验非常奇怪。
后来就在创建通知的时候加了一句判断:
1 | if (userId.equals(actorId)) { |
这样就不会出现自己通知自己的情况了。
七、如果以后要升级
现在这个通知系统其实是一个比较基础的实现,如果继续完善的话还有不少可以做的。
比如:
1. 实时通知
现在前端是每隔一段时间轮询接口。
如果用 WebSocket,其实可以做到真正的实时推送。
2. 消息队列
现在通知是同步创建的。
如果以后并发比较高,可以把通知放到 MQ 里异步处理。
流程会变成:
用户操作
↓
发送通知事件
↓
消息队列
↓
通知服务消费
↓
写入数据库
这样可以减少主业务的延迟。
