[译]英雄联盟聊天服务的持久层演进

原文:CHAT SERVICE ARCHITECTURE: PERSISTENCE

[译]英雄联盟聊天服务的持久层演进玩了很多年英雄联盟,我和全球的一些玩家已经建立了良好的社交关系。不管他们是工作中的朋友,还是老同学,曾经结对过的玩友,他们在我的朋友列表中都是很重要的存在。因为和这些朋友一起玩很方便,大大提高了我对游戏的体验。如果这些社交信息出了问题,我需要回忆,重新添加这200多个朋友,无疑会是一场灾难,其糟糕程度不亚于丢失手机连同里面存储的联系人信息。

我相信Riot聊天服务会把我的帐号信息持久化,如我的好友列表,某好友的原始记录,还有被我拉进黑名单的玩家列表。更进一步,这些数据应该安全保存,只有我可以访问,而且随时随地可以访问。在这篇文章中,我将回顾一下我们的聊天服务持久层的发展简史,同时描述一下现在的架构轮廓,我希望正被存储技术和持久化问题困扰的朋友可以从中找到有趣和有用的东西。

几年前,英雄联盟玩家还没有真正开始飞速增长,我们决定基于开源的XMPP服务实现:ejabberd来构建一个聊天服务。过去ejabberd只提供了少量的持久层:你可以使用mnesia或者一个ODBC后端(可以是MSSQL,PostgreSQL,或者MySQL服务)。

早年MYSQL时代

我们选择MySQL做为我们聊天数据的主存储,因为那时候扩展性并不是我们关心的首要问题,我们有很多的内部经验。所有数据都要存储到服务器并持久化,用户的登录要存储到MySQL,包括:

  • 好友列表(或者叫名册)和好友的元数据:如备注,群组,关系状态,创建日期,等等。

  • 黑名单:那些你不想联系的注册用户。

  • 离线信息:你离线时发给你的聊天信息,你登录后需要尽快回复。

每个结点我们有三个MySQL服务:分别是主实例,备份实例和ETL(抽取,转换,加载)。主实例负责聊天服务的读写,并将数据复制到备份实例和ETL服务器。备份实例是用做数据快照,并在主实例维护和宕机时充当主实例。最后,ETL服务器是用来做统计分析,可以部署在低性能硬件上。

总之,聊天结点使用MySQL做后端持久层是这个样子的:

[译]英雄联盟聊天服务的持久层演进

玩家基数增长

随着时间的推移,英雄联盟玩家的数量也在增长,我们开始遇到一些问题:聊天服务器导致MySQL主实例过载。玩家在加载名册,添加新朋友,编辑玩家日志时发现了问题。这多次引起关于未来使用MySQL做为后端的争论,让我们认识到这种架构存在的几个问题:

  • MySQL主实例需要纵向扩展—为了获得更多的存储能力,我们需要不断地增加内存扩展昂贵的存储(例如FusionIO)。给玩家提供新功能时这将会是一个瓶颈,Riot对这件事情非常重视。

  • 主实例在我们系统中会导致单点故障。一个极小的性能故障都可能导致玩家加载朋友列表时超时。尽管有一个备份实例,但如果瞬态故障没有触发容灾机制,足以影响到整个系统。大停电(软件或硬件导致)会导致灾难性的服务器宕机,需要大量的体力劳动来解决问题。

  • 当数据集开始增长,任何一种模式迁移都需要很大的成本。他们的应用程序需要周密计划,极其勤奋,经常花数小时处理聊天服务宕机。这大大地减慢了开发进度和新功能发布。

我们评估了解决上述问题的几种方案。选项包括MySQL应用层分片,MySQL集群,Cassandra和Riak。不论是MySQL分片还是集群我们都在同步复制时遇到从快照中快速恢复的问题。Cassadra确实是一个可行选项,但它对模式的限制是我们竭力避免的。最后一个选项Riak ,被证明是一个灵活,可扩展,并且可以容错的数据存储,我们决定使用它。

RIAK

在传统的关系数据库管理系统(RDBMS)中,数据通常是做为一组实体的关系表存储。在绝大多数案例中,关系数据库管理系统的用户都是通过SQL来访问和管理他们的数据。像Riak这样的NoSQL系统脱离了关系表。这导致了一些复杂性,但也有很多值得注意的优点。通常NoSQL数据库为工程师提供更宽松的开发环境:无模式;接口允许快速迭代;横向扩展减轻了扩展问题的负担;内部复制为数据提供保障。Riak是一个分布式key-value NoSQL数据库,可以线性横向扩展,提供CAP定理定义的AP(可用性和分区容灾)语义。

迁移到RIAK

从MySQL迁移到NoSQL是一个巨大的改变。我们要学很多CAP定理,一致性模型和一些其他有趣概念相关的东西。这些是Riak的基础。

为了全局快速部署Riak,我们决定共用我们的聊天硬件,将聊天服务和数据存储部署在一起。这让我们可以避免在全世界获取和重建服务的漫长过程,和新机器的代价是一样的。我们最终形成了下面的架构:

[译]英雄联盟聊天服务的持久层演进将Riak实例和聊天服务部署在相同的硬件上只是一个配置工具的事情,并被证明是处理集群的一种稳固、具有前瞻性的方式。

在这种架构下每一个聊天服务通过协议缓冲接口连接到一个可以使用的Riak实例,选择运行在同一台机器上的Riak实际具有很高的性能。如果发生持久化协议缓冲接口连接被终断,聊天服务会自动重新连接到另一个实例以保证玩家数据不会丢失。聊天服务还监控Riak的拓扑结构--当发生变化时,他们会尝试平衡所有集群成员的连接(包括本机运行的Riak实例)。

在内部,Riak使用leveldb和后端设置允许同一个对象多个版本(如:allow_mult = true, last_write_wins = false),一个复制因子根据类型和重要性分配3到5个。我们考虑到朋友列表的重要程度把它们复制到多个分区。像发送的玩家信息,因为他们还没有登录,所以不是重要信息。

由于分布式系统的天性和类似网络分区产生的外部事件会导致写冲突。Aphyr在他的博客里对写的问题做了很好的介绍。在传统的关系数据库管理系统中,事务的原子性通过一个两段提交协议实现的锁定来保证。通过牺牲可用性,这种方式可以保证数据在整个集群中都是一致的。但不幸的是在分布式系统中这种一致性没有保证。我们通过实现应用层CRDTs(收敛的复制数据类型,请查看*条目)来达到这个目的,聊天服务以可预见的自动化方式解决潜在的切片列表问题。

让我们假设玩家Bob和Charlie想和Alice成为朋友。碰巧他们同时向Alice发送了添加好友请求,两个聊天服务同时请求编辑Alice的朋友列表。两个都更新成功,Riak内部存储两个版本的好友列表(一个是Bob的邀请,一个是Charlie的邀请)。下一刻Alice登录进来,聊天服务从Riak读取名册,会发现冲突,并合并日志,结束两个请求对Alicke批准的等待。

结果当一个聊天服务想存储一个对象,他先编码成CRDT结构,再编码成JSON(所以很容易被多个服务读取),最后压缩。下面是一个我们存储在Riak上的数据结构示例(玩家朋友列表的CRDT,JSON编码):

[[email protected] ~]$ curl localhost:8097/buckets/NA1_roster/keys/sum1234 | gunzip | jq .

{

  "v": 1,

  "log": [

    {

      "val": {

        "created_at": "2015-09-17 03:44:42",

        "priority": 2,

        "group": "**Default",

        "askm": "",

        "ask": "none",

        "sub": "none",

        "nick": "0xDEADB33F",

        "jid": "[email protected]"

      },

      "type": "add",

      "ts": 1442461482936116

    },

    {

      "val": [

        "sum98765",

        "pvp.net",

        "out"

      ],

     "type": "change_ask",

      "ts": 1442461483381002

    },

    {

      "val": [

        "sum112211",

        "pvp.net",

        ""

      ],

      "type": "del",

      "ts": 1442461487937193

    }

  ],

  "val": [

    {

      "created_at": "2015-01-02 03:12:00",

      "group": "**Default",

      "askm": "",

      "ask": "none",

      "sub": "both",

      "nick": "Teemo Ward",

      "jid": "[email protected]"

    },

    {

      "created_at": "2014-10-23 10:32:47",

      "priority": "3",

      "group": "**Default",

      "askm": "",

      "note": "Mom",

      "ask": "none",

      "sub": "both",

      "nick": "Mooooorg",

      "jid": "[email protected]"

    },

    ...

  ],

  "mod": "mod_roster_riak",

  "ts": 1442461482739733

}

就像你在例子中可以看到的,一个玩家的名册就是朋友列表,以一种变体日志的方式记录。这个变体日志包括所有日志状态的变化,例如朋友关系的变化,从列表中删除记录,向列表添加记录。

虽然Riak提供了snappy做磁盘压缩,但我们发现大文件(超过1MB)处理起有困难,这让人很烦恼,sibling explosion甚至会导致服务宕机。JSON压缩相当好用,可以减少95%的存储空间,在Riak里100kB的JSON只需要10kB。下图展示了西欧地区一整天中对象大小分布情况。

[译]英雄联盟聊天服务的持久层演进

得益于LevelDB缓存扩展,和几乎整个社交图都在内存中的事实,99.9%的Riak查询都可以在~8ms内完成(我们几乎从不从硬盘查询):

[译]英雄联盟聊天服务的持久层演进

99%的朋友列表加载(包括解压缩,解析JSON,合并CRDT,在内存中构建列表)可以在小于10ms的时间内完成:

[译]英雄联盟聊天服务的持久层演进另外,我们建了一个Riak备份集群,通过“多数据中心复制”与生产集群保持同步。这是为了在数据中心发生故障或者突发的bug导致NOSQL删除数据时能保护用户数据不丢失。我们定期在备份集群上为LevelDB做快照,它随时可以恢复而且很方便。

当运行聊天服务的测试系统时生产集群的快照也有很大的用处:让我们可以为不同地区的玩家模拟1:1的社交关系,观察一些小的变化(例如,将好友列表上限从300调整325)对系统的影响。

总结

将聊天服务从MySQL迁移到Riak,我们获得了不可思议的*,可以用来开发新的功能,甚至在集群发生部分故障时服务仍能稳定运行。当然,Riak需要我们在数据存储的范式上有重大转变,但最终证明这是值得的!几个月的时间,我们在全球迁移了大量的集群,有一半联盟玩家迁移到Riak聊天服务器,剩下的将在2016年初迁移。

迁移没有影响功能开发,让我们能够实现一些功能,如即将到来的移动端消息推送支持和去年的导入Facebook好友时扩展朋友列表大小。最重要的是处理玩家查询从来没有如此之快,我们注意到查询执行时间有了显著改进。例如,东欧地区,大量的慢查询数量(如超过1秒才完成的查询)降至每天每个服务不足50个。这种变化让登录速度更快而且加好友时0故障(由查询过时导致),最后,感谢Riak的稳定性,我们的服务不可用时间从未超过1分钟,即便发生磁盘和网络错误时!

[译]英雄联盟聊天服务的持久层演进

版权声明:“并发编程网”所推送文章,除非确实无法确认,我们都会注明作者和来源。部分文章推送时未能与原作者取得联系。若涉及版权问题,烦请原作者联系我们,我们会在24小时内删除处理,谢谢!^_^15701189222

[译]英雄联盟聊天服务的持久层演进
微信号:并发编程网
[译]英雄联盟聊天服务的持久层演进[译]英雄联盟聊天服务的持久层演进