本文是本人在InfoQ高可用架构群里的分享,整理发布于高可用架构微信公众号。

我们是一个创业团队,Grouk是我们研发的团队通讯工具,应用刚开始公测。下面我主要从技术和产品的结合场景进行一些心得分享,感觉我们在多终端数据同步的探索还是值得和大家探讨的,这种需求业界也没有非常成熟的公开解决方案。

移动时代应用面临的挑战

  • 终端爆发 我们有了手机,Pad,PC,TV,智能设备等各种终端。
  • 使用场景变化 PC时代是坐在电脑前使用应用,移动时代我们随时随地使用应用,使用场景随时切换。

理想中的跨屏体验

  • 终端切换时保持一致性体验,不丢失上下文
  • 支持多个终端同时操作,实时同步数据

优秀案例

  • Trello 看板应用

    用户打开后,其他人操作看板,能实时变化,不依赖用户刷新页面。如果用户在多个终端操作,也需要做到实时变化。通过测试,我发现Trello在移动端和PC同步的时候还是有Bug的。例如,PC设置离线,手机上操作card。PC连网,不刷新页面,数据经常无法同步。

  • Quip这样的多人协作编辑

    某人的编辑结果,其他人要能实时看到。同时还支持离线编辑、冲突合并。例如,Evernote多人协作是文档锁定模式的,冲突很难自动合并,体验上就差些。

大多数现状

  • 切换设备后丢失上下文
  • 需要用户手动拉取同步
  • 未实现增量同步,自动全量拉取

实现理想中的跨屏体验面临的挑战

  • 实时 保证体验上的及时性
  • 同步 保证上下文的完整以及实现增量,节省资源
  • 多端 如何降低研发成本?包括客户端和服务器端的架构

协议的选型

首先,移动时代带来的一个变化是应用的富客户端化。Web端流行SPA(Single-page application),移动端是各平台上独立的应用,和原来PC互联网时代刷页面的交互体验完全不一样,用户变『懒』了,希望服务器把数据推送给用户,而不是用户主动刷新。 于是应用和服务器的交互模式也从原来的拉模式(http)转变为推模式(comet,websocket, 长连接)。推模式其实就是应用内置了实时通讯机制。

我们来了解下当前已经存在实时通讯IM协议,有名的有XMPP(rfc3921)和SIMPLE。但这类IM协议本身关注的是,消息的投递和用户设备的到场(Presence)状态,并不关心消息的存储和多终端的同步,大多数的实现里,离线消息只能一个设备获取。同时移动时代,我们有了离线Push机制,基本上假设用户用户永远在线,和传统的的IM的在线,离开,离线机制等状态的维护不同。

虽然传统的IM投递机制+历史记录,也可以实现多终端同步:

  • 所有设备都在线的情况下,直接投递。
  • 离线→在线时,拉取历史记录补充缺失记录。

但这样做比较困难的地方在于:

  • 变更如何同步?我们的消息不像传统IM,是不可变对象,我们的消息是可变的。同时,群组列表,联系人列表,这些都是可变的,如何同步?
  • 根据SMC(Single-Message Communication 任何端到端的消息传递协议,消息既不丢失,也不重复是不可能的) 定理,在当前IM协议之上保证多个终端数据的一致性也是比较困难的。如果出现多个终端不一致的情况,客户端无法自修复。只能提供特殊的刷新机制,由用户自己刷新。

我们再来看看当前已有的同步协议:

  • 版本控制系统(git/svn) 基于拉取模式,主要用于同步文本文件
  • CalDAV/CardDAV/WebDAV 主要用于同步日历,联系人等
  • SyncML 公开的同步协议,基于XML
  • Exchange ActiveSync(Mail, Calendar and Address Book)微软的协议
  • WeiSync(微信) 公开分享说明是参考ActiveSync实现的,但没有详细的说明。

以上的协议都无法直接拿来用在我们的场景,于是我们只好重新造轮子了。根据以上分析,我们总结出我们需要一种IM + Sync的同步协议。

Grouk多终端实时同步协议

  • 记录对象变更历史
  • 实时投递变更而不是数据对象,也不是通知
  • 客户端通过回放变更来更新数据
  • 支持多种数据对象(联系人/消息/群组)
  • 冲突解决(新数据优先)

下面详细说明下我们的协议

  1. 数据格式

    • 每个需要同步的数据集抽象成一个Folder,Folder可能是多人共享的,也可能是某人专用的。这里的Folder相当于一个索引表,引用的是对象ID。
    • 每个Folder维护一个变更集(ChangeSet),增量同步通过变更实现,变更的版本号有序递增。变更是每次操作生成的,每一次Folder索引或者Folder引用对象的操作都生成一个变更。
    • 变更(Change)有对应的操作(OP)。如:新增、更新、删除等。包括索引变更和索引引用对象的变更,携带变更数据。客户端根据操作要在本地实现重放逻辑。
    • 每个Folder中的索引对象会被分配一个该Folder中的有序递增ID。每个索引对象也可以拥有自定义属性。
    • 所有的数据对象都统一定义,有更新时间,等基本字段。抽象出通用的操作接口(ObjectStore)。
    • 客户端会通过Change将服务器的Folder及对象库同步下去,不过同步的只是服务器上的一个子集,并不是全量。
  2. 投递流程

    • 客户端的操作(发消息)生成变更,写数据对象
    • 将变更投递给在线的设备
    • 客户端应用变更到本地仓库
  3. 同步流程

    • 离线客户端上线,发起同步请求,携带客户端本地的最新版本号
    • 服务根据客户端版本号查询变更集,返回客户端
    • 客户端在本地回放变更
  4. 一致性保证

    • 客户端收到变更后,会检查变更的版本号是否和本地最新变更版本号连续,不连续就说明有消息丢失。
    • 如果有消息丢失,客户端先发起一次同步请求,补全丢失的数据,合并变更再在本地回放。
  5. Folder设计
    我们是每个会话一个folder,所有会话成员共享同一个folder,优点是节省资源,方便保证一致性,缺点是同步成本比较高。微信的设计是每个人的消息folder是自己的,每人只有一个。 我们主要设计了以下会话:

    • 会话
    • 用户的会话列表
    • 用户加入的群
    • 团队联系人
    • 群成员
    • 收藏列表
    • 已读未读状态处理 我们记录每个folder已读消息的索引id,在folder条目的扩展字段里,很容易实现多终端同步,客户端只需拿最新索引id和已读消息索引id做减法就可计算出未读数。
  6. 本地对象库
    消息同步的目的其实就是将服务器上的数据同步到客户端。但由于客户端本地的存储受限,客户端会重新安装等原因,客户端上不可能保存全量数据,所以必须配合缓存机制。
    web时代的页面应用缓存是由浏览器按照http协议统一处理的,但富客户端应用,服务器返回的一般是json,缓存的是json中的数据对象,不是整合请求的响应结果,所以大多数情况下都是客户端根据自己的业务场景来实现。
    我们配合sync机制,统一实现了客户端的缓存机制。

    • 统一的数据对象设计 每个对象包含更新时间字段,提供统一的获取数据对象的接口。客户端请求时带上客户端本地的该对象的更新时间,服务器根据时间做校验,如果发现客户端已经是最新的结果,则不需要返回数据。
    • 数据对象本地缓存过期机制,每种数据对象有默认的过期时间
    • 服务器的列表接口只返回对象ID,客户端根据ID先去本地数据库,如果本地数据没有,则请求服务器。如果已经有但已过期,则异步更新。
    • 接口支持Protobuf和json,服务器通过请求的accept头来确定返回的数据格式。
  7. 网关架构

    • 接入层支持http/websocket/tcp自定义协议 三种协议
    • 请求通过接入层后,统一转换成自定义的request对象,由handler层处理。
    • handler层调用service实现业务逻辑后,返回response,转换成不同接入层协议的响应返回给客户端。

    这样做的好处是,同一个请求既可以通过长连接操作,也可以通过http短链接操作,客户端可以根据当前的网络和连接情况决定请求走长连接还是短链接。同时可以最大化复用服务器端逻辑,web版本和手机版本只有接入层的协议有区别,其他逻辑一致。

架构优缺点

优点

  • 用户在线的情况下,大多数情况变更是直接投递下去的。比通知→拉取模式和服务器的交互少,更省资源
  • 离线缓存比较容易实现,离线浏览的体验会比较好。
  • 能保证终端和服务器的数据一致性
  • 相对比较通用,可以适用于多个业务场景

缺点

  • 本地客户端的实现逻辑比较重。和轻客户端,重服务器的思路有冲突。
  • 未实现客户端和服务器端双向同步,是中心化的模式。这个当前没有这种需求。如果是编辑器,需要支持离线编辑,可能需要实现双向同步。

未来 —— 标准化

  • 传输通道 尽量寻找一种可靠的标准传输协议。
  • 适用场景 扩展适用场景,可以实现日程和文档的同步。
  • 存储机制 服务器端和客户端提供标准的存储机制
  • 权限校验 权限校验和业务解耦

总结

总结下当前应用,尤其是工具应用的一种趋势。

IM已经变得不像IM,不是IM的要变成IM 前半句是说,当前的IM已经逐渐不像传统的IM了,无论是微信,还是Slack,还是我们的Grouk,和传统的IM区别越来越大。后半句是说,不是IM的应用因为要做多终端实时同步,协议越来越靠近IM机制了。

另外个人感觉这种趋势不一定仅局限在工具类。哪怕是电商网站,如果能同步用户的购物车到多个终端,用户的体验也会更进一步。

Q&A

Q1:为什么不采用XMPP协议呢?多人协作时后端出现用户不在一台服务器上如何同步?

XMPP由于众所周知的原因,XML不太适合移动使用。一般移动上使用都要做压缩,比如WhatsApp。另外就是前面描述的,做变更同步比较麻烦。

Q2:客户端所有请求都是通过和服务器的长连接过来吗?没有走比如短连接的HTTP协议之类的?

不是所有,有走短连接的。我们采用一种动态机制,长连接优先。消息上我们没有采用长轮询的方式。客户端是TCP长连接,Web版本是WebSocket。

Q3:这个版本号必须是有序的吗?是否可以跟Git一样用随机字符+链表的方式做?

我们这个方案里版本号必须是有序严格递增的,因为要靠这个判断是否丢失消息。Git的方案是因为需要离线写操作,我们当前没这个需求,写都是通过服务器中心写的。

Q4:QQ的消息只投递到一个终端?这是多年前了吧?

QQ是对移动端做了写优化,离线登陆后会补充投递一部分消息,但做不到全终端一致同步。

Q5:如何选择客户端服务器之间心跳的时长?有哪些选择的因素考虑,怎么权衡?

这个说实话我们也在摸索。没有太多数据支撑的经验。移动端其实心跳已经不是很重要了,大家的使用习惯基本上是查看消息回复,然后就沉后台了。我们做了点优化就是所有的消息都视为一种心跳。心跳其实是服务器端判断客户端是否在线的一种方式。移动客户端网络变化能收到通知,一般是几分钟一次。 Web版本没有这种功能,所以要靠心跳来判断网络,一般是几秒钟一次。

Q6:发现消息版本不连续后,是全量拉取吗?还是可以判断拉去到哪?

发现消息不连续后,由于版本号是有序递增的,可以计算出中间的差距,直接拉缺失的即可。当然服务器的版本是有限的,如果发现客户端的本地数据太旧,是需要重新全量拉取的。全量拉取的机制不同,Folder的规则不一样。

公众号文章地址

本文于2016年2月1日重新修订