在线协作是近年来非常热门的应用场景,比如在线文档、在线白板、协同编辑等。这类应用的架构设计有很多挑战,既要保证高可用,又要支持高并发,还要处理实时同步、冲突解决等复杂问题。本文分享了我在设计在线协作系统架构过程中的一些经验和思考,包括整体架构、实时同步方案、高可用设计、高并发优化、冲突解决策略等。如果你也在做在线协作类应用,希望这篇文章能给你一些参考。
一、在线协作系统的挑战
先说说在线协作系统面临的主要挑战吧。
在线协作系统和传统的Web应用有很大不同。传统Web应用主要是请求-响应模式,用户发一个请求,服务器返回一个响应,相对简单。但是在线协作系统需要支持多人实时编辑,这就带来了很多挑战。
挑战1:实时性
在线协作最核心的需求是实时性。一个用户的修改,需要在很短的时间内同步到其他用户的屏幕上。这个延迟通常要求在100毫秒以内,否则用户就会感觉到明显的卡顿。
要实现这么低的延迟,传统的HTTP轮询是不行的,必须用WebSocket或者其他长连接技术。而且数据传输的路径要尽可能短,处理逻辑要尽可能高效。
挑战2:高并发
一个热门的在线文档可能同时有几百甚至上千人在编辑。这对服务器的并发处理能力提出了很高的要求。每个用户都维持一个长连接,服务器要同时处理大量的连接和消息。
而且并发不仅是连接数多,消息量也大。每个用户的每次修改都要广播给其他所有用户,消息量是用户数的平方级增长。100个用户同时编辑,每秒可能有几千条消息要处理。
挑战3:冲突解决
多人同时编辑同一份文档,必然会产生冲突。比如两个人同时修改同一段文字,或者一个人删除了某段文字,另一个人在修改这段文字。这时候怎么处理冲突,保证所有用户看到的内容一致,是一个很难的问题。
常见的冲突解决算法有OT(Operational Transformation)和CRDT(Conflict-free Replicated Data Type)。这两种算法各有优缺点,实现起来都比较复杂。
挑战4:高可用
在线协作系统通常是生产级应用,要求高可用。用户正在编辑文档的时候,如果服务器挂了,用户的修改可能会丢失,这是不可接受的。
所以系统必须有完善的高可用设计,包括服务集群、故障转移、数据备份、断线重连等。任何一个节点挂了,都不能影响用户的使用。
挑战5:数据一致性
在线协作系统中,同一份文档在多个用户的客户端和多个服务器节点上都有副本。怎么保证这些副本的数据一致性,是一个经典的分布式系统问题。
而且在线协作系统对一致性的要求比较特殊,它不要求强一致性(因为实时编辑的时候允许短暂的不一致),但是要求最终一致性,并且冲突解决之后所有副本要收敛到相同的状态。
二、整体架构设计
面对这些挑战,我们设计了一套分层的在线协作架构。整体分为几层:接入层、协作层、存储层、通知层。
接入层
接入层负责处理客户端的连接,包括WebSocket连接的建立、认证、消息收发。接入层是无状态的,可以水平扩展,前面挂负载均衡器。
接入层的主要职责是:
- 建立和维护WebSocket连接
- 用户认证和鉴权
- 消息的接收和发送
- 心跳检测和连接管理
- 简单的消息路由
接入层不处理具体的协作逻辑,只负责连接管理和消息转发。这样接入层可以很轻量,支持大量的并发连接。
协作层
协作层是在线协作的核心,负责处理协作逻辑。包括文档的加载、操作的处理、冲突解决、版本管理、广播同步等。
协作层是有状态的,每个文档的协作状态保存在协作层的节点上。为了支持高可用,协作层也做了集群化,通过一致性哈希来分配文档到不同的节点。
协作层的主要职责是:
- 文档的加载和缓存
- 操作的接收和验证
- 冲突解决(OT或CRDT)
- 版本管理和历史记录
- 操作的广播和同步
- 文档的持久化
存储层
存储层负责数据的持久化。包括文档内容、版本历史、用户信息、协作记录等。
存储层分为几部分:
- 文档数据库:存文档的最新内容和元数据,用MySQL或者MongoDB
- 版本历史:存文档的历史版本,支持版本回溯,用对象存储或者专门的版本存储
- 缓存:存热点文档的内容和协作状态,用Redis
- 消息队列:存操作日志和异步任务,用Kafka或者RabbitMQ
通知层
通知层负责处理非实时的通知,比如邮件通知、推送通知、站内信等。当用户被@、文档被评论、协作权限变更的时候,通知层会发送相应的通知。
通知层是异步的,通过消息队列和其他层解耦,不影响实时协作的性能。
三、实时同步方案
实时同步是在线协作最核心的功能。我们采用的是基于WebSocket的实时同步方案。
连接建立
客户端通过WebSocket连接到接入层。连接建立之后,客户端发送认证信息,接入层验证通过之后,连接就建立好了。
为了支持断线重连,我们设计了重连机制。客户端断线之后,会尝试重新连接。重连的时候,客户端会带上最后一次收到的版本号,服务器会把这个版本号之后的所有操作都推送给客户端,这样客户端就能快速恢复到最新状态。
操作传输
用户在客户端的每次修改,都会被封装成一个操作(Operation),通过WebSocket发送到服务器。操作包含修改的类型、位置、内容、版本号等信息。
服务器收到操作之后,先做验证,然后进行冲突解决,再把操作应用到文档上,最后把操作广播给其他所有在线的用户。
为了减少传输量,我们对操作做了压缩和批量处理。用户连续输入的时候,会把多个小操作合并成一个大操作再发送,减少网络开销。
广播策略
一个用户的操作需要广播给其他所有在线用户。广播的方式有两种:服务器广播和P2P广播。
我们采用的是服务器广播的方式。服务器收到操作之后,直接推送给其他在线用户。这种方式实现简单,可靠性高,但是服务器的压力比较大。
为了减轻服务器压力,我们做了一些优化:
- 接入层之间用消息队列同步,避免每个协作节点都要维护所有连接
- 对广播消息做批量合并,减少消息数量
- 对不活跃的用户降低推送频率,只推送关键操作
四、冲突解决策略
冲突解决是在线协作中最难的部分。我们对比了OT和CRDT两种方案,最终选择了OT算法。
OT算法
OT(Operational Transformation)的核心思想是,当两个操作并发产生的时候,对其中一个操作进行转换,使得两个操作应用之后的结果一致。
比如用户A在位置5插入了"hello",同时用户B在位置3删除了2个字符。这时候A的操作的位置就需要调整,因为B的删除操作改变了文档的长度。OT算法就是计算这种位置调整,保证最终结果一致。
OT算法的优点是:
- 结果一致性好,所有用户最终看到的内容完全一致
- 支持细粒度的操作,比如字符级别的编辑
- 算法成熟,有很多现成的实现
OT算法的缺点是:
- 实现复杂,尤其是支持多种操作类型的时候
- 服务器端需要维护操作历史,内存开销大
- 对网络延迟比较敏感
CRDT算法
CRDT(Conflict-free Replicated Data Type)是另一种冲突解决算法。它的核心思想是设计一种数据结构,使得并发操作天然可交换,不管操作的顺序如何,最终结果都一致。
CRDT的优点是:
- 实现相对简单,不需要复杂的转换逻辑
- 对网络延迟不敏感,适合弱网环境
- 可以去中心化,不需要中心服务器
CRDT的缺点是:
- 数据结构比较特殊,内存开销大
- 支持的操作类型有限
- 结果可能不是用户期望的(比如删除的内容可能会"复活")
我们的选择
我们最终选择了OT算法,主要是因为它的结果一致性更好,用户体验更好。我们在开源OT实现的基础上做了优化,支持了富文本、评论、光标位置等多种操作类型。
为了降低OT算法的复杂度,我们做了一些限制:
- 只支持线性的文档结构,不支持复杂的嵌套
- 操作粒度是字符级,不支持更细粒度
- 服务器端只保留最近的操作历史,超过一定数量就做快照
五、高可用设计
高可用是在线协作系统的基本要求。我们从几个层面做了高可用设计。
接入层高可用
接入层是无状态的,前面挂负载均衡器。任何一个接入节点挂了,负载均衡器会把流量切换到其他节点,用户无感知。
接入层的连接状态不保存在本地,而是存在Redis里。节点挂了之后,其他节点可以从Redis恢复连接状态,用户只需要重新连接一次。
协作层高可用
协作层是有状态的,高可用设计比较复杂。我们采用的是主从模式,每个文档有一个主节点和一个从节点。主节点处理协作逻辑,从节点同步主节点的状态。
主节点挂了之后,从节点会自动升级为主节点,继续提供服务。因为从节点实时同步主节点的状态,所以切换的时候数据不会丢失,用户只会有短暂的卡顿。
为了防止脑裂,我们用ZooKeeper做集群协调,保证每个文档在同一时间只有一个主节点。
数据高可用
数据存储层也做了高可用。数据库用主从复制,一主多从。主库挂了之后,从库可以升级为主库。
文档内容定期做快照,存在对象存储里。即使数据库出了问题,也可以从快照恢复。
操作日志写入Kafka,多副本存储,不会丢失。任何时候都可以通过操作日志重建文档状态。
客户端高可用
客户端也做了高可用设计。用户的修改先存在本地,然后再同步到服务器。如果网络断了,用户可以继续编辑,修改存在本地。网络恢复之后,再把本地的修改同步到服务器。
这样即使服务器短暂不可用,用户也可以继续编辑,不会丢失数据。
六、高并发优化
高并发是在线协作系统的另一个挑战。我们做了很多优化来支持高并发。
连接优化
接入层用Netty来处理WebSocket连接,单机可以支持几十万的并发连接。连接的内存开销控制在每个连接几KB的级别。
心跳机制做了优化,用应用层心跳而不是TCP心跳,心跳间隔动态调整。活跃用户心跳间隔短,不活跃用户心跳间隔长,减少不必要的流量。
消息优化
消息做了批量合并和压缩。多个小操作合并成一个大操作,减少消息数量。消息用Protocol Buffer序列化,比JSON小很多。再加上gzip压缩,进一步减少传输量。
广播的时候,根据用户的在线状态做差异化推送。活跃用户实时推送,不活跃用户降低推送频率,离线用户等上线之后再批量同步。
计算优化
OT计算是CPU密集型操作,我们做了很多优化。比如用增量计算,只计算变化的部分,而不是每次都重新计算整个文档。用缓存来存储计算结果,避免重复计算。
协作节点的CPU利用率超过阈值的时候,会触发负载迁移,把部分文档迁移到负载较低的节点,保证每个节点的负载都在合理范围内。
存储优化
热点文档缓存在内存里,不需要每次都从数据库加载。缓存用LRU策略,保证热点文档一直在内存里。
版本历史做了分层存储。最近的版本存在Redis里,方便快速访问。历史版本存在对象存储里,需要的时候再加载。
数据库做了分库分表,按文档ID分片。单表数据量控制在合理范围内,保证查询性能。
七、监控和运维
一个生产级的在线协作系统,必须有完善的监控和运维体系。
我们监控的指标包括:
- 连接数、在线用户数、活跃文档数
- 消息吞吐量、延迟、错误率
- 协作节点的CPU、内存、负载
- OT计算的耗时和成功率
- 数据库的查询性能和慢查询
- 缓存的命中率
告警规则覆盖了所有关键指标,任何指标异常都会触发告警,通知到负责人。
运维方面,我们做了自动化部署和扩容。代码提交之后自动构建、自动测试、自动部署。流量增长的时候,可以一键扩容接入层和协作层。
我们还做了全链路压测,定期模拟高并发场景,验证系统的承载能力,及时发现和解决瓶颈。
八、遇到的坑和经验
在开发和运维在线协作系统的过程中,我们遇到了很多坑,也积累了一些经验。
坑1:OT算法的边界情况
OT算法看起来简单,但是边界情况非常多。比如并发的插入和删除、并发的格式修改、光标位置的同步等,每一种情况都需要仔细处理。我们在上线之后,陆续发现了好几个OT算法的bug,都是边界情况没处理好导致的。
经验:OT算法一定要有完善的测试,特别是边界情况的测试。可以用模糊测试来自动生成各种并发操作,验证算法的正确性。
坑2:大文档的性能问题
当文档很大(比如几十万字)的时候,OT计算和广播的性能会急剧下降。因为每次操作都要遍历整个文档来计算位置,耗时很长。
经验:对大文档做分片处理,把大文档分成多个小片段,每个片段独立做OT计算和同步。这样可以把计算量控制在合理范围内。
坑3:网络抖动导致的状态不一致
网络不好的时候,客户端和服务器之间可能出现消息丢失或者乱序,导致状态不一致。用户可能会看到内容错乱或者光标跳动。
经验:设计完善的版本号和确认机制。每个操作都有版本号,服务器和客户端都要维护版本号,发现不一致的时候及时做全量同步。
坑4:恶意用户的攻击
在线协作系统是开放的,可能会有恶意用户发送大量的垃圾操作,或者构造特殊的操作来触发bug,影响其他用户。
经验:做好输入验证和限流。对每个用户的操作频率做限制,对操作内容做校验,防止恶意操作。对异常行为及时检测和封禁。
九、写在最后
在线协作系统的架构设计是一个很有挑战性的工作,涉及到实时通信、分布式系统、冲突解决、高可用、高并发等多个技术领域。
我们的架构也不是一蹴而就的,是在实际使用中不断迭代和优化的。每一次流量高峰、每一次故障、每一个用户反馈,都推动着我们的架构不断改进。
本文分享的只是我们的一些经验和思考,不一定是最优的方案。在线协作领域还有很多问题值得深入研究,比如更高效的冲突解决算法、更低延迟的传输方案、更智能的协作体验等。
如果你也在做在线协作类应用,希望这篇文章能给你一些参考和启发。如果有不同的想法和经验,欢迎在评论区交流讨论。
最后用一句话结束本文:"好的架构不是设计出来的,而是在实际使用中不断演进出来的。"愿每一个架构师都能构建出稳定、高效、可扩展的系统。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录