最近在给自己的博客系统补一些互动功能,比如点赞、收藏、关注之类的。
做着做着就发现一个问题:用户其实不知道别人对他做了什么。

比如:

  • 有人点赞了你的文章
  • 有人收藏了你的文章
  • 有人关注了你
  • 或者有人给你发了私信

如果没有通知系统,这些行为基本就是“悄悄发生”,用户完全感知不到。

所以就顺手把消息通知模块也做了一遍。这篇文章主要记录一下整个实现过程,以及中间踩的一些坑。

一、先想清楚通知是什么

一开始我其实想过几种设计,比如:

  • 点赞通知一张表
  • 评论通知一张表
  • 关注通知一张表

但很快发现这样会非常麻烦。

更简单的方式其实是把通知抽象成一条行为记录:

某个用户对另一个用户做了一件事情。

于是通知结构其实只需要几个关键字段:

  • 通知接收者
  • 操作发起者
  • 操作类型
  • 是否已读
  • 创建时间
  • (如果涉及文章)文章 ID

于是最后设计了一张 notifications 表。

1
2
3
4
5
6
7
8
9
10
CREATE TABLE notifications (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
actor_id BIGINT NOT NULL,
type VARCHAR(50) NOT NULL,
article_id BIGINT,
message_content VARCHAR(1000),
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

这里有两个字段比较关键。

user_id
表示通知接收者。

比如:

“A 给 B 点赞”

这里 user_id = B

actor_id
表示触发行为的人。

同样这个例子里:

actor_id = A

type
通知类型,用来区分不同通知。

目前我的系统里大概有几种:

  • like
  • favorite
  • comment
  • follow
  • private_message

这种设计的好处是以后扩展会很方便,比如以后想加:

  • 系统通知
  • 管理员警告
  • 评论回复提醒

都可以直接扩展 type

二、通知是在哪生成的

通知本身其实只是一个附属功能,它一定是伴随着其他业务产生的。

比如:

  • 点赞成功
  • 评论成功
  • 关注成功

所以我的做法是把通知逻辑统一封装在一个 NotificationService 里。

各个业务模块只需要在操作成功之后调用它。

比如点赞文章的时候:

1
2
3
4
5
6
notificationService.createNotification(
article.getAuthor().getId(),
userId,
"like",
articleId
);

收藏、关注基本也是同样的逻辑。

这样做的好处是:

  • 通知逻辑和业务逻辑是解耦的
  • 以后如果要改通知系统,只需要改一个地方

三、未读通知计数

通知列表其实很好做,分页查数据库就行。

真正麻烦的是未读通知数。

因为很多地方都会显示它,比如:

  • 页面右上角的小红点
  • 消息中心
  • 移动端提示

如果每次都查数据库:

countByUserIdAndIsReadFalse(userId)

在用户量大的情况下其实挺浪费资源的。

所以我给未读计数加了一层 Redis 缓存。

逻辑很简单:

  • 先查 Redis
  • 没命中再查数据库
  • 查到结果写回 Redis

代码大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public long getUnreadCount(Long userId) {

String key = "notification:unread:count:" + userId;

Object cached = redisUtil.get(key);

if (cached != null) {
return Long.parseLong(cached.toString());
}

long count = notificationRepository
.countByUserIdAndIsReadFalse(userId);

redisUtil.set(key, count, 1800);

return count;
}

这样大部分请求都会直接命中缓存。

四、缓存更新策略

缓存加上去之后,还要解决一个问题:什么时候更新 Redis?

我的策略是:

新通知产生的时候:

Redis count +1,但前提是缓存已经存在。

1
2
3
if (redisUtil.hasKey(countKey)) {
redisUtil.incr(countKey, 1);
}

如果缓存不存在就不管,因为下次查询会重新建立缓存。

而当通知被标记为已读时,我一开始的想法是:

count -1

但后来发现这个方案在并发情况下其实容易出问题。

比如:

  • 用户同时标记多条通知
  • 或者新通知和标记已读同时发生

最后可能导致计数不准确。

所以最后我选择了一个更简单的方法:

直接删除缓存。

1
redisUtil.delete(countKey);

这样下次查询时会重新统计数据库。

虽然多查了一次数据库,但逻辑会简单很多,也更安全。

五、数据库索引

通知列表是一个比较典型的查询:

1
2
WHERE user_id = ?
ORDER BY created_at DESC

另外一个高频查询是:

1
2
WHERE user_id = ?
AND is_read = false

所以我给表加了一个联合索引:

1
INDEX idx_user_read (user_id, is_read, created_at DESC)

这个索引基本可以覆盖:

  • 通知列表查询
  • 未读通知查询
  • 未读数量统计

效果还是挺明显的。

六、一个小坑

功能写完之后测试的时候,我发现一个很奇怪的现象:

我给自己的文章点赞,系统也给我发了一条通知。

技术上没问题,但体验非常奇怪。

后来就在创建通知的时候加了一句判断:

1
2
3
if (userId.equals(actorId)) {
return;
}

这样就不会出现自己通知自己的情况了。

七、如果以后要升级

现在这个通知系统其实是一个比较基础的实现,如果继续完善的话还有不少可以做的。

比如:

1. 实时通知

现在前端是每隔一段时间轮询接口。

如果用 WebSocket,其实可以做到真正的实时推送。

2. 消息队列

现在通知是同步创建的。

如果以后并发比较高,可以把通知放到 MQ 里异步处理。

流程会变成:

用户操作

发送通知事件

消息队列

通知服务消费

写入数据库

这样可以减少主业务的延迟。