最难不过二叉树

邮件消息服务技术细节

2022-07-27

组件选型

● 主从版redis 4.0+,保存全服邮件,理论上不需要太大
● mongodb 3.6+,保存所有邮件信息,需要大一些

redis

redis保存两类数据:

  1. 某group的当前邮件index
  2. group邮件的详细信息。

group邮件index是一个自增int,每次发送一个group邮件时取index然后基于其生成group邮件信息的key。

redis中保存的group邮件信息格式如下:

key:"groupmail_%s_%d" % (group_name, index),其中,index就是上文所说的自增索引,通过这个index我们就可以知道group mail至今发了多少封了。
group邮件的详细信息保存在redis中,当玩家进行邮件操作时去redis中拉取相关信息。

mongodb
    def __init__(self, service_methods, namespace):
        super(App, self).__init__(service_methods, namespace)
        self.mongo_mails_content = self.mail_mongo.db.mails_content
        self.mongo_mails_user = self.mail_mongo.db.mails_user
        self.mongo_mails_sendlist = self.mail_mongo.db.mails_sendlist

mongodb是主存储组件,玩家的所有邮件信息都会落地到mongo存储,这些信息分为3张表存储:

● 邮件内容表:每一条邮件都是一个doc,邮件结构体内有index字段,每插入一个邮件,index自增1,用于索引邮件。所有邮件都会存储于该表中。
● 玩家邮箱表:每个玩家拥有一个doc,以玩家唯一ID gid作为索引,自己的邮件列表,是list结构,单条邮件信息存储的只是邮件的简单摘要信息,如邮件主题、摘要、发件人等基础信息。要获取完整邮件信息需查表邮件内容表。
● 发件历史表:每个玩家拥有一个doc,以玩家唯一ID gid作为索引,发件历史是list结构,单条邮件信息存储的只是邮件的简单摘要信息,如邮件主题、摘要、发件人等基础信息。要获取完整邮件信息需查表邮件内容表。

接口汇总

● 注册时,调用register接口注册玩家信息
● 登录时,调用sync_group和delete_on_time接口,分别表示同步group邮件和删除定时邮件。
● 打开邮箱时,调用get_user_mail_list接口获得邮件历史。获得后可以客户端本地保存,也可以每次打开都重新调用接口。(注意分页支持)
● 打开具体某个邮件时,调用get_content获取邮件的全部内容,或者get_detail获取邮件的全部信息。
● 收到推送消息收到新的邮件时,若是一个对点邮件,下次打开邮箱时调用get_user_mail_list重新获得邮件列表即可;若是group邮件,需要马上调用sync_group去同步group邮件信息,然后调用get_user_mail_list。sync_group需要保证在get_user_mail_list之前执行,也可以调用get_user_mail_list(sync_group_flag=True)保证顺序。
● 删除邮件,调用delete_mail或delete_all
● 已读等信息更新,调用update_mail_params
● 发对点邮件,调用send_mail_to_user
● 发group邮件(全服邮件), 调用send_mail_to_group
● 获取发件历史列表,get_send_mail_list

技术细节

注册玩家信息

一个玩家要使用邮件服务,首先需要将该玩家信息注册到邮件系统中,一般在玩家角色首次登陆时注册。

注意,调用注册接口后要关注是否成功,若不成功,需要游戏服记录未注册状态,并且继续尝试注册直至注册成功。

接口: register(gid, groups=["global"])。gid是一个玩家的全局唯一id。注册的本质是将玩家加入的group加入到mongodb玩家数据字段之中记录,下面的gidxs结构是个map,如["global":2, "org1":1],key是group名,value是该group当前index数值(已经往该group发了多少条邮件)。

mails_user.find_one_and_update(
{"gid": gid, "version": result["version"]},
{"$set": {"last_group_sync": gidxs}}
)

注册的时候,同时注册该玩家所属于的groups,这里的group可以指全服玩家global,也可以指某个帮派的玩家或者某个门派的玩家。注册后可以重复调用register接口,以增加groups信息。

发邮件

邮件发送分为两个类型:

● 对点邮件:发送给单个玩家的邮件,应用场景是单对单聊天。send_mail_to_user(src_gid, dst_gids, title, content, params)
● group邮件:发送大量玩家的邮件,即组播或者广播,应用场景是帮主给帮派发邮件,GM给全服玩家发邮件。send_mail_to_group(src_gid, group, title, content, params)

收件人的发件历史和收件人的邮箱数据都需要存储,这里邮件消息数据存储在数据库mongodb中。

点对点邮件

对于发件人,需要记录发件历史,"send_mails"是个发送邮件历史列表,插入的位置为列表头部。从存储来看就是find_one_and_update进mongodb,利用mongo的$push指令,把邮件信息插入到对应的gid的邮件列表里。伪代码:

    suc = self.container.mongo_mails_sendlist.find_one_and_update(
        {"gid": src_gid, "send_mails.%d" % (get_busi_conf("USER_MAX_SEND_MAIL_COUNT") - 1): {"$exists": False}},
        {
            "$push": {"send_mails": {
                "$each": [mail_info],
                "$position": 0,
            }},
        }
    )

对于收件人,需要更新收件列表,我们利用mongo存储数据,"recv_mails"该字段存储邮件信息列表,更新后version字段加1,插入的位置为列表头部。伪代码:

    suc = mails_user.find_one_and_update(
        {"gid": gid, "recv_mails.%d" % (get_busi_conf("USER_MAX_RECV_MAIL_COUNT") - 1): {"$exists": False}},
        {
            "$push": {"recv_mails": {
                "$each": [mail_info],
                "$position": 0,
            }},
            "$inc": {"version": 1}
         }
    )
group邮件

group邮件发送时,同样需要把发送的邮件存储于发件人的mongodb 发件历史的字段中,操作步骤跟上面的点对点邮件发件人记录发件历史是一样的。

对于收件人而言,如何记录收到的邮件,这里的技术场景其实跟微博发帖、微信发朋友圈是一样的道理,我们在技术方案上是使用写扩散还是读扩散?

● 如果是基于写扩散,也就是玩家A发出一个全服广播的邮件后,都需要在每个玩家的邮箱(数据库中)里插入一条一模一样的邮件数据。当收件人需要拉取邮件读取时,直接从自己的数据库表里拉取自己的邮箱数据即可。这个技术方案会产生写风暴,即一次的全服邮件,导致数据库的超高并发的瞬时写入,数据库写压力很大。
● 如果基于读扩散,那就一个全服邮件我们记录到一个数据中的一条数据中,当玩家需要拉取这个邮件时,会从这个数据库中请求查询数据。这个方案会产生读风暴,即生成了热点数据,此时数据库的压力很大。

这里我们采取一个折中的方案来实现,这里就需要解决读扩散中的读风暴以及写扩散的写风暴问题。思路如下:

● 首先我们可以把读的时机打散:因为很多时候邮件的实时性并不会太强,1秒钟后收到和10秒后收到,对于玩家而言差别不大,因此我们可以在消息通知时把时间按照1~10秒打散,等于并发请求量降低为原来请求QPS的10分之1。
● 采取延迟写操作:把group邮件数据插入到玩家db数据的时机延迟到玩家点击打开邮件列表时,这样进一步分散了并发写DB的请求量。
● 利用redis做缓存:广播的邮件先入mongo,再写redis缓存,因为业务逻辑上不允许邮件的撤回(对应的是数据库的删除数据操作),因此在数据库缓存数据一致性上不需要进行过多考虑,实现上比较轻松。玩家在读取全服邮件时,直接读redis的group邮件内容再回写到自己db里就好。

group邮件插入收件人邮件的操作步骤:

  1. redis 邮件index先自增,index = self.redis_group_info.incrby("mail_%s_index" % group, 1)
  2. 拼接邮件key group_index = "groupmail_%s_%d" % (group, index)
  3. 把邮件内容set进对应key中。self.redis_group_info.set(group_index, mail_content, ex=get_busi_conf("AUTO_LIFE_TIME"))
  4. 广播发送消息通知客户端有group新邮件,需要拉取更新邮件列表。
  5. 客户端发起拉取更新邮件列表请求,服务器根据group从redis读取到group邮件具体信息,回写插入进mongodb对应的gid recv_mails中。
  6. 服务器把更新后mongo的recv_mails返回给客户端。

邮件通知

当邮件到达玩家的邮箱时,玩家的邮箱图标会产生红点和闪烁,提示玩家可以点击图标查看邮件,此时就涉及邮件通知。本质上跟IM系统的消息红点是相类似的,当A给B发送消息后,完整消息是不会直接发送到B的客户端,而是通过B主动点击图标后,再向系统拉取具体消息内容。

玩家发送邮件后,邮件信息是存储与数据库中,此时需要一定的机制通知收件人,即消息同步机制。同步时机有两个:

● 登录时 (客户端拉)
● 发送邮件且收件人在线时 (先推消息通知客户端,客户端再主动拉)

客户端收到邮件时增加一个红点(技术上看就是发送邮件时触发一个消息推送给客户端),等玩家打开邮件列表即可。如果是全服邮件,可以考虑消息推送时也可以加一个0~10s的随即延迟,平均一下玩家请求邮件列表的操作,不然全服玩家同一时间请求后台,请求量过大,这其实是没必要的。

邮件列表拉取

点击邮件图标后,客户端向后台拉取邮件列表,其中这里包括已读邮件和未读邮件,即完整的邮件收取历史,当然这里的邮件列表拉取是必须分页的,比如限制每页展示数为20,以收取的邮件时间排序。get_user_mail_list(gid, groups=None, start=0, limit=0, sync_group_flag=False)

邮件列表是个list,里面的元素是个map,即对应一条邮件信息,注意,这里的邮件信息并非完整的邮件信息,而是包括主题、摘要、发件人等基础信息。如果需要看完整邮件,需要通过获取邮件详细内容的接口拉取。

{
"id": mail_index, // 邮件ID
"srcid": src_gid, // 发件人
"title": title_utf8, // 主题
"abstract": abst_utf8, // 摘要
"parm": params,
"group": group_id, // 表示是group邮件
"time": timestamp, // 收件时间戳
"read_done": read_status // 标记是否已读
}

这里的mail_index其实就是一个邮件的标记索引,这个索引的生成方式是:当我们往mongo insert 一个数据时,数据库就会给我们返回一个doc的唯一索引,我们就取这个唯一索引为邮件的唯一索引。

拉取邮件详细内容

邮件列表没有保存邮件的全部内容,而是保存了邮件内容的摘要。邮件详细内容通过
get_detail(gid, mail_id)取邮件全部信息,其中mail_id是获取邮件列表中的邮件的"id"字段。拉取过邮件详细内容的数据需标记为已读,下次拉取邮件列表时,这个数据就不会出现未读标记。

压测

7个微服务实例,2个普通版的WiredTiger mongoshard,read优先读secondary,大概可以支持综合QPS 5k。
此时,mongo的CPU使用率在60~70,pypyCPU80%+。已经达到极限。
若希望增加QPS,需要对mongo和微服务实例都扩容。