很长时间没有更新博客了,距上一次发表博客的时间太久远,好吧,我承认我比较低产~~

在私信中我们通常会遇到给大量用户群发的情况,由于最近在做这一块的优化,所以写写个人的想法与总结

前言

在做私信大规模群发的时候,有这样的场景,就是我们有可能需要给我们的所有用户发送消息或通知,假如有千万级别的用户,甚至到亿级别的用户量,这个时候我们可以根据不同的业务情况对不同的群发要求做处理。

这里仅对大规模运用于mysql存储IM一对一私信进行总结,其它的方案不在考虑范围之内

当然这里的设计是依托在原来对于私信的设计之上,以后有机会的话还会对整个私信的架构做一个简单的总结,但是对于本篇文章,有些东西可能会简单的提到(づ′▽`)づ

群发可能的需求

由于群发消息是给所有注册有效用户发送消息,所以,这里不包含推送

  • 一次能发送多条消息

  • 给指定用户发送

  • 给指定用户列表发送

  • 给粉丝群体发送

  • 给所有效用户发送

  • 嵌套式发送

这里是我所承接的需求,每条需求不一样,所以设计的可能就不一样,有一些需求需要存储数据库,这个时候最重要的是容灾以及频控,有一些需求不需要存储,在下行无压力且用户量极高的情况下,我们需要让用户自行的拉取。

可能面临的问题

  1. 影响用户体验 消息发送得慢,并且发送失败。

  2. 服务崩溃,那么有的消息发送不完全,容灾如何解决

  3. 发送速率的问题

  4. 如何推送给所有用户且信息并不需要存储的情况下

对于业务的分析以及接口的设计

对于一次可以发多条消息

我们可以这样设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 消息内容
struct MsgAttr {
1: i32 msgType; //消息类型
2: optional RTFMessage rtfMsg; //图文消息 MSG_TYPE_ITING
3: optional TextMessage textMsg; //文本消息 MSG_TYPE_TEXT 、MSG_TYPE_PICTURE、 MSG_TYPE_LINK、MSG_TYPE_GROUP_INVITE、MSG_TYPE_SUBSCRIBE、MSG_TYPE_OFFICIAL、MSG_TYPE_OFFICIAL_RECOMMENDED
4: optional bool isStrongNotify; //是否为强提醒
5: optional i32 strongNotifyExpireTime; //强提醒过期时间,为unixtime时间戳,类似1503990460
}

// 发送的消息属性
struct XChatMsg {
1: string bizCode; //业务号
2: list<MsgAttr> msgAttrList; //信息列表(排好序) 一次最好不要过多
3: optional string attParam; //附带参数 (msgType == RCV_TYPE_PAY_ONE_ALBUM 为 专辑id的字符串、)
4: optional bool isSubscribe; //是否是推荐号
}

我们可以把多个消息放在一个list中,一个MsgAttr就是一条消息,做成一个消息list,对用户进行发送,这样可以一次发送连续的消息。

RTFMessage和TextMessage是业务类型中指定的两种类型 具体不列举了

对于给指定用户发送

  • 对发送做频控,否则影响数据库的读写

  • 异步发送处理数据能大大提高效率

对异步处理的设计:

  1. 单开一条线程处理

    由于是一条一条消息的存储,并且业务量相对于大规模的群发来说,量并不大,所以每一条的存储消息的时间足够快,所以并不需要考虑多条线程

  2. 500条/s的存储

    这里设计的时候我是只考虑了单独另外开一条线程处理,因为是,我们可以设计为500条/s–1000条/s存储,甚至可以更多,这个根据数据库的吞吐量以及数据的是否包含其它业务决定,在这里我才用的是500条/s的速率。

对于给指定用户列表发送以及给粉丝群体发送

在我看来,他们等价,因为指定用户列表是相当于外界传入用户列表,而发送给粉丝群体则是相当于服务内部去粉丝服务上拉到粉丝用户列表并且发送。

此处有可能存在的问题:拉取粉丝列表过慢

此处要考虑的问题:

  • 跟发送给指定用户类似,给发送做频控

  • 异步发送处理数据能大大提高效率

  • 数据库的分表规则以及将一个消息分成多个小包进行批量存储

对于数据库的分表规则以及小包进行批量存储:

对于小公司或者创业公司来说,用mysql来存储消息,是一个性价比比较高的存储。但是对于大厂来说可能有自己的存储方式,比如Tencent可能会用非关系型数据库加磁盘的方式存储,或者其它的存储方式。在这里我们只针对对于mysql的存储来说吧。

对于mysql的高效存储来说,一般的话分表是必不可少的,这样可以大大的提高异步存储的效率,只不过说可能真对于具体的业务来说分表的规则有可能不一样,比如按userid分表、token分表等等。我们在大规模批量信息进入数据库的时候就要考虑到分表规则,要将符合相同规则的消息存储于同一个小包中对数据库进行批量存储。

这里可能有人会问为什么需要大规模的拆分成小包进行存储?

我们说对于用户的私信发送,也就是每个用户都能看得见的,我们必须要把每一条消息都存入库,那么假如说是百万条消息的话,也就是百万条消息都必须存入库,为了提高存储效率,单条存储入数据库的效率肯定是低下的,因为每次对数据库进行访问都需要进行网络连接,那么我们为了提高效率就必须批量存入。

在考虑到批量存入的情况下,我们就必须考虑到一次的存储数量能否会给数据库带来压力,每次的数量大概是多少,并且应该遵循分表规则。

就拿我的业务举例子:

  • 数据库分表规则: userid后两位分表 100个表
  • 分小包大小 每个小包500条消息

那么假如要提高效率的话,userid尾号相同的后两位在一个小包中,即使是100个用户,这个userid 为1-100,那么根据分包规则,因为尾号相同的userid在一个包中,那么分为100个包,每个小包中有1个userid,得存100数据库,根据分包规则,这是在所难免的。当量大了以后,每个包中有500条消息,都是根据userid尾号分好的,可以批量存入表中。

对异步处理的设计:

  1. 开多条线程进行异步处理

    这里跟给指定用户发送略有不同,由于是对大规模的用户群发,所以这里设计的时候考虑用的是多条线程,这里要对数据库的读写性能进行分析,对于数据库来说,可以进行大规模的批量存储,也就是打包存储,但是数据库是否对于入库的并发量能容忍,这个是要考虑的问题。对于存储线程,我对我们的服务设定的发送线程数为3。

  2. 500条/s的存储

    3条线程,每条线程500条/s的小包对数据进行存储(注意:这里的条指的是sql语句),每个小包500条消息(注意:这里的消息指的不是sql语句,而是一条sql语句中批量存储用户的500条消息),每个线程约存储2500条/s,3个线程7500条/s,这个并发其实足够,在百万级用户面前发送一条消息也只是短短几分钟

群发系统的设计

其实对于群发系统的设计,我可以说有两套方案,我们老大出了方案A,而我误理解为方案B,各有各的优缺点吧。

(以下有可能简称一条群发消息任务为大包 而其拆分成为小包进行存储)

对于批量推送

与用户私信的发送通道拆分

这是比较重要的一点,之前的私信群发操作是在批量调用的过程中,调用了用户每条私信的存储函数
言简意赅就是把消息的批量存储写为了,多条对用户私信的存储。
假如是群发3000条消息,那么就会调用3000条普通私信的存储函数,相当于3000次对数据库的调用。

缺点:

  • 对数据库的操作效率是低下的
  • 对用户是不友好的,因为占用了用户发送消息的通道,假如用户要发送私信,有可能发送失败,因为入库失败~
容灾

考虑的问题主要是发送消息在发送一半的时候,假如服务突然挂了,那么剩下的发送任务能不能在下一次重启的时候得到解决,那么对于消息发送进度的临时存储就是一个很必要的问题了。
Tencent的容灾很方便,他们有CFS仓库,可以将发送过来的包储存成文件,存储在CFS仓库中。每次群发前先通过hippo组件进行上报并将任务存储在CFS仓库中,在任务发送失败后,通过hippo,diff出差异文件继续发送。

而我们相对来说,没有那么一套完整的管理机制,就需要用一些现成的数据系统去做这个第三方的存储,我们首选就是redis。

容灾存储选择redis理由

  • 非关系型数据库拉取存储效率更高效
  • redis中的消息类型足够我们去使用
  • 临时存储的数据不需要落地,存入只是做备份,当任务执行完后则将存备数据包删除

对于如何使用redis,方案A和方案B有不同的使用方法

对于批量推送方案A 所有的集群结合为一个整体的发送系统
规则:

将大的消息拆成小包放入redis中,redis做队列,每个服务单独领取小包做处理

优点:
  1. 把整个任务系统作为一个整体,可以不必要去单独的关注一个大包,也不必去关注每一个服务器,可以使每个服务充分的利用去执行一个任务,对一个任务的可执行率更高。

  2. 能够容忍小包丢失所带来的失误

缺点:

同步操作,相当于每次只对一个大任务包执行任务,将大的任务包拆成小的任务包放入一条队列中,当这个大任务包执行队列被堵死,那么会影响后面的任务发送

对于批量推送方案B 每个服务分配到独立的发送任务并且对单一的任务负责到底
规则:

当一个服务领取到一个大任务包以后,这个大包就归这一个服务所执行,其它的服务器在规定时间内不能执行这个大包,当判定认领这个大包的服务器超时,则将这个大包重新分配到未发送完成的任务队列中,由其他的分布式服务器认领并执行

优点:
  1. 可以多任务同时执行,不会对后面发送的任务造成影响
  2. 在一台服务挂机以后其他任务也能执行
缺点:

过于复杂,相当于自己写了一个对简单的分布式锁的系统,锁的颗粒还特别大,特别粗糙,花费时间长,坑多。

对于两种方案的看法

两种方案只是对于任务的安全性不一样。
论效率、简易程度,非A莫属,论对于多个任务执行的安全性、以及并发性,那还是方案B,
当然,这两种也是各有利弊吧。

但是它们都有一个共同的点就是对redis的依赖很重,这是由业务的方案决定的,当我们决定需要容灾的时候就需要一个第三方的稳定存储,如果不用redis的话,我们也一样可能会用其他的。

对于给所有注册用户发送

消息场景与抛出问题

对于给所有的注册用户发送消息,这是一个挑战,因为我们的任务场景是:

  • 无论用户是否在线,皆能收得到消息,并且是面向站内的所有用户,用户单位为亿级
  • 发送给用户以后不需要再去服务端拉取,本地会缓存住。
  • 发送只发送一次

单单是用户是亿级别这个需求都能弄得人脑壳疼对吧,这个事就不能按正常人的思维去脑补它,难点 :

  • 亿级别的需求,发送下行没事,对于通过RPC去拉取亿级别的用户名单,这个是不太可能去做到的。
  • 数据库一次性存储亿级别的消息,影响到后面任务性能。

根据这些个问题,总结出此任务场景需要反正常的设计:

  • 单独对这类消息建立一个表,一条消息只存一条进表中
  • 用户在更新消息的时候主动来拉这条消息。(主动:给用户一个notify 被动:用户自己来拉 都一样~~~~)

为什么这么设计呢?

  1. 用户在收到消息以后只做本地存储,也许以后再也不会看到这条消息,这么大容量去推送根本不需要
  2. 这种场景对于群发来说简直不要太不友好。

好了,考虑到这里我觉得我们应该思考设计一些细节问题,在这里我不简单的提一下我们原来的私信设计,因为群发的业务是依托于私信的架构之上的(后面有时间可能会写私信的结构分析),不然没法往下分析 ( : з 」∠)

当用户没有缓存的时候,私信的客户端会拉取最新的和当前用户有关的消息,大概是2000条左右。
当用户有缓存的时候会发送上一次拉取私信的最大id,从上一次拉取私信的最大id往后拉。

OK~~~一笔带过!!

那么这里中间我们需要考虑什么问题呢?

那就是我们几乎对用户每一次的拉取都要加上这条额外的新的消息。那么对于这条消息存在哪呢

  • 方案A Redis:
    假如我们把这条消息存入redis,那么再从redis进行拉取,其实过程上是简单的,但是从效率上来说我们需要两次从数据库中拉取数据,其实用我们老大的经验来说,多从数据库中拉取一次数据会大大损失服务的性能,原因很简单,对于私信的服务来说,拉消息的量要远远高于推消息的量,每一次用户登陆注册进服务器,都要拉取一次数据,假如多增加一次对redis的调用,那么则会增加一次网络耗时,那么相当于对网络性能增加了一倍,而每次拉取,拉取都增加一倍,对于大规模的用户来说,拉取的时间就降低了。

  • 方案B 本机的内存中:
    假如这条消息是存入的是内存,那么只需要一个从拉取,然后再对所有的一个消息进行重排就Ok,减小了对性能的损耗。

那么OK,对比上面的这两个方案后,我们选择了方案B

其实这中间,还产生了一些新的问题,同步问题

假如用户发送了一条消息,那么这条消息必定生成一个msgid,而生成msgid的规则是不同的,唯一能确保的一点是这个msgid必须唯一,你可以根据时间去生成它,也可以用uuid等规则。。。
这里我就说我们用了时间的规则哈

假如用时间的规则,那么由于时间戳的产生,时间戳经过一些移位等规则,必定是唯一的,那么假如某条消息发送到了一台服务器上,那么此刻如何通知另外的服务器这条消息,另外的话在产生这条消息的同时,我们需要发送一个notify给用户来拉取这条消息,如何在所有服务器都通知到了这条消息以后,再让用户来拉这条消息。(所有的后端业务服务都是分布式的,所以对于和它相处同一层的服务器来说,完全感知不到其它服务器的存在),那么如何做到在同一层还能互相感知其余的服务器,并且进行通知呢?如果要这么做的话,必须要引入一个类似于redis、zk的第三方,那么就会引入更复杂的问题。

好吧,那我们先假设动用了这套方案~~!!

我们假设动用了第三方的存储来给我们做同步,那么会出现第二个问题去考虑,时间差问题

假如我们已经做到了一台机器接受到消息以后可以同步给另外的机器,那么我们需要同步的话需要时间。由于我们每台服务器均不知道另外服务器的情况,我们必须让每台服务器定时去第三方的存储中拉取消息,那么中间会产生一个时间差。我们拉取消息的规则是我们根据上一次拉取的id,往后拉,假如我们在获得这条群发消息的过程中,人在聊天,那么它聊天生成的时间戳会大于这条群发的时间戳,那么这条消息即使通知到了其它服务器上,这条消息也不会被拉到,因为客户端有可能在聊天中生成的消息id就大于这条群发消息的id了。

给出的解决方案

经过上面的场景以及问题分析,我们知道我们的解决方案主要还是依靠内存,并且还遇到了两个问题,同步问题时间差问题

假如不能用正常的方式去思考解决这个方案,那么我们只能换一种方式去思考:

让发送的消息更早的同步到每个服务器,并且让每个消息更早的生成msgid

这样我们就可以这么设计

  • 接口增加两个字段 发送时间和有效期时间,发送时间的作用是设定一个未来的发送时间,在未来的发送时间去发送这条消息。有效期日期的作用是,在有效时间内允许服务从数据库中拉取到该数据,由于当客户端拉到消息后存入客户端本地缓存,所以对于每个人来说消息只是拉取一次。
  • 每个服务定时轮询去数据库收集在有效期前发送的数据,轮询到了以后加载入内存中

这样做的好处:

  • 可以让每个服务都能超前获得msgid,对于在未来的某一时刻来说,到了发送时间节点的时候,基本上每个服务都已经同步的获得了需要发送的数据,间接的同步了所有服务器的数据,解决了同步问题
  • 每个服务器在到定点发送时间的时候,从等待发送的状态,到可以发送的状态,会浪费掉部分时间,虽然这中间可能会有部分用户会在聊天而受到拉不到消息的影响,而对于大部分的用户来说,拉不到消息的几率极小,解决了时间差问题

总结

总结就不那么啰嗦了~ 对于这次设计反思和思考太多了,直接在下面列几个点:

  • 在数据量大的情况下,批量存入数据库,会获得更高的效率,但是最重要的是频控,不要因为一个批量存储影响了其他业务
  • redis不仅仅可以用来当缓存,也可以用来做第三方落地式安全存储(AOF模式),也可以用来充当第三方的数据队列或消息队列,或做备份等。
  • 在缓存的选择上redis只是一种,但是缺点是会用到网络传输,效率不如自己在本机上的内存。假如在数据一致性或同步性不高的情况下,缓存要善于利用自己机子上的内存,效率会比redis更高。
  • 在对于结构的设计上,不要太固执,要灵活(当然我觉得有可能是由于我代码写的少(〃′o`) ),就比如让多台服务器同步一条消息,往后延时可以解决同步的问题。
  • 尽量的往简单的方向去考虑,在实在不得已的情况下再去考虑第三方的插件插入,因为多引入一个服务会增加更多的坑~