未读数也是系统中一个常见的模块,以微博系统为例,你可看到有多个未读计数的场景,比如:

  • 当有人 @你、评论你、给你的博文点赞或者给你发送私信的时候,你会收到相应的未读提醒;

  • 在早期的微博版本中有系统通知的功能,也就是系统会给全部用户发送消息,通知用户有新的版本或者有一些好玩的运营活动,如果用户没有看,系统就会给他展示有多少条未读的提醒。

  • 我们在浏览信息流的时候,如果长时间没有刷新页面,那么信息流上方就会提示你在这段时间有多少条信息没有看。

我们在浏览信息流的时候,如果长时间没有刷新页面,那么信息流上方就会提示你在这段时间有多少条信息没有看。

你可以在计数系统中增加一块儿内存区域,以用户 ID 为 Key 存储多个未读数,当有人 @ 你时,增加你的未读 @的计数;当有人评论你时,增加你的未读评论的计数,以此类推。当你点击了未读数字进入通知页面,查看 @ 你或者评论你的消息时,重置这些未读计数为零。

那么系统通知的未读数是如何实现的呢?我们能用通用计数系统实现吗?答案是不能的,因为会出现一些问题。

系统通知的未读数要如何设计

来看具体的例子。假如你的系统中只有 A、B、C 三个用户,那么你可以在通用计数系统中增加一块儿内存区域,并且以用户 ID 为 Key 来存储这三个用户的未读通知数据,当系统发送一个新的通知时,我们会循环给每一个用户的未读数加 1,这个处理逻辑的伪代码就像下面这样:

List userIds = getAllUserIds();
for(Long id : userIds) {
 incrUnreadCount(id);
}

这样看来,似乎简单可行,但随着系统中的用户越来越多,这个方案存在两个致命的问题。

首先,获取全量用户就是一个比较耗时的操作,相当于对用户库做一次全表的扫描,这不仅会对数据库造成很大的压力,而且查询全量用户数据的响应时间是很长的,对于在线业务来说是难以接受的。如果你的用户库已经做了分库分表,那么就要扫描所有的库表,响应时间就更长了。不过有一个折中的方法, 那就是在发送系统通知之前,先从线下的数据仓库中获取全量的用户 ID,并且存储在一个本地的文件中,然后再轮询所有的用户 ID,给这些用户增加未读计数。这似乎是一个可行的技术方案,然而它给所有人增加未读计数,会消耗非常长的时间。

你计算一下,假如你的系统中有一个亿的用户,给一个用户增加未读数需要消耗 1ms,那么给所有人都增加未读计数就需要 100000000 * 1 /1000 = 100000 秒,也就是超过一天的时间;即使你启动 100 个线程并发的设置,也需要十几分钟的时间才能完成,而用户很难接受这么长的延迟时间。

另外,使用这种方式需要给系统中的每一个用户都记一个未读数的值,而在系统中,活跃用户只是很少的一部分,大部分的用户是不活跃的,甚至从来没有打开过系统通知,为这些用户记录未读数显然是一种浪费。

通过上面的内容,你可以知道为什么我们不能用通用计数系统实现系统通知未读数了吧?

那正确的做法是什么呢?要知道,系统通知实际上是存储在一个大的列表中的,这个列表对所有用户共享,也就是所有人看到的都是同一份系统通知的数据。不过不同的人最近看到的消息不同,所以每个人会有不同的未读数。因此,你可以记录一下在这个列表中每个人看过最后一条消息的 ID,然后统计这个 ID 之后有多少条消息,这就是未读数了。

大并发下,未读数系统如何设计?_大并发

这个方案在实现时有这样几个关键点:

  • 用户访问系统通知页面需要设置未读数为 0,我们需要将用户最近看过的通知 设置为最新的一条系统通知 ID;

  • 如果最近看过的通知 ID 为空,则认为是一个新的用户,返回未读数为 0;

  • 对于非活跃用户,比如最近一个月都没有登录和使用过系统的用户,可以把用户最近看过的通知 ID 清空,节省内存空间。

这是一种比较通用的方案,既节省内存,又能尽量减少获取未读数的延迟。 这个方案适用的另一个业务场景是全量用户打点的场景,比如像下面这张微博截图中的红点。

大并发下,未读数系统如何设计?_大并发_02

这个红点和系统通知类似,也是一种通知全量用户的手段,如果逐个通知用户,延迟也是无法接受的。因此你可以采用和系统通知类似的方案。

首先,我们为每一个用户存储一个时间戳,代表最近点过这个红点的时间,用户点了红点,就把这个时间戳设置为当前时间;然后,我们也记录一个全局的时间戳,这个时间戳标识最新的一次打点时间,如果你在后台操作给全体用户打点,就更新这个时间戳为当前时间。而我们在判断是否需要展示红点时,只需要判断用户的时间戳和全局时间戳的大小,如果用户时间戳小于全局时间戳,代表在用户最后一次点击红点之后又有新的红点推送,那么就要展示红点,反之,就不展示红点了。

大并发下,未读数系统如何设计?_redis_03

这两个场景的共性是全部用户共享一份有限的存储数据,每个人只记录自己在这份存储中的偏移量,就可以得到未读数了。

你可以看到,系统消息未读的实现方案不是很复杂,它通过设计避免了操作全量数据未读数,如果你的系统中有这种打红点的需求,那我建议你可以结合实际工作灵活使用上述方案。